diff --git a/src/frontend/src/components/barcodes/BarcodeCameraInput.tsx b/src/frontend/src/components/barcodes/BarcodeCameraInput.tsx index 374c50274a..fcba47d4b3 100644 --- a/src/frontend/src/components/barcodes/BarcodeCameraInput.tsx +++ b/src/frontend/src/components/barcodes/BarcodeCameraInput.tsx @@ -1,22 +1,86 @@ import { t } from '@lingui/core/macro'; -import { ActionIcon, Container, Group, Select, Stack } from '@mantine/core'; +import { + ActionIcon, + Container, + Group, + Select, + Stack, + Tooltip +} from '@mantine/core'; import { useDocumentVisibility, useLocalStorage } from '@mantine/hooks'; import { showNotification } from '@mantine/notifications'; import { + IconBulb, + IconBulbOff, IconCamera, IconPlayerPlayFilled, IconPlayerStopFilled, IconX } from '@tabler/icons-react'; import { type CameraDevice, Html5Qrcode } from 'html5-qrcode'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Expand from '../items/Expand'; import type { BarcodeInputProps } from './BarcodeInput'; +/* + * Native browser barcode detection (Shape Detection API). + * + * Backed by the OS / hardware-accelerated decoder (Android ML Kit / Google + * Play Services, macOS Vision, Windows platform decoder) and therefore much + * faster and far more robust to poor lighting, blur and small codes than the + * pure-JS `html5-qrcode` fallback (which decodes in software on the CPU). + * + * We always prefer the native detector when available and only fall back to + * `html5-qrcode` for browsers that do not implement the Shape Detection API + * (e.g. Firefox, iOS Safari). + */ +interface NativeDetectedBarcode { + rawValue: string; + format: string; + boundingBox: DOMRectReadOnly; + cornerPoints: { x: number; y: number }[]; +} + +interface NativeBarcodeDetector { + detect(source: CanvasImageSource): Promise; +} + +declare global { + interface Window { + BarcodeDetector?: new (options?: { + formats?: string[]; + }) => NativeBarcodeDetector; + } +} + +// Interval between native detection attempts. Native decoding is hardware +// accelerated and detection runs over the full frame (no aiming box), so this +// feels much faster than the legacy 10fps software loop while staying gentle +// on the CPU. +const NATIVE_DETECT_INTERVAL_MS = 100; + +function getNativeDetector(): NativeBarcodeDetector | null { + try { + if (typeof window === 'undefined' || !('BarcodeDetector' in window)) { + return null; + } + const Detector = window.BarcodeDetector; + if (!Detector) return null; + // Do not pass an explicit format list: the browser then detects every + // format it supports. Passing a list that includes an unsupported format + // throws in some browsers, which would silently disable native scanning. + return new Detector(); + } catch { + return null; + } +} + export default function BarcodeCameraInput({ onScan }: Readonly) { - const [qrCodeScanner, setQrCodeScanner] = useState(null); + const nativeDetectorRef = useRef(null); + const [useNative, setUseNative] = useState(false); + const [camId, setCamId] = useLocalStorage({ key: 'camId', defaultValue: null @@ -24,31 +88,65 @@ export default function BarcodeCameraInput({ const [cameras, setCameras] = useState([]); const [cameraValue, setCameraValue] = useState(null); const [scanningEnabled, setScanningEnabled] = useState(false); + const [torchSupported, setTorchSupported] = useState(false); + const [torchOn, setTorchOn] = useState(false); const [wasAutoPaused, setWasAutoPaused] = useState(false); const documentState = useDocumentVisibility(); - let lastValue = ''; + const videoRef = useRef(null); + const streamRef = useRef(null); + const detectTimerRef = useRef(null); + const detectingRef = useRef(false); + const lastValueRef = useRef(''); - // Mount QR code once we are loaded + // Legacy software scanner (fallback only) + const legacyScannerRef = useRef(null); + + // Detect native support + load camera list once useEffect(() => { - setQrCodeScanner(new Html5Qrcode('reader')); + const detector = getNativeDetector(); + nativeDetectorRef.current = detector; + setUseNative(detector !== null); + + if (!detector) { + legacyScannerRef.current = new Html5Qrcode('reader'); + } - // load cameras Html5Qrcode.getCameras().then((devices) => { if (devices?.length) { setCameras(devices); + + // Auto-select a camera so the play button is immediately usable, + // preferring the rear/back camera (better autofocus + resolution). + if (!camId) { + const preferred = + devices.find((device) => + /back|rear|environment|后置|后/i.test(device.label || '') + ) || devices[0]; + setCamId(preferred); + } } }); }, []); - // set camera value from id + // Set camera value from saved id useEffect(() => { if (camId) { setCameraValue(camId.id); } }, [camId]); - // Stop/start when leaving or reentering page + // Cleanup native resources on unmount + useEffect(() => { + return () => { + if (detectTimerRef.current !== null) { + window.clearInterval(detectTimerRef.current); + } + streamRef.current?.getTracks().forEach((track) => track.stop()); + }; + }, []); + + // Stop/start when leaving or reentering the page useEffect(() => { if (scanningEnabled && documentState === 'hidden') { btnStopScanning(); @@ -57,45 +155,148 @@ export default function BarcodeCameraInput({ btnStartScanning(); setWasAutoPaused(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [documentState]); - // Scanner functions - function onScanSuccess(decodedText: string) { - qrCodeScanner?.pause(); - - // dedouplication - if (decodedText === lastValue) { - qrCodeScanner?.resume(); - return; - } - lastValue = decodedText; - - // submit value upstream - onScan?.(decodedText); - - qrCodeScanner?.resume(); + // Common result handling with consecutive-duplicate suppression + function handleScanResult(value: string) { + if (!value || value === lastValueRef.current) return; + lastValueRef.current = value; + onScan?.(value); } - function onScanFailure(error: string) { - if ( - error != - 'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.' - ) { - console.warn(`Code scan error = ${error}`); + // --- Native (BarcodeDetector) path --- + + async function startNativeScanning(device?: CameraDevice) { + const target = device ?? camId; + if (!target || !videoRef.current) return; + + try { + const constraints: MediaStreamConstraints = { + video: { + ...(target.id ? { deviceId: { ideal: target.id } } : {}), + width: { ideal: 1920 }, + height: { ideal: 1080 } + }, + audio: false + }; + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + streamRef.current = stream; + videoRef.current.srcObject = stream; + await videoRef.current.play(); + + const track = stream.getVideoTracks()[0]; + if (track && typeof track.getCapabilities === 'function') { + const caps = track.getCapabilities() as MediaTrackCapabilities & { + torch?: boolean; + }; + setTorchSupported(!!caps.torch); + } + + setScanningEnabled(true); + setTorchOn(false); + startNativeDetectLoop(); + } catch (err) { + showNotification({ + title: t`Error while scanning`, + message: String(err), + color: 'red', + icon: + }); } } - function btnStartScanning() { - if (camId && qrCodeScanner && !scanningEnabled) { - qrCodeScanner + function stopNativeScanning() { + if (detectTimerRef.current !== null) { + window.clearInterval(detectTimerRef.current); + detectTimerRef.current = null; + } + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + if (videoRef.current) { + videoRef.current.srcObject = null; + } + setScanningEnabled(false); + setTorchOn(false); + setTorchSupported(false); + } + + function startNativeDetectLoop() { + if (detectTimerRef.current !== null) { + window.clearInterval(detectTimerRef.current); + } + detectTimerRef.current = window.setInterval(() => { + void detectOnce(); + }, NATIVE_DETECT_INTERVAL_MS); + } + + async function detectOnce() { + if (detectingRef.current) return; + const detector = nativeDetectorRef.current; + const video = videoRef.current; + if (!detector || !video || video.readyState < 2) return; + + detectingRef.current = true; + try { + const results = await detector.detect(video); + if (results?.length) { + for (const result of results) { + if (result?.rawValue) { + handleScanResult(result.rawValue); + } + } + } + } catch { + // Per-frame decode failures are expected (e.g. no code currently in view). + } finally { + detectingRef.current = false; + } + } + + async function toggleTorch() { + const track = streamRef.current?.getVideoTracks()[0]; + if (!track) return; + try { + await track.applyConstraints({ + advanced: [{ torch: !torchOn }] + } as unknown as MediaTrackConstraints); + setTorchOn(!torchOn); + } catch { + showNotification({ + title: t`Flashlight`, + message: t`Flashlight control is not supported on this device`, + color: 'yellow' + }); + } + } + + // --- Legacy (html5-qrcode) fallback path --- + + function startLegacyScanning(device?: CameraDevice) { + const target = device ?? camId; + const scanner = legacyScannerRef.current; + if (target && scanner && !scanningEnabled) { + scanner .start( - camId.id, - { fps: 10, qrbox: { width: 250, height: 250 } }, + target.id, + { + fps: 10, + qrbox: { width: 250, height: 250 }, + aspectRatio: 1.0 + }, (decodedText) => { - onScanSuccess(decodedText); + scanner.pause(); + handleScanResult(decodedText); + scanner.resume(); }, (errorMessage) => { - onScanFailure(errorMessage); + if ( + errorMessage != + 'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.' + ) { + console.warn(`Code scan error = ${errorMessage}`); + } } ) .catch((err: string) => { @@ -110,9 +311,10 @@ export default function BarcodeCameraInput({ } } - function btnStopScanning() { - if (qrCodeScanner && scanningEnabled) { - qrCodeScanner.stop().catch((err: string) => { + function stopLegacyScanning() { + const scanner = legacyScannerRef.current; + if (scanner && scanningEnabled) { + scanner.stop().catch((err: string) => { showNotification({ title: t`Error while stopping`, message: err, @@ -124,36 +326,41 @@ export default function BarcodeCameraInput({ } } - // on value change - useEffect(() => { - if (cameraValue === null) return; - if (cameraValue === camId?.id) { - return; - } - - const cam = cameras.find((cam) => cam.id === cameraValue); - - // stop scanning if cam changed while scanning - if (qrCodeScanner && scanningEnabled) { - // stop scanning - qrCodeScanner.stop().then(() => { - // change ID - setCamId(cam); - // start scanning - qrCodeScanner.start( - cam.id, - { fps: 10, qrbox: { width: 250, height: 250 } }, - (decodedText) => { - onScanSuccess(decodedText); - }, - (errorMessage) => { - onScanFailure(errorMessage); - } - ); - }); + function btnStartScanning() { + if (useNative) { + void startNativeScanning(); } else { - setCamId(cam); + startLegacyScanning(); } + } + + function btnStopScanning() { + if (useNative) { + stopNativeScanning(); + } else { + stopLegacyScanning(); + } + } + + // Restart when the selected camera changes while scanning + useEffect(() => { + if (cameraValue === null || cameraValue === camId?.id) return; + const cam = cameras.find((camera) => camera.id === cameraValue); + if (!cam) return; + + const wasScanning = scanningEnabled; + if (wasScanning) { + btnStopScanning(); + } + setCamId(cam); + if (wasScanning) { + if (useNative) { + void startNativeScanning(cam); + } else { + startLegacyScanning(cam); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [cameraValue]); const placeholder = t`Start scanning by selecting a camera and pressing the play button.`; @@ -172,6 +379,22 @@ export default function BarcodeCameraInput({ /> + {useNative && scanningEnabled && torchSupported && ( + + void toggleTorch()} + title={torchOn ? t`Turn off flashlight` : t`Turn on flashlight`} + > + {torchOn ? : } + + + )} + {scanningEnabled ? ( )} - {scanningEnabled ? ( - + + {useNative ? ( + + ) : ( - - {placeholder} + + {!scanningEnabled && placeholder} )}