mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-04-20 14:56:17 -04:00
feat(new tool): camera screenshot and recorder
This commit is contained in:
parent
8515c24264
commit
34d8e5ce2c
15 changed files with 448 additions and 10 deletions
|
@ -22,7 +22,9 @@
|
||||||
"createGlobalState": true,
|
"createGlobalState": true,
|
||||||
"createInjectionState": true,
|
"createInjectionState": true,
|
||||||
"createReactiveFn": true,
|
"createReactiveFn": true,
|
||||||
|
"createReusableTemplate": true,
|
||||||
"createSharedComposable": true,
|
"createSharedComposable": true,
|
||||||
|
"createTemplatePromise": true,
|
||||||
"createUnrefFn": true,
|
"createUnrefFn": true,
|
||||||
"customRef": true,
|
"customRef": true,
|
||||||
"debouncedRef": true,
|
"debouncedRef": true,
|
||||||
|
@ -42,9 +44,6 @@
|
||||||
"isReactive": true,
|
"isReactive": true,
|
||||||
"isReadonly": true,
|
"isReadonly": true,
|
||||||
"isRef": true,
|
"isRef": true,
|
||||||
"logicAnd": true,
|
|
||||||
"logicNot": true,
|
|
||||||
"logicOr": true,
|
|
||||||
"makeDestructurable": true,
|
"makeDestructurable": true,
|
||||||
"markRaw": true,
|
"markRaw": true,
|
||||||
"nextTick": true,
|
"nextTick": true,
|
||||||
|
@ -97,6 +96,7 @@
|
||||||
"toReactive": true,
|
"toReactive": true,
|
||||||
"toRef": true,
|
"toRef": true,
|
||||||
"toRefs": true,
|
"toRefs": true,
|
||||||
|
"toValue": true,
|
||||||
"triggerRef": true,
|
"triggerRef": true,
|
||||||
"tryOnBeforeMount": true,
|
"tryOnBeforeMount": true,
|
||||||
"tryOnBeforeUnmount": true,
|
"tryOnBeforeUnmount": true,
|
||||||
|
@ -107,6 +107,19 @@
|
||||||
"unrefElement": true,
|
"unrefElement": true,
|
||||||
"until": true,
|
"until": true,
|
||||||
"useActiveElement": true,
|
"useActiveElement": true,
|
||||||
|
"useAnimate": true,
|
||||||
|
"useArrayDifference": true,
|
||||||
|
"useArrayEvery": true,
|
||||||
|
"useArrayFilter": true,
|
||||||
|
"useArrayFind": true,
|
||||||
|
"useArrayFindIndex": true,
|
||||||
|
"useArrayFindLast": true,
|
||||||
|
"useArrayIncludes": true,
|
||||||
|
"useArrayJoin": true,
|
||||||
|
"useArrayMap": true,
|
||||||
|
"useArrayReduce": true,
|
||||||
|
"useArraySome": true,
|
||||||
|
"useArrayUnique": true,
|
||||||
"useAsyncQueue": true,
|
"useAsyncQueue": true,
|
||||||
"useAsyncState": true,
|
"useAsyncState": true,
|
||||||
"useAttrs": true,
|
"useAttrs": true,
|
||||||
|
@ -117,8 +130,8 @@
|
||||||
"useBroadcastChannel": true,
|
"useBroadcastChannel": true,
|
||||||
"useBrowserLocation": true,
|
"useBrowserLocation": true,
|
||||||
"useCached": true,
|
"useCached": true,
|
||||||
"useClamp": true,
|
|
||||||
"useClipboard": true,
|
"useClipboard": true,
|
||||||
|
"useCloned": true,
|
||||||
"useColorMode": true,
|
"useColorMode": true,
|
||||||
"useConfirmDialog": true,
|
"useConfirmDialog": true,
|
||||||
"useCounter": true,
|
"useCounter": true,
|
||||||
|
@ -192,12 +205,18 @@
|
||||||
"useOnline": true,
|
"useOnline": true,
|
||||||
"usePageLeave": true,
|
"usePageLeave": true,
|
||||||
"useParallax": true,
|
"useParallax": true,
|
||||||
|
"useParentElement": true,
|
||||||
|
"usePerformanceObserver": true,
|
||||||
"usePermission": true,
|
"usePermission": true,
|
||||||
"usePointer": true,
|
"usePointer": true,
|
||||||
|
"usePointerLock": true,
|
||||||
"usePointerSwipe": true,
|
"usePointerSwipe": true,
|
||||||
"usePreferredColorScheme": true,
|
"usePreferredColorScheme": true,
|
||||||
|
"usePreferredContrast": true,
|
||||||
"usePreferredDark": true,
|
"usePreferredDark": true,
|
||||||
"usePreferredLanguages": true,
|
"usePreferredLanguages": true,
|
||||||
|
"usePreferredReducedMotion": true,
|
||||||
|
"usePrevious": true,
|
||||||
"useRafFn": true,
|
"useRafFn": true,
|
||||||
"useRefHistory": true,
|
"useRefHistory": true,
|
||||||
"useResizeObserver": true,
|
"useResizeObserver": true,
|
||||||
|
@ -211,14 +230,17 @@
|
||||||
"useSessionStorage": true,
|
"useSessionStorage": true,
|
||||||
"useShare": true,
|
"useShare": true,
|
||||||
"useSlots": true,
|
"useSlots": true,
|
||||||
|
"useSorted": true,
|
||||||
"useSpeechRecognition": true,
|
"useSpeechRecognition": true,
|
||||||
"useSpeechSynthesis": true,
|
"useSpeechSynthesis": true,
|
||||||
"useStepper": true,
|
"useStepper": true,
|
||||||
"useStorage": true,
|
"useStorage": true,
|
||||||
"useStorageAsync": true,
|
"useStorageAsync": true,
|
||||||
"useStyleTag": true,
|
"useStyleTag": true,
|
||||||
|
"useSupported": true,
|
||||||
"useSwipe": true,
|
"useSwipe": true,
|
||||||
"useTemplateRefsList": true,
|
"useTemplateRefsList": true,
|
||||||
|
"useTextDirection": true,
|
||||||
"useTextSelection": true,
|
"useTextSelection": true,
|
||||||
"useTextareaAutosize": true,
|
"useTextareaAutosize": true,
|
||||||
"useThrottle": true,
|
"useThrottle": true,
|
||||||
|
@ -230,6 +252,8 @@
|
||||||
"useTimeoutPoll": true,
|
"useTimeoutPoll": true,
|
||||||
"useTimestamp": true,
|
"useTimestamp": true,
|
||||||
"useTitle": true,
|
"useTitle": true,
|
||||||
|
"useToNumber": true,
|
||||||
|
"useToString": true,
|
||||||
"useToggle": true,
|
"useToggle": true,
|
||||||
"useTransition": true,
|
"useTransition": true,
|
||||||
"useUrlSearchParams": true,
|
"useUrlSearchParams": true,
|
||||||
|
@ -250,8 +274,10 @@
|
||||||
"watchArray": true,
|
"watchArray": true,
|
||||||
"watchAtMost": true,
|
"watchAtMost": true,
|
||||||
"watchDebounced": true,
|
"watchDebounced": true,
|
||||||
|
"watchDeep": true,
|
||||||
"watchEffect": true,
|
"watchEffect": true,
|
||||||
"watchIgnorable": true,
|
"watchIgnorable": true,
|
||||||
|
"watchImmediate": true,
|
||||||
"watchOnce": true,
|
"watchOnce": true,
|
||||||
"watchPausable": true,
|
"watchPausable": true,
|
||||||
"watchPostEffect": true,
|
"watchPostEffect": true,
|
||||||
|
|
16
components.d.ts
vendored
16
components.d.ts
vendored
|
@ -19,6 +19,9 @@ declare module '@vue/runtime-core' {
|
||||||
Bcrypt: typeof import('./src/tools/bcrypt/bcrypt.vue')['default']
|
Bcrypt: typeof import('./src/tools/bcrypt/bcrypt.vue')['default']
|
||||||
BenchmarkBuilder: typeof import('./src/tools/benchmark-builder/benchmark-builder.vue')['default']
|
BenchmarkBuilder: typeof import('./src/tools/benchmark-builder/benchmark-builder.vue')['default']
|
||||||
Bip39Generator: typeof import('./src/tools/bip39-generator/bip39-generator.vue')['default']
|
Bip39Generator: typeof import('./src/tools/bip39-generator/bip39-generator.vue')['default']
|
||||||
|
CAlert: typeof import('./src/ui/c-alert/c-alert.vue')['default']
|
||||||
|
'CAlert.demo': typeof import('./src/ui/c-alert/c-alert.demo.vue')['default']
|
||||||
|
CameraRecorder: typeof import('./src/tools/camera-recorder/camera-recorder.vue')['default']
|
||||||
CaseConverter: typeof import('./src/tools/case-converter/case-converter.vue')['default']
|
CaseConverter: typeof import('./src/tools/case-converter/case-converter.vue')['default']
|
||||||
CButton: typeof import('./src/ui/c-button/c-button.vue')['default']
|
CButton: typeof import('./src/ui/c-button/c-button.vue')['default']
|
||||||
'CButton.demo': typeof import('./src/ui/c-button/c-button.demo.vue')['default']
|
'CButton.demo': typeof import('./src/ui/c-button/c-button.demo.vue')['default']
|
||||||
|
@ -57,11 +60,24 @@ declare module '@vue/runtime-core' {
|
||||||
HtmlWysiwygEditor: typeof import('./src/tools/html-wysiwyg-editor/html-wysiwyg-editor.vue')['default']
|
HtmlWysiwygEditor: typeof import('./src/tools/html-wysiwyg-editor/html-wysiwyg-editor.vue')['default']
|
||||||
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
|
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
|
||||||
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
|
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
|
||||||
|
IconMdiCamera: typeof import('~icons/mdi/camera')['default']
|
||||||
|
IconMdiCameraOutline: typeof import('~icons/mdi/camera-outline')['default']
|
||||||
|
IconMdiCameraVideoOff: typeof import('~icons/mdi/camera-video-off')['default']
|
||||||
IconMdiClose: typeof import('~icons/mdi/close')['default']
|
IconMdiClose: typeof import('~icons/mdi/close')['default']
|
||||||
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
|
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
|
||||||
|
IconMdiDelete: typeof import('~icons/mdi/delete')['default']
|
||||||
|
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
||||||
|
IconMdiDeleteOutlined: typeof import('~icons/mdi/delete-outlined')['default']
|
||||||
|
IconMdiDownload: typeof import('~icons/mdi/download')['default']
|
||||||
IconMdiEye: typeof import('~icons/mdi/eye')['default']
|
IconMdiEye: typeof import('~icons/mdi/eye')['default']
|
||||||
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
|
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
|
||||||
|
IconMdiPause: typeof import('~icons/mdi/pause')['default']
|
||||||
|
IconMdiPlay: typeof import('~icons/mdi/play')['default']
|
||||||
|
IconMdiRecord: typeof import('~icons/mdi/record')['default']
|
||||||
|
IconMdiRecordRec: typeof import('~icons/mdi/record-rec')['default']
|
||||||
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||||
|
IconMdiStopCircle: typeof import('~icons/mdi/stop-circle')['default']
|
||||||
|
IconMdiVideo: typeof import('~icons/mdi/video')['default']
|
||||||
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
|
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
|
||||||
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
|
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
|
||||||
Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default']
|
Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default']
|
||||||
|
|
|
@ -4,10 +4,10 @@
|
||||||
v-model:value="input"
|
v-model:value="input"
|
||||||
:placeholder="inputPlaceholder"
|
:placeholder="inputPlaceholder"
|
||||||
:label="inputLabel"
|
:label="inputLabel"
|
||||||
multiline
|
|
||||||
autosize
|
|
||||||
rows="20"
|
rows="20"
|
||||||
|
autosize
|
||||||
raw-text
|
raw-text
|
||||||
|
multiline
|
||||||
test-id="input"
|
test-id="input"
|
||||||
:validation-rules="inputValidationRules"
|
:validation-rules="inputValidationRules"
|
||||||
/>
|
/>
|
||||||
|
|
7
src/modules/shared/date.models.ts
Normal file
7
src/modules/shared/date.models.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export { getUrlFriendlyDateTime };
|
||||||
|
|
||||||
|
function getUrlFriendlyDateTime({ date = new Date() }: { date?: Date } = {}) {
|
||||||
|
return format(date, 'yyyy-MM-dd-HH-mm-ss');
|
||||||
|
}
|
6
src/shims.d.ts
vendored
6
src/shims.d.ts
vendored
|
@ -8,3 +8,9 @@ declare module '*.md' {
|
||||||
const Component: ComponentOptions;
|
const Component: ComponentOptions;
|
||||||
export default Component;
|
export default Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '~icons/*' {
|
||||||
|
import { FunctionalComponent, SVGAttributes } from 'vue';
|
||||||
|
const component: FunctionalComponent<SVGAttributes>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
202
src/tools/camera-recorder/camera-recorder.vue
Normal file
202
src/tools/camera-recorder/camera-recorder.vue
Normal file
|
@ -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>
|
12
src/tools/camera-recorder/index.ts
Normal file
12
src/tools/camera-recorder/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { Camera } from '@vicons/tabler';
|
||||||
|
import { defineTool } from '../tool';
|
||||||
|
|
||||||
|
export const tool = defineTool({
|
||||||
|
name: 'Camera recorder',
|
||||||
|
path: '/camera-recorder',
|
||||||
|
description: 'Take a picture or record a video from your webcam or camera.',
|
||||||
|
keywords: ['camera', 'recoder'],
|
||||||
|
component: () => import('./camera-recorder.vue'),
|
||||||
|
icon: Camera,
|
||||||
|
createdAt: new Date('2023-05-15'),
|
||||||
|
});
|
88
src/tools/camera-recorder/useMediaRecorder.ts
Normal file
88
src/tools/camera-recorder/useMediaRecorder.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import { computed, ref, type Ref } from 'vue';
|
||||||
|
|
||||||
|
export { useMediaRecorder };
|
||||||
|
|
||||||
|
function useMediaRecorder({ stream }: { stream: Ref<MediaStream | undefined> }): {
|
||||||
|
isRecordingSupported: Ref<boolean>;
|
||||||
|
recordingState: Ref<'stopped' | 'recording' | 'paused'>;
|
||||||
|
startRecording: () => void;
|
||||||
|
stopRecording: () => void;
|
||||||
|
pauseRecording: () => void;
|
||||||
|
resumeRecording: () => void;
|
||||||
|
onRecordAvailable: (cb: (url: string) => void) => void;
|
||||||
|
} {
|
||||||
|
const isRecordingSupported = computed(() => MediaRecorder.isTypeSupported('video/webm'));
|
||||||
|
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||||
|
const recordedChunks = ref<Blob[]>([]);
|
||||||
|
const recordAvailable = createEventHook();
|
||||||
|
const recordingState = ref<'stopped' | 'recording' | 'paused'>('stopped');
|
||||||
|
|
||||||
|
const startRecording = () => {
|
||||||
|
if (!isRecordingSupported.value) return;
|
||||||
|
if (!stream.value) return;
|
||||||
|
if (recordingState.value !== 'stopped') return;
|
||||||
|
|
||||||
|
mediaRecorder.value = new MediaRecorder(stream.value, { mimeType: 'video/webm' });
|
||||||
|
|
||||||
|
mediaRecorder.value.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) {
|
||||||
|
recordedChunks.value.push(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.value.onstop = () => {
|
||||||
|
recordAvailable.trigger(createVideo());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mediaRecorder.value.state !== 'inactive') return;
|
||||||
|
|
||||||
|
mediaRecorder.value.start();
|
||||||
|
recordingState.value = 'recording';
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
if (!isRecordingSupported.value) return;
|
||||||
|
if (!mediaRecorder.value) return;
|
||||||
|
if (recordingState.value === 'stopped') return;
|
||||||
|
|
||||||
|
mediaRecorder.value.stop();
|
||||||
|
recordingState.value = 'stopped';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pauseRecording = () => {
|
||||||
|
if (!isRecordingSupported.value) return;
|
||||||
|
if (!mediaRecorder.value) return;
|
||||||
|
if (recordingState.value !== 'recording') return;
|
||||||
|
|
||||||
|
mediaRecorder.value.pause();
|
||||||
|
recordingState.value = 'paused';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeRecording = () => {
|
||||||
|
if (!isRecordingSupported.value) return;
|
||||||
|
if (!mediaRecorder.value) return;
|
||||||
|
|
||||||
|
if (recordingState.value !== 'paused') return;
|
||||||
|
|
||||||
|
mediaRecorder.value.resume();
|
||||||
|
recordingState.value = 'recording';
|
||||||
|
};
|
||||||
|
|
||||||
|
const createVideo = () => {
|
||||||
|
const blob = new Blob(recordedChunks.value, { type: 'video/webm' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
recordedChunks.value = [];
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecordingSupported,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
pauseRecording,
|
||||||
|
resumeRecording,
|
||||||
|
recordingState,
|
||||||
|
|
||||||
|
onRecordAvailable: recordAvailable.on,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { tool as base64FileConverter } from './base64-file-converter';
|
import { tool as base64FileConverter } from './base64-file-converter';
|
||||||
import { tool as base64StringConverter } from './base64-string-converter';
|
import { tool as base64StringConverter } from './base64-string-converter';
|
||||||
import { tool as basicAuthGenerator } from './basic-auth-generator';
|
import { tool as basicAuthGenerator } from './basic-auth-generator';
|
||||||
|
import { tool as cameraRecorder } from './camera-recorder';
|
||||||
import { tool as listConverter } from './list-converter';
|
import { tool as listConverter } from './list-converter';
|
||||||
import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter';
|
import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter';
|
||||||
import { tool as jsonDiff } from './json-diff';
|
import { tool as jsonDiff } from './json-diff';
|
||||||
|
@ -99,8 +100,8 @@ export const toolsByCategory: ToolCategory[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Images',
|
name: 'Images and videos',
|
||||||
components: [qrCodeGenerator, svgPlaceholderGenerator],
|
components: [qrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Development',
|
name: 'Development',
|
||||||
|
|
11
src/ui/c-alert/c-alert.demo.vue
Normal file
11
src/ui/c-alert/c-alert.demo.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<c-alert v-for="variant in variants" :key="variant" :type="variant" mb-4>
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni reprehenderit itaque enim? Suscipit magni optio velit
|
||||||
|
quia, eveniet repellat pariatur quaerat laudantium dignissimos natus, beatae deleniti adipisci, atque necessitatibus
|
||||||
|
odio!
|
||||||
|
</c-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const variants = ['warning'] as const;
|
||||||
|
</script>
|
25
src/ui/c-alert/c-alert.theme.ts
Normal file
25
src/ui/c-alert/c-alert.theme.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { darken } from '../color/color.models';
|
||||||
|
import { defineThemes } from '../theme/theme.models';
|
||||||
|
import { appThemes } from '../theme/themes';
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import WarningIcon from '~icons/mdi/alert-circle-outline';
|
||||||
|
|
||||||
|
export const { useTheme } = defineThemes({
|
||||||
|
dark: {
|
||||||
|
warning: {
|
||||||
|
backgroundColor: appThemes.dark.warning.colorFaded,
|
||||||
|
borderColor: appThemes.dark.warning.color,
|
||||||
|
textColor: appThemes.dark.warning.color,
|
||||||
|
icon: WarningIcon,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
warning: {
|
||||||
|
backgroundColor: appThemes.light.warning.colorFaded,
|
||||||
|
borderColor: appThemes.light.warning.color,
|
||||||
|
textColor: darken(appThemes.light.warning.color, 40),
|
||||||
|
icon: WarningIcon,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
32
src/ui/c-alert/c-alert.vue
Normal file
32
src/ui/c-alert/c-alert.vue
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<template>
|
||||||
|
<div class="c-alert" flex items-center b-rd-4px pa-5>
|
||||||
|
<div class="c-alert--icon" mr-4 text-40px op-60>
|
||||||
|
<slot name="icon">
|
||||||
|
<component :is="variantTheme.icon" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-alert--content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useTheme } from './c-alert.theme';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ type?: 'warning' }>(), { type: 'warning' });
|
||||||
|
const { type } = toRefs(props);
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const variantTheme = computed(() => theme.value[type.value]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.c-alert {
|
||||||
|
background-color: v-bind('variantTheme.backgroundColor');
|
||||||
|
color: v-bind('variantTheme.textColor');
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -45,7 +45,7 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
const buttonVariants = ['basic', 'text'] as const;
|
const buttonVariants = ['basic', 'text'] as const;
|
||||||
const buttonTypes = ['default', 'primary', 'warning'] as const;
|
const buttonTypes = ['default', 'primary', 'warning', 'error'] as const;
|
||||||
const buttonSizes = ['small', 'medium', 'large'] as const;
|
const buttonSizes = ['small', 'medium', 'large'] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -61,6 +61,12 @@ const createTheme = ({ style }: { style: 'light' | 'dark' }) => {
|
||||||
hoverBackground: lighten(theme.warning.colorFaded, 30),
|
hoverBackground: lighten(theme.warning.colorFaded, 30),
|
||||||
pressedBackground: darken(theme.warning.colorFaded, 30),
|
pressedBackground: darken(theme.warning.colorFaded, 30),
|
||||||
}),
|
}),
|
||||||
|
error: createState({
|
||||||
|
textColor: theme.error.color,
|
||||||
|
backgroundColor: theme.error.colorFaded,
|
||||||
|
hoverBackground: lighten(theme.error.colorFaded, 30),
|
||||||
|
pressedBackground: darken(theme.error.colorFaded, 30),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
default: createState({
|
default: createState({
|
||||||
|
@ -81,6 +87,12 @@ const createTheme = ({ style }: { style: 'light' | 'dark' }) => {
|
||||||
hoverBackground: theme.warning.colorFaded,
|
hoverBackground: theme.warning.colorFaded,
|
||||||
pressedBackground: darken(theme.warning.colorFaded, 30),
|
pressedBackground: darken(theme.warning.colorFaded, 30),
|
||||||
}),
|
}),
|
||||||
|
error: createState({
|
||||||
|
textColor: darken(theme.error.color, 20),
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
hoverBackground: theme.error.colorFaded,
|
||||||
|
pressedBackground: darken(theme.error.colorFaded, 30),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { useAppTheme } from '../theme/themes';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
type?: 'default' | 'primary' | 'warning';
|
type?: 'default' | 'primary' | 'warning' | 'error';
|
||||||
variant?: 'basic' | 'text';
|
variant?: 'basic' | 'text';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue