This commit is contained in:
j3d1 2026-08-23 00:56:03 +02:00
parent 5c3b7fc252
commit bbe52e4a78
9 changed files with 817 additions and 287 deletions

View file

@ -14,10 +14,57 @@
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from image</h5>
<h5 class="card-title mb-0">Results</h5>
</div>
<div class="card-body">
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<p v-if="!cameraSupported" class="text-muted">
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}"
@ -40,58 +87,6 @@
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<p v-if="!cameraSupported" class="text-muted">
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"
:disabled="cameraRunning">
<option value="">Auto (prefer rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ c.label || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div class="col-auto">
<button v-if="!cameraRunning" class="btn btn-primary"
:disabled="!insecureContext" @click="startCamera">
<b-icon-camera class="me-1"></b-icon-camera>
Start camera
</button>
<button v-else 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>
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</main>
@ -102,6 +97,7 @@
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
import cameraManager from "@/cameraManager.js";
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
@ -145,12 +141,27 @@ export default {
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
selectedCameraId: "",
localSelectedCameraId: "",
cameraRunning: false,
cameraRes: "",
cameraLog: [],
};
},
computed: {
// Switches camera the moment the select changes, rather than waiting for an explicit
// "apply" step - matches 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: {
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
// shared by the file input, drag&drop and paste handlers below rather than each
@ -229,8 +240,17 @@ export default {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
this.cameras = devices.filter(d => d.kind === "videoinput");
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() {
@ -260,6 +280,7 @@ export default {
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
// has moved out of frame.
drawOverlay(codes) {
console.log("drawOverlay", codes);
const overlay = this.$refs.overlay;
const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) {
@ -286,26 +307,43 @@ export default {
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
// Attaches `stream` to the video element - shared by startCamera and the
// camera-switch/reconnect paths so the scanner (which just keeps reading frames off the
// same video element) never needs to be recreated. videoWidth/videoHeight aren't known yet
// right after play() - the video's own "resize" event (see onVideoResize) is what tells us
// the new aspect ratio has actually taken effect, which is also when the overlay needs to
// be resized to match.
setupVideoStream(stream) {
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
},
// Fires on the video element's own "resize"/"loadedmetadata" events - i.e. whenever its
// intrinsic width/height actually change (initial load, or switching to a camera with a
// different native resolution/aspect ratio) - so the overlay canvas is resized to match the
// video's *new* rendered size rather than the stale one from before the switch.
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 constraints = {
video: this.selectedCameraId
? {deviceId: {exact: this.selectedCameraId}}
: {facingMode: {ideal: "environment"}},
audio: false,
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = this.$refs.video;
video.srcObject = this.stream;
await video.play();
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
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(video, {
fps: 12,
this.scanner = anyd.createCameraScanner(this.$refs.video, {
fps: 4,
downscale: 2,
onDecode: (codes) => {
codes.forEach(this.logDecode);
@ -320,11 +358,20 @@ export default {
}
},
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;
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
cameraManager.closeStream();
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
@ -334,25 +381,56 @@ export default {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
},
// The camera manager already tries a fallback device on disconnect (see cameraManager.js)
// and only fires 'camera-disconnected' once none is left, so by the time this runs there's
// nothing left to fall back to and the running scan session has to stop.
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() {
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
this.anydPromise = loadAnyDCode();
this.stream = null;
this.scanner = null;
this.clearOverlayTimer = null;
},
mounted() {
window.addEventListener("paste", this.onPaste);
window.addEventListener("resize", this.syncOverlaySize);
this.populateCameraList();
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);
},
}
@ -412,7 +490,6 @@ export default {
display: flex;
flex-direction: column;
gap: .4rem;
max-height: 260px;
overflow-y: auto;
}