| 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 | |
| 110 | // Stopped tracks can never be resumed, the whole stream has to be requested again |
| 111 | if (streamCamera.value?.getTracks().some(track => track.readyState === 'ended')) |
| 112 | closeStream(streamCamera) |
| 113 | |
| 114 | if (!streamCamera.value) { |
| 115 | if (currentCamera.value === 'none' && currentMic.value === 'none') |
| 116 | return |
| 117 | |
| 118 | streamCamera.value = await navigator.mediaDevices.getUserMedia({ |
| 119 | video: (currentCamera.value === 'none' || recordCamera.value !== true) |
| 120 | ? false |
| 121 | : { |
| 122 | deviceId: currentCamera.value, |
| 123 | }, |
| 124 | audio: currentMic.value === 'none' |
| 125 | ? false |
| 126 | : { |
| 127 | deviceId: currentMic.value, |
| 128 | }, |
| 129 | }) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | watch(currentCamera, async (v) => { |
| 134 | if (v === 'none') { |
| 135 | closeStream(streamCamera) |
| 136 | } |
| 137 | else { |
| 138 | if (recording.value) |
| 139 | return |
| 140 | // restart camera stream |
| 141 | if (streamCamera.value) { |
| 142 | closeStream(streamCamera) |
| 143 | await startCameraStream() |
| 144 | } |
| 145 | } |
| 146 | }) |
| 147 | |
| 148 | async function startRecording(customConfig?: RecorderOptions) { |
| 149 | await ensureDevicesListPermissions() |
| 150 | const { default: Recorder } = await import('recordrtc') |
| 151 | await startCameraStream() |
| 152 | |
| 153 | const [width, height] = resolution.value.split('x').map(Number) |
| 154 | streamCapture.value = await navigator.mediaDevices.getDisplayMedia({ |
| 155 | video: { |
| 156 | // aspectRatio: 1.6, |
| 157 | frameRate: frameRate.value, |
| 158 | width, |
| 159 | height, |
| 160 | // @ts-expect-error missing types |
| 161 | cursor: 'motion', |
| 162 | resizeMode: 'crop-and-scale', |
| 163 | }, |
| 164 | selfBrowserSurface: 'include', |
| 165 | }) |
| 166 | streamCapture.value.addEventListener('inactive', stopRecording) |
| 167 | |
| 168 | // We need to create a new Stream to merge video and audio to have the inactive event working on streamCapture |
| 169 | streamSlides.value = new MediaStream() |
| 170 | streamCapture.value!.getVideoTracks().forEach(videoTrack => streamSlides.value!.addTrack(videoTrack)) |
| 171 | |
| 172 | // merge config |
| 173 | Object.assign(config, customConfig) |
| 174 | |
| 175 | if (streamCamera.value) { |
| 176 | const audioTrack = streamCamera.value!.getAudioTracks()?.[0] |
| 177 | if (audioTrack) |
| 178 | streamSlides.value!.addTrack(audioTrack) |
| 179 | |
| 180 | recorderCamera.value = new Recorder( |
| 181 | streamCamera.value!, |
| 182 | config, |
| 183 | ) |
| 184 | recorderCamera.value.startRecording() |
| 185 | } |
| 186 | |
| 187 | recorderSlides.value = new Recorder( |
| 188 | streamSlides.value!, |
| 189 | config, |
| 190 | ) |
| 191 | |
| 192 | recorderSlides.value.startRecording() |
| 193 | recordingStartTime = Date.now() |
| 194 | recording.value = true |
| 195 | } |
| 196 | |
| 197 | async function stopRecording() { |
| 198 | recording.value = false |
| 199 | const duration = Date.now() - recordingStartTime |
| 200 | |
| 201 | recorderCamera.value?.stopRecording(() => { |
| 202 | if (recordCamera.value) { |
| 203 | const blob = recorderCamera.value!.getBlob() |
| 204 | downloadBlob(blob, duration, getFilename('camera', config.mimeType)) |
| 205 | } |
| 206 | recorderCamera.value = undefined |
| 207 | if (!showAvatar.value) |
| 208 | closeStream(streamCamera) |
| 209 | }) |
| 210 | recorderSlides.value?.stopRecording(() => { |
| 211 | const blob = recorderSlides.value!.getBlob() |
| 212 | downloadBlob(blob, duration, getFilename('screen', config.mimeType)) |
| 213 | closeStream(streamCapture) |
| 214 | // `streamSlides` only borrows its tracks from `streamCapture` and `streamCamera`, |
| 215 | // stopping them here would also end the mic still used by the camera stream |
| 216 | streamSlides.value = undefined |
| 217 | recorderSlides.value = undefined |
| 218 | }) |
| 219 | } |
| 220 | |
| 221 | async function downloadBlob(blob: Blob, duration: number, filename: string) { |
| 222 | const fixedBlob = await fixWebmDuration(blob, duration, { logger: false }) |
| 223 | const url = URL.createObjectURL(fixedBlob) |
| 224 | download(filename, url) |
| 225 | window.URL.revokeObjectURL(url) |
| 226 | } |
| 227 | |
| 228 | function closeStream(stream: Ref<MediaStream | undefined>) { |
| 229 | const s = stream.value |
| 230 | if (!s) |
| 231 | return |
| 232 | s.getTracks().forEach((i) => { |
| 233 | i.stop() |
| 234 | s.removeTrack(i) |
| 235 | }) |
| 236 | stream.value = undefined |
| 237 | } |
| 238 | |
| 239 | function toggleRecording() { |
| 240 | if (recording.value) |
| 241 | stopRecording() |
| 242 | else |
| 243 | startRecording() |
| 244 | } |
| 245 | |
| 246 | useEventListener('beforeunload', (event) => { |
| 247 | if (!recording.value) |
| 248 | return |
| 249 | // eslint-disable-next-line no-alert |
| 250 | if (confirm('Recording is not saved yet, do you want to leave?')) |
| 251 | return |
| 252 | event.preventDefault() |
| 253 | event.returnValue = '' |
| 254 | }) |
| 255 | |
| 256 | return { |
| 257 | recording, |
| 258 | showAvatar, |
| 259 | toggleRecording, |
| 260 | startRecording, |
| 261 | stopRecording, |
| 262 | toggleAvatar, |
| 263 | recorderCamera, |
| 264 | recorderSlides, |
| 265 | streamCamera, |
| 266 | streamCapture, |
| 267 | streamSlides, |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | export const recorder = useRecording() |
| 272 |