import { useCallback, useEffect, useRef, useState } from "react"; import { Html5Qrcode } from "html5-qrcode"; import { Icon } from "./primitives/index.js"; export function CameraScanner({ onScan, onClose, }: { onScan: (decodedText: string) => void; onClose: () => void; }) { const scannerRef = useRef(null); const containerRef = useRef(null); const [error, setError] = useState(null); const [started, setStarted] = useState(false); const [torchOn, setTorchOn] = useState(false); const [torchAvailable, setTorchAvailable] = useState(false); const lastScan = useRef(""); const lastScanTime = useRef(0); const startScanner = useCallback(async () => { const id = "apothecary-scanner"; let scanner = scannerRef.current; if (!scanner) { scanner = new Html5Qrcode(id, { verbose: false }); scannerRef.current = scanner; } if (scanner.isScanning) return; await scanner.start( { facingMode: "environment" }, { fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) }, (text) => { const now = Date.now(); if (text === lastScan.current && now - lastScanTime.current < 3000) return; lastScan.current = text; lastScanTime.current = now; onScan(text); }, () => {}, ); setStarted(true); try { const caps = scanner.getRunningTrackCameraCapabilities(); if (caps.torchFeature().isSupported()) { setTorchAvailable(true); } } catch { // torch check can fail on some browsers } }, [onScan]); const startCamera = useCallback(async () => { if (!window.isSecureContext) { setError( "Camera requires a secure connection (HTTPS). " + "Access this site over https:// or on localhost to use the scanner.", ); return; } if (!navigator.mediaDevices?.getUserMedia) { setError("Camera API not available in this browser."); return; } // Request camera permission directly in the user gesture handler. // html5-qrcode does async DOM work before calling getUserMedia internally, // which breaks iOS Safari's transient user activation window. // By calling getUserMedia here first, we trigger the permission prompt // while the gesture is still active, then let html5-qrcode reuse the grant. let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("denied") || msg.includes("NotAllowedError")) { setError( "Camera permission was denied. Open your browser settings to allow camera access for this site, then try again.", ); } else { setError(msg); } return; } // Permission granted — stop this stream so html5-qrcode can open its own stream.getTracks().forEach((t) => t.stop()); try { await startScanner(); } catch (err: unknown) { setError(err instanceof Error ? err.message : "Scanner failed to start"); } }, [startScanner]); useEffect(() => { // Auto-start on desktop where getUserMedia works outside gestures const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; if (!isTouchDevice && window.isSecureContext) { startCamera(); } return () => { if (scannerRef.current?.isScanning) { scannerRef.current.stop().catch(() => {}); } }; }, [startCamera]); const toggleTorch = () => { try { const caps = scannerRef.current?.getRunningTrackCameraCapabilities(); if (caps?.torchFeature().isSupported()) { const next = !torchOn; caps.torchFeature().apply(next); setTorchOn(next); } } catch { // ignore } }; const insecure = !window.isSecureContext; return (
{/* Top bar */}
Scan
{torchAvailable && ( )}
{/* Camera viewport */}
{!started && !error && (
{insecure ? ( <>
HTTPS required
Camera access requires a secure connection. Access this site over https:// to use the barcode scanner.
) : ( <>
Tap to start camera
Allow camera access when prompted
)}
)} {error && (
Camera unavailable
{error}
{!insecure && ( )}
)}
{/* Bottom hint */}
{started ? "Point at a barcode or QR code" : ""}
); }