feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component
This commit is contained in:
121
web/src/hooks/useBarcodeScanner.ts
Normal file
121
web/src/hooks/useBarcodeScanner.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user