92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { pushOverlayBack } from './platform.js';
|
|
import type { ScanResult } from './scanner.js';
|
|
import { Icon } from './ui.js';
|
|
|
|
type Formats = 'card' | 'qr';
|
|
|
|
/** Fullscreen camera view that resolves with the first detected code. */
|
|
export function ScannerView({ formats, hint, onResult, onClose }: { formats: Formats; hint: string; onResult: (result: ScanResult) => void; onClose: () => void }) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [error, setError] = useState<string>();
|
|
const onResultRef = useRef(onResult);
|
|
onResultRef.current = onResult;
|
|
// Android back button closes the scanner, not the screen underneath.
|
|
const closeRef = useRef(onClose);
|
|
closeRef.current = onClose;
|
|
useEffect(() => pushOverlayBack(closeRef), []);
|
|
|
|
useEffect(() => {
|
|
let stopped = false;
|
|
let stream: MediaStream | undefined;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
(async () => {
|
|
try {
|
|
const { createDetector, CARD_FORMATS, QR_FORMATS } = await import('./scanner.js');
|
|
const detect = createDetector(formats === 'qr' ? QR_FORMATS : CARD_FORMATS);
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
audio: false,
|
|
});
|
|
if (stopped) return;
|
|
const video = videoRef.current!;
|
|
video.srcObject = stream;
|
|
await video.play();
|
|
|
|
const tick = async () => {
|
|
if (stopped) return;
|
|
try {
|
|
if (video.readyState >= 2) {
|
|
const found = await detect(video);
|
|
if (found && !stopped) {
|
|
stopped = true;
|
|
onResultRef.current(found);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// a frame that could not be decoded
|
|
}
|
|
timer = setTimeout(tick, 200);
|
|
};
|
|
void tick();
|
|
} catch (e) {
|
|
const name = (e as Error).name;
|
|
setError(
|
|
name === 'NotAllowedError'
|
|
? 'Camera access was denied. Allow it in the app or browser settings.'
|
|
: name === 'NotFoundError'
|
|
? 'No camera found.'
|
|
: `Cannot start the camera: ${(e as Error).message}`,
|
|
);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
stopped = true;
|
|
if (timer) clearTimeout(timer);
|
|
stream?.getTracks().forEach((t) => t.stop());
|
|
};
|
|
}, [formats]);
|
|
|
|
return (
|
|
<div className="scanner">
|
|
<video ref={videoRef} playsInline muted />
|
|
<div className={`scanner-frame ${formats}`} />
|
|
<div className="scanner-top">
|
|
<button className="icon-button" onClick={onClose} aria-label="Close">
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
<div className="scanner-hint">{error ?? hint}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Detects a code on a picked image file. */
|
|
export async function scanImageFile(file: Blob, formats: Formats): Promise<ScanResult | undefined> {
|
|
const { createDetector, CARD_FORMATS, QR_FORMATS } = await import('./scanner.js');
|
|
return createDetector(formats === 'qr' ? QR_FORMATS : CARD_FORMATS)(file);
|
|
}
|