237 lines
9.3 KiB
Vue
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> Capture
|
|
</button>
|
|
<button
|
|
class="btn btn-danger"
|
|
:class="{'disabled': !dataImage}"
|
|
@click="(dataImage) && retake()"
|
|
>
|
|
<b-icon-trash></b-icon-trash> Retake
|
|
</button>
|
|
<button
|
|
class="btn btn-primary"
|
|
:class="{'disabled': !dataImage}"
|
|
@click="(dataImage) && save()"
|
|
>
|
|
<b-icon-save></b-icon-save> 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>
|