// 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();
}