feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component
This commit is contained in:
39
web/src/components/Scanner.tsx
Normal file
39
web/src/components/Scanner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useRef } from 'react'
|
||||
import { useBarcodeScanner } from '../hooks/useBarcodeScanner.js'
|
||||
|
||||
export default function Scanner({
|
||||
enabled,
|
||||
onDetect,
|
||||
}: {
|
||||
enabled: boolean
|
||||
onDetect: (code: string) => void
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const { status } = useBarcodeScanner(videoRef, onDetect, enabled)
|
||||
|
||||
return (
|
||||
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-2xl bg-black">
|
||||
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-8 rounded-2xl border-2 border-emerald-400/80"
|
||||
/>
|
||||
{status === 'starting' && (
|
||||
<p className="absolute inset-x-0 top-1/2 text-center text-sm text-neutral-300">Starting camera…</p>
|
||||
)}
|
||||
{status === 'denied' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<p className="font-medium">Camera permission denied</p>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Allow camera access in your browser settings, then reload this page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<p className="absolute inset-x-0 top-1/2 text-center text-sm text-red-400">
|
||||
Camera could not start.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
88
web/test/scanner.test.ts
Normal file
88
web/test/scanner.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useBarcodeScanner, shouldEmit } from '../src/hooks/useBarcodeScanner.js'
|
||||
|
||||
function fakeStream(): MediaStream {
|
||||
const tracks: MediaStreamTrack[] = [{ stop: vi.fn() } as unknown as MediaStreamTrack]
|
||||
return { getTracks: () => tracks } as unknown as MediaStream
|
||||
}
|
||||
|
||||
function stubMediaDevices(impl: () => Promise<MediaStream>) {
|
||||
vi.stubGlobal(
|
||||
'navigator',
|
||||
Object.assign(Object.create(Object.getPrototypeOf(navigator)), navigator, {
|
||||
mediaDevices: { getUserMedia: impl },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('shouldEmit (cooldown dedup)', () => {
|
||||
it('emits a new code, suppresses a rapid repeat, emits after cooldown', () => {
|
||||
expect(shouldEmit(null, '123', 0, 1500)).toBe(true)
|
||||
expect(shouldEmit({ code: '123', at: 100 }, '123', 500, 1500)).toBe(false)
|
||||
expect(shouldEmit({ code: '123', at: 100 }, '456', 500, 1500)).toBe(true)
|
||||
expect(shouldEmit({ code: '123', at: 100 }, '123', 1700, 1500)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBarcodeScanner', () => {
|
||||
it('detects a barcode via BarcodeDetector and calls onDetect', async () => {
|
||||
const detections = [[], [{ rawValue: '5021592210629', format: 'ean_13' }]]
|
||||
let call = 0
|
||||
class FakeDetector {
|
||||
constructor(_opts: { formats: string[] }) {
|
||||
expect(_opts.formats).toEqual(['ean_13', 'upc_a', 'ean_8'])
|
||||
}
|
||||
async detect() {
|
||||
return detections[Math.min(call++, 1)]!
|
||||
}
|
||||
}
|
||||
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = FakeDetector
|
||||
stubMediaDevices(() => Promise.resolve(fakeStream()))
|
||||
|
||||
const onDetect = vi.fn()
|
||||
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||
const { result } = renderHook(() => useBarcodeScanner(videoRef, onDetect, true))
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'))
|
||||
await waitFor(() => expect(onDetect).toHaveBeenCalledWith('5021592210629'))
|
||||
})
|
||||
|
||||
it('reports denied when camera permission is rejected', async () => {
|
||||
stubMediaDevices(() =>
|
||||
Promise.reject(new DOMException('denied', 'NotAllowedError'))
|
||||
)
|
||||
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||
const { result } = renderHook(() => useBarcodeScanner(videoRef, () => {}, true))
|
||||
await waitFor(() => expect(result.current.status).toBe('denied'))
|
||||
})
|
||||
|
||||
it('stops the stream when enabled goes false', async () => {
|
||||
const stream = fakeStream()
|
||||
const stopSpy = vi.spyOn(stream.getTracks()[0]!, 'stop')
|
||||
stubMediaDevices(() => Promise.resolve(stream))
|
||||
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = class {
|
||||
async detect() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useBarcodeScanner(videoRef, () => {}, enabled),
|
||||
{ initialProps: { enabled: true } }
|
||||
)
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'))
|
||||
rerender({ enabled: false })
|
||||
expect(stopSpy).toHaveBeenCalled()
|
||||
expect(result.current.status).toBe('idle')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user