This commit is contained in:
j3d1 2026-08-24 19:49:10 +02:00
parent aa94c92000
commit f87b689e27
3 changed files with 314 additions and 399 deletions

View file

@ -9,6 +9,9 @@
<SearchBox/> <SearchBox/>
<div class="navbar-collapse collapse"> <div class="navbar-collapse collapse">
<ul class="navbar-nav navbar-align"> <ul class="navbar-nav navbar-align">
<router-link to="/scan?navigate=immediate" class="nav-icon">
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
</router-link>
<Notifications :notifications="notifications"/> <Notifications :notifications="notifications"/>
<Messages :messages="messages"/> <Messages :messages="messages"/>
<UserDropdown/> <UserDropdown/>

View file

@ -0,0 +1,287 @@
<template>
<div class="camera-scanner">
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<select class="form-control" v-model="selectedCameraId">
<option value="">Auto (prefer recent, then rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div v-if="cameraRunning" class="col-auto">
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
</template>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
import cameraManager from "@/cameraManager.js";
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
// the code has moved out of frame.
const OVERLAY_CLEAR_MS = 2000;
export default {
name: "CameraScanner",
components: {
...BIcons
},
props: {
// Whether to open the camera as soon as it's available (still gated on browser support
// and a secure context - the camera API is unavailable otherwise). false lets a caller
// that wants to gate opening the camera behind its own condition (e.g. a "scan" button)
// skip the auto-open.
autostart: {
type: Boolean,
default: true,
},
},
emits: [
// Fired once per onDecode batch with anyd-qr.js's raw decoded codes (type/text/metadata/box).
"scan",
// Fired with a message string when a camera error occurs, or null once it clears (e.g. on
// reconnect) - lets the caller show/clear it alongside errors from its own other sources.
"error",
],
data() {
return {
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
localSelectedCameraId: "",
cameraRunning: false,
cameraRes: "",
};
},
computed: {
// Switches camera immediately on selection change (no explicit "apply" step), matching
// prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
selectedCameraId: {
get() {
return this.localSelectedCameraId;
},
set(value) {
this.localSelectedCameraId = value;
if (this.cameraRunning) {
this.switchCamera(value || null);
}
},
},
},
methods: {
// Device labels only populate once permission is granted, so this is called again right
// after getUserMedia resolves in startCamera, not just once up front.
async populateCameraList() {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
await cameraManager.enumerateDevices();
this.cameras = cameraManager.getAvailableCameras();
},
cleanCameraLabel(label) {
if (!label) return "";
const parts = label.split(":").map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
},
syncOverlaySize() {
const video = this.$refs.video;
const overlay = this.$refs.overlay;
if (!video || !overlay) {
return;
}
overlay.width = video.clientWidth;
overlay.height = video.clientHeight;
},
// Draws each detected code's box (video's native pixel space, scaled to the overlay's
// displayed size) and fades it out after OVERLAY_CLEAR_MS.
drawOverlay(codes) {
const overlay = this.$refs.overlay;
const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) {
return;
}
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const scaleX = overlay.width / video.videoWidth;
const scaleY = overlay.height / video.videoHeight;
ctx.lineWidth = 3;
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = "14px sans-serif";
for (const code of codes) {
if (!code.box) {
continue;
}
const {x0, y0, x1, y1} = code.box;
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
}
clearTimeout(this.clearOverlayTimer);
this.clearOverlayTimer = setTimeout(
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
// Attaches `stream` to the video element so the scanner never needs recreating. See
// docs/implementation.md#video-stream-attach-and-resize-sync.
setupVideoStream(stream) {
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
},
// Resizes the overlay to match the video's new size once its intrinsic dimensions change.
// See docs/implementation.md#video-stream-attach-and-resize-sync.
onVideoResize() {
const video = this.$refs.video;
if (!video || !video.videoWidth) {
return;
}
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
},
async startCamera() {
this.$emit("error", null);
try {
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
this.setupVideoStream(stream);
await this.populateCameraList();
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(this.$refs.video, {
fps: 4,
downscale: 2,
onDecode: (codes) => {
this.$emit("scan", codes);
this.drawOverlay(codes);
},
onError: (err) => console.error("[camera scan]", err),
});
this.scanner.start();
this.cameraRunning = true;
} catch (e) {
this.$emit("error", `Camera error: ${e.message ?? e}`);
}
},
async switchCamera(cameraId) {
if (!this.cameraRunning) return;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (e) {
this.$emit("error", `Camera error: ${e.message ?? e}`);
}
},
stopCamera() {
this.scanner?.stop();
this.scanner = null;
cameraManager.closeStream();
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
this.cameraRunning = false;
const overlay = this.$refs.overlay;
if (overlay) {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
},
// cameraManager already tries a fallback device and only fires this event once none
// remain, so the running scan session has to stop here.
handleCameraDisconnected(event) {
if (!this.cameraRunning) return;
this.$emit("error", `Camera error: ${event.detail?.error || "Camera disconnected"}`);
this.stopCamera();
},
handleCameraReconnected() {
if (!this.cameraRunning) return;
const stream = cameraManager.getActiveStream();
if (!stream) return;
this.$emit("error", null);
this.setupVideoStream(stream);
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
},
},
created() {
// Kept as a promise (not awaited) so this and Scan.vue's own file-decode path share one
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
// free too.
this.anydPromise = loadAnyDCode();
this.scanner = null;
this.clearOverlayTimer = null;
},
mounted() {
window.addEventListener("resize", this.syncOverlaySize);
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.addEventListener("resize", this.onVideoResize);
if (this.cameraSupported && window.isSecureContext && this.autostart) {
this.startCamera();
} else {
this.populateCameraList();
}
},
beforeUnmount() {
this.stopCamera();
window.removeEventListener("resize", this.syncOverlaySize);
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer);
},
}
</script>
<style scoped>
.video-wrap {
position: relative;
margin-bottom: .75rem;
background: #000;
border-radius: .35rem;
overflow: hidden;
}
.video-wrap video {
display: block;
width: 100%;
height: auto;
}
.video-wrap canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
</style>

View file

@ -34,10 +34,13 @@
<a v-if="c.link?.href" :href="c.link.href" target="_blank" <a v-if="c.link?.href" :href="c.link.href" target="_blank"
rel="noopener noreferrer">{{ c.text }}</a> rel="noopener noreferrer">{{ c.text }}</a>
<template v-else-if="c.link"> <template v-else-if="c.link">
<router-link :to="c.link.to">{{ c.text }}</router-link> <router-link :to="c.link.to">{{ c.text }}
<span v-if="c.error" class="text-danger"> &rarr; {{ c.error }}</span> <span v-if="c.error" class="text-danger"> &rarr; {{
<span v-else-if="c.description" class="text-muted"> &rarr; {{ c.description }}</span> c.error
<span v-else-if="c.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span> }}</span>
<span v-else-if="c.description"> &rarr; {{ c.description }}</span>
<span v-else-if="c.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
</router-link>
</template> </template>
<template v-else>{{ c.text }}</template> <template v-else>{{ c.text }}</template>
</div> </div>
@ -53,63 +56,7 @@
<h5 class="card-title mb-0">Scan from camera</h5> <h5 class="card-title mb-0">Scan from camera</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<p v-if="!cameraSupported" class="text-muted"> <CameraScanner @scan="onCameraScan" @error="e => error = e"/>
This browser has no camera API available.
</p>
<template v-else>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<label class="form-label">Camera</label>
<select class="form-control" v-model="selectedCameraId">
<option value="">Auto (prefer recent, then rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div v-if="cameraRunning" class="col-auto">
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
@change="onFileInputChange">
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
@dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
Drag &amp; drop an image here, paste one, or use the file picker above.
</div>
<div class="scan-canvas-wrap" v-show="hasFileImage">
<canvas ref="fileCanvas" class="scan-canvas"></canvas>
</div>
<ul class="scan-results">
<li v-if="hasFileImage && !fileResults.length" class="scan-results-empty">
No codes found.
</li>
<li v-for="(r, i) in fileResults" :key="i">
<span class="scan-result-type">{{ r.type }}</span>
<span v-if="r.metaText" class="text-muted"> {{ r.metaText }}</span>
<div class="text-break">
<a v-if="r.link?.href" :href="r.link.href" target="_blank"
rel="noopener noreferrer">{{ r.text }}</a>
<template v-else-if="r.link">
<router-link :to="r.link.to">{{ r.text }}</router-link>
<span v-if="r.error" class="text-danger"> &rarr; {{ r.error }}</span>
<span v-else-if="r.description" class="text-muted"> &rarr; {{ r.description }}</span>
<span v-else-if="r.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
</template>
<template v-else>{{ r.text }}</template>
</div>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
@ -121,10 +68,8 @@
<script> <script>
import {mapActions} from "vuex"; import {mapActions} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js"; import CameraScanner from "@/components/CameraScanner.vue";
import cameraManager from "@/cameraManager.js";
import {encodeHandleForUrl, expandedRoute} from "@/router"; import {encodeHandleForUrl, expandedRoute} from "@/router";
import {decodeShortId, deserializeShortId} from "@/short-id"; import {decodeShortId, deserializeShortId} from "@/short-id";
@ -174,7 +119,7 @@ function classifyScanText(text) {
} }
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like // Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
// "(v7, ec=M, mask=3)"; shared by the "from image" and "from camera" result lists below. // "(v7, ec=M, mask=3)".
function metaSummary(metadata) { function metaSummary(metadata) {
const parts = []; const parts = [];
if (metadata.version != null) { if (metadata.version != null) {
@ -192,15 +137,11 @@ function metaSummary(metadata) {
return parts.length ? `(${parts.join(", ")})` : ""; return parts.length ? `(${parts.join(", ")})` : "";
} }
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
// the code has moved out of frame.
const OVERLAY_CLEAR_MS = 2000;
export default { export default {
name: "Scan", name: "Scan",
components: { components: {
BaseLayout, BaseLayout,
...BIcons CameraScanner,
}, },
data() { data() {
return { return {
@ -210,35 +151,13 @@ export default {
// One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't // One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
// keep firing router.push for every later scan/decode while left switched on. // keep firing router.push for every later scan/decode while left switched on.
visitFirstMatch: false, // Defaults on when the page itself was opened with ?navigate=immediate (e.g. a link
// shared for a "scan and go" workflow), so the toggle doesn't need a manual flip first.
visitFirstMatch: this.$route.query.navigate === "immediate",
dropHover: false,
hasFileImage: false,
fileResults: [],
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
localSelectedCameraId: "",
cameraRunning: false,
cameraRes: "",
cameraLog: [], cameraLog: [],
}; };
}, },
computed: {
// Switches camera immediately on selection change (no explicit "apply" step), matching
// prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
selectedCameraId: {
get() {
return this.localSelectedCameraId;
},
set(value) {
this.localSelectedCameraId = value;
if (this.cameraRunning) {
this.switchCamera(value || null);
}
},
},
},
methods: { methods: {
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]), ...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
@ -249,13 +168,13 @@ export default {
// flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow // flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
// short id, or the owner/item genuinely couldn't be resolved). // short id, or the owner/item genuinely couldn't be resolved).
// //
// descriptionCache (keyed by the raw scanned text, shared across cameraLog and // descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame
// fileResults) memoizes the outcome - a still-in-frame code gets re-decoded and // code gets re-decoded and re-logged several times a second (see logDecode), and
// re-logged several times a second (see logDecode), and re-scanning the same printed // re-scanning the same printed label later is common too, so without this every repeat
// label later is common too, so without this every repeat would re-fire the same // would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap
// fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap round trip. Caching the // round trip. Caching the in-flight promise itself (not just its settled value) also
// in-flight promise itself (not just its settled value) also dedupes concurrent lookups // dedupes concurrent lookups for the same still-in-frame code, rather than firing one
// for the same still-in-frame code, rather than firing one request per decode. // request per decode.
resolveDescription(entry) { resolveDescription(entry) {
const {link, text} = entry; const {link, text} = entry;
if (!link || link.href) { if (!link || link.href) {
@ -341,106 +260,9 @@ export default {
return item ? `[#${item.id}] ${item.name}` : null; return item ? `[#${item.id}] ${item.name}` : null;
}, },
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes it; shared by the file // Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
// input, drag&drop, and paste handlers below to avoid duplicating this sequence. onCameraScan(codes) {
async decodeBlob(file) { codes.forEach(this.logDecode);
this.error = null;
try {
const bitmap = await createImageBitmap(file);
const canvas = this.$refs.fileCanvas;
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const anyd = await this.anydPromise;
const results = anyd.decodeImage(imageData);
this.drawBoxes(ctx, results);
this.hasFileImage = true;
this.fileResults = results.map(r => ({
...r, metaText: metaSummary(r.metadata), link: classifyScanText(r.text),
description: undefined, error: null
}));
this.fileResults.forEach(this.resolveDescription);
} catch (e) {
this.error = e.message ?? String(e);
}
},
drawBoxes(ctx, results) {
ctx.lineWidth = Math.max(2, ctx.canvas.width / 300);
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = `${Math.max(14, ctx.canvas.width / 60)}px sans-serif`;
for (const r of results) {
if (!r.box) {
continue;
}
const {x0, y0, x1, y1} = r.box;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
ctx.fillText(r.type, x0, Math.max(14, y0 - 4));
}
},
onFileInputChange() {
const file = this.$refs.fileInput.files[0];
if (file) {
this.decodeBlob(file);
}
},
onDragOver(e) {
e.preventDefault();
this.dropHover = true;
},
onDragLeave() {
this.dropHover = false;
},
onDrop(e) {
e.preventDefault();
this.dropHover = false;
const file = e.dataTransfer.files[0];
if (file) {
this.decodeBlob(file);
}
},
onPaste(e) {
const item = [...(e.clipboardData?.items ?? [])].find(i => i.type.startsWith("image/"));
if (item) {
this.decodeBlob(item.getAsFile());
}
},
// Device labels only populate once permission is granted, so this is called again right
// after getUserMedia resolves in startCamera, not just once up front.
async populateCameraList() {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
await cameraManager.enumerateDevices();
this.cameras = cameraManager.getAvailableCameras();
},
cleanCameraLabel(label) {
if (!label) return "";
const parts = label.split(":").map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
},
syncOverlaySize() {
const video = this.$refs.video;
const overlay = this.$refs.overlay;
if (!video || !overlay) {
return;
}
overlay.width = video.clientWidth;
overlay.height = video.clientHeight;
}, },
logDecode(code) { logDecode(code) {
@ -465,208 +287,16 @@ export default {
// CameraScanner's own dedupe window that makes them stale for re-matching. // CameraScanner's own dedupe window that makes them stale for re-matching.
this.cameraLog.length = Math.min(this.cameraLog.length, 20); this.cameraLog.length = Math.min(this.cameraLog.length, 20);
}, },
// Draws each detected code's box (video's native pixel space, scaled to the overlay's
// displayed size) and fades it out after OVERLAY_CLEAR_MS.
drawOverlay(codes) {
console.log("drawOverlay", codes);
const overlay = this.$refs.overlay;
const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) {
return;
}
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const scaleX = overlay.width / video.videoWidth;
const scaleY = overlay.height / video.videoHeight;
ctx.lineWidth = 3;
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = "14px sans-serif";
for (const code of codes) {
if (!code.box) {
continue;
}
const {x0, y0, x1, y1} = code.box;
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
}
clearTimeout(this.clearOverlayTimer);
this.clearOverlayTimer = setTimeout(
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
// Attaches `stream` to the video element so the scanner never needs recreating. See
// docs/implementation.md#video-stream-attach-and-resize-sync.
setupVideoStream(stream) {
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
},
// Resizes the overlay to match the video's new size once its intrinsic dimensions change.
// See docs/implementation.md#video-stream-attach-and-resize-sync.
onVideoResize() {
const video = this.$refs.video;
if (!video || !video.videoWidth) {
return;
}
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
},
async startCamera() {
this.error = null;
try {
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
this.setupVideoStream(stream);
await this.populateCameraList();
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(this.$refs.video, {
fps: 4,
downscale: 2,
onDecode: (codes) => {
codes.forEach(this.logDecode);
this.drawOverlay(codes);
},
onError: (err) => console.error("[camera scan]", err),
});
this.scanner.start();
this.cameraRunning = true;
} catch (e) {
this.error = `Camera error: ${e.message ?? e}`;
}
},
async switchCamera(cameraId) {
if (!this.cameraRunning) return;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (e) {
this.error = `Camera error: ${e.message ?? e}`;
}
},
stopCamera() {
this.scanner?.stop();
this.scanner = null;
cameraManager.closeStream();
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
this.cameraRunning = false;
const overlay = this.$refs.overlay;
if (overlay) {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
},
// cameraManager already tries a fallback device and only fires this event once none
// remain, so the running scan session has to stop here.
handleCameraDisconnected(event) {
if (!this.cameraRunning) return;
this.error = `Camera error: ${event.detail?.error || "Camera disconnected"}`;
this.stopCamera();
},
handleCameraReconnected() {
if (!this.cameraRunning) return;
const stream = cameraManager.getActiveStream();
if (!stream) return;
this.error = null;
this.setupVideoStream(stream);
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
},
}, },
created() { created() {
// Kept as a promise (not awaited) so decodeBlob and startCamera share one in-flight load;
// loadAnyDCode also memoizes, so later calls elsewhere in the app are free too.
this.anydPromise = loadAnyDCode();
this.scanner = null;
this.clearOverlayTimer = null;
// Not component data: resolveDescription's cached values are read back into each entry's // Not component data: resolveDescription's cached values are read back into each entry's
// own (reactive) `description` field, so the cache itself never needs to be reactive. // own (reactive) `description` field, so the cache itself never needs to be reactive.
this.descriptionCache = new Map(); this.descriptionCache = new Map();
}, },
mounted() {
window.addEventListener("paste", this.onPaste);
window.addEventListener("resize", this.syncOverlaySize);
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.addEventListener("resize", this.onVideoResize);
if (this.cameraSupported && this.insecureContext) {
this.startCamera();
} else {
this.populateCameraList();
}
},
beforeUnmount() {
this.stopCamera();
window.removeEventListener("paste", this.onPaste);
window.removeEventListener("resize", this.syncOverlaySize);
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer);
},
} }
</script> </script>
<style scoped> <style scoped>
.dropzone {
margin-bottom: .75rem;
border: 2px dashed var(--bs-gray-400);
border-radius: .35rem;
padding: 1.25rem;
text-align: center;
color: var(--bs-gray-600);
font-size: .9rem;
}
.dropzone-hover {
border-color: var(--bs-primary);
color: var(--bs-primary);
}
.scan-canvas-wrap {
text-align: center;
margin-bottom: .75rem;
}
.scan-canvas {
max-width: 100%;
border-radius: .35rem;
border: 1px solid var(--bs-gray-300);
}
.video-wrap {
position: relative;
margin-bottom: .75rem;
background: #000;
border-radius: .35rem;
overflow: hidden;
}
.video-wrap video {
display: block;
width: 100%;
height: auto;
}
.video-wrap canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.scan-results { .scan-results {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -688,9 +318,4 @@ export default {
font-weight: 600; font-weight: 600;
color: var(--bs-success); color: var(--bs-success);
} }
.scan-results-empty {
color: var(--bs-gray-600);
border-style: dashed;
}
</style> </style>