Label scanner: fix QR detection, blank camera on processing, improve permission flow

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:05:41 -04:00
parent 97cf6dcbf0
commit cf36e41139
2 changed files with 98 additions and 81 deletions
@@ -36,6 +36,13 @@
</svg> </svg>
</div> </div>
<!-- Processing overlay: shown while the server lookup is running -->
<div id="scan-processing" style="display:none;position:absolute;inset:0;z-index:10;background:rgba(0,0,0,0.88);align-items:center;justify-content:center;flex-direction:column;color:#fff;text-align:center;padding:1.5rem;">
<div class="spinner-border text-light mb-3" style="width:2.5rem;height:2.5rem;"></div>
<div id="scan-processing-msg" class="fw-medium fs-6">Looking up product…</div>
<div class="text-white-50 small mt-1">This may take a few seconds</div>
</div>
<!-- Status inside the modal --> <!-- Status inside the modal -->
<div id="scan-modal-status" class="alert alert-info py-2 small mb-0 mt-2 d-none mx-2 mb-2" <div id="scan-modal-status" class="alert alert-info py-2 small mb-0 mt-2 d-none mx-2 mb-2"
style="position:absolute;bottom:0;left:0;right:0;margin:8px !important;"></div> style="position:absolute;bottom:0;left:0;right:0;margin:8px !important;"></div>
@@ -1,53 +1,49 @@
/** /**
* In-browser powder label scanner for the Inventory Create/Edit forms. * In-browser powder label scanner for the Inventory Create/Edit forms.
* *
* Flow: * QR scanning priority:
* 1. User clicks "Scan Label" → modal opens, rear camera starts (permission asked once * 1. Native BarcodeDetector API (Chrome/Edge/Android) — uses OS-level decoding,
* per page load — stream is kept alive between modal opens to avoid re-prompting). * all supported formats, canvas-snapshot approach for reliability.
* 2. QR scanning uses the native BarcodeDetector API when available (Chrome/Edge/Android), * 2. jsQR fallback for Safari/Firefox.
* 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: * Camera permission strategy:
* - getUserMedia is called only once per page session (on first open). * - A localStorage flag ('scannerCameraGranted') is set the first time getUserMedia
* - The stream is kept alive when the modal closes so subsequent opens are instant * succeeds. On subsequent page loads the flag triggers a proactive getUserMedia call
* and the browser does not re-prompt for camera access. * that succeeds silently when Chrome has the site at "Allow", bypassing any quirk
* - An idle timer (IDLE_RELEASE_MS) stops the stream after prolonged inactivity so * where navigator.permissions.query still returns 'prompt' for localhost.
* the camera indicator light doesn't stay on indefinitely. * - The MediaStream is kept alive between modal opens (only closed after 2 min idle
* - The stream is always released on page unload. * or page unload) so re-opening the scanner within a session never re-prompts.
*/ */
(function () { (function () {
'use strict'; 'use strict';
const SCAN_URL = '/Inventory/ScanLabel'; const SCAN_URL = '/Inventory/ScanLabel';
const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; 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 scanBtn = document.getElementById('scan-label-btn');
const statusEl = document.getElementById('ai-lookup-status'); const statusEl = document.getElementById('ai-lookup-status');
if (!scanBtn) return; if (!scanBtn) return;
// Stream is kept alive between modal opens; only released on idle timeout or page unload let stream = null;
let stream = null; let rafId = null;
let rafId = null; let qrFound = false;
let qrFound = false;
let shutterTimer = null; let shutterTimer = null;
let idleTimer = null; let idleTimer = null;
// ── Modal elements ──────────────────────────────────────────────────── // ── Modal elements ────────────────────────────────────────────────────
const modalEl = document.getElementById('labelScanModal'); const modalEl = document.getElementById('labelScanModal');
const bsModal = modalEl ? new bootstrap.Modal(modalEl) : null; const bsModal = modalEl ? new bootstrap.Modal(modalEl) : null;
const videoEl = document.getElementById('scan-video'); const videoEl = document.getElementById('scan-video');
const canvasEl = document.getElementById('scan-canvas'); const canvasEl = document.getElementById('scan-canvas');
const scanStatusEl = document.getElementById('scan-modal-status'); const scanStatusEl = document.getElementById('scan-modal-status');
const shutterBtn = document.getElementById('scan-shutter-btn'); const shutterBtn = document.getElementById('scan-shutter-btn');
const shutterWrap = document.getElementById('scan-shutter-wrap'); 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; if (!modalEl || !videoEl || !canvasEl) return;
@@ -56,8 +52,7 @@
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame); if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
window.addEventListener('beforeunload', releaseCamera); window.addEventListener('beforeunload', releaseCamera);
// If the user has already granted camera permission, silently pre-warm the stream // Pre-warm if we've ever gotten camera permission on this site
// so the next Scan Label click opens instantly without any browser prompt.
preWarmCamera(); preWarmCamera();
// ── Open / close ────────────────────────────────────────────────────── // ── Open / close ──────────────────────────────────────────────────────
@@ -66,17 +61,15 @@
if (!bsModal) return; if (!bsModal) return;
qrFound = false; qrFound = false;
stopQrLoop(); stopQrLoop();
hideProcessing();
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (shutterWrap) shutterWrap.classList.add('d-none'); if (shutterWrap) shutterWrap.classList.add('d-none');
bsModal.show(); bsModal.show();
if (!stream) { if (!stream) {
// First open (or after idle release) — request camera access
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…'); setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…');
try { try {
stream = await navigator.mediaDevices.getUserMedia({ stream = await startStream();
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
} catch (err) { } catch (err) {
setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message); setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
if (shutterWrap) shutterWrap.classList.remove('d-none'); 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.srcObject !== stream) { if (videoEl.paused) { try { await videoEl.play(); } catch { /* ignore */ } }
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.'); setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.');
startQrLoop(); startQrLoop();
// Reveal the shutter fallback after 5 s in case there is no QR
shutterTimer = setTimeout(() => { shutterTimer = setTimeout(() => {
if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none'); if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none');
}, 5000); }, 5000);
} }
function onModalClose() { function onModalClose() {
// Stop the scan loop but keep the stream alive to avoid re-prompting on next open
stopQrLoop(); stopQrLoop();
// Start idle timer — release camera entirely after 2 min of inactivity hideProcessing();
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
} }
function stopQrLoop() { function stopQrLoop() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; } if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; } if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; }
} }
function releaseCamera() { function releaseCamera() {
@@ -120,41 +106,71 @@
videoEl.srcObject = null; 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() { function startQrLoop() {
if (typeof BarcodeDetector !== 'undefined') { if (typeof BarcodeDetector !== 'undefined') {
startBarcodeDetectorLoop(); startBarcodeDetectorLoop();
} else { } else {
loadJsQR().then(startJsQrLoop).catch(() => { loadJsQR().then(startJsQrLoop).catch(() => {
// jsQR failed to load — just show shutter button immediately
if (shutterWrap) shutterWrap.classList.remove('d-none'); if (shutterWrap) shutterWrap.classList.remove('d-none');
}); });
} }
} }
// Native BarcodeDetector path (Chrome/Edge/Android) — more reliable than jsQR async function startBarcodeDetectorLoop() {
function startBarcodeDetectorLoop() {
let detector; let detector;
try { 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 { } catch {
// BarcodeDetector exists but qr_code format not supported — fall back
loadJsQR().then(startJsQrLoop); loadJsQR().then(startJsQrLoop);
return; return;
} }
const ctx = canvasEl.getContext('2d');
async function tick() { async function tick() {
if (!stream || qrFound) return; if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) {
try { 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) { if (codes.length > 0 && !qrFound) {
qrFound = true; qrFound = true;
handleQrResult(codes[0].rawValue); handleQrResult(codes[0].rawValue);
return; return;
} }
} catch { /* video not ready or detect failed — try next frame */ } } catch { /* frame not ready or detect failed — try next frame */ }
} }
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
} }
@@ -162,7 +178,6 @@
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
} }
// jsQR fallback path (Safari, Firefox, older browsers)
function startJsQrLoop() { function startJsQrLoop() {
const ctx = canvasEl.getContext('2d'); const ctx = canvasEl.getContext('2d');
@@ -188,24 +203,6 @@
rafId = requestAnimationFrame(tick); 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() { function loadJsQR() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (window.jsQR) { resolve(); return; } if (window.jsQR) { resolve(); return; }
@@ -221,7 +218,7 @@
async function handleQrResult(url) { async function handleQrResult(url) {
stopQrLoop(); stopQrLoop();
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>QR code found — looking up product…'); showProcessing('QR code found — looking up product…');
setScanBtnLoading(true); setScanBtnLoading(true);
try { try {
const fd = new FormData(); const fd = new FormData();
@@ -235,19 +232,19 @@
// ── Vision fallback: grab frame and POST ────────────────────────────── // ── Vision fallback: grab frame and POST ──────────────────────────────
async function captureFrame() { async function captureFrame() {
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Reading label…');
setScanBtnLoading(true);
stopQrLoop(); stopQrLoop();
setScanBtnLoading(true);
if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
const ctx = canvasEl.getContext('2d'); 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; const scale = videoEl.videoWidth > maxW ? maxW / videoEl.videoWidth : 1;
canvasEl.width = Math.round(videoEl.videoWidth * scale); canvasEl.width = Math.round(videoEl.videoWidth * scale);
canvasEl.height = Math.round(videoEl.videoHeight * scale); canvasEl.height = Math.round(videoEl.videoHeight * scale);
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height); ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
showProcessing('Reading label with AI…');
canvasEl.toBlob(async (blob) => { canvasEl.toBlob(async (blob) => {
try { try {
const base64 = await blobToBase64(blob); const base64 = await blobToBase64(blob);
@@ -260,13 +257,14 @@
} }
}, 'image/jpeg', 0.88); }, 'image/jpeg', 0.88);
} else { } 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'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'image/*'; input.accept = 'image/*';
input.onchange = async () => { input.onchange = async () => {
const file = input.files[0]; const file = input.files[0];
if (!file) { setScanBtnLoading(false); return; } if (!file) { setScanBtnLoading(false); return; }
showProcessing('Reading label with AI…');
try { try {
const base64 = await blobToBase64(file); const base64 = await blobToBase64(file);
const fd = new FormData(); const fd = new FormData();
@@ -290,6 +288,7 @@
const data = await resp.json(); const data = await resp.json();
if (!data.success) { if (!data.success) {
hideProcessing();
setScanStatus('danger', data.errorMessage || 'Scan failed.'); setScanStatus('danger', data.errorMessage || 'Scan failed.');
return; return;
} }
@@ -298,6 +297,7 @@
fillFromScan(data); fillFromScan(data);
} catch (err) { } catch (err) {
hideProcessing();
setScanStatus('danger', 'Scan failed: ' + err.message); setScanStatus('danger', 'Scan failed: ' + err.message);
} }
} }
@@ -327,7 +327,6 @@
setIf('field-partnumber', data.manufacturerPartNumber, 'Part Number'); setIf('field-partnumber', data.manufacturerPartNumber, 'Part Number');
setIf('field-colorname', data.colorName, 'Color Name'); setIf('field-colorname', data.colorName, 'Color Name');
// Name field: use color name if blank
const nameEl = document.getElementById('field-name'); const nameEl = document.getElementById('field-name');
if (nameEl && !nameEl.value.trim() && data.colorName) { if (nameEl && !nameEl.value.trim() && data.colorName) {
nameEl.value = data.colorName; nameEl.value = data.colorName;
@@ -407,6 +406,17 @@
// ── Helpers ─────────────────────────────────────────────────────────── // ── 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) { function blobToBase64(blob) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();