From 2f994b122748f04bd3dfb6782799cb88e46beaaf Mon Sep 17 00:00:00 2001 From: Leonard Kim Date: Wed, 29 Mar 2017 10:43:30 -0700 Subject: [PATCH 1/2] feat: new device selection modal with previews The Device Selection modal consists of: - DeviceSelection, an overly smart component responsible for triggering stream creation and cleanup. - DeviceSelector for selector elements. - VideoInputPreview for displaying a video preview. - AudioInputPreview for displaying a volume meter. - AudioOutputPreview for a test sound output link. Store changes include is primarily storing the list of available devices in redux. Other app state has been left alone for future refactoring. --- conference.js | 23 +- css/main.scss | 1 + .../device-selection/_device-selection.scss | 84 +++ lang/main.json | 6 + modules/UI/UI.js | 5 + package.json | 1 + react/features/base/devices/actionTypes.js | 45 ++ react/features/base/devices/actions.js | 71 +++ react/features/base/devices/index.js | 5 + react/features/base/devices/middleware.js | 34 + react/features/base/devices/reducer.js | 64 ++ .../features/base/lib-jitsi-meet/functions.js | 21 + .../components/AudioInputPreview.js | 132 ++++ .../components/AudioOutputPreview.js | 122 ++++ .../components/DeviceSelectionDialog.js | 597 ++++++++++++++++++ .../components/DeviceSelector.js | 180 ++++++ .../components/VideoInputPreview.js | 203 ++++++ .../device-selection/components/index.js | 1 + react/features/device-selection/index.js | 1 + 19 files changed, 1595 insertions(+), 1 deletion(-) create mode 100644 css/modals/device-selection/_device-selection.scss create mode 100644 react/features/base/devices/actionTypes.js create mode 100644 react/features/base/devices/actions.js create mode 100644 react/features/base/devices/index.js create mode 100644 react/features/base/devices/middleware.js create mode 100644 react/features/base/devices/reducer.js create mode 100644 react/features/device-selection/components/AudioInputPreview.js create mode 100644 react/features/device-selection/components/AudioOutputPreview.js create mode 100644 react/features/device-selection/components/DeviceSelectionDialog.js create mode 100644 react/features/device-selection/components/DeviceSelector.js create mode 100644 react/features/device-selection/components/VideoInputPreview.js create mode 100644 react/features/device-selection/components/index.js create mode 100644 react/features/device-selection/index.js diff --git a/conference.js b/conference.js index 8b025b474..8d2cbcd21 100644 --- a/conference.js +++ b/conference.js @@ -30,6 +30,9 @@ import { conferenceLeft, EMAIL_COMMAND } from './react/features/base/conference'; +import { + updateDeviceList +} from './react/features/base/devices'; import { isFatalJitsiConnectionError } from './react/features/base/lib-jitsi-meet'; @@ -1029,6 +1032,15 @@ export default { }); }, + /** + * Returns the current local video track in use. + * + * @returns {JitsiLocalTrack} + */ + getLocalVideoTrack() { + return room.getLocalVideoTrack(); + }, + /** * Start using provided audio stream. * Stops previous audio stream. @@ -1058,6 +1070,15 @@ export default { }); }, + /** + * Returns the current local audio track in use. + * + * @returns {JitsiLocalTrack} + */ + getLocalAudioTrack() { + return room.getLocalAudioTrack(); + }, + videoSwitchInProgress: false, toggleScreenSharing (shareScreen = !this.isSharingScreen) { if (this.videoSwitchInProgress) { @@ -1756,8 +1777,8 @@ export default { } mediaDeviceHelper.setCurrentMediaDevices(devices); - APP.UI.onAvailableDevicesChanged(devices); + APP.store.dispatch(updateDeviceList(devices)); }); this.deviceChangeListener = (devices) => diff --git a/css/main.scss b/css/main.scss index dc1cafff2..55004580d 100644 --- a/css/main.scss +++ b/css/main.scss @@ -38,6 +38,7 @@ @import 'inlay'; @import 'reload_overlay/reload_overlay'; @import 'modals/desktop-picker/desktop-picker'; +@import 'modals/device-selection/device-selection'; @import 'modals/dialog'; @import 'modals/feedback/feedback'; @import 'modals/speaker_stats/speaker_stats'; diff --git a/css/modals/device-selection/_device-selection.scss b/css/modals/device-selection/_device-selection.scss new file mode 100644 index 000000000..971566b9c --- /dev/null +++ b/css/modals/device-selection/_device-selection.scss @@ -0,0 +1,84 @@ +.device-selection { + color: $feedbackInputTextColor; + + .device-selectors { + font-size: 14px; + + > div { + margin-bottom: 10px; + } + + > div:last-child { + margin-bottom: 5px; + } + } + + .device-selection-column-selectors, + .device-selection-column-video { + padding: 10px; + display: inline-block; + vertical-align: top; + } + .device-selection-column-selectors { + width: 46%; + } + .device-selection-column-video { + width: 49%; + padding: 10px 0; + } + + .device-selection-video-container { + background: black; + height: 156px; + margin: 15px 0 5px; + + .video-input-preview { + position: relative; + + .video-input-preview-muted { + color: $participantNameColor; + display: none; + left: 0; + position: absolute; + right: 0; + text-align: center; + top: 50%; + } + + &.video-muted .video-input-preview-muted { + display: block; + } + + .video-input-preview-display { + height: 100%; + overflow: hidden; + width: 100%; + } + } + } + + .audio-output-preview { + text-align: right; + + a { + cursor: pointer; + text-decoration: none; + } + } + + .audio-input-preview { + background: #f4f5f7; + border-radius: 5px; + height: 6px; + + .audio-input-preview-level { + background: #0052cc; + border-radius: 5px; + height: 100%; + -webkit-transition: width .1s ease-in-out; + -moz-transition: width .1s ease-in-out; + -o-transition: width .1s ease-in-out; + transition: width .1s ease-in-out; + } + } +} diff --git a/lang/main.json b/lang/main.json index e46e9e079..816cdd0ae 100644 --- a/lang/main.json +++ b/lang/main.json @@ -422,5 +422,11 @@ "seconds": "__count__s", "speakerStats": "Speaker Stats", "speakerTime": "Speaker Time" + }, + "deviceSelection": { + "deviceSettings": "Device settings", + "noOtherDevices": "No other devices available", + "selectADevice": "Select a device", + "testAudio": "Test sound" } } diff --git a/modules/UI/UI.js b/modules/UI/UI.js index ab8f31c9a..56249e4cc 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -4,6 +4,10 @@ const logger = require("jitsi-meet-logger").getLogger(__filename); var UI = {}; +import { + updateDeviceList +} from '../../react/features/base/devices'; + import Chat from "./side_pannels/chat/Chat"; import SidePanels from "./side_pannels/SidePanels"; import Avatar from "./avatar/Avatar"; @@ -1081,6 +1085,7 @@ UI.onLocalRaiseHandChanged = function (isRaisedHand) { */ UI.onAvailableDevicesChanged = function (devices) { SettingsMenu.changeDevicesList(devices); + APP.store.dispatch(updateDeviceList(devices)); }; /** diff --git a/package.json b/package.json index b45672692..c3ceb19a1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@atlaskit/button-group": "1.0.0", "@atlaskit/field-text": "2.0.3", "@atlaskit/modal-dialog": "1.2.4", + "@atlaskit/single-select": "1.6.1", "@atlaskit/tabs": "1.2.5", "async": "0.9.0", "autosize": "1.18.13", diff --git a/react/features/base/devices/actionTypes.js b/react/features/base/devices/actionTypes.js new file mode 100644 index 000000000..a828d08dd --- /dev/null +++ b/react/features/base/devices/actionTypes.js @@ -0,0 +1,45 @@ +import { Symbol } from '../react'; + +/** + * The type of Redux action which signals that the currently used audio + * input device should be changed. + * + * { + * type: SET_AUDIO_INPUT_DEVICE, + * deviceId: string, + * } + */ +export const SET_AUDIO_INPUT_DEVICE = Symbol('SET_AUDIO_INPUT_DEVICE'); + +/** + * The type of Redux action which signals that the currently used audio + * output device should be changed. + * + * { + * type: SET_AUDIO_OUTPUT_DEVICE, + * deviceId: string, + * } + */ +export const SET_AUDIO_OUTPUT_DEVICE = Symbol('SET_AUDIO_OUTPUT_DEVICE'); + +/** + * The type of Redux action which signals that the currently used video + * input device should be changed. + * + * { + * type: SET_VIDEO_INPUT_DEVICE, + * deviceId: string, + * } + */ +export const SET_VIDEO_INPUT_DEVICE = Symbol('SET_VIDEO_INPUT_DEVICE'); + +/** + * The type of Redux action which signals that the list of known available + * audio and video sources has changed. + * + * { + * type: UPDATE_DEVICE_LIST, + * devices: Array, + * } + */ +export const UPDATE_DEVICE_LIST = Symbol('UPDATE_DEVICE_LIST'); diff --git a/react/features/base/devices/actions.js b/react/features/base/devices/actions.js new file mode 100644 index 000000000..44d57d7ca --- /dev/null +++ b/react/features/base/devices/actions.js @@ -0,0 +1,71 @@ +import { + SET_AUDIO_INPUT_DEVICE, + SET_AUDIO_OUTPUT_DEVICE, + SET_VIDEO_INPUT_DEVICE, + UPDATE_DEVICE_LIST +} from './actionTypes'; + +/** + * Signals to update the currently used audio input device. + * + * @param {string} deviceId - The id of the new audio input device. + * @returns {{ + * type: SET_AUDIO_INPUT_DEVICE, + * deviceId: string + * }} + */ +export function setAudioInputDevice(deviceId) { + return { + type: SET_AUDIO_INPUT_DEVICE, + deviceId + }; +} + +/** + * Signals to update the currently used audio output device. + * + * @param {string} deviceId - The id of the new audio ouput device. + * @returns {{ + * type: SET_AUDIO_OUTPUT_DEVICE, + * deviceId: string + * }} + */ +export function setAudioOutputDevice(deviceId) { + return { + type: SET_AUDIO_OUTPUT_DEVICE, + deviceId + }; +} + +/** + * Signals to update the currently used video input device. + * + * @param {string} deviceId - The id of the new video input device. + * @returns {{ + * type: SET_VIDEO_INPUT_DEVICE, + * deviceId: string + * }} + */ +export function setVideoInputDevice(deviceId) { + return { + type: SET_VIDEO_INPUT_DEVICE, + deviceId + }; +} + +/** + * Signals to update the list of known audio and video devices. + * + * @param {Array} devices - All known available audio input, + * audio output, and video input devices. + * @returns {{ + * type: UPDATE_DEVICE_LIST, + * devices: Array + * }} + */ +export function updateDeviceList(devices) { + return { + type: UPDATE_DEVICE_LIST, + devices + }; +} diff --git a/react/features/base/devices/index.js b/react/features/base/devices/index.js new file mode 100644 index 000000000..f3a2aac65 --- /dev/null +++ b/react/features/base/devices/index.js @@ -0,0 +1,5 @@ +export * from './actions'; +export * from './actionTypes'; + +import './middleware'; +import './reducer'; diff --git a/react/features/base/devices/middleware.js b/react/features/base/devices/middleware.js new file mode 100644 index 000000000..69ce37b46 --- /dev/null +++ b/react/features/base/devices/middleware.js @@ -0,0 +1,34 @@ +/* global APP */ + +import UIEvents from '../../../../service/UI/UIEvents'; + +import { MiddlewareRegistry } from '../redux'; + +import { + SET_AUDIO_INPUT_DEVICE, + SET_AUDIO_OUTPUT_DEVICE, + SET_VIDEO_INPUT_DEVICE +} from './actionTypes'; + +/** + * Implements the middleware of the feature base/devices. + * + * @param {Store} store - Redux store. + * @returns {Function} + */ +// eslint-disable-next-line no-unused-vars +MiddlewareRegistry.register(store => next => action => { + switch (action.type) { + case SET_AUDIO_INPUT_DEVICE: + APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId); + break; + case SET_AUDIO_OUTPUT_DEVICE: + APP.UI.emitEvent(UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED, action.deviceId); + break; + case SET_VIDEO_INPUT_DEVICE: + APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId); + break; + } + + return next(action); +}); diff --git a/react/features/base/devices/reducer.js b/react/features/base/devices/reducer.js new file mode 100644 index 000000000..0434c7ae9 --- /dev/null +++ b/react/features/base/devices/reducer.js @@ -0,0 +1,64 @@ +import { + SET_AUDIO_INPUT_DEVICE, + SET_AUDIO_OUTPUT_DEVICE, + SET_VIDEO_INPUT_DEVICE, + UPDATE_DEVICE_LIST +} from './actionTypes'; + +import { ReducerRegistry } from '../redux'; + +const DEFAULT_STATE = { + audioInput: [], + audioOutput: [], + videoInput: [] +}; + +/** + * Listen for actions which changes the state of known and used devices. + * + * @param {Object} state - The Redux state of the feature features/base/devices. + * @param {Object} action - Action object. + * @param {string} action.type - Type of action. + * @param {Array} action.devices - All available audio and + * video devices. + * @returns {Object} + */ +ReducerRegistry.register( + 'features/base/devices', + (state = DEFAULT_STATE, action) => { + switch (action.type) { + case UPDATE_DEVICE_LIST: { + const deviceList = _groupDevicesByKind(action.devices); + + return { + ...deviceList + }; + } + + // TODO: Changing of current audio and video device id is currently + // handled outside of react/redux. Fall through to default logic for + // now. + case SET_AUDIO_INPUT_DEVICE: + case SET_VIDEO_INPUT_DEVICE: + case SET_AUDIO_OUTPUT_DEVICE: + default: + return state; + } + }); + +/** + * Converts an array of media devices into an object organized by device kind. + * + * @param {Array} devices - Available media devices. + * @private + * @returns {Object} An object with the media devices split by type. The keys + * are device type and the values are arrays with devices matching the device + * type. + */ +function _groupDevicesByKind(devices) { + return { + audioInput: devices.filter(device => device.kind === 'audioinput'), + audioOutput: devices.filter(device => device.kind === 'audiooutput'), + videoInput: devices.filter(device => device.kind === 'videoinput') + }; +} diff --git a/react/features/base/lib-jitsi-meet/functions.js b/react/features/base/lib-jitsi-meet/functions.js index 01b7e126e..8941d1d41 100644 --- a/react/features/base/lib-jitsi-meet/functions.js +++ b/react/features/base/lib-jitsi-meet/functions.js @@ -60,3 +60,24 @@ export function loadConfig(host: string, path: string = '/config.js') { throw err; }); } + +/** + * Creates a JitsiLocalTrack model from the given device id. + * + * @param {string} type - The media type of track being created. Expected values + * are "video" or "audio". + * @param {string} deviceId - The id of the target media source. + * @returns {Promise} + */ +export function createLocalTrack(type, deviceId) { + return JitsiMeetJS + .createLocalTracks({ + devices: [ type ], + micDeviceId: deviceId, + cameraDeviceId: deviceId, + + // eslint-disable-next-line camelcase + firefox_fake_device: window.config + && window.config.firefox_fake_device + }).then(([ jitsiLocalTrack ]) => jitsiLocalTrack); +} diff --git a/react/features/device-selection/components/AudioInputPreview.js b/react/features/device-selection/components/AudioInputPreview.js new file mode 100644 index 000000000..c309cfafc --- /dev/null +++ b/react/features/device-selection/components/AudioInputPreview.js @@ -0,0 +1,132 @@ +import React, { PureComponent } from 'react'; + +import { JitsiTrackEvents } from '../../base/lib-jitsi-meet'; + +/** + * React component for displaying a audio level meter for a JitsiLocalTrack. + */ +class AudioInputPreview extends PureComponent { + /** + * AudioInputPreview component's property types. + * + * @static + */ + static propTypes = { + /* + * The JitsiLocalTrack to show an audio level meter for. + */ + track: React.PropTypes.object + } + + /** + * Initializes a new AudioInputPreview instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + audioLevel: 0 + }; + + this._updateAudioLevel = this._updateAudioLevel.bind(this); + } + + /** + * Starts listening for audio level updates after the initial render. + * + * @inheritdoc + * @returns {void} + */ + componentDidMount() { + this._listenForAudioUpdates(this.props.track); + } + + /** + * Stops listening for audio level updates on the old track and starts + * listening instead on the new track. + * + * @inheritdoc + * @returns {void} + */ + componentWillReceiveProps(nextProps) { + this._listenForAudioUpdates(nextProps.track); + this._updateAudioLevel(0); + } + + /** + * Unsubscribe from audio level updates. + * + * @inheritdoc + * @returns {void} + */ + componentWillUnmount() { + this._stopListeningForAudioUpdates(); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const audioMeterFill = { + width: `${Math.floor(this.state.audioLevel * 100)}%` + }; + + return ( +
+
+
+ ); + } + + /** + * Starts listening for audio level updates from the library. + * + * @param {JitstiLocalTrack} track - The track to listen to for audio level + * updates. + * @private + * @returns {void} + */ + _listenForAudioUpdates(track) { + this._stopListeningForAudioUpdates(); + + track && track.on( + JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, + this._updateAudioLevel); + } + + /** + * Stops listening to further updates from the current track. + * + * @private + * @returns {void} + */ + _stopListeningForAudioUpdates() { + this.props.track && this.props.track.off( + JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, + this._updateAudioLevel); + } + + /** + * Updates the internal state of the last know audio level. The level should + * be between 0 and 1, as the level will be used as a percentage out of 1. + * + * @param {number} audioLevel - The new audio level for the track. + * @private + * @returns {void} + */ + _updateAudioLevel(audioLevel) { + this.setState({ + audioLevel + }); + } +} + +export default AudioInputPreview; diff --git a/react/features/device-selection/components/AudioOutputPreview.js b/react/features/device-selection/components/AudioOutputPreview.js new file mode 100644 index 000000000..addd95f40 --- /dev/null +++ b/react/features/device-selection/components/AudioOutputPreview.js @@ -0,0 +1,122 @@ +import React, { Component } from 'react'; + +import { translate } from '../../base/i18n'; + +const TEST_SOUND_PATH = 'sounds/ring.wav'; + +/** + * React component for playing a test sound through a specified audio device. + * + * @extends Component + */ +class AudioOutputPreview extends Component { + /** + * AudioOutputPreview component's property types. + * + * @static + */ + static propTypes = { + /** + * The device id of the audio output device to use. + */ + deviceId: React.PropTypes.string, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + } + + /** + * Initializes a new AudioOutputPreview instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props) { + super(props); + + this._audioElement = null; + + this._onClick = this._onClick.bind(this); + this._setAudioElement = this._setAudioElement.bind(this); + } + + /** + * Sets the target output device on the component's audio element after + * initial render. + * + * @inheritdoc + * @returns {void} + */ + componentDidMount() { + this._setAudioSink(); + } + + /** + * Updates the audio element when the target output device changes and the + * audio element has re-rendered. + * + * @inheritdoc + * @returns {void} + */ + componentDidUpdate() { + this._setAudioSink(); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( + + ); + } + + /** + * Plays a test sound. + * + * @private + * @returns {void} + */ + _onClick() { + this._audioElement + && this._audioElement.play(); + } + + /** + * Sets the instance variable for the component's audio element so it can be + * accessed directly. + * + * @param {Object} element - The DOM element for the component's audio. + * @private + * @returns {void} + */ + _setAudioElement(element) { + this._audioElement = element; + } + + /** + * Updates the target output device for playing the test sound. + * + * @private + * @returns {void} + */ + _setAudioSink() { + this._audioElement + && this._audioElement.setSinkId(this.props.deviceId); + } +} + +export default translate(AudioOutputPreview); diff --git a/react/features/device-selection/components/DeviceSelectionDialog.js b/react/features/device-selection/components/DeviceSelectionDialog.js new file mode 100644 index 000000000..bb1fa4bbb --- /dev/null +++ b/react/features/device-selection/components/DeviceSelectionDialog.js @@ -0,0 +1,597 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { + setAudioInputDevice, + setAudioOutputDevice, + setVideoInputDevice +} from '../../base/devices'; +import { + Dialog, + hideDialog +} from '../../base/dialog'; +import { translate } from '../../base/i18n'; +import { createLocalTrack } from '../../base/lib-jitsi-meet'; + +import AudioInputPreview from './AudioInputPreview'; +import AudioOutputPreview from './AudioOutputPreview'; +import DeviceSelector from './DeviceSelector'; +import VideoInputPreview from './VideoInputPreview'; + +/** + * React component for previewing and selecting new audio and video sources. + * + * @extends Component + */ +class DeviceSelectionDialog extends Component { + /** + * DeviceSelectionDialog component's property types. + * + * @static + */ + static propTypes = { + /** + * All known audio and video devices split by type. This prop comes from + * the app state. + */ + _devices: React.PropTypes.object, + + /** + * Device id for the current audio output device. + */ + 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. + */ + currentAudioTrack: React.PropTypes.object, + + /** + * JitsiLocalTrack for the current local video. + * + * Needed for reuse. See comment for propTypes.currentAudioTrack. + */ + currentVideoTrack: React.PropTypes.object, + + /** + * Whether or not the audio selector can be interacted with. If true, + * the audio input selector will be rendered as disabled. This is + * specifically used to prevent audio device changing in Firefox, which + * currently does not work due to a browser-side regression. + */ + disableAudioInputChange: React.PropTypes.bool, + + /** + * True if device changing is configured to be disallowed. Selectors + * will display as disabled. + */ + disableDeviceChange: React.PropTypes.bool, + + /** + * Invoked to notify the store of app state changes. + */ + dispatch: React.PropTypes.func, + + /** + * Whether or not new audio input source can be selected. + */ + hasAudioPermission: React.PropTypes.bool, + + /** + * Whether or not new video input sources can be selected. + */ + hasVideoPermission: React.PropTypes.bool, + + /** + * If true, the audio meter will not display. Necessary for browsers or + * configurations that do not support local stats to prevent a + * non-responsive mic preview from displaying. + */ + hideAudioInputPreview: React.PropTypes.bool, + + /** + * Whether or not the audio output source selector should display. If + * true, the audio output selector and test audio link will not be + * rendered. This is specifically used for hiding audio output on + * temasys browsers which do not support such change. + */ + hideAudioOutputSelect: React.PropTypes.bool, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + } + + /** + * Initializes a new DeviceSelectionDialog instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + // JitsiLocalTracks to use for live previewing. + previewAudioTrack: null, + previewVideoTrack: null, + + // Device ids to keep track of new selections. + videInput: null, + audioInput: null, + audioOutput: null + }; + + // Preventing closing while cleaning up previews is important for + // supporting temasys video cleanup. Temasys requires its video object + // to be in the dom and visible for proper detaching of tracks. Delaying + // closure until cleanup is complete ensures no errors in the process. + this._isClosing = false; + + 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); + } + + /** + * Clean up any preview tracks that might not have been cleaned up already. + * + * @inheritdoc + */ + componentWillUnmount() { + // This handles the case where neither submit nor cancel were triggered, + // such as on modal switch. In that case, make a dying attempt to clean + // up previews. + if (!this._isClosing) { + this._attemptPreviewTrackCleanup(); + } + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + */ + render() { + return ( + +
+
+
+ { this._renderSelectors() } +
+ { this._renderAudioOutputPreview() } +
+
+
+ +
+ { this._renderAudioInputPreview() } +
+
+
+ ); + } + + /** + * Cleans up preview tracks if they are not active tracks. + * + * @private + * @returns {Array} Zero to two promises will be returned. One + * 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; + } + + /** + * Signals to close DeviceSelectionDialog. + * + * @private + * @returns {void} + */ + _closeModal() { + this.props.dispatch(hideDialog()); + } + + /** + * Utility function for disposing the current audio preview. + * + * @private + * @returns {Promise} + */ + _disposeAudioPreview() { + return this.state.previewAudioTrack + ? this.state.previewAudioTrack.dispose() : Promise.resolve(); + } + + /** + * Utility function for disposing the current video preview. + * + * @private + * @returns {Promise} + */ + _disposeVideoPreview() { + return this.state.previewVideoTrack + ? this.state.previewVideoTrack.dispose() : Promise.resolve(); + } + + /** + * 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. + * + * @private + * @returns {boolean} Returns false to prevent closure until cleanup is + * complete. + */ + _onCancel() { + if (this._isClosing) { + return false; + } + + this._isClosing = true; + + const cleanupPromises = this._attemptPreviewTrackCleanup(); + + Promise.all(cleanupPromises) + .then(this._closeModal) + .catch(this._closeModal); + + return false; + } + + /** + * Identify 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. + * + * @private + * @returns {boolean} Returns false to prevent closure until cleanup is + * complete. + */ + _onSubmit() { + if (this._isClosing) { + return false; + } + + this._isClosing = true; + + const deviceChangePromises = []; + + if (this.state.videoInput && !this._isPreviewingCurrentVideoTrack()) { + const changeVideoPromise = this._disposeVideoPreview() + .then(() => { + this.props.dispatch(setVideoInputDevice( + this.state.videoInput)); + }); + + 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)); + } + + Promise.all(deviceChangePromises) + .then(this._closeModal) + .catch(this._closeModal); + + return false; + } + + /** + * Creates an AudioInputPreview for previewing if audio is being received. + * Null will be returned if local stats for tracking audio input levels + * cannot be obtained. + * + * @private + * @returns {ReactComponent|null} + */ + _renderAudioInputPreview() { + if (this.props.hideAudioInputPreview) { + return null; + } + + return ( + + ); + } + + /** + * Creates an AudioOutputPreview instance for playing a test sound with the + * passed in device id. Null will be returned if hideAudioOutput is truthy. + * + * @private + * @returns {ReactComponent|null} + */ + _renderAudioOutputPreview() { + if (this.props.hideAudioOutputSelect) { + return null; + } + + return ( + + ); + } + + /** + * Creates a DeviceSelector instance based on the passed in configuration. + * + * @private + * @param {Object} props - The props for the DeviceSelector. + * @returns {ReactElement} + */ + _renderSelector(props) { + return ( + + ); + } + + /** + * Creates DeviceSelector instances for video output, audio input, and audio + * output. + * + * @private + * @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 configurations = [ + { + devices: availableDevices.videoInput, + hasPermission: this.props.hasVideoPermission, + isDisabled: this.props.disableDeviceChange, + key: 'videoInput', + label: 'settings.selectCamera', + onSelect: this._getAndSetVideoTrack, + selectedDeviceId: currentVideoId + }, + { + devices: availableDevices.audioInput, + hasPermission: this.props.hasAudioPermission, + isDisabled: this.props.disableAudioInputChange + || this.props.disableDeviceChange, + key: 'audioInput', + label: 'settings.selectMic', + onSelect: this._getAndSetAudioTrack, + selectedDeviceId: currentAudioId + } + ]; + + if (!this.props.hideAudioOutputSelect) { + configurations.push({ + devices: availableDevices.audioOutput, + hasPermission: this.props.hasAudioPermission + || this.props.hasVideoPermission, + isDisabled: this.props.disableDeviceChange, + key: 'audioOutput', + label: 'settings.selectAudioOutput', + onSelect: this._getAndSetAudioOutput, + selectedDeviceId: currentAudioOutId + }); + } + + return configurations.map(this._renderSelector); + } +} + +/** + * Maps (parts of) the Redux state to the associated DeviceSelectionDialog's + * props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _devices: Object + * }} + */ +function _mapStateToProps(state) { + return { + _devices: state['features/base/devices'] + }; +} + +export default translate(connect(_mapStateToProps)(DeviceSelectionDialog)); diff --git a/react/features/device-selection/components/DeviceSelector.js b/react/features/device-selection/components/DeviceSelector.js new file mode 100644 index 000000000..552306e51 --- /dev/null +++ b/react/features/device-selection/components/DeviceSelector.js @@ -0,0 +1,180 @@ +import Select from '@atlaskit/single-select'; +import React, { Component } from 'react'; + +import { translate } from '../../base/i18n'; + +/** + * React component for selecting a device from a select element. Wraps Select + * with device selection specific logic. + * + * @extends Component + */ +class DeviceSelector extends Component { + /** + * DeviceSelector component's property types. + * + * @static + */ + static propTypes = { + /** + * MediaDeviceInfos used for display in the select element. + */ + devices: React.PropTypes.array, + + /** + * If false, will return a selector with no selection options. + */ + hasPermission: React.PropTypes.bool, + + /** + * If true, will render the selector disabled with a default selection. + */ + isDisabled: React.PropTypes.bool, + + /** + * The translation key to display as a menu label. + */ + label: React.PropTypes.string, + + /** + * The callback to invoke when a selection is made. + */ + onSelect: React.PropTypes.func, + + /** + * The default device to display as selected. + */ + selectedDeviceId: React.PropTypes.string, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + } + + /** + * Initializes a new DeviceSelector instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props) { + super(props); + + this._onSelect = this._onSelect.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + if (!this.props.hasPermission) { + return this._renderNoPermission(); + } + + if (!this.props.devices.length) { + return this._renderNoDevices(); + } + + const items = this.props.devices.map(this._createSelectItem); + const defaultSelected = items.find(item => + item.value === this.props.selectedDeviceId + ); + + return this._createSelector({ + defaultSelected, + isDisabled: this.props.isDisabled, + items, + placeholder: 'deviceSelection.selectADevice' + }); + } + + /** + * Creates an object in the format expected by Select for an option element. + * + * @param {MediaDeviceInfo} device - An object with a label and a deviceId. + * @private + * @returns {Object} The passed in media device description converted to a + * format recognized as a valid Select item. + */ + _createSelectItem(device) { + return { + content: device.label, + value: device.deviceId + }; + } + + /** + * Creates a Select Component using passed in props and options. + * + * @param {Object} options - Additional configuration for display Select. + * @param {Object} options.defaultSelected - The option that should be set + * as currently chosen. + * @param {boolean} options.isDisabled - If true Select will not open on + * click. + * @param {Array} options.items - All the selectable options to display. + * @param {string} options.placeholder - The translation key to display when + * no selection has been made. + * @private + * @returns {ReactElement} + */ + _createSelector(options) { + return ( + element to select microphone ID from settings. - */ -UI.setSelectedMicFromSettings = function () { - SettingsMenu.setSelectedMicFromSettings(); -}; - -/** - * Sets camera's element to select audio output ID from - * settings. - */ -UI.setSelectedAudioOutputFromSettings = function () { - SettingsMenu.setSelectedAudioOutputFromSettings(); -}; - /** * Returns the id of the current video shown on large. * Currently used by tests (torture). diff --git a/modules/UI/side_pannels/settings/SettingsMenu.js b/modules/UI/side_pannels/settings/SettingsMenu.js index 2e063cf8f..a8aa681c1 100644 --- a/modules/UI/side_pannels/settings/SettingsMenu.js +++ b/modules/UI/side_pannels/settings/SettingsMenu.js @@ -1,12 +1,15 @@ /* global $, APP, AJS, interfaceConfig, JitsiMeetJS */ - +import { openDialog } from '../../../../react/features/base/dialog'; import { LANGUAGES } from "../../../../react/features/base/i18n"; +import { DeviceSelectionDialog } + from '../../../../react/features/device-selection'; import UIUtil from "../../util/UIUtil"; import UIEvents from "../../../../service/UI/UIEvents"; -import Settings from '../../../settings/Settings'; const sidePanelsContainerId = 'sideToolbarContainer'; +const deviceSelectionButtonClasses + = 'button-control button-control_primary button-control_full-width'; const htmlStr = `
@@ -19,17 +22,11 @@ const htmlStr = `
- - -
-
- - -
-
- - +
@@ -89,40 +86,6 @@ function generateLanguagesOptions(items, currentLang) { }).join(''); } -/** - * Generate html select options for available physical devices. - * - * @param {{ deviceId, label }[]} items available devices - * @param {string} [selectedId] id of selected device - * @param {boolean} permissionGranted if permission to use selected device type - * is granted - * @returns {string} - */ -function generateDevicesOptions(items, selectedId, permissionGranted) { - if (!permissionGranted && items.length) { - return ''; - } - - var options = items.map(function (item) { - let attrs = { - value: item.deviceId - }; - - if (item.deviceId === selectedId) { - attrs.selected = 'selected'; - } - - let attrsStr = UIUtil.attrsToString(attrs); - return ``; - }); - - if (!items.length) { - options.unshift(''); - } - - return options.join(''); -} - /** * Replace html select element to select2 custom dropdown * @@ -138,6 +101,34 @@ function initSelect2($el, onSelectedCb) { } } +/** + * Open DeviceSelectionDialog with a configuration based on the environment's + * supported abilities. + * + * @param {boolean} isDeviceListAvailable - Whether or not device enumeration + * is possible. This is a value obtained through an async operation whereas all + * other configurations for the modal are obtained synchronously. + * @private + * @returns {void} + */ +function _openDeviceSelectionModal(isDeviceListAvailable) { + APP.store.dispatch(openDialog(DeviceSelectionDialog, { + currentAudioOutputId: APP.settings.getAudioOutputDeviceId(), + currentAudioTrack: APP.conference.getLocalAudioTrack(), + currentVideoTrack: APP.conference.getLocalVideoTrack(), + disableAudioInputChange: !JitsiMeetJS.isMultipleAudioInputSupported(), + disableDeviceChange: !isDeviceListAvailable + || !JitsiMeetJS.mediaDevices.isDeviceChangeAvailable(), + hasAudioPermission: JitsiMeetJS.mediaDevices + .isDevicePermissionGranted('audio'), + hasVideoPermission: JitsiMeetJS.mediaDevices + .isDevicePermissionGranted('video'), + hideAudioInputPreview: !JitsiMeetJS.isCollectingLocalStats(), + hideAudioOutputSelect: !JitsiMeetJS.mediaDevices + .isDeviceChangeAvailable('output') + })); +} + export default { init (emitter) { initHTML(); @@ -181,11 +172,11 @@ export default { JitsiMeetJS.mediaDevices.isDeviceListAvailable() .then((isDeviceListAvailable) => { - if (isDeviceListAvailable && - JitsiMeetJS.mediaDevices.isDeviceChangeAvailable()) { - this._initializeDeviceSelectionSettings(emitter); - } + $('#deviceSelection').on('click', () => { + _openDeviceSelectionModal(isDeviceListAvailable); + }); }); + // Only show the subtitle if this isn't the only setting section. if (interfaceConfig.SETTINGS_SECTIONS.length > 1) UIUtil.setVisible("deviceOptionsTitle", true); @@ -219,30 +210,6 @@ export default { } }, - _initializeDeviceSelectionSettings(emitter) { - this.changeDevicesList([]); - - $('#selectCamera').change(function () { - let cameraDeviceId = $(this).val(); - if (cameraDeviceId !== Settings.getCameraDeviceId()) { - emitter.emit(UIEvents.VIDEO_DEVICE_CHANGED, cameraDeviceId); - } - }); - $('#selectMic').change(function () { - let micDeviceId = $(this).val(); - if (micDeviceId !== Settings.getMicDeviceId()) { - emitter.emit(UIEvents.AUDIO_DEVICE_CHANGED, micDeviceId); - } - }); - $('#selectAudioOutput').change(function () { - let audioOutputDeviceId = $(this).val(); - if (audioOutputDeviceId !== Settings.getAudioOutputDeviceId()) { - emitter.emit( - UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED, audioOutputDeviceId); - } - }); - }, - /** * If start audio muted/start video muted options should be visible or not. * @param {boolean} show @@ -286,91 +253,5 @@ export default { */ isVisible () { return UIUtil.isVisible(document.getElementById("settings_container")); - }, - - /** - * Sets microphone's element to select camera ID from settings. - */ - setSelectedCameraFromSettings () { - $('#selectCamera').val(Settings.getCameraDeviceId()); - }, - - /** - * Sets audio outputs's