/** * Hybrid Barcode Scanner * Supports: Real-time camera scanning (ZXing.js), image upload fallback (server-side pyzbar), * and USB/keyboard scanner emulation. */ class HybridScanner { constructor(options = {}) { this.options = { videoId: options.videoId || 'scanner-video', canvasId: options.canvasId || 'scanner-canvas', formats: options.formats || ['QR_CODE', 'CODE_128', 'EAN_13', 'ISBN', 'CODE_39', 'UPC_A'], fps: options.fps || 10, onSuccess: options.onSuccess || (() => {}), onError: options.onError || (() => {}), facingMode: options.facingMode || 'environment', // 'environment' for rear, 'user' for front }; this.state = { isRunning: false, stream: null, reader: null, canvas: null, videoElement: null, zxingReady: false, lastScanTime: 0, scanDebounceMs: 300, // Prevent rapid duplicate scans }; this.keyboardInputBuffer = ''; this.keyboardInputTimeout = null; } /** * Initialize ZXing library from CDN */ async initZXing() { if (this.state.zxingReady) { return true; } return new Promise((resolve) => { // Load ZXing library from CDN if (typeof ZXing !== 'undefined') { this.state.zxingReady = true; resolve(true); return; } // Use fallback URL for better mobile compatibility let scriptUrl = 'https://unpkg.com/@zxing/library@latest/umd/index.min.js'; // Check if already loading const existingScript = document.querySelector(`script[src="${scriptUrl}"]`); if (existingScript) { existingScript.addEventListener('load', () => { this.state.zxingReady = typeof ZXing !== 'undefined'; resolve(this.state.zxingReady); }, { once: true }); existingScript.addEventListener('error', () => { console.error('Failed to load ZXing library'); resolve(false); }, { once: true }); return; } const script = document.createElement('script'); script.src = scriptUrl; script.async = true; script.onload = () => { this.state.zxingReady = typeof ZXing !== 'undefined'; resolve(this.state.zxingReady); }; script.onerror = () => { console.error('Failed to load ZXing library'); resolve(false); }; document.head.appendChild(script); }); } /** * Start real-time barcode scanning via camera */ async start() { if (this.state.isRunning) { return; } try { // Ensure ZXing is loaded const zxingReady = await this.initZXing(); if (!zxingReady) { this.options.onError('ZXing library failed to load. Please use image upload fallback.'); return; } // Get DOM elements this.state.videoElement = document.getElementById(this.options.videoId); this.state.canvas = document.getElementById(this.options.canvasId); if (!this.state.videoElement || !this.state.canvas) { this.options.onError('Scanner video or canvas element not found.'); return; } // Request camera access const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: this.options.facingMode, width: { ideal: 1280 }, height: { ideal: 720 }, }, audio: false, }); this.state.stream = stream; this.state.videoElement.srcObject = stream; // Make video element visible (ensure it's not hidden by display: none) this.state.videoElement.style.display = 'block'; // Ensure autoplay works on mobile by muting and setting playsInline try { this.state.videoElement.muted = true; this.state.videoElement.autoplay = true; this.state.videoElement.playsInline = true; // Some browsers require explicit play() after setting srcObject const p = this.state.videoElement.play(); if (p && p.then) { p.catch(() => { // ignore play promise rejection (user gesture required on some browsers) }); } } catch (e) { // ignore } // Initialize ZXing reader const hints = new Map(); hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, this.options.formats); hints.set(ZXing.DecodeHintType.TRY_HARDER, true); this.state.reader = new ZXing.BrowserMultiFormatReader(hints); this.state.isRunning = true; // Start scanning loop this.scanLoop(); // Setup keyboard input fallback for USB scanners this.setupKeyboardInput(); } catch (err) { this.options.onError(`Camera error: ${err.message || err}`); } } /** * Stop barcode scanning */ stop() { if (!this.state.isRunning) { return; } this.state.isRunning = false; if (this.state.stream) { this.state.stream.getTracks().forEach((track) => track.stop()); this.state.stream = null; } if (this.state.videoElement) { this.state.videoElement.srcObject = null; this.state.videoElement.style.display = 'none'; } // Reset ZXing reader to stop any internal loops try { if (this.state.reader && typeof this.state.reader.reset === 'function') { this.state.reader.reset(); } } catch (e) { // ignore } this.state.reader = null; this.removeKeyboardInput(); } /** * Main scanning loop for real-time frame processing */ scanLoop() { if (!this.state.isRunning) { return; } const canvas = this.state.canvas; const video = this.state.videoElement; if (!video || !canvas) { setTimeout(() => this.scanLoop(), 100); return; } if (video.readyState === video.HAVE_ENOUGH_DATA) { try { canvas.width = video.videoWidth; canvas.height = video.videoHeight; if (canvas.width === 0 || canvas.height === 0) { setTimeout(() => this.scanLoop(), 100); return; } const context = canvas.getContext('2d'); if (context) { context.drawImage(video, 0, 0); try { const luminanceSource = new ZXing.HTMLCanvasElementLuminanceSource(canvas); const binaryBitmap = new ZXing.BinaryBitmap(new ZXing.HybridBinarizer(luminanceSource)); try { const result = this.state.reader.decodeFromBitmap(binaryBitmap); this.handleScanResult(result.text); } catch (e) { // No barcode found in frame; continue scanning } } catch (err) { // Silently ignore canvas errors } } } catch (err) { console.warn('Scan loop error:', err); } } // Throttle scan loop based on FPS setting setTimeout(() => this.scanLoop(), 1000 / this.options.fps); } /** * Handle successful barcode scan with debouncing */ handleScanResult(scannedText) { const now = Date.now(); if (now - this.state.lastScanTime < this.state.scanDebounceMs) { return; // Ignore rapid duplicates } this.state.lastScanTime = now; this.options.onSuccess(scannedText.trim()); } /** * Setup keyboard input listener for USB scanner emulation * Most USB scanners emulate keyboard input (barcode followed by Enter) */ setupKeyboardInput() { this.keyboardListener = (event) => { // Allow only alphanumeric, dash, colon (common in barcodes) if (/^[a-zA-Z0-9:_\-]$/.test(event.key)) { event.preventDefault(); this.keyboardInputBuffer += event.key; // Reset timeout for multi-part barcodes if (this.keyboardInputTimeout) { clearTimeout(this.keyboardInputTimeout); } } else if (event.key === 'Enter') { event.preventDefault(); if (this.keyboardInputBuffer.length > 0) { this.handleScanResult(this.keyboardInputBuffer); this.keyboardInputBuffer = ''; } } else if (event.key === 'Escape') { this.keyboardInputBuffer = ''; this.stop(); } // Auto-trigger if buffer looks complete (common barcode lengths) if ([8, 12, 13, 17].includes(this.keyboardInputBuffer.length)) { this.keyboardInputTimeout = setTimeout(() => { if (this.keyboardInputBuffer.length > 0) { this.handleScanResult(this.keyboardInputBuffer); this.keyboardInputBuffer = ''; } }, 200); } }; document.addEventListener('keydown', this.keyboardListener); } /** * Remove keyboard input listener */ removeKeyboardInput() { if (this.keyboardListener) { document.removeEventListener('keydown', this.keyboardListener); this.keyboardListener = null; } this.keyboardInputBuffer = ''; } /** * Upload image for server-side decoding (fallback) * Requires /api/scan_upload endpoint */ static async uploadImageForDecoding(file, onSuccess, onError) { try { const formData = new FormData(); formData.append('image', file); const response = await fetch('/api/scan_upload', { method: 'POST', body: formData, }); const data = await response.json(); if (data.ok && data.barcodes && data.barcodes.length > 0) { // Return first detected barcode (highest confidence) onSuccess(data.barcodes[0].value); } else { onError(data.message || 'No barcode detected in image.'); } } catch (err) { onError(`Upload error: ${err.message}`); } } /** * Toggle camera (front/rear on mobile) */ toggleCamera() { if (this.options.facingMode === 'environment') { this.options.facingMode = 'user'; } else { this.options.facingMode = 'environment'; } // Restart scanning with new camera this.stop(); this.start(); } } // Export for use in templates if (typeof window !== 'undefined') { window.HybridScanner = HybridScanner; }