From 96e83989a54974499decf6985fd17c8990984db8 Mon Sep 17 00:00:00 2001 From: hristoterezov Date: Sat, 3 Jun 2017 22:12:04 -0500 Subject: [PATCH] feat(device_selection): Implement popup --- Makefile | 2 + css/_filmstrip.scss | 2 +- css/_toolbars.scss | 8 +- css/_variables.scss | 1 + interface_config.js | 4 +- .../base/dialog/components/AbstractDialog.js | 50 +- .../base/dialog/components/Dialog.web.js | 128 +---- .../dialog/components/StatelessDialog.web.js | 210 +++++++ .../features/base/dialog/components/index.js | 1 + react/features/base/dialog/constants.js | 50 ++ .../device-selection/DeviceSelectionPopup.js | 300 ++++++++++ .../features/device-selection/actionTypes.js | 10 + react/features/device-selection/actions.js | 176 +++++- .../components/AudioInputPreview.js | 2 +- .../components/DeviceSelectionDialog.js | 451 ++------------- .../components/DeviceSelectionDialogBase.js | 529 ++++++++++++++++++ .../components/DeviceSelector.js | 2 +- .../device-selection/components/index.js | 2 + react/features/device-selection/index.js | 4 + react/features/device-selection/middleware.js | 24 + react/features/device-selection/popup.js | 13 + react/features/device-selection/reducer.js | 28 + .../components/ToolbarButton.native.js | 22 +- .../toolbox/components/ToolbarButton.web.js | 14 +- .../features/toolbox/defaultToolbarButtons.js | 52 +- react/features/toolbox/functions.web.js | 7 +- react/features/toolbox/reducer.js | 22 +- static/deviceSelectionPopup.html | 19 + webpack.config.js | 10 + 29 files changed, 1551 insertions(+), 592 deletions(-) create mode 100644 react/features/base/dialog/components/StatelessDialog.web.js create mode 100644 react/features/base/dialog/constants.js create mode 100644 react/features/device-selection/DeviceSelectionPopup.js create mode 100644 react/features/device-selection/actionTypes.js create mode 100644 react/features/device-selection/components/DeviceSelectionDialogBase.js create mode 100644 react/features/device-selection/middleware.js create mode 100644 react/features/device-selection/popup.js create mode 100644 react/features/device-selection/reducer.js create mode 100644 static/deviceSelectionPopup.html diff --git a/Makefile b/Makefile index a104af7cb9..15f4b02110 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,8 @@ deploy-appbundle: $(BUILD_DIR)/do_external_connect.min.map \ $(BUILD_DIR)/external_api.min.js \ $(BUILD_DIR)/external_api.min.map \ + $(BUILD_DIR)/device_selection_popup_bundle.min.js \ + $(BUILD_DIR)/device_selection_popup_bundle.min.map \ $(OUTPUT_DIR)/analytics.js \ $(DEPLOY_DIR) diff --git a/css/_filmstrip.scss b/css/_filmstrip.scss index 8c7fb225b7..6f936ab269 100644 --- a/css/_filmstrip.scss +++ b/css/_filmstrip.scss @@ -136,7 +136,7 @@ &__videos-filmstripOnly { margin-top: auto; margin-bottom: auto; - padding-right: $defaultToolbarSize; + padding-right: $defaultFilmStripOnlyToolbarSize; } .remote-videos-container { diff --git a/css/_toolbars.scss b/css/_toolbars.scss index d73f5b635e..abf1dc215a 100644 --- a/css/_toolbars.scss +++ b/css/_toolbars.scss @@ -200,7 +200,13 @@ height: auto; position: absolute; right: 0; - width: $defaultToolbarSize; + width: $defaultFilmStripOnlyToolbarSize; + + .button { + height: 37px; + line-height: 37px !important; + width: 37px; + } .button:first-child { border-top-left-radius: 3px; diff --git a/css/_variables.scss b/css/_variables.scss index 5473ca8b93..39963f9fcf 100644 --- a/css/_variables.scss +++ b/css/_variables.scss @@ -34,6 +34,7 @@ $tooltipBg: rgba(0,0,0, 0.7); * Toolbar */ $defaultToolbarSize: 50px; +$defaultFilmStripOnlyToolbarSize: 37px; $splitterToolbarButtonMargin: 18px; $toolbarBackground: rgba(0, 0, 0, 0.5); $toolbarBadgeBackground: #165ECC; diff --git a/interface_config.js b/interface_config.js index f19508bfe8..4e0a7b1b5d 100644 --- a/interface_config.js +++ b/interface_config.js @@ -36,14 +36,14 @@ var interfaceConfig = { // eslint-disable-line no-unused-vars */ TOOLBAR_BUTTONS: [ //main toolbar - 'microphone', 'camera', 'desktop', 'invite', 'fullscreen', 'hangup', + 'microphone', 'camera', 'desktop', 'invite', 'fullscreen', 'hangup', 'deviceselection', // jshint ignore:line //extended toolbar 'profile', 'contacts', 'chat', 'recording', 'etherpad', 'sharedvideo', 'dialout', 'settings', 'raisehand', 'filmstrip'], // jshint ignore:line /** * Main Toolbar Buttons * All of them should be in TOOLBAR_BUTTONS */ - MAIN_TOOLBAR_BUTTONS: ['microphone', 'camera', 'desktop', 'invite', 'fullscreen', 'hangup'], // jshint ignore:line + MAIN_TOOLBAR_BUTTONS: ['microphone', 'camera', 'desktop', 'invite', 'fullscreen', 'hangup', 'deviceselection'], // jshint ignore:line SETTINGS_SECTIONS: ['language', 'devices', 'moderator'], // Determines how the video would fit the screen. 'both' would fit the whole // screen, 'height' would fit the original video height to the height of the diff --git a/react/features/base/dialog/components/AbstractDialog.js b/react/features/base/dialog/components/AbstractDialog.js index e0649dfe45..f1cc3045c9 100644 --- a/react/features/base/dialog/components/AbstractDialog.js +++ b/react/features/base/dialog/components/AbstractDialog.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import { hideDialog } from '../actions'; +import { dialogPropTypes } from '../constants'; /** * Abstract dialog to display dialogs. @@ -13,57 +14,12 @@ export default class AbstractDialog extends Component { * @static */ static propTypes = { - /** - * Whether cancel button is disabled. Enabled by default. - */ - cancelDisabled: React.PropTypes.bool, - - /** - * Optional i18n key to change the cancel button title. - */ - cancelTitleKey: React.PropTypes.string, + ...dialogPropTypes, /** * Used to show/hide the dialog on cancel. */ - dispatch: React.PropTypes.func, - - /** - * Is ok button enabled/disabled. Enabled by default. - */ - okDisabled: React.PropTypes.bool, - - /** - * Optional i18n key to change the ok button title. - */ - okTitleKey: React.PropTypes.string, - - /** - * The handler for onCancel event. - */ - onCancel: React.PropTypes.func, - - /** - * The handler for the event when submitting the dialog. - */ - onSubmit: React.PropTypes.func, - - /** - * Used to obtain translations in children classes. - */ - t: React.PropTypes.func, - - /** - * Key to use for showing a title. - */ - titleKey: React.PropTypes.string, - - /** - * The string to use as a title instead of {@code titleKey}. If a truthy - * value is specified, it takes precedence over {@code titleKey} i.e. - * the latter is unused. - */ - titleString: React.PropTypes.string + dispatch: React.PropTypes.func }; /** diff --git a/react/features/base/dialog/components/Dialog.web.js b/react/features/base/dialog/components/Dialog.web.js index d44b340edb..006fc345d5 100644 --- a/react/features/base/dialog/components/Dialog.web.js +++ b/react/features/base/dialog/components/Dialog.web.js @@ -1,12 +1,8 @@ -import AKButton from '@atlaskit/button'; -import AKButtonGroup from '@atlaskit/button-group'; -import ModalDialog from '@atlaskit/modal-dialog'; import React from 'react'; import { connect } from 'react-redux'; -import { translate } from '../../i18n'; - import AbstractDialog from './AbstractDialog'; +import StatelessDialog from './StatelessDialog'; /** * Web dialog that uses atlaskit modal-dialog to display dialogs. @@ -19,6 +15,8 @@ class Dialog extends AbstractDialog { * @static */ static propTypes = { + ...AbstractDialog.propTypes, + /** * This is the body of the dialog, the component children. */ @@ -30,6 +28,11 @@ class Dialog extends AbstractDialog { */ isModal: React.PropTypes.bool, + /** + * Disables rendering of the submit button. + */ + submitDisabled: React.PropTypes.bool, + /** * Width of the dialog, can be: * - 'small' (400px), 'medium' (600px), 'large' (800px), @@ -40,6 +43,19 @@ class Dialog extends AbstractDialog { width: React.PropTypes.string }; + /** + * Initializes a new Dialog instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this._onCancel = this._onCancel.bind(this); + this._onSubmit = this._onSubmit.bind(this); + } + /** * Implements React's {@link Component#render()}. * @@ -47,101 +63,15 @@ class Dialog extends AbstractDialog { * @returns {ReactElement} */ render() { - return ( - -
- -
-
); - } + const props = { + ...this.props, + onSubmit: this._onSubmit, + onCancel: this._onCancel + }; - /** - * Render cancel button. - * - * @returns {*} The cancel button if enabled and dialog is not modal. - * @private - */ - _renderCancelButton() { - if (this.props.cancelDisabled || this.props.isModal) { - return null; - } + delete props.dispatch; - return ( - - { this.props.t(this.props.cancelTitleKey || 'dialog.Cancel') } - - ); - } - - /** - * Render component in dialog footer. - * - * @returns {ReactElement} - * @private - */ - _renderFooter() { - return ( - - ); - } - - /** - * Render component in dialog header. - * - * @returns {ReactElement} - * @private - */ - _renderHeader() { - const { t } = this.props; - - return ( -
-

- { this.props.titleString || t(this.props.titleKey) } -

-
- ); - } - - /** - * Render ok button. - * - * @returns {*} The ok button if enabled. - * @private - */ - _renderOKButton() { - if (this.props.submitDisabled) { - return null; - } - - return ( - - { this.props.t(this.props.okTitleKey || 'dialog.Ok') } - - ); + return ; } /** @@ -158,4 +88,4 @@ class Dialog extends AbstractDialog { } } -export default translate(connect()(Dialog)); +export default connect()(Dialog); diff --git a/react/features/base/dialog/components/StatelessDialog.web.js b/react/features/base/dialog/components/StatelessDialog.web.js new file mode 100644 index 0000000000..6640e1cc6b --- /dev/null +++ b/react/features/base/dialog/components/StatelessDialog.web.js @@ -0,0 +1,210 @@ +import AKButton from '@atlaskit/button'; +import AKButtonGroup from '@atlaskit/button-group'; +import ModalDialog from '@atlaskit/modal-dialog'; +import React, { Component } from 'react'; + +import { translate } from '../../i18n'; + +import { dialogPropTypes } from '../constants'; + +/** + * Web dialog that uses atlaskit modal-dialog to display dialogs. + */ +class StatelessDialog extends Component { + + /** + * Web dialog component's property types. + * + * @static + */ + static propTypes = { + ...dialogPropTypes, + + /** + * This is the body of the dialog, the component children. + */ + children: React.PropTypes.node, + + /** + * Disables dismissing the dialog when the blanket is clicked. Enabled + * by default. + */ + disableBlanketClickDismiss: React.PropTypes.bool, + + /** + * Whether the dialog is modal. This means clicking on the blanket will + * leave the dialog open. No cancel button. + */ + isModal: React.PropTypes.bool, + + /** + * Disables rendering of the submit button. + */ + submitDisabled: React.PropTypes.bool, + + /** + * Width of the dialog, can be: + * - 'small' (400px), 'medium' (600px), 'large' (800px), + * 'x-large' (968px) + * - integer value for pixel width + * - string value for percentage + */ + width: React.PropTypes.string + + }; + + /** + * Initializes a new Dialog instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this._onCancel = this._onCancel.bind(this); + this._onDialogDismissed = this._onDialogDismissed.bind(this); + this._onSubmit = this._onSubmit.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( + +
+ +
+
); + } + + /** + * Handles click on the blanket area. + * + * @returns {void} + */ + _onDialogDismissed() { + if (!this.props.disableBlanketClickDismiss) { + this._onCancel(); + } + } + + /** + * Render cancel button. + * + * @returns {*} The cancel button if enabled and dialog is not modal. + * @private + */ + _renderCancelButton() { + if (this.props.cancelDisabled || this.props.isModal) { + return null; + } + + return ( + + { this.props.t(this.props.cancelTitleKey || 'dialog.Cancel') } + + ); + } + + /** + * Render component in dialog footer. + * + * @returns {ReactElement} + * @private + */ + _renderFooter() { + return ( +
+ + { this._renderCancelButton() } + { this._renderOKButton() } + +
+ ); + } + + /** + * Render component in dialog header. + * + * @returns {ReactElement} + * @private + */ + _renderHeader() { + const { t } = this.props; + + return ( +
+

+ { this.props.titleString || t(this.props.titleKey) } +

+
+ ); + } + + /** + * Render ok button. + * + * @returns {*} The ok button if enabled. + * @private + */ + _renderOKButton() { + if (this.props.submitDisabled) { + return null; + } + + return ( + + { this.props.t(this.props.okTitleKey || 'dialog.Ok') } + + ); + } + + /** + * Dispatches action to hide the dialog. + * + * @returns {void} + */ + _onCancel() { + if (this.props.isModal) { + return; + } + + this.props.onCancel(); + } + + /** + * Dispatches the action when submitting the dialog. + * + * @private + * @param {string} value - The submitted value if any. + * @returns {void} + */ + _onSubmit(value) { + this.props.onSubmit(value); + } +} + +export default translate(StatelessDialog); diff --git a/react/features/base/dialog/components/index.js b/react/features/base/dialog/components/index.js index 8c7c7d9ebf..057e25b2ee 100644 --- a/react/features/base/dialog/components/index.js +++ b/react/features/base/dialog/components/index.js @@ -1,2 +1,3 @@ export { default as DialogContainer } from './DialogContainer'; export { default as Dialog } from './Dialog'; +export { default as StatelessDialog } from './StatelessDialog'; diff --git a/react/features/base/dialog/constants.js b/react/features/base/dialog/constants.js new file mode 100644 index 0000000000..0a74eef6a5 --- /dev/null +++ b/react/features/base/dialog/constants.js @@ -0,0 +1,50 @@ +import React from 'react'; + +export const dialogPropTypes = { + /** + * Whether cancel button is disabled. Enabled by default. + */ + cancelDisabled: React.PropTypes.bool, + + /** + * Optional i18n key to change the cancel button title. + */ + cancelTitleKey: React.PropTypes.string, + + /** + * Is ok button enabled/disabled. Enabled by default. + */ + okDisabled: React.PropTypes.bool, + + /** + * Optional i18n key to change the ok button title. + */ + okTitleKey: React.PropTypes.string, + + /** + * The handler for onCancel event. + */ + onCancel: React.PropTypes.func, + + /** + * The handler for the event when submitting the dialog. + */ + onSubmit: React.PropTypes.func, + + /** + * Used to obtain translations in children classes. + */ + t: React.PropTypes.func, + + /** + * Key to use for showing a title. + */ + titleKey: React.PropTypes.string, + + /** + * The string to use as a title instead of {@code titleKey}. If a truthy + * value is specified, it takes precedence over {@code titleKey} i.e. + * the latter is unused. + */ + titleString: React.PropTypes.string +}; diff --git a/react/features/device-selection/DeviceSelectionPopup.js b/react/features/device-selection/DeviceSelectionPopup.js new file mode 100644 index 0000000000..778c637f21 --- /dev/null +++ b/react/features/device-selection/DeviceSelectionPopup.js @@ -0,0 +1,300 @@ +import Logger from 'jitsi-meet-logger'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { I18nextProvider } from 'react-i18next'; + +import { + PostMessageTransportBackend, + Transport +} from '../../../modules/transport'; +import { parseURLParams } from '../base/config'; + +import DeviceSelectionDialogBase from './components/DeviceSelectionDialogBase'; + +declare var JitsiMeetJS: Object; + +const logger = Logger.getLogger(__filename); + +/** + * Implements a class that renders the React components for the device selection + * popup page and handles the communication between the components and Jitsi + * Meet. + */ +export default class DeviceSelectionPopup { + /** + * Initializes a new DeviceSelectionPopup instance. + * + * @param {Object} i18next - The i18next instance used for translation. + */ + constructor(i18next) { + this.close = this.close.bind(this); + this._setVideoInputDevice = this._setVideoInputDevice.bind(this); + this._setAudioInputDevice = this._setAudioInputDevice.bind(this); + this._setAudioOutputDevice = this._setAudioOutputDevice.bind(this); + this._i18next = i18next; + const { scope } = parseURLParams(window.location); + + this._transport = new Transport({ + backend: new PostMessageTransportBackend({ + postisOptions: { + scope, + window: window.opener + } + }) + }); + + this._transport.on('event', event => { + if (event.name === 'deviceListChanged') { + this._updateAvailableDevices(); + + return true; + } + + return false; + }); + + this._dialogProps = { + availableDevices: {}, + currentAudioInputId: '', + currentAudioOutputId: '', + currentVideoInputId: '', + disableAudioInputChange: true, + disableDeviceChange: true, + hasAudioPermission: JitsiMeetJS.mediaDevices + .isDevicePermissionGranted('audio'), + hasVideoPermission: JitsiMeetJS.mediaDevices + .isDevicePermissionGranted('video'), + hideAudioInputPreview: !JitsiMeetJS.isCollectingLocalStats(), + hideAudioOutputSelect: true + }; + this._initState(); + } + + /** + * Sends event to Jitsi Meet to close the popup dialog. + * + * @returns {void} + */ + close() { + this._transport.sendEvent({ + type: 'devices-dialog', + name: 'close' + }); + } + + /** + * Changes the properties of the react component and re-renders it. + * + * @param {Object} newProps - The new properties that will be assigned to + * the current ones. + * @returns {void} + */ + _changeDialogProps(newProps) { + this._dialogProps = { + ...this._dialogProps, + ...newProps + }; + this._render(); + } + + /** + * Returns Promise that resolves with result an list of available devices. + * + * @returns {Promise} + */ + _getAvailableDevices() { + return this._transport.sendRequest({ + type: 'devices', + name: 'getAvailableDevices' + }).catch(e => { + logger.error(e); + + return {}; + }); + } + + /** + * Returns Promise that resolves with current selected devices. + * + * @returns {Promise} + */ + _getCurrentDevices() { + return this._transport.sendRequest({ + type: 'devices', + name: 'getCurrentDevices' + }).catch(e => { + logger.error(e); + + return {}; + }); + } + + /** + * Initializes the state. + * + * @returns {void} + */ + _initState() { + return Promise.all([ + this._getAvailableDevices(), + this._isDeviceListAvailable(), + this._isDeviceChangeAvailable(), + this._getCurrentDevices(), + this._isMultipleAudioInputSupported() + ]).then(([ + availableDevices, + listAvailable, + changeAvailable, + currentDevices, + multiAudioInputSupported + ]) => { + this._changeDialogProps({ + availableDevices, + currentAudioInputId: currentDevices.audioInput, + currentAudioOutputId: currentDevices.audioOutput, + currentVideoInputId: currentDevices.videoInput, + disableAudioInputChange: !multiAudioInputSupported, + disableDeviceChange: !listAvailable || !changeAvailable, + hideAudioOutputSelect: !changeAvailable + }); + }); + } + + /** + * Returns Promise that resolves with true if the device change is available + * and with false if not. + * + * @returns {Promise} + */ + _isDeviceChangeAvailable() { + return this._transport.sendRequest({ + type: 'devices', + name: 'isDeviceChangeAvailable' + }).catch(e => { + logger.error(e); + + return false; + }); + } + + /** + * Returns Promise that resolves with true if the device list is available + * and with false if not. + * + * @returns {Promise} + */ + _isDeviceListAvailable() { + return this._transport.sendRequest({ + type: 'devices', + name: 'isDeviceListAvailable' + }).catch(e => { + logger.error(e); + + return false; + }); + } + + /** + * Returns Promise that resolves with true if the device list is available + * and with false if not. + * + * @returns {Promise} + */ + _isMultipleAudioInputSupported() { + return this._transport.sendRequest({ + type: 'devices', + name: 'isMultipleAudioInputSupported' + }).catch(e => { + logger.error(e); + + return false; + }); + } + + /** + * Renders the React components for the popup page. + * + * @returns {void} + */ + _render() { + const props = { + ...this._dialogProps, + closeModal: this.close, + disableBlanketClickDismiss: true, + setAudioInputDevice: this._setAudioInputDevice, + setAudioOutputDevice: this._setAudioOutputDevice, + setVideoInputDevice: this._setVideoInputDevice + }; + + ReactDOM.render( + + + , + document.getElementById('react')); + } + + /** + * Sets the audio input device to the one with the id that is passed. + * + * @param {string} id - The id of the new device. + * @returns {Promise} + */ + _setAudioInputDevice(id) { + return this._setDevice({ + id, + kind: 'audioinput' + }); + } + + /** + * Sets the audio output device to the one with the id that is passed. + * + * @param {string} id - The id of the new device. + * @returns {Promise} + */ + _setAudioOutputDevice(id) { + return this._setDevice({ + id, + kind: 'audiooutput' + }); + } + + /** + * Sets the currently used device to the one that is passed. + * + * @param {Object} device - The new device to be used. + * @returns {Promise} + */ + _setDevice(device) { + return this._transport.sendRequest({ + type: 'devices', + name: 'setDevice', + device + }); + } + + /** + * Sets the video input device to the one with the id that is passed. + * + * @param {string} id - The id of the new device. + * @returns {Promise} + */ + _setVideoInputDevice(id) { + return this._setDevice({ + id, + kind: 'videoinput' + }); + } + + /** + * Updates the available devices. + * + * @returns {void} + */ + _updateAvailableDevices() { + this._getAvailableDevices().then(devices => + this._changeDialogProps({ availableDevices: devices }) + ); + } +} diff --git a/react/features/device-selection/actionTypes.js b/react/features/device-selection/actionTypes.js new file mode 100644 index 0000000000..901afdb2de --- /dev/null +++ b/react/features/device-selection/actionTypes.js @@ -0,0 +1,10 @@ +/** + * The type of Redux action which Sets information about device selection popup. + * + * {{ + * type: SET_DEVICE_SELECTION_POPUP_DATA, + * popupDialogData: Object + * }} + */ +export const SET_DEVICE_SELECTION_POPUP_DATA + = Symbol('SET_DEVICE_SELECTION_POPUP_DATA'); diff --git a/react/features/device-selection/actions.js b/react/features/device-selection/actions.js index f08a6025c3..f5b922591f 100644 --- a/react/features/device-selection/actions.js +++ b/react/features/device-selection/actions.js @@ -1,8 +1,20 @@ -/* globals APP */ +/* globals APP, interfaceConfig */ import { openDialog } from '../base/dialog'; import JitsiMeetJS from '../base/lib-jitsi-meet'; +import { API_ID } from '../../../modules/API/constants'; +import { + setAudioInputDevice, + setAudioOutputDevice, + setVideoInputDevice +} from '../base/devices'; +import { i18next } from '../base/i18n'; +import { + PostMessageTransportBackend, + Transport +} from '../../../modules/transport'; +import { SET_DEVICE_SELECTION_POPUP_DATA } from './actionTypes'; import { DeviceSelectionDialog } from './components'; /** @@ -13,6 +25,21 @@ import { DeviceSelectionDialog } from './components'; */ export function openDeviceSelectionDialog() { return dispatch => { + if (interfaceConfig.filmStripOnly) { + dispatch(_openDeviceSelectionDialogInPopup()); + } else { + dispatch(_openDeviceSelectionDialogHere()); + } + }; +} + +/** + * Opens the DeviceSelectionDialog in the same window. + * + * @returns {Function} + */ +function _openDeviceSelectionDialogHere() { + return dispatch => JitsiMeetJS.mediaDevices.isDeviceListAvailable() .then(isDeviceListAvailable => { dispatch(openDialog(DeviceSelectionDialog, { @@ -33,5 +60,152 @@ export function openDeviceSelectionDialog() { .isDeviceChangeAvailable('output') })); }); +} + +/** + * Opens a popup window with the device selection dialog in it. + * + * @returns {Function} + */ +function _openDeviceSelectionDialogInPopup() { + return (dispatch, getState) => { + const { popupDialogData } = getState()['features/device-selection']; + + if (popupDialogData) { + popupDialogData.popup.focus(); + + return; + } + + // API_ID will always be defined because the iframe api is enabled + const scope = `dialog_${API_ID}`; + const url = `static/deviceSelectionPopup.html#scope=${ + encodeURIComponent(JSON.stringify(scope))}`; + const popup + = window.open( + url, + 'device-selection-popup', + 'toolbar=no,scrollbars=no,resizable=no,width=720,height=458'); + + popup.addEventListener('DOMContentLoaded', () => { + popup.init(i18next); + }); + + const transport = new Transport({ + backend: new PostMessageTransportBackend({ + postisOptions: { + scope, + window: popup + } + }) + }); + + transport.on('request', + _processRequest.bind(undefined, dispatch, getState)); + transport.on('event', event => { + if (event.type === 'devices-dialog' && event.name === 'close') { + popup.close(); + transport.dispose(); + dispatch(_setDeviceSelectionPopupData()); + + return true; + } + + return false; + }); + + dispatch(_setDeviceSelectionPopupData({ + popup, + transport + })); + }; +} + +/** + * Processes device requests from external applications. + * + * @param {Dispatch} dispatch - The redux {@code dispatch} function. + * @param {Function} getState - The redux function that gets/retrieves the redux + * state. + * @param {Object} request - The request to be processed. + * @param {Function} responseCallback - The callback that will send the + * response. + * @returns {boolean} + */ // eslint-disable-next-line max-params +function _processRequest(dispatch, getState, request, responseCallback) { + if (request.type === 'devices') { + switch (request.name) { + case 'isDeviceListAvailable': + JitsiMeetJS.mediaDevices.isDeviceListAvailable() + .then(isDeviceListAvailable => + responseCallback(isDeviceListAvailable)) + .catch(e => responseCallback(null, e)); + break; + case 'isDeviceChangeAvailable': + responseCallback( + JitsiMeetJS.mediaDevices.isDeviceChangeAvailable()); + break; + case 'isMultipleAudioInputSupported': + responseCallback(JitsiMeetJS.isMultipleAudioInputSupported()); + break; + case 'getCurrentDevices': + responseCallback({ + audioInput: APP.settings.getMicDeviceId(), + audioOutput: APP.settings.getAudioOutputDeviceId(), + videoInput: APP.settings.getCameraDeviceId() + }); + break; + case 'getAvailableDevices': + responseCallback(getState()['features/base/devices']); + break; + case 'setDevice': { + let action; + const { device } = request; + + switch (device.kind) { + case 'audioinput': + action = setAudioInputDevice; + break; + case 'audiooutput': + action = setAudioOutputDevice; + break; + case 'videoinput': + action = setVideoInputDevice; + break; + default: + + } + dispatch(action(device.id)); + responseCallback(true); + break; + } + default: + + return false; + } + + return true; + } + + return false; +} + +/** + * Sets information about device selection popup in the store. + * + * @param {Object} popupDialogData - Information about the popup. + * @param {Object} popupDialog.popup - The popup object returned from + * window.open. + * @param {Object} popupDialogData.transport - The transport instance used for + * communication with the popup window. + * @returns {{ + * type: SET_DEVICE_SELECTION_POPUP_DATA, + * popupDialogData: Object + * }} + */ +function _setDeviceSelectionPopupData(popupDialogData) { + return { + type: SET_DEVICE_SELECTION_POPUP_DATA, + popupDialogData }; } diff --git a/react/features/device-selection/components/AudioInputPreview.js b/react/features/device-selection/components/AudioInputPreview.js index a99fefbaf9..1ab5675dfa 100644 --- a/react/features/device-selection/components/AudioInputPreview.js +++ b/react/features/device-selection/components/AudioInputPreview.js @@ -53,7 +53,7 @@ class AudioInputPreview extends PureComponent { */ componentWillReceiveProps(nextProps) { this._listenForAudioUpdates(nextProps.track); - this._updateAudioLevel(0); + this._updateAudioLevel(undefined, 0); } /** diff --git a/react/features/device-selection/components/DeviceSelectionDialog.js b/react/features/device-selection/components/DeviceSelectionDialog.js index ee1f7a8d12..d7cb8eae24 100644 --- a/react/features/device-selection/components/DeviceSelectionDialog.js +++ b/react/features/device-selection/components/DeviceSelectionDialog.js @@ -6,17 +6,9 @@ import { setAudioOutputDevice, setVideoInputDevice } from '../../base/devices'; -import { - Dialog, - hideDialog -} from '../../base/dialog'; -import { translate } from '../../base/i18n'; -import { createLocalTrack } from '../../base/lib-jitsi-meet'; +import { hideDialog } from '../../base/dialog'; -import AudioInputPreview from './AudioInputPreview'; -import AudioOutputPreview from './AudioOutputPreview'; -import DeviceSelector from './DeviceSelector'; -import VideoInputPreview from './VideoInputPreview'; +import DeviceSelectionDialogBase from './DeviceSelectionDialogBase'; /** * React component for previewing and selecting new audio and video sources. @@ -96,417 +88,58 @@ class DeviceSelectionDialog extends Component { * 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 + hideAudioOutputSelect: React.PropTypes.bool }; - /** - * 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); - - const { _availableDevices } = this.props; - - this.state = { - // JitsiLocalTrack to use for live previewing of audio input. - previewAudioTrack: null, - - // JitsiLocalTrack to use for live previewing of video input. - previewVideoTrack: 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 - // 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; - - // Bind event handlers so they are only bound once for every instance. - this._closeModal = this._closeModal.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); - } - - /** - * 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 - */ - 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._renderAudioInputPreview() } -
-
-
- { this._renderSelectors() } -
- { this._renderAudioOutputPreview() } -
-
-
- ); - } + const { + currentAudioInputId, + currentAudioOutputId, + currentVideoInputId, + disableAudioInputChange, + disableDeviceChange, + dispatch, + hasAudioPermission, + hasVideoPermission, + hideAudioInputPreview, + hideAudioOutputSelect + } = this.props; - /** - * 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() { - return Promise.all([ - this._disposeVideoPreview(), - this._disposeAudioPreview() - ]); - } + const props = { + availableDevices: this.props._availableDevices, + closeModal: () => dispatch(hideDialog()), + currentAudioInputId, + currentAudioOutputId, + currentVideoInputId, + disableAudioInputChange, + disableDeviceChange, + hasAudioPermission, + hasVideoPermission, + hideAudioInputPreview, + hideAudioOutputSelect, + setAudioInputDevice: id => { + dispatch(setAudioInputDevice(id)); - /** - * 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(); - } - - /** - * Disposes preview tracks and signals to close DeviceSelectionDialog. - * - * @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; - } - - /** - * 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. - * - * @private - * @returns {boolean} Returns false to prevent closure until cleanup is - * complete. - */ - _onSubmit() { - if (this._isClosing) { - return false; - } - - this._isClosing = true; - - const deviceChangePromises = this._attemptPreviewTrackCleanup() - .then(() => { - if (this.state.selectedVideoInputId - !== this.props.currentVideoInputId) { - this.props.dispatch( - setVideoInputDevice(this.state.selectedVideoInputId)); - } - - if (this.state.selectedAudioInputId - !== this.props.currentAudioInputId) { - this.props.dispatch( - setAudioInputDevice(this.state.selectedAudioInputId)); - } - - if (this.state.selectedAudioOutputId - !== this.props.currentAudioOutputId) { - this.props.dispatch( - setAudioOutputDevice(this.state.selectedAudioOutputId)); - } - }); - - 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; - const configurations = [ - { - devices: _availableDevices.videoInput, - hasPermission: this.props.hasVideoPermission, - icon: 'icon-camera', - isDisabled: this.props.disableDeviceChange, - key: 'videoInput', - label: 'settings.selectCamera', - onSelect: this._updateVideoInput, - selectedDeviceId: this.state.selectedVideoInputId + return Promise.resolve(); }, - { - devices: _availableDevices.audioInput, - hasPermission: this.props.hasAudioPermission, - icon: 'icon-microphone', - isDisabled: this.props.disableAudioInputChange - || this.props.disableDeviceChange, - key: 'audioInput', - label: 'settings.selectMic', - onSelect: this._updateAudioInput, - selectedDeviceId: this.state.selectedAudioInputId + setAudioOutputDevice: id => { + dispatch(setAudioOutputDevice(id)); + + return Promise.resolve(); + }, + setVideoInputDevice: id => { + dispatch(setVideoInputDevice(id)); + + return Promise.resolve(); } - ]; + }; - if (!this.props.hideAudioOutputSelect) { - configurations.push({ - devices: _availableDevices.audioOutput, - hasPermission: this.props.hasAudioPermission - || this.props.hasVideoPermission, - icon: 'icon-volume', - isDisabled: this.props.disableDeviceChange, - key: 'audioOutput', - label: 'settings.selectAudioOutput', - 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') - }); - }); - }); + return ; } } @@ -526,4 +159,4 @@ function _mapStateToProps(state) { }; } -export default translate(connect(_mapStateToProps)(DeviceSelectionDialog)); +export default connect(_mapStateToProps)(DeviceSelectionDialog); diff --git a/react/features/device-selection/components/DeviceSelectionDialogBase.js b/react/features/device-selection/components/DeviceSelectionDialogBase.js new file mode 100644 index 0000000000..f12901243e --- /dev/null +++ b/react/features/device-selection/components/DeviceSelectionDialogBase.js @@ -0,0 +1,529 @@ +import React, { Component } from 'react'; + +import { StatelessDialog } 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 DeviceSelectionDialogBase extends Component { + /** + * DeviceSelectionDialogBase component's property types. + * + * @static + */ + static propTypes = { + /** + * All known audio and video devices split by type. This prop comes from + * the app state. + */ + availableDevices: React.PropTypes.object, + + /** + * Closes the dialog. + */ + closeModal: React.PropTypes.func, + + /** + * 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, + + /** + * Device id for the current video input device. This device will be set + * as the default video input device to preview. + */ + currentVideoInputId: React.PropTypes.string, + + /** + * 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, + + /** + * Disables dismissing the dialog when the blanket is clicked. Enabled + * by default. + */ + disableBlanketClickDismiss: React.PropTypes.bool, + + /** + * True if device changing is configured to be disallowed. Selectors + * will display as disabled. + */ + disableDeviceChange: React.PropTypes.bool, + + /** + * Whether or not a new audio input source can be selected. + */ + hasAudioPermission: React.PropTypes.bool, + + /** + * Whether or not a 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, + + /** + * Function that sets the audio input device. + */ + setAudioInputDevice: React.PropTypes.func, + + /** + * Function that sets the audio output device. + */ + setAudioOutputDevice: React.PropTypes.func, + + /** + * Function that sets the video input device. + */ + setVideoInputDevice: React.PropTypes.func, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + }; + + /** + * Initializes a new DeviceSelectionDialogBase instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props) { + super(props); + + const { availableDevices } = this.props; + + this.state = { + // JitsiLocalTrack to use for live previewing of audio input. + previewAudioTrack: null, + + // JitsiLocalTrack to use for live previewing of video input. + previewVideoTrack: 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 + // 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._setDevicesAndClose = this._setDevicesAndClose.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); + } + + /** + * 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 + */ + 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._renderAudioInputPreview() } +
+
+
+ { this._renderSelectors() } +
+ { this._renderAudioOutputPreview() } +
+
+
+ ); + } + + /** + * 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() { + return Promise.all([ + this._disposeVideoPreview(), + this._disposeAudioPreview() + ]); + } + + /** + * 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(); + } + + /** + * Disposes preview tracks and signals to + * close DeviceSelectionDialogBase. + * + * @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.props.closeModal) + .catch(this.props.closeModal); + + return false; + } + + /** + * 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. + * + * @private + * @returns {boolean} Returns false to prevent closure until cleanup is + * complete. + */ + _onSubmit() { + if (this._isClosing) { + return false; + } + + this._isClosing = true; + + this._attemptPreviewTrackCleanup() + .then(this._setDevicesAndClose, this._setDevicesAndClose); + + 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; + + const configurations = [ + { + devices: availableDevices.videoInput, + hasPermission: this.props.hasVideoPermission, + icon: 'icon-camera', + isDisabled: this.props.disableDeviceChange, + key: 'videoInput', + label: 'settings.selectCamera', + onSelect: this._updateVideoInput, + selectedDeviceId: this.state.selectedVideoInputId + }, + { + devices: availableDevices.audioInput, + hasPermission: this.props.hasAudioPermission, + icon: 'icon-microphone', + isDisabled: this.props.disableAudioInputChange + || this.props.disableDeviceChange, + key: 'audioInput', + label: 'settings.selectMic', + onSelect: this._updateAudioInput, + selectedDeviceId: this.state.selectedAudioInputId + } + ]; + + if (!this.props.hideAudioOutputSelect) { + configurations.push({ + devices: availableDevices.audioOutput, + hasPermission: this.props.hasAudioPermission + || this.props.hasVideoPermission, + icon: 'icon-volume', + isDisabled: this.props.disableDeviceChange, + key: 'audioOutput', + label: 'settings.selectAudioOutput', + onSelect: this._updateAudioOutput, + selectedDeviceId: this.state.selectedAudioOutputId + }); + } + + return configurations.map(this._renderSelector); + } + + /** + * Sets the selected devices and closes the dialog. + * + * @returns {void} + */ + _setDevicesAndClose() { + const { + setVideoInputDevice, + setAudioInputDevice, + setAudioOutputDevice, + closeModal + } = this.props; + + const promises = []; + + if (this.state.selectedVideoInputId + !== this.props.currentVideoInputId) { + promises.push(setVideoInputDevice(this.state.selectedVideoInputId)); + } + + if (this.state.selectedAudioInputId + !== this.props.currentAudioInputId) { + promises.push(setAudioInputDevice(this.state.selectedAudioInputId)); + } + + if (this.state.selectedAudioOutputId + !== this.props.currentAudioOutputId) { + promises.push( + setAudioOutputDevice(this.state.selectedAudioOutputId)); + } + Promise.all(promises).then(closeModal, closeModal); + } + + /** + * 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') + }); + }); + }); + } +} + +export default translate(DeviceSelectionDialogBase); diff --git a/react/features/device-selection/components/DeviceSelector.js b/react/features/device-selection/components/DeviceSelector.js index af389925dd..0d5517a392 100644 --- a/react/features/device-selection/components/DeviceSelector.js +++ b/react/features/device-selection/components/DeviceSelector.js @@ -83,7 +83,7 @@ class DeviceSelector extends Component { return this._renderNoPermission(); } - if (!this.props.devices.length) { + if (!this.props.devices || !this.props.devices.length) { return this._renderNoDevices(); } diff --git a/react/features/device-selection/components/index.js b/react/features/device-selection/components/index.js index 0ab79b3c11..67a0f39128 100644 --- a/react/features/device-selection/components/index.js +++ b/react/features/device-selection/components/index.js @@ -1 +1,3 @@ export { default as DeviceSelectionDialog } from './DeviceSelectionDialog'; +export { default as DeviceSelectionDialogBase } + from './DeviceSelectionDialogBase'; diff --git a/react/features/device-selection/index.js b/react/features/device-selection/index.js index 3c46ed49d1..a29aa08e02 100644 --- a/react/features/device-selection/index.js +++ b/react/features/device-selection/index.js @@ -1,2 +1,6 @@ export * from './actions'; +export * from './actionTypes'; export * from './components'; + +import './middleware'; +import './reducer'; diff --git a/react/features/device-selection/middleware.js b/react/features/device-selection/middleware.js new file mode 100644 index 0000000000..9494a65697 --- /dev/null +++ b/react/features/device-selection/middleware.js @@ -0,0 +1,24 @@ +import { UPDATE_DEVICE_LIST } from '../base/devices'; +import { MiddlewareRegistry } from '../base/redux'; + +/** + * Implements the middleware of the feature device-selection. + * + * @param {Store} store - Redux store. + * @returns {Function} + */ +// eslint-disable-next-line no-unused-vars +MiddlewareRegistry.register(store => next => action => { + const result = next(action); + + if (action.type === UPDATE_DEVICE_LIST) { + const { popupDialogData } + = store.getState()['features/device-selection']; + + if (popupDialogData) { + popupDialogData.transport.sendEvent({ name: 'deviceListChanged' }); + } + } + + return result; +}); diff --git a/react/features/device-selection/popup.js b/react/features/device-selection/popup.js new file mode 100644 index 0000000000..bfefe882eb --- /dev/null +++ b/react/features/device-selection/popup.js @@ -0,0 +1,13 @@ +import 'aui-css'; +import 'aui-experimental-css'; + +import DeviceSelectionPopup from './DeviceSelectionPopup'; + +let deviceSelectionPopup; + +window.init = function(i18next) { + deviceSelectionPopup = new DeviceSelectionPopup(i18next); +}; + +window.addEventListener('beforeunload', () => + deviceSelectionPopup.close()); diff --git a/react/features/device-selection/reducer.js b/react/features/device-selection/reducer.js new file mode 100644 index 0000000000..fce21fc4f6 --- /dev/null +++ b/react/features/device-selection/reducer.js @@ -0,0 +1,28 @@ +import { ReducerRegistry } from '../base/redux'; + +import { SET_DEVICE_SELECTION_POPUP_DATA } from './actionTypes'; + +/** + * Listen for actions which changes the state of the popup window for the device + * selection. + * + * @param {Object} state - The Redux state of the feature + * features/device-selection. + * @param {Object} action - Action object. + * @param {string} action.type - Type of action. + * @param {Object} action.popupDialogData - Object that stores the current + * Window object of the popup and the Transport instance. If no popup is shown + * the value will be undefined. + * @returns {Object} + */ +ReducerRegistry.register('features/device-selection', + (state = {}, action) => { + if (action.type === SET_DEVICE_SELECTION_POPUP_DATA) { + return { + ...state, + popupDialogData: action.popupDialogData + }; + } + + return state; + }); diff --git a/react/features/toolbox/components/ToolbarButton.native.js b/react/features/toolbox/components/ToolbarButton.native.js index 8e51cc1bb6..885f83ebd4 100644 --- a/react/features/toolbox/components/ToolbarButton.native.js +++ b/react/features/toolbox/components/ToolbarButton.native.js @@ -1,5 +1,6 @@ import React from 'react'; import { TouchableHighlight } from 'react-native'; +import { connect } from 'react-redux'; import { Icon } from '../../base/font-icons'; @@ -10,13 +11,20 @@ import AbstractToolbarButton from './AbstractToolbarButton'; * * @extends AbstractToolbarButton */ -export default class ToolbarButton extends AbstractToolbarButton { +class ToolbarButton extends AbstractToolbarButton { /** * ToolbarButton component's property types. * * @static */ - static propTypes = AbstractToolbarButton.propTypes + static propTypes = { + ...AbstractToolbarButton.propTypes, + + /** + * Used to dispatch an action when the button is clicked. + */ + dispatch: React.PropTypes.func + }; /** * Renders the button of this Toolbar button. @@ -29,7 +37,13 @@ export default class ToolbarButton extends AbstractToolbarButton { _renderButton(children) { const props = {}; - 'onClick' in this.props && (props.onPress = this.props.onClick); + 'onClick' in this.props && (props.onPress = () => { + const action = this.props.onClick(event); + + if (action) { + this.props.dispatch(action); + } + }); 'style' in this.props && (props.style = this.props.style); 'underlayColor' in this.props && (props.underlayColor = this.props.underlayColor); @@ -45,3 +59,5 @@ export default class ToolbarButton extends AbstractToolbarButton { return super._renderIcon(Icon); } } + +export default connect()(ToolbarButton); diff --git a/react/features/toolbox/components/ToolbarButton.web.js b/react/features/toolbox/components/ToolbarButton.web.js index d05c9d11d4..7a3091221f 100644 --- a/react/features/toolbox/components/ToolbarButton.web.js +++ b/react/features/toolbox/components/ToolbarButton.web.js @@ -1,6 +1,7 @@ /* @flow */ import React from 'react'; +import { connect } from 'react-redux'; import { translate } from '../../base/i18n'; @@ -36,6 +37,11 @@ class ToolbarButton extends AbstractToolbarButton { */ button: React.PropTypes.object.isRequired, + /** + * Used to dispatch an action when the button is clicked. + */ + dispatch: React.PropTypes.func, + /** * Handler for component mount. */ @@ -151,7 +157,11 @@ class ToolbarButton extends AbstractToolbarButton { } = button; if (enabled && !unclickable && onClick) { - onClick(event); + const action = onClick(event); + + if (action) { + this.props.dispatch(action); + } } } @@ -228,4 +238,4 @@ class ToolbarButton extends AbstractToolbarButton { } } -export default translate(ToolbarButton); +export default translate(connect()(ToolbarButton)); diff --git a/react/features/toolbox/defaultToolbarButtons.js b/react/features/toolbox/defaultToolbarButtons.js index 019a385472..4e46d807e0 100644 --- a/react/features/toolbox/defaultToolbarButtons.js +++ b/react/features/toolbox/defaultToolbarButtons.js @@ -2,25 +2,26 @@ import React from 'react'; +import { openDeviceSelectionDialog } from '../device-selection'; +import { openDialOutDialog } from '../dial-out'; +import { openInviteDialog } from '../invite'; import UIEvents from '../../../service/UI/UIEvents'; -import { openInviteDialog } from '../invite'; -import { openDialOutDialog } from '../dial-out'; - declare var APP: Object; +declare var interfaceConfig: Object; declare var JitsiMeetJS: Object; /** * All toolbar buttons' descriptors. */ -export default { +const buttons: Object = { /** * The descriptor of the camera toolbar button. */ camera: { classNames: [ 'button', 'icon-camera' ], enabled: true, - filmstripOnlyEnabled: true, + isDisplayed: () => true, id: 'toolbar_button_camera', onClick() { if (APP.conference.videoMuted) { @@ -153,11 +154,32 @@ export default { id: 'toolbar_button_dial_out', onClick() { JitsiMeetJS.analytics.sendEvent('toolbar.sip.clicked'); - APP.store.dispatch(openDialOutDialog()); + + return openDialOutDialog(); }, tooltipKey: 'dialOut.dialOut' }, + /** + * The descriptor of the device selection toolbar button. + */ + fodeviceselection: { + classNames: [ 'button', 'icon-settings' ], + enabled: true, + isDisplayed() { + return interfaceConfig.filmStripOnly; + }, + id: 'toolbar_button_fodeviceselection', + onClick() { + JitsiMeetJS.analytics.sendEvent( + 'toolbar.fodeviceselection.toggled'); + + return openDeviceSelectionDialog(); + }, + sideContainerId: 'settings_container', + tooltipKey: 'toolbar.Settings' + }, + /** * The descriptor of the dialpad toolbar button. */ @@ -217,7 +239,7 @@ export default { hangup: { classNames: [ 'button', 'icon-hangup', 'button_hangup' ], enabled: true, - filmstripOnlyEnabled: true, + isDisplayed: () => true, id: 'toolbar_button_hangup', onClick() { JitsiMeetJS.analytics.sendEvent('toolbar.hangup'); @@ -235,7 +257,8 @@ export default { id: 'toolbar_button_link', onClick() { JitsiMeetJS.analytics.sendEvent('toolbar.invite.clicked'); - APP.store.dispatch(openInviteDialog()); + + return openInviteDialog(); }, tooltipKey: 'toolbar.invite' }, @@ -246,7 +269,7 @@ export default { microphone: { classNames: [ 'button', 'icon-microphone' ], enabled: true, - filmstripOnlyEnabled: true, + isDisplayed: () => true, id: 'toolbar_button_mute', onClick() { const sharedVideoManager = APP.UI.getSharedVideoManager(); @@ -386,3 +409,14 @@ export default { tooltipKey: 'toolbar.sharedvideo' } }; + + +Object.keys(buttons).forEach(name => { + const button = buttons[name]; + + if (!button.isDisplayed) { + button.isDisplayed = () => !interfaceConfig.filmStripOnly; + } +}); + +export default buttons; diff --git a/react/features/toolbox/functions.web.js b/react/features/toolbox/functions.web.js index 56cd3f8ed9..9269785b1e 100644 --- a/react/features/toolbox/functions.web.js +++ b/react/features/toolbox/functions.web.js @@ -64,7 +64,6 @@ export function getDefaultToolboxButtons(buttonHandlers: Object): Object { if (typeof interfaceConfig !== 'undefined' && interfaceConfig.TOOLBAR_BUTTONS) { - const { filmStripOnly } = interfaceConfig; toolbarButtons = interfaceConfig.TOOLBAR_BUTTONS.reduce( @@ -84,9 +83,9 @@ export function getDefaultToolboxButtons(buttonHandlers: Object): Object { }; } - // In filmstrip-only mode we only add a button if it's - // filmstrip-only enabled. - if (!filmStripOnly || button.filmstripOnlyEnabled) { + // If isDisplayed method is not defined, display the + // button only for non-filmstripOnly mode + if (button.isDisplayed()) { acc[place].set(buttonName, button); } } diff --git a/react/features/toolbox/reducer.js b/react/features/toolbox/reducer.js index 7c8c5b9ec9..475d48c010 100644 --- a/react/features/toolbox/reducer.js +++ b/react/features/toolbox/reducer.js @@ -15,6 +15,7 @@ import { SET_TOOLBOX_TIMEOUT_MS, SET_TOOLBOX_VISIBLE } from './actionTypes'; +import defaultToolbarButtons from './defaultToolbarButtons'; declare var interfaceConfig: Object; @@ -208,6 +209,15 @@ ReducerRegistry.register( * @returns {Object} */ function _setButton(state, { button, buttonName }): Object { + const buttonDefinition = defaultToolbarButtons[buttonName]; + + // We don't need to update if the button shouldn't be displayed + if (!buttonDefinition || !buttonDefinition.isDisplayed()) { + return { + ...state + }; + } + const { primaryToolbarButtons, secondaryToolbarButtons } = state; let selectedButton = primaryToolbarButtons.get(buttonName); let place = 'primaryToolbarButtons'; @@ -222,18 +232,6 @@ function _setButton(state, { button, buttonName }): Object { ...button }; - // In filmstrip-only mode we only show buttons if they're filmstrip-only - // enabled, so we don't need to update if this isn't the case. - // FIXME A reducer should be a pure function of the current state and the - // specified action so it should not use the global variable - // interfaceConfig. Anyway, we'll move interfaceConfig into the (redux) - // store so we'll surely revisit the source code bellow. - if (interfaceConfig.filmStripOnly && !selectedButton.filmstripOnlyEnabled) { - return { - ...state - }; - } - const updatedToolbar = state[place].set(buttonName, selectedButton); return { diff --git a/static/deviceSelectionPopup.html b/static/deviceSelectionPopup.html new file mode 100644 index 0000000000..ebce7cf5a4 --- /dev/null +++ b/static/deviceSelectionPopup.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + +
+ + diff --git a/webpack.config.js b/webpack.config.js index a81161f898..310a822d06 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -193,6 +193,16 @@ const configs = [ output: Object.assign({}, config.output, { library: 'JitsiMeetExternalAPI' }) + }), + + // The Webpack configuration to bundle popup_bundle.js (js file for the + // device selection popup dialog). + Object.assign({}, config, { + entry: { + 'device_selection_popup_bundle': + './react/features/device-selection/popup.js' + }, + output: config.output }) ];