Spaces:
Running
Running
| 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; | |
| }, | |
| }; | |