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). * * 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); 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); activeRef.current = active; onScanRef.current = onScan; async function start() { if (startingRef.current) return; startingRef.current = true; setError(null); 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: 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, }), 8000, ); streamRef.current = stream; const video = videoRef.current; if (video) { video.srcObject = stream; video.setAttribute("playsinline", "true"); video.muted = true; video.play().catch(() => {}); } const detector = new BarcodeDetector({ formats: ["qr_code"] }); // Offscreen canvas holding just the centered reticle crop — we only run // detection on this region so QR codes elsewhere in view are ignored. const canvas = document.createElement("canvas"); const cctx = canvas.getContext("2d", { willReadFrequently: true }); let busy = false; const tick = async () => { rafRef.current = requestAnimationFrame(tick); const v = videoRef.current; 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 { // Crop the central square of the frame (matches the on-screen // reticle) and detect only within it. const vw = v.videoWidth; const vh = v.videoHeight; let target: HTMLVideoElement | HTMLCanvasElement = v; if (cctx && vw && vh) { const side = Math.round(Math.min(vw, vh) * 0.62); const sx = Math.round((vw - side) / 2); const sy = Math.round((vh - side) / 2); canvas.width = side; canvas.height = side; cctx.drawImage(v, sx, sy, side, side, 0, 0, side, side); target = canvas; } const codes = await detector.detect(target); 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; } }; if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(tick); } catch (e: any) { setError( 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; } } useEffect(() => { start(); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); streamRef.current?.getTracks().forEach((t) => t.stop()); }; // 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 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, }, 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" }, });