1
0
Files
record-shop/web/src/hooks/useBarcodeScanner.ts

122 lines
3.9 KiB
TypeScript

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<DetectedCode[]>
}
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<HTMLVideoElement | null>,
onDetect: (code: string) => void,
enabled: boolean
): { status: ScannerStatus; stop: () => void } {
const [status, setStatus] = useState<ScannerStatus>('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<void> {
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<void> => {
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<void> 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 }
}