diff --git a/css/modals/device-selection/_device-selection.scss b/css/modals/device-selection/_device-selection.scss index ab1428856..4ac49d92e 100644 --- a/css/modals/device-selection/_device-selection.scss +++ b/css/modals/device-selection/_device-selection.scss @@ -79,7 +79,7 @@ border-radius: 3px; } - .video-input-preview-muted { + .video-input-preview-error { color: $participantNameColor; display: none; left: 0; @@ -89,12 +89,10 @@ top: 50%; } - &.video-muted { - /* TOFIX: to be removed when we move out from muted preview */ + &.video-preview-has-error { background: black; - /* TOFIX-END */ - .video-input-preview-muted { + .video-input-preview-error { display: block; } } diff --git a/lang/main.json b/lang/main.json index 22bc14cb8..4ce44012b 100644 --- a/lang/main.json +++ b/lang/main.json @@ -429,9 +429,9 @@ "speakerTime": "Speaker Time" }, "deviceSelection": { - "currentlyVideoMuted": "Video is currently muted", "deviceSettings": "Device settings", "noPermission": "Permission not granted", + "previewUnavailable": "Preview unavailable", "selectADevice": "Select a device", "testAudio": "Test sound" }, diff --git a/react/features/device-selection/actions.js b/react/features/device-selection/actions.js index dd0b0f6f4..f08a6025c 100644 --- a/react/features/device-selection/actions.js +++ b/react/features/device-selection/actions.js @@ -12,16 +12,13 @@ import { DeviceSelectionDialog } from './components'; * @returns {Function} */ export function openDeviceSelectionDialog() { - return (dispatch, getState) => { + return dispatch => { JitsiMeetJS.mediaDevices.isDeviceListAvailable() .then(isDeviceListAvailable => { - const state = getState(); - const conference = state['features/base/conference'].conference; - dispatch(openDialog(DeviceSelectionDialog, { + currentAudioInputId: APP.settings.getMicDeviceId(), currentAudioOutputId: APP.settings.getAudioOutputDeviceId(), - currentAudioTrack: conference.getLocalAudioTrack(), - currentVideoTrack: conference.getLocalVideoTrack(), + currentVideoInputId: APP.settings.getCameraDeviceId(), disableAudioInputChange: !JitsiMeetJS.isMultipleAudioInputSupported(), disableDeviceChange: !isDeviceListAvailable diff --git a/react/features/device-selection/components/DeviceSelectionDialog.js b/react/features/device-selection/components/DeviceSelectionDialog.js index 4eca98476..abb8198b2 100644 --- a/react/features/device-selection/components/DeviceSelectionDialog.js +++ b/react/features/device-selection/components/DeviceSelectionDialog.js @@ -34,29 +34,25 @@ class DeviceSelectionDialog extends Component { * All known audio and video devices split by type. This prop comes from * the app state. */ - _devices: React.PropTypes.object, + _availableDevices: React.PropTypes.object, /** - * Device id for the current audio output device. + * Device id for the current audio input device. This device will be set + * as the default audio input device to preview. + */ + currentAudioInputId: React.PropTypes.string, + + /** + * Device id for the current audio output device. This device will be + * set as the default audio output device to preview. */ currentAudioOutputId: React.PropTypes.string, /** - * JitsiLocalTrack for the current local audio. - * - * JitsiLocalTracks for the current audio and video, if any, should be - * passed in for re-use in the previews. This is needed for Internet - * Explorer, which cannot get multiple tracks from the same device, even - * across tabs. + * Device id for the current video input device. This device will be set + * as the default video input device to preview. */ - currentAudioTrack: React.PropTypes.object, - - /** - * JitsiLocalTrack for the current local video. - * - * Needed for reuse. See comment for propTypes.currentAudioTrack. - */ - currentVideoTrack: React.PropTypes.object, + currentVideoInputId: React.PropTypes.string, /** * Whether or not the audio selector can be interacted with. If true, @@ -78,12 +74,12 @@ class DeviceSelectionDialog extends Component { dispatch: React.PropTypes.func, /** - * Whether or not new audio input source can be selected. + * Whether or not a new audio input source can be selected. */ hasAudioPermission: React.PropTypes.bool, /** - * Whether or not new video input sources can be selected. + * Whether or not a new video input sources can be selected. */ hasVideoPermission: React.PropTypes.bool, @@ -117,15 +113,40 @@ class DeviceSelectionDialog extends Component { constructor(props) { super(props); + const { _availableDevices } = this.props; + this.state = { - // JitsiLocalTracks to use for live previewing. + // JitsiLocalTrack to use for live previewing of audio input. previewAudioTrack: null, + + // JitsiLocalTrack to use for live previewing of video input. previewVideoTrack: null, - // Device ids to keep track of new selections. - videInput: null, - audioInput: null, - audioOutput: null + // An message describing a problem with obtaining a video preview. + previewVideoTrackError: null, + + // The audio input device id to show as selected by default. + selectedAudioInputId: this.props.currentAudioInputId || '', + + // The audio output device id to show as selected by default. + selectedAudioOutputId: this.props.currentAudioOutputId || '', + + // The video input device id to show as selected by default. + // FIXME: On temasys, without a device selected and put into local + // storage as the default device to use, the current video device id + // is a blank string. This is because the library gets a local video + // track and then maps the track's device id by matching the track's + // label to the MediaDeviceInfos returned from enumerateDevices. In + // WebRTC, the track label is expected to return the camera device + // label. However, temasys video track labels refer to track id, not + // device label, so the library cannot match the track to a device. + // The workaround of defaulting to the first videoInput available + // is re-used from the previous device settings implementation. + selectedVideoInputId: this.props.currentVideoInputId + || (_availableDevices.videoInput + && _availableDevices.videoInput[0] + && _availableDevices.videoInput[0].deviceId) + || '' }; // Preventing closing while cleaning up previews is important for @@ -134,16 +155,29 @@ class DeviceSelectionDialog extends Component { // closure until cleanup is complete ensures no errors in the process. this._isClosing = false; + // Bind event handlers so they are only bound once for every instance. this._closeModal = this._closeModal.bind(this); - this._getAndSetAudioOutput = this._getAndSetAudioOutput.bind(this); - this._getAndSetAudioTrack = this._getAndSetAudioTrack.bind(this); - this._getAndSetVideoTrack = this._getAndSetVideoTrack.bind(this); this._onCancel = this._onCancel.bind(this); this._onSubmit = this._onSubmit.bind(this); + this._updateAudioOutput = this._updateAudioOutput.bind(this); + this._updateAudioInput = this._updateAudioInput.bind(this); + this._updateVideoInput = this._updateVideoInput.bind(this); } /** - * Clean up any preview tracks that might not have been cleaned up already. + * Sets default device choices so a choice is pre-selected in the dropdowns + * and live previews are created. + * + * @inheritdoc + */ + componentDidMount() { + this._updateAudioOutput(this.state.selectedAudioOutputId); + this._updateAudioInput(this.state.selectedAudioInputId); + this._updateVideoInput(this.state.selectedVideoInputId); + } + + /** + * Disposes preview tracks that might not already be disposed. * * @inheritdoc */ @@ -173,8 +207,8 @@ class DeviceSelectionDialog extends Component {
+ error = { this.state.previewVideoTrackError } + track = { this.state.previewVideoTrack } />
{ this._renderAudioInputPreview() }
@@ -197,17 +231,10 @@ class DeviceSelectionDialog extends Component { * promise can be for video cleanup and another for audio cleanup. */ _attemptPreviewTrackCleanup() { - const cleanupPromises = []; - - if (!this._isPreviewingCurrentVideoTrack()) { - cleanupPromises.push(this._disposeVideoPreview()); - } - - if (!this._isPreviewingCurrentAudioTrack()) { - cleanupPromises.push(this._disposeAudioPreview()); - } - - return cleanupPromises; + return Promise.all([ + this._disposeVideoPreview(), + this._disposeAudioPreview() + ]); } /** @@ -243,147 +270,7 @@ class DeviceSelectionDialog extends Component { } /** - * Callback invoked when a new audio output device has been selected. - * Updates the internal state of the user's selection. - * - * @param {string} deviceId - The id of the chosen audio output device. - * @private - * @returns {void} - */ - _getAndSetAudioOutput(deviceId) { - this.setState({ - audioOutput: deviceId - }); - } - - /** - * Callback invoked when a new audio input device has been selected. - * Updates the internal state of the user's selection as well as the audio - * track that should display in the preview. Will reuse the current local - * audio track if it has been selected. - * - * @param {string} deviceId - The id of the chosen audio input device. - * @private - * @returns {void} - */ - _getAndSetAudioTrack(deviceId) { - this.setState({ - audioInput: deviceId - }, () => { - const cleanupPromise = this._isPreviewingCurrentAudioTrack() - ? Promise.resolve() : this._disposeAudioPreview(); - - if (this._isCurrentAudioTrack(deviceId)) { - cleanupPromise - .then(() => { - this.setState({ - previewAudioTrack: this.props.currentAudioTrack - }); - }); - } else { - cleanupPromise - .then(() => createLocalTrack('audio', deviceId)) - .then(jitsiLocalTrack => { - this.setState({ - previewAudioTrack: jitsiLocalTrack - }); - }); - } - }); - } - - /** - * Callback invoked when a new video input device has been selected. Updates - * the internal state of the user's selection as well as the video track - * that should display in the preview. Will reuse the current local video - * track if it has been selected. - * - * @param {string} deviceId - The id of the chosen video input device. - * @private - * @returns {void} - */ - _getAndSetVideoTrack(deviceId) { - this.setState({ - videoInput: deviceId - }, () => { - const cleanupPromise = this._isPreviewingCurrentVideoTrack() - ? Promise.resolve() : this._disposeVideoPreview(); - - if (this._isCurrentVideoTrack(deviceId)) { - cleanupPromise - .then(() => { - this.setState({ - previewVideoTrack: this.props.currentVideoTrack - }); - }); - } else { - cleanupPromise - .then(() => createLocalTrack('video', deviceId)) - .then(jitsiLocalTrack => { - this.setState({ - previewVideoTrack: jitsiLocalTrack - }); - }); - } - }); - } - - /** - * Utility function for determining if the current local audio track has the - * passed in device id. - * - * @param {string} deviceId - The device id to match against. - * @private - * @returns {boolean} True if the device id is being used by the local audio - * track. - */ - _isCurrentAudioTrack(deviceId) { - return this.props.currentAudioTrack - && this.props.currentAudioTrack.getDeviceId() === deviceId; - } - - /** - * Utility function for determining if the current local video track has the - * passed in device id. - * - * @param {string} deviceId - The device id to match against. - * @private - * @returns {boolean} True if the device id is being used by the local - * video track. - */ - _isCurrentVideoTrack(deviceId) { - return this.props.currentVideoTrack - && this.props.currentVideoTrack.getDeviceId() === deviceId; - } - - /** - * Utility function for detecting if the current audio preview track is not - * the currently used audio track. - * - * @private - * @returns {boolean} True if the current audio track is being used for - * the preview. - */ - _isPreviewingCurrentAudioTrack() { - return !this.state.previewAudioTrack - || this.state.previewAudioTrack === this.props.currentAudioTrack; - } - - /** - * Utility function for detecting if the current video preview track is not - * the currently used video track. - * - * @private - * @returns {boolean} True if the current video track is being used as the - * preview. - */ - _isPreviewingCurrentVideoTrack() { - return !this.state.previewVideoTrack - || this.state.previewVideoTrack === this.props.currentVideoTrack; - } - - /** - * Cleans existing preview tracks and signal to closeDeviceSelectionDialog. + * Disposes preview tracks and signals to close DeviceSelectionDialog. * * @private * @returns {boolean} Returns false to prevent closure until cleanup is @@ -406,7 +293,7 @@ class DeviceSelectionDialog extends Component { } /** - * Identify changes to the preferred input/output devices and perform + * Identifies changes to the preferred input/output devices and perform * necessary cleanup and requests to use those devices. Closes the modal * after cleanup and device change requests complete. * @@ -421,32 +308,26 @@ class DeviceSelectionDialog extends Component { this._isClosing = true; - const deviceChangePromises = []; + const deviceChangePromises = this._attemptPreviewTrackCleanup() + .then(() => { + if (this.state.selectedVideoInputId + !== this.props.currentVideoInputId) { + this.props.dispatch( + setVideoInputDevice(this.state.selectedVideoInputId)); + } - if (this.state.videoInput && !this._isPreviewingCurrentVideoTrack()) { - const changeVideoPromise = this._disposeVideoPreview() - .then(() => { - this.props.dispatch(setVideoInputDevice( - this.state.videoInput)); - }); + if (this.state.selectedAudioInputId + !== this.props.currentAudioInputId) { + this.props.dispatch( + setAudioInputDevice(this.state.selectedAudioInputId)); + } - deviceChangePromises.push(changeVideoPromise); - } - - if (this.state.audioInput && !this._isPreviewingCurrentAudioTrack()) { - const changeAudioPromise = this._disposeAudioPreview() - .then(() => { - this.props.dispatch(setAudioInputDevice( - this.state.audioInput)); - }); - - deviceChangePromises.push(changeAudioPromise); - } - - if (this.state.audioOutput - && this.state.audioOutput !== this.props.currentAudioOutputId) { - this.props.dispatch(setAudioOutputDevice(this.state.audioOutput)); - } + if (this.state.selectedAudioOutputId + !== this.props.currentAudioOutputId) { + this.props.dispatch( + setAudioOutputDevice(this.state.selectedAudioOutputId)); + } + }); Promise.all(deviceChangePromises) .then(this._closeModal) @@ -470,8 +351,7 @@ class DeviceSelectionDialog extends Component { return ( + track = { this.state.previewAudioTrack } /> ); } @@ -489,8 +369,7 @@ class DeviceSelectionDialog extends Component { return ( + deviceId = { this.state.selectedAudioOutputId } /> ); } @@ -515,70 +394,120 @@ class DeviceSelectionDialog extends Component { * @returns {Array} DeviceSelector instances. */ _renderSelectors() { - const availableDevices = this.props._devices; - const currentAudioId = this.state.audioInput - || (this.props.currentAudioTrack - && this.props.currentAudioTrack.getDeviceId()); - const currentAudioOutId = this.state.audioOutput - || this.props.currentAudioOutputId; - - // FIXME: On temasys, without a device selected and put into local - // storage as the default device to use, the current video device id is - // a blank string. This is because the library gets a local video track - // and then maps the track's device id by matching the track's label to - // the MediaDeviceInfos returned from enumerateDevices. In WebRTC, the - // track label is expected to return the camera device label. However, - // temasys video track labels refer to track id, not device label, so - // the library cannot match the track to a device. The workaround of - // defaulting to the first videoInput available has been re-used from - // the previous device settings implementation. - const currentVideoId = this.state.videoInput - || (this.props.currentVideoTrack - && this.props.currentVideoTrack.getDeviceId()) - || (availableDevices.videoInput[0] - && availableDevices.videoInput[0].deviceId) - || ''; // DeviceSelector expects a string for prop selectedDeviceId. - + const { _availableDevices } = this.props; const configurations = [ { - devices: availableDevices.videoInput, + devices: _availableDevices.videoInput, hasPermission: this.props.hasVideoPermission, icon: 'icon-camera', isDisabled: this.props.disableDeviceChange, key: 'videoInput', label: 'settings.selectCamera', - onSelect: this._getAndSetVideoTrack, - selectedDeviceId: currentVideoId + onSelect: this._updateVideoInput, + selectedDeviceId: this.state.selectedVideoInputId }, { - devices: availableDevices.audioInput, + devices: _availableDevices.audioInput, hasPermission: this.props.hasAudioPermission, icon: 'icon-microphone', isDisabled: this.props.disableAudioInputChange || this.props.disableDeviceChange, key: 'audioInput', label: 'settings.selectMic', - onSelect: this._getAndSetAudioTrack, - selectedDeviceId: currentAudioId + onSelect: this._updateAudioInput, + selectedDeviceId: this.state.selectedAudioInputId } ]; if (!this.props.hideAudioOutputSelect) { configurations.push({ - devices: availableDevices.audioOutput, + devices: _availableDevices.audioOutput, hasPermission: this.props.hasAudioPermission || this.props.hasVideoPermission, icon: 'icon-volume', isDisabled: this.props.disableDeviceChange, key: 'audioOutput', label: 'settings.selectAudioOutput', - onSelect: this._getAndSetAudioOutput, - selectedDeviceId: currentAudioOutId + onSelect: this._updateAudioOutput, + selectedDeviceId: this.state.selectedAudioOutputId }); } return configurations.map(this._renderSelector); } + + /** + * Callback invoked when a new audio input device has been selected. Updates + * the internal state of the user's selection as well as the audio track + * that should display in the preview. + * + * @param {string} deviceId - The id of the chosen audio input device. + * @private + * @returns {void} + */ + _updateAudioInput(deviceId) { + this.setState({ + selectedAudioInputId: deviceId + }, () => { + this._disposeAudioPreview() + .then(() => createLocalTrack('audio', deviceId)) + .then(jitsiLocalTrack => { + this.setState({ + previewAudioTrack: jitsiLocalTrack + }); + }) + .catch(() => { + this.setState({ + previewAudioTrack: null + }); + }); + }); + } + + /** + * Callback invoked when a new audio output device has been selected. + * Updates the internal state of the user's selection. + * + * @param {string} deviceId - The id of the chosen audio output device. + * @private + * @returns {void} + */ + _updateAudioOutput(deviceId) { + this.setState({ + selectedAudioOutputId: deviceId + }); + } + + /** + * Callback invoked when a new video input device has been selected. Updates + * the internal state of the user's selection as well as the video track + * that should display in the preview. + * + * @param {string} deviceId - The id of the chosen video input device. + * @private + * @returns {void} + */ + _updateVideoInput(deviceId) { + this.setState({ + selectedVideoInputId: deviceId + }, () => { + this._disposeVideoPreview() + .then(() => createLocalTrack('video', deviceId)) + .then(jitsiLocalTrack => { + this.setState({ + previewVideoTrack: jitsiLocalTrack, + previewVideoTrackError: null + }); + }) + .catch(() => { + this.setState({ + previewVideoTrack: null, + previewVideoTrackError: + this.props.t('deviceSelection.previewUnavailable') + }); + }); + }); + } } /** @@ -588,12 +517,12 @@ class DeviceSelectionDialog extends Component { * @param {Object} state - The Redux state. * @private * @returns {{ - * _devices: Object + * _availableDevices: Object * }} */ function _mapStateToProps(state) { return { - _devices: state['features/base/devices'] + _availableDevices: state['features/base/devices'] }; } diff --git a/react/features/device-selection/components/VideoInputPreview.js b/react/features/device-selection/components/VideoInputPreview.js index 2b7eb5c14..23d74640d 100644 --- a/react/features/device-selection/components/VideoInputPreview.js +++ b/react/features/device-selection/components/VideoInputPreview.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import { translate } from '../../base/i18n'; -const VIDEO_MUTE_CLASS = 'video-muted'; +const VIDEO_ERROR_CLASS = 'video-preview-has-error'; /** * React component for displaying video. This component defers to lib-jitsi-meet @@ -17,12 +17,18 @@ class VideoInputPreview extends Component { * @static */ static propTypes = { + /** + * An error message to display instead of a preview. Displaying an error + * will take priority over displaying a video preview. + */ + error: React.PropTypes.string, + /** * Invoked to obtain translated strings. */ t: React.PropTypes.func, - /* + /** * The JitsiLocalTrack to display. */ track: React.PropTypes.object @@ -37,9 +43,37 @@ class VideoInputPreview extends Component { constructor(props) { super(props); + /** + * The internal reference to the DOM/HTML element intended for showing + * error messages. + * + * @private + * @type {HTMLDivElement} + */ + this._errorElement = null; + + /** + * The internal reference to topmost DOM/HTML element backing the React + * {@code Component}. Accessed directly for toggling a classname to + * indicate an error is present so styling can be changed to display it. + * + * @private + * @type {HTMLDivElement} + */ this._rootElement = null; + + /** + * The internal reference to the DOM/HTML element intended for + * displaying a video. This element may be an HTML video element or a + * temasys video object. + * + * @private + * @type {HTMLVideoElement|Object} + */ this._videoElement = null; + // Bind event handlers so they are only bound once for every instance. + this._setErrorElement = this._setErrorElement.bind(this); this._setRootElement = this._setRootElement.bind(this); this._setVideoElement = this._setVideoElement.bind(this); } @@ -51,7 +85,11 @@ class VideoInputPreview extends Component { * @returns {void} */ componentDidMount() { - this._attachTrack(this.props.track); + if (this.props.error) { + this._updateErrorView(this.props.error); + } else { + this._attachTrack(this.props.track); + } } /** @@ -80,9 +118,9 @@ class VideoInputPreview extends Component { autoPlay = { true } className = 'video-input-preview-display flipVideoX' ref = { this._setVideoElement } /> -
- { this.props.t('deviceSelection.currentlyVideoMuted') } -
+
); } @@ -99,8 +137,15 @@ class VideoInputPreview extends Component { * @returns {void} */ shouldComponentUpdate(nextProps) { - if (nextProps.track !== this.props.track) { + const hasNewTrack = nextProps.track !== this.props.track; + + if (hasNewTrack || nextProps.error) { this._detachTrack(this.props.track); + this._updateErrorView(nextProps.error); + } + + // Never attempt to show the new track if there is an error present. + if (hasNewTrack && !nextProps.error) { this._attachTrack(nextProps.track); } @@ -123,17 +168,9 @@ class VideoInputPreview extends Component { return; } - // Do not attempt to display a preview if the track is muted, as the - // library will simply return a falsy value for the element anyway. - if (track.isMuted()) { - this._showMuteOverlay(true); - } else { - this._showMuteOverlay(false); + const updatedVideoElement = track.attach(this._videoElement); - const updatedVideoElement = track.attach(this._videoElement); - - this._setVideoElement(updatedVideoElement); - } + this._setVideoElement(updatedVideoElement); } /** @@ -159,6 +196,19 @@ class VideoInputPreview extends Component { } } + /** + * Sets an instance variable for the component's element intended for + * displaying error messages. The element will be accessed directly to + * display an error message. + * + * @param {Object} element - DOM element intended for displaying errors. + * @private + * @returns {void} + */ + _setErrorElement(element) { + this._errorElement = element; + } + /** * Sets the component's root element. * @@ -183,20 +233,22 @@ class VideoInputPreview extends Component { } /** - * Adds or removes a class to the component's parent node to indicate mute - * status. + * Adds or removes a class to the component's parent node to indicate an + * error has occurred. Also sets the error text. * - * @param {boolean} shouldShow - True if the mute class should be added and - * false if the class should be removed. + * @param {string} error - The error message to display. If falsy, error + * message display will be hidden. * @private * @returns {void} */ - _showMuteOverlay(shouldShow) { - if (shouldShow) { - this._rootElement.classList.add(VIDEO_MUTE_CLASS); + _updateErrorView(error) { + if (error) { + this._rootElement.classList.add(VIDEO_ERROR_CLASS); } else { - this._rootElement.classList.remove(VIDEO_MUTE_CLASS); + this._rootElement.classList.remove(VIDEO_ERROR_CLASS); } + + this._errorElement.innerText = error || ''; } }