uppy/packages/@uppy/screen-capture/src/ScreenCapture.tsx
Merlijn Vos 59740ae808
@uppy/screen-capture: migrate to TS (#4965)
Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
2024-03-07 11:28:16 +01:00

482 lines
13 KiB
TypeScript

import { h, type ComponentChild } from 'preact'
import { UIPlugin, Uppy, type UIPluginOptions } from '@uppy/core'
import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension'
import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts'
import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
import ScreenRecIcon from './ScreenRecIcon.tsx'
import RecorderScreen from './RecorderScreen.tsx'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore We don't want TS to generate types for the package.json
import packageJson from '../package.json'
import locale from './locale.ts'
// Check if screen capturing is supported.
// mediaDevices is supprted on mobile Safari, getDisplayMedia is not
function isScreenRecordingSupported() {
return window.MediaRecorder && navigator.mediaDevices?.getDisplayMedia // eslint-disable-line compat/compat
}
// Adapted from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
function getMediaDevices() {
return window.MediaRecorder && navigator.mediaDevices // eslint-disable-line compat/compat
}
export interface ScreenCaptureOptions extends UIPluginOptions {
title?: string
displayMediaConstraints?: MediaStreamConstraints
userMediaConstraints?: MediaStreamConstraints
preferredVideoMimeType?: string
}
// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints
const defaultOptions = {
// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#Properties_of_shared_screen_tracks
displayMediaConstraints: {
video: {
width: 1280,
height: 720,
frameRate: {
ideal: 3,
max: 5,
},
cursor: 'motion',
displaySurface: 'monitor',
},
},
// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints/audio
userMediaConstraints: {
audio: true,
},
preferredVideoMimeType: 'video/webm',
}
type Opts = DefinePluginOpts<ScreenCaptureOptions, keyof typeof defaultOptions>
export type ScreenCaptureState = {
streamActive: boolean
audioStreamActive: boolean
recording: boolean
recordedVideo: string | null
screenRecError: string | null
}
export default class ScreenCapture<
M extends Meta,
B extends Body,
> extends UIPlugin<Opts, M, B, ScreenCaptureState> {
static VERSION = packageJson.version
mediaDevices: MediaDevices
protocol: string
icon: ComponentChild
streamInterrupted: () => void
captureActive: boolean
capturedMediaFile: null | {
source: string
name: string
data: Blob
type: string
}
videoStream: null | MediaStream
audioStream: null | MediaStream
userDenied: boolean
recorder: null | MediaRecorder
outputStream: null | MediaStream
recordingChunks: Blob[] | null
constructor(uppy: Uppy<M, B>, opts?: ScreenCaptureOptions) {
super(uppy, { ...defaultOptions, ...opts })
this.mediaDevices = getMediaDevices()
// eslint-disable-next-line no-restricted-globals
this.protocol = location.protocol === 'https:' ? 'https' : 'http'
this.id = this.opts.id || 'ScreenCapture'
this.title = this.opts.title || 'Screencast'
this.type = 'acquirer'
this.icon = ScreenRecIcon
this.defaultLocale = locale
// i18n
this.i18nInit()
// uppy plugin class related
this.install = this.install.bind(this)
this.setPluginState = this.setPluginState.bind(this)
this.render = this.render.bind(this)
// screen capturer related
this.start = this.start.bind(this)
this.stop = this.stop.bind(this)
this.startRecording = this.startRecording.bind(this)
this.stopRecording = this.stopRecording.bind(this)
this.submit = this.submit.bind(this)
this.streamInterrupted = this.streamInactivated.bind(this)
// initialize
this.captureActive = false
this.capturedMediaFile = null
}
install(): null | undefined {
if (!isScreenRecordingSupported()) {
this.uppy.log('Screen recorder access is not supported', 'warning')
return null
}
this.setPluginState({
streamActive: false,
audioStreamActive: false,
})
const { target } = this.opts
if (target) {
this.mount(target, this)
}
return undefined
}
uninstall(): void {
if (this.videoStream) {
this.stop()
}
this.unmount()
}
start(): Promise<void> {
if (!this.mediaDevices) {
return Promise.reject(new Error('Screen recorder access not supported'))
}
this.captureActive = true
this.selectAudioStreamSource()
return this.selectVideoStreamSource().then((res) => {
// something happened in start -> return
if (res === false) {
// Close the Dashboard panel if plugin is installed
// into Dashboard (could be other parent UI plugin)
// @ts-expect-error we can't know Dashboard types here
if (this.parent && this.parent.hideAllPanels) {
// @ts-expect-error we can't know Dashboard types here
this.parent.hideAllPanels()
this.captureActive = false
}
}
})
}
selectVideoStreamSource(): Promise<MediaStream | false> {
// if active stream available, return it
if (this.videoStream) {
return new Promise((resolve) => resolve(this.videoStream!))
}
// ask user to select source to record and get mediastream from that
// eslint-disable-next-line compat/compat
return this.mediaDevices
.getDisplayMedia(this.opts.displayMediaConstraints)
.then((videoStream) => {
this.videoStream = videoStream
// add event listener to stop recording if stream is interrupted
this.videoStream.addEventListener('inactive', () => {
this.streamInactivated()
})
this.setPluginState({
streamActive: true,
})
return videoStream
})
.catch((err) => {
this.setPluginState({
screenRecError: err,
})
this.userDenied = true
setTimeout(() => {
this.userDenied = false
}, 1000)
return false
})
}
selectAudioStreamSource(): Promise<MediaStream | false> {
// if active stream available, return it
if (this.audioStream) {
return new Promise((resolve) => resolve(this.audioStream!))
}
// ask user to select source to record and get mediastream from that
// eslint-disable-next-line compat/compat
return this.mediaDevices
.getUserMedia(this.opts.userMediaConstraints)
.then((audioStream) => {
this.audioStream = audioStream
this.setPluginState({
audioStreamActive: true,
})
return audioStream
})
.catch((err) => {
if (err.name === 'NotAllowedError') {
this.uppy.info(this.i18n('micDisabled'), 'error', 5000)
this.uppy.log(this.i18n('micDisabled'), 'warning')
}
return false
})
}
startRecording(): void {
const options: { mimeType?: string } = {}
this.capturedMediaFile = null
this.recordingChunks = []
const { preferredVideoMimeType } = this.opts
this.selectVideoStreamSource()
.then((videoStream) => {
if (videoStream === false) {
throw new Error('No video stream available')
}
// Attempt to use the passed preferredVideoMimeType (if any) during recording.
// If the browser doesn't support it, we'll fall back to the browser default instead
if (
preferredVideoMimeType &&
MediaRecorder.isTypeSupported(preferredVideoMimeType) &&
getFileTypeExtension(preferredVideoMimeType)
) {
options.mimeType = preferredVideoMimeType
}
// prepare tracks
const tracks = [videoStream.getVideoTracks()[0]]
// merge audio if exits
if (this.audioStream) {
tracks.push(this.audioStream.getAudioTracks()[0])
}
// create new stream from video and audio
// eslint-disable-next-line compat/compat
this.outputStream = new MediaStream(tracks)
// initialize mediarecorder
// eslint-disable-next-line compat/compat
this.recorder = new MediaRecorder(this.outputStream, options)
// push data to buffer when data available
this.recorder.addEventListener('dataavailable', (event) => {
this.recordingChunks!.push(event.data)
})
// start recording
this.recorder.start()
// set plugin state to recording
this.setPluginState({
recording: true,
})
})
.catch((err) => {
this.uppy.log(err, 'error')
})
}
streamInactivated(): void {
// get screen recorder state
const { recordedVideo, recording } = { ...this.getPluginState() }
if (!recordedVideo && !recording) {
// Close the Dashboard panel if plugin is installed
// into Dashboard (could be other parent UI plugin)
// @ts-expect-error we can't know Dashboard types here
if (this.parent && this.parent.hideAllPanels) {
// @ts-expect-error we can't know Dashboard types here
this.parent.hideAllPanels()
}
} else if (recording) {
// stop recorder if it is active
this.uppy.log('Capture stream inactive — stop recording')
this.stopRecording()
}
this.videoStream = null
this.audioStream = null
this.setPluginState({
streamActive: false,
audioStreamActive: false,
})
}
stopRecording(): Promise<void> {
const stopped = new Promise<void>((resolve) => {
this.recorder!.addEventListener('stop', () => {
resolve()
})
this.recorder!.stop()
})
return stopped
.then(() => {
// recording stopped
this.setPluginState({
recording: false,
})
// get video file after recorder stopped
return this.getVideo()
})
.then((file) => {
// store media file
this.capturedMediaFile = file
// create object url for capture result preview
this.setPluginState({
// eslint-disable-next-line compat/compat
recordedVideo: URL.createObjectURL(file.data),
})
})
.then(
() => {
this.recordingChunks = null
this.recorder = null
},
(error) => {
this.recordingChunks = null
this.recorder = null
throw error
},
)
}
submit(): void {
try {
// add recorded file to uppy
if (this.capturedMediaFile) {
this.uppy.addFile(this.capturedMediaFile)
}
} catch (err) {
// Logging the error, exept restrictions, which is handled in Core
if (!err.isRestriction) {
this.uppy.log(err, 'warning')
}
}
}
stop(): void {
// flush video stream
if (this.videoStream) {
this.videoStream.getVideoTracks().forEach((track) => {
track.stop()
})
this.videoStream.getAudioTracks().forEach((track) => {
track.stop()
})
this.videoStream = null
}
// flush audio stream
if (this.audioStream) {
this.audioStream.getAudioTracks().forEach((track) => {
track.stop()
})
this.audioStream.getVideoTracks().forEach((track) => {
track.stop()
})
this.audioStream = null
}
// flush output stream
if (this.outputStream) {
this.outputStream.getAudioTracks().forEach((track) => {
track.stop()
})
this.outputStream.getVideoTracks().forEach((track) => {
track.stop()
})
this.outputStream = null
}
// remove preview video
this.setPluginState({
recordedVideo: null,
})
this.captureActive = false
}
getVideo(): Promise<{
source: string
name: string
data: Blob
type: string
}> {
const mimeType = this.recordingChunks![0].type
const fileExtension = getFileTypeExtension(mimeType)
if (!fileExtension) {
return Promise.reject(
new Error(
`Could not retrieve recording: Unsupported media type "${mimeType}"`,
),
)
}
const name = `screencap-${Date.now()}.${fileExtension}`
const blob = new Blob(this.recordingChunks!, { type: mimeType })
const file = {
source: this.id,
name,
data: new Blob([blob], { type: mimeType }),
type: mimeType,
}
return Promise.resolve(file)
}
render(): ComponentChild {
// get screen recorder state
const recorderState = this.getPluginState()
if (
!recorderState.streamActive &&
!this.captureActive &&
!this.userDenied
) {
this.start()
}
return (
<RecorderScreen<M, B>
{...recorderState} // eslint-disable-line react/jsx-props-no-spreading
onStartRecording={this.startRecording}
onStopRecording={this.stopRecording}
onStop={this.stop}
onSubmit={this.submit}
i18n={this.i18n}
stream={this.videoStream}
/>
)
}
}