const video = document.getElementById("webcam"); const canvas = document.getElementById("canvas"); const startOverlay = document.getElementById("startOverlay"); const ctx = canvas.getContext("2d"); let lastSpokenText = ""; let isSpeaking = false; let silenceUntil = 0; let isPageVisible = true; let isRequestInFlight = false; // prevents double-taps from flooding the server function speak(message) { if (!message) return; if (Date.now() < silenceUntil) return; speechSynthesis.cancel(); isSpeaking = true; const utterance = new SpeechSynthesisUtterance(message); utterance.rate = 1.0; utterance.pitch = 1.0; utterance.onend = () => { isSpeaking = false; lastSpokenText = message; }; utterance.onerror = (e) => { console.warn("Speech error:", e.error); isSpeaking = false; }; speechSynthesis.speak(utterance); } function captureFrame() { return new Promise((resolve) => { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => resolve(blob), "image/jpeg", 0.8); }); } async function apiTell() { if (isRequestInFlight) return; isRequestInFlight = true; try { const blob = await captureFrame(); if (!blob) { isRequestInFlight = false; return; } const formData = new FormData(); formData.append("file", blob, "frame.jpg"); const res = await fetch("/api/tell", { method: "POST", body: formData }); const data = await res.json(); if (data.summary) { speak(data.summary); } else if (data.error) { speak(data.error); } } catch (e) { console.error("apiTell error:", e); speak("Connection failed."); } finally { isRequestInFlight = false; } } async function apiMore() { if (isRequestInFlight) return; isRequestInFlight = true; speak("Looking closer..."); try { const blob = await captureFrame(); if (!blob) { isRequestInFlight = false; return; } const formData = new FormData(); formData.append("file", blob, "frame.jpg"); const res = await fetch("/api/more", { method: "POST", body: formData }); const data = await res.json(); if (data.caption) { speak(data.caption); } else if (data.error) { speak(data.error); } } catch (e) { console.error("apiMore error:", e); speak("Connection failed."); } finally { isRequestInFlight = false; } } // gesture setup let tapTimer = null; let longPressTimer = null; let isLongPress = false; const DOUBLE_TAP_DELAY = 300; function setupGestures(target) { // prevent default context menus and text selection target.addEventListener("contextmenu", (e) => e.preventDefault()); // mobile touch gestures target.addEventListener("touchstart", (e) => { if (e.touches.length >= 2) { e.preventDefault(); clearTimeout(tapTimer); clearTimeout(longPressTimer); cmdShut(); return; } isLongPress = false; longPressTimer = setTimeout(() => { isLongPress = true; cmdRepeat(); }, 600); }, { passive: false }); target.addEventListener("touchend", (e) => { clearTimeout(longPressTimer); if (isLongPress || e.changedTouches.length > 1) return; e.preventDefault(); if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; cmdMore(); } else { tapTimer = setTimeout(() => { tapTimer = null; cmdTell(); }, DOUBLE_TAP_DELAY); } }); target.addEventListener("touchmove", () => { clearTimeout(longPressTimer); }); // desktop mouse gestures fallback target.addEventListener("click", (e) => { if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; cmdMore(); } else { tapTimer = setTimeout(() => { tapTimer = null; cmdTell(); }, DOUBLE_TAP_DELAY); } }); target.addEventListener("contextmenu", (e) => { e.preventDefault(); cmdShut(); }); target.addEventListener("mousedown", (e) => { if (e.button === 1) { e.preventDefault(); cmdRepeat(); } }); } function cmdTell() { console.log("CMD: tell (single tap)"); apiTell(); } function cmdMore() { console.log("CMD: more (double tap)"); apiMore(); } function cmdShut() { console.log("CMD: shut (two-finger / right-click)"); speechSynthesis.cancel(); isSpeaking = false; silenceUntil = Date.now() + 5000; } function cmdRepeat() { console.log("CMD: repeat (long press / middle click)"); if (lastSpokenText) { speak(lastSpokenText); } else { speak("Nothing to repeat yet."); } } document.addEventListener("visibilitychange", () => { isPageVisible = !document.hidden; if (!isPageVisible) { console.log("Page hidden — silencing speech"); speechSynthesis.cancel(); isSpeaking = false; } }); function initCamera() { // try rear camera first (mobile), fall back to any camera (laptop) navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } } }) .then(onCameraReady) .catch(() => { console.log("Rear camera not available, trying any camera..."); return navigator.mediaDevices.getUserMedia({ video: true }); }) .then(onCameraReady) .catch((err) => { console.error("Camera error:", err); speak("Camera access failed. Please allow camera permissions."); }); } function onCameraReady(stream) { if (!stream || video.srcObject) return; video.srcObject = stream; video.addEventListener("loadedmetadata", () => { canvas.width = 640; canvas.height = 480; speak("System online. Tap the screen to hear what I see."); }); } startOverlay.addEventListener("click", () => { startOverlay.classList.add("hidden"); initCamera(); setupGestures(document.body); });