toolshed/frontend/src/components/inputs/WebcamFileSource.vue
2026-08-24 15:57:17 +02:00

237 lines
9.3 KiB
Vue

<template>
<div class="d-inline-block">
<label @click="open">
<slot></slot>
</label>
<div class="modal" :class="{'d-block': show_modal}" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content p-2">
<button type="button" class="btn-close position-absolute fixed-top m-2"
style="right: 1rem; left: auto;"
@click="close"></button>
<div v-if="error" class="alert alert-danger" role="alert">
{{ lastError }}
</div>
<div class="row" v-if="capturing && !streaming">
<div class="spinner-grow text-danger mx-auto" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<img
v-if="!capturing && dataImage"
class="img-fluid rounded mx-auto d-block w-75"
:src="dataImage"
alt="Image not available."
/>
<video
ref="video"
class="img-fluid rounded d-block img-preview w-75 mx-auto"
:class="{'d-none': !capturing}"
>
Video stream not available.
</video>
<canvas ref="canvas" class="img-fluid d-none img-preview"/>
<div class="position-absolute fixed-bottom text-center">
<div class="btn-group shadow">
<button
class="btn btn-success"
:class="{'disabled': dataImage}"
@click="(!dataImage) && captureVideoImage()">
<b-icon-camera></b-icon-camera>&nbsp;Capture
</button>
<button
class="btn btn-danger"
:class="{'disabled': !dataImage}"
@click="(dataImage) && retake()"
>
<b-icon-trash></b-icon-trash>&nbsp;Retake
</button>
<button
class="btn btn-primary"
:class="{'disabled': !dataImage}"
@click="(dataImage) && save()"
>
<b-icon-save></b-icon-save>&nbsp;Save
</button>
</div>
<select
v-if="availableCameras.length > 1"
v-model="selectedCameraId"
class="form-select position-relative w-50 mx-auto mb-5 mt-2 shadow"
aria-label="Select Camera Source">
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera.deviceId">
{{ cleanCameraLabel(camera.label) || `Camera ${availableCameras.indexOf(camera) + 1}` }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<script>
import cameraManager from '@/cameraManager.js';
export default {
name: "WebcamFileSource",
data: () => ({
lastError: undefined,
error: false,
show_modal: false,
availableCameras: [],
localSelectedCameraId: null,
capturing: false,
streaming: false,
dataImage: undefined
}),
emits: ['input'],
computed: {
selectedCameraId: {
get() {
return this.localSelectedCameraId || cameraManager.getSelectedCameraId();
},
set(value) {
if (value) {
this.localSelectedCameraId = value;
this.switchCamera(value);
}
},
},
},
methods: {
setupVideoStream(stream) {
this.capturing = true;
this.streaming = false;
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
video.oncanplay = () => {
this.streaming = true;
this.availableCameras = cameraManager.getAvailableCameras();
this.localSelectedCameraId = cameraManager.getSelectedCameraId();
};
},
async openStream(cameraId) {
this.error = false;
this.lastError = undefined;
try {
const stream = await cameraManager.openStream(cameraId);
this.setupVideoStream(stream);
} catch (err) {
console.error('Failed to open camera stream:', err);
if (err.name === "NotAllowedError") this.lastError = "Camera Permission Not Granted";
else if (err.name === "NotReadableError") this.lastError = "Camera Hardware Error";
else this.lastError = "Unknown Error";
this.error = true;
this.capturing = false;
}
},
async switchCamera(cameraId) {
if (!this.capturing) return;
this.streaming = false;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (err) {
console.error('Failed to switch camera:', err);
}
},
closeStream() {
if (this.capturing) {
cameraManager.closeStream();
this.capturing = false;
this.streaming = false;
}
},
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;
},
handleCameraDisconnected(event) {
console.error('Camera disconnected:', event.detail);
this.streaming = false;
this.error = true;
this.lastError = 'Camera Disconnected';
},
handleCameraReconnected() {
const stream = cameraManager.getActiveStream();
if (stream) {
this.error = false;
this.lastError = undefined;
this.setupVideoStream(stream);
}
},
async open() {
this.show_modal = true;
await this.openStream();
},
async close() {
this.closeStream();
this.show_modal = false;
this.dataImage = undefined;
this.error = false;
this.lastError = undefined;
},
captureVideoImage() {
const {video, canvas} = this.$refs;
const context = canvas.getContext('2d');
const {videoWidth, videoHeight} = video;
canvas.width = videoWidth;
canvas.height = videoHeight;
context.drawImage(video, 0, 0, videoWidth, videoHeight);
this.dataImage = canvas.toDataURL('image/jpeg', 0.5);
this.closeStream();
},
retake() {
this.dataImage = undefined;
this.openStream();
},
async refreshCameraList() {
await cameraManager.enumerateDevices();
this.availableCameras = cameraManager.getAvailableCameras();
},
async save() {
const mimeType = this.dataImage.split(';')[0].split(':')[1];
const data = this.dataImage.split(',')[1];
const raw_data = atob(data);
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
const bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
const image = {
name: hash.slice(0, 12) + ".jpg",
size: raw_data.length,
mime_type: mimeType,
data: data,
hash: hash,
};
this.$emit('input', [image]);
this.close();
}
},
mounted() {
window.addEventListener('camera-disconnected', this.handleCameraDisconnected);
window.addEventListener('camera-reconnected', this.handleCameraReconnected);
navigator.mediaDevices?.addEventListener('devicechange', this.refreshCameraList);
},
beforeUnmount() {
window.removeEventListener('camera-disconnected', this.handleCameraDisconnected);
window.removeEventListener('camera-reconnected', this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener('devicechange', this.refreshCameraList);
this.closeStream();
},
}
</script>