import type RecorderType from 'recordrtc' import type { Options as RecorderOptions } from 'recordrtc' import type { Ref } from 'vue' import { isTruthy } from '@antfu/utils' import { fixWebmDuration } from '@fix-webm-duration/fix' import { useDevicesList, useEventListener, useLocalStorage } from '@vueuse/core' import { nextTick, ref, shallowRef, watch } from 'vue' import { currentCamera, currentMic } from '../state' type Defined = T extends undefined ? never : T type MimeType = Defined export const recordingName = ref('') export const recordCamera = ref(true) export const mimeType = useLocalStorage('slidev-record-mimetype', 'video/webm') export const frameRate = useLocalStorage('slidev-record-framerate', 30) export const bitRate = useLocalStorage('slidev-record-bitrate', 8192) export const resolution = useLocalStorage('slidev-record-resolution', '1920x1080') export const mimeExtMap: Record = { 'video/webm': 'webm', 'video/webm;codecs=h264': 'mp4', 'video/x-matroska;codecs=avc1': 'mkv', } export function getFilename(media?: string, mimeType?: string) { const d = new Date() const pad = (v: number) => `${v}`.padStart(2, '0') const date = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}` const ext = mimeType ? mimeExtMap[mimeType] : 'webm' return `${[media, recordingName.value, date].filter(isTruthy).join('-')}.${ext}` } function getSupportedMimeTypes() { if (MediaRecorder && typeof MediaRecorder.isTypeSupported === 'function') return Object.keys(mimeExtMap).filter(mime => MediaRecorder.isTypeSupported(mime)) return [] } export const supportedMimeTypes = getSupportedMimeTypes() export const { devices, videoInputs: cameras, audioInputs: microphones, ensurePermissions: ensureDevicesListPermissions, } = useDevicesList({ onUpdated() { if (currentCamera.value !== 'none') { if (!cameras.value.some(i => i.deviceId === currentCamera.value)) currentCamera.value = cameras.value[0]?.deviceId || 'default' } if (currentMic.value !== 'none') { if (!microphones.value.some(i => i.deviceId === currentMic.value)) currentMic.value = microphones.value[0]?.deviceId || 'default' } }, }) export function download(name: string, url: string) { const a = document.createElement('a') a.setAttribute('href', url) a.setAttribute('download', name) document.body.appendChild(a) a.click() document.body.removeChild(a) } export function useRecording() { const recording = ref(false) const showAvatar = ref(false) const recorderCamera: Ref = shallowRef() const recorderSlides: Ref = shallowRef() const streamCamera: Ref = shallowRef() const streamCapture: Ref = shallowRef() const streamSlides: Ref = shallowRef() let recordingStartTime = 0 const config: RecorderOptions = { type: 'video', // Extending recording limit as default is only 1h (see https://github.com/muaz-khan/RecordRTC/issues/144) timeSlice: 24 * 60 * 60 * 1000, } async function toggleAvatar() { if (currentCamera.value === 'none') return if (showAvatar.value) { showAvatar.value = false if (!recording.value) closeStream(streamCamera) } else { await startCameraStream() if (streamCamera.value) showAvatar.value = !!streamCamera.value } } async function startCameraStream() { await ensureDevicesListPermissions() await nextTick() if (!streamCamera.value) { if (currentCamera.value === 'none' && currentMic.value === 'none') return streamCamera.value = await navigator.mediaDevices.getUserMedia({ video: (currentCamera.value === 'none' || recordCamera.value !== true) ? false : { deviceId: currentCamera.value, }, audio: currentMic.value === 'none' ? false : { deviceId: currentMic.value, }, }) } } watch(currentCamera, async (v) => { if (v === 'none') { closeStream(streamCamera) } else { if (recording.value) return // restart camera stream if (streamCamera.value) { closeStream(streamCamera) await startCameraStream() } } }) async function startRecording(customConfig?: RecorderOptions) { await ensureDevicesListPermissions() const { default: Recorder } = await import('recordrtc') await startCameraStream() const [width, height] = resolution.value.split('x').map(Number) streamCapture.value = await navigator.mediaDevices.getDisplayMedia({ video: { // aspectRatio: 1.6, frameRate: frameRate.value, width, height, // @ts-expect-error missing types cursor: 'motion', resizeMode: 'crop-and-scale', }, selfBrowserSurface: 'include', }) streamCapture.value.addEventListener('inactive', stopRecording) // We need to create a new Stream to merge video and audio to have the inactive event working on streamCapture streamSlides.value = new MediaStream() streamCapture.value!.getVideoTracks().forEach(videoTrack => streamSlides.value!.addTrack(videoTrack)) // merge config Object.assign(config, customConfig) if (streamCamera.value) { const audioTrack = streamCamera.value!.getAudioTracks()?.[0] if (audioTrack) streamSlides.value!.addTrack(audioTrack) recorderCamera.value = new Recorder( streamCamera.value!, config, ) recorderCamera.value.startRecording() } recorderSlides.value = new Recorder( streamSlides.value!, config, ) recorderSlides.value.startRecording() recordingStartTime = Date.now() recording.value = true } async function stopRecording() { recording.value = false const duration = Date.now() - recordingStartTime recorderCamera.value?.stopRecording(() => { if (recordCamera.value) { const blob = recorderCamera.value!.getBlob() downloadBlob(blob, duration, getFilename('camera', config.mimeType)) } recorderCamera.value = undefined if (!showAvatar.value) closeStream(streamCamera) }) recorderSlides.value?.stopRecording(() => { const blob = recorderSlides.value!.getBlob() downloadBlob(blob, duration, getFilename('screen', config.mimeType)) closeStream(streamCapture) closeStream(streamSlides) recorderSlides.value = undefined }) } async function downloadBlob(blob: Blob, duration: number, filename: string) { const fixedBlob = await fixWebmDuration(blob, duration, { logger: false }) const url = URL.createObjectURL(fixedBlob) download(filename, url) window.URL.revokeObjectURL(url) } function closeStream(stream: Ref) { const s = stream.value if (!s) return s.getTracks().forEach((i) => { i.stop() s.removeTrack(i) }) stream.value = undefined } function toggleRecording() { if (recording.value) stopRecording() else startRecording() } useEventListener('beforeunload', (event) => { if (!recording.value) return // eslint-disable-next-line no-alert if (confirm('Recording is not saved yet, do you want to leave?')) return event.preventDefault() event.returnValue = '' }) return { recording, showAvatar, toggleRecording, startRecording, stopRecording, toggleAvatar, recorderCamera, recorderSlides, streamCamera, streamCapture, streamSlides, } } export const recorder = useRecording()