CampgroundTickets/app/components/QRScanner.web.tsx
Hank 8b202e4942
Some checks failed
Build Android APK / build-apk (push) Failing after 8m55s
Scanner: tappable start overlay so the camera can never silently hang
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>
2026-07-08 17:51:42 +00:00

174 lines
6 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).
*
* 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<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const rafRef = useRef<number | null>(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<string | null>(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"] });
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 {
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;
}
};
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<T>(p: Promise<T>, ms: number): Promise<T> {
return Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)),
]);
}
return (
<View style={styles.fill}>
{/* Raw DOM video element; react-dom renders it inside the RN-Web tree. */}
<video
ref={videoRef as any}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
muted
autoPlay
playsInline
/>
{!playing && (
// Whole overlay is a button so a tap (re)starts the camera in a gesture.
<Pressable style={styles.overlayMsg} onPress={start}>
{error ? (
<>
<Text style={styles.icon}>📷</Text>
<Text style={styles.msg}>{error}</Text>
<View style={styles.btn}>
<Text style={styles.btnText}>Tap to start camera</Text>
</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>
);
}
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" },
});