fix(video): sync camera capture and float preview
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user