From 91683d663abc8c9083488a57a08a81de5c74f2ff Mon Sep 17 00:00:00 2001 From: robertpin Date: Wed, 1 Mar 2023 17:14:45 +0200 Subject: [PATCH] feat(device-selection) Separate Devices into Audio and Video in Settings Create separate tabs for Audio and Video in the Settings Dialog Move some settings from the More tab to Audio/ Video tab Implement redesign Convert some files to TS Move some styles from SCSS to JSS Enable device selection on welcome page --- css/main.scss | 1 - .../device-selection/_device-selection.scss | 148 ------ lang/main.json | 7 +- .../base/ui/components/web/DialogWithTabs.tsx | 12 +- .../base/ui/components/web/Select.tsx | 2 +- .../features/device-selection/actions.web.ts | 64 ++- .../components/AudioDevicesSelection.web.tsx | 387 ++++++++++++++++ .../components/AudioInputPreview.js | 150 ------ .../components/AudioInputPreview.web.tsx | 103 +++++ ...tPreview.js => AudioOutputPreview.web.tsx} | 61 ++- .../components/DeviceHidContainer.web.tsx | 26 +- .../components/DeviceSelection.js | 429 ------------------ ...Selector.web.js => DeviceSelector.web.tsx} | 132 +++--- .../components/VideoDeviceSelection.web.tsx | 368 +++++++++++++++ .../components/VideoInputPreview.js | 58 --- .../components/VideoInputPreview.web.tsx | 73 +++ .../device-selection/components/index.js | 4 - .../device-selection/functions.web.ts | 91 +++- react/features/screen-share/actions.native.ts | 1 - react/features/settings/actions.ts | 7 - .../settings/components/web/MoreTab.tsx | 58 --- .../settings/components/web/SettingsButton.js | 2 +- .../components/web/SettingsDialog.tsx | 74 +-- react/features/settings/constants.ts | 5 +- react/features/settings/functions.any.ts | 5 - 25 files changed, 1219 insertions(+), 1049 deletions(-) delete mode 100644 css/modals/device-selection/_device-selection.scss create mode 100644 react/features/device-selection/components/AudioDevicesSelection.web.tsx delete mode 100644 react/features/device-selection/components/AudioInputPreview.js create mode 100644 react/features/device-selection/components/AudioInputPreview.web.tsx rename react/features/device-selection/components/{AudioOutputPreview.js => AudioOutputPreview.web.tsx} (71%) delete mode 100644 react/features/device-selection/components/DeviceSelection.js rename react/features/device-selection/components/{DeviceSelector.web.js => DeviceSelector.web.tsx} (71%) create mode 100644 react/features/device-selection/components/VideoDeviceSelection.web.tsx delete mode 100644 react/features/device-selection/components/VideoInputPreview.js create mode 100644 react/features/device-selection/components/VideoInputPreview.web.tsx delete mode 100644 react/features/device-selection/components/index.js delete mode 100644 react/features/screen-share/actions.native.ts diff --git a/css/main.scss b/css/main.scss index da90c9648..3a3bf7570 100644 --- a/css/main.scss +++ b/css/main.scss @@ -33,7 +33,6 @@ $flagsImagePath: "../images/"; @import 'reload_overlay/reload_overlay'; @import 'mini_toolbox'; @import 'modals/desktop-picker/desktop-picker'; -@import 'modals/device-selection/device-selection'; @import 'modals/dialog'; @import 'modals/embed-meeting/embed-meeting'; @import 'modals/feedback/feedback'; diff --git a/css/modals/device-selection/_device-selection.scss b/css/modals/device-selection/_device-selection.scss deleted file mode 100644 index a7d48c042..000000000 --- a/css/modals/device-selection/_device-selection.scss +++ /dev/null @@ -1,148 +0,0 @@ -.device-selection { - .device-selectors { - font-size: 14px; - - > div { - display: block; - margin-bottom: 4px; - } - - .device-selector-icon { - align-self: center; - color: inherit; - font-size: 20px; - margin-left: 3px; - } - - .device-selector-label { - margin-bottom: 1px; - } - - /* device-selector-trigger stylings attempt to mimic AtlasKit button */ - .device-selector-trigger { - background-color: #0E1624; - border: 1px solid #455166; - border-radius: 5px; - display: flex; - height: 2.3em; - justify-content: space-between; - line-height: 2.3em; - overflow: hidden; - padding: 0 8px; - } - .device-selector-trigger-disabled { - .device-selector-trigger { - color: #a5adba; - cursor: default; - } - } - - .device-selector-trigger-text { - overflow: hidden; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; - } - } - - .device-selection-column { - box-sizing: border-box; - display: inline-block; - vertical-align: top; - - &.column-selectors { - margin-left: 15px; - width: 45%; - } - - &.column-video { - width: 50%; - } - } - - .device-selection-video-container { - border-radius: 3px; - margin-bottom: 5px; - - .video-input-preview { - margin-top: 2px; - position: relative; - - > video { - border-radius: 3px; - } - - .video-input-preview-error { - color: $participantNameColor; - display: none; - left: 0; - position: absolute; - right: 0; - text-align: center; - top: 50%; - } - - &.video-preview-has-error { - background: black; - - .video-input-preview-error { - display: block; - } - } - - .video-input-preview-display { - height: auto; - overflow: hidden; - width: 100%; - } - } - } - - .audio-output-preview { - font-size: 14px; - - a { - color: #6FB1EA; - cursor: pointer; - text-decoration: none; - } - - a:hover { - color: #B3D4FF; - } - } - - .audio-input-preview { - background: #1B2638; - border-radius: 5px; - height: 8px; - - .audio-input-preview-level { - background: #75B1FF; - 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; - } - } -} - -.device-selection.video-hidden { - display: flex; - flex-direction: column; - width: 100%; - - .column-selectors { - width: 100%; - margin-left: 0; - } - - .column-video { - order: 1; - width: 100%; - margin-top: 8px; - } -} diff --git a/lang/main.json b/lang/main.json index 7c60c96b6..f9e3e520d 100644 --- a/lang/main.json +++ b/lang/main.json @@ -220,7 +220,7 @@ "noPermission": "Permission not granted", "previewUnavailable": "Preview unavailable", "selectADevice": "Select a device", - "testAudio": "Play a test sound" + "testAudio": "Test" }, "dialIn": { "screenTitle": "Dial-in summary" @@ -971,6 +971,7 @@ "title": "Security Options" }, "settings": { + "audio": "Audio", "buttonLabel": "Settings", "calendar": { "about": "The {{appName}} calendar integration is used to securely access your calendar so it can read upcoming events.", @@ -1012,7 +1013,8 @@ "startReactionsMuted": "Mute reaction sounds for everyone", "startVideoMuted": "Everyone starts hidden", "talkWhileMuted": "Talk while muted", - "title": "Settings" + "title": "Settings", + "video": "Video" }, "settingsView": { "advanced": "Advanced", @@ -1171,6 +1173,7 @@ "download": "Download our apps", "e2ee": "End-to-End Encryption", "embedMeeting": "Embed meeting", + "enableNoiseSuppression": "Enable noise suppression", "endConference": "End meeting for all", "enterFullScreen": "View full screen", "enterTileView": "Enter tile view", diff --git a/react/features/base/ui/components/web/DialogWithTabs.tsx b/react/features/base/ui/components/web/DialogWithTabs.tsx index c798169d8..c12a79ac1 100644 --- a/react/features/base/ui/components/web/DialogWithTabs.tsx +++ b/react/features/base/ui/components/web/DialogWithTabs.tsx @@ -89,6 +89,10 @@ const useStyles = makeStyles()(theme => { } }, + closeButtonContainer: { + paddingBottom: theme.spacing(4) + }, + buttonContainer: { width: '100%', boxSizing: 'border-box', @@ -144,20 +148,20 @@ interface IObject { [key: string]: string | string[] | boolean | number | number[] | {} | undefined; } -export interface IDialogTab { +export interface IDialogTab

{ className?: string; component: ComponentType; icon: Function; labelKey: string; name: string; props?: IObject; - propsUpdateFunction?: (tabState: IObject, newProps: IObject) => IObject; + propsUpdateFunction?: (tabState: IObject, newProps: P) => P; submit?: Function; } interface IProps extends IBaseProps { defaultTab?: string; - tabs: IDialogTab[]; + tabs: IDialogTab[]; } const DialogWithTabs = ({ @@ -294,7 +298,7 @@ const DialogWithTabs = ({ )} {(!isMobile || selectedTab) && (

-
+
{isMobile && ( { width: '100%', ...withPixelLineHeight(theme.typography.bodyShortRegular), color: theme.palette.text01, - padding: '8px 16px', + padding: '10px 16px', paddingRight: '42px', border: 0, appearance: 'none', diff --git a/react/features/device-selection/actions.web.ts b/react/features/device-selection/actions.web.ts index e0658da67..6dd289975 100644 --- a/react/features/device-selection/actions.web.ts +++ b/react/features/device-selection/actions.web.ts @@ -7,31 +7,23 @@ import { } from '../base/devices/actions'; import { getDeviceLabelById, setAudioOutputDeviceId } from '../base/devices/functions'; import { updateSettings } from '../base/settings/actions'; +import { toggleNoiseSuppression } from '../noise-suppression/actions'; +import { setScreenshareFramerate } from '../screen-share/actions'; -import { getDeviceSelectionDialogProps } from './functions'; +import { getAudioDeviceSelectionDialogProps, getVideoDeviceSelectionDialogProps } from './functions'; import logger from './logger'; /** - * Submits the settings related to device selection. + * Submits the settings related to audio device selection. * * @param {Object} newState - The new settings. * @param {boolean} isDisplayedOnWelcomePage - Indicates whether the device selection dialog is displayed on the * welcome page or not. * @returns {Function} */ -export function submitDeviceSelectionTab(newState: any, isDisplayedOnWelcomePage: boolean) { +export function submitAudioDeviceSelectionTab(newState: any, isDisplayedOnWelcomePage: boolean) { return (dispatch: IStore['dispatch'], getState: IStore['getState']) => { - const currentState = getDeviceSelectionDialogProps(getState(), isDisplayedOnWelcomePage); - - if (newState.selectedVideoInputId && (newState.selectedVideoInputId !== currentState.selectedVideoInputId)) { - dispatch(updateSettings({ - userSelectedCameraDeviceId: newState.selectedVideoInputId, - userSelectedCameraDeviceLabel: - getDeviceLabelById(getState(), newState.selectedVideoInputId, 'videoInput') - })); - - dispatch(setVideoInputDevice(newState.selectedVideoInputId)); - } + const currentState = getAudioDeviceSelectionDialogProps(getState(), isDisplayedOnWelcomePage); if (newState.selectedAudioInputId && newState.selectedAudioInputId !== currentState.selectedAudioInputId) { dispatch(updateSettings({ @@ -44,8 +36,8 @@ export function submitDeviceSelectionTab(newState: any, isDisplayedOnWelcomePage } if (newState.selectedAudioOutputId - && newState.selectedAudioOutputId - !== currentState.selectedAudioOutputId) { + && newState.selectedAudioOutputId + !== currentState.selectedAudioOutputId) { sendAnalytics(createDeviceChangedEvent('audio', 'output')); setAudioOutputDeviceId( @@ -62,5 +54,45 @@ export function submitDeviceSelectionTab(newState: any, isDisplayedOnWelcomePage err); }); } + + if (newState.noiseSuppressionEnabled !== currentState.noiseSuppressionEnabled) { + dispatch(toggleNoiseSuppression()); + } + }; +} + +/** + * Submits the settings related to device selection. + * + * @param {Object} newState - The new settings. + * @param {boolean} isDisplayedOnWelcomePage - Indicates whether the device selection dialog is displayed on the + * welcome page or not. + * @returns {Function} + */ +export function submitVideoDeviceSelectionTab(newState: any, isDisplayedOnWelcomePage: boolean) { + return (dispatch: IStore['dispatch'], getState: IStore['getState']) => { + const currentState = getVideoDeviceSelectionDialogProps(getState(), isDisplayedOnWelcomePage); + + if (newState.selectedVideoInputId && (newState.selectedVideoInputId !== currentState.selectedVideoInputId)) { + dispatch(updateSettings({ + userSelectedCameraDeviceId: newState.selectedVideoInputId, + userSelectedCameraDeviceLabel: + getDeviceLabelById(getState(), newState.selectedVideoInputId, 'videoInput') + })); + + dispatch(setVideoInputDevice(newState.selectedVideoInputId)); + } + + if (newState.localFlipX !== currentState.localFlipX) { + dispatch(updateSettings({ + localFlipX: newState.localFlipX + })); + } + + if (newState.currentFramerate !== currentState.currentFramerate) { + const frameRate = parseInt(newState.currentFramerate, 10); + + dispatch(setScreenshareFramerate(frameRate)); + } }; } diff --git a/react/features/device-selection/components/AudioDevicesSelection.web.tsx b/react/features/device-selection/components/AudioDevicesSelection.web.tsx new file mode 100644 index 000000000..573605897 --- /dev/null +++ b/react/features/device-selection/components/AudioDevicesSelection.web.tsx @@ -0,0 +1,387 @@ +import { Theme } from '@mui/material'; +import { withStyles } from '@mui/styles'; +import React from 'react'; +import { WithTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; + +import { IReduxState, IStore } from '../../app/types'; +import { getAvailableDevices } from '../../base/devices/actions.web'; +import AbstractDialogTab, { + type IProps as AbstractDialogTabProps +} from '../../base/dialog/components/web/AbstractDialogTab'; +import { translate } from '../../base/i18n/functions'; +import { createLocalTrack } from '../../base/lib-jitsi-meet/functions.web'; +import Checkbox from '../../base/ui/components/web/Checkbox'; +import logger from '../logger'; + +import AudioInputPreview from './AudioInputPreview'; +import AudioOutputPreview from './AudioOutputPreview'; +import DeviceHidContainer from './DeviceHidContainer.web'; +import DeviceSelector from './DeviceSelector.web'; + +/** + * The type of the React {@code Component} props of {@link AudioDevicesSelection}. + */ +interface IProps extends AbstractDialogTabProps, WithTranslation { + + /** + * All known audio and video devices split by type. This prop comes from + * the app state. + */ + availableDevices: { + audioInput?: MediaDeviceInfo[]; + audioOutput?: MediaDeviceInfo[]; + }; + + /** + * CSS classes object. + */ + classes: any; + + /** + * 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: boolean; + + /** + * True if device changing is configured to be disallowed. Selectors + * will display as disabled. + */ + disableDeviceChange: boolean; + + /** + * Redux dispatch function. + */ + dispatch: IStore['dispatch']; + + /** + * Whether or not the audio permission was granted. + */ + hasAudioPermission: boolean; + + /** + * 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: boolean; + + /** + * If true, the button to play a test sound on the selected speaker will not be displayed. + * This needs to be hidden on browsers that do not support selecting an audio output device. + */ + hideAudioOutputPreview: boolean; + + /** + * Whether or not the audio output source selector should display. If + * true, the audio output selector and test audio link will not be + * rendered. + */ + hideAudioOutputSelect: boolean; + + /** + * Whether or not the hid device container should display. + */ + hideDeviceHIDContainer: boolean; + + /** + * Whether to hide noise suppression checkbox or not. + */ + hideNoiseSuppression: boolean; + + /** + * Wether noise suppression is on or not. + */ + noiseSuppressionEnabled: boolean; + + /** + * The id of the audio input device to preview. + */ + selectedAudioInputId: string; + + /** + * The id of the audio output device to preview. + */ + selectedAudioOutputId: string; +} + +/** + * The type of the React {@code Component} state of {@link AudioDevicesSelection}. + */ +type State = { + + /** + * The JitsiTrack to use for previewing audio input. + */ + previewAudioTrack?: any | null; +}; + +const styles = (theme: Theme) => { + return { + container: { + display: 'flex', + flexDirection: 'column' as const, + padding: '0 2px', + width: '100%' + }, + + inputContainer: { + marginBottom: theme.spacing(3) + }, + + outputContainer: { + margin: `${theme.spacing(5)} 0`, + display: 'flex', + alignItems: 'flex-end' + }, + + outputButton: { + marginLeft: theme.spacing(3) + }, + + noiseSuppressionContainer: { + marginBottom: theme.spacing(5) + } + }; +}; + +/** + * React {@code Component} for previewing audio and video input/output devices. + * + * @augments Component + */ +class AudioDevicesSelection extends AbstractDialogTab { + + /** + * Whether current component is mounted or not. + * + * In component did mount we start a Promise to create tracks and + * set the tracks in the state, if we unmount the component in the meanwhile + * tracks will be created and will never been disposed (dispose tracks is + * in componentWillUnmount). When tracks are created and component is + * unmounted we dispose the tracks. + */ + _unMounted: boolean; + + /** + * Initializes a new DeviceSelection instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props: IProps) { + super(props); + + this.state = { + previewAudioTrack: null + }; + this._unMounted = true; + } + + /** + * Generate the initial previews for audio input and video input. + * + * @inheritdoc + */ + componentDidMount() { + this._unMounted = false; + Promise.all([ + this._createAudioInputTrack(this.props.selectedAudioInputId) + ]) + .catch(err => logger.warn('Failed to initialize preview tracks', err)) + .then(() => { + this.props.dispatch(getAvailableDevices()); + }); + } + + /** + * Checks if audio / video permissions were granted. Updates audio input and + * video input previews. + * + * @param {Object} prevProps - Previous props this component received. + * @returns {void} + */ + componentDidUpdate(prevProps: IProps) { + if (prevProps.selectedAudioInputId + !== this.props.selectedAudioInputId) { + this._createAudioInputTrack(this.props.selectedAudioInputId); + } + } + + /** + * Ensure preview tracks are destroyed to prevent continued use. + * + * @inheritdoc + */ + componentWillUnmount() { + this._unMounted = true; + this._disposeAudioInputPreview(); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + */ + render() { + const { + classes, + hasAudioPermission, + hideAudioInputPreview, + hideAudioOutputPreview, + hideDeviceHIDContainer, + hideNoiseSuppression, + noiseSuppressionEnabled, + selectedAudioOutputId, + t + } = this.props; + const { audioInput, audioOutput } = this._getSelectors(); + + return ( +
+
+ {this._renderSelector(audioInput)} +
+ {!hideAudioInputPreview && hasAudioPermission + && } +
+ {this._renderSelector(audioOutput)} + {!hideAudioOutputPreview && hasAudioPermission + && } +
+ {!hideNoiseSuppression && ( +
+ super._onChange({ + noiseSuppressionEnabled: !noiseSuppressionEnabled + }) } /> +
+ )} + {!hideDeviceHIDContainer + && } +
+ ); + } + + /** + * Creates the JitsiTrack for the audio input preview. + * + * @param {string} deviceId - The id of audio input device to preview. + * @private + * @returns {void} + */ + _createAudioInputTrack(deviceId: string) { + const { hideAudioInputPreview } = this.props; + + if (hideAudioInputPreview) { + return; + } + + return this._disposeAudioInputPreview() + .then(() => createLocalTrack('audio', deviceId, 5000)) + .then(jitsiLocalTrack => { + if (this._unMounted) { + jitsiLocalTrack.dispose(); + + return; + } + + this.setState({ + previewAudioTrack: jitsiLocalTrack + }); + }) + .catch(() => { + this.setState({ + previewAudioTrack: null + }); + }); + } + + /** + * Utility function for disposing the current audio input preview. + * + * @private + * @returns {Promise} + */ + _disposeAudioInputPreview(): Promise { + return this.state.previewAudioTrack + ? this.state.previewAudioTrack.dispose() : Promise.resolve(); + } + + /** + * Creates a DeviceSelector instance based on the passed in configuration. + * + * @private + * @param {Object} deviceSelectorProps - The props for the DeviceSelector. + * @returns {ReactElement} + */ + _renderSelector(deviceSelectorProps: any) { + return deviceSelectorProps ? ( + + ) : null; + } + + /** + * Returns object configurations for audio input and output. + * + * @private + * @returns {Object} Configurations. + */ + _getSelectors() { + const { availableDevices, hasAudioPermission } = this.props; + + const audioInput = { + devices: availableDevices.audioInput, + hasPermission: hasAudioPermission, + icon: 'icon-microphone', + isDisabled: this.props.disableAudioInputChange || this.props.disableDeviceChange, + key: 'audioInput', + id: 'audioInput', + label: 'settings.selectMic', + onSelect: (selectedAudioInputId: string) => super._onChange({ selectedAudioInputId }), + selectedDeviceId: this.state.previewAudioTrack + ? this.state.previewAudioTrack.getDeviceId() : this.props.selectedAudioInputId + }; + let audioOutput; + + if (!this.props.hideAudioOutputSelect) { + audioOutput = { + devices: availableDevices.audioOutput, + hasPermission: hasAudioPermission, + icon: 'icon-speaker', + isDisabled: this.props.disableDeviceChange, + key: 'audioOutput', + id: 'audioOutput', + label: 'settings.selectAudioOutput', + onSelect: (selectedAudioOutputId: string) => super._onChange({ selectedAudioOutputId }), + selectedDeviceId: this.props.selectedAudioOutputId + }; + } + + return { audioInput, + audioOutput }; + } +} + +const mapStateToProps = (state: IReduxState) => { + return { + availableDevices: state['features/base/devices'].availableDevices ?? {} + }; +}; + +export default connect(mapStateToProps)(withStyles(styles)(translate(AudioDevicesSelection))); diff --git a/react/features/device-selection/components/AudioInputPreview.js b/react/features/device-selection/components/AudioInputPreview.js deleted file mode 100644 index 5c0df3ce2..000000000 --- a/react/features/device-selection/components/AudioInputPreview.js +++ /dev/null @@ -1,150 +0,0 @@ -/* @flow */ - -import React, { Component } from 'react'; - -import JitsiMeetJS from '../../base/lib-jitsi-meet/_'; - -const JitsiTrackEvents = JitsiMeetJS.events.track; - -/** - * The type of the React {@code Component} props of {@link AudioInputPreview}. - */ -type Props = { - - /** - * The JitsiLocalTrack to show an audio level meter for. - */ - track: Object -}; - -/** - * The type of the React {@code Component} props of {@link AudioInputPreview}. - */ -type State = { - - /** - * The current audio input level being received, from 0 to 1. - */ - audioLevel: number -}; - -/** - * React component for displaying a audio level meter for a JitsiLocalTrack. - */ -class AudioInputPreview extends Component { - /** - * 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: 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} - */ - componentDidUpdate(prevProps: Props) { - if (prevProps.track !== this.props.track) { - this._listenForAudioUpdates(this.props.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); - } - - _updateAudioLevel: (number) => void; - - /** - * 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/AudioInputPreview.web.tsx b/react/features/device-selection/components/AudioInputPreview.web.tsx new file mode 100644 index 000000000..ced99dedb --- /dev/null +++ b/react/features/device-selection/components/AudioInputPreview.web.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import { makeStyles } from 'tss-react/mui'; + +// @ts-ignore +import JitsiMeetJS from '../../base/lib-jitsi-meet/_.web'; + +const JitsiTrackEvents = JitsiMeetJS.events.track; + +/** + * The type of the React {@code Component} props of {@link AudioInputPreview}. + */ +interface IProps { + + /** + * The JitsiLocalTrack to show an audio level meter for. + */ + track: any; +} + +const useStyles = makeStyles()(theme => { + return { + container: { + display: 'flex' + }, + + section: { + flex: 1, + height: '4px', + borderRadius: '1px', + backgroundColor: theme.palette.ui04, + marginRight: theme.spacing(1), + + '&:last-of-type': { + marginRight: 0 + } + }, + + activeSection: { + backgroundColor: theme.palette.success01 + } + }; +}); + +const NO_OF_PREVIEW_SECTIONS = 11; + +const AudioInputPreview = (props: IProps) => { + const [ audioLevel, setAudioLevel ] = useState(0); + const { classes, cx } = useStyles(); + + /** + * Starts listening for audio level updates from the library. + * + * @param {JitsiLocalTrack} track - The track to listen to for audio level + * updates. + * @private + * @returns {void} + */ + function _listenForAudioUpdates(track: any) { + _stopListeningForAudioUpdates(); + + track?.on( + JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, + setAudioLevel); + } + + /** + * Stops listening to further updates from the current track. + * + * @private + * @returns {void} + */ + function _stopListeningForAudioUpdates() { + props.track?.off( + JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, + setAudioLevel); + } + + useEffect(() => { + _listenForAudioUpdates(props.track); + + return _stopListeningForAudioUpdates; + }, []); + + useEffect(() => { + _listenForAudioUpdates(props.track); + setAudioLevel(0); + }, [ props.track ]); + + const audioMeterFill = Math.ceil(Math.floor(audioLevel * 100) / (100 / NO_OF_PREVIEW_SECTIONS)); + + return ( +
+ {new Array(NO_OF_PREVIEW_SECTIONS).fill(0) + .map((_, idx) => + (
) + )} +
+ ); +}; + +export default AudioInputPreview; diff --git a/react/features/device-selection/components/AudioOutputPreview.js b/react/features/device-selection/components/AudioOutputPreview.web.tsx similarity index 71% rename from react/features/device-selection/components/AudioOutputPreview.js rename to react/features/device-selection/components/AudioOutputPreview.web.tsx index 73633d601..784765801 100644 --- a/react/features/device-selection/components/AudioOutputPreview.js +++ b/react/features/device-selection/components/AudioOutputPreview.web.tsx @@ -1,35 +1,38 @@ -/* @flow */ - import React, { Component } from 'react'; +import { WithTranslation } from 'react-i18next'; import { translate } from '../../base/i18n/functions'; -import Audio from '../../base/media/components/Audio'; +// eslint-disable-next-line lines-around-comment +// @ts-ignore +import Audio from '../../base/media/components/Audio.web'; +import Button from '../../base/ui/components/web/Button'; +import { BUTTON_TYPES } from '../../base/ui/constants.any'; const TEST_SOUND_PATH = 'sounds/ring.mp3'; /** * The type of the React {@code Component} props of {@link AudioOutputPreview}. */ -type Props = { +interface IProps extends WithTranslation { + + /** + * Button className. + */ + className?: string; /** * The device id of the audio output device to use. */ - deviceId: string, - - /** - * Invoked to obtain translated strings. - */ - t: Function -}; + deviceId: string; +} /** * React component for playing a test sound through a specified audio device. * * @augments Component */ -class AudioOutputPreview extends Component { - _audioElement: ?Object; +class AudioOutputPreview extends Component { + _audioElement: HTMLAudioElement | null; /** * Initializes a new AudioOutputPreview instance. @@ -37,7 +40,7 @@ class AudioOutputPreview extends Component { * @param {Object} props - The read-only React Component props with which * the new instance is to be initialized. */ - constructor(props: Props) { + constructor(props: IProps) { super(props); this._audioElement = null; @@ -66,24 +69,21 @@ class AudioOutputPreview extends Component { */ render() { return ( -
- +
+ ); } - _audioElementReady: (Object) => void; - /** * Sets the instance variable for the component's audio element so it can be * accessed directly. @@ -92,14 +92,12 @@ class AudioOutputPreview extends Component { * @private * @returns {void} */ - _audioElementReady(element: Object) { + _audioElementReady(element: HTMLAudioElement) { this._audioElement = element; this._setAudioSink(); } - _onClick: () => void; - /** * Plays a test sound. * @@ -107,12 +105,9 @@ class AudioOutputPreview extends Component { * @returns {void} */ _onClick() { - this._audioElement - && this._audioElement.play(); + this._audioElement?.play(); } - _onKeyPress: (Object) => void; - /** * KeyPress handler for accessibility. * @@ -120,7 +115,7 @@ class AudioOutputPreview extends Component { * * @returns {void} */ - _onKeyPress(e) { + _onKeyPress(e: React.KeyboardEvent) { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); this._onClick(); @@ -135,7 +130,7 @@ class AudioOutputPreview extends Component { */ _setAudioSink() { this._audioElement - && this.props.deviceId + && this.props.deviceId // @ts-ignore && this._audioElement.setSinkId(this.props.deviceId); } } diff --git a/react/features/device-selection/components/DeviceHidContainer.web.tsx b/react/features/device-selection/components/DeviceHidContainer.web.tsx index 839cb02c0..b151ea07a 100644 --- a/react/features/device-selection/components/DeviceHidContainer.web.tsx +++ b/react/features/device-selection/components/DeviceHidContainer.web.tsx @@ -5,33 +5,40 @@ import { makeStyles } from 'tss-react/mui'; import Icon from '../../base/icons/components/Icon'; import { IconTrash } from '../../base/icons/svg'; +import { withPixelLineHeight } from '../../base/styles/functions.web'; import Button from '../../base/ui/components/web/Button'; import { BUTTON_TYPES } from '../../base/ui/constants.any'; import { closeHidDevice, requestHidDevice } from '../../web-hid/actions'; import { getDeviceInfo, shouldRequestHIDDevice } from '../../web-hid/functions'; -const useStyles = makeStyles()(() => { +const useStyles = makeStyles()(theme => { return { callControlContainer: { - marginTop: '8px', - marginBottom: '16px', - fontSize: '14px', - '> label': { - display: 'block', - marginBottom: '20px' - } + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start' }, + + label: { + ...withPixelLineHeight(theme.typography.bodyShortRegular), + color: theme.palette.text01, + marginBottom: theme.spacing(2) + }, + deviceRow: { display: 'flex', justifyContent: 'space-between' }, + deleteDevice: { cursor: 'pointer', textAlign: 'center' }, + headerConnectedDevice: { fontWeight: 600 }, + hidContainer: { '> span': { marginLeft: '16px' @@ -66,7 +73,7 @@ function DeviceHidContainer() { className = { classes.callControlContainer } key = 'callControl'> @@ -77,7 +84,6 @@ function DeviceHidContainer() { key = 'request-control-btn' label = { t('deviceSelection.hid.pairDevice') } onClick = { onRequestControl } - size = 'small' type = { BUTTON_TYPES.SECONDARY } /> )} {!showRequestDeviceInfo && ( diff --git a/react/features/device-selection/components/DeviceSelection.js b/react/features/device-selection/components/DeviceSelection.js deleted file mode 100644 index 0f004680a..000000000 --- a/react/features/device-selection/components/DeviceSelection.js +++ /dev/null @@ -1,429 +0,0 @@ -// @flow - -import React from 'react'; - -import { getAvailableDevices } from '../../base/devices/actions.web'; -import AbstractDialogTab, { - type Props as AbstractDialogTabProps -} from '../../base/dialog/components/web/AbstractDialogTab'; -import { translate } from '../../base/i18n/functions'; -import { createLocalTrack } from '../../base/lib-jitsi-meet/functions'; -import logger from '../logger'; - -import AudioInputPreview from './AudioInputPreview'; -import AudioOutputPreview from './AudioOutputPreview'; -import DeviceHidContainer from './DeviceHidContainer.web'; -import DeviceSelector from './DeviceSelector'; -import VideoInputPreview from './VideoInputPreview'; - -/** - * The type of the React {@code Component} props of {@link DeviceSelection}. - */ -export type Props = { - ...$Exact, - - /** - * All known audio and video devices split by type. This prop comes from - * the app state. - */ - availableDevices: 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: boolean, - - /** - * True if device changing is configured to be disallowed. Selectors - * will display as disabled. - */ - disableDeviceChange: boolean, - - /** - * Whether video input dropdown should be enabled or not. - */ - disableVideoInputSelect: boolean, - - /** - * Whether or not the audio permission was granted. - */ - hasAudioPermission: boolean, - - /** - * Whether or not the audio permission was granted. - */ - hasVideoPermission: boolean, - - /** - * 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: boolean, - - /** - * If true, the button to play a test sound on the selected speaker will not be displayed. - * This needs to be hidden on browsers that do not support selecting an audio output device. - */ - hideAudioOutputPreview: boolean, - - /** - * Whether or not the audio output source selector should display. If - * true, the audio output selector and test audio link will not be - * rendered. - */ - hideAudioOutputSelect: boolean, - - /** - * Whether or not the hid device container should display. - */ - hideDeviceHIDContainer: boolean, - - /** - * Whether video input preview should be displayed or not. - * (In the case of iOS Safari). - */ - hideVideoInputPreview: boolean, - - /** - * The id of the audio input device to preview. - */ - selectedAudioInputId: string, - - /** - * The id of the audio output device to preview. - */ - selectedAudioOutputId: string, - - /** - * The id of the video input device to preview. - */ - selectedVideoInputId: string, - - /** - * Invoked to obtain translated strings. - */ - t: Function -}; - -/** - * The type of the React {@code Component} state of {@link DeviceSelection}. - */ -type State = { - - /** - * The JitsiTrack to use for previewing audio input. - */ - previewAudioTrack: ?Object, - - /** - * The JitsiTrack to use for previewing video input. - */ - previewVideoTrack: ?Object, - - /** - * The error message from trying to use a video input device. - */ - previewVideoTrackError: ?string -}; - -/** - * React {@code Component} for previewing audio and video input/output devices. - * - * @augments Component - */ -class DeviceSelection extends AbstractDialogTab { - - /** - * Whether current component is mounted or not. - * - * In component did mount we start a Promise to create tracks and - * set the tracks in the state, if we unmount the component in the meanwhile - * tracks will be created and will never been disposed (dispose tracks is - * in componentWillUnmount). When tracks are created and component is - * unmounted we dispose the tracks. - */ - _unMounted: boolean; - - /** - * Initializes a new DeviceSelection instance. - * - * @param {Object} props - The read-only React Component props with which - * the new instance is to be initialized. - */ - constructor(props: Props) { - super(props); - - this.state = { - previewAudioTrack: null, - previewVideoTrack: null, - previewVideoTrackError: null - }; - this._unMounted = true; - } - - /** - * Generate the initial previews for audio input and video input. - * - * @inheritdoc - */ - componentDidMount() { - this._unMounted = false; - Promise.all([ - this._createAudioInputTrack(this.props.selectedAudioInputId), - this._createVideoInputTrack(this.props.selectedVideoInputId) - ]) - .catch(err => logger.warn('Failed to initialize preview tracks', err)) - .then(() => getAvailableDevices()); - } - - /** - * Checks if audio / video permissions were granted. Updates audio input and - * video input previews. - * - * @param {Object} prevProps - Previous props this component received. - * @returns {void} - */ - componentDidUpdate(prevProps) { - if (prevProps.selectedAudioInputId - !== this.props.selectedAudioInputId) { - this._createAudioInputTrack(this.props.selectedAudioInputId); - } - - if (prevProps.selectedVideoInputId - !== this.props.selectedVideoInputId) { - this._createVideoInputTrack(this.props.selectedVideoInputId); - } - } - - /** - * Ensure preview tracks are destroyed to prevent continued use. - * - * @inheritdoc - */ - componentWillUnmount() { - this._unMounted = true; - this._disposeAudioInputPreview(); - this._disposeVideoInputPreview(); - } - - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - */ - render() { - const { - hideAudioInputPreview, - hideAudioOutputPreview, - hideDeviceHIDContainer, - hideVideoInputPreview, - selectedAudioOutputId - } = this.props; - - return ( -
-
- { !hideVideoInputPreview - &&
- -
- } - { !hideAudioInputPreview - && } -
-
-
- { this._renderSelectors() } -
- { !hideAudioOutputPreview - && } - { !hideDeviceHIDContainer - && } -
-
- ); - } - - /** - * Creates the JitiTrack for the audio input preview. - * - * @param {string} deviceId - The id of audio input device to preview. - * @private - * @returns {void} - */ - _createAudioInputTrack(deviceId) { - const { hideAudioInputPreview } = this.props; - - if (hideAudioInputPreview) { - return; - } - - return this._disposeAudioInputPreview() - .then(() => createLocalTrack('audio', deviceId, 5000)) - .then(jitsiLocalTrack => { - if (this._unMounted) { - jitsiLocalTrack.dispose(); - - return; - } - - this.setState({ - previewAudioTrack: jitsiLocalTrack - }); - }) - .catch(() => { - this.setState({ - previewAudioTrack: null - }); - }); - } - - /** - * Creates the JitiTrack for the video input preview. - * - * @param {string} deviceId - The id of video device to preview. - * @private - * @returns {void} - */ - _createVideoInputTrack(deviceId) { - const { hideVideoInputPreview } = this.props; - - if (hideVideoInputPreview) { - return; - } - - return this._disposeVideoInputPreview() - .then(() => createLocalTrack('video', deviceId, 5000)) - .then(jitsiLocalTrack => { - if (!jitsiLocalTrack) { - return Promise.reject(); - } - - if (this._unMounted) { - jitsiLocalTrack.dispose(); - - return; - } - - this.setState({ - previewVideoTrack: jitsiLocalTrack, - previewVideoTrackError: null - }); - }) - .catch(() => { - this.setState({ - previewVideoTrack: null, - previewVideoTrackError: - this.props.t('deviceSelection.previewUnavailable') - }); - }); - } - - /** - * Utility function for disposing the current audio input preview. - * - * @private - * @returns {Promise} - */ - _disposeAudioInputPreview(): Promise<*> { - return this.state.previewAudioTrack - ? this.state.previewAudioTrack.dispose() : Promise.resolve(); - } - - /** - * Utility function for disposing the current video input preview. - * - * @private - * @returns {Promise} - */ - _disposeVideoInputPreview(): Promise<*> { - return this.state.previewVideoTrack - ? this.state.previewVideoTrack.dispose() : Promise.resolve(); - } - - /** - * Creates a DeviceSelector instance based on the passed in configuration. - * - * @private - * @param {Object} deviceSelectorProps - The props for the DeviceSelector. - * @returns {ReactElement} - */ - _renderSelector(deviceSelectorProps) { - return ( -
- - -
- ); - } - - /** - * Creates DeviceSelector instances for video output, audio input, and audio - * output. - * - * @private - * @returns {Array} DeviceSelector instances. - */ - _renderSelectors() { - const { availableDevices, hasAudioPermission, hasVideoPermission } = this.props; - - const configurations = [ - { - devices: availableDevices.audioInput, - hasPermission: hasAudioPermission, - icon: 'icon-microphone', - isDisabled: this.props.disableAudioInputChange || this.props.disableDeviceChange, - key: 'audioInput', - id: 'audioInput', - label: 'settings.selectMic', - onSelect: selectedAudioInputId => super._onChange({ selectedAudioInputId }), - selectedDeviceId: this.state.previewAudioTrack - ? this.state.previewAudioTrack.getDeviceId() : this.props.selectedAudioInputId - }, - { - devices: availableDevices.videoInput, - hasPermission: hasVideoPermission, - icon: 'icon-camera', - isDisabled: this.props.disableVideoInputSelect || this.props.disableDeviceChange, - key: 'videoInput', - id: 'videoInput', - label: 'settings.selectCamera', - onSelect: selectedVideoInputId => super._onChange({ selectedVideoInputId }), - selectedDeviceId: this.state.previewVideoTrack - ? this.state.previewVideoTrack.getDeviceId() : this.props.selectedVideoInputId - } - ]; - - if (!this.props.hideAudioOutputSelect) { - configurations.push({ - devices: availableDevices.audioOutput, - hasPermission: hasAudioPermission || hasVideoPermission, - icon: 'icon-speaker', - isDisabled: this.props.disableDeviceChange, - key: 'audioOutput', - id: 'audioOutput', - label: 'settings.selectAudioOutput', - onSelect: selectedAudioOutputId => super._onChange({ selectedAudioOutputId }), - selectedDeviceId: this.props.selectedAudioOutputId - }); - } - - return configurations.map(config => this._renderSelector(config)); - } -} - -export default translate(DeviceSelection); diff --git a/react/features/device-selection/components/DeviceSelector.web.js b/react/features/device-selection/components/DeviceSelector.web.tsx similarity index 71% rename from react/features/device-selection/components/DeviceSelector.web.js rename to react/features/device-selection/components/DeviceSelector.web.tsx index b4e373687..b0f5fa9e4 100644 --- a/react/features/device-selection/components/DeviceSelector.web.js +++ b/react/features/device-selection/components/DeviceSelector.web.tsx @@ -1,58 +1,76 @@ -/* @flow */ +import { Theme } from '@mui/material'; +import { withStyles } from '@mui/styles'; import React, { Component } from 'react'; +import { WithTranslation } from 'react-i18next'; import { translate } from '../../base/i18n/functions'; +import { withPixelLineHeight } from '../../base/styles/functions.web'; import Select from '../../base/ui/components/web/Select'; /** * The type of the React {@code Component} props of {@link DeviceSelector}. */ -type Props = { +interface IProps extends WithTranslation { + + /** + * CSS classes object. + */ + classes: any; /** * MediaDeviceInfos used for display in the select element. */ - devices: Array, + devices: Array | undefined; /** * If false, will return a selector with no selection options. */ - hasPermission: boolean, + hasPermission: boolean; /** * CSS class for the icon to the left of the dropdown trigger. */ - icon: string, - - /** - * If true, will render the selector disabled with a default selection. - */ - isDisabled: boolean, - - /** - * The translation key to display as a menu label. - */ - label: string, - - /** - * The callback to invoke when a selection is made. - */ - onSelect: Function, - - /** - * The default device to display as selected. - */ - selectedDeviceId: string, - - /** - * Invoked to obtain translated strings. - */ - t: Function, + icon: string; /** * The id of the dropdown element. */ - id: string + id: string; + + /** + * If true, will render the selector disabled with a default selection. + */ + isDisabled: boolean; + + /** + * The translation key to display as a menu label. + */ + label: string; + + /** + * The callback to invoke when a selection is made. + */ + onSelect: Function; + + /** + * The default device to display as selected. + */ + selectedDeviceId: string; +} + +const styles = (theme: Theme) => { + return { + textSelector: { + width: '100%', + boxSizing: 'border-box', + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.uiBackground, + padding: '10px 16px', + textAlign: 'center', + ...withPixelLineHeight(theme.typography.bodyShortRegular), + border: `1px solid ${theme.palette.ui03}` + } + }; }; /** @@ -61,17 +79,18 @@ type Props = { * * @augments Component */ -class DeviceSelector extends Component { +class DeviceSelector extends Component { /** * 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) { + constructor(props: IProps) { super(props); this._onSelect = this._onSelect.bind(this); + this._createDropdown = this._createDropdown.bind(this); } /** @@ -111,25 +130,6 @@ class DeviceSelector extends Component { }); } - /** - * Creates a React Element for displaying the passed in text surrounded by - * two icons. The left icon is the icon class passed in through props and - * the right icon is AtlasKit ExpandIcon. - * - * @param {string} triggerText - The text to display within the element. - * @private - * @returns {ReactElement} - */ - _createDropdownTrigger(triggerText) { - return ( -
- - { triggerText } - -
- ); - } - /** * Creates a AKDropdownMenu Component using passed in props and options. If * the dropdown needs to be disabled, then only the AKDropdownMenu trigger @@ -146,32 +146,30 @@ class DeviceSelector extends Component { * @private * @returns {ReactElement} */ - _createDropdown(options) { + _createDropdown(options: { defaultSelected?: MediaDeviceInfo; isDisabled: boolean; + items?: Array<{ label: string; value: string; }>; placeholder: string; }) { const triggerText = (options.defaultSelected && (options.defaultSelected.label || options.defaultSelected.deviceId)) || options.placeholder; - const trigger = this._createDropdownTrigger(triggerText); + const { classes } = this.props; - if (options.isDisabled || !options.items.length) { + if (options.isDisabled || !options.items?.length) { return ( -
- { trigger } +
+ {triggerText}
); } return ( -
- ); } - _onSelect: (Object) => void; - /** * Invokes the passed in callback to notify of selection changes. * @@ -180,7 +178,7 @@ class DeviceSelector extends Component { * @private * @returns {void} */ - _onSelect(e) { + _onSelect(e: React.ChangeEvent) { const deviceId = e.target.value; if (this.props.selectedDeviceId !== deviceId) { @@ -217,4 +215,4 @@ class DeviceSelector extends Component { } } -export default translate(DeviceSelector); +export default withStyles(styles)(translate(DeviceSelector)); diff --git a/react/features/device-selection/components/VideoDeviceSelection.web.tsx b/react/features/device-selection/components/VideoDeviceSelection.web.tsx new file mode 100644 index 000000000..4e593682f --- /dev/null +++ b/react/features/device-selection/components/VideoDeviceSelection.web.tsx @@ -0,0 +1,368 @@ +import { Theme } from '@mui/material'; +import { withStyles } from '@mui/styles'; +import React from 'react'; +import { WithTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; + +import { IReduxState, IStore } from '../../app/types'; +import { getAvailableDevices } from '../../base/devices/actions.web'; +import AbstractDialogTab, { + type IProps as AbstractDialogTabProps +} from '../../base/dialog/components/web/AbstractDialogTab'; +import { translate } from '../../base/i18n/functions'; +import { createLocalTrack } from '../../base/lib-jitsi-meet/functions.web'; +import Checkbox from '../../base/ui/components/web/Checkbox'; +import Select from '../../base/ui/components/web/Select'; +import { SS_DEFAULT_FRAME_RATE } from '../../settings/constants'; +import logger from '../logger'; + +import DeviceSelector from './DeviceSelector.web'; +import VideoInputPreview from './VideoInputPreview'; + +/** + * The type of the React {@code Component} props of {@link VideoDeviceSelection}. + */ +export interface IProps extends AbstractDialogTabProps, WithTranslation { + + /** + * All known audio and video devices split by type. This prop comes from + * the app state. + */ + availableDevices: { videoInput?: MediaDeviceInfo[]; }; + + /** + * CSS classes object. + */ + classes: any; + + /** + * The currently selected desktop share frame rate in the frame rate select dropdown. + */ + currentFramerate: string; + + /** + * All available desktop capture frame rates. + */ + desktopShareFramerates: Array; + + /** + * True if device changing is configured to be disallowed. Selectors + * will display as disabled. + */ + disableDeviceChange: boolean; + + /** + * Whether video input dropdown should be enabled or not. + */ + disableVideoInputSelect: boolean; + + /** + * Redux dispatch. + */ + dispatch: IStore['dispatch']; + + /** + * Whether or not the audio permission was granted. + */ + hasVideoPermission: boolean; + + /** + * Whether to hide the additional settings or not. + */ + hideAdditionalSettings: boolean; + + /** + * Whether video input preview should be displayed or not. + * (In the case of iOS Safari). + */ + hideVideoInputPreview: boolean; + + /** + * Whether or not the local video is flipped. + */ + localFlipX: boolean; + + /** + * The id of the video input device to preview. + */ + selectedVideoInputId: string; +} + +/** + * The type of the React {@code Component} state of {@link VideoDeviceSelection}. + */ +type State = { + + /** + * The JitsiTrack to use for previewing video input. + */ + previewVideoTrack: any | null; + + /** + * The error message from trying to use a video input device. + */ + previewVideoTrackError: string | null; +}; + +const styles = (theme: Theme) => { + return { + container: { + display: 'flex', + flexDirection: 'column' as const, + padding: '0 2px', + width: '100%' + }, + + checkboxContainer: { + margin: `${theme.spacing(4)} 0` + } + }; +}; + +/** + * React {@code Component} for previewing audio and video input/output devices. + * + * @augments Component + */ +class VideoDeviceSelection extends AbstractDialogTab { + + /** + * Whether current component is mounted or not. + * + * In component did mount we start a Promise to create tracks and + * set the tracks in the state, if we unmount the component in the meanwhile + * tracks will be created and will never been disposed (dispose tracks is + * in componentWillUnmount). When tracks are created and component is + * unmounted we dispose the tracks. + */ + _unMounted: boolean; + + /** + * Initializes a new DeviceSelection instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props: IProps) { + super(props); + + this.state = { + previewVideoTrack: null, + previewVideoTrackError: null + }; + this._unMounted = true; + + this._onFramerateItemSelect = this._onFramerateItemSelect.bind(this); + } + + /** + * Generate the initial previews for audio input and video input. + * + * @inheritdoc + */ + componentDidMount() { + this._unMounted = false; + Promise.all([ + this._createVideoInputTrack(this.props.selectedVideoInputId) + ]) + .catch(err => logger.warn('Failed to initialize preview tracks', err)) + .then(() => { + this.props.dispatch(getAvailableDevices()); + }); + } + + /** + * Checks if audio / video permissions were granted. Updates audio input and + * video input previews. + * + * @param {Object} prevProps - Previous props this component received. + * @returns {void} + */ + componentDidUpdate(prevProps: IProps) { + + if (prevProps.selectedVideoInputId + !== this.props.selectedVideoInputId) { + this._createVideoInputTrack(this.props.selectedVideoInputId); + } + } + + /** + * Ensure preview tracks are destroyed to prevent continued use. + * + * @inheritdoc + */ + componentWillUnmount() { + this._unMounted = true; + this._disposeVideoInputPreview(); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + */ + render() { + const { + classes, + hideAdditionalSettings, + hideVideoInputPreview, + localFlipX, + t + } = this.props; + + return ( +
+ { !hideVideoInputPreview + && + } +
+ {this._renderVideoSelector()} +
+ {!hideAdditionalSettings && ( + <> +
+ super._onChange({ localFlipX: !localFlipX }) } /> +
+ {this._renderFramerateSelect()} + + )} +
+ ); + } + + /** + * Creates the JitsiTrack for the video input preview. + * + * @param {string} deviceId - The id of video device to preview. + * @private + * @returns {void} + */ + _createVideoInputTrack(deviceId: string) { + const { hideVideoInputPreview } = this.props; + + if (hideVideoInputPreview) { + return; + } + + return this._disposeVideoInputPreview() + .then(() => createLocalTrack('video', deviceId, 5000)) + .then(jitsiLocalTrack => { + if (!jitsiLocalTrack) { + return Promise.reject(); + } + + if (this._unMounted) { + jitsiLocalTrack.dispose(); + + return; + } + + this.setState({ + previewVideoTrack: jitsiLocalTrack, + previewVideoTrackError: null + }); + }) + .catch(() => { + this.setState({ + previewVideoTrack: null, + previewVideoTrackError: + this.props.t('deviceSelection.previewUnavailable') + }); + }); + } + + /** + * Utility function for disposing the current video input preview. + * + * @private + * @returns {Promise} + */ + _disposeVideoInputPreview(): Promise { + return this.state.previewVideoTrack + ? this.state.previewVideoTrack.dispose() : Promise.resolve(); + } + + /** + * Creates a DeviceSelector instance based on the passed in configuration. + * + * @private + * @returns {ReactElement} + */ + _renderVideoSelector() { + const { availableDevices, hasVideoPermission } = this.props; + + const videoConfig = { + devices: availableDevices.videoInput, + hasPermission: hasVideoPermission, + icon: 'icon-camera', + isDisabled: this.props.disableVideoInputSelect || this.props.disableDeviceChange, + key: 'videoInput', + id: 'videoInput', + label: 'settings.selectCamera', + onSelect: (selectedVideoInputId: string) => super._onChange({ selectedVideoInputId }), + selectedDeviceId: this.state.previewVideoTrack + ? this.state.previewVideoTrack.getDeviceId() : this.props.selectedVideoInputId + }; + + return ( + + ); + } + + /** + * Callback invoked to select a frame rate from the select dropdown. + * + * @param {Object} e - The key event to handle. + * @private + * @returns {void} + */ + _onFramerateItemSelect(e: React.ChangeEvent) { + const frameRate = e.target.value; + + super._onChange({ currentFramerate: frameRate }); + } + + /** + * Returns the React Element for the desktop share frame rate dropdown. + * + * @returns {JSX} + */ + _renderFramerateSelect() { + const { currentFramerate, desktopShareFramerates, t } = this.props; + const frameRateItems = desktopShareFramerates.map((frameRate: number) => { + return { + value: frameRate, + label: `${frameRate} ${t('settings.framesPerSecond')}` + }; + }); + + return ( + SS_DEFAULT_FRAME_RATE - ? t('settings.desktopShareHighFpsWarning') - : t('settings.desktopShareWarning') } - label = { t('settings.desktopShareFramerate') } - onChange = { this._onFramerateItemSelect } - options = { frameRateItems } - value = { currentFramerate } /> -
-
- ); - } - /** * Returns the React Element for modifying prejoin screen settings. * @@ -244,7 +187,6 @@ class MoreTab extends AbstractDialogTab {
- { this._renderFramerateSelect() } { this._renderMaxStageParticipantsSelect() }
); diff --git a/react/features/settings/components/web/SettingsButton.js b/react/features/settings/components/web/SettingsButton.js index 2f6c14345..e43b28ebf 100644 --- a/react/features/settings/components/web/SettingsButton.js +++ b/react/features/settings/components/web/SettingsButton.js @@ -46,7 +46,7 @@ class SettingsButton extends AbstractButton { * @returns {void} */ _handleClick() { - const { defaultTab = SETTINGS_TABS.DEVICES, dispatch, isDisplayedOnWelcomePage = false } = this.props; + const { defaultTab = SETTINGS_TABS.AUDIO, dispatch, isDisplayedOnWelcomePage = false } = this.props; sendAnalytics(createToolbarEvent('settings')); dispatch(openSettingsDialog(defaultTab, isDisplayedOnWelcomePage)); diff --git a/react/features/settings/components/web/SettingsDialog.tsx b/react/features/settings/components/web/SettingsDialog.tsx index e1e7c406a..cfd209a70 100644 --- a/react/features/settings/components/web/SettingsDialog.tsx +++ b/react/features/settings/components/web/SettingsDialog.tsx @@ -1,4 +1,3 @@ -/* eslint-disable lines-around-comment */ import { Theme } from '@mui/material'; import { withStyles } from '@mui/styles'; import React, { Component } from 'react'; @@ -11,18 +10,20 @@ import { IconHost, IconShortcuts, IconUser, + IconVideo, IconVolumeUp } from '../../../base/icons/svg'; import { connect } from '../../../base/redux/functions'; import { withPixelLineHeight } from '../../../base/styles/functions.web'; import DialogWithTabs, { IDialogTab } from '../../../base/ui/components/web/DialogWithTabs'; import { isCalendarEnabled } from '../../../calendar-sync/functions.web'; +import { submitAudioDeviceSelectionTab, submitVideoDeviceSelectionTab } from '../../../device-selection/actions.web'; +import AudioDevicesSelection from '../../../device-selection/components/AudioDevicesSelection'; +import VideoDeviceSelection from '../../../device-selection/components/VideoDeviceSelection'; import { - DeviceSelection, - getDeviceSelectionDialogProps, - submitDeviceSelectionTab - // @ts-ignore -} from '../../../device-selection'; + getAudioDeviceSelectionDialogProps, + getVideoDeviceSelectionDialogProps +} from '../../../device-selection/functions.web'; import { submitModeratorTab, submitMoreTab, @@ -47,7 +48,6 @@ import MoreTab from './MoreTab'; import NotificationsTab from './NotificationsTab'; import ProfileTab from './ProfileTab'; import ShortcutsTab from './ShortcutsTab'; -/* eslint-enable lines-around-comment */ /** * The type of the React {@code Component} props of @@ -58,7 +58,7 @@ interface IProps { /** * Information about the tabs to be rendered. */ - _tabs: IDialogTab[]; + _tabs: IDialogTab[]; /** * An object containing the CSS classes. @@ -100,10 +100,6 @@ const styles = (theme: Theme) => { marginBottom: theme.spacing(1) }, - '& .calendar-tab, & .device-selection': { - marginTop: '20px' - }, - '& .mock-atlaskit-label': { color: '#b8c7e0', fontSize: '12px', @@ -168,7 +164,8 @@ const styles = (theme: Theme) => { flexDirection: 'column', fontSize: '14px', minHeight: '100px', - textAlign: 'center' + textAlign: 'center', + marginTop: '20px' }, '& .calendar-tab-sign-in': { @@ -185,11 +182,6 @@ const styles = (theme: Theme) => { }, '@media only screen and (max-width: 700px)': { - '& .device-selection': { - display: 'flex', - flexDirection: 'column' - }, - '& .more-tab': { flexDirection: 'column' } @@ -262,15 +254,15 @@ function _mapStateToProps(state: IReduxState, ownProps: any) { const showSoundsSettings = configuredTabs.includes('sounds'); const enabledNotifications = getNotificationsMap(state); const showNotificationsSettings = Object.keys(enabledNotifications).length > 0; - const tabs: IDialogTab[] = []; + const tabs: IDialogTab[] = []; if (showDeviceSettings) { tabs.push({ - name: SETTINGS_TABS.DEVICES, - component: DeviceSelection, - labelKey: 'settings.devices', - props: getDeviceSelectionDialogProps(state, isDisplayedOnWelcomePage), - propsUpdateFunction: (tabState: any, newProps: any) => { + name: SETTINGS_TABS.AUDIO, + component: AudioDevicesSelection, + labelKey: 'settings.audio', + props: getAudioDeviceSelectionDialogProps(state, isDisplayedOnWelcomePage), + propsUpdateFunction: (tabState: any, newProps: ReturnType) => { // Ensure the device selection tab gets updated when new devices // are found by taking the new props and only preserving the // current user selected devices. If this were not done, the @@ -279,14 +271,37 @@ function _mapStateToProps(state: IReduxState, ownProps: any) { return { ...newProps, + noiseSuppressionEnabled: tabState.noiseSuppressionEnabled, selectedAudioInputId: tabState.selectedAudioInputId, - selectedAudioOutputId: tabState.selectedAudioOutputId, + selectedAudioOutputId: tabState.selectedAudioOutputId + }; + }, + className: `settings-pane ${classes.settingsDialog} devices-pane`, + submit: (newState: any) => submitAudioDeviceSelectionTab(newState, isDisplayedOnWelcomePage), + icon: IconVolumeUp + }); + tabs.push({ + name: SETTINGS_TABS.VIDEO, + component: VideoDeviceSelection, + labelKey: 'settings.video', + props: getVideoDeviceSelectionDialogProps(state, isDisplayedOnWelcomePage), + propsUpdateFunction: (tabState: any, newProps: ReturnType) => { + // Ensure the device selection tab gets updated when new devices + // are found by taking the new props and only preserving the + // current user selected devices. If this were not done, the + // tab would keep using a copy of the initial props it received, + // leaving the device list to become stale. + + return { + ...newProps, + currentFramerate: tabState?.currentFramerate, + localFlipX: tabState.localFlipX, selectedVideoInputId: tabState.selectedVideoInputId }; }, className: `settings-pane ${classes.settingsDialog} devices-pane`, - submit: (newState: any) => submitDeviceSelectionTab(newState, isDisplayedOnWelcomePage), - icon: IconVolumeUp + submit: (newState: any) => submitVideoDeviceSelectionTab(newState, isDisplayedOnWelcomePage), + icon: IconVideo }); } @@ -314,7 +329,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) { component: ModeratorTab, labelKey: 'settings.moderator', props: moderatorTabProps, - propsUpdateFunction: (tabState: any, newProps: any) => { + propsUpdateFunction: (tabState: any, newProps: typeof moderatorTabProps) => { // Updates tab props, keeping users selection return { @@ -379,12 +394,11 @@ function _mapStateToProps(state: IReduxState, ownProps: any) { component: MoreTab, labelKey: 'settings.more', props: moreTabProps, - propsUpdateFunction: (tabState: any, newProps: any) => { + propsUpdateFunction: (tabState: any, newProps: typeof moreTabProps) => { // Updates tab props, keeping users selection return { ...newProps, - currentFramerate: tabState?.currentFramerate, currentLanguage: tabState?.currentLanguage, hideSelfView: tabState?.hideSelfView, showPrejoinPage: tabState?.showPrejoinPage, diff --git a/react/features/settings/constants.ts b/react/features/settings/constants.ts index 6fd50300a..69911a5e2 100644 --- a/react/features/settings/constants.ts +++ b/react/features/settings/constants.ts @@ -1,11 +1,12 @@ export const SETTINGS_TABS = { + AUDIO: 'audio_tab', CALENDAR: 'calendar_tab', - DEVICES: 'devices_tab', MORE: 'more_tab', MODERATOR: 'moderator-tab', NOTIFICATIONS: 'notifications_tab', PROFILE: 'profile_tab', - SHORTCUTS: 'shortcuts_tab' + SHORTCUTS: 'shortcuts_tab', + VIDEO: 'video_tab' }; /** diff --git a/react/features/settings/functions.any.ts b/react/features/settings/functions.any.ts index d17295358..4e0276da3 100644 --- a/react/features/settings/functions.any.ts +++ b/react/features/settings/functions.any.ts @@ -19,8 +19,6 @@ import { getParticipantsPaneConfig } from '../participants-pane/functions'; import { isPrejoinPageVisible } from '../prejoin/functions'; import { isReactionsEnabled } from '../reactions/functions.any'; -import { SS_DEFAULT_FRAME_RATE, SS_SUPPORTED_FRAMERATES } from './constants'; - /** * Used for web. Indicates if the setting section is enabled. * @@ -118,12 +116,9 @@ export function getNotificationsMap(stateful: IStateful) { */ export function getMoreTabProps(stateful: IStateful) { const state = toState(stateful); - const framerate = state['features/screen-share'].captureFrameRate ?? SS_DEFAULT_FRAME_RATE; const stageFilmstripEnabled = isStageFilmstripEnabled(state); return { - currentFramerate: framerate, - desktopShareFramerates: SS_SUPPORTED_FRAMERATES, showPrejoinPage: !state['features/base/settings'].userSelectedSkipPrejoin, showPrejoinSettings: state['features/base/config'].prejoinConfig?.enabled, maxStageParticipants: state['features/base/settings'].maxStageParticipants,