/** * Lightweight QR scanning. Uses the native BarcodeDetector API when present * (Android Chrome). Falls back to manual code entry where it isn't (iOS Safari * has no BarcodeDetector). Returns the decoded payload string, or null if the * user cancels. */ export async function scanQR() { if ("BarcodeDetector" in window) { try { const formats = await window.BarcodeDetector.getSupportedFormats(); if (formats.includes("qr_code")) return await scanWithCamera(); } catch (_) { /* fall through to manual */ } } return manualEntry(); } async function scanWithCamera() { const detector = new window.BarcodeDetector({ formats: ["qr_code"] }); const overlay = buildOverlay(); const video = overlay.querySelector("video"); document.body.appendChild(overlay); let stream; try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false, }); } catch { overlay.remove(); return manualEntry(); } video.srcObject = stream; await video.play().catch(() => {}); return new Promise((resolve) => { let done = false; const stop = (val) => { if (done) return; done = true; stream.getTracks().forEach((t) => t.stop()); overlay.remove(); resolve(val); }; overlay.querySelector(".qr-cancel").onclick = () => stop(null); overlay.querySelector(".qr-manual").onclick = async () => { stop(null); resolve(await manualEntry()); }; const tick = async () => { if (done) return; try { const codes = await detector.detect(video); if (codes.length) return stop(codes[0].rawValue); } catch (_) { /* keep trying */ } requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); } function manualEntry() { const code = window.prompt( "Enter the set code (printed beneath its QR), e.g. SET-GRAVEYARD:" ); return code ? code.trim() : null; } function buildOverlay() { const el = document.createElement("div"); el.className = "qr-overlay"; el.innerHTML = `

Point at a set's QR code

`; return el; }