window.Camera = { _stream: null, _devices: [], _currentDeviceIndex: 0, _activeSide: null, _bound: null, _isScanning: false, _scanAborted: false, _scanAttempts: 0, _countdownTimer: null, _barcodeDetector: null, _MAX_SERIAL_ATTEMPTS: 15, _SERIAL_TIPS: [ 'Hold the label steady and try again...', 'Try moving closer to the label...', 'Ensure good lighting on the label...', 'Tilt the device to reduce glare...', ], open: function (side) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { Utils.showToast('Camera is not supported in this browser.', 'error'); return; } var self = this; this._activeSide = side; this._isScanning = false; this._scanAborted = false; this._scanAttempts = 0; // Initialize client-side barcode detector if available if (window.BarcodeDetector) { try { this._barcodeDetector = new BarcodeDetector({ formats: ['pdf417', 'code_39'] }); } catch (e) { this._barcodeDetector = null; } } Utils.$('#camera-modal').classList.remove('hidden'); Utils.$('#camera-loading').classList.remove('hidden'); Utils.$('#camera-capture-btn').disabled = true; Utils.$('#camera-scan-btn').disabled = true; this._setScanStatus(''); this._bindEvents(); var constraints = { video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 }, }, audio: false, }; this._startStream(constraints) .then(function () { return self._enumerateDevices(); }) .then(function () { self._updateSwitchButton(); self.startScan(); }) .catch(function (err) { self._handleError(err); self.close(); }); }, close: function () { this.stopScan(); this._stopStream(); Utils.$('#camera-modal').classList.add('hidden'); Utils.$('#camera-scan-guide').classList.add('hidden'); var video = Utils.$('#camera-video'); if (video) video.srcObject = null; this._activeSide = null; this._unbindEvents(); }, capture: function () { var self = this; var video = Utils.$('#camera-video'); if (!video || !video.videoWidth) { Utils.showToast('Camera not ready. Please wait.', 'warning'); return; } var canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; var ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0); this._canvasToFile(canvas).then(function (file) { Upload._setFile(self._activeSide, file); self.close(); Utils.showToast('Photo captured', 'success'); }); }, // ── Auto-Scan ────────────────────────────────────────── startScan: function () { if (this._isScanning) return; this._isScanning = true; this._scanAborted = false; this._scanAttempts = 0; var docType = Utils.$('#doc-type-select').value; var isSerial = docType === 'product_serial'; Utils.$('#camera-scan-btn').textContent = 'Stop Scan'; Utils.$('#camera-scan-btn').classList.add('btn-scanning'); Utils.$('#camera-capture-btn').disabled = true; Utils.$('#camera-scan-status').classList.remove('hidden'); Utils.$('#camera-video').classList.add('scanning'); // Show scan guide for serial number mode var guide = Utils.$('#camera-scan-guide'); if (isSerial) { guide.classList.remove('hidden'); } else { guide.classList.add('hidden'); } this._setScanStatus(isSerial ? 'Position serial number in the frame...' : 'Hold your ID steady...'); this._scanLoop(); }, stopScan: function () { if (!this._isScanning) return; this._isScanning = false; this._scanAborted = true; this._clearCountdown(); Utils.$('#camera-scan-btn').textContent = '\uD83D\uDD0D Scan ID'; Utils.$('#camera-scan-btn').classList.remove('btn-scanning'); Utils.$('#camera-capture-btn').disabled = false; Utils.$('#camera-scan-status').classList.add('hidden'); Utils.$('#camera-video').classList.remove('scanning'); Utils.$('#camera-scan-guide').classList.add('hidden'); }, toggleScan: function () { if (this._isScanning) { this.stopScan(); } else { this.startScan(); } }, _scanLoop: function () { if (!this._isScanning || this._scanAborted) return; var self = this; var video = Utils.$('#camera-video'); if (!video || !video.videoWidth) { setTimeout(function () { self._scanLoop(); }, 1000); return; } this._scanAttempts++; var docType = Utils.$('#doc-type-select').value; var isSerialMode = docType === 'product_serial'; var isBarcodeMode = this._activeSide === 'back' && !Upload.frontFile; // ── Serial number scanning ─────────────────────────── if (isSerialMode) { if (this._scanAttempts > this._MAX_SERIAL_ATTEMPTS) { this.stopScan(); Utils.showToast('Could not detect serial number. Try better lighting or move closer.', 'warning', 5000); return; } this._setScanStatus('Scanning... (attempt ' + this._scanAttempts + '/' + this._MAX_SERIAL_ATTEMPTS + ')'); // Crop to the center 60% of the frame (matches scan guide overlay) var cropW = Math.round(video.videoWidth * 0.6); var cropH = Math.round(video.videoHeight * 0.4); var cropX = Math.round((video.videoWidth - cropW) / 2); var cropY = Math.round((video.videoHeight - cropH) / 2); var canvas = document.createElement('canvas'); canvas.width = cropW; canvas.height = cropH; canvas.getContext('2d').drawImage( video, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH ); this._canvasToFile(canvas).then(function (file) { if (!self._isScanning || self._scanAborted) return; self._setScanStatus('Processing...'); Api.scanSerial(file).then(function (result) { if (!self._isScanning || self._scanAborted) return; if (result.serial_number) { self._isScanning = false; Upload._setFile('front', file); Results.renderSerial(result, file); History.add(History.buildEntry(result, file, 0, null)); self.close(); Utils.showToast('Serial number detected: ' + result.serial_number, 'success'); } else { var tip = self._SERIAL_TIPS[self._scanAttempts % self._SERIAL_TIPS.length]; self._setScanStatus(tip); setTimeout(function () { self._scanLoop(); }, 2000); } }).catch(function (err) { if (!self._isScanning || self._scanAborted) return; if (err.status === 429) { self._startCountdown(10, function () { self._scanLoop(); }); } else { self._setScanStatus('Error, retrying...'); setTimeout(function () { self._scanLoop(); }, 3000); } }); }); return; } // ── Client-side barcode detection (fast, decodes PDF417) ── if (isBarcodeMode && this._barcodeDetector) { this._setScanStatus('Looking for barcode... (attempt ' + this._scanAttempts + ')'); this._barcodeDetector.detect(video).then(function (barcodes) { if (!self._isScanning || self._scanAborted) return; if (!barcodes.length) { setTimeout(function () { self._scanLoop(); }, 1000); return; } var rawText = barcodes[0].rawValue; self._setScanStatus('Barcode detected! Processing...'); Api.parseBarcode(rawText).then(function (result) { if (!self._isScanning || self._scanAborted) return; var fields = result.fields || {}; var hasFields = Object.keys(fields).some(function (k) { return !!fields[k]; }); var canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext('2d').drawImage(video, 0, 0); self._canvasToFile(canvas).then(function (file) { self._isScanning = false; Upload._setFile(self._activeSide, file); Results.render(result, file); History.add(History.buildEntry(result, file, 0, null)); self.close(); Utils.showToast( hasFields ? 'Barcode scanned successfully' : 'Barcode detected but could not extract data', hasFields ? 'success' : 'warning' ); }); }).catch(function () { if (!self._isScanning || self._scanAborted) return; self._setScanStatus('Error, retrying...'); setTimeout(function () { self._scanLoop(); }, 2000); }); }).catch(function () { if (!self._isScanning || self._scanAborted) return; setTimeout(function () { self._scanLoop(); }, 1500); }); return; } // ── Server-side scan (front side or no barcode detector) ─ this._setScanStatus('Scanning... (attempt ' + this._scanAttempts + ')'); var canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; var ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0); var docType = Utils.$('#doc-type-select').value; this._canvasToFile(canvas).then(function (file) { if (!self._isScanning || self._scanAborted) return; self._setScanStatus('Processing... (attempt ' + self._scanAttempts + ')'); var apiCall; if (self._activeSide === 'back' && Upload.frontFile) { apiCall = Api.processDocument(Upload.frontFile, docType, file); } else { apiCall = Api.processDocument(file, docType, null); } return apiCall .then(function (result) { if (!self._isScanning || self._scanAborted) return; var fields = result.fields || {}; var hasFields = Object.keys(fields).some(function (k) { return !!fields[k]; }); if (isBarcodeMode && !hasFields) { self._setScanStatus('No data extracted, retrying...'); setTimeout(function () { self._scanLoop(); }, 2000); return; } self._isScanning = false; Upload._setFile(self._activeSide, file); Results.render(result, file); History.add(History.buildEntry(result, file, 0, null)); self.close(); Utils.showToast( hasFields ? 'ID scanned successfully' : 'Scan complete — try better lighting for more details', hasFields ? 'success' : 'warning' ); }) .catch(function (err) { if (!self._isScanning || self._scanAborted) return; if (err.status === 429) { self._setScanStatus('Rate limited, waiting...'); setTimeout(function () { self._scanLoop(); }, 10000); } else { self._setScanStatus('Error, retrying...'); setTimeout(function () { self._scanLoop(); }, 3000); } }); }); }, _setScanStatus: function (text) { var el = Utils.$('#camera-scan-text'); if (el) el.textContent = text; }, _startCountdown: function (seconds, callback) { var self = this; var remaining = seconds; this._clearCountdown(); this._setScanStatus('Rate limited, retrying in ' + remaining + 's...'); this._countdownTimer = setInterval(function () { remaining--; if (remaining <= 0) { self._clearCountdown(); callback(); } else { self._setScanStatus('Rate limited, retrying in ' + remaining + 's...'); } }, 1000); }, _clearCountdown: function () { if (this._countdownTimer) { clearInterval(this._countdownTimer); this._countdownTimer = null; } }, // ── Camera Controls ──────────────────────────────────── switchCamera: function () { if (this._devices.length < 2) return; var self = this; this._currentDeviceIndex = (this._currentDeviceIndex + 1) % this._devices.length; var device = this._devices[this._currentDeviceIndex]; Utils.$('#camera-capture-btn').disabled = true; Utils.$('#camera-loading').classList.remove('hidden'); this._startStream({ video: { deviceId: { exact: device.deviceId } }, audio: false, }).catch(function (err) { self._handleError(err); return self._startStream({ video: true, audio: false }); }); }, // ── Private ──────────────────────────────────────────── _startStream: function (constraints) { var self = this; if (this._stream) { this._stopStream(); } return navigator.mediaDevices.getUserMedia(constraints) .then(function (stream) { self._stream = stream; var video = Utils.$('#camera-video'); video.srcObject = stream; return new Promise(function (resolve) { video.onloadedmetadata = function () { video.play().then(resolve).catch(resolve); }; setTimeout(resolve, 3000); }); }) .then(function () { Utils.$('#camera-loading').classList.add('hidden'); Utils.$('#camera-capture-btn').disabled = false; Utils.$('#camera-scan-btn').disabled = false; }); }, _stopStream: function () { if (!this._stream) return; var tracks = this._stream.getTracks(); for (var i = 0; i < tracks.length; i++) { tracks[i].stop(); } this._stream = null; }, _enumerateDevices: function () { var self = this; return navigator.mediaDevices.enumerateDevices().then(function (devices) { self._devices = devices.filter(function (d) { return d.kind === 'videoinput'; }); return self._devices; }); }, _canvasToFile: function (canvas) { return new Promise(function (resolve) { canvas.toBlob(function (blob) { var ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); var file = new File([blob], 'camera-' + ts + '.jpg', { type: 'image/jpeg', lastModified: Date.now(), }); resolve(file); }, 'image/jpeg', 0.92); }); }, _updateSwitchButton: function () { var btn = Utils.$('#camera-switch-btn'); if (this._devices.length > 1) { btn.classList.remove('hidden'); } else { btn.classList.add('hidden'); } }, _handleError: function (err) { var message; switch (err.name) { case 'NotAllowedError': message = 'Camera access denied. Please allow camera permission in your browser settings.'; break; case 'NotFoundError': message = 'No camera found on this device.'; break; case 'NotReadableError': message = 'Camera is already in use by another application.'; break; case 'OverconstrainedError': message = 'Camera does not support the requested settings.'; break; case 'AbortError': message = 'Camera access was interrupted.'; break; case 'SecurityError': message = 'Camera access requires HTTPS or localhost.'; break; default: message = 'Could not access camera: ' + (err.message || 'Unknown error'); } Utils.showToast(message, 'error', 6000); }, _bindEvents: function () { var self = this; this._bound = { capture: function () { self.capture(); }, scan: function () { self.toggleScan(); }, switchCam: function () { self.switchCamera(); }, close: function () { self.close(); }, backdrop: function (e) { if (e.target.classList.contains('camera-modal-backdrop')) { self.close(); } }, escape: function (e) { if (e.key === 'Escape') { self.close(); } }, }; Utils.$('#camera-capture-btn').addEventListener('click', this._bound.capture); Utils.$('#camera-scan-btn').addEventListener('click', this._bound.scan); Utils.$('#camera-switch-btn').addEventListener('click', this._bound.switchCam); Utils.$('#camera-close-btn').addEventListener('click', this._bound.close); Utils.$('#camera-cancel-btn').addEventListener('click', this._bound.close); Utils.$('.camera-modal-backdrop').addEventListener('click', this._bound.backdrop); document.addEventListener('keydown', this._bound.escape); }, _unbindEvents: function () { if (!this._bound) return; Utils.$('#camera-capture-btn').removeEventListener('click', this._bound.capture); Utils.$('#camera-scan-btn').removeEventListener('click', this._bound.scan); Utils.$('#camera-switch-btn').removeEventListener('click', this._bound.switchCam); Utils.$('#camera-close-btn').removeEventListener('click', this._bound.close); Utils.$('#camera-cancel-btn').removeEventListener('click', this._bound.close); Utils.$('.camera-modal-backdrop').removeEventListener('click', this._bound.backdrop); document.removeEventListener('keydown', this._bound.escape); this._bound = null; }, };