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>
</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 -->
<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>
@@ -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', '<span class="spinner-border spinner-border-sm me-1"></span>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', '<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);
try {
const fd = new FormData();
@@ -235,19 +232,19 @@
// ── Vision fallback: grab frame and POST ──────────────────────────────
async function captureFrame() {
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>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();