Browse Source

@uppy/webcam: refactor to ESM (#3686)

Antoine du Hamel 3 years ago
parent
commit
94b646aef0

+ 1 - 0
.eslintrc.js

@@ -210,6 +210,7 @@ module.exports = {
         'packages/@uppy/svelte/src/**/*.js',
         'packages/@uppy/svelte/rollup.config.js',
         'packages/@uppy/vue/src/**/*.js',
+        'packages/@uppy/webcam/src/**/*.js',
       ],
       parser: 'espree',
       parserOptions: {

+ 1 - 0
packages/@uppy/webcam/package.json

@@ -6,6 +6,7 @@
   "main": "lib/index.js",
   "style": "dist/style.min.css",
   "types": "types/index.d.ts",
+  "type": "module",
   "keywords": [
     "file uploader",
     "uppy",

+ 2 - 2
packages/@uppy/webcam/src/CameraIcon.js → packages/@uppy/webcam/src/CameraIcon.jsx

@@ -1,6 +1,6 @@
-const { h } = require('preact')
+import { h } from 'preact'
 
-module.exports = () => {
+export default () => {
   return (
     <svg aria-hidden="true" focusable="false" fill="#0097DC" width="66" height="55" viewBox="0 0 66 55">
       <path d="M57.3 8.433c4.59 0 8.1 3.51 8.1 8.1v29.7c0 4.59-3.51 8.1-8.1 8.1H8.7c-4.59 0-8.1-3.51-8.1-8.1v-29.7c0-4.59 3.51-8.1 8.1-8.1h9.45l4.59-7.02c.54-.54 1.35-1.08 2.16-1.08h16.2c.81 0 1.62.54 2.16 1.08l4.59 7.02h9.45zM33 14.64c-8.62 0-15.393 6.773-15.393 15.393 0 8.62 6.773 15.393 15.393 15.393 8.62 0 15.393-6.773 15.393-15.393 0-8.62-6.773-15.393-15.393-15.393zM33 40c-5.648 0-9.966-4.319-9.966-9.967 0-5.647 4.318-9.966 9.966-9.966s9.966 4.319 9.966 9.966C42.966 35.681 38.648 40 33 40z" fillRule="evenodd" />

+ 8 - 8
packages/@uppy/webcam/src/CameraScreen.js → packages/@uppy/webcam/src/CameraScreen.jsx

@@ -1,11 +1,11 @@
 /* eslint-disable jsx-a11y/media-has-caption */
-const { h, Component } = require('preact')
-const SnapshotButton = require('./SnapshotButton')
-const RecordButton = require('./RecordButton')
-const RecordingLength = require('./RecordingLength')
-const VideoSourceSelect = require('./VideoSourceSelect')
-const SubmitButton = require('./SubmitButton')
-const DiscardButton = require('./DiscardButton')
+import { h, Component } from 'preact'
+import SnapshotButton from './SnapshotButton.jsx'
+import RecordButton from './RecordButton.jsx'
+import RecordingLength from './RecordingLength.jsx'
+import VideoSourceSelect from './VideoSourceSelect.jsx'
+import SubmitButton from './SubmitButton.jsx'
+import DiscardButton from './DiscardButton.jsx'
 
 function isModeAvailable (modes, mode) {
   return modes.indexOf(mode) !== -1
@@ -116,4 +116,4 @@ class CameraScreen extends Component {
   }
 }
 
-module.exports = CameraScreen
+export default CameraScreen

+ 2 - 2
packages/@uppy/webcam/src/DiscardButton.js → packages/@uppy/webcam/src/DiscardButton.jsx

@@ -1,4 +1,4 @@
-const { h } = require('preact')
+import { h } from 'preact'
 
 function DiscardButton ({ onDiscard, i18n }) {
   return (
@@ -28,4 +28,4 @@ function DiscardButton ({ onDiscard, i18n }) {
   )
 }
 
-module.exports = DiscardButton
+export default DiscardButton

+ 0 - 11
packages/@uppy/webcam/src/PermissionsScreen.js

@@ -1,11 +0,0 @@
-const { h } = require('preact')
-
-module.exports = (props) => {
-  return (
-    <div className="uppy-Webcam-permissons">
-      <div className="uppy-Webcam-permissonsIcon">{props.icon()}</div>
-      <h1 className="uppy-Webcam-title">{props.hasCamera ? props.i18n('allowAccessTitle') : props.i18n('noCameraTitle')}</h1>
-      <p>{props.hasCamera ? props.i18n('allowAccessDescription') : props.i18n('noCameraDescription')}</p>
-    </div>
-  )
-}

+ 11 - 0
packages/@uppy/webcam/src/PermissionsScreen.jsx

@@ -0,0 +1,11 @@
+import { h } from 'preact'
+
+export default ({ icon, i18n, hasCamera }) => {
+  return (
+    <div className="uppy-Webcam-permissons">
+      <div className="uppy-Webcam-permissonsIcon">{icon()}</div>
+      <h1 className="uppy-Webcam-title">{hasCamera ? i18n('allowAccessTitle') : i18n('noCameraTitle')}</h1>
+      <p>{hasCamera ? i18n('allowAccessDescription') : i18n('noCameraDescription')}</p>
+    </div>
+  )
+}

+ 2 - 2
packages/@uppy/webcam/src/RecordButton.js → packages/@uppy/webcam/src/RecordButton.jsx

@@ -1,6 +1,6 @@
-const { h } = require('preact')
+import { h } from 'preact'
 
-module.exports = function RecordButton ({ recording, onStartRecording, onStopRecording, i18n }) {
+export default function RecordButton ({ recording, onStartRecording, onStopRecording, i18n }) {
   if (recording) {
     return (
       <button

+ 3 - 3
packages/@uppy/webcam/src/RecordingLength.js → packages/@uppy/webcam/src/RecordingLength.jsx

@@ -1,7 +1,7 @@
-const { h } = require('preact')
-const formatSeconds = require('./formatSeconds')
+import { h } from 'preact'
+import formatSeconds from './formatSeconds.js'
 
-module.exports = function RecordingLength ({ recordingLengthSeconds, i18n }) {
+export default function RecordingLength ({ recordingLengthSeconds, i18n }) {
   const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
 
   return (

+ 3 - 3
packages/@uppy/webcam/src/SnapshotButton.js → packages/@uppy/webcam/src/SnapshotButton.jsx

@@ -1,7 +1,7 @@
-const { h } = require('preact')
-const CameraIcon = require('./CameraIcon')
+import { h } from 'preact'
+import CameraIcon from './CameraIcon.jsx'
 
-module.exports = ({ onSnapshot, i18n }) => {
+export default ({ onSnapshot, i18n }) => {
   return (
     <button
       className="uppy-u-reset uppy-c-btn uppy-Webcam-button uppy-Webcam-button--picture"

+ 2 - 2
packages/@uppy/webcam/src/SubmitButton.js → packages/@uppy/webcam/src/SubmitButton.jsx

@@ -1,4 +1,4 @@
-const { h } = require('preact')
+import { h } from 'preact'
 
 function SubmitButton ({ onSubmit, i18n }) {
   return (
@@ -25,4 +25,4 @@ function SubmitButton ({ onSubmit, i18n }) {
   )
 }
 
-module.exports = SubmitButton
+export default SubmitButton

+ 2 - 2
packages/@uppy/webcam/src/VideoSourceSelect.js → packages/@uppy/webcam/src/VideoSourceSelect.jsx

@@ -1,6 +1,6 @@
-const { h } = require('preact')
+import { h } from 'preact'
 
-module.exports = ({ currentDeviceId, videoSources, onChangeVideoSource }) => {
+export default ({ currentDeviceId, videoSources, onChangeVideoSource }) => {
   return (
     <div className="uppy-Webcam-videoSource">
       <select

+ 635 - 0
packages/@uppy/webcam/src/Webcam.jsx

@@ -0,0 +1,635 @@
+import { h } from 'preact'
+
+import { UIPlugin } from '@uppy/core'
+import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension'
+import mimeTypes from '@uppy/utils/lib/mimeTypes'
+import canvasToBlob from '@uppy/utils/lib/canvasToBlob'
+import supportsMediaRecorder from './supportsMediaRecorder.js'
+import CameraIcon from './CameraIcon.jsx'
+import CameraScreen from './CameraScreen.jsx'
+import PermissionsScreen from './PermissionsScreen.jsx'
+
+import packageJson from '../package.json'
+import locale from './locale.js'
+
+/**
+ * Normalize a MIME type or file extension into a MIME type.
+ *
+ * @param {string} fileType - MIME type or a file extension prefixed with `.`.
+ * @returns {string|undefined} The MIME type or `undefined` if the fileType is an extension and is not known.
+ */
+function toMimeType (fileType) {
+  if (fileType[0] === '.') {
+    return mimeTypes[fileType.slice(1)]
+  }
+  return fileType
+}
+
+/**
+ * Is this MIME type a video?
+ *
+ * @param {string} mimeType - MIME type.
+ * @returns {boolean}
+ */
+function isVideoMimeType (mimeType) {
+  return /^video\/[^*]+$/.test(mimeType)
+}
+
+/**
+ * Is this MIME type an image?
+ *
+ * @param {string} mimeType - MIME type.
+ * @returns {boolean}
+ */
+function isImageMimeType (mimeType) {
+  return /^image\/[^*]+$/.test(mimeType)
+}
+
+function getMediaDevices () {
+  // bug in the compatibility data
+  // eslint-disable-next-line compat/compat
+  return navigator.mediaDevices
+}
+/**
+ * Webcam
+ */
+export default class Webcam extends UIPlugin {
+  static VERSION = packageJson.version
+
+  // enableMirror is used to toggle mirroring, for instance when discarding the video,
+  // while `opts.mirror` is used to remember the initial user setting
+  #enableMirror
+
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.mediaDevices = getMediaDevices()
+    this.supportsUserMedia = !!this.mediaDevices
+    // eslint-disable-next-line no-restricted-globals
+    this.protocol = location.protocol.match(/https/i) ? 'https' : 'http'
+    this.id = this.opts.id || 'Webcam'
+    this.type = 'acquirer'
+    this.capturedMediaFile = null
+    this.icon = () => (
+      <svg aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32">
+        <g fill="none" fillRule="evenodd">
+          <rect className="uppy-ProviderIconBg" fill="#03BFEF" width="32" height="32" rx="16" />
+          <path d="M22 11c1.133 0 2 .867 2 2v7.333c0 1.134-.867 2-2 2H10c-1.133 0-2-.866-2-2V13c0-1.133.867-2 2-2h2.333l1.134-1.733C13.6 9.133 13.8 9 14 9h4c.2 0 .4.133.533.267L19.667 11H22zm-6 1.533a3.764 3.764 0 0 0-3.8 3.8c0 2.129 1.672 3.801 3.8 3.801s3.8-1.672 3.8-3.8c0-2.13-1.672-3.801-3.8-3.801zm0 6.261c-1.395 0-2.46-1.066-2.46-2.46 0-1.395 1.065-2.461 2.46-2.461s2.46 1.066 2.46 2.46c0 1.395-1.065 2.461-2.46 2.461z" fill="#FFF" fillRule="nonzero" />
+        </g>
+      </svg>
+    )
+
+    this.defaultLocale = locale
+
+    // set default options
+    const defaultOptions = {
+      onBeforeSnapshot: () => Promise.resolve(),
+      countdown: false,
+      modes: [
+        'video-audio',
+        'video-only',
+        'audio-only',
+        'picture',
+      ],
+      mirror: true,
+      showVideoSourceDropdown: false,
+      facingMode: 'user',
+      preferredImageMimeType: null,
+      preferredVideoMimeType: null,
+      showRecordingLength: false,
+    }
+
+    this.opts = { ...defaultOptions, ...opts }
+    this.i18nInit()
+    this.title = this.i18n('pluginNameCamera')
+
+    this.#enableMirror = this.opts.mirror
+
+    this.install = this.install.bind(this)
+    this.setPluginState = this.setPluginState.bind(this)
+    this.render = this.render.bind(this)
+
+    // Camera controls
+    this.start = this.start.bind(this)
+    this.stop = this.stop.bind(this)
+    this.takeSnapshot = this.takeSnapshot.bind(this)
+    this.startRecording = this.startRecording.bind(this)
+    this.stopRecording = this.stopRecording.bind(this)
+    this.discardRecordedVideo = this.discardRecordedVideo.bind(this)
+    this.submit = this.submit.bind(this)
+    this.oneTwoThreeSmile = this.oneTwoThreeSmile.bind(this)
+    this.focus = this.focus.bind(this)
+    this.changeVideoSource = this.changeVideoSource.bind(this)
+
+    this.webcamActive = false
+
+    if (this.opts.countdown) {
+      this.opts.onBeforeSnapshot = this.oneTwoThreeSmile
+    }
+
+    this.setPluginState({
+      hasCamera: false,
+      cameraReady: false,
+      cameraError: null,
+      recordingLengthSeconds: 0,
+      videoSources: [],
+      currentDeviceId: null,
+    })
+  }
+
+  setOptions (newOpts) {
+    super.setOptions({
+      ...newOpts,
+      videoConstraints: {
+        // May be undefined but ... handles that
+        ...this.opts.videoConstraints,
+        ...newOpts?.videoConstraints,
+      },
+    })
+  }
+
+  hasCameraCheck () {
+    if (!this.mediaDevices) {
+      return Promise.resolve(false)
+    }
+
+    return this.mediaDevices.enumerateDevices().then(devices => {
+      return devices.some(device => device.kind === 'videoinput')
+    })
+  }
+
+  isAudioOnly () {
+    return this.opts.modes.length === 1 && this.opts.modes[0] === 'audio-only'
+  }
+
+  getConstraints (deviceId = null) {
+    const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1
+      || this.opts.modes.indexOf('audio-only') !== -1
+    const acceptsVideo = !this.isAudioOnly()
+        && (this.opts.modes.indexOf('video-audio') !== -1
+          || this.opts.modes.indexOf('video-only') !== -1
+          || this.opts.modes.indexOf('picture') !== -1)
+
+    const videoConstraints = {
+      ...(this.opts.videoConstraints || { facingMode: this.opts.facingMode }),
+      // facingMode takes precedence over deviceId, and not needed
+      // when specific device is selected
+      ...(deviceId ? { deviceId, facingMode: null } : {}),
+    }
+
+    return {
+      audio: acceptsAudio,
+      video: acceptsVideo ? videoConstraints : false,
+    }
+  }
+
+  // eslint-disable-next-line consistent-return
+  start (options = null) {
+    if (!this.supportsUserMedia) {
+      return Promise.reject(new Error('Webcam access not supported'))
+    }
+
+    this.webcamActive = true
+
+    if (this.opts.mirror) {
+      this.#enableMirror = true
+    }
+
+    const constraints = this.getConstraints(options && options.deviceId ? options.deviceId : null)
+
+    this.hasCameraCheck().then(hasCamera => {
+      this.setPluginState({
+        hasCamera,
+      })
+
+      // ask user for access to their camera
+      return this.mediaDevices.getUserMedia(constraints)
+        .then((stream) => {
+          this.stream = stream
+
+          let currentDeviceId = null
+          const tracks = this.isAudioOnly() ? stream.getAudioTracks() : stream.getVideoTracks()
+
+          if (!options || !options.deviceId) {
+            currentDeviceId = tracks[0].getSettings().deviceId
+          } else {
+            tracks.forEach((track) => {
+              if (track.getSettings().deviceId === options.deviceId) {
+                currentDeviceId = track.getSettings().deviceId
+              }
+            })
+          }
+
+          // Update the sources now, so we can access the names.
+          this.updateVideoSources()
+
+          this.setPluginState({
+            currentDeviceId,
+            cameraReady: true,
+          })
+        })
+        .catch((err) => {
+          this.setPluginState({
+            cameraReady: false,
+            cameraError: err,
+          })
+          this.uppy.info(err.message, 'error')
+        })
+    })
+  }
+
+  /**
+   * @returns {object}
+   */
+  getMediaRecorderOptions () {
+    const options = {}
+
+    // Try to use the `opts.preferredVideoMimeType` or one of the `allowedFileTypes` for the recording.
+    // If the browser doesn't support it, we'll fall back to the browser default instead.
+    // Safari doesn't have the `isTypeSupported` API.
+    if (MediaRecorder.isTypeSupported) {
+      const { restrictions } = this.uppy.opts
+      let preferredVideoMimeTypes = []
+      if (this.opts.preferredVideoMimeType) {
+        preferredVideoMimeTypes = [this.opts.preferredVideoMimeType]
+      } else if (restrictions.allowedFileTypes) {
+        preferredVideoMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isVideoMimeType)
+      }
+
+      const filterSupportedTypes = (candidateType) => MediaRecorder.isTypeSupported(candidateType)
+        && getFileTypeExtension(candidateType)
+      const acceptableMimeTypes = preferredVideoMimeTypes.filter(filterSupportedTypes)
+
+      if (acceptableMimeTypes.length > 0) {
+        // eslint-disable-next-line prefer-destructuring
+        options.mimeType = acceptableMimeTypes[0]
+      }
+    }
+
+    return options
+  }
+
+  startRecording () {
+    // only used if supportsMediaRecorder() returned true
+    // eslint-disable-next-line compat/compat
+    this.recorder = new MediaRecorder(this.stream, this.getMediaRecorderOptions())
+    this.recordingChunks = []
+    let stoppingBecauseOfMaxSize = false
+    this.recorder.addEventListener('dataavailable', (event) => {
+      this.recordingChunks.push(event.data)
+
+      const { restrictions } = this.uppy.opts
+      if (this.recordingChunks.length > 1
+          && restrictions.maxFileSize != null
+          && !stoppingBecauseOfMaxSize) {
+        const totalSize = this.recordingChunks.reduce((acc, chunk) => acc + chunk.size, 0)
+        // Exclude the initial chunk from the average size calculation because it is likely to be a very small outlier
+        const averageChunkSize = (totalSize - this.recordingChunks[0].size) / (this.recordingChunks.length - 1)
+        const expectedEndChunkSize = averageChunkSize * 3
+        const maxSize = Math.max(0, restrictions.maxFileSize - expectedEndChunkSize)
+
+        if (totalSize > maxSize) {
+          stoppingBecauseOfMaxSize = true
+          this.uppy.info(this.i18n('recordingStoppedMaxSize'), 'warning', 4000)
+          this.stopRecording()
+        }
+      }
+    })
+
+    // use a "time slice" of 500ms: ondataavailable will be called each 500ms
+    // smaller time slices mean we can more accurately check the max file size restriction
+    this.recorder.start(500)
+
+    if (this.opts.showRecordingLength) {
+      // Start the recordingLengthTimer if we are showing the recording length.
+      this.recordingLengthTimer = setInterval(() => {
+        const currentRecordingLength = this.getPluginState().recordingLengthSeconds
+        this.setPluginState({ recordingLengthSeconds: currentRecordingLength + 1 })
+      }, 1000)
+    }
+
+    this.setPluginState({
+      isRecording: true,
+    })
+  }
+
+  stopRecording () {
+    const stopped = new Promise((resolve) => {
+      this.recorder.addEventListener('stop', () => {
+        resolve()
+      })
+      this.recorder.stop()
+
+      if (this.opts.showRecordingLength) {
+        // Stop the recordingLengthTimer if we are showing the recording length.
+        clearInterval(this.recordingLengthTimer)
+        this.setPluginState({ recordingLengthSeconds: 0 })
+      }
+    })
+
+    return stopped.then(() => {
+      this.setPluginState({
+        isRecording: false,
+      })
+      return this.getVideo()
+    }).then((file) => {
+      try {
+        this.capturedMediaFile = file
+        // create object url for capture result preview
+        this.setPluginState({
+          // eslint-disable-next-line compat/compat
+          recordedVideo: URL.createObjectURL(file.data),
+        })
+        this.#enableMirror = false
+      } catch (err) {
+        // Logging the error, exept restrictions, which is handled in Core
+        if (!err.isRestriction) {
+          this.uppy.log(err)
+        }
+      }
+    }).then(() => {
+      this.recordingChunks = null
+      this.recorder = null
+    }, (error) => {
+      this.recordingChunks = null
+      this.recorder = null
+      throw error
+    })
+  }
+
+  discardRecordedVideo () {
+    this.setPluginState({ recordedVideo: null })
+
+    if (this.opts.mirror) {
+      this.#enableMirror = true
+    }
+
+    this.capturedMediaFile = null
+  }
+
+  submit () {
+    try {
+      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, 'error')
+      }
+    }
+  }
+
+  async stop () {
+    if (this.stream) {
+      const audioTracks = this.stream.getAudioTracks()
+      const videoTracks = this.stream.getVideoTracks()
+
+      audioTracks.concat(videoTracks).forEach((track) => track.stop())
+    }
+
+    if (this.recorder) {
+      await new Promise((resolve) => {
+        this.recorder.addEventListener('stop', resolve, { once: true })
+        this.recorder.stop()
+
+        if (this.opts.showRecordingLength) {
+          clearInterval(this.recordingLengthTimer)
+        }
+      })
+    }
+
+    this.recordingChunks = null
+    this.recorder = null
+    this.webcamActive = false
+    this.stream = null
+
+    this.setPluginState({
+      recordedVideo: null,
+      isRecording: false,
+      recordingLengthSeconds: 0,
+    })
+  }
+
+  getVideoElement () {
+    return this.el.querySelector('.uppy-Webcam-video')
+  }
+
+  oneTwoThreeSmile () {
+    return new Promise((resolve, reject) => {
+      let count = this.opts.countdown
+
+      // eslint-disable-next-line consistent-return
+      const countDown = setInterval(() => {
+        if (!this.webcamActive) {
+          clearInterval(countDown)
+          this.captureInProgress = false
+          return reject(new Error('Webcam is not active'))
+        }
+
+        if (count > 0) {
+          this.uppy.info(`${count}...`, 'warning', 800)
+          count--
+        } else {
+          clearInterval(countDown)
+          this.uppy.info(this.i18n('smile'), 'success', 1500)
+          setTimeout(() => resolve(), 1500)
+        }
+      }, 1000)
+    })
+  }
+
+  takeSnapshot () {
+    if (this.captureInProgress) return
+
+    this.captureInProgress = true
+
+    this.opts.onBeforeSnapshot().catch((err) => {
+      const message = typeof err === 'object' ? err.message : err
+      this.uppy.info(message, 'error', 5000)
+      return Promise.reject(new Error(`onBeforeSnapshot: ${message}`))
+    }).then(() => {
+      return this.getImage()
+    }).then((tagFile) => {
+      this.captureInProgress = false
+      try {
+        this.uppy.addFile(tagFile)
+      } catch (err) {
+        // Logging the error, except restrictions, which is handled in Core
+        if (!err.isRestriction) {
+          this.uppy.log(err)
+        }
+      }
+    }, (error) => {
+      this.captureInProgress = false
+      throw error
+    })
+  }
+
+  getImage () {
+    const video = this.getVideoElement()
+    if (!video) {
+      return Promise.reject(new Error('No video element found, likely due to the Webcam tab being closed.'))
+    }
+
+    const width = video.videoWidth
+    const height = video.videoHeight
+
+    const canvas = document.createElement('canvas')
+    canvas.width = width
+    canvas.height = height
+    const ctx = canvas.getContext('2d')
+    ctx.drawImage(video, 0, 0)
+
+    const { restrictions } = this.uppy.opts
+    let preferredImageMimeTypes = []
+    if (this.opts.preferredImageMimeType) {
+      preferredImageMimeTypes = [this.opts.preferredImageMimeType]
+    } else if (restrictions.allowedFileTypes) {
+      preferredImageMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isImageMimeType)
+    }
+
+    const mimeType = preferredImageMimeTypes[0] || 'image/jpeg'
+    const ext = getFileTypeExtension(mimeType) || 'jpg'
+    const name = `cam-${Date.now()}.${ext}`
+
+    return canvasToBlob(canvas, mimeType).then((blob) => {
+      return {
+        source: this.id,
+        name,
+        data: new Blob([blob], { type: mimeType }),
+        type: mimeType,
+      }
+    })
+  }
+
+  getVideo () {
+    // Sometimes in iOS Safari, Blobs (especially the first Blob in the recordingChunks Array)
+    // have empty 'type' attributes (e.g. '') so we need to find a Blob that has a defined 'type'
+    // attribute in order to determine the correct MIME type.
+    const mimeType = this.recordingChunks.find(blob => blob.type?.length > 0).type
+
+    const fileExtension = getFileTypeExtension(mimeType)
+
+    if (!fileExtension) {
+      return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`))
+    }
+
+    const name = `webcam-${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)
+  }
+
+  focus () {
+    if (!this.opts.countdown) return
+    setTimeout(() => {
+      this.uppy.info(this.i18n('smile'), 'success', 1500)
+    }, 1000)
+  }
+
+  changeVideoSource (deviceId) {
+    this.stop()
+    this.start({ deviceId })
+  }
+
+  updateVideoSources () {
+    this.mediaDevices.enumerateDevices().then(devices => {
+      this.setPluginState({
+        videoSources: devices.filter((device) => device.kind === 'videoinput'),
+      })
+    })
+  }
+
+  render () {
+    if (!this.webcamActive) {
+      this.start()
+    }
+
+    const webcamState = this.getPluginState()
+
+    if (!webcamState.cameraReady || !webcamState.hasCamera) {
+      return (
+        <PermissionsScreen
+          icon={CameraIcon}
+          i18n={this.i18n}
+          hasCamera={webcamState.hasCamera}
+        />
+      )
+    }
+
+    return (
+      <CameraScreen
+        // eslint-disable-next-line react/jsx-props-no-spreading
+        {...webcamState}
+        onChangeVideoSource={this.changeVideoSource}
+        onSnapshot={this.takeSnapshot}
+        onStartRecording={this.startRecording}
+        onStopRecording={this.stopRecording}
+        onDiscardRecordedVideo={this.discardRecordedVideo}
+        onSubmit={this.submit}
+        onFocus={this.focus}
+        onStop={this.stop}
+        i18n={this.i18n}
+        modes={this.opts.modes}
+        showRecordingLength={this.opts.showRecordingLength}
+        showVideoSourceDropdown={this.opts.showVideoSourceDropdown}
+        supportsRecording={supportsMediaRecorder()}
+        recording={webcamState.isRecording}
+        mirror={this.#enableMirror}
+        src={this.stream}
+      />
+    )
+  }
+
+  install () {
+    this.setPluginState({
+      cameraReady: false,
+      recordingLengthSeconds: 0,
+    })
+
+    const { target } = this.opts
+    if (target) {
+      this.mount(target, this)
+    }
+
+    if (this.mediaDevices) {
+      this.updateVideoSources()
+
+      this.mediaDevices.ondevicechange = () => {
+        this.updateVideoSources()
+
+        if (this.stream) {
+          let restartStream = true
+
+          const { videoSources, currentDeviceId } = this.getPluginState()
+
+          videoSources.forEach((videoSource) => {
+            if (currentDeviceId === videoSource.deviceId) {
+              restartStream = false
+            }
+          })
+
+          if (restartStream) {
+            this.stop()
+            this.start()
+          }
+        }
+      }
+    }
+  }
+
+  uninstall () {
+    this.stop()
+    this.unmount()
+  }
+
+  onUnmount () {
+    this.stop()
+  }
+}

+ 2 - 2
packages/@uppy/webcam/src/index.test.js → packages/@uppy/webcam/src/Webcam.test.js

@@ -1,5 +1,5 @@
-const Uppy = require('@uppy/core')
-const Webcam = require('./index')
+import Uppy from '@uppy/core'
+import Webcam from '../lib/index.js'
 
 describe('Webcam', () => {
   describe('_getMediaRecorderOptions', () => {

+ 1 - 1
packages/@uppy/webcam/src/formatSeconds.js

@@ -5,7 +5,7 @@
  * @returns {string} the formatted seconds (e.g. '1:23' for 1 minute and 23 seconds)
  *
  */
-module.exports = function formatSeconds (seconds) {
+export default function formatSeconds (seconds) {
   return `${Math.floor(
     seconds / 60,
   )}:${String(seconds % 60).padStart(2, 0)}`

+ 1 - 1
packages/@uppy/webcam/src/formatSeconds.test.js

@@ -1,4 +1,4 @@
-const formatSeconds = require('./formatSeconds')
+import formatSeconds from './formatSeconds.js'
 
 describe('formatSeconds', () => {
   it('should return a value of \'0:43\' when an argument of 43 seconds is supplied', () => {

+ 1 - 633
packages/@uppy/webcam/src/index.js

@@ -1,633 +1 @@
-const { h } = require('preact')
-const { UIPlugin } = require('@uppy/core')
-const getFileTypeExtension = require('@uppy/utils/lib/getFileTypeExtension')
-const mimeTypes = require('@uppy/utils/lib/mimeTypes')
-const canvasToBlob = require('@uppy/utils/lib/canvasToBlob')
-const supportsMediaRecorder = require('./supportsMediaRecorder')
-const CameraIcon = require('./CameraIcon')
-const CameraScreen = require('./CameraScreen')
-const PermissionsScreen = require('./PermissionsScreen')
-
-const locale = require('./locale.js')
-/**
- * Normalize a MIME type or file extension into a MIME type.
- *
- * @param {string} fileType - MIME type or a file extension prefixed with `.`.
- * @returns {string|undefined} The MIME type or `undefined` if the fileType is an extension and is not known.
- */
-function toMimeType (fileType) {
-  if (fileType[0] === '.') {
-    return mimeTypes[fileType.slice(1)]
-  }
-  return fileType
-}
-
-/**
- * Is this MIME type a video?
- *
- * @param {string} mimeType - MIME type.
- * @returns {boolean}
- */
-function isVideoMimeType (mimeType) {
-  return /^video\/[^*]+$/.test(mimeType)
-}
-
-/**
- * Is this MIME type an image?
- *
- * @param {string} mimeType - MIME type.
- * @returns {boolean}
- */
-function isImageMimeType (mimeType) {
-  return /^image\/[^*]+$/.test(mimeType)
-}
-
-function getMediaDevices () {
-  // bug in the compatibility data
-  // eslint-disable-next-line compat/compat
-  return navigator.mediaDevices
-}
-/**
- * Webcam
- */
-module.exports = class Webcam extends UIPlugin {
-  // eslint-disable-next-line global-require
-  static VERSION = require('../package.json').version
-
-  // enableMirror is used to toggle mirroring, for instance when discarding the video,
-  // while `opts.mirror` is used to remember the initial user setting
-  #enableMirror
-
-  constructor (uppy, opts) {
-    super(uppy, opts)
-    this.mediaDevices = getMediaDevices()
-    this.supportsUserMedia = !!this.mediaDevices
-    // eslint-disable-next-line no-restricted-globals
-    this.protocol = location.protocol.match(/https/i) ? 'https' : 'http'
-    this.id = this.opts.id || 'Webcam'
-    this.type = 'acquirer'
-    this.capturedMediaFile = null
-    this.icon = () => (
-      <svg aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32">
-        <g fill="none" fillRule="evenodd">
-          <rect className="uppy-ProviderIconBg" fill="#03BFEF" width="32" height="32" rx="16" />
-          <path d="M22 11c1.133 0 2 .867 2 2v7.333c0 1.134-.867 2-2 2H10c-1.133 0-2-.866-2-2V13c0-1.133.867-2 2-2h2.333l1.134-1.733C13.6 9.133 13.8 9 14 9h4c.2 0 .4.133.533.267L19.667 11H22zm-6 1.533a3.764 3.764 0 0 0-3.8 3.8c0 2.129 1.672 3.801 3.8 3.801s3.8-1.672 3.8-3.8c0-2.13-1.672-3.801-3.8-3.801zm0 6.261c-1.395 0-2.46-1.066-2.46-2.46 0-1.395 1.065-2.461 2.46-2.461s2.46 1.066 2.46 2.46c0 1.395-1.065 2.461-2.46 2.461z" fill="#FFF" fillRule="nonzero" />
-        </g>
-      </svg>
-    )
-
-    this.defaultLocale = locale
-
-    // set default options
-    const defaultOptions = {
-      onBeforeSnapshot: () => Promise.resolve(),
-      countdown: false,
-      modes: [
-        'video-audio',
-        'video-only',
-        'audio-only',
-        'picture',
-      ],
-      mirror: true,
-      showVideoSourceDropdown: false,
-      facingMode: 'user',
-      preferredImageMimeType: null,
-      preferredVideoMimeType: null,
-      showRecordingLength: false,
-    }
-
-    this.opts = { ...defaultOptions, ...opts }
-    this.i18nInit()
-    this.title = this.i18n('pluginNameCamera')
-
-    this.#enableMirror = this.opts.mirror
-
-    this.install = this.install.bind(this)
-    this.setPluginState = this.setPluginState.bind(this)
-    this.render = this.render.bind(this)
-
-    // Camera controls
-    this.start = this.start.bind(this)
-    this.stop = this.stop.bind(this)
-    this.takeSnapshot = this.takeSnapshot.bind(this)
-    this.startRecording = this.startRecording.bind(this)
-    this.stopRecording = this.stopRecording.bind(this)
-    this.discardRecordedVideo = this.discardRecordedVideo.bind(this)
-    this.submit = this.submit.bind(this)
-    this.oneTwoThreeSmile = this.oneTwoThreeSmile.bind(this)
-    this.focus = this.focus.bind(this)
-    this.changeVideoSource = this.changeVideoSource.bind(this)
-
-    this.webcamActive = false
-
-    if (this.opts.countdown) {
-      this.opts.onBeforeSnapshot = this.oneTwoThreeSmile
-    }
-
-    this.setPluginState({
-      hasCamera: false,
-      cameraReady: false,
-      cameraError: null,
-      recordingLengthSeconds: 0,
-      videoSources: [],
-      currentDeviceId: null,
-    })
-  }
-
-  setOptions (newOpts) {
-    super.setOptions({
-      ...newOpts,
-      videoConstraints: {
-        // May be undefined but ... handles that
-        ...this.opts.videoConstraints,
-        ...newOpts?.videoConstraints,
-      },
-    })
-  }
-
-  hasCameraCheck () {
-    if (!this.mediaDevices) {
-      return Promise.resolve(false)
-    }
-
-    return this.mediaDevices.enumerateDevices().then(devices => {
-      return devices.some(device => device.kind === 'videoinput')
-    })
-  }
-
-  isAudioOnly () {
-    return this.opts.modes.length === 1 && this.opts.modes[0] === 'audio-only'
-  }
-
-  getConstraints (deviceId = null) {
-    const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1
-      || this.opts.modes.indexOf('audio-only') !== -1
-    const acceptsVideo = !this.isAudioOnly()
-        && (this.opts.modes.indexOf('video-audio') !== -1
-          || this.opts.modes.indexOf('video-only') !== -1
-          || this.opts.modes.indexOf('picture') !== -1)
-
-    const videoConstraints = {
-      ...(this.opts.videoConstraints || { facingMode: this.opts.facingMode }),
-      // facingMode takes precedence over deviceId, and not needed
-      // when specific device is selected
-      ...(deviceId ? { deviceId, facingMode: null } : {}),
-    }
-
-    return {
-      audio: acceptsAudio,
-      video: acceptsVideo ? videoConstraints : false,
-    }
-  }
-
-  // eslint-disable-next-line consistent-return
-  start (options = null) {
-    if (!this.supportsUserMedia) {
-      return Promise.reject(new Error('Webcam access not supported'))
-    }
-
-    this.webcamActive = true
-
-    if (this.opts.mirror) {
-      this.#enableMirror = true
-    }
-
-    const constraints = this.getConstraints(options && options.deviceId ? options.deviceId : null)
-
-    this.hasCameraCheck().then(hasCamera => {
-      this.setPluginState({
-        hasCamera,
-      })
-
-      // ask user for access to their camera
-      return this.mediaDevices.getUserMedia(constraints)
-        .then((stream) => {
-          this.stream = stream
-
-          let currentDeviceId = null
-          const tracks = this.isAudioOnly() ? stream.getAudioTracks() : stream.getVideoTracks()
-
-          if (!options || !options.deviceId) {
-            currentDeviceId = tracks[0].getSettings().deviceId
-          } else {
-            tracks.forEach((track) => {
-              if (track.getSettings().deviceId === options.deviceId) {
-                currentDeviceId = track.getSettings().deviceId
-              }
-            })
-          }
-
-          // Update the sources now, so we can access the names.
-          this.updateVideoSources()
-
-          this.setPluginState({
-            currentDeviceId,
-            cameraReady: true,
-          })
-        })
-        .catch((err) => {
-          this.setPluginState({
-            cameraReady: false,
-            cameraError: err,
-          })
-          this.uppy.info(err.message, 'error')
-        })
-    })
-  }
-
-  /**
-   * @returns {object}
-   */
-  getMediaRecorderOptions () {
-    const options = {}
-
-    // Try to use the `opts.preferredVideoMimeType` or one of the `allowedFileTypes` for the recording.
-    // If the browser doesn't support it, we'll fall back to the browser default instead.
-    // Safari doesn't have the `isTypeSupported` API.
-    if (MediaRecorder.isTypeSupported) {
-      const { restrictions } = this.uppy.opts
-      let preferredVideoMimeTypes = []
-      if (this.opts.preferredVideoMimeType) {
-        preferredVideoMimeTypes = [this.opts.preferredVideoMimeType]
-      } else if (restrictions.allowedFileTypes) {
-        preferredVideoMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isVideoMimeType)
-      }
-
-      const filterSupportedTypes = (candidateType) => MediaRecorder.isTypeSupported(candidateType)
-        && getFileTypeExtension(candidateType)
-      const acceptableMimeTypes = preferredVideoMimeTypes.filter(filterSupportedTypes)
-
-      if (acceptableMimeTypes.length > 0) {
-        // eslint-disable-next-line prefer-destructuring
-        options.mimeType = acceptableMimeTypes[0]
-      }
-    }
-
-    return options
-  }
-
-  startRecording () {
-    // only used if supportsMediaRecorder() returned true
-    // eslint-disable-next-line compat/compat
-    this.recorder = new MediaRecorder(this.stream, this.getMediaRecorderOptions())
-    this.recordingChunks = []
-    let stoppingBecauseOfMaxSize = false
-    this.recorder.addEventListener('dataavailable', (event) => {
-      this.recordingChunks.push(event.data)
-
-      const { restrictions } = this.uppy.opts
-      if (this.recordingChunks.length > 1
-          && restrictions.maxFileSize != null
-          && !stoppingBecauseOfMaxSize) {
-        const totalSize = this.recordingChunks.reduce((acc, chunk) => acc + chunk.size, 0)
-        // Exclude the initial chunk from the average size calculation because it is likely to be a very small outlier
-        const averageChunkSize = (totalSize - this.recordingChunks[0].size) / (this.recordingChunks.length - 1)
-        const expectedEndChunkSize = averageChunkSize * 3
-        const maxSize = Math.max(0, restrictions.maxFileSize - expectedEndChunkSize)
-
-        if (totalSize > maxSize) {
-          stoppingBecauseOfMaxSize = true
-          this.uppy.info(this.i18n('recordingStoppedMaxSize'), 'warning', 4000)
-          this.stopRecording()
-        }
-      }
-    })
-
-    // use a "time slice" of 500ms: ondataavailable will be called each 500ms
-    // smaller time slices mean we can more accurately check the max file size restriction
-    this.recorder.start(500)
-
-    if (this.opts.showRecordingLength) {
-      // Start the recordingLengthTimer if we are showing the recording length.
-      this.recordingLengthTimer = setInterval(() => {
-        const currentRecordingLength = this.getPluginState().recordingLengthSeconds
-        this.setPluginState({ recordingLengthSeconds: currentRecordingLength + 1 })
-      }, 1000)
-    }
-
-    this.setPluginState({
-      isRecording: true,
-    })
-  }
-
-  stopRecording () {
-    const stopped = new Promise((resolve) => {
-      this.recorder.addEventListener('stop', () => {
-        resolve()
-      })
-      this.recorder.stop()
-
-      if (this.opts.showRecordingLength) {
-        // Stop the recordingLengthTimer if we are showing the recording length.
-        clearInterval(this.recordingLengthTimer)
-        this.setPluginState({ recordingLengthSeconds: 0 })
-      }
-    })
-
-    return stopped.then(() => {
-      this.setPluginState({
-        isRecording: false,
-      })
-      return this.getVideo()
-    }).then((file) => {
-      try {
-        this.capturedMediaFile = file
-        // create object url for capture result preview
-        this.setPluginState({
-          // eslint-disable-next-line compat/compat
-          recordedVideo: URL.createObjectURL(file.data),
-        })
-        this.#enableMirror = false
-      } catch (err) {
-        // Logging the error, exept restrictions, which is handled in Core
-        if (!err.isRestriction) {
-          this.uppy.log(err)
-        }
-      }
-    }).then(() => {
-      this.recordingChunks = null
-      this.recorder = null
-    }, (error) => {
-      this.recordingChunks = null
-      this.recorder = null
-      throw error
-    })
-  }
-
-  discardRecordedVideo () {
-    this.setPluginState({ recordedVideo: null })
-
-    if (this.opts.mirror) {
-      this.#enableMirror = true
-    }
-
-    this.capturedMediaFile = null
-  }
-
-  submit () {
-    try {
-      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, 'error')
-      }
-    }
-  }
-
-  async stop () {
-    if (this.stream) {
-      const audioTracks = this.stream.getAudioTracks()
-      const videoTracks = this.stream.getVideoTracks()
-
-      audioTracks.concat(videoTracks).forEach((track) => track.stop())
-    }
-
-    if (this.recorder) {
-      await new Promise((resolve) => {
-        this.recorder.addEventListener('stop', resolve, { once: true })
-        this.recorder.stop()
-
-        if (this.opts.showRecordingLength) {
-          clearInterval(this.recordingLengthTimer)
-        }
-      })
-    }
-
-    this.recordingChunks = null
-    this.recorder = null
-    this.webcamActive = false
-    this.stream = null
-
-    this.setPluginState({
-      recordedVideo: null,
-      isRecording: false,
-      recordingLengthSeconds: 0,
-    })
-  }
-
-  getVideoElement () {
-    return this.el.querySelector('.uppy-Webcam-video')
-  }
-
-  oneTwoThreeSmile () {
-    return new Promise((resolve, reject) => {
-      let count = this.opts.countdown
-
-      // eslint-disable-next-line consistent-return
-      const countDown = setInterval(() => {
-        if (!this.webcamActive) {
-          clearInterval(countDown)
-          this.captureInProgress = false
-          return reject(new Error('Webcam is not active'))
-        }
-
-        if (count > 0) {
-          this.uppy.info(`${count}...`, 'warning', 800)
-          count--
-        } else {
-          clearInterval(countDown)
-          this.uppy.info(this.i18n('smile'), 'success', 1500)
-          setTimeout(() => resolve(), 1500)
-        }
-      }, 1000)
-    })
-  }
-
-  takeSnapshot () {
-    if (this.captureInProgress) return
-
-    this.captureInProgress = true
-
-    this.opts.onBeforeSnapshot().catch((err) => {
-      const message = typeof err === 'object' ? err.message : err
-      this.uppy.info(message, 'error', 5000)
-      return Promise.reject(new Error(`onBeforeSnapshot: ${message}`))
-    }).then(() => {
-      return this.getImage()
-    }).then((tagFile) => {
-      this.captureInProgress = false
-      try {
-        this.uppy.addFile(tagFile)
-      } catch (err) {
-        // Logging the error, except restrictions, which is handled in Core
-        if (!err.isRestriction) {
-          this.uppy.log(err)
-        }
-      }
-    }, (error) => {
-      this.captureInProgress = false
-      throw error
-    })
-  }
-
-  getImage () {
-    const video = this.getVideoElement()
-    if (!video) {
-      return Promise.reject(new Error('No video element found, likely due to the Webcam tab being closed.'))
-    }
-
-    const width = video.videoWidth
-    const height = video.videoHeight
-
-    const canvas = document.createElement('canvas')
-    canvas.width = width
-    canvas.height = height
-    const ctx = canvas.getContext('2d')
-    ctx.drawImage(video, 0, 0)
-
-    const { restrictions } = this.uppy.opts
-    let preferredImageMimeTypes = []
-    if (this.opts.preferredImageMimeType) {
-      preferredImageMimeTypes = [this.opts.preferredImageMimeType]
-    } else if (restrictions.allowedFileTypes) {
-      preferredImageMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isImageMimeType)
-    }
-
-    const mimeType = preferredImageMimeTypes[0] || 'image/jpeg'
-    const ext = getFileTypeExtension(mimeType) || 'jpg'
-    const name = `cam-${Date.now()}.${ext}`
-
-    return canvasToBlob(canvas, mimeType).then((blob) => {
-      return {
-        source: this.id,
-        name,
-        data: new Blob([blob], { type: mimeType }),
-        type: mimeType,
-      }
-    })
-  }
-
-  getVideo () {
-    // Sometimes in iOS Safari, Blobs (especially the first Blob in the recordingChunks Array)
-    // have empty 'type' attributes (e.g. '') so we need to find a Blob that has a defined 'type'
-    // attribute in order to determine the correct MIME type.
-    const mimeType = this.recordingChunks.find(blob => blob.type?.length > 0).type
-
-    const fileExtension = getFileTypeExtension(mimeType)
-
-    if (!fileExtension) {
-      return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`))
-    }
-
-    const name = `webcam-${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)
-  }
-
-  focus () {
-    if (!this.opts.countdown) return
-    setTimeout(() => {
-      this.uppy.info(this.i18n('smile'), 'success', 1500)
-    }, 1000)
-  }
-
-  changeVideoSource (deviceId) {
-    this.stop()
-    this.start({ deviceId })
-  }
-
-  updateVideoSources () {
-    this.mediaDevices.enumerateDevices().then(devices => {
-      this.setPluginState({
-        videoSources: devices.filter((device) => device.kind === 'videoinput'),
-      })
-    })
-  }
-
-  render () {
-    if (!this.webcamActive) {
-      this.start()
-    }
-
-    const webcamState = this.getPluginState()
-
-    if (!webcamState.cameraReady || !webcamState.hasCamera) {
-      return (
-        <PermissionsScreen
-          icon={CameraIcon}
-          i18n={this.i18n}
-          hasCamera={webcamState.hasCamera}
-        />
-      )
-    }
-
-    return (
-      <CameraScreen
-        // eslint-disable-next-line react/jsx-props-no-spreading
-        {...webcamState}
-        onChangeVideoSource={this.changeVideoSource}
-        onSnapshot={this.takeSnapshot}
-        onStartRecording={this.startRecording}
-        onStopRecording={this.stopRecording}
-        onDiscardRecordedVideo={this.discardRecordedVideo}
-        onSubmit={this.submit}
-        onFocus={this.focus}
-        onStop={this.stop}
-        i18n={this.i18n}
-        modes={this.opts.modes}
-        showRecordingLength={this.opts.showRecordingLength}
-        showVideoSourceDropdown={this.opts.showVideoSourceDropdown}
-        supportsRecording={supportsMediaRecorder()}
-        recording={webcamState.isRecording}
-        mirror={this.#enableMirror}
-        src={this.stream}
-      />
-    )
-  }
-
-  install () {
-    this.setPluginState({
-      cameraReady: false,
-      recordingLengthSeconds: 0,
-    })
-
-    const { target } = this.opts
-    if (target) {
-      this.mount(target, this)
-    }
-
-    if (this.mediaDevices) {
-      this.updateVideoSources()
-
-      this.mediaDevices.ondevicechange = () => {
-        this.updateVideoSources()
-
-        if (this.stream) {
-          let restartStream = true
-
-          const { videoSources, currentDeviceId } = this.getPluginState()
-
-          videoSources.forEach((videoSource) => {
-            if (currentDeviceId === videoSource.deviceId) {
-              restartStream = false
-            }
-          })
-
-          if (restartStream) {
-            this.stop()
-            this.start()
-          }
-        }
-      }
-    }
-  }
-
-  uninstall () {
-    this.stop()
-    this.unmount()
-  }
-
-  onUnmount () {
-    this.stop()
-  }
-}
+export { default } from './Webcam.jsx'

+ 1 - 1
packages/@uppy/webcam/src/locale.js

@@ -1,4 +1,4 @@
-module.exports = {
+export default {
   strings: {
     pluginNameCamera: 'Camera',
     noCameraTitle: 'Camera Not Available',

+ 1 - 1
packages/@uppy/webcam/src/supportsMediaRecorder.js

@@ -1,4 +1,4 @@
-module.exports = function supportsMediaRecorder () {
+export default function supportsMediaRecorder () {
   /* eslint-disable compat/compat */
   return typeof MediaRecorder === 'function' && !!MediaRecorder.prototype
     && typeof MediaRecorder.prototype.start === 'function'

+ 2 - 1
packages/@uppy/webcam/src/supportsMediaRecorder.test.js

@@ -1,4 +1,5 @@
-const supportsMediaRecorder = require('./supportsMediaRecorder')
+/* eslint-disable max-classes-per-file, class-methods-use-this */
+import supportsMediaRecorder from './supportsMediaRecorder.js'
 
 describe('supportsMediaRecorder', () => {
   it('should return true if MediaRecorder is supported', () => {

+ 1 - 1
website/src/docs/webcam.md

@@ -161,7 +161,7 @@ If no preferred image mime type is given, the Webcam plugin will prefer types li
 <!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-module.exports = {
+export default {
   strings: {
     pluginNameCamera: 'Camera',
     noCameraTitle: 'Camera Not Available',