// app.mjs — fixed-line barcode scanner v1 const video = document.getElementById('video'); const overlay = document.getElementById('overlay'); const ctx = overlay.getContext('2d'); const result_el = document.getElementById('result'); const start_btn = document.getElementById('start-btn'); const debug_el = document.getElementById('debug'); const camera_select = document.getElementById('camera-select'); const cam_info = document.getElementById('cam-info'); const focus_row = document.getElementById('focus-row'); const focus_slider = document.getElementById('focus-slider'); const focus_val_el = document.getElementById('focus-val'); const focus_auto = document.getElementById('focus-auto'); // Canvases for ZXing decode pipeline const strip_canvas = document.createElement('canvas'); const strip_ctx = strip_canvas.getContext('2d', { willReadFrequently: true }); const band_canvas = document.createElement('canvas'); const band_ctx = band_canvas.getContext('2d', { willReadFrequently: true }); let running = false; let last_decode_ms = 0; const DECODE_INTERVAL_MS = 80; let last_frame_ts = 0; let fps = 0; let last_format = '—'; let decode_attempts = 0; let last_error = '—'; let focus_info = '—'; // Center band of the frame fed to ZXing (fraction of video height) const STRIP_START = 0.35; const STRIP_END = 0.65; let flash_until_ms = 0; // --- Camera --- let active_track = null; async function start_camera(device_id = null) { if (active_track) { active_track.stop(); } try { const video_constraints = device_id ? { deviceId: { exact: device_id }, width: { ideal: 256 }, height: { ideal: 2048 } } : { facingMode: { exact: 'environment' }, width: { ideal: 256 }, height: { ideal: 2048 } }; const stream = await navigator.mediaDevices.getUserMedia({ video: video_constraints, audio: false, }); video.srcObject = stream; await new Promise(res => { video.onloadedmetadata = res; }); await video.play(); const track = stream.getVideoTracks()[0]; active_track = track; const caps = track.getCapabilities(); await log_focus_caps(caps); const has_continuous = (caps.focusMode ?? []).includes('continuous'); const has_distance = !!caps.focusDistance; if (has_continuous || has_distance) { focus_row.style.display = 'flex'; } if (has_distance) { focus_slider.min = caps.focusDistance.min; focus_slider.max = caps.focusDistance.max; focus_slider.step = (caps.focusDistance.max - caps.focusDistance.min) / 100; focus_slider.value = 0.3; focus_slider.oninput = () => set_focus_distance(track, parseFloat(focus_slider.value)); } if (!has_continuous) { focus_auto.checked = false; focus_auto.disabled = true; } focus_auto.onchange = () => apply_focus(track, has_continuous, has_distance); await apply_focus(track, has_continuous, has_distance); // Tap to focus (replace listener on camera switch) overlay.onclick = e => tap_to_focus(e, track); start_btn.style.display = 'none'; running = true; request_frame(); } catch (err) { show_error(`Camera error: ${err.message}`); } } // --- Focus --- async function apply_focus(track, has_continuous, has_distance) { const auto = focus_auto.checked; focus_slider.disabled = auto; try { if (auto && has_continuous) { await track.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }); focus_info = 'continuous'; } else if (has_distance) { await set_focus_distance(track, parseFloat(focus_slider.value)); } } catch (err) { focus_info = `focus failed: ${err.message}`; } } async function set_focus_distance(track, dist) { focus_val_el.textContent = `${dist.toFixed(2)}m`; try { await track.applyConstraints({ advanced: [{ focusMode: 'manual', focusDistance: dist }] }); focus_info = `manual ${dist.toFixed(2)}m`; } catch (err) { focus_info = `dist failed: ${err.message}`; } } async function tap_to_focus(e, track) { const rect = overlay.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = (e.clientY - rect.top) / rect.height; try { await track.applyConstraints({ advanced: [{ pointsOfInterest: [{ x, y }] }], }); focus_info = `poi ${x.toFixed(2)},${y.toFixed(2)}`; } catch (err) { focus_info = `poi failed: ${err.message}`; } } let focus_caps_info = '—'; async function log_focus_caps(caps) { const parts = []; if (caps.focusMode) { parts.push(`modes:${caps.focusMode.join(',')}`); } if (caps.focusDistance) { parts.push(`dist:${caps.focusDistance.min}-${caps.focusDistance.max}`); } focus_caps_info = parts.join(' ') || 'none'; focus_info = focus_caps_info; } // --- Render loop --- function request_frame() { requestAnimationFrame(frame); } function frame(ts) { if (!running) { return; } const dpr = window.devicePixelRatio || 1; const dW = overlay.clientWidth; const dH = overlay.clientHeight; const pW = Math.round(dW * dpr); const pH = Math.round(dH * dpr); if (overlay.width !== pW || overlay.height !== pH) { overlay.width = pW; overlay.height = pH; ctx.scale(dpr, dpr); } fps = last_frame_ts ? Math.round(1000 / (ts - last_frame_ts)) : 0; last_frame_ts = ts; ctx.clearRect(0, 0, dW, dH); const glowing = ts < flash_until_ms; draw_scan_lines(dW, dH, glowing); if (ts - last_decode_ms > DECODE_INTERVAL_MS) { last_decode_ms = ts; attempt_decode(); } const vW = video.videoWidth; const vH = video.videoHeight; const zxing_status = window.ZXing ? 'ok' : 'MISSING'; debug_el.innerHTML = `vid:${vW}×${vH} overlay:${overlay.width}×${overlay.height} ${fps}fps
strip:${strip_canvas.width}×${strip_canvas.height} band:${band_canvas.width}×${band_canvas.height} dpr:${dpr}
caps:${focus_caps_info}
focus:${focus_info} attempts:${decode_attempts} zxing:${zxing_status}
err:${last_error} last:${last_format}`; request_frame(); } // --- Scan line drawing --- function draw_scan_lines(dW, dH, glowing) { const h_margin = Math.round(dW * 0.04); const cy = Math.round(dH * 0.5); const color_main = glowing ? '#00ff88' : 'rgba(255,80,80,0.9)'; const color_band = glowing ? 'rgba(0,255,136,0.08)' : 'rgba(255,80,80,0.06)'; // Shaded band showing the decode strip ctx.fillStyle = color_band; ctx.fillRect(0, dH * STRIP_START, dW, dH * (STRIP_END - STRIP_START)); // Center scan line ctx.shadowColor = glowing ? '#00ff88' : 'transparent'; ctx.shadowBlur = glowing ? 10 : 0; ctx.strokeStyle = color_main; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(h_margin, cy); ctx.lineTo(dW - h_margin, cy); ctx.stroke(); // End caps ctx.shadowBlur = 0; ctx.strokeStyle = glowing ? 'rgba(0,255,136,0.8)' : 'rgba(255,80,80,0.7)'; ctx.lineWidth = 2; const cap = 10; for (const x of [h_margin, dW - h_margin]) { ctx.beginPath(); ctx.moveTo(x, cy - cap); ctx.lineTo(x, cy + cap); ctx.stroke(); } } // --- ZXing decode --- let zxing_reader = null; function get_reader() { if (!window.ZXing) { return null; } if (!zxing_reader) { zxing_reader = new ZXing.MultiFormatReader(); } return zxing_reader; } function attempt_decode() { const reader = get_reader(); const vW = video.videoWidth; const vH = video.videoHeight; if (!reader || !vW || !vH) { return; } // Full frame into decoder — rotate if camera swapped dims to portrait const landscape = vW >= vH; const dst_w = landscape ? vW : vH; const dst_h = landscape ? vH : vW; if (strip_canvas.width !== dst_w || strip_canvas.height !== dst_h) { strip_canvas.width = dst_w; strip_canvas.height = dst_h; } if (landscape) { strip_ctx.drawImage(video, 0, 0, vW, vH); } else { strip_ctx.save(); strip_ctx.translate(dst_w / 2, dst_h / 2); strip_ctx.rotate(-Math.PI / 2); strip_ctx.drawImage(video, -vW / 2, -vH / 2, vW, vH); strip_ctx.restore(); } // Crop to a thin band around the center row — only decode what's on the aim line const band_h = Math.max(32, Math.round(strip_canvas.height * 0.15)); const band_y = Math.round((strip_canvas.height - band_h) / 2); if (band_canvas.width !== strip_canvas.width || band_canvas.height !== band_h) { band_canvas.width = strip_canvas.width; band_canvas.height = band_h; } band_ctx.filter = 'contrast(1.8) brightness(1.05)'; band_ctx.drawImage(strip_canvas, 0, band_y, strip_canvas.width, band_h, 0, 0, strip_canvas.width, band_h); band_ctx.filter = 'none'; decode_attempts++; const source = new ZXing.HTMLCanvasElementLuminanceSource(band_canvas); const binarizers = [ZXing.HybridBinarizer, ZXing.GlobalHistogramBinarizer]; let decoded = false; for (const Binarizer of binarizers) { try { const bitmap = new ZXing.BinaryBitmap(new Binarizer(source)); const result = reader.decode(bitmap); on_success(result.getText(), result.getBarcodeFormat()); decoded = true; break; } catch (e) { last_error = e?.name ?? String(e); } } } let last_decoded_text = null; function on_success(text, format) { flash_until_ms = performance.now() + 1200; last_format = String(format); result_el.textContent = `${text} [${format}]`; result_el.classList.add('found'); result_el.classList.remove('error'); if (text !== last_decoded_text) { navigator.vibrate?.(60); last_decoded_text = text; } } function show_error(msg) { result_el.textContent = msg; result_el.classList.add('error'); result_el.classList.remove('found'); } // --- Init --- async function init() { await start_camera(); const devices = await navigator.mediaDevices.enumerateDevices(); const cameras = devices.filter(d => d.kind === 'videoinput'); const scored = cameras.map(d => { const label = d.label.toLowerCase(); let score = 0; // Strongly prefer back camera index 0 if (label.match(/\b0\b.*facing back|camera2 0/)) { score += 100; } else if (label.includes('facing back') || label.includes('back')) { score += 10; } // Penalise specialised lenses if (label.includes('tele')) { score -= 50; } if (label.includes('ultra')) { score -= 20; } return { d, score }; }); scored.sort((a, b) => b.score - a.score); cam_info.textContent = scored.map(({ d }) => d.label || 'no-label').join(' | '); camera_select.innerHTML = scored.map(({ d }) => `` ).join(''); camera_select.style.display = 'block'; camera_select.onchange = () => start_camera(camera_select.value); const best_id = scored[0].d.deviceId; if (best_id !== active_track?.getSettings().deviceId) { camera_select.value = best_id; await start_camera(best_id); } } start_btn.addEventListener('click', init); if ( location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1' ) { init(); }