diff --git a/conference.js b/conference.js index 8b025b474..134a3e0ad 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) { @@ -1622,7 +1643,6 @@ export default { }) .catch((err) => { APP.UI.showDeviceErrorDialog(null, err); - APP.UI.setSelectedCameraFromSettings(); }); } ); @@ -1644,7 +1664,6 @@ export default { }) .catch((err) => { APP.UI.showDeviceErrorDialog(err, null); - APP.UI.setSelectedMicFromSettings(); }); } ); @@ -1660,7 +1679,6 @@ export default { logger.warn('Failed to change audio output device. ' + 'Default or previously set audio output device ' + 'will be used instead.', err); - APP.UI.setSelectedAudioOutputFromSettings(); }); } ); @@ -1756,8 +1774,8 @@ export default { } mediaDeviceHelper.setCurrentMediaDevices(devices); - APP.UI.onAvailableDevicesChanged(devices); + APP.store.dispatch(updateDeviceList(devices)); }); this.deviceChangeListener = (devices) => diff --git a/css/_device_settings_dialog.scss b/css/_device_settings_dialog.scss deleted file mode 100644 index 78862f5d4..000000000 --- a/css/_device_settings_dialog.scss +++ /dev/null @@ -1,31 +0,0 @@ -.settingsContent { - display: flex; - display: -webkit-flex; - - #localVideoPreview { - width: 50%; - align-self: baseline; - } - - .deviceSelection { - display: flex; - display: -webkit-flex; - -webkit-flex: 1; - flex: 1; - flex-direction: column; - flex-wrap: nowrap; - justify-content: flex-start; - align-items: left; - margin-left: 10px; - - .device { - display: flex; - margin-bottom: 5px; - - select { - flex: 1; - margin_right: 5px; - } - } - } -} \ No newline at end of file diff --git a/css/_side_toolbar_container.scss b/css/_side_toolbar_container.scss index 528ce39fb..362036c0b 100644 --- a/css/_side_toolbar_container.scss +++ b/css/_side_toolbar_container.scss @@ -113,6 +113,12 @@ text-align: center; } +#deviceOptionsWrapper { + button { + float: none; + } +} + /** * Profile */ diff --git a/css/main.scss b/css/main.scss index dc1cafff2..bb77dedda 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'; @@ -54,7 +55,6 @@ @import 'welcome_page'; @import 'toolbars'; @import 'side_toolbar_container'; -@import 'device_settings_dialog'; @import 'jquery.contextMenu'; @import 'keyboard-shortcuts'; @import 'redirect_page'; 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 23b44a331..483b5d4e0 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"; @@ -1080,29 +1084,7 @@ UI.onLocalRaiseHandChanged = function (isRaisedHand) { * @param {object[]} devices new list of available devices */ UI.onAvailableDevicesChanged = function (devices) { - SettingsMenu.changeDevicesList(devices); -}; - -/** - * Sets microphone's element to select camera ID from settings. - */ -UI.setSelectedCameraFromSettings = function () { - SettingsMenu.setSelectedCameraFromSettings(); -}; - -/** - * Sets audio outputs's - -
- - -
-
- - +
@@ -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 + ); + } + + /** + * Invokes the passed in callback to notify of selection changes. + * + * @param {Object} selection - Event returned from Select. + * @private + * @returns {void} + */ + _onSelect(selection) { + this.props.onSelect(selection.item.value); + } + + /** + * Creates a Select Component that is disabled and has a placeholder + * indicating there are no devices to select. + * + * @private + * @returns {ReactElement} + */ + _renderNoDevices() { + return this._createSelector({ + isDisabled: true, + placeholder: 'settings.noDevice' + }); + } + + /** + * Creates a Select Component that is disabled and has a placeholder stating + * there is no permission to display the devices. + * + * @private + * @returns {ReactElement} + */ + _renderNoPermission() { + return this._createSelector({ + isDisabled: true, + placeholder: 'settings.noPermission' + }); + } +} + +export default translate(DeviceSelector); diff --git a/react/features/device-selection/components/VideoInputPreview.js b/react/features/device-selection/components/VideoInputPreview.js new file mode 100644 index 000000000..c9d495872 --- /dev/null +++ b/react/features/device-selection/components/VideoInputPreview.js @@ -0,0 +1,203 @@ +import React, { Component } from 'react'; + +import { translate } from '../../base/i18n'; + +const VIDEO_MUTE_CLASS = 'video-muted'; + +/** + * React component for displaying video. This component defers to lib-jitsi-meet + * logic for rendering the video. + * + * @extends Component + */ +class VideoInputPreview extends Component { + /** + * VideoInputPreview component's property types. + * + * @static + */ + static propTypes = { + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func, + + /* + * The JitsiLocalTrack to display. + */ + track: React.PropTypes.object + } + + /** + * Initializes a new VideoInputPreview instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props) { + super(props); + + this._rootElement = null; + this._videoElement = null; + + this._setRootElement = this._setRootElement.bind(this); + this._setVideoElement = this._setVideoElement.bind(this); + } + + /** + * Invokes the library for rendering the video on initial display. + * + * @inheritdoc + * @returns {void} + */ + componentDidMount() { + this._attachTrack(this.props.track); + } + + /** + * Remove any existing associations between the current previewed track and + * the component's video element. + * + * @inheritdoc + * @returns {void} + */ + componentWillUnmount() { + this._detachTrack(this.props.track); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( +
+
+ ); + } + + /** + * Only update when the deviceId has changed. This component is somewhat + * black-boxed from React's rendering so lib-jitsi-meet can instead handle + * updating of the video preview, which takes browser differences into + * consideration. For example, temasys's video object must be visible to + * update the displayed track, but React's re-rendering could potentially + * remove the video object from the page. + * + * @inheritdoc + * @returns {void} + */ + shouldComponentUpdate(nextProps) { + if (nextProps.track !== this.props.track) { + this._detachTrack(this.props.track); + this._attachTrack(nextProps.track); + } + + return false; + } + + /** + * Calls into the passed in track to associate the track with the + * component's video element and render video. Also sets the instance + * variable for the video element as the element the track attached to, + * which could be an Object if on a temasys supported browser. + * + * @param {JitsiLocalTrack} track - The library's track model which will be + * displayed. + * @private + * @returns {void} + */ + _attachTrack(track) { + if (!track) { + return; + } + + // Do not attempt to display a preview if the track is muted, as the + // library will simply return a falsy value for the element anyway. + if (track.isMuted()) { + this._showMuteOverlay(true); + } else { + this._showMuteOverlay(false); + + const updatedVideoElement = track.attach(this._videoElement); + + this._setVideoElement(updatedVideoElement); + } + } + + /** + * Removes the association to the component's video element from the passed + * in JitsiLocalTrack to stop the track from rendering. With temasys, the + * video element must still be visible for detaching to complete. + * + * @param {JitsiLocalTrack} track - The library's track model which needs + * to stop previewing in the video element. + * @private + * @returns {void} + */ + _detachTrack(track) { + // Detach the video element from the track only if it has already + // been attached. This accounts for a special case with temasys + // where if detach is being called before attach, the video + // element is converted to Object without updating this + // component's reference to the video element. + if (this._videoElement + && track + && track.containers.includes(this._videoElement)) { + track.detach(this._videoElement); + } + } + + /** + * Sets the component's root element. + * + * @param {Object} element - The highest DOM element in the component. + * @private + * @returns {void} + */ + _setRootElement(element) { + this._rootElement = element; + } + + /** + * Sets an instance variable for the component's video element so it can be + * referenced later for attaching and detaching a JitsiLocalTrack. + * + * @param {Object} element - DOM element for the component's video display. + * @private + * @returns {void} + */ + _setVideoElement(element) { + this._videoElement = element; + } + + /** + * Adds or removes a class to the component's parent node to indicate mute + * status. + * + * @param {boolean} shouldShow - True if the mute class should be added and + * false if the class should be removed. + * @private + * @returns {void} + */ + _showMuteOverlay(shouldShow) { + if (shouldShow) { + this._rootElement.classList.add(VIDEO_MUTE_CLASS); + } else { + this._rootElement.classList.remove(VIDEO_MUTE_CLASS); + } + } +} + +export default translate(VideoInputPreview); diff --git a/react/features/device-selection/components/index.js b/react/features/device-selection/components/index.js new file mode 100644 index 000000000..0ab79b3c1 --- /dev/null +++ b/react/features/device-selection/components/index.js @@ -0,0 +1 @@ +export { default as DeviceSelectionDialog } from './DeviceSelectionDialog'; diff --git a/react/features/device-selection/index.js b/react/features/device-selection/index.js new file mode 100644 index 000000000..07635cbbc --- /dev/null +++ b/react/features/device-selection/index.js @@ -0,0 +1 @@ +export * from './components';