Scanner: tappable start overlay so the camera can never silently hang
Some checks failed
Build Android APK / build-apk (push) Failing after 8m55s

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 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 17:51:42 +00:00
parent 8f62a84e9b
commit 8b202e4942

View file

@ -4,39 +4,48 @@ import { BarcodeDetector } from "barcode-detector/ponyfill";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
import type { QRScannerProps } from "./QRScanner"; 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) { export default function QRScanner({ onScan, active }: QRScannerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const activeRef = useRef(active); const activeRef = useRef(active);
// Route onScan through a ref so the long-lived camera loop (started once in const startingRef = useRef(false);
// useEffect) always calls the LATEST handler — otherwise a mode switch const playingRef = useRef(false);
// (e.g. to Banquet) keeps hitting the mount-time handler.
const onScanRef = useRef(onScan); const onScanRef = useRef(onScan);
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 }); const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(true);
activeRef.current = active; activeRef.current = active;
onScanRef.current = onScan; onScanRef.current = onScan;
async function start() { async function start() {
if (startingRef.current) return;
startingRef.current = true;
setError(null); setError(null);
setStarting(true); playingRef.current = false;
// Stop any previous stream before requesting a new one (Retry / remounts). setPlaying(false);
// Drop any previous stream before requesting a new one.
streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null; streamRef.current = null;
try { try {
// Watchdog: on iOS, getUserMedia can hang indefinitely when it isn't tied // Watchdog: if getUserMedia neither resolves nor rejects (seen as a hung
// to a user gesture (we navigate here from the name screen). If it doesn't // "Starting camera…"), reject after 8s so the overlay becomes an
// resolve, surface an error + Retry button (Retry IS a fresh gesture). // actionable "tap to start" instead of hanging forever.
const stream = await withTimeout( const stream = await withTimeout(
navigator.mediaDevices.getUserMedia({ navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } }, video: { facingMode: { ideal: "environment" } },
audio: false, audio: false,
}), }),
12000, 8000,
); );
streamRef.current = stream; streamRef.current = stream;
const video = videoRef.current; const video = videoRef.current;
@ -44,8 +53,6 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
video.srcObject = stream; video.srcObject = stream;
video.setAttribute("playsinline", "true"); video.setAttribute("playsinline", "true");
video.muted = 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(() => {}); video.play().catch(() => {});
} }
const detector = new BarcodeDetector({ formats: ["qr_code"] }); const detector = new BarcodeDetector({ formats: ["qr_code"] });
@ -53,7 +60,12 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
const tick = async () => { const tick = async () => {
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
const v = videoRef.current; 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; busy = true;
try { try {
const codes = await detector.detect(v); const codes = await detector.detect(v);
@ -71,28 +83,19 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
busy = false; busy = false;
} }
}; };
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
setStarting(false);
} catch (e: any) { } catch (e: any) {
setStarting(false);
setError( setError(
e?.name === "NotAllowedError" e?.name === "NotAllowedError" || e?.name === "SecurityError"
? "Camera permission was denied. Allow camera access, then tap Retry." ? "Camera permission was denied. Enable camera access for this site, then tap to try again."
: e?.message === "timeout" : "Could not open the camera. Make sure no other app is using it, then tap to try again.",
? "Camera didn't start. Tap Retry."
: "Could not open the camera. Make sure you're on HTTPS and no other app is using it.",
); );
} finally {
startingRef.current = false;
} }
} }
// 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(() => { useEffect(() => {
start(); start();
return () => { return () => {
@ -102,9 +105,17 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Reject after `ms` if the promise hasn't settled (un-sticks a hung 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)),
]);
}
return ( return (
<View style={styles.fill}> <View style={styles.fill}>
{/* 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. */}
<video <video
ref={videoRef as any} ref={videoRef as any}
style={{ width: "100%", height: "100%", objectFit: "cover" }} style={{ width: "100%", height: "100%", objectFit: "cover" }}
@ -112,18 +123,25 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
autoPlay autoPlay
playsInline playsInline
/> />
{starting && !error && ( {!playing && (
<View style={styles.overlayMsg}> // Whole overlay is a button so a tap (re)starts the camera in a gesture.
<Text style={styles.msg}>Starting camera</Text> <Pressable style={styles.overlayMsg} onPress={start}>
</View> {error ? (
)} <>
{error && ( <Text style={styles.icon}>📷</Text>
<View style={styles.overlayMsg}>
<Text style={styles.msg}>{error}</Text> <Text style={styles.msg}>{error}</Text>
<Pressable style={styles.btn} onPress={start}> <View style={styles.btn}>
<Text style={styles.btnText}>Retry</Text> <Text style={styles.btnText}>Tap to start camera</Text>
</Pressable>
</View> </View>
</>
) : (
<>
<Text style={styles.icon}>📷</Text>
<Text style={styles.msg}>Starting camera</Text>
<Text style={styles.hint}>Tap here if it doesn't start</Text>
</>
)}
</Pressable>
)} )}
</View> </View>
); );
@ -142,7 +160,15 @@ const styles = StyleSheet.create({
padding: 24, padding: 24,
backgroundColor: theme.bg, backgroundColor: theme.bg,
}, },
msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 16 }, icon: { fontSize: 44, marginBottom: 12 },
btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 }, msg: { color: theme.text, fontSize: 17, textAlign: "center", marginBottom: 12, lineHeight: 24 },
btnText: { color: "#fff", fontSize: 16, fontWeight: "600" }, 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" },
}); });