| 1 | import type RecorderType from 'recordrtc' |
| 2 | import type { Options as RecorderOptions } from 'recordrtc' |
| 3 | import type { Ref } from 'vue' |
| 4 | import { isTruthy } from '@antfu/utils' |
| 5 | import { fixWebmDuration } from '@fix-webm-duration/fix' |
| 6 | import { useDevicesList, useEventListener, useLocalStorage } from '@vueuse/core' |
| 7 | import { nextTick, ref, shallowRef, watch } from 'vue' |
| 8 | import { currentCamera, currentMic } from '../state' |
| 9 | |
| 10 | type Defined<T> = T extends undefined ? never : T |
| 11 | type MimeType = Defined<RecorderOptions['mimeType']> |
| 12 | |
| 13 | export const recordingName = ref('') |
| 14 | export const recordCamera = ref(true) |
| 15 | export const mimeType = useLocalStorage<MimeType>('slidev-record-mimetype', 'video/webm') |
| 16 | export const frameRate = useLocalStorage<number>('slidev-record-framerate', 30) |
| 17 | export const bitRate = useLocalStorage<number>('slidev-record-bitrate', 8192) |
| 18 | export const resolution = useLocalStorage<string>('slidev-record-resolution', '1920x1080') |
| 19 | |
| 20 | export const mimeExtMap: Record<string, string> = { |
| 21 | 'video/webm': 'webm', |
| 22 | 'video/webm;codecs=h264': 'mp4', |
| 23 | 'video/x-matroska;codecs=avc1': 'mkv', |
| 24 | } |
| 25 | |
| 26 | export function getFilename(media?: string, mimeType?: string) { |
| 27 | const d = new Date() |
| 28 | |
| 29 | const pad = (v: number) => `${v}`.padStart(2, '0') |
| 30 | |
| 31 | const date = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}` |
| 32 | |
| 33 | const ext = mimeType ? mimeExtMap[mimeType] : 'webm' |
| 34 | |
| 35 | return `${[media, recordingName.value, date].filter(isTruthy).join('-')}.${ext}` |
| 36 | } |
| 37 | |
| 38 | function getSupportedMimeTypes() { |
| 39 | if (MediaRecorder && typeof MediaRecorder.isTypeSupported === 'function') |
| 40 | return Object.keys(mimeExtMap).filter(mime => MediaRecorder.isTypeSupported(mime)) |
| 41 | return [] |
| 42 | } |
| 43 | |
| 44 | export const supportedMimeTypes = getSupportedMimeTypes() |
| 45 | |
| 46 | export const { |
| 47 | devices, |
| 48 | videoInputs: cameras, |
| 49 | audioInputs: microphones, |
| 50 | ensurePermissions: ensureDevicesListPermissions, |
| 51 | } = useDevicesList({ |
| 52 | onUpdated() { |
| 53 | if (currentCamera.value !== 'none') { |
| 54 | if (!cameras.value.some(i => i.deviceId === currentCamera.value)) |
| 55 | currentCamera.value = cameras.value[0]?.deviceId || 'default' |
| 56 | } |
| 57 | if (currentMic.value !== 'none') { |
| 58 | if (!microphones.value.some(i => i.deviceId === currentMic.value)) |
| 59 | currentMic.value = microphones.value[0]?.deviceId || 'default' |
| 60 | } |
| 61 | }, |
| 62 | }) |
| 63 | |
| 64 | export function download(name: string, url: string) { |
| 65 | const a = document.createElement('a') |
| 66 | a.setAttribute('href', url) |
| 67 | a.setAttribute('download', name) |
| 68 | document.body.appendChild(a) |
| 69 | a.click() |
| 70 | document.body.removeChild(a) |
| 71 | } |
| 72 | |
| 73 | export function useRecording() { |
| 74 | const recording = ref(false) |
| 75 | const showAvatar = ref(false) |
| 76 | |
| 77 | const recorderCamera: Ref<RecorderType | undefined> = shallowRef() |
| 78 | const recorderSlides: Ref<RecorderType | undefined> = shallowRef() |
| 79 | const streamCamera: Ref<MediaStream | undefined> = shallowRef() |
| 80 | const streamCapture: Ref<MediaStream | undefined> = shallowRef() |
| 81 | const streamSlides: Ref<MediaStream | undefined> = shallowRef() |
| 82 | let recordingStartTime = 0 |
| 83 | |
| 84 | const config: RecorderOptions = { |
| 85 | type: 'video', |
| 86 | // Extending recording limit as default is only 1h (see https://github.com/muaz-khan/RecordRTC/issues/144) |
| 87 | timeSlice: 24 * 60 * 60 * 1000, |
| 88 | } |
| 89 | |
| 90 | async function toggleAvatar() { |
| 91 | if (currentCamera.value === 'none') |
| 92 | return |
| 93 | |
| 94 | if (showAvatar.value) { |
| 95 | showAvatar.value = false |
| 96 | if (!recording.value) |
| 97 | closeStream(streamCamera) |
| 98 | } |
| 99 | else { |
| 100 | await startCameraStream() |
| 101 | if (streamCamera.value) |
| 102 | showAvatar.value = !!streamCamera.value |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | async function startCameraStream() { |
| 107 | await ensureDevicesListPermissions() |
| 108 | await nextTick() |
| 109 | if (!streamCamera.value) { |
| 110 | if (currentCamera.value === 'none' && currentMic.value === 'none') |
| 111 | return |
| 112 | |
| 113 | streamCamera.value = await navigator.mediaDevices.getUserMedia({ |
| 114 | video: (currentCamera.value === 'none' || recordCamera.value !== true) |
| 115 | ? false |
| 116 | : { |
| 117 | deviceId: currentCamera.value, |
| 118 | }, |
| 119 | audio: currentMic.value === 'none' |
| 120 | ? false |
| 121 | : { |
| 122 | deviceId: currentMic.value, |
| 123 | }, |
| 124 | }) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | watch(currentCamera, async (v) => { |
| 129 | if (v === 'none') { |
| 130 | closeStream(streamCamera) |
| 131 | } |
| 132 | else { |
| 133 | if (recording.value) |
| 134 | return |
| 135 | // restart camera stream |
| 136 | if (streamCamera.value) { |
| 137 | closeStream(streamCamera) |
| 138 | await startCameraStream() |
| 139 | } |
| 140 | } |
| 141 | }) |
| 142 | |
| 143 | async function startRecording(customConfig?: RecorderOptions) { |
| 144 | await ensureDevicesListPermissions() |
| 145 | const { default: Recorder } = await import('recordrtc') |
| 146 | await startCameraStream() |
| 147 | |
| 148 | const [width, height] = resolution.value.split('x').map(Number) |
| 149 | streamCapture.value = await navigator.mediaDevices.getDisplayMedia({ |
| 150 | video: { |
| 151 | // aspectRatio: 1.6, |
| 152 | frameRate: frameRate.value, |
| 153 | width, |
| 154 | height, |
| 155 | // @ts-expect-error missing types |
| 156 | cursor: 'motion', |
| 157 | resizeMode: 'crop-and-scale', |
| 158 | }, |
| 159 | selfBrowserSurface: 'include', |
| 160 | }) |
| 161 | streamCapture.value.addEventListener('inactive', stopRecording) |
| 162 | |
| 163 | // We need to create a new Stream to merge video and audio to have the inactive event working on streamCapture |
| 164 | streamSlides.value = new MediaStream() |
| 165 | streamCapture.value!.getVideoTracks().forEach(videoTrack => streamSlides.value!.addTrack(videoTrack)) |
| 166 | |
| 167 | // merge config |
| 168 | Object.assign(config, customConfig) |
| 169 | |
| 170 | if (streamCamera.value) { |
| 171 | const audioTrack = streamCamera.value!.getAudioTracks()?.[0] |
| 172 | if (audioTrack) |
| 173 | streamSlides.value!.addTrack(audioTrack) |
| 174 | |
| 175 | recorderCamera.value = new Recorder( |
| 176 | streamCamera.value!, |
| 177 | config, |
| 178 | ) |
| 179 | recorderCamera.value.startRecording() |
| 180 | } |
| 181 | |
| 182 | recorderSlides.value = new Recorder( |
| 183 | streamSlides.value!, |
| 184 | config, |
| 185 | ) |
| 186 | |
| 187 | recorderSlides.value.startRecording() |
| 188 | recordingStartTime = Date.now() |
| 189 | recording.value = true |
| 190 | } |
| 191 | |
| 192 | async function stopRecording() { |
| 193 | recording.value = false |
| 194 | const duration = Date.now() - recordingStartTime |
| 195 | |
| 196 | recorderCamera.value?.stopRecording(() => { |
| 197 | if (recordCamera.value) { |
| 198 | const blob = recorderCamera.value!.getBlob() |
| 199 | downloadBlob(blob, duration, getFilename('camera', config.mimeType)) |
| 200 | } |
| 201 | recorderCamera.value = undefined |
| 202 | if (!showAvatar.value) |
| 203 | closeStream(streamCamera) |
| 204 | }) |
| 205 | recorderSlides.value?.stopRecording(() => { |
| 206 | const blob = recorderSlides.value!.getBlob() |
| 207 | downloadBlob(blob, duration, getFilename('screen', config.mimeType)) |
| 208 | closeStream(streamCapture) |
| 209 | closeStream(streamSlides) |
| 210 | recorderSlides.value = undefined |
| 211 | }) |
| 212 | } |
| 213 | |
| 214 | async function downloadBlob(blob: Blob, duration: number, filename: string) { |
| 215 | const fixedBlob = await fixWebmDuration(blob, duration, { logger: false }) |
| 216 | const url = URL.createObjectURL(fixedBlob) |
| 217 | download(filename, url) |
| 218 | window.URL.revokeObjectURL(url) |
| 219 | } |
| 220 | |
| 221 | function closeStream(stream: Ref<MediaStream | undefined>) { |
| 222 | const s = stream.value |
| 223 | if (!s) |
| 224 | return |
| 225 | s.getTracks().forEach((i) => { |
| 226 | i.stop() |
| 227 | s.removeTrack(i) |
| 228 | }) |
| 229 | stream.value = undefined |
| 230 | } |
| 231 | |
| 232 | function toggleRecording() { |
| 233 | if (recording.value) |
| 234 | stopRecording() |
| 235 | else |
| 236 | startRecording() |
| 237 | } |
| 238 | |
| 239 | useEventListener('beforeunload', (event) => { |
| 240 | if (!recording.value) |
| 241 | return |
| 242 | // eslint-disable-next-line no-alert |
| 243 | if (confirm('Recording is not saved yet, do you want to leave?')) |
| 244 | return |
| 245 | event.preventDefault() |
| 246 | event.returnValue = '' |
| 247 | }) |
| 248 | |
| 249 | return { |
| 250 | recording, |
| 251 | showAvatar, |
| 252 | toggleRecording, |
| 253 | startRecording, |
| 254 | stopRecording, |
| 255 | toggleAvatar, |
| 256 | recorderCamera, |
| 257 | recorderSlides, |
| 258 | streamCamera, |
| 259 | streamCapture, |
| 260 | streamSlides, |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | export const recorder = useRecording() |
| 265 |