Files
phone-barcode/app.mjs
mikael-lovqvists-claude-agent 2fbe6f4334 Camera selector, focus controls, and decode improvements
- Camera picker with auto-selection of main back camera (index 0)
- Manual/auto focus toggle button with focus distance slider
- Dual binarizer (Hybrid + GlobalHistogram) for blur tolerance
- Only vibrate on new unique scan result
- Band canvas to restrict decoding to aim line region
- Camera select overlaid in viewfinder corner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:53:20 +00:00

370 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_btn = document.getElementById('focus-auto-btn');
let focus_auto = true;
// 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 = false;
focus_auto_btn.disabled = true;
} else {
focus_auto_btn.disabled = false;
}
focus_auto_btn.classList.toggle('active', focus_auto);
focus_auto_btn.onclick = async () => {
focus_auto = !focus_auto;
focus_auto_btn.classList.toggle('active', focus_auto);
focus_slider.disabled = focus_auto;
await apply_focus(active_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;
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<br>strip:${strip_canvas.width}×${strip_canvas.height} band:${band_canvas.width}×${band_canvas.height} dpr:${dpr}<br>caps:${focus_caps_info}<br>focus:${focus_info} attempts:${decode_attempts} zxing:${zxing_status}<br>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.drawImage(strip_canvas, 0, band_y, strip_canvas.width, band_h, 0, 0, strip_canvas.width, band_h);
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 }) =>
`<option value="${d.deviceId}">${d.label || d.deviceId.slice(0, 12)}</option>`
).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();
}