ScreenCapture.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import { h, type ComponentChild } from 'preact'
  2. import { UIPlugin, Uppy, type UIPluginOptions } from '@uppy/core'
  3. import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension'
  4. import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin'
  5. import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
  6. import ScreenRecIcon from './ScreenRecIcon.tsx'
  7. import RecorderScreen from './RecorderScreen.tsx'
  8. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  9. // @ts-ignore We don't want TS to generate types for the package.json
  10. import packageJson from '../package.json'
  11. import locale from './locale.ts'
  12. // Check if screen capturing is supported.
  13. // mediaDevices is supprted on mobile Safari, getDisplayMedia is not
  14. function isScreenRecordingSupported() {
  15. return window.MediaRecorder && navigator.mediaDevices?.getDisplayMedia // eslint-disable-line compat/compat
  16. }
  17. // Adapted from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
  18. function getMediaDevices() {
  19. return window.MediaRecorder && navigator.mediaDevices // eslint-disable-line compat/compat
  20. }
  21. export interface ScreenCaptureOptions extends UIPluginOptions {
  22. title?: string
  23. displayMediaConstraints?: MediaStreamConstraints
  24. userMediaConstraints?: MediaStreamConstraints
  25. preferredVideoMimeType?: string
  26. }
  27. // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints
  28. const defaultOptions = {
  29. // https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#Properties_of_shared_screen_tracks
  30. displayMediaConstraints: {
  31. video: {
  32. width: 1280,
  33. height: 720,
  34. frameRate: {
  35. ideal: 3,
  36. max: 5,
  37. },
  38. cursor: 'motion',
  39. displaySurface: 'monitor',
  40. },
  41. },
  42. // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints/audio
  43. userMediaConstraints: {
  44. audio: true,
  45. },
  46. preferredVideoMimeType: 'video/webm',
  47. }
  48. type Opts = DefinePluginOpts<ScreenCaptureOptions, keyof typeof defaultOptions>
  49. export type ScreenCaptureState = {
  50. streamActive: boolean
  51. audioStreamActive: boolean
  52. recording: boolean
  53. recordedVideo: string | null
  54. screenRecError: string | null
  55. }
  56. export default class ScreenCapture<
  57. M extends Meta,
  58. B extends Body,
  59. > extends UIPlugin<Opts, M, B, ScreenCaptureState> {
  60. static VERSION = packageJson.version
  61. mediaDevices: MediaDevices
  62. protocol: string
  63. icon: ComponentChild
  64. streamInterrupted: () => void
  65. captureActive: boolean
  66. capturedMediaFile: null | {
  67. source: string
  68. name: string
  69. data: Blob
  70. type: string
  71. }
  72. videoStream: null | MediaStream
  73. audioStream: null | MediaStream
  74. userDenied: boolean
  75. recorder: null | MediaRecorder
  76. outputStream: null | MediaStream
  77. recordingChunks: Blob[] | null
  78. constructor(uppy: Uppy<M, B>, opts?: ScreenCaptureOptions) {
  79. super(uppy, { ...defaultOptions, ...opts })
  80. this.mediaDevices = getMediaDevices()
  81. // eslint-disable-next-line no-restricted-globals
  82. this.protocol = location.protocol === 'https:' ? 'https' : 'http'
  83. this.id = this.opts.id || 'ScreenCapture'
  84. this.title = this.opts.title || 'Screencast'
  85. this.type = 'acquirer'
  86. this.icon = ScreenRecIcon
  87. this.defaultLocale = locale
  88. // i18n
  89. this.i18nInit()
  90. // uppy plugin class related
  91. this.install = this.install.bind(this)
  92. this.setPluginState = this.setPluginState.bind(this)
  93. this.render = this.render.bind(this)
  94. // screen capturer related
  95. this.start = this.start.bind(this)
  96. this.stop = this.stop.bind(this)
  97. this.startRecording = this.startRecording.bind(this)
  98. this.stopRecording = this.stopRecording.bind(this)
  99. this.submit = this.submit.bind(this)
  100. this.streamInterrupted = this.streamInactivated.bind(this)
  101. // initialize
  102. this.captureActive = false
  103. this.capturedMediaFile = null
  104. }
  105. install(): null | undefined {
  106. if (!isScreenRecordingSupported()) {
  107. this.uppy.log('Screen recorder access is not supported', 'warning')
  108. return null
  109. }
  110. this.setPluginState({
  111. streamActive: false,
  112. audioStreamActive: false,
  113. })
  114. const { target } = this.opts
  115. if (target) {
  116. this.mount(target, this)
  117. }
  118. return undefined
  119. }
  120. uninstall(): void {
  121. if (this.videoStream) {
  122. this.stop()
  123. }
  124. this.unmount()
  125. }
  126. start(): Promise<void> {
  127. if (!this.mediaDevices) {
  128. return Promise.reject(new Error('Screen recorder access not supported'))
  129. }
  130. this.captureActive = true
  131. this.selectAudioStreamSource()
  132. return this.selectVideoStreamSource().then((res) => {
  133. // something happened in start -> return
  134. if (res === false) {
  135. // Close the Dashboard panel if plugin is installed
  136. // into Dashboard (could be other parent UI plugin)
  137. // @ts-expect-error we can't know Dashboard types here
  138. if (this.parent && this.parent.hideAllPanels) {
  139. // @ts-expect-error we can't know Dashboard types here
  140. this.parent.hideAllPanels()
  141. this.captureActive = false
  142. }
  143. }
  144. })
  145. }
  146. selectVideoStreamSource(): Promise<MediaStream | false> {
  147. // if active stream available, return it
  148. if (this.videoStream) {
  149. return new Promise((resolve) => resolve(this.videoStream!))
  150. }
  151. // ask user to select source to record and get mediastream from that
  152. // eslint-disable-next-line compat/compat
  153. return this.mediaDevices
  154. .getDisplayMedia(this.opts.displayMediaConstraints)
  155. .then((videoStream) => {
  156. this.videoStream = videoStream
  157. // add event listener to stop recording if stream is interrupted
  158. this.videoStream.addEventListener('inactive', () => {
  159. this.streamInactivated()
  160. })
  161. this.setPluginState({
  162. streamActive: true,
  163. })
  164. return videoStream
  165. })
  166. .catch((err) => {
  167. this.setPluginState({
  168. screenRecError: err,
  169. })
  170. this.userDenied = true
  171. setTimeout(() => {
  172. this.userDenied = false
  173. }, 1000)
  174. return false
  175. })
  176. }
  177. selectAudioStreamSource(): Promise<MediaStream | false> {
  178. // if active stream available, return it
  179. if (this.audioStream) {
  180. return new Promise((resolve) => resolve(this.audioStream!))
  181. }
  182. // ask user to select source to record and get mediastream from that
  183. // eslint-disable-next-line compat/compat
  184. return this.mediaDevices
  185. .getUserMedia(this.opts.userMediaConstraints)
  186. .then((audioStream) => {
  187. this.audioStream = audioStream
  188. this.setPluginState({
  189. audioStreamActive: true,
  190. })
  191. return audioStream
  192. })
  193. .catch((err) => {
  194. if (err.name === 'NotAllowedError') {
  195. this.uppy.info(this.i18n('micDisabled'), 'error', 5000)
  196. this.uppy.log(this.i18n('micDisabled'), 'warning')
  197. }
  198. return false
  199. })
  200. }
  201. startRecording(): void {
  202. const options: { mimeType?: string } = {}
  203. this.capturedMediaFile = null
  204. this.recordingChunks = []
  205. const { preferredVideoMimeType } = this.opts
  206. this.selectVideoStreamSource()
  207. .then((videoStream) => {
  208. if (videoStream === false) {
  209. throw new Error('No video stream available')
  210. }
  211. // Attempt to use the passed preferredVideoMimeType (if any) during recording.
  212. // If the browser doesn't support it, we'll fall back to the browser default instead
  213. if (
  214. preferredVideoMimeType &&
  215. MediaRecorder.isTypeSupported(preferredVideoMimeType) &&
  216. getFileTypeExtension(preferredVideoMimeType)
  217. ) {
  218. options.mimeType = preferredVideoMimeType
  219. }
  220. // prepare tracks
  221. const tracks = [videoStream.getVideoTracks()[0]]
  222. // merge audio if exits
  223. if (this.audioStream) {
  224. tracks.push(this.audioStream.getAudioTracks()[0])
  225. }
  226. // create new stream from video and audio
  227. // eslint-disable-next-line compat/compat
  228. this.outputStream = new MediaStream(tracks)
  229. // initialize mediarecorder
  230. // eslint-disable-next-line compat/compat
  231. this.recorder = new MediaRecorder(this.outputStream, options)
  232. // push data to buffer when data available
  233. this.recorder.addEventListener('dataavailable', (event) => {
  234. this.recordingChunks!.push(event.data)
  235. })
  236. // start recording
  237. this.recorder.start()
  238. // set plugin state to recording
  239. this.setPluginState({
  240. recording: true,
  241. })
  242. })
  243. .catch((err) => {
  244. this.uppy.log(err, 'error')
  245. })
  246. }
  247. streamInactivated(): void {
  248. // get screen recorder state
  249. const { recordedVideo, recording } = { ...this.getPluginState() }
  250. if (!recordedVideo && !recording) {
  251. // Close the Dashboard panel if plugin is installed
  252. // into Dashboard (could be other parent UI plugin)
  253. // @ts-expect-error we can't know Dashboard types here
  254. if (this.parent && this.parent.hideAllPanels) {
  255. // @ts-expect-error we can't know Dashboard types here
  256. this.parent.hideAllPanels()
  257. }
  258. } else if (recording) {
  259. // stop recorder if it is active
  260. this.uppy.log('Capture stream inactive — stop recording')
  261. this.stopRecording()
  262. }
  263. this.videoStream = null
  264. this.audioStream = null
  265. this.setPluginState({
  266. streamActive: false,
  267. audioStreamActive: false,
  268. })
  269. }
  270. stopRecording(): Promise<void> {
  271. const stopped = new Promise<void>((resolve) => {
  272. this.recorder!.addEventListener('stop', () => {
  273. resolve()
  274. })
  275. this.recorder!.stop()
  276. })
  277. return stopped
  278. .then(() => {
  279. // recording stopped
  280. this.setPluginState({
  281. recording: false,
  282. })
  283. // get video file after recorder stopped
  284. return this.getVideo()
  285. })
  286. .then((file) => {
  287. // store media file
  288. this.capturedMediaFile = file
  289. // create object url for capture result preview
  290. this.setPluginState({
  291. // eslint-disable-next-line compat/compat
  292. recordedVideo: URL.createObjectURL(file.data),
  293. })
  294. })
  295. .then(
  296. () => {
  297. this.recordingChunks = null
  298. this.recorder = null
  299. },
  300. (error) => {
  301. this.recordingChunks = null
  302. this.recorder = null
  303. throw error
  304. },
  305. )
  306. }
  307. submit(): void {
  308. try {
  309. // add recorded file to uppy
  310. if (this.capturedMediaFile) {
  311. this.uppy.addFile(this.capturedMediaFile)
  312. }
  313. } catch (err) {
  314. // Logging the error, exept restrictions, which is handled in Core
  315. if (!err.isRestriction) {
  316. this.uppy.log(err, 'warning')
  317. }
  318. }
  319. }
  320. stop(): void {
  321. // flush video stream
  322. if (this.videoStream) {
  323. this.videoStream.getVideoTracks().forEach((track) => {
  324. track.stop()
  325. })
  326. this.videoStream.getAudioTracks().forEach((track) => {
  327. track.stop()
  328. })
  329. this.videoStream = null
  330. }
  331. // flush audio stream
  332. if (this.audioStream) {
  333. this.audioStream.getAudioTracks().forEach((track) => {
  334. track.stop()
  335. })
  336. this.audioStream.getVideoTracks().forEach((track) => {
  337. track.stop()
  338. })
  339. this.audioStream = null
  340. }
  341. // flush output stream
  342. if (this.outputStream) {
  343. this.outputStream.getAudioTracks().forEach((track) => {
  344. track.stop()
  345. })
  346. this.outputStream.getVideoTracks().forEach((track) => {
  347. track.stop()
  348. })
  349. this.outputStream = null
  350. }
  351. // remove preview video
  352. this.setPluginState({
  353. recordedVideo: null,
  354. })
  355. this.captureActive = false
  356. }
  357. getVideo(): Promise<{
  358. source: string
  359. name: string
  360. data: Blob
  361. type: string
  362. }> {
  363. const mimeType = this.recordingChunks![0].type
  364. const fileExtension = getFileTypeExtension(mimeType)
  365. if (!fileExtension) {
  366. return Promise.reject(
  367. new Error(
  368. `Could not retrieve recording: Unsupported media type "${mimeType}"`,
  369. ),
  370. )
  371. }
  372. const name = `screencap-${Date.now()}.${fileExtension}`
  373. const blob = new Blob(this.recordingChunks!, { type: mimeType })
  374. const file = {
  375. source: this.id,
  376. name,
  377. data: new Blob([blob], { type: mimeType }),
  378. type: mimeType,
  379. }
  380. return Promise.resolve(file)
  381. }
  382. render(): ComponentChild {
  383. // get screen recorder state
  384. const recorderState = this.getPluginState()
  385. if (
  386. !recorderState.streamActive &&
  387. !this.captureActive &&
  388. !this.userDenied
  389. ) {
  390. this.start()
  391. }
  392. return (
  393. <RecorderScreen<M, B>
  394. {...recorderState} // eslint-disable-line react/jsx-props-no-spreading
  395. onStartRecording={this.startRecording}
  396. onStopRecording={this.stopRecording}
  397. onStop={this.stop}
  398. onSubmit={this.submit}
  399. i18n={this.i18n}
  400. stream={this.videoStream}
  401. />
  402. )
  403. }
  404. }