172 lines
6.2 KiB
JavaScript
Executable file
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);
|
|
}
|