feat: scan page redesign — fixed 2x zoom, torch, camera flip, tabs, recents, AI number OCR

- Always-on 2x hardware zoom (CSS scale fallback)
- Torch button with toggle + visual feedback
- Camera flip (front/back) with settings persistence
- 3-tab input panel: Barcode / Name / AI
- Recent products chips (last 6 scanned, from localStorage)
- Live barcode code overlay during partial detection
- Confirm overlay (checkmark + name) on successful scan
- AI number OCR (Gemini reads barcode digits from image, shown after 4s)
- Guide corners frame in viewport
- PHP: gemini_number_ocr action + rate-limited
- Translations: new scan.* keys in it/en/de
This commit is contained in:
dadaloop82
2026-05-12 14:55:14 +00:00
parent 27ba41700f
commit 696a9c6d11
7 changed files with 613 additions and 157 deletions
+199 -28
View File
@@ -1774,28 +1774,176 @@ let currentLocation = '';
let scannerStream = null;
let quaggaRunning = false;
let aiStream = null;
let _scanZoomLevel = 1; // 1 or 2
let _scanZoomLevel = 2; // always 2x
let _torchActive = false;
async function toggleScanZoom() {
_scanZoomLevel = _scanZoomLevel === 1 ? 2 : 1;
const btn = document.getElementById('scan-zoom-btn');
if (btn) btn.textContent = `x${_scanZoomLevel}`;
if (scannerStream) {
const track = scannerStream.getVideoTracks()[0];
if (track) {
const caps = track.getCapabilities ? track.getCapabilities() : {};
if (caps.zoom) {
// Hardware zoom (Android Chrome)
const z = _scanZoomLevel === 2
? Math.min(caps.zoom.max, caps.zoom.min * 2 || 2)
: caps.zoom.min;
try { await track.applyConstraints({ advanced: [{ zoom: z }] }); } catch(e) {}
} else {
// Software zoom via CSS scale on the video element
const video = document.getElementById('scanner-video');
if (video) video.style.transform = _scanZoomLevel === 2 ? 'scale(2)' : 'scale(1)';
// Apply fixed 2x zoom (hardware if available, CSS fallback)
async function _applyFixedZoom() {
if (!scannerStream) return;
const track = scannerStream.getVideoTracks()[0];
if (!track) return;
const caps = track.getCapabilities ? track.getCapabilities() : {};
if (caps.zoom && caps.zoom.max >= 2) {
const z = Math.min(caps.zoom.max, caps.zoom.min * 2);
try { await track.applyConstraints({ advanced: [{ zoom: z }] }); scanLog(`HW zoom: ${z}`); } catch(e) {}
} else {
const video = document.getElementById('scanner-video');
if (video) video.style.transform = 'scale(2)';
scanLog('SW zoom: scale(2)');
}
}
async function toggleTorch() {
if (!scannerStream) return;
const track = scannerStream.getVideoTracks()[0];
if (!track) return;
const caps = track.getCapabilities ? track.getCapabilities() : {};
if (!caps.torch) { showToast(t('scan.torch_unavailable'), 'info'); return; }
_torchActive = !_torchActive;
try {
await track.applyConstraints({ advanced: [{ torch: _torchActive }] });
const btn = document.getElementById('scan-torch-btn');
if (btn) btn.classList.toggle('torch-on', _torchActive);
showToast(_torchActive ? t('scan.torch_on') : t('scan.torch_off'), 'info');
} catch(e) { showToast(t('scan.torch_unavailable'), 'info'); _torchActive = false; }
}
async function flipCamera() {
const s = getSettings();
const current = s.camera_facing || 'environment';
const next = current === 'environment' ? 'user' : 'environment';
s.camera_facing = next;
try { localStorage.setItem('evershelf_settings', JSON.stringify(s)); } catch(_) {}
showToast(next === 'user' ? t('scan.flip_front') : t('scan.flip_back'), 'info');
stopScanner();
setTimeout(() => initScanner(), 150);
}
// ===== SCAN TAB SWITCHING =====
function switchScanTab(tab) {
['barcode','name','ai'].forEach(id => {
const btn = document.getElementById(`scan-tab-${id}`);
const content = document.getElementById(`scan-tabcontent-${id}`);
const active = id === tab;
if (btn) btn.classList.toggle('active', active);
if (content) content.style.display = active ? '' : 'none';
});
// Focus input on tab switch
if (tab === 'barcode') {
const el = document.getElementById('manual-barcode-input');
if (el) setTimeout(() => el.focus(), 80);
} else if (tab === 'name') {
const el = document.getElementById('quick-product-name');
if (el) setTimeout(() => el.focus(), 80);
}
}
// ===== SCAN RECENTS (localStorage) =====
const _SCAN_RECENTS_KEY = 'evershelf_scan_recents';
const _SCAN_RECENTS_MAX = 6;
function _getScanRecents() {
try { return JSON.parse(localStorage.getItem(_SCAN_RECENTS_KEY) || '[]'); } catch(_) { return []; }
}
function addToScanRecents(product) {
if (!product || !product.id) return;
let list = _getScanRecents().filter(r => r.id !== product.id);
list.unshift({ id: product.id, name: product.name, brand: product.brand || '', category: product.category || '' });
if (list.length > _SCAN_RECENTS_MAX) list = list.slice(0, _SCAN_RECENTS_MAX);
try { localStorage.setItem(_SCAN_RECENTS_KEY, JSON.stringify(list)); } catch(_) {}
}
function updateScanRecents() {
const list = _getScanRecents();
const wrap = document.getElementById('scan-recents');
const chips = document.getElementById('scan-recents-chips');
if (!wrap || !chips) return;
if (list.length === 0) { wrap.style.display = 'none'; return; }
wrap.style.display = 'flex';
chips.innerHTML = list.map(r => {
const icon = CATEGORY_ICONS[mapToLocalCategory(r.category, r.name)] || '📦';
const label = escapeHtml(r.name) + (r.brand ? ` <span style="color:var(--text-muted);font-weight:400">${escapeHtml(r.brand)}</span>` : '');
return `<button class="scan-recent-chip" onclick="_selectRecentProduct(${r.id})" title="${escapeHtml(r.name)}">
<span class="scan-recent-chip-icon">${icon}</span>${label}
</button>`;
}).join('');
}
async function _selectRecentProduct(productId) {
showLoading(true);
try {
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
if (!currentProduct.weight_info && currentProduct.notes) {
const m = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (m) currentProduct.weight_info = m[1].trim();
}
showLoading(false);
stopScanner();
showProductAction();
} else {
showLoading(false);
showToast(t('error.not_found'), 'error');
}
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// ===== SCAN LIVE CODE / CONFIRM OVERLAY =====
let _liveCodeTimer = null;
function _showScanLiveCode(code) {
const el = document.getElementById('scan-live-code');
if (!el) return;
el.textContent = code;
el.style.display = 'block';
clearTimeout(_liveCodeTimer);
_liveCodeTimer = setTimeout(() => { if (el) el.style.display = 'none'; }, 1500);
}
function _hideScanLiveCode() {
const el = document.getElementById('scan-live-code');
if (el) { el.style.display = 'none'; clearTimeout(_liveCodeTimer); }
}
function _showScanConfirm(name) {
const overlay = document.getElementById('scan-confirm-overlay');
const nameEl = document.getElementById('scan-confirm-name');
if (!overlay) return;
if (nameEl) nameEl.textContent = name || '';
overlay.style.display = 'flex';
setTimeout(() => { if (overlay) overlay.style.display = 'none'; }, 900);
}
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
let _numOcrRunning = false;
async function _tryGeminiNumberOCR() {
if (_numOcrRunning || !_requireGemini()) return;
const video = document.getElementById('scanner-video');
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
_numOcrRunning = true;
const btn = document.getElementById('scan-num-ocr-btn');
if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); }
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
if (result.barcode) {
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
onBarcodeDetected(result.barcode);
} else {
showToast(t('scan.num_ocr_not_found'), 'warning');
}
} catch(e) {
showToast(t('error.connection'), 'error');
} finally {
_numOcrRunning = false;
if (btn) { btn.disabled = false; btn.textContent = t('scan.num_ocr_btn'); }
}
}
@@ -2594,7 +2742,7 @@ function showPage(pageId, param = null) {
}
loadInventory();
break;
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner();
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
// Pre-warm the embedding model the first time user visits scan page
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
window._getCategoryPipeline(); // fire-and-forget
@@ -4942,12 +5090,25 @@ async function initScanner() {
video.srcObject = stream;
await video.play();
scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`);
// Apply fixed 2x zoom
await _applyFixedZoom();
if (_useBarcodeDetector) {
startNativeScanner(video);
} else {
startQuaggaScanner(video);
}
// After 4s without a scan, reveal the AI number OCR fallback button
if (_geminiAvailable) {
setTimeout(() => {
if (scannerStream) { // still scanning
const btn = document.getElementById('scan-num-ocr-btn');
if (btn) btn.style.display = '';
}
}, 4000);
}
} catch (err) {
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
@@ -5024,6 +5185,7 @@ async function startNativeScanner(videoEl) {
partialCount++;
scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`);
updateFeedback('detecting');
_showScanLiveCode(code);
if (!detectionHistory[code]) detectionHistory[code] = { count: 0 };
detectionHistory[code].count++;
@@ -5191,9 +5353,11 @@ function startQuaggaScanner(videoEl) {
quaggaRunning = false;
updateScannerFeedback(null);
scanLog(`CONFIRMED: ${code} [${passName2}] f${frameCount} consec:${detectCount} total:${dominated.count}`);
_hideScanLiveCode();
onBarcodeDetected(code);
return;
}
_showScanLiveCode(code);
} else {
updateScannerFeedback('scanning');
}
@@ -5235,16 +5399,19 @@ function enhanceCanvasForBarcode(ctx, w, h) {
function stopScanner() {
quaggaRunning = false;
_scanZoomLevel = 1;
_scanZoomLevel = 2; // always 2x on next start
_torchActive = false;
if (scannerStream) {
scannerStream.getTracks().forEach(t => t.stop());
scannerStream = null;
}
const video = document.getElementById('scanner-video');
if (video) video.srcObject = null;
const zoomBtn = document.getElementById('scan-zoom-btn');
if (zoomBtn) zoomBtn.textContent = 'x1';
if (video) { video.srcObject = null; video.style.transform = ''; }
// Reset torch button
const tb = document.getElementById('scan-torch-btn');
if (tb) tb.classList.remove('torch-on');
// Hide live code
_hideScanLiveCode();
// Also stop AI camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
@@ -5304,8 +5471,10 @@ async function onBarcodeDetected(barcode) {
if (detected.confCount) currentProduct._confCount = detected.confCount;
}
showLoading(false);
addToScanRecents(currentProduct);
_showScanConfirm(currentProduct.name);
stopScanner();
showProductAction();
setTimeout(() => showProductAction(), 300);
return;
}
@@ -5362,8 +5531,10 @@ async function onBarcodeDetected(barcode) {
stores: p.stores || '',
};
showLoading(false);
addToScanRecents(currentProduct);
_showScanConfirm(currentProduct.name);
stopScanner();
showProductAction();
setTimeout(() => showProductAction(), 300);
return;
}
}