diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js index e2df43c..2266b39 100644 --- a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js +++ b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js @@ -2,36 +2,49 @@ * In-browser powder label scanner for the Inventory Create/Edit forms. * * Flow: - * 1. User clicks "Scan Label" → modal opens, rear camera starts. - * 2. jsQR runs in a requestAnimationFrame loop scanning the live video. + * 1. User clicks "Scan Label" → modal opens, rear camera starts (permission asked once + * per page load — stream is kept alive between modal opens to avoid re-prompting). + * 2. QR scanning uses the native BarcodeDetector API when available (Chrome/Edge/Android), + * falling back to jsQR for other browsers. * - QR detected automatically → POST qrUrl to /Inventory/ScanLabel → fill form. * 3. If no QR within ~5 s the "Scan Text" button appears as a fallback. * - User taps it → grab current frame → base64 → POST to /Inventory/ScanLabel → fill form. * 4. Either path: server searches catalog, auto-contributes if new, returns unified data. * 5. Modal closes, form fills using the same field IDs as inventory-catalog-lookup.js. + * + * Camera permission strategy: + * - getUserMedia is called only once per page session (on first open). + * - The stream is kept alive when the modal closes so subsequent opens are instant + * and the browser does not re-prompt for camera access. + * - An idle timer (IDLE_RELEASE_MS) stops the stream after prolonged inactivity so + * the camera indicator light doesn't stay on indefinitely. + * - The stream is always released on page unload. */ (function () { 'use strict'; - const SCAN_URL = '/Inventory/ScanLabel'; - const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; + const SCAN_URL = '/Inventory/ScanLabel'; + const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; + const IDLE_RELEASE_MS = 2 * 60 * 1000; // release camera after 2 min idle - const scanBtn = document.getElementById('scan-label-btn'); - const statusEl = document.getElementById('ai-lookup-status'); + const scanBtn = document.getElementById('scan-label-btn'); + const statusEl = document.getElementById('ai-lookup-status'); if (!scanBtn) return; - let stream = null; // MediaStream - let rafId = null; // requestAnimationFrame handle - let qrFound = false; - let shutterTimer = null; // timer to reveal the fallback shutter button + // Stream is kept alive between modal opens; only released on idle timeout or page unload + let stream = null; + let rafId = null; + let qrFound = false; + let shutterTimer = null; + let idleTimer = null; - // ── Modal bootstrap ─────────────────────────────────────────────────── + // ── Modal elements ──────────────────────────────────────────────────── - const modalEl = document.getElementById('labelScanModal'); - const bsModal = modalEl ? new bootstrap.Modal(modalEl) : null; - const videoEl = document.getElementById('scan-video'); - const canvasEl = document.getElementById('scan-canvas'); + const modalEl = document.getElementById('labelScanModal'); + const bsModal = modalEl ? new bootstrap.Modal(modalEl) : null; + const videoEl = document.getElementById('scan-video'); + const canvasEl = document.getElementById('scan-canvas'); const scanStatusEl = document.getElementById('scan-modal-status'); const shutterBtn = document.getElementById('scan-shutter-btn'); const shutterWrap = document.getElementById('scan-shutter-wrap'); @@ -39,66 +52,119 @@ if (!modalEl || !videoEl || !canvasEl) return; scanBtn.addEventListener('click', openScanner); - modalEl.addEventListener('hide.bs.modal', stopCamera); + modalEl.addEventListener('hide.bs.modal', onModalClose); if (shutterBtn) shutterBtn.addEventListener('click', captureFrame); + window.addEventListener('beforeunload', releaseCamera); // ── Open / close ────────────────────────────────────────────────────── async function openScanner() { if (!bsModal) return; qrFound = false; + stopQrLoop(); + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (shutterWrap) shutterWrap.classList.add('d-none'); - setScanStatus('info', 'Starting camera…'); bsModal.show(); - try { - stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } - }); - videoEl.srcObject = stream; - await videoEl.play(); - setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.'); - await loadJsQR(); - startQrLoop(); - // Reveal the shutter fallback after 5 s in case there is no QR - shutterTimer = setTimeout(() => { - if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none'); - }, 5000); - } catch (err) { - setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message); - if (shutterWrap) shutterWrap.classList.remove('d-none'); // let them try vision path + if (!stream) { + // First open (or after idle release) — request camera access + setScanStatus('info', 'Starting camera…'); + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } + }); + } catch (err) { + setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message); + if (shutterWrap) shutterWrap.classList.remove('d-none'); + return; + } } + + // Attach (or re-attach) stream to video element + if (videoEl.srcObject !== stream) { + videoEl.srcObject = stream; + } + if (videoEl.paused) { + try { await videoEl.play(); } catch { /* ignore */ } + } + + setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.'); + startQrLoop(); + + // Reveal the shutter fallback after 5 s in case there is no QR + shutterTimer = setTimeout(() => { + if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none'); + }, 5000); } - function stopCamera() { - if (rafId) { cancelAnimationFrame(rafId); rafId = null; } - if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; } - if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } + function onModalClose() { + // Stop the scan loop but keep the stream alive to avoid re-prompting on next open + stopQrLoop(); + // Start idle timer — release camera entirely after 2 min of inactivity + idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); + } + + function stopQrLoop() { + if (rafId) { cancelAnimationFrame(rafId); rafId = null; } + if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; } + } + + function releaseCamera() { + stopQrLoop(); + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } + if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } videoEl.srcObject = null; } - // ── jsQR lazy load ──────────────────────────────────────────────────── - - function loadJsQR() { - return new Promise((resolve, reject) => { - if (window.jsQR) { resolve(); return; } - const s = document.createElement('script'); - s.src = JSQR_CDN; - s.onload = resolve; - s.onerror = () => reject(new Error('Failed to load jsQR')); - document.head.appendChild(s); - }); - } - - // ── QR scan loop ────────────────────────────────────────────────────── + // ── QR scan — BarcodeDetector (native) with jsQR fallback ──────────── function startQrLoop() { + if (typeof BarcodeDetector !== 'undefined') { + startBarcodeDetectorLoop(); + } else { + loadJsQR().then(startJsQrLoop).catch(() => { + // jsQR failed to load — just show shutter button immediately + if (shutterWrap) shutterWrap.classList.remove('d-none'); + }); + } + } + + // Native BarcodeDetector path (Chrome/Edge/Android) — more reliable than jsQR + function startBarcodeDetectorLoop() { + let detector; + try { + detector = new BarcodeDetector({ formats: ['qr_code'] }); + } catch { + // BarcodeDetector exists but qr_code format not supported — fall back + loadJsQR().then(startJsQrLoop); + return; + } + + async function tick() { + if (!stream || qrFound) return; + if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { + try { + const codes = await detector.detect(videoEl); + if (codes.length > 0 && !qrFound) { + qrFound = true; + handleQrResult(codes[0].rawValue); + return; + } + } catch { /* video not ready or detect failed — try next frame */ } + } + rafId = requestAnimationFrame(tick); + } + + rafId = requestAnimationFrame(tick); + } + + // jsQR fallback path (Safari, Firefox, older browsers) + function startJsQrLoop() { const ctx = canvasEl.getContext('2d'); function tick() { if (!stream || qrFound) return; - - if (videoEl.readyState === videoEl.HAVE_ENOUGH_DATA) { + if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { canvasEl.width = videoEl.videoWidth; canvasEl.height = videoEl.videoHeight; ctx.drawImage(videoEl, 0, 0); @@ -106,27 +172,35 @@ const code = window.jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); - if (code && code.data) { qrFound = true; handleQrResult(code.data); return; } } - rafId = requestAnimationFrame(tick); } rafId = requestAnimationFrame(tick); } + function loadJsQR() { + return new Promise((resolve, reject) => { + if (window.jsQR) { resolve(); return; } + const s = document.createElement('script'); + s.src = JSQR_CDN; + s.onload = resolve; + s.onerror = () => reject(new Error('Failed to load jsQR')); + document.head.appendChild(s); + }); + } + // ── QR result handler ───────────────────────────────────────────────── async function handleQrResult(url) { - stopCamera(); + stopQrLoop(); setScanStatus('info', 'QR code found — looking up product…'); setScanBtnLoading(true); - try { const fd = new FormData(); fd.append('qrUrl', url); @@ -141,19 +215,17 @@ async function captureFrame() { setScanStatus('info', 'Reading label…'); setScanBtnLoading(true); + stopQrLoop(); - // If camera is running, grab the current frame; otherwise show file picker if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { const ctx = canvasEl.getContext('2d'); // Scale down to ≤1024px wide to keep payload small while preserving label text - const maxW = 1024; + const maxW = 1024; const scale = videoEl.videoWidth > maxW ? maxW / videoEl.videoWidth : 1; canvasEl.width = Math.round(videoEl.videoWidth * scale); canvasEl.height = Math.round(videoEl.videoHeight * scale); ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height); - stopCamera(); - canvasEl.toBlob(async (blob) => { try { const base64 = await blobToBase64(blob); @@ -167,7 +239,6 @@ }, 'image/jpeg', 0.88); } else { // No live camera (e.g. desktop) — fall back to file picker - stopCamera(); const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; @@ -230,9 +301,9 @@ } } - setIf('field-manufacturer', data.manufacturer, 'Manufacturer'); - setIf('field-partnumber', data.manufacturerPartNumber,'Part Number'); - setIf('field-colorname', data.colorName, 'Color Name'); + setIf('field-manufacturer', data.manufacturer, 'Manufacturer'); + setIf('field-partnumber', data.manufacturerPartNumber, 'Part Number'); + setIf('field-colorname', data.colorName, 'Color Name'); // Name field: use color name if blank const nameEl = document.getElementById('field-name'); @@ -241,12 +312,12 @@ filled.push('Name'); } - setIfEmpty('field-description', data.description, 'Description'); - setIfEmpty('field-finish', data.finish, 'Finish'); - setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp'); - setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time'); - setIfEmpty('field-coverage', data.coverageSqFtPerLb,'Coverage'); - setIfEmpty('field-transfer', data.transferEfficiency,'Transfer Efficiency'); + setIfEmpty('field-description', data.description, 'Description'); + setIfEmpty('field-finish', data.finish, 'Finish'); + setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp'); + setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time'); + setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage'); + setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency'); if (data.unitPrice > 0) { const costEl = document.getElementById('field-unitcost'); @@ -317,8 +388,7 @@ function blobToBase64(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => { - // Strip the data URI prefix — server only wants the raw base64 + reader.onload = () => { const result = reader.result; const comma = result.indexOf(','); resolve(comma >= 0 ? result.slice(comma + 1) : result);