Compare commits

...

2 Commits

Author SHA1 Message Date
spouliot 97cf6dcbf0 Pre-warm camera stream on page load if permission already granted
Uses Permissions API (non-prompting) to check camera state on load.
If state === 'granted', silently starts the stream so Scan Label opens
instantly with no browser prompt on subsequent page visits. Falls back
gracefully when Permissions API is unavailable or permission is 'prompt'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:48:37 -04:00
spouliot 4b65572f6f Label scanner: native BarcodeDetector + keep stream alive between opens
- Use BarcodeDetector API (Chrome/Edge/Android) as primary QR scanner; it uses
  native OS-level decoding which is far more reliable than jsQR for Prismatic's
  QR codes. Falls back to jsQR (attemptBoth) on Safari/Firefox.
- Keep MediaStream alive between modal opens so the browser does not re-prompt
  for camera permission on each scan within the same page session. Stream is
  released after 2 min of idle (IDLE_RELEASE_MS) or on page unload.
- stopCamera() split into stopQrLoop() (cancel rAF only) and releaseCamera()
  (stop tracks + null srcObject); modal hide now calls stopQrLoop, not releaseCamera.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:45:25 -04:00
@@ -2,36 +2,49 @@
* In-browser powder label scanner for the Inventory Create/Edit forms. * In-browser powder label scanner for the Inventory Create/Edit forms.
* *
* Flow: * Flow:
* 1. User clicks "Scan Label" → modal opens, rear camera starts. * 1. User clicks "Scan Label" → modal opens, rear camera starts (permission asked once
* 2. jsQR runs in a requestAnimationFrame loop scanning the live video. * 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. * - 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. * 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. * - 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. * 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. * 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 () { (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 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;
let stream = null; // MediaStream // Stream is kept alive between modal opens; only released on idle timeout or page unload
let rafId = null; // requestAnimationFrame handle let stream = null;
let qrFound = false; let rafId = null;
let shutterTimer = null; // timer to reveal the fallback shutter button let qrFound = false;
let shutterTimer = null;
let idleTimer = null;
// ── Modal bootstrap ─────────────────────────────────────────────────── // ── 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');
@@ -39,66 +52,123 @@
if (!modalEl || !videoEl || !canvasEl) return; if (!modalEl || !videoEl || !canvasEl) return;
scanBtn.addEventListener('click', openScanner); scanBtn.addEventListener('click', openScanner);
modalEl.addEventListener('hide.bs.modal', stopCamera); modalEl.addEventListener('hide.bs.modal', onModalClose);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame); 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.
preWarmCamera();
// ── Open / close ────────────────────────────────────────────────────── // ── Open / close ──────────────────────────────────────────────────────
async function openScanner() { async function openScanner() {
if (!bsModal) return; if (!bsModal) return;
qrFound = false; qrFound = false;
stopQrLoop();
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (shutterWrap) shutterWrap.classList.add('d-none'); if (shutterWrap) shutterWrap.classList.add('d-none');
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…');
bsModal.show(); bsModal.show();
try { if (!stream) {
stream = await navigator.mediaDevices.getUserMedia({ // First open (or after idle release) — request camera access
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…');
}); try {
videoEl.srcObject = stream; stream = await navigator.mediaDevices.getUserMedia({
await videoEl.play(); video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.'); });
await loadJsQR(); } catch (err) {
startQrLoop(); setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
// Reveal the shutter fallback after 5 s in case there is no QR if (shutterWrap) shutterWrap.classList.remove('d-none');
shutterTimer = setTimeout(() => { return;
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
} }
// 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() { function onModalClose() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; } // Stop the scan loop but keep the stream alive to avoid re-prompting on next open
if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; } stopQrLoop();
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } // 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; videoEl.srcObject = null;
} }
// ── jsQR lazy load ──────────────────────────────────────────────────── // ── QR scan — BarcodeDetector (native) with jsQR fallback ────────────
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 ──────────────────────────────────────────────────────
function startQrLoop() { 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'); const ctx = canvasEl.getContext('2d');
function tick() { function tick() {
if (!stream || qrFound) return; if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
if (videoEl.readyState === videoEl.HAVE_ENOUGH_DATA) {
canvasEl.width = videoEl.videoWidth; canvasEl.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight; canvasEl.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0); ctx.drawImage(videoEl, 0, 0);
@@ -106,27 +176,53 @@
const code = window.jsQR(imageData.data, imageData.width, imageData.height, { const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth' inversionAttempts: 'attemptBoth'
}); });
if (code && code.data) { if (code && code.data) {
qrFound = true; qrFound = true;
handleQrResult(code.data); handleQrResult(code.data);
return; return;
} }
} }
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
} }
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() {
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 ───────────────────────────────────────────────── // ── QR result handler ─────────────────────────────────────────────────
async function handleQrResult(url) { async function handleQrResult(url) {
stopCamera(); stopQrLoop();
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>QR code found — looking up product…'); setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>QR code found — looking up product…');
setScanBtnLoading(true); setScanBtnLoading(true);
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('qrUrl', url); fd.append('qrUrl', url);
@@ -141,19 +237,17 @@
async function captureFrame() { async function captureFrame() {
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Reading label…'); setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Reading label…');
setScanBtnLoading(true); setScanBtnLoading(true);
stopQrLoop();
// If camera is running, grab the current frame; otherwise show file picker
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 // 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);
stopCamera();
canvasEl.toBlob(async (blob) => { canvasEl.toBlob(async (blob) => {
try { try {
const base64 = await blobToBase64(blob); const base64 = await blobToBase64(blob);
@@ -167,7 +261,6 @@
}, 'image/jpeg', 0.88); }, 'image/jpeg', 0.88);
} else { } else {
// No live camera (e.g. desktop) — fall back to file picker // No live camera (e.g. desktop) — fall back to file picker
stopCamera();
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'image/*'; input.accept = 'image/*';
@@ -230,9 +323,9 @@
} }
} }
setIf('field-manufacturer', data.manufacturer, 'Manufacturer'); setIf('field-manufacturer', data.manufacturer, 'Manufacturer');
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 // Name field: use color name if blank
const nameEl = document.getElementById('field-name'); const nameEl = document.getElementById('field-name');
@@ -241,12 +334,12 @@
filled.push('Name'); filled.push('Name');
} }
setIfEmpty('field-description', data.description, 'Description'); setIfEmpty('field-description', data.description, 'Description');
setIfEmpty('field-finish', data.finish, 'Finish'); setIfEmpty('field-finish', data.finish, 'Finish');
setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp'); setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time'); setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
setIfEmpty('field-coverage', data.coverageSqFtPerLb,'Coverage'); setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
setIfEmpty('field-transfer', data.transferEfficiency,'Transfer Efficiency'); setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
if (data.unitPrice > 0) { if (data.unitPrice > 0) {
const costEl = document.getElementById('field-unitcost'); const costEl = document.getElementById('field-unitcost');
@@ -317,8 +410,7 @@
function blobToBase64(blob) { function blobToBase64(blob) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
// Strip the data URI prefix — server only wants the raw base64
const result = reader.result; const result = reader.result;
const comma = result.indexOf(','); const comma = result.indexOf(',');
resolve(comma >= 0 ? result.slice(comma + 1) : result); resolve(comma >= 0 ? result.slice(comma + 1) : result);