feat(new tool): camera screenshot and recorder
This commit is contained in:
		
							parent
							
								
									8515c24264
								
							
						
					
					
						commit
						34d8e5ce2c
					
				| @ -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…
	
		Reference in New Issue
	
	Block a user