import { useEffect, useRef, useState } from "react"; import { StyleSheet, View, Text, Pressable } from "react-native"; 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). */ 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 onScanRef = useRef(onScan); const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 }); const [error, setError] = useState(null); const [starting, setStarting] = useState(true); activeRef.current = active; onScanRef.current = onScan; 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 { // 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"); 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; const tick = async () => { rafRef.current = requestAnimationFrame(tick); const v = videoRef.current; if (!v || v.readyState < 2 || busy || !activeRef.current) return; busy = true; try { const codes = await detector.detect(v); if (codes && codes.length) { const data = codes[0].rawValue; const now = Date.now(); if (!(data === lastScan.current.code && now - lastScan.current.at < 3000)) { lastScan.current = { code: data, at: now }; onScanRef.current(data); } } } catch { /* transient decode error; keep scanning */ } finally { busy = false; } }; 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.", ); } } // 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 () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); streamRef.current?.getTracks().forEach((t) => t.stop()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( {/* Raw DOM video element; react-dom renders it inside the RN-Web div tree. */} ); } const styles = StyleSheet.create({ fill: { flex: 1, width: "100%", height: "100%", backgroundColor: "#000" }, overlayMsg: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center", 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" }, });