瀏覽代碼

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 4 年之前
父節點
當前提交
ef5ba9c8fc

+ 5 - 1
examples/dev/Dashboard.js

@@ -70,7 +70,11 @@ module.exports = () => {
     .use(OneDrive, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Zoom, { 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(Form, { target: '#upload-form' })
     .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 RecordButton = require('./RecordButton')
 const RecordingLength = require('./RecordingLength')
+const VideoSourceSelect = require('./VideoSourceSelect')
 
 function isModeAvailable (modes, mode) {
   return modes.indexOf(mode) !== -1
@@ -24,18 +25,25 @@ class CameraScreen extends Component {
     )
     const shouldShowSnapshotButton = isModeAvailable(this.props.modes, 'picture')
     const shouldShowRecordingLength = this.props.supportsRecording && this.props.showRecordingLength
+    const shouldShowVideoSourceDropdown = this.props.showVideoSourceDropdown && this.props.videoSources && this.props.videoSources.length > 1
 
     return (
       <div class="uppy uppy-Webcam-container">
         <div class="uppy-Webcam-videoContainer">
           <video class={`uppy-Webcam-video  ${this.props.mirror ? 'uppy-Webcam-video--mirrored' : ''}`} autoplay muted playsinline srcObject={this.props.src || ''} />
         </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>
     )

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

@@ -5,8 +5,8 @@ module.exports = function RecordingLength ({ recordingLengthSeconds, i18n }) {
   const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
 
   return (
-    <div class="uppy-Webcam-recordingLength" aria-label={i18n('recordingLength', { recording_length: formattedRecordingLengthSeconds })}>
+    <span aria-label={i18n('recordingLength', { recording_length: 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'
       ],
       mirror: true,
+      showVideoSourceDropdown: false,
       facingMode: 'user',
       preferredImageMimeType: null,
       preferredVideoMimeType: null,
@@ -138,12 +139,22 @@ module.exports = class Webcam extends Plugin {
     this._stopRecording = this._stopRecording.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) {
@@ -176,15 +187,18 @@ module.exports = class Webcam extends Plugin {
     })
   }
 
-  getConstraints () {
+  getConstraints (deviceId = null) {
     const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1 ||
       this.opts.modes.indexOf('audio-only') !== -1
     const acceptsVideo = 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
+    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 {
@@ -193,13 +207,14 @@ module.exports = class Webcam extends Plugin {
     }
   }
 
-  _start () {
+  _start (options = null) {
     if (!this.supportsUserMedia) {
       return Promise.reject(new Error('Webcam access not supported'))
     }
 
     this.webcamActive = true
-    const constraints = this.getConstraints()
+
+    const constraints = this.getConstraints(options && options.deviceId ? options.deviceId : null)
 
     this.hasCameraCheck().then(hasCamera => {
       this.setPluginState({
@@ -210,7 +225,23 @@ module.exports = class Webcam extends Plugin {
       return this.mediaDevices.getUserMedia(constraints)
         .then((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({
+            currentDeviceId,
             cameraReady: true
           })
         })
@@ -465,6 +496,19 @@ module.exports = class Webcam extends Plugin {
     }, 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 () {
     if (!this.webcamActive) {
       this._start()
@@ -485,6 +529,7 @@ module.exports = class Webcam extends Plugin {
     return (
       <CameraScreen
         {...webcamState}
+        onChangeVideoSource={this._changeVideoSource}
         onSnapshot={this._takeSnapshot}
         onStartRecording={this._startRecording}
         onStopRecording={this._stopRecording}
@@ -493,6 +538,7 @@ module.exports = class Webcam extends Plugin {
         i18n={this.i18n}
         modes={this.opts.modes}
         showRecordingLength={this.opts.showRecordingLength}
+        showVideoSourceDropdown={this.opts.showVideoSourceDropdown}
         supportsRecording={supportsMediaRecorder()}
         recording={webcamState.isRecording}
         mirror={this.opts.mirror}
@@ -511,6 +557,31 @@ module.exports = class Webcam extends Plugin {
     if (target) {
       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 () {

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

@@ -31,18 +31,76 @@
   margin: auto;
 }
 
-  .uppy-Webcam-video--mirrored {
-    transform: scaleX(-1);
-  }
+.uppy-Webcam-video--mirrored {
+  transform: scaleX(-1);
+}
 
-.uppy-Webcam-buttonContainer {
+.uppy-Webcam-footer {
   width: 100%;
-  height: 75px;
-  border-top: 1px solid $gray-200;
+  min-height: 75px;
   display: flex;
+  flex-wrap: wrap;
   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 {
@@ -110,13 +168,6 @@
   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 {
   font-size: 22px;
   line-height: 1.35;

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

@@ -15,6 +15,7 @@ declare module Webcam {
     countdown?: number | boolean
     mirror?: boolean
     facingMode?: string
+    showVideoSourceDropdown?: boolean
     modes?: WebcamMode[]
     locale?: WebcamLocale
     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
 [`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`
 
 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')
   if (opts.Webcam && !webcamInstance) {
-    window.uppy.use(Webcam, { target: Dashboard })
+    window.uppy.use(Webcam, {
+      target: Dashboard,
+      showVideoSourceDropdown: true
+    })
   }
   if (!opts.Webcam && webcamInstance) {
     window.uppy.removePlugin(webcamInstance)