207 lines
7.0 KiB
Svelte
207 lines
7.0 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* src/lib/elements/element_qr_scanner.svelte
|
|
* QR Scanner v3 — Svelte 5 runes, auto-starts, no manual permission step.
|
|
*
|
|
* html5-qrcode's .start() handles camera permission internally.
|
|
* A unique viewfinder ID is generated per instance so multiple scanners
|
|
* can coexist on the same page without collision.
|
|
*
|
|
* Props:
|
|
* start_qr_scanner (bindable) — true = scan, false = stop
|
|
* on_qr_scan_result — callback fired with { detail: { result, entry_method } }
|
|
* qr_fps — scan frames per second (default 10)
|
|
* qr_facing_mode — 'environment' (rear) or 'user' (front)
|
|
*/
|
|
import { onDestroy, untrack } from 'svelte';
|
|
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
import { RefreshCw } from '@lucide/svelte';
|
|
|
|
interface Props {
|
|
start_qr_scanner?: boolean;
|
|
on_qr_scan_result?: (event: {
|
|
detail: { result: string; entry_method: string };
|
|
}) => void;
|
|
qr_fps?: number;
|
|
qr_facing_mode?: string;
|
|
}
|
|
|
|
let {
|
|
start_qr_scanner = $bindable(true),
|
|
on_qr_scan_result,
|
|
qr_fps = 25,
|
|
qr_facing_mode = 'environment'
|
|
}: Props = $props();
|
|
|
|
// Unique DOM ID per instance — prevents conflicts when multiple scanners mount
|
|
const viewfinder_id = `qr_vf_${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
let scanner: Html5Qrcode | null = null;
|
|
let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle');
|
|
let error_msg = $state('');
|
|
let start_timeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// React to start_qr_scanner prop changes from the parent
|
|
$effect(() => {
|
|
const should_scan = start_qr_scanner;
|
|
|
|
untrack(() => {
|
|
if (should_scan && (status === 'idle' || status === 'error')) {
|
|
start_scanning();
|
|
} else if (!should_scan && status === 'scanning') {
|
|
stop_scanning();
|
|
}
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
clear_start_timeout();
|
|
stop_scanning();
|
|
});
|
|
|
|
function clear_start_timeout() {
|
|
if (start_timeout !== null) {
|
|
clearTimeout(start_timeout);
|
|
start_timeout = null;
|
|
}
|
|
}
|
|
|
|
async function start_scanning() {
|
|
if (status === 'starting' || status === 'scanning') return;
|
|
status = 'starting';
|
|
error_msg = '';
|
|
|
|
// If the camera hasn't started within 7 seconds, show a helpful nudge.
|
|
// Common causes: permission dialog left open, camera in use by another app, slow device.
|
|
start_timeout = setTimeout(() => {
|
|
if (status === 'starting') {
|
|
status = 'error';
|
|
error_msg =
|
|
'Camera is taking too long to start. Try refreshing the page, or check that no other app is using your camera.';
|
|
}
|
|
}, 7000);
|
|
|
|
try {
|
|
scanner = new Html5Qrcode(viewfinder_id, {
|
|
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
|
|
verbose: false
|
|
});
|
|
|
|
await scanner.start(
|
|
{ facingMode: qr_facing_mode },
|
|
{
|
|
fps: qr_fps,
|
|
// Use a percentage of the viewfinder so it scales on any screen size
|
|
qrbox: (w: number, h: number) => {
|
|
const side = Math.floor(Math.min(w, h) * 0.88);
|
|
return { width: side, height: side };
|
|
},
|
|
// Use native BarcodeDetector API on Chrome/Edge — significantly faster
|
|
// than the JS ZXing fallback used on Firefox/older Safari.
|
|
// Cast: experimentalFeatures exists at runtime but is missing from the
|
|
// html5-qrcode TypeScript type definitions for this version.
|
|
...({
|
|
experimentalFeatures: {
|
|
useBarCodeDetectorIfSupported: true
|
|
}
|
|
} as {
|
|
experimentalFeatures: {
|
|
useBarCodeDetectorIfSupported: boolean;
|
|
};
|
|
})
|
|
},
|
|
on_scan_success,
|
|
on_scan_error
|
|
);
|
|
|
|
clear_start_timeout();
|
|
status = 'scanning';
|
|
} catch (e: any) {
|
|
clear_start_timeout();
|
|
status = 'error';
|
|
if (e?.name === 'NotAllowedError') {
|
|
error_msg =
|
|
'Camera access denied. Please allow camera in your browser settings and try again.';
|
|
} else {
|
|
error_msg = 'Could not start camera. Please try again.';
|
|
}
|
|
console.warn('[QR v3] Scanner start failed:', e);
|
|
}
|
|
}
|
|
|
|
async function stop_scanning() {
|
|
clear_start_timeout();
|
|
if (!scanner) return;
|
|
const s = scanner;
|
|
scanner = null;
|
|
try {
|
|
await s.stop();
|
|
await s.clear();
|
|
} catch {
|
|
// Ignore cleanup errors — component may be unmounting
|
|
}
|
|
status = 'idle';
|
|
}
|
|
|
|
function on_scan_success(decoded_text: string) {
|
|
// Stop scanning before notifying parent so the camera shuts down cleanly
|
|
stop_scanning().then(() => {
|
|
start_qr_scanner = false;
|
|
});
|
|
|
|
if (on_qr_scan_result) {
|
|
on_qr_scan_result({
|
|
detail: { result: decoded_text, entry_method: 'QR' }
|
|
});
|
|
}
|
|
}
|
|
|
|
function on_scan_error(_msg: string) {
|
|
// Called on every frame that doesn't contain a QR code — expected, not an error
|
|
}
|
|
</script>
|
|
|
|
<!-- Viewfinder fills whatever container the parent provides -->
|
|
<div
|
|
class="qr-scanner-v3 flex h-full w-full flex-col items-center justify-center">
|
|
<div id={viewfinder_id} class="h-full w-full"></div>
|
|
|
|
{#if status === 'starting'}
|
|
<div
|
|
class="absolute inset-0 flex items-center justify-center rounded-xl bg-black/30">
|
|
<span
|
|
class="animate-pulse rounded-full bg-black/60 px-4 py-2 text-sm font-semibold text-white shadow-lg">
|
|
Starting camera...
|
|
</span>
|
|
</div>
|
|
{:else if status === 'error'}
|
|
<div
|
|
class="absolute inset-0 flex flex-col items-center justify-center gap-5 rounded-xl bg-black/75 p-6 text-center">
|
|
<p
|
|
class="text-sm leading-snug font-semibold text-white drop-shadow">
|
|
{error_msg}
|
|
</p>
|
|
<!-- bg-white + dark text: always readable on the dark camera overlay regardless of theme.
|
|
preset-filled-primary is theme-adaptive and has poor contrast on black/75 backgrounds. -->
|
|
<button
|
|
type="button"
|
|
class="text-surface-950 hover:bg-surface-100 flex cursor-pointer items-center gap-2 rounded-xl bg-white px-8 py-3 text-base font-bold shadow-lg transition-colors"
|
|
onclick={start_scanning}>
|
|
<RefreshCw size="1.2em" />
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
/* html5-qrcode injects a <video> with inline width/height. Override with cover so the
|
|
camera stream fills the container completely on any camera aspect ratio (4:3, 16:9, etc.).
|
|
Without this, portrait-mode mobile cameras leave a gray letterbox at the bottom. */
|
|
:global(.qr-scanner-v3 video) {
|
|
object-fit: cover !important;
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
</style>
|