mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 17:42:16 -05:00
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:
1 parent
0f7cdc6faf
commit
cf82d5b68f
7 files changed
+322
-3
No files matched your search
Generated
+16
-1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user