// cameraScanner.js — camera-based barcode scanning via BarcodeDetector. // BarcodeDetector is not natively supported on Windows; a WASM polyfill is loaded // on first use from jsDelivr (which correctly serves the .wasm binary alongside the JS). let _detector = null; let _stream = null; let _interval = null; let _running = false; let _onScan = null; let _onStop = null; const INTERVAL_MS = 175; // WASM detection takes 50-120ms; 175ms avoids main-thread congestion const FORMATS = ['qr_code', 'code_128', 'code_39', 'ean_13', 'ean_8', 'upc_a', 'data_matrix', 'pdf417']; // ── Public API ──────────────────────────────────────────────────────────────── export function init(onScan, onStop) { _onScan = onScan; _onStop = onStop; document.getElementById('btn-camera-close')?.addEventListener('click', stop); } export function isActive() { return _running; } export async function start() { if (_running) return; _showOverlay(); _setStatus('Initializing…'); try { await _initDetector(); _stream = await _acquireCamera(); const video = document.getElementById('camera-video'); video.srcObject = _stream; await video.play(); _setStatus('Scanning…'); _running = true; _startLoop(video); } catch (err) { _handleError(err); } } export async function stop() { _running = false; clearInterval(_interval); _interval = null; _releaseCamera(); _hideOverlay(); _onStop?.(); } // ── Detector init (lazy, singleton) ────────────────────────────────────────── async function _initDetector() { if (_detector) return; _setStatus('Loading barcode scanner…'); // Try native BarcodeDetector first (works on Android/macOS, NOT Windows) if ('BarcodeDetector' in window) { try { const supported = await BarcodeDetector.getSupportedFormats(); if (supported.length > 0) { _detector = new BarcodeDetector({ formats: FORMATS.filter(f => supported.includes(f)) }); return; } } catch { // Fall through to polyfill } } // WASM polyfill via jsDelivr — jsDelivr correctly serves the .wasm binary // alongside the JS module (esm.sh does not host .wasm files) const { BarcodeDetector: Poly } = await import( 'https://cdn.jsdelivr.net/npm/barcode-detector@3/+esm' ); _detector = new Poly({ formats: FORMATS }); } // ── Camera acquisition ──────────────────────────────────────────────────────── async function _acquireCamera() { const devices = await navigator.mediaDevices.enumerateDevices(); const inputs = devices.filter(d => d.kind === 'videoinput'); if (!inputs.length) { const err = new Error('No camera found on this device.'); err.name = 'NoCameraError'; throw err; } // Surface Pro: enumerateDevices labels are only populated after first permission grant. // On first run, labels are empty strings → facingMode fallback is used. // On subsequent runs, labels like "Microsoft Camera Rear" are available. const rear = inputs.find(d => /rear|back/i.test(d.label)); const constraints = rear ? { video: { deviceId: { exact: rear.deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } } : { video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } } }; return navigator.mediaDevices.getUserMedia(constraints); } // ── Scan loop ───────────────────────────────────────────────────────────────── function _startLoop(video) { _interval = setInterval(async () => { if (!_running || video.readyState < video.HAVE_ENOUGH_DATA) return; try { const hits = await _detector.detect(video); if (hits.length) { const value = hits[0].rawValue; // Flash detected state briefly so the user sees the scanner found something const overlay = document.getElementById('camera-overlay'); overlay?.classList.add('detected'); _setStatus('Barcode found!'); await new Promise(r => setTimeout(r, 350)); await stop(); // Close overlay then fire scan (matches USB wedge UX) _onScan?.(value); } } catch { // Silently skip frames where detect() throws (video not ready, stream ended, etc.) } }, INTERVAL_MS); } // ── Error handling ──────────────────────────────────────────────────────────── function _handleError(err) { _running = false; clearInterval(_interval); _interval = null; _releaseCamera(); const msg = err.name === 'NotAllowedError' ? 'Camera permission denied. Allow camera access and try again.' : err.name === 'NoCameraError' ? 'No camera found on this device.' : err.name === 'NotFoundError' ? 'Camera not found. Is it connected?' : err.name === 'NotReadableError' ? 'Camera is in use by another application.' : `Scanner error: ${err.message}`; _setStatus(msg, true); // Keep overlay visible on error so the user can read the message; X button still works } // ── Helpers ─────────────────────────────────────────────────────────────────── function _releaseCamera() { _stream?.getTracks().forEach(t => t.stop()); _stream = null; const video = document.getElementById('camera-video'); if (video) video.srcObject = null; } function _showOverlay() { document.getElementById('camera-overlay').hidden = false; } function _hideOverlay() { const el = document.getElementById('camera-overlay'); el.classList.remove('detected'); el.hidden = true; } function _setStatus(msg, isError = false) { const el = document.getElementById('camera-status'); if (!el) return; el.textContent = msg; el.classList.toggle('error', isError); }