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 () => {