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>
This commit is contained in:
Renée Kooi 2020-10-18 13:21:59 +02:00 committed by GitHub
parent ae4b9df31e
commit ef5ba9c8fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 194 additions and 31 deletions

View file

@ -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 })

View file

@ -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>
)

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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 () {

View file

@ -31,18 +31,76 @@
margin: auto;
}
.uppy-Webcam-video--mirrored {
transform: scaleX(-1);
.uppy-Webcam-video--mirrored {
transform: scaleX(-1);
}
.uppy-Webcam-footer {
width: 100%;
min-height: 75px;
display: flex;
flex-wrap: wrap;
align-items: center;
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: 100%;
height: 75px;
border-top: 1px solid $gray-200;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
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;

View file

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

View file

@ -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`.

View file

@ -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)