asset_browser/public/modules/cameraScanner.js
setonc a558804026 Initial commit — asset browser web app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:06:25 -04:00

172 lines
6.2 KiB
JavaScript
Executable file

// 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);
}