Scanner: tappable start overlay so the camera can never silently hang
Some checks failed
Build Android APK / build-apk (push) Failing after 8m55s
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:
parent
8f62a84e9b
commit
8b202e4942
1 changed files with 71 additions and 45 deletions
|
|
@ -4,39 +4,48 @@ 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). */
|
||||
/**
|
||||
* 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);
|
||||
// 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 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);
|
||||
const [starting, setStarting] = useState(true);
|
||||
|
||||
activeRef.current = active;
|
||||
onScanRef.current = onScan;
|
||||
|
||||
async function start() {
|
||||
if (startingRef.current) return;
|
||||
startingRef.current = true;
|
||||
setError(null);
|
||||
setStarting(true);
|
||||
// Stop any previous stream before requesting a new one (Retry / remounts).
|
||||
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: 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).
|
||||
// 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,
|
||||
}),
|
||||
12000,
|
||||
8000,
|
||||
);
|
||||
streamRef.current = stream;
|
||||
const video = videoRef.current;
|
||||
|
|
@ -44,8 +53,6 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
|||
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"] });
|
||||
|
|
@ -53,7 +60,12 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
|||
const tick = async () => {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
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;
|
||||
try {
|
||||
const codes = await detector.detect(v);
|
||||
|
|
@ -71,28 +83,19 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
|||
busy = false;
|
||||
}
|
||||
};
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
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.",
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
|
|
@ -102,9 +105,17 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
|||
// 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 div tree. */}
|
||||
{/* 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" }}
|
||||
|
|
@ -112,18 +123,25 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
|||
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>
|
||||
{!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>
|
||||
);
|
||||
|
|
@ -142,7 +160,15 @@ const styles = StyleSheet.create({
|
|||
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" },
|
||||
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" },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue