Add camera QR scanner to v2 client

Restores the old client's built-in scanner: a TopBar button opens a
camera modal (qr-scanner) that reads Spoolman label codes and opens the
matching spool in the Library. Parses both the WEB+SPOOLMAN:S-<id> URI
and the /spool/show/<id> URL forms.

Camera-start errors are reported specifically: an insecure (non-HTTPS)
context is detected up front via isSecureContext, and other failures are
re-probed via getUserMedia to recover the real error (permission
blocked, no camera, camera in use) since qr-scanner collapses them all
into a generic string.
This commit is contained in:
Donkie committed 2026-07-13 11:04:20 +02:00
1 parent 0f7cdc6faf
commit cf82d5b68f
7 files changed
+322 -3

No files matched your search

+16 -1
View File
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"konva": "^10.3.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"svelte-konva": "^1.0.1"
},
@@ -957,11 +958,16 @@
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@@ -1518,6 +1524,15 @@
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/qr-scanner": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
"license": "MIT",
"dependencies": {
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+1
View File
@@ -26,6 +26,7 @@
},
"dependencies": {
"konva": "^10.3.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"svelte-konva": "^1.0.1"
}
@@ -0,0 +1,234 @@
<script lang="ts">
import QrScanner from 'qr-scanner';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { parseSpoolCode } from '$lib/utils/spoolCode';
interface Props {
open: boolean;
onclose?: () => void;
}
let { open, onclose }: Props = $props();
let video = $state<HTMLVideoElement | null>(null);
let error = $state<string | null>(null);
let starting = $state(false);
function close() {
onclose?.();
}
// A scanned code that decodes to a Spoolman spool id opens that spool in the
// Library. Non-Spoolman codes are ignored so the camera keeps scanning.
function onDecode(result: QrScanner.ScanResult) {
const id = parseSpoolCode(result.data);
if (id === null) return;
close();
goto(`${base}/?sel=spool:${id}`);
}
const MSG = {
insecure:
'The camera needs a secure connection. Browsers only allow camera access over HTTPS (or via localhost / 127.0.0.1) — open Spoolman that way to scan.',
unsupported: 'This browser does not provide camera access.',
denied:
'Camera access was blocked. Allow the camera permission for this site in your browser, then try again.',
notFound: 'No camera was found on this device.',
inUse: 'The camera is already in use by another application.',
generic: 'Could not start the camera.'
};
/** Map a getUserMedia DOMException to a friendly reason (by its `name`). */
function messageForError(err: unknown): string {
const name = (err as { name?: string } | null)?.name ?? '';
if (name === 'NotAllowedError' || name === 'SecurityError') return MSG.denied;
if (name === 'NotFoundError' || name === 'OverconstrainedError' || name === 'DevicesNotFoundError')
return MSG.notFound;
if (name === 'NotReadableError' || name === 'TrackStartError') return MSG.inUse;
return MSG.generic;
}
// qr-scanner collapses every getUserMedia failure into the opaque string
// "Camera not found.", so when start() rejects we re-request the camera
// ourselves to recover the real error name and report a specific reason.
async function classifyStartFailure(): Promise<string> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
stream.getTracks().forEach((t) => t.stop());
return MSG.generic; // camera works now — the earlier failure was transient
} catch (err) {
return messageForError(err);
}
}
// Start the camera while the modal is open and tear it down on close/unmount.
// $effect re-runs when `open` (or the bound `video`) changes; its cleanup stops
// the stream so the camera light goes off the moment the modal closes.
$effect(() => {
if (!open || !video) return;
error = null;
starting = false;
// Insecure (non-HTTPS, non-localhost) origins don't expose the camera API
// at all — qr-scanner would only report a generic "camera not found", so
// detect this up front and tell the user the real reason before we try.
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
error = !window.isSecureContext ? MSG.insecure : MSG.unsupported;
return;
}
let scanner: QrScanner | null = null;
let cancelled = false;
starting = true;
scanner = new QrScanner(video, onDecode, {
// Prefer the rear camera; ignore frames that don't decode.
preferredCamera: 'environment',
highlightScanRegion: true,
highlightCodeOutline: true,
maxScansPerSecond: 5,
returnDetailedScanResult: true
});
scanner
.start()
.then(() => {
if (cancelled) scanner?.stop();
})
.catch(async (err) => {
if (cancelled) return;
console.error('QR scanner failed to start:', err);
error = await classifyStartFailure();
})
.finally(() => {
if (!cancelled) starting = false;
});
return () => {
cancelled = true;
scanner?.destroy();
scanner = null;
};
});
</script>
{#if open}
<div
class="overlay"
role="button"
tabindex="0"
onclick={close}
onkeydown={(e) => e.key === 'Escape' && close()}
>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label="Scan spool QR code"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="modal-head">
<span class="title">Scan a spool</span>
<button class="x" onclick={close} aria-label="Close">✕</button>
</div>
<p class="hint">Point your camera at a Spoolman QR code to open its spool.</p>
<div class="stage">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={video} playsinline></video>
{#if error}
<div class="msg error">{error}</div>
{:else if starting}
<div class="msg">Starting camera…</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 50;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 8vh 16px 16px;
}
.modal {
width: 460px;
max-width: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-xl);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.modal-head {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px 0;
flex: none;
}
.title {
font-weight: 700;
font-size: 16px;
}
.x {
margin-left: auto;
color: var(--text-dim);
cursor: pointer;
font-size: 15px;
padding: 4px 8px;
background: none;
border: none;
}
.x:hover {
color: var(--text);
}
.hint {
padding: 8px 20px 0;
margin: 0;
font-size: 12.5px;
color: var(--text-muted);
}
.stage {
position: relative;
margin: 14px 20px 20px;
aspect-ratio: 1 / 1;
background: #000;
border-radius: var(--radius-md);
overflow: hidden;
}
.stage video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.msg {
position: absolute;
inset: auto 12px 12px;
text-align: center;
font-size: 12.5px;
color: #fff;
background: rgba(0, 0, 0, 0.55);
padding: 8px 12px;
border-radius: var(--radius);
}
.msg.error {
inset: 12px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+38 -1
View File
@@ -8,9 +8,10 @@
interface Props {
onadd?: () => void;
onscan?: () => void;
}
let { onadd }: Props = $props();
let { onadd, onscan }: Props = $props();
// The search box lives in the layout (above the routed page), so it reads the
// query straight from the URL and writes it back through the params helper.
@@ -22,6 +23,25 @@
<Logo />
<div class="nav-desktop"><NavTabs /></div>
<div class="spacer"></div>
<button class="scan-btn" onclick={onscan} aria-label="Scan spool QR code" title="Scan spool QR code">
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3 8V5a2 2 0 0 1 2-2h3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M21 16v3a2 2 0 0 1-2 2h-3" />
<path d="M3 12h18" />
</svg>
</button>
<div class="search-desktop">
<SearchInput value={query} placeholder={'⌕ Search — try "red" or "pla"'} oninput={(v) => setQuery(v)} />
</div>
@@ -62,6 +82,23 @@
.spacer {
flex: 1;
}
.scan-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: none;
color: var(--text-2);
cursor: pointer;
}
.scan-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.mobile-search,
.mobile-nav {
+10
View File
@@ -9,6 +9,9 @@ class UiState {
addModalOpen = $state(false);
addModalFilamentId = $state<string | null>(null);
/** QR-code scanner modal (camera). */
scannerOpen = $state(false);
/** Open the Add-spools modal, optionally pre-seeded with a filament. */
openAddModal(filamentId?: string) {
this.addModalFilamentId = filamentId ?? null;
@@ -18,6 +21,13 @@ class UiState {
this.addModalOpen = false;
this.addModalFilamentId = null;
}
openScanner() {
this.scannerOpen = true;
}
closeScanner() {
this.scannerOpen = false;
}
}
export const ui = new UiState();
+19
View File
@@ -0,0 +1,19 @@
// Parse the payload of a scanned Spoolman label back into a spool id. Mirrors
// the two forms produced by the QR generator (lib/labels/qr.ts) and understood
// by the old client's scanner (client/src/components/qrCodeScanner.tsx):
// scheme → WEB+SPOOLMAN:S-<id> (compact custom URI)
// url → <base_url>/spool/show/<id> (opens in a browser)
// Anything that isn't a Spoolman code returns null, so unrelated codes in view
// are simply ignored.
const SCHEME_RE = /^web\+spoolman:s-(?<id>[0-9]+)$/i;
const URL_RE = /^https?:\/\/[^/]+(?:\/[^/]+)*\/spool\/show\/(?<id>[0-9]+)\/?$/i;
/** Extract the spool id from a scanned code, or null if it isn't a Spoolman code. */
export function parseSpoolCode(raw: string): number | null {
const text = raw.trim();
const match = SCHEME_RE.exec(text) ?? URL_RE.exec(text);
if (!match?.groups) return null;
const id = Number(match.groups.id);
return Number.isSafeInteger(id) ? id : null;
}
+4 -1
View File
@@ -3,6 +3,7 @@
import TopBar from '$components/TopBar.svelte';
import Footer from '$components/Footer.svelte';
import AddSpoolModal from '$components/AddSpoolModal.svelte';
import QrScannerModal from '$components/QrScannerModal.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { startLiveSync } from '$lib/api/liveSync';
@@ -19,7 +20,7 @@
</script>
<div class="app">
<TopBar onadd={() => ui.openAddModal()} />
<TopBar onadd={() => ui.openAddModal()} onscan={() => ui.openScanner()} />
<main>
{@render children()}
</main>
@@ -32,6 +33,8 @@
onclose={() => ui.closeAddModal()}
/>
<QrScannerModal open={ui.scannerOpen} onclose={() => ui.closeScanner()} />
<style>
.app {
display: flex;