From 8b202e4942446848be300cefec7e3858bfc9e17c Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 17:51:42 +0000 Subject: [PATCH] Scanner: tappable start overlay so the camera can never silently hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-start on mount as before, but keep a tappable overlay until the video is actually playing (readyState>=2). If getUserMedia hangs (8s watchdog), is denied, or fails, the overlay becomes a "tap to start camera" button that retries in a real user-gesture context — instead of sitting on "Starting camera…" with no recovery. Works for both Android Chrome PWA and iOS Safari. Co-Authored-By: Claude Fable 5 --- app/components/QRScanner.web.tsx | 116 +++++++++++++++++++------------ 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/app/components/QRScanner.web.tsx b/app/components/QRScanner.web.tsx index d4594a2..db5f1d6 100644 --- a/app/components/QRScanner.web.tsx +++ b/app/components/QRScanner.web.tsx @@ -4,39 +4,48 @@ import { BarcodeDetector } from "barcode-detector/ponyfill"; import { theme } from "../lib/theme"; import type { QRScannerProps } from "./QRScanner"; -/** Web/PWA scanner using getUserMedia + the BarcodeDetector ponyfill (zxing-wasm). */ +/** + * Web/PWA scanner using getUserMedia + the BarcodeDetector ponyfill (zxing-wasm). + * + * iOS Safari only reliably grants the camera from inside a user gesture, and it + * loses that context across the app's navigation (PIN → name → scanner). So we + * auto-attempt on mount but always keep a tappable overlay: if auto-start + * doesn't produce a playing video, one tap starts it in a real gesture context. + */ export default function QRScanner({ onScan, active }: QRScannerProps) { const videoRef = useRef(null); const streamRef = useRef(null); const rafRef = useRef(null); const activeRef = useRef(active); - // Route onScan through a ref so the long-lived camera loop (started once in - // useEffect) always calls the LATEST handler — otherwise a mode switch - // (e.g. to Banquet) keeps hitting the mount-time handler. + const startingRef = useRef(false); + const playingRef = useRef(false); const onScanRef = useRef(onScan); const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 }); + const [playing, setPlaying] = useState(false); const [error, setError] = useState(null); - const [starting, setStarting] = useState(true); activeRef.current = active; onScanRef.current = onScan; async function start() { + if (startingRef.current) return; + startingRef.current = true; setError(null); - setStarting(true); - // Stop any previous stream before requesting a new one (Retry / remounts). + playingRef.current = false; + setPlaying(false); + // Drop any previous stream before requesting a new one. streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; try { - // 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). + // Watchdog: if getUserMedia neither resolves nor rejects (seen as a hung + // "Starting camera…"), reject after 8s so the overlay becomes an + // actionable "tap to start" instead of hanging forever. const stream = await withTimeout( navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false, }), - 12000, + 8000, ); streamRef.current = stream; const video = videoRef.current; @@ -44,8 +53,6 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { video.srcObject = stream; video.setAttribute("playsinline", "true"); 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"] }); @@ -53,7 +60,12 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { const tick = async () => { rafRef.current = requestAnimationFrame(tick); const v = videoRef.current; - if (!v || v.readyState < 2 || busy || !activeRef.current) return; + if (!v || v.readyState < 2) return; + if (!playingRef.current) { + playingRef.current = true; + setPlaying(true); // camera is actually up + } + if (busy || !activeRef.current) return; busy = true; try { const codes = await detector.detect(v); @@ -71,28 +83,19 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { busy = false; } }; + if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(tick); - setStarting(false); } catch (e: any) { - setStarting(false); setError( - e?.name === "NotAllowedError" - ? "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.", + e?.name === "NotAllowedError" || e?.name === "SecurityError" + ? "Camera permission was denied. Enable camera access for this site, then tap to try again." + : "Could not open the camera. Make sure no other app is using it, then tap to try again.", ); + } finally { + startingRef.current = false; } } - // 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 () => { @@ -102,9 +105,17 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Reject after `ms` if the promise hasn't settled (un-sticks a hung getUserMedia). + function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)), + ]); + } + return ( - {/* Raw DOM video element; react-dom renders it inside the RN-Web div tree. */} + {/* Raw DOM video element; react-dom renders it inside the RN-Web tree. */} ); @@ -142,7 +160,15 @@ const styles = StyleSheet.create({ padding: 24, backgroundColor: theme.bg, }, - msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 16 }, - btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 }, - btnText: { color: "#fff", fontSize: 16, fontWeight: "600" }, + icon: { fontSize: 44, marginBottom: 12 }, + msg: { color: theme.text, fontSize: 17, textAlign: "center", marginBottom: 12, lineHeight: 24 }, + hint: { color: theme.textDim, fontSize: 14, textAlign: "center" }, + btn: { + backgroundColor: theme.successBright, + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 12, + marginTop: 6, + }, + btnText: { color: "#06210f", fontSize: 17, fontWeight: "800" }, });