feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component
This commit is contained in:
32
package-lock.json
generated
32
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
@@ -2322,6 +2323,28 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@zxing/library": {
|
||||||
|
"version": "0.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
|
||||||
|
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"ts-custom-error": "^3.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 24.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@zxing/text-encoding": "~0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@zxing/text-encoding": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
||||||
|
"license": "(Unlicense OR Apache-2.0)",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/abstract-logging": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -4705,6 +4728,15 @@
|
|||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-custom-error": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
test: {
|
test: {
|
||||||
name: 'web',
|
name: 'web',
|
||||||
include: ['web/test/**/*.test.tsx'],
|
include: ['web/test/**/*.test.{ts,tsx}'],
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['web/test/setup.ts'],
|
setupFiles: ['web/test/setup.ts'],
|
||||||
},
|
},
|
||||||
|
|||||||
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