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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user