diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js
index e2df43c..2266b39 100644
--- a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js
+++ b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js
@@ -2,36 +2,49 @@
* In-browser powder label scanner for the Inventory Create/Edit forms.
*
* Flow:
- * 1. User clicks "Scan Label" → modal opens, rear camera starts.
- * 2. jsQR runs in a requestAnimationFrame loop scanning the live video.
+ * 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.
+ *
+ * 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 () {
'use strict';
- const SCAN_URL = '/Inventory/ScanLabel';
- const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
+ 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 scanBtn = document.getElementById('scan-label-btn');
- const statusEl = document.getElementById('ai-lookup-status');
+ const scanBtn = document.getElementById('scan-label-btn');
+ const statusEl = document.getElementById('ai-lookup-status');
if (!scanBtn) return;
- let stream = null; // MediaStream
- let rafId = null; // requestAnimationFrame handle
- let qrFound = false;
- let shutterTimer = null; // timer to reveal the fallback shutter button
+ // 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 shutterTimer = null;
+ let idleTimer = null;
- // ── Modal bootstrap ───────────────────────────────────────────────────
+ // ── 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 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');
@@ -39,66 +52,119 @@
if (!modalEl || !videoEl || !canvasEl) return;
scanBtn.addEventListener('click', openScanner);
- modalEl.addEventListener('hide.bs.modal', stopCamera);
+ modalEl.addEventListener('hide.bs.modal', onModalClose);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
+ window.addEventListener('beforeunload', releaseCamera);
// ── Open / close ──────────────────────────────────────────────────────
async function openScanner() {
if (!bsModal) return;
qrFound = false;
+ stopQrLoop();
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (shutterWrap) shutterWrap.classList.add('d-none');
- setScanStatus('info', 'Starting camera…');
bsModal.show();
- try {
- stream = await navigator.mediaDevices.getUserMedia({
- video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
- });
- videoEl.srcObject = stream;
- await videoEl.play();
- setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.');
- await loadJsQR();
- 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);
- } catch (err) {
- setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
- if (shutterWrap) shutterWrap.classList.remove('d-none'); // let them try vision path
+ 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 } }
+ });
+ } catch (err) {
+ setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
+ if (shutterWrap) shutterWrap.classList.remove('d-none');
+ return;
+ }
}
+
+ // 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() {
- if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
- if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; }
- if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
+ 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
+ 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;
}
- // ── jsQR lazy load ────────────────────────────────────────────────────
-
- 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 ──────────────────────────────────────────────────────
+ // ── QR scan — BarcodeDetector (native) with 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() {
+ 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');
function tick() {
if (!stream || qrFound) return;
-
- if (videoEl.readyState === videoEl.HAVE_ENOUGH_DATA) {
+ if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
canvasEl.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
@@ -106,27 +172,35 @@
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth'
});
-
if (code && code.data) {
qrFound = true;
handleQrResult(code.data);
return;
}
}
-
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
}
+ 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 ─────────────────────────────────────────────────
async function handleQrResult(url) {
- stopCamera();
+ stopQrLoop();
setScanStatus('info', 'QR code found — looking up product…');
setScanBtnLoading(true);
-
try {
const fd = new FormData();
fd.append('qrUrl', url);
@@ -141,19 +215,17 @@
async function captureFrame() {
setScanStatus('info', 'Reading label…');
setScanBtnLoading(true);
+ stopQrLoop();
- // If camera is running, grab the current frame; otherwise show file picker
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 maxW = 1024;
+ 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);
- stopCamera();
-
canvasEl.toBlob(async (blob) => {
try {
const base64 = await blobToBase64(blob);
@@ -167,7 +239,6 @@
}, 'image/jpeg', 0.88);
} else {
// No live camera (e.g. desktop) — fall back to file picker
- stopCamera();
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
@@ -230,9 +301,9 @@
}
}
- setIf('field-manufacturer', data.manufacturer, 'Manufacturer');
- setIf('field-partnumber', data.manufacturerPartNumber,'Part Number');
- setIf('field-colorname', data.colorName, 'Color Name');
+ setIf('field-manufacturer', data.manufacturer, 'Manufacturer');
+ 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');
@@ -241,12 +312,12 @@
filled.push('Name');
}
- setIfEmpty('field-description', data.description, 'Description');
- setIfEmpty('field-finish', data.finish, 'Finish');
- setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
- setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
- setIfEmpty('field-coverage', data.coverageSqFtPerLb,'Coverage');
- setIfEmpty('field-transfer', data.transferEfficiency,'Transfer Efficiency');
+ setIfEmpty('field-description', data.description, 'Description');
+ setIfEmpty('field-finish', data.finish, 'Finish');
+ setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
+ setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
+ setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
+ setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
if (data.unitPrice > 0) {
const costEl = document.getElementById('field-unitcost');
@@ -317,8 +388,7 @@
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
- reader.onload = () => {
- // Strip the data URI prefix — server only wants the raw base64
+ reader.onload = () => {
const result = reader.result;
const comma = result.indexOf(',');
resolve(comma >= 0 ? result.slice(comma + 1) : result);