Explorar o código

webcam: add video source selector (#2492)

* webcam: add showVideoSourceDropdown

* webcam: update video device list when adding/removing a video device

* webcam: cleaner changes

* delete extraneous file

* webcam: store sources data in state

* webcam: regather sources after getting access

* webcam: put the source selector next to the buttons

* webcam: put the video source selection on its own row in mobile view

* website: show webcam video source dropdown

* enable webcam features in dev example

* facingMode takes precedence over deviceId, and not needed when specific device is selected

fixes issue where on mobile (iOS) the device selection was ignored due to facingMode being set to 'user'

* Remove “video source” copy, style select, deal with iOS zoom-in issue

Co-authored-by: Tsy-Jon Lau <tlau@parsys.com>
Co-authored-by: Artur Paikin <artur@arturpaikin.com>
Renée Kooi %!s(int64=4) %!d(string=hai) anos
pai
achega
ef5ba9c8fc

+ 5 - 1
examples/dev/Dashboard.js

@@ -70,7 +70,11 @@ module.exports = () => {
     .use(OneDrive, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(OneDrive, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Zoom, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Zoom, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Url, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Url, { target: Dashboard, companionUrl: COMPANION_URL })
-    .use(Webcam, { target: Dashboard })
+    .use(Webcam, {
+      target: Dashboard,
+      showVideoSourceDropdown: true,
+      showRecordingLength: true
+    })
     .use(ScreenCapture, { target: Dashboard })
     .use(ScreenCapture, { target: Dashboard })
     .use(Form, { target: '#upload-form' })
     .use(Form, { target: '#upload-form' })
     .use(ImageEditor, { target: Dashboard })
     .use(ImageEditor, { target: Dashboard })

+ 14 - 6
packages/@uppy/webcam/src/CameraScreen.js

@@ -2,6 +2,7 @@ const { h, Component } = require('preact')
 const SnapshotButton = require('./SnapshotButton')
 const SnapshotButton = require('./SnapshotButton')
 const RecordButton = require('./RecordButton')
 const RecordButton = require('./RecordButton')
 const RecordingLength = require('./RecordingLength')
 const RecordingLength = require('./RecordingLength')
+const VideoSourceSelect = require('./VideoSourceSelect')
 
 
 function isModeAvailable (modes, mode) {
 function isModeAvailable (modes, mode) {
   return modes.indexOf(mode) !== -1
   return modes.indexOf(mode) !== -1
@@ -24,18 +25,25 @@ class CameraScreen extends Component {
     )
     )
     const shouldShowSnapshotButton = isModeAvailable(this.props.modes, 'picture')
     const shouldShowSnapshotButton = isModeAvailable(this.props.modes, 'picture')
     const shouldShowRecordingLength = this.props.supportsRecording && this.props.showRecordingLength
     const shouldShowRecordingLength = this.props.supportsRecording && this.props.showRecordingLength
+    const shouldShowVideoSourceDropdown = this.props.showVideoSourceDropdown && this.props.videoSources && this.props.videoSources.length > 1
 
 
     return (
     return (
       <div class="uppy uppy-Webcam-container">
       <div class="uppy uppy-Webcam-container">
         <div class="uppy-Webcam-videoContainer">
         <div class="uppy-Webcam-videoContainer">
           <video class={`uppy-Webcam-video  ${this.props.mirror ? 'uppy-Webcam-video--mirrored' : ''}`} autoplay muted playsinline srcObject={this.props.src || ''} />
           <video class={`uppy-Webcam-video  ${this.props.mirror ? 'uppy-Webcam-video--mirrored' : ''}`} autoplay muted playsinline srcObject={this.props.src || ''} />
         </div>
         </div>
-        <div class="uppy-Webcam-buttonContainer">
-          {shouldShowRecordingLength ? RecordingLength(this.props) : null}
-          {' '}
-          {shouldShowSnapshotButton ? SnapshotButton(this.props) : null}
-          {' '}
-          {shouldShowRecordButton ? RecordButton(this.props) : null}
+        <div class="uppy-Webcam-footer">
+          <div class="uppy-Webcam-videoSourceContainer">
+            {shouldShowVideoSourceDropdown ? VideoSourceSelect(this.props) : null}
+          </div>
+          <div class="uppy-Webcam-buttonContainer">
+            {shouldShowSnapshotButton ? SnapshotButton(this.props) : null}
+            {' '}
+            {shouldShowRecordButton ? RecordButton(this.props) : null}
+          </div>
+          <div class="uppy-Webcam-recordingLength">
+            {shouldShowRecordingLength ? RecordingLength(this.props) : null}
+          </div>
         </div>
         </div>
       </div>
       </div>
     )
     )

+ 2 - 2
packages/@uppy/webcam/src/RecordingLength.js

@@ -5,8 +5,8 @@ module.exports = function RecordingLength ({ recordingLengthSeconds, i18n }) {
   const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
   const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
 
 
   return (
   return (
-    <div class="uppy-Webcam-recordingLength" aria-label={i18n('recordingLength', { recording_length: formattedRecordingLengthSeconds })}>
+    <span aria-label={i18n('recordingLength', { recording_length: formattedRecordingLengthSeconds })}>
       {formattedRecordingLengthSeconds}
       {formattedRecordingLengthSeconds}
-    </div>
+    </span>
   )
   )
 }
 }

+ 21 - 0
packages/@uppy/webcam/src/VideoSourceSelect.js

@@ -0,0 +1,21 @@
+const { h } = require('preact')
+
+module.exports = ({ currentDeviceId, videoSources, onChangeVideoSource }) => {
+  return (
+    <div className="uppy-Webcam-videoSource">
+      <select
+        className="uppy-u-reset uppy-Webcam-videoSource-select"
+        onchange={(event) => { onChangeVideoSource(event.target.value) }}
+      >
+        {videoSources.map((videoSource) =>
+          <option
+            key={videoSource.deviceId}
+            value={videoSource.deviceId}
+            selected={videoSource.deviceId === currentDeviceId}
+          >
+            {videoSource.label}
+          </option>)}
+      </select>
+    </div>
+  )
+}

+ 76 - 5
packages/@uppy/webcam/src/index.js

@@ -115,6 +115,7 @@ module.exports = class Webcam extends Plugin {
         'picture'
         'picture'
       ],
       ],
       mirror: true,
       mirror: true,
+      showVideoSourceDropdown: false,
       facingMode: 'user',
       facingMode: 'user',
       preferredImageMimeType: null,
       preferredImageMimeType: null,
       preferredVideoMimeType: null,
       preferredVideoMimeType: null,
@@ -138,12 +139,22 @@ module.exports = class Webcam extends Plugin {
     this._stopRecording = this._stopRecording.bind(this)
     this._stopRecording = this._stopRecording.bind(this)
     this._oneTwoThreeSmile = this._oneTwoThreeSmile.bind(this)
     this._oneTwoThreeSmile = this._oneTwoThreeSmile.bind(this)
     this._focus = this._focus.bind(this)
     this._focus = this._focus.bind(this)
+    this._changeVideoSource = this._changeVideoSource.bind(this)
 
 
     this.webcamActive = false
     this.webcamActive = false
 
 
     if (this.opts.countdown) {
     if (this.opts.countdown) {
       this.opts.onBeforeSnapshot = this._oneTwoThreeSmile
       this.opts.onBeforeSnapshot = this._oneTwoThreeSmile
     }
     }
+
+    this.setPluginState({
+      hasCamera: false,
+      cameraReady: false,
+      cameraError: null,
+      recordingLengthSeconds: 0,
+      videoSources: [],
+      currentDeviceId: null
+    })
   }
   }
 
 
   setOptions (newOpts) {
   setOptions (newOpts) {
@@ -176,15 +187,18 @@ module.exports = class Webcam extends Plugin {
     })
     })
   }
   }
 
 
-  getConstraints () {
+  getConstraints (deviceId = null) {
     const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1 ||
     const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1 ||
       this.opts.modes.indexOf('audio-only') !== -1
       this.opts.modes.indexOf('audio-only') !== -1
     const acceptsVideo = this.opts.modes.indexOf('video-audio') !== -1 ||
     const acceptsVideo = this.opts.modes.indexOf('video-audio') !== -1 ||
       this.opts.modes.indexOf('video-only') !== -1 ||
       this.opts.modes.indexOf('video-only') !== -1 ||
       this.opts.modes.indexOf('picture') !== -1
       this.opts.modes.indexOf('picture') !== -1
 
 
-    const videoConstraints = this.opts.videoConstraints ?? {
-      facingMode: this.opts.facingMode
+    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 {
     return {
@@ -193,13 +207,14 @@ module.exports = class Webcam extends Plugin {
     }
     }
   }
   }
 
 
-  _start () {
+  _start (options = null) {
     if (!this.supportsUserMedia) {
     if (!this.supportsUserMedia) {
       return Promise.reject(new Error('Webcam access not supported'))
       return Promise.reject(new Error('Webcam access not supported'))
     }
     }
 
 
     this.webcamActive = true
     this.webcamActive = true
-    const constraints = this.getConstraints()
+
+    const constraints = this.getConstraints(options && options.deviceId ? options.deviceId : null)
 
 
     this.hasCameraCheck().then(hasCamera => {
     this.hasCameraCheck().then(hasCamera => {
       this.setPluginState({
       this.setPluginState({
@@ -210,7 +225,23 @@ module.exports = class Webcam extends Plugin {
       return this.mediaDevices.getUserMedia(constraints)
       return this.mediaDevices.getUserMedia(constraints)
         .then((stream) => {
         .then((stream) => {
           this.stream = stream
           this.stream = stream
+
+          let currentDeviceId = null
+          if (!options || !options.deviceId) {
+            currentDeviceId = stream.getVideoTracks()[0].getSettings().deviceId
+          } else {
+            stream.getVideoTracks().forEach((videoTrack) => {
+              if (videoTrack.getSettings().deviceId === options.deviceId) {
+                currentDeviceId = videoTrack.getSettings().deviceId
+              }
+            })
+          }
+
+          // Update the sources now, so we can access the names.
+          this.updateVideoSources()
+
           this.setPluginState({
           this.setPluginState({
+            currentDeviceId,
             cameraReady: true
             cameraReady: true
           })
           })
         })
         })
@@ -465,6 +496,19 @@ module.exports = class Webcam extends Plugin {
     }, 1000)
     }, 1000)
   }
   }
 
 
+  _changeVideoSource (deviceId) {
+    this._stop()
+    this._start({ deviceId: deviceId })
+  }
+
+  updateVideoSources () {
+    this.mediaDevices.enumerateDevices().then(devices => {
+      this.setPluginState({
+        videoSources: devices.filter((device) => device.kind === 'videoinput')
+      })
+    })
+  }
+
   render () {
   render () {
     if (!this.webcamActive) {
     if (!this.webcamActive) {
       this._start()
       this._start()
@@ -485,6 +529,7 @@ module.exports = class Webcam extends Plugin {
     return (
     return (
       <CameraScreen
       <CameraScreen
         {...webcamState}
         {...webcamState}
+        onChangeVideoSource={this._changeVideoSource}
         onSnapshot={this._takeSnapshot}
         onSnapshot={this._takeSnapshot}
         onStartRecording={this._startRecording}
         onStartRecording={this._startRecording}
         onStopRecording={this._stopRecording}
         onStopRecording={this._stopRecording}
@@ -493,6 +538,7 @@ module.exports = class Webcam extends Plugin {
         i18n={this.i18n}
         i18n={this.i18n}
         modes={this.opts.modes}
         modes={this.opts.modes}
         showRecordingLength={this.opts.showRecordingLength}
         showRecordingLength={this.opts.showRecordingLength}
+        showVideoSourceDropdown={this.opts.showVideoSourceDropdown}
         supportsRecording={supportsMediaRecorder()}
         supportsRecording={supportsMediaRecorder()}
         recording={webcamState.isRecording}
         recording={webcamState.isRecording}
         mirror={this.opts.mirror}
         mirror={this.opts.mirror}
@@ -511,6 +557,31 @@ module.exports = class Webcam extends Plugin {
     if (target) {
     if (target) {
       this.mount(target, this)
       this.mount(target, this)
     }
     }
+
+    if (this.mediaDevices) {
+      this.updateVideoSources()
+
+      this.mediaDevices.ondevicechange = (event) => {
+        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 () {
   uninstall () {

+ 66 - 15
packages/@uppy/webcam/src/style.scss

@@ -31,18 +31,76 @@
   margin: auto;
   margin: auto;
 }
 }
 
 
-  .uppy-Webcam-video--mirrored {
-    transform: scaleX(-1);
-  }
+.uppy-Webcam-video--mirrored {
+  transform: scaleX(-1);
+}
 
 
-.uppy-Webcam-buttonContainer {
+.uppy-Webcam-footer {
   width: 100%;
   width: 100%;
-  height: 75px;
-  border-top: 1px solid $gray-200;
+  min-height: 75px;
   display: flex;
   display: flex;
+  flex-wrap: wrap;
   align-items: center;
   align-items: center;
-  justify-content: center;
-  padding: 0 20px;
+  justify-content: space-between;
+  padding: 20px 20px;
+}
+
+.uppy-Webcam-videoSourceContainer {
+  width: 100%;
+  flex-grow: 0;
+}
+
+.uppy-size--lg .uppy-Webcam-videoSourceContainer {
+  width: 33%;
+  margin: 0; // vertical alignment handled by the flexbox wrapper
+}
+
+.uppy-Webcam-videoSource-select {
+  display: block;
+  font-size: 16px;
+  line-height: 1.2;
+  padding: .4em 1em .3em .4em;
+  width: 100%;
+  max-width: 90%;
+  border: 1px solid $gray-600;
+  background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23757575%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
+  background-repeat: no-repeat;
+  background-position: right .4em top 50%, 0 0;
+  background-size: .65em auto, 100%;
+  margin: auto;
+  margin-bottom: 10px;
+
+  .uppy-size--md & {
+    font-size: 14px;
+    margin-bottom: 0;
+  }
+}
+
+  .uppy-Webcam-videoSource-select::-ms-expand {
+    display: none;
+  }
+
+.uppy-Webcam-buttonContainer {
+  width: 50%;
+  margin-left: 25%;
+  text-align: center;
+}
+
+.uppy-size--lg .uppy-Webcam-buttonContainer {
+  width: 34%;
+  margin-left: 0;
+}
+
+.uppy-Webcam-recordingLength {
+  width: 25%;
+  flex-grow: 0;
+  color: $gray-600;
+  font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  text-align: right;
+}
+
+.uppy-size--lg .uppy-Webcam-recordingLength {
+  width: 33%;
 }
 }
 
 
 .uppy-Webcam-button {
 .uppy-Webcam-button {
@@ -110,13 +168,6 @@
   margin-bottom: 30px;
   margin-bottom: 30px;
 }
 }
 
 
-.uppy-Webcam-recordingLength {
-  position: absolute;
-  right: 20px;
-  color: $gray-600;
-  font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
-}
-
 .uppy-Webcam-title {
 .uppy-Webcam-title {
   font-size: 22px;
   font-size: 22px;
   line-height: 1.35;
   line-height: 1.35;

+ 1 - 0
packages/@uppy/webcam/types/index.d.ts

@@ -15,6 +15,7 @@ declare module Webcam {
     countdown?: number | boolean
     countdown?: number | boolean
     mirror?: boolean
     mirror?: boolean
     facingMode?: string
     facingMode?: string
+    showVideoSourceDropdown?: boolean
     modes?: WebcamMode[]
     modes?: WebcamMode[]
     locale?: WebcamLocale
     locale?: WebcamLocale
     title?: string
     title?: string

+ 4 - 0
website/src/docs/webcam.md

@@ -132,6 +132,10 @@ For a full list of available properties, see MDN's [MediaTrackConstraints][] doc
 [`height`]: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/height
 [`height`]: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/height
 [`facingMode`]: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode
 [`facingMode`]: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode
 
 
+### `showVideoSourceDropdown: false`
+
+Configures whether or not to show a dropdown which enables to choose the video device to use. This option will have priority over `facingMode` if enabled. The default is `false`.
+
 ### `showRecordingLength: false`
 ### `showRecordingLength: false`
 
 
 Configures whether or not to show the length of the recording while the recording is in progress. The default is `false`.
 Configures whether or not to show the length of the recording while the recording is in progress. The default is `false`.

+ 4 - 1
website/src/examples/dashboard/app.es6

@@ -145,7 +145,10 @@ function uppySetOptions () {
 
 
   const webcamInstance = window.uppy.getPlugin('Webcam')
   const webcamInstance = window.uppy.getPlugin('Webcam')
   if (opts.Webcam && !webcamInstance) {
   if (opts.Webcam && !webcamInstance) {
-    window.uppy.use(Webcam, { target: Dashboard })
+    window.uppy.use(Webcam, {
+      target: Dashboard,
+      showVideoSourceDropdown: true
+    })
   }
   }
   if (!opts.Webcam && webcamInstance) {
   if (!opts.Webcam && webcamInstance) {
     window.uppy.removePlugin(webcamInstance)
     window.uppy.removePlugin(webcamInstance)