fix(video): sync camera capture and float preview
Some checks failed
Mirror to GitHub / mirror (push) Failing after 33s
Build Release Binaries / build-amd64 (push) Failing after 3m9s

This commit is contained in:
Siavash Sameni
2026-05-26 07:30:19 +04:00
parent a08a37b5eb
commit 0c2297a2b7
2 changed files with 179 additions and 35 deletions

View File

@@ -180,8 +180,85 @@ let pendingCallId: string | null = null;
let cameraActive = false; let cameraActive = false;
let cameraStream: MediaStream | null = null; let cameraStream: MediaStream | null = null;
let cameraFrameTimer: number | null = null; let cameraFrameTimer: number | null = null;
let cameraFrameCallbackHandle: number | null = null;
let cameraCaptureInFlight = false;
let lastCameraCaptureAtMs = 0;
let remoteVideoActive = false; let remoteVideoActive = false;
interface FrameCallbackVideoElement extends HTMLVideoElement {
requestVideoFrameCallback?: (callback: (now: DOMHighResTimeStamp, metadata: unknown) => void) => number;
cancelVideoFrameCallback?: (handle: number) => void;
}
// Keep the local preview out of the video stage stacking context so it can float
// above the call drawer and remain draggable on phones.
document.body.appendChild(vdLocalVideo);
vdLocalVideo.classList.add("hidden");
function clampNumber(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function keepLocalPipInViewport() {
if (vdLocalVideo.classList.contains("hidden")) return;
const rect = vdLocalVideo.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const margin = 12;
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
const left = clampNumber(rect.left, margin, maxLeft);
const top = clampNumber(rect.top, margin, maxTop);
vdLocalVideo.style.left = `${left}px`;
vdLocalVideo.style.top = `${top}px`;
vdLocalVideo.style.right = "auto";
vdLocalVideo.style.bottom = "auto";
}
function initLocalPipDrag() {
let dragPointerId: number | null = null;
let dragOffsetX = 0;
let dragOffsetY = 0;
vdLocalVideo.addEventListener("pointerdown", (event) => {
if (vdLocalVideo.classList.contains("hidden")) return;
dragPointerId = event.pointerId;
const rect = vdLocalVideo.getBoundingClientRect();
dragOffsetX = event.clientX - rect.left;
dragOffsetY = event.clientY - rect.top;
vdLocalVideo.classList.add("dragging");
vdLocalVideo.setPointerCapture(event.pointerId);
event.preventDefault();
});
vdLocalVideo.addEventListener("pointermove", (event) => {
if (dragPointerId !== event.pointerId) return;
const rect = vdLocalVideo.getBoundingClientRect();
const margin = 12;
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
const left = clampNumber(event.clientX - dragOffsetX, margin, maxLeft);
const top = clampNumber(event.clientY - dragOffsetY, margin, maxTop);
vdLocalVideo.style.left = `${left}px`;
vdLocalVideo.style.top = `${top}px`;
vdLocalVideo.style.right = "auto";
vdLocalVideo.style.bottom = "auto";
event.preventDefault();
});
function endDrag(event: PointerEvent) {
if (dragPointerId !== event.pointerId) return;
dragPointerId = null;
vdLocalVideo.classList.remove("dragging");
try { vdLocalVideo.releasePointerCapture(event.pointerId); } catch {}
}
vdLocalVideo.addEventListener("pointerup", endDrag);
vdLocalVideo.addEventListener("pointercancel", endDrag);
window.addEventListener("resize", keepLocalPipInViewport);
}
initLocalPipDrag();
function showToast(msg: string, durationMs = 3500) { function showToast(msg: string, durationMs = 3500) {
let el = document.getElementById("wzp-toast"); let el = document.getElementById("wzp-toast");
if (!el) { if (!el) {
@@ -497,6 +574,7 @@ const CAMERA_SEND_WIDTH = 1280;
const CAMERA_SEND_HEIGHT = 720; const CAMERA_SEND_HEIGHT = 720;
let cameraCaptureFrameNo = 0; let cameraCaptureFrameNo = 0;
let cameraPushFailures = 0; let cameraPushFailures = 0;
const CAMERA_CAPTURE_INTERVAL_MS = 67; // ≈ 15 fps
function drawCameraFrameForSend() { function drawCameraFrameForSend() {
const vw = vdLocalVideo.videoWidth || camCaptureCanvas.width; const vw = vdLocalVideo.videoWidth || camCaptureCanvas.width;
@@ -514,6 +592,81 @@ function drawCameraFrameForSend() {
camCaptureCtx.drawImage(vdLocalVideo, dx, dy, dw, dh); camCaptureCtx.drawImage(vdLocalVideo, dx, dy, dw, dh);
} }
async function captureAndPushCameraFrame() {
if (!cameraActive || cameraCaptureInFlight) return;
cameraCaptureInFlight = true;
cameraCaptureFrameNo++;
try {
drawCameraFrameForSend();
const dataUrl = camCaptureCanvas.toDataURL("image/jpeg", 0.75);
const b64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
if (cameraCaptureFrameNo === 1 || cameraCaptureFrameNo % 150 === 0) {
debugLog("camera:capture_frame", {
frame_no: cameraCaptureFrameNo,
width: camCaptureCanvas.width,
height: camCaptureCanvas.height,
jpeg_b64_len: b64.length,
capture_clock: getVideoFrameCallbackApi() ? "video_frame_callback" : "interval",
});
}
await invoke("push_camera_frame", { jpegB64: b64 });
} catch (e: any) {
cameraPushFailures++;
if (cameraPushFailures === 1 || cameraPushFailures % 30 === 0) {
debugLog("camera:push_failed", {
frame_no: cameraCaptureFrameNo,
failures: cameraPushFailures,
error: errorMessage(e),
});
}
} finally {
cameraCaptureInFlight = false;
}
}
function getVideoFrameCallbackApi() {
const video = vdLocalVideo as FrameCallbackVideoElement;
if (typeof video.requestVideoFrameCallback !== "function") return null;
return video;
}
function cancelCameraCaptureLoop() {
if (cameraFrameTimer != null) {
window.clearInterval(cameraFrameTimer);
cameraFrameTimer = null;
}
const video = getVideoFrameCallbackApi();
if (video && cameraFrameCallbackHandle != null && typeof video.cancelVideoFrameCallback === "function") {
video.cancelVideoFrameCallback(cameraFrameCallbackHandle);
}
cameraFrameCallbackHandle = null;
}
function scheduleCameraFrameCapture() {
cancelCameraCaptureLoop();
lastCameraCaptureAtMs = 0;
const video = getVideoFrameCallbackApi();
if (video) {
const onVideoFrame = (now: DOMHighResTimeStamp) => {
cameraFrameCallbackHandle = null;
if (!cameraActive) return;
if (lastCameraCaptureAtMs === 0 || now - lastCameraCaptureAtMs >= CAMERA_CAPTURE_INTERVAL_MS) {
lastCameraCaptureAtMs = now;
void captureAndPushCameraFrame();
}
cameraFrameCallbackHandle = video.requestVideoFrameCallback!(onVideoFrame);
};
cameraFrameCallbackHandle = video.requestVideoFrameCallback(onVideoFrame);
debugLog("camera:capture_clock", { mode: "video_frame_callback", interval_ms: CAMERA_CAPTURE_INTERVAL_MS });
return;
}
cameraFrameTimer = window.setInterval(() => {
void captureAndPushCameraFrame();
}, CAMERA_CAPTURE_INTERVAL_MS);
debugLog("camera:capture_clock", { mode: "interval", interval_ms: CAMERA_CAPTURE_INTERVAL_MS });
}
async function startCamera() { async function startCamera() {
if (cameraActive) return; if (cameraActive) return;
const constraints = { const constraints = {
@@ -545,35 +698,10 @@ async function startCamera() {
cameraPushFailures = 0; cameraPushFailures = 0;
vdCamIcon.textContent = "Cam ✓"; vdCamIcon.textContent = "Cam ✓";
vdCamBtn.classList.add("active"); vdCamBtn.classList.add("active");
vdLocalVideo.classList.remove("hidden");
keepLocalPipInViewport();
// Capture loop at ~15 fps scheduleCameraFrameCapture();
cameraFrameTimer = window.setInterval(async () => {
if (!cameraActive) return;
cameraCaptureFrameNo++;
try {
drawCameraFrameForSend();
const dataUrl = camCaptureCanvas.toDataURL("image/jpeg", 0.75);
const b64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
if (cameraCaptureFrameNo === 1 || cameraCaptureFrameNo % 150 === 0) {
debugLog("camera:capture_frame", {
frame_no: cameraCaptureFrameNo,
width: camCaptureCanvas.width,
height: camCaptureCanvas.height,
jpeg_b64_len: b64.length,
});
}
await invoke("push_camera_frame", { jpegB64: b64 });
} catch (e: any) {
cameraPushFailures++;
if (cameraPushFailures === 1 || cameraPushFailures % 30 === 0) {
debugLog("camera:push_failed", {
frame_no: cameraCaptureFrameNo,
failures: cameraPushFailures,
error: errorMessage(e),
});
}
}
}, 67); // 67 ms ≈ 15 fps
} catch (e: any) { } catch (e: any) {
console.warn("camera access denied or unavailable:", e); console.warn("camera access denied or unavailable:", e);
debugLog("camera:get_user_media_failed", { debugLog("camera:get_user_media_failed", {
@@ -588,9 +716,10 @@ function stopCamera() {
debugLog("camera:stopped", { frames: cameraCaptureFrameNo }); debugLog("camera:stopped", { frames: cameraCaptureFrameNo });
} }
cameraActive = false; cameraActive = false;
if (cameraFrameTimer != null) { window.clearInterval(cameraFrameTimer); cameraFrameTimer = null; } cancelCameraCaptureLoop();
if (cameraStream) { cameraStream.getTracks().forEach(t => t.stop()); cameraStream = null; } if (cameraStream) { cameraStream.getTracks().forEach(t => t.stop()); cameraStream = null; }
vdLocalVideo.srcObject = null; vdLocalVideo.srcObject = null;
vdLocalVideo.classList.add("hidden");
vdCamIcon.textContent = "Cam"; vdCamIcon.textContent = "Cam";
vdCamBtn.classList.remove("active"); vdCamBtn.classList.remove("active");
// Hide strip only if remote video is also gone // Hide strip only if remote video is also gone

View File

@@ -350,17 +350,32 @@ body {
.vd-placeholder-text { font-size: 18px; margin-bottom: 8px; } .vd-placeholder-text { font-size: 18px; margin-bottom: 8px; }
.vd-placeholder-sub { font-size: 12px; opacity: 0.7; } .vd-placeholder-sub { font-size: 12px; opacity: 0.7; }
.vd-local-pip { .vd-local-pip {
position: absolute; position: fixed;
right: 16px; right: 18px;
bottom: 16px; bottom: calc(176px + env(safe-area-inset-bottom, 0px));
width: 200px; width: min(34vw, 220px);
height: 112px; height: auto;
aspect-ratio: 16 / 9;
border-radius: 8px; border-radius: 8px;
background: #111; background: #111;
border: 2px solid rgba(255, 255, 255, 0.2); border: 2px solid rgba(255, 255, 255, 0.2);
object-fit: cover; object-fit: cover;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 2; z-index: 90;
cursor: grab;
touch-action: none;
-webkit-user-drag: none;
}
.vd-local-pip.dragging {
cursor: grabbing;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.65);
}
@media (max-width: 520px) {
.vd-local-pip {
width: min(48vw, 190px);
right: 12px;
bottom: calc(188px + env(safe-area-inset-bottom, 0px));
}
} }
/* Incoming call banner */ /* Incoming call banner */