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 <noreply@anthropic.com>
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
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<HTMLVideoElement | null>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const rafRef = useRef<number | null>(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<string | null>(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<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
return Promise.race([
|
|
p,
|
|
new Promise<T>((_, 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 (
|
|
<View style={styles.fill}>
|
|
{/* Raw DOM video element; react-dom renders it inside the RN-Web div tree. */}
|
|
<video
|
|
ref={videoRef as any}
|
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
muted
|
|
autoPlay
|
|
playsInline
|
|
/>
|
|
{starting && !error && (
|
|
<View style={styles.overlayMsg}>
|
|
<Text style={styles.msg}>Starting camera…</Text>
|
|
</View>
|
|
)}
|
|
{error && (
|
|
<View style={styles.overlayMsg}>
|
|
<Text style={styles.msg}>{error}</Text>
|
|
<Pressable style={styles.btn} onPress={start}>
|
|
<Text style={styles.btnText}>Retry</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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" },
|
|
});
|