// apps/<app>/src/components/BarcodeScanner.tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import Webcam from 'react-webcam';
import 'barcode-detector/pure';
// The polyfill registers BarcodeDetector on window; focusMode isn't in lib.dom yet.
declare global {
class BarcodeDetector {
constructor(options?: { formats: string[] });
static getSupportedFormats(): Promise<string[]>;
detect(image: ImageBitmapSource): Promise<{ rawValue: string; format: string }[]>;
}
interface MediaTrackConstraintSet {
focusMode?: 'none' | 'manual' | 'single-shot' | 'continuous';
}
}
// Mod-10 check digit catches most single-frame misreads on 1D retail barcodes.
const isValidEAN = (v: string, len: 8 | 13): boolean => {
if (v.length !== len) return false;
const nums = Array.from(v, c => c.charCodeAt(0) - 48);
if (nums.some(n => n < 0 || n > 9)) return false;
const check = nums.pop() as number;
const weights = len === 13 ? [1, 3] : [3, 1];
const sum = nums.reduce((acc, d, i) => acc + d * weights[i % 2], 0);
return (10 - (sum % 10)) % 10 === check;
};
const isValidBarcode = (v: string, fmt: string): boolean =>
fmt === 'ean_13' ? isValidEAN(v, 13)
: fmt === 'ean_8' ? isValidEAN(v, 8)
: fmt === 'upc_a' ? isValidEAN('0' + v, 13)
: true; // qr_code / code_128 / code_39 / upc_e validate internally
type Props = {
onScan: (value: string, format: string) => void;
formats?: string[];
confirmations?: number; // consecutive frames that must agree; default 2
};
export default function BarcodeScanner({
onScan,
formats = ['qr_code', 'ean_13', 'code_128'],
confirmations = 2,
}: Props) {
const webcamRef = useRef<Webcam>(null);
const detectorRef = useRef<BarcodeDetector>();
const [error, setError] = useState<string>();
// Keep the latest callback/threshold in refs so the loop (mounted once) isn't
// rebuilt on every parent render.
const onScanRef = useRef(onScan);
const confirmationsRef = useRef(confirmations);
useEffect(() => { onScanRef.current = onScan; confirmationsRef.current = confirmations; });
const formatsKey = formats.join(',');
useEffect(() => {
BarcodeDetector.getSupportedFormats().then(supported => {
const toUse = formats.filter(f => supported.includes(f));
if (!toUse.length) { setError(`No requested formats supported: ${formatsKey}`); return; }
setError(undefined);
detectorRef.current = new BarcodeDetector({ formats: toUse });
}).catch(() => setError('Barcode detection not supported in this browser'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formatsKey]);
// Continuous autofocus keeps a close barcode sharp on mobile.
const handleUserMedia = useCallback((stream: MediaStream) => {
stream.getVideoTracks()[0]
?.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }).catch(() => {});
}, []);
// One RAF detection loop per mount; all state is closure-local.
useEffect(() => {
let stopped = false, busy = false, lastValue: string | undefined, matchCount = 0, rafId = 0;
const tick = async () => {
const video = webcamRef.current?.video;
// Gate on videoWidth — Chrome throws "Invalid element or state" before the first frame.
if (video && detectorRef.current && !busy && video.readyState >= 2 && video.videoWidth > 0) {
busy = true;
try {
const [hit] = await detectorRef.current.detect(video); // pass <video> directly
if (stopped) return;
if (hit && isValidBarcode(hit.rawValue, hit.format)) {
if (hit.rawValue === lastValue) matchCount += 1;
else { lastValue = hit.rawValue; matchCount = 1; }
if (matchCount >= confirmationsRef.current) {
stopped = true;
navigator.vibrate?.(50);
onScanRef.current(hit.rawValue, hit.format);
return;
}
} else { lastValue = undefined; matchCount = 0; }
} catch { /* transient frame error — skip, never kill the loop */ }
finally { busy = false; }
}
if (!stopped) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => { stopped = true; cancelAnimationFrame(rafId); };
}, []);
if (error) return <div className="text-destructive">{error}</div>;
return (
<Webcam
ref={webcamRef}
audio={false}
onUserMedia={handleUserMedia}
videoConstraints={{ facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } }}
className="w-full rounded-lg"
/>
);
}