Files
jitsi-meet/react/features/device-selection/components/AudioDevicesSelection.web.tsx
Hugo Lavernhe 1e15d9421b feat(settings) Add advanced audio settings checkboxes (#16316)
* Add checkboxes to toggle audio settings

* Sync checkboxes with audio mixer effect

* Add tooltips

* Move previewAudioTrack to redux

* Add translation

* Add audio settings state to redux

* Update docs

* Apply review comments

* Create local track with audio contraints when unmuting

* Refactor functions and naming

* Add enableAdvancedAudioSettings config

* Fix mobile imports

* Add tooltips content

* Update react/features/base/config/functions.any.ts

* Layout checkboxes in a two-column grid

* Fix web imports

* Sort translation alphabetically

* Separate audio mute implementation for mobile and web

* Apply review comments

* squash: Add imports for middleware.any

* squash: fix linter errors

* Remove tooltips

* Lint

* Refactored setting of audio constraints in createLocalTracksF with checks for feature flag and desktop

---------

Co-authored-by: Jaya Allamsetty <54324652+jallamsetty1@users.noreply.github.com>
Co-authored-by: Jaya Allamsetty <jaya.allamsetty@8x8.com>
2025-09-05 16:52:35 -04:00

474 lines
16 KiB
TypeScript

import { Theme } from '@mui/material';
import clsx from 'clsx';
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../app/types';
import { isAdvancedAudioSettingsEnabled } from '../../base/config/functions.any';
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 { IAudioSettings } from '../../base/settings/reducer';
import Checkbox from '../../base/ui/components/web/Checkbox';
import { setPreviewAudioTrack } from '../../settings/actions.web';
import { disposeTrack } from '../../settings/functions.web';
import { iAmVisitor as iAmVisitorCheck } from '../../visitors/functions';
import logger from '../logger';
import AudioInputPreview from './AudioInputPreview.web';
import AudioOutputPreview from './AudioOutputPreview.web';
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 {
/**
* The audio local track settings.
*/
audioSettings?: IAudioSettings;
/**
* 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?: Partial<Record<keyof ReturnType<typeof styles>, 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: 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;
/**
* Whether we are in visitors mode.
*/
iAmVisitor: boolean;
/**
* Whether the advanced audio settings are enabled from config.
*/
isAdvancedAudioSettingsConfigEnabled: boolean;
/**
* Whether noise suppression is on or not.
*/
noiseSuppressionEnabled: boolean;
/**
* The audio track that is used for previewing the audio input.
*/
previewAudioTrack: any | null;
/**
* The id of the audio input device to preview.
*/
selectedAudioInputId: string;
/**
* The id of the audio output device to preview.
*/
selectedAudioOutputId: string;
}
const styles = (theme: Theme) => {
return {
checkbox: {
width: 'max-content'
},
checkboxGrid: {
display: 'grid',
gridTemplateColumns: 'auto auto',
gap: theme.spacing(3),
margin: `${theme.spacing(3)} 0`
},
container: {
display: 'flex',
flexDirection: 'column' as const,
padding: '0 2px',
width: '100%'
},
hidden: {
display: 'none'
},
inputContainer: {
marginBottom: theme.spacing(3)
},
outputContainer: {
display: 'flex',
alignItems: 'flex-end',
margin: `${theme.spacing(5)} 0`
},
outputButton: {
marginLeft: theme.spacing(3)
}
};
};
/**
* React {@code Component} for previewing audio and video input/output devices.
*
* @augments Component
*/
class AudioDevicesSelection extends AbstractDialogTab<IProps, {}> {
/**
* 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._unMounted = true;
}
/**
* Generate the initial previews for audio input and video input.
*
* @inheritdoc
*/
override 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}
*/
override componentDidUpdate(prevProps: IProps) {
if (prevProps.selectedAudioInputId
!== this.props.selectedAudioInputId) {
this._createAudioInputTrack(this.props.selectedAudioInputId);
}
}
/**
* Ensure preview tracks are destroyed to prevent continued use.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._unMounted = true;
disposeTrack(this.props.previewAudioTrack);
}
/**
* Toggles the audio settings based on the input change event and updates the state.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onToggleAudioSettings = ({ target: { checked, name } }: React.ChangeEvent<HTMLInputElement>) => {
const { audioSettings } = this.props;
const newValue = name === 'channelCount' ? (checked ? 2 : 1) : checked;
super._onChange({
audioSettings: {
...audioSettings,
[name]: newValue
}
});
};
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const {
audioSettings,
hasAudioPermission,
hideAudioInputPreview,
hideAudioOutputPreview,
hideDeviceHIDContainer,
hideNoiseSuppression,
isAdvancedAudioSettingsConfigEnabled,
iAmVisitor,
noiseSuppressionEnabled,
selectedAudioOutputId,
t
} = this.props;
const { audioInput, audioOutput } = this._getSelectors();
const classes = withStyles.getClasses(this.props);
const isAudioSettingsEnabled = Boolean(audioSettings?.autoGainControl || audioSettings?.channelCount === 2 || audioSettings?.echoCancellation || audioSettings?.noiseSuppression);
const shouldDisplayNoiseSuppressionCheckbox = !hideNoiseSuppression && !iAmVisitor;
const shouldDisplayAdvancedAudioSettingsCheckboxes = !hideNoiseSuppression && !iAmVisitor && isAdvancedAudioSettingsConfigEnabled && Boolean(audioSettings);
const shouldDisabledNoiseSupressionCheckbox = shouldDisplayAdvancedAudioSettingsCheckboxes && (isAudioSettingsEnabled && !noiseSuppressionEnabled);
return (
<div className = { classes.container }>
{!iAmVisitor && <div
aria-live = 'polite'
className = { classes.inputContainer }>
{this._renderSelector(audioInput)}
</div>}
{!hideAudioInputPreview && hasAudioPermission && !iAmVisitor
&& <AudioInputPreview
track = { this.props.previewAudioTrack } />}
<fieldset className = { classes.checkboxGrid }>
<Checkbox
checked = { Boolean(audioSettings?.echoCancellation) }
className = { clsx(classes.checkbox, {
[classes.hidden]: !shouldDisplayAdvancedAudioSettingsCheckboxes,
}) }
disabled = { noiseSuppressionEnabled }
label = { t('toolbar.advancedAudioSettings.aec.label') }
name = { 'echoCancellation' }
onChange = { this._onToggleAudioSettings } />
<Checkbox
checked = { Boolean(audioSettings?.channelCount === 2) }
className = { clsx(classes.checkbox, {
[classes.hidden]: !shouldDisplayAdvancedAudioSettingsCheckboxes,
}) }
disabled = { noiseSuppressionEnabled }
label = { t('toolbar.advancedAudioSettings.stereo.label') }
name = { 'channelCount' }
onChange = { this._onToggleAudioSettings } />
<Checkbox
checked = { Boolean(audioSettings?.autoGainControl) }
className = { clsx(classes.checkbox, {
[classes.hidden]: !shouldDisplayAdvancedAudioSettingsCheckboxes,
}) }
disabled = { noiseSuppressionEnabled }
label = { t('toolbar.advancedAudioSettings.agc.label') }
name = { 'autoGainControl' }
onChange = { this._onToggleAudioSettings } />
<Checkbox
checked = { Boolean(audioSettings?.noiseSuppression) }
className = { clsx(classes.checkbox, {
[classes.hidden]: !shouldDisplayAdvancedAudioSettingsCheckboxes,
}) }
disabled = { noiseSuppressionEnabled }
label = { t('toolbar.advancedAudioSettings.ns.label') }
name = { 'noiseSuppression' }
onChange = { this._onToggleAudioSettings } />
<Checkbox
checked = { noiseSuppressionEnabled }
className = { clsx(classes.checkbox, {
[classes.hidden]: !shouldDisplayNoiseSuppressionCheckbox,
}) }
disabled = { shouldDisabledNoiseSupressionCheckbox }
label = { t('toolbar.enableNoiseSuppression') }
// eslint-disable-next-line react/jsx-no-bind
onChange = { () => super._onChange({
noiseSuppressionEnabled: !noiseSuppressionEnabled
}) } />
</fieldset>
<div
aria-live = 'polite'
className = { classes.outputContainer }>
{this._renderSelector(audioOutput)}
{!hideAudioOutputPreview && hasAudioPermission
&& <AudioOutputPreview
className = { classes.outputButton }
deviceId = { selectedAudioOutputId } />}
</div>
{!hideDeviceHIDContainer && !iAmVisitor
&& <DeviceHidContainer />}
</div>
);
}
/**
* 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, previewAudioTrack } = this.props;
if (hideAudioInputPreview) {
return;
}
return disposeTrack(previewAudioTrack)
.then(() => createLocalTrack('audio', deviceId, 5000))
.then(jitsiLocalTrack => {
if (this._unMounted) {
jitsiLocalTrack.dispose();
return;
}
this.props.dispatch(setPreviewAudioTrack(jitsiLocalTrack));
})
.catch(() => {
this.props.dispatch(setPreviewAudioTrack(null));
});
}
/**
* 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 ? (
<DeviceSelector
{ ...deviceSelectorProps }
key = { deviceSelectorProps.id } />
) : 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.props.previewAudioTrack
? this.props.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 ?? {},
isAdvancedAudioSettingsConfigEnabled: isAdvancedAudioSettingsEnabled(state),
iAmVisitor: iAmVisitorCheck(state),
previewAudioTrack: state['features/settings'].previewAudioTrack
};
};
export default connect(mapStateToProps)(withStyles(translate(AudioDevicesSelection), styles));