From cf36e41139502ced9faa013280487c5a806322e0 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 3 May 2026 19:05:41 -0400 Subject: [PATCH] Label scanner: fix QR detection, blank camera on processing, improve permission flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR scanning: - BarcodeDetector now snapshots to canvas before detect() instead of passing live video element — more reliable across Chrome versions - Uses BarcodeDetector.getSupportedFormats() to detect all formats the browser supports rather than hardcoding ['qr_code'], catching data_matrix etc. - jsQR fallback unchanged (attemptBoth inversion) Processing overlay: - Added #scan-processing overlay div to _LabelScanModal with spinner + message - Camera/scanning UI blanks immediately when QR is found or Scan Text tapped; overlay message differs per path ("QR code found..." vs "Reading label with AI...") - Overlay hides on error (modal stays open); modal close triggers hideProcessing() Camera permission: - localStorage flag (scannerCameraGranted) set on every successful getUserMedia - preWarmCamera() checks flag first, bypassing navigator.permissions.query which can return 'prompt' for localhost even when Chrome has 'Allow' internally; proactive getUserMedia on page load succeeds silently when permission is granted Co-Authored-By: Claude Sonnet 4.6 --- .../Views/Inventory/_LabelScanModal.cshtml | 7 + .../wwwroot/js/inventory-label-scan.js | 172 +++++++++--------- 2 files changed, 98 insertions(+), 81 deletions(-) diff --git a/src/PowderCoating.Web/Views/Inventory/_LabelScanModal.cshtml b/src/PowderCoating.Web/Views/Inventory/_LabelScanModal.cshtml index c85b3db..16ae126 100644 --- a/src/PowderCoating.Web/Views/Inventory/_LabelScanModal.cshtml +++ b/src/PowderCoating.Web/Views/Inventory/_LabelScanModal.cshtml @@ -36,6 +36,13 @@ + + +
diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js index 4e60903..a57e3c3 100644 --- a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js +++ b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js @@ -1,53 +1,49 @@ /** * In-browser powder label scanner for the Inventory Create/Edit forms. * - * Flow: - * 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. + * QR scanning priority: + * 1. Native BarcodeDetector API (Chrome/Edge/Android) — uses OS-level decoding, + * all supported formats, canvas-snapshot approach for reliability. + * 2. jsQR fallback for Safari/Firefox. * * 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. + * - A localStorage flag ('scannerCameraGranted') is set the first time getUserMedia + * succeeds. On subsequent page loads the flag triggers a proactive getUserMedia call + * that succeeds silently when Chrome has the site at "Allow", bypassing any quirk + * where navigator.permissions.query still returns 'prompt' for localhost. + * - The MediaStream is kept alive between modal opens (only closed after 2 min idle + * or page unload) so re-opening the scanner within a session never re-prompts. */ (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 IDLE_RELEASE_MS = 2 * 60 * 1000; // release camera after 2 min idle + const STORAGE_KEY = 'scannerCameraGranted'; + const IDLE_RELEASE_MS = 2 * 60 * 1000; const scanBtn = document.getElementById('scan-label-btn'); const statusEl = document.getElementById('ai-lookup-status'); if (!scanBtn) return; - // 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 stream = null; + let rafId = null; + let qrFound = false; let shutterTimer = null; let idleTimer = null; // ── 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 scanStatusEl = document.getElementById('scan-modal-status'); - const shutterBtn = document.getElementById('scan-shutter-btn'); - const shutterWrap = document.getElementById('scan-shutter-wrap'); + 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'); + const processingEl = document.getElementById('scan-processing'); + const processingMsgEl= document.getElementById('scan-processing-msg'); if (!modalEl || !videoEl || !canvasEl) return; @@ -56,8 +52,7 @@ if (shutterBtn) shutterBtn.addEventListener('click', captureFrame); window.addEventListener('beforeunload', releaseCamera); - // If the user has already granted camera permission, silently pre-warm the stream - // so the next Scan Label click opens instantly without any browser prompt. + // Pre-warm if we've ever gotten camera permission on this site preWarmCamera(); // ── Open / close ────────────────────────────────────────────────────── @@ -66,17 +61,15 @@ if (!bsModal) return; qrFound = false; stopQrLoop(); + hideProcessing(); if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (shutterWrap) shutterWrap.classList.add('d-none'); bsModal.show(); 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 } } - }); + stream = await startStream(); } catch (err) { setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message); if (shutterWrap) shutterWrap.classList.remove('d-none'); @@ -84,33 +77,26 @@ } } - // Attach (or re-attach) stream to video element - if (videoEl.srcObject !== stream) { - videoEl.srcObject = stream; - } - if (videoEl.paused) { - try { await videoEl.play(); } catch { /* ignore */ } - } + 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 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 + hideProcessing(); idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); } function stopQrLoop() { - if (rafId) { cancelAnimationFrame(rafId); rafId = null; } - if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; } + if (rafId) { cancelAnimationFrame(rafId); rafId = null; } + if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; } } function releaseCamera() { @@ -120,41 +106,71 @@ videoEl.srcObject = null; } - // ── QR scan — BarcodeDetector (native) with jsQR fallback ──────────── + async function startStream() { + const s = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } + }); + localStorage.setItem(STORAGE_KEY, '1'); // remember that user has granted before + return s; + } + + // ── Camera pre-warm ─────────────────────────────────────────────────── + + // On page load, if we've seen a grant before, call getUserMedia now. + // When Chrome has the site at "Allow" this succeeds silently — no prompt. + // If Chrome reverted to "Ask" the user sees the prompt on load (once) rather + // than when they tap Scan Label. If permission was revoked, we clear the flag. + async function preWarmCamera() { + if (!localStorage.getItem(STORAGE_KEY)) return; + try { + stream = await startStream(); + idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + + // ── QR scanning: BarcodeDetector (canvas) → 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() { + async function startBarcodeDetectorLoop() { let detector; try { - detector = new BarcodeDetector({ formats: ['qr_code'] }); + // Use every format the browser supports — we don't hardcode qr_code only + // in case the label uses data_matrix or another variant. + const supported = await BarcodeDetector.getSupportedFormats(); + detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] }); } catch { - // BarcodeDetector exists but qr_code format not supported — fall back loadJsQR().then(startJsQrLoop); return; } + const ctx = canvasEl.getContext('2d'); + async function tick() { if (!stream || qrFound) return; - if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { + if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) { try { - const codes = await detector.detect(videoEl); + // Snapshot to canvas — more reliable than detecting from the live video element + canvasEl.width = videoEl.videoWidth; + canvasEl.height = videoEl.videoHeight; + ctx.drawImage(videoEl, 0, 0); + const codes = await detector.detect(canvasEl); if (codes.length > 0 && !qrFound) { qrFound = true; handleQrResult(codes[0].rawValue); return; } - } catch { /* video not ready or detect failed — try next frame */ } + } catch { /* frame not ready or detect failed — try next frame */ } } rafId = requestAnimationFrame(tick); } @@ -162,7 +178,6 @@ rafId = requestAnimationFrame(tick); } - // jsQR fallback path (Safari, Firefox, older browsers) function startJsQrLoop() { const ctx = canvasEl.getContext('2d'); @@ -188,24 +203,6 @@ rafId = requestAnimationFrame(tick); } - // ── Camera pre-warm ─────────────────────────────────────────────────── - - // Check camera permission state without prompting. If already granted, start the stream - // silently on page load so Scan Label opens instantly with no browser prompt on this visit. - // The idle timer ensures the camera releases if the user never actually scans anything. - async function preWarmCamera() { - try { - if (!navigator.permissions) return; - const perm = await navigator.permissions.query({ name: 'camera' }); - if (perm.state !== 'granted') return; - stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } - }); - // Start idle timer — release if user never opens the scanner - idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); - } catch { /* permission denied or getUserMedia failed — ignore */ } - } - function loadJsQR() { return new Promise((resolve, reject) => { if (window.jsQR) { resolve(); return; } @@ -221,7 +218,7 @@ async function handleQrResult(url) { stopQrLoop(); - setScanStatus('info', 'QR code found — looking up product…'); + showProcessing('QR code found — looking up product…'); setScanBtnLoading(true); try { const fd = new FormData(); @@ -235,19 +232,19 @@ // ── Vision fallback: grab frame and POST ────────────────────────────── async function captureFrame() { - setScanStatus('info', 'Reading label…'); - setScanBtnLoading(true); stopQrLoop(); + setScanBtnLoading(true); 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 ctx = canvasEl.getContext('2d'); 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); + showProcessing('Reading label with AI…'); + canvasEl.toBlob(async (blob) => { try { const base64 = await blobToBase64(blob); @@ -260,13 +257,14 @@ } }, 'image/jpeg', 0.88); } else { - // No live camera (e.g. desktop) — fall back to file picker + // No live camera — fall back to file picker const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = async () => { const file = input.files[0]; if (!file) { setScanBtnLoading(false); return; } + showProcessing('Reading label with AI…'); try { const base64 = await blobToBase64(file); const fd = new FormData(); @@ -290,6 +288,7 @@ const data = await resp.json(); if (!data.success) { + hideProcessing(); setScanStatus('danger', data.errorMessage || 'Scan failed.'); return; } @@ -298,6 +297,7 @@ fillFromScan(data); } catch (err) { + hideProcessing(); setScanStatus('danger', 'Scan failed: ' + err.message); } } @@ -327,7 +327,6 @@ 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'); if (nameEl && !nameEl.value.trim() && data.colorName) { nameEl.value = data.colorName; @@ -407,6 +406,17 @@ // ── Helpers ─────────────────────────────────────────────────────────── + function showProcessing(msg) { + if (processingEl) { + if (processingMsgEl) processingMsgEl.textContent = msg; + processingEl.style.display = 'flex'; + } + } + + function hideProcessing() { + if (processingEl) processingEl.style.display = 'none'; + } + function blobToBase64(blob) { return new Promise((resolve, reject) => { const reader = new FileReader();