commit b98534214f08ed0d5045a7fdb2bb448f67c78440 Author: mikael-lovqvists-claude-agent Date: Sat Mar 21 23:41:23 2026 +0000 Initial barcode scanner webapp Camera-based barcode scanner using ZXing for decoding. Requests wide video (256x2048), rotates to landscape for decoding, applies contrast boost and tries both HybridBinarizer and GlobalHistogramBinarizer. Includes camera selector, manual/auto focus controls, and focus slider. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..059642a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +vendor/ +dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2206703 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +DEST ?= dist + +SRC = index.html app.mjs + +.PHONY: all build deploy clean + +all: build + +node_modules/.package-lock.json: package.json + npm install + @touch $@ + +vendor/zxing.min.js: node_modules/.package-lock.json + @mkdir -p vendor + cp node_modules/@zxing/library/umd/index.min.js vendor/zxing.min.js + +build: vendor/zxing.min.js + +deploy: build + @mkdir -p $(DEST)/vendor + cp $(SRC) $(DEST)/ + cp vendor/zxing.min.js $(DEST)/vendor/ + +clean: + rm -rf vendor dist diff --git a/app.mjs b/app.mjs new file mode 100644 index 0000000..7bce66b --- /dev/null +++ b/app.mjs @@ -0,0 +1,364 @@ +// 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(); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..d560439 --- /dev/null +++ b/index.html @@ -0,0 +1,170 @@ + + + + + + Barcode Scanner + + + +
+
+ + +
+
+
+
+
Point camera at a barcode
+
+ + + 0.30m +
+ + +
+
+ + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..77ba6a7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,46 @@ +{ + "name": "phone-barcode", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phone-barcode", + "version": "1.0.0", + "dependencies": { + "@zxing/library": "0.19.2" + } + }, + "node_modules/@zxing/library": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.19.2.tgz", + "integrity": "sha512-0aCQIbGXsDHcBuRYuZRZfJeLRYiPfdLxIlh5bY7k+k2bkxFwzla+75VyFi1rnjU5hqaZogdSH+Dw013YDsQOjg==", + "license": "MIT", + "dependencies": { + "ts-custom-error": "^3.0.0" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a45745 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "phone-barcode", + "version": "1.0.0", + "dependencies": { + "@zxing/library": "0.19.2" + } +}