aboutsummaryrefslogtreecommitdiff
path: root/src/tools/camera-recorder/camera-recorder.vue
diff options
context:
space:
mode:
Diffstat (limited to 'src/tools/camera-recorder/camera-recorder.vue')
-rw-r--r--src/tools/camera-recorder/camera-recorder.vue202
1 files changed, 202 insertions, 0 deletions
diff --git a/src/tools/camera-recorder/camera-recorder.vue b/src/tools/camera-recorder/camera-recorder.vue
new file mode 100644
index 0000000..81fec42
--- /dev/null
+++ b/src/tools/camera-recorder/camera-recorder.vue
@@ -0,0 +1,202 @@
+<template>
+ <div>
+ <c-card v-if="!isSupported"> Your browser does not support recording video from camera </c-card>
+
+ <c-card v-else-if="!permissionGranted" text-center>
+ You need to grant permission to use your camera and microphone
+
+ <c-alert v-if="permissionCannotBePrompted" mt-4 text-left>
+ Your browser has blocked permission request or does not support it. You need to grant permission manually in
+ your browser settings (usually the lock icon in the address bar).
+ </c-alert>
+
+ <div v-else mt-4 flex justify-center>
+ <c-button @click="requestPermissions">Grant permission</c-button>
+ </div>
+ </c-card>
+
+ <c-card v-else>
+ <div flex gap-2>
+ <div flex-1>
+ <div>Video</div>
+ <n-select
+ v-model:value="currentCamera"
+ :options="cameras.map(({ deviceId, label }) => ({ value: deviceId, label }))"
+ placeholder="Select camera"
+ />
+ </div>
+ <div v-if="currentMicrophone && microphones.length > 0" flex-1>
+ <div>Audio</div>
+ <n-select
+ v-model:value="currentMicrophone"
+ :options="microphones.map(({ deviceId, label }) => ({ value: deviceId, label }))"
+ placeholder="Select microphone"
+ />
+ </div>
+ </div>
+
+ <div v-if="!isMediaStreamAvailable" mt-3 flex justify-center>
+ <c-button type="primary" @click="start">Start webcam</c-button>
+ </div>
+
+ <div v-else>
+ <div my-2>
+ <video ref="video" autoplay controls playsinline max-h-full w-full />
+ </div>
+
+ <div flex items-center justify-between gap-2>
+ <c-button :disabled="!isMediaStreamAvailable" @click="takeScreenshot">
+ <span mr-2> <icon-mdi-camera /></span>
+ Take screenshot
+ </c-button>
+
+ <div v-if="isRecordingSupported" flex justify-center gap-2>
+ <c-button v-if="recordingState === 'stopped'" @click="startRecording">
+ <span mr-2> <icon-mdi-video /></span>
+ Start recording
+ </c-button>
+
+ <c-button v-if="recordingState === 'recording'" @click="pauseRecording">
+ <span mr-2> <icon-mdi-pause /></span>
+ Pause
+ </c-button>
+
+ <c-button v-if="recordingState === 'paused'" @click="resumeRecording">
+ <span mr-2> <icon-mdi-play /></span>
+ Resume
+ </c-button>
+
+ <c-button v-if="recordingState !== 'stopped'" type="error" @click="stopRecording">
+ <span mr-2> <icon-mdi-record /></span>
+ Stop
+ </c-button>
+ </div>
+ <div v-else italic op-60>Video recording is not supported in your browser</div>
+ </div>
+ </div>
+ </c-card>
+
+ <div grid grid-cols-2 mt-5 gap-2>
+ <c-card v-for="({ type, value, createdAt }, index) in medias" :key="index">
+ <img v-if="type === 'image'" :src="value" max-h-full w-full alt="screenshot" />
+
+ <video v-else :src="value" controls max-h-full w-full />
+
+ <div flex items-center justify-between>
+ <div font-bold>{{ type === 'image' ? 'Screenshot' : 'Video' }}</div>
+
+ <div flex gap-2>
+ <c-button @click="downloadMedia({ type, value, createdAt })">
+ <icon-mdi-download />
+ </c-button>
+
+ <c-button @click="medias = medias.filter((_ignored, i) => i !== index)">
+ <icon-mdi-delete-outline />
+ </c-button>
+ </div>
+ </div>
+ </c-card>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import _ from 'lodash';
+
+import { useMediaRecorder } from './useMediaRecorder';
+
+type Media = { type: 'image' | 'video'; value: string; createdAt: Date };
+
+const {
+ videoInputs: cameras,
+ audioInputs: microphones,
+ permissionGranted,
+ isSupported,
+ ensurePermissions,
+} = useDevicesList({
+ requestPermissions: true,
+ constraints: { video: true, audio: true },
+ onUpdated() {
+ refreshCurrentDevices();
+ },
+});
+
+const video = ref<HTMLVideoElement>();
+const medias = ref<Media[]>([]);
+const currentCamera = ref(cameras.value[0]?.deviceId);
+const currentMicrophone = ref(microphones.value[0]?.deviceId);
+const permissionCannotBePrompted = ref(false);
+
+const {
+ stream,
+ start,
+ enabled: isMediaStreamAvailable,
+} = useUserMedia({
+ constraints: computed(() => ({
+ video: { deviceId: currentCamera.value },
+ ...(currentMicrophone.value ? { audio: { deviceId: currentMicrophone.value } } : {}),
+ })),
+ autoSwitch: true,
+});
+
+const {
+ isRecordingSupported,
+ onRecordAvailable,
+ startRecording,
+ stopRecording,
+ pauseRecording,
+ recordingState,
+ resumeRecording,
+} = useMediaRecorder({
+ stream,
+});
+
+onRecordAvailable((value) => {
+ medias.value.unshift({ type: 'video', value, createdAt: new Date() });
+});
+
+function refreshCurrentDevices() {
+ console.log('refreshCurrentDevices');
+
+ if (_.isNil(currentCamera) || !cameras.value.find((i) => i.deviceId === currentCamera.value)) {
+ currentCamera.value = cameras.value[0]?.deviceId;
+ }
+
+ if (_.isNil(microphones) || !microphones.value.find((i) => i.deviceId === currentMicrophone.value)) {
+ currentMicrophone.value = microphones.value[0]?.deviceId;
+ }
+}
+
+function takeScreenshot() {
+ if (!video.value) return;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = video.value.videoWidth;
+ canvas.height = video.value.videoHeight;
+ canvas.getContext('2d')?.drawImage(video.value, 0, 0);
+ const image = canvas.toDataURL('image/png');
+
+ medias.value.unshift({ type: 'image', value: image, createdAt: new Date() });
+}
+
+watchEffect(() => {
+ if (video.value && stream.value) video.value.srcObject = stream.value;
+});
+
+async function requestPermissions() {
+ try {
+ await ensurePermissions();
+ } catch (e) {
+ permissionCannotBePrompted.value = true;
+ }
+}
+
+function downloadMedia({ type, value, createdAt }: Media) {
+ const link = document.createElement('a');
+ link.href = value;
+ link.download = `${type}-${createdAt.getTime()}.${type === 'image' ? 'png' : 'webm'}`;
+ link.click();
+}
+</script>
+
+<style lang="less" scoped></style>