Fix web camera stuck on "Starting camera…"

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>
This commit is contained in:
Hank 2026-07-08 17:26:00 +00:00
parent f14e45349e
commit e87be49ebd

View file

@ -24,17 +24,29 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
async function start() { async function start() {
setError(null); setError(null);
setStarting(true); setStarting(true);
// Stop any previous stream before requesting a new one (Retry / remounts).
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ // Watchdog: on iOS, getUserMedia can hang indefinitely when it isn't tied
video: { facingMode: { ideal: "environment" } }, // to a user gesture (we navigate here from the name screen). If it doesn't
audio: false, // 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; streamRef.current = stream;
const video = videoRef.current; const video = videoRef.current;
if (video) { if (video) {
video.srcObject = stream; video.srcObject = stream;
video.setAttribute("playsinline", "true"); video.setAttribute("playsinline", "true");
await video.play().catch(() => {}); 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"] }); const detector = new BarcodeDetector({ formats: ["qr_code"] });
let busy = false; let busy = false;
@ -65,12 +77,22 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
setStarting(false); setStarting(false);
setError( setError(
e?.name === "NotAllowedError" e?.name === "NotAllowedError"
? "Camera permission was denied. Allow camera access and reload." ? "Camera permission was denied. Allow camera access, then tap Retry."
: "Could not open the camera. Make sure you're on HTTPS and no other app is using it.", : 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(() => { useEffect(() => {
start(); start();
return () => { return () => {