From e87be49ebd96b8748892175c145aa329171bdee3 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 17:26:00 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20web=20camera=20stuck=20on=20"Starting=20c?= =?UTF-8?q?amera=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getUserMedia (and the awaited play()) could hang indefinitely on iOS when the scanner mounts after navigation (not in a user-gesture context), leaving the UI stuck with no recovery. Add a 12s watchdog that surfaces an error + Retry button (Retry is a fresh gesture iOS honors), stop awaiting play() (fire and forget), and stop any prior stream before re-requesting. Co-Authored-By: Claude Fable 5 --- app/components/QRScanner.web.tsx | 36 +++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/app/components/QRScanner.web.tsx b/app/components/QRScanner.web.tsx index 43ee6d5..d4594a2 100644 --- a/app/components/QRScanner.web.tsx +++ b/app/components/QRScanner.web.tsx @@ -24,17 +24,29 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { async function start() { setError(null); setStarting(true); + // Stop any previous stream before requesting a new one (Retry / remounts). + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: { ideal: "environment" } }, - audio: false, - }); + // Watchdog: on iOS, getUserMedia can hang indefinitely when it isn't tied + // to a user gesture (we navigate here from the name screen). If it doesn't + // resolve, surface an error + Retry button (Retry IS a fresh gesture). + const stream = await withTimeout( + navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: "environment" } }, + audio: false, + }), + 12000, + ); streamRef.current = stream; const video = videoRef.current; if (video) { video.srcObject = stream; video.setAttribute("playsinline", "true"); - await video.play().catch(() => {}); + video.muted = true; + // Fire-and-forget: awaiting play() can itself hang on iOS. We only need + // the stream attached; the tick loop waits for readyState. + video.play().catch(() => {}); } const detector = new BarcodeDetector({ formats: ["qr_code"] }); let busy = false; @@ -65,12 +77,22 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { setStarting(false); setError( e?.name === "NotAllowedError" - ? "Camera permission was denied. Allow camera access and reload." - : "Could not open the camera. Make sure you're on HTTPS and no other app is using it.", + ? "Camera permission was denied. Allow camera access, then tap Retry." + : e?.message === "timeout" + ? "Camera didn't start. Tap Retry." + : "Could not open the camera. Make sure you're on HTTPS and no other app is using it.", ); } } + // Reject after `ms` if the promise hasn't settled (used to un-stick getUserMedia). + function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)), + ]); + } + useEffect(() => { start(); return () => {