354 lines
9.9 KiB
JavaScript
354 lines
9.9 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|