122 lines
3.9 KiB
TypeScript
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 }
|
|
}
|