stash
This commit is contained in:
parent
5c3b7fc252
commit
bbe52e4a78
9 changed files with 817 additions and 287 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue