locqr/companion/src/scanner.ts

59 lines
2.0 KiB
TypeScript

// Camera-based QR scanning (mobile/claude.md web stub: "Scans QR codes via
// the browser MediaDevices camera API"). Decode library: jsQR — pure JS,
// no WASM, the standard lightweight choice for in-browser barcode reading;
// not a crypto primitive, so it isn't subject to crypto.md's pinned-library
// list.
import jsQR from 'jsqr';
export interface ScannerHandle {
stop: () => void;
}
/**
* Opens the camera and polls frames via requestAnimationFrame until a QR
* code decodes, then calls `onDecode` once and stops polling on its own
* (the caller is still responsible for calling `stop()` to release the
* camera stream once it's done with the decoded content).
*/
export async function startScanner(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
onDecode: (content: string) => void,
): Promise<ScannerHandle> {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
video.srcObject = stream;
await video.play();
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('2D canvas context unavailable');
let stopped = false;
let decoded = false;
function tick(): void {
if (stopped || decoded) return;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx!.drawImage(video, 0, 0, canvas.width, canvas.height);
const frame = ctx!.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(frame.data, frame.width, frame.height);
if (code) {
decoded = true;
onDecode(code.data);
return;
}
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
return {
stop: () => {
stopped = true;
stream.getTracks().forEach((track) => track.stop());
},
};
}