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:
parent
f14e45349e
commit
e87be49ebd
1 changed files with 29 additions and 7 deletions
|
|
@ -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
|
||||||
|
// 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" } },
|
video: { facingMode: { ideal: "environment" } },
|
||||||
audio: false,
|
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."
|
||||||
|
: 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.",
|
: "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 () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue