import { useEffect, useRef, useState } from 'react' export type ScannerStatus = 'idle' | 'starting' | 'ready' | 'denied' | 'error' export const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8'] as const interface DetectedCode { rawValue: string format: string } interface BarcodeDetectorLike { detect(source: HTMLVideoElement): Promise } type BarcodeDetectorCtor = new (options: { formats: string[] }) => BarcodeDetectorLike export function getBarcodeDetectorCtor(): BarcodeDetectorCtor | null { return (window as unknown as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector ?? null } /** Pure dedup rule: suppress a repeat of the same code within cooldownMs. */ export function shouldEmit( last: { code: string; at: number } | null, code: string, now: number, cooldownMs: number ): boolean { if (!last) return true if (last.code !== code) return true return now - last.at >= cooldownMs } export function useBarcodeScanner( videoRef: React.RefObject, onDetect: (code: string) => void, enabled: boolean ): { status: ScannerStatus; stop: () => void } { const [status, setStatus] = useState('idle') const onDetectRef = useRef(onDetect) onDetectRef.current = onDetect useEffect(() => { if (!enabled) { setStatus('idle') return } let stopped = false let stream: MediaStream | null = null let rafId = 0 let zxingStop: (() => void) | null = null let last: { code: string; at: number } | null = null const emit = (code: string) => { const now = Date.now() if (!shouldEmit(last, code, now, 1500)) return last = { code, at: now } onDetectRef.current(code) } async function start(): Promise { setStatus('starting') try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }) } catch (err) { setStatus(err instanceof DOMException && err.name === 'NotAllowedError' ? 'denied' : 'error') return } const video = videoRef.current if (!video || stopped) { stream.getTracks().forEach((t) => t.stop()) return } video.srcObject = stream await video.play().catch(() => {}) const Ctor = getBarcodeDetectorCtor() if (Ctor) { const detector = new Ctor({ formats: [...BARCODE_FORMATS] }) const tick = async (): Promise => { if (stopped) return try { const codes = await detector.detect(video) const first = codes[0] if (first) emit(first.rawValue) } catch { // undecodable frame — skip } rafId = requestAnimationFrame(() => void tick()) } void tick() } else { const { BrowserMultiFormatReader, BarcodeFormat, DecodeHintType } = await import('@zxing/library') if (stopped) return const hints = new Map() hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.EAN_13, BarcodeFormat.UPC_A, BarcodeFormat.EAN_8]) const reader = new BrowserMultiFormatReader(hints) // 0.23.0's decodeFromStream returns Promise and only resolves once // its decode loop is stopped — start it without awaiting and break the // loop on cleanup via stopContinuousDecode(). zxingStop = () => reader.stopContinuousDecode() void reader .decodeFromStream(stream, video, (result) => { if (result) emit(result.getText()) }) .catch(() => {}) } if (!stopped) setStatus('ready') } void start() return () => { stopped = true cancelAnimationFrame(rafId) zxingStop?.() stream?.getTracks().forEach((t) => t.stop()) setStatus('idle') } // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, videoRef]) return { status, stop: () => undefined } }