init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,357 @@
/* eslint-disable react/no-multi-comp */
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../../app/types';
import { IconMic, IconVolumeUp } from '../../../../base/icons/svg';
import JitsiMeetJS from '../../../../base/lib-jitsi-meet';
import { equals } from '../../../../base/redux/functions';
import Checkbox from '../../../../base/ui/components/web/Checkbox';
import ContextMenu from '../../../../base/ui/components/web/ContextMenu';
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
import ContextMenuItemGroup from '../../../../base/ui/components/web/ContextMenuItemGroup';
import { toggleNoiseSuppression } from '../../../../noise-suppression/actions';
import { isNoiseSuppressionEnabled } from '../../../../noise-suppression/functions';
import { isPrejoinPageVisible } from '../../../../prejoin/functions';
import { createLocalAudioTracks } from '../../../functions.web';
import MicrophoneEntry from './MicrophoneEntry';
import SpeakerEntry from './SpeakerEntry';
const browser = JitsiMeetJS.util.browser;
/**
* Translates the default device label into a more user friendly one.
*
* @param {string} deviceId - The device Id.
* @param {string} label - The device label.
* @param {Function} t - The translation function.
* @returns {string}
*/
function transformDefaultDeviceLabel(deviceId: string, label: string, t: Function) {
return deviceId === 'default'
? t('settings.sameAsSystem', { label: label.replace('Default - ', '') })
: label;
}
export interface IProps {
/**
* The deviceId of the microphone in use.
*/
currentMicDeviceId: string;
/**
* The deviceId of the output device in use.
*/
currentOutputDeviceId?: string;
/**
* Used to decide whether to measure audio levels for microphone devices.
*/
measureAudioLevels: boolean;
/**
* A list with objects containing the labels and deviceIds
* of all the input devices.
*/
microphoneDevices: Array<{ deviceId: string; label: string; }>;
/**
* Whether noise suppression is enabled or not.
*/
noiseSuppressionEnabled: boolean;
/**
* A list of objects containing the labels and deviceIds
* of all the output devices.
*/
outputDevices: Array<{ deviceId: string; label: string; }>;
/**
* Whether the prejoin page is visible or not.
*/
prejoinVisible: boolean;
/**
* Used to set a new microphone as the current one.
*/
setAudioInputDevice: Function;
/**
* Used to set a new output device as the current one.
*/
setAudioOutputDevice: Function;
/**
* Function to toggle noise suppression.
*/
toggleSuppression: () => void;
}
const useStyles = makeStyles()(theme => {
return {
contextMenu: {
position: 'relative',
right: 'auto',
margin: 0,
marginBottom: theme.spacing(1),
maxHeight: 'calc(100dvh - 100px)',
overflow: 'auto',
width: '300px'
},
header: {
'&:hover': {
backgroundColor: 'initial',
cursor: 'initial'
}
},
list: {
margin: 0,
padding: 0,
listStyleType: 'none'
},
checkboxContainer: {
padding: '10px 16px'
}
};
});
const AudioSettingsContent = ({
currentMicDeviceId,
currentOutputDeviceId,
measureAudioLevels,
microphoneDevices,
noiseSuppressionEnabled,
outputDevices,
prejoinVisible,
setAudioInputDevice,
setAudioOutputDevice,
toggleSuppression
}: IProps) => {
const _componentWasUnmounted = useRef(false);
const microphoneHeaderId = 'microphone_settings_header';
const speakerHeaderId = 'speaker_settings_header';
const { classes } = useStyles();
const [ audioTracks, setAudioTracks ] = useState(microphoneDevices.map(({ deviceId, label }) => {
return {
deviceId,
hasError: false,
jitsiTrack: null,
label
};
}));
const microphoneDevicesRef = useRef(microphoneDevices);
const { t } = useTranslation();
/**
* Click handler for the microphone entries.
*
* @param {string} deviceId - The deviceId for the clicked microphone.
* @returns {void}
*/
const _onMicrophoneEntryClick = useCallback((deviceId: string) => {
setAudioInputDevice(deviceId);
}, [ setAudioInputDevice ]);
/**
* Click handler for the speaker entries.
*
* @param {string} deviceId - The deviceId for the clicked speaker.
* @returns {void}
*/
const _onSpeakerEntryClick = useCallback((deviceId: string) => {
setAudioOutputDevice(deviceId);
}, [ setAudioOutputDevice ]);
/**
* Renders a single microphone entry.
*
* @param {Object} data - An object with the deviceId, jitsiTrack & label of the microphone.
* @param {number} index - The index of the element, used for creating a key.
* @param {length} length - The length of the microphone list.
* @returns {React$Node}
*/
const _renderMicrophoneEntry = (data: { deviceId: string; hasError: boolean; jitsiTrack: any; label: string; },
index: number, length: number) => {
const { deviceId, jitsiTrack, hasError } = data;
const label = transformDefaultDeviceLabel(deviceId, data.label, t);
const isSelected = deviceId === currentMicDeviceId;
return (
<MicrophoneEntry
deviceId = { deviceId }
hasError = { hasError }
index = { index }
isSelected = { isSelected }
jitsiTrack = { jitsiTrack }
key = { `me-${index}` }
length = { length }
measureAudioLevels = { measureAudioLevels }
onClick = { _onMicrophoneEntryClick }>
{label}
</MicrophoneEntry>
);
};
/**
* Renders a single speaker entry.
*
* @param {Object} data - An object with the deviceId and label of the speaker.
* @param {number} index - The index of the element, used for creating a key.
* @param {length} length - The length of the speaker list.
* @returns {React$Node}
*/
const _renderSpeakerEntry = (data: { deviceId: string; label: string; }, index: number, length: number) => {
const { deviceId } = data;
const label = transformDefaultDeviceLabel(deviceId, data.label, t);
const key = `se-${index}`;
const isSelected = deviceId === currentOutputDeviceId;
return (
<SpeakerEntry
deviceId = { deviceId }
index = { index }
isSelected = { isSelected }
key = { key }
length = { length }
onClick = { _onSpeakerEntryClick }>
{label}
</SpeakerEntry>
);
};
/**
* Disposes the audio tracks.
*
* @param {Object} tracks - The object holding the audio tracks.
* @returns {void}
*/
const _disposeTracks = (tracks: Array<{ jitsiTrack: any; }>) => {
tracks.forEach(({ jitsiTrack }) => {
jitsiTrack?.dispose();
});
};
/**
* Creates and updates the audio tracks.
*
* @returns {void}
*/
const _setTracks = async () => {
if (browser.isWebKitBased()) {
// It appears that at the time of this writing, creating audio tracks blocks the browser's main thread for
// long time on safari. Wasn't able to confirm which part of track creation does the blocking exactly, but
// not creating the tracks seems to help and makes the UI much more responsive.
return;
}
_disposeTracks(audioTracks);
const newAudioTracks = await createLocalAudioTracks(microphoneDevices, 5000);
if (_componentWasUnmounted.current) {
_disposeTracks(newAudioTracks);
} else {
setAudioTracks(newAudioTracks);
}
};
useEffect(() => {
_setTracks();
return () => {
_componentWasUnmounted.current = true;
_disposeTracks(audioTracks);
};
}, []);
useEffect(() => {
if (!equals(microphoneDevices, microphoneDevicesRef.current)) {
_setTracks();
microphoneDevicesRef.current = microphoneDevices;
}
}, [ microphoneDevices ]);
return (
<ContextMenu
activateFocusTrap = { true }
aria-labelledby = 'audio-settings-button'
className = { classes.contextMenu }
hidden = { false }
id = 'audio-settings-dialog'
role = 'menu'
tabIndex = { -1 }>
<ContextMenuItemGroup
aria-labelledby = { microphoneHeaderId }
role = 'group'>
<ContextMenuItem
className = { classes.header }
icon = { IconMic }
id = { microphoneHeaderId }
text = { t('settings.microphones') } />
<ul
className = { classes.list }
role = 'presentation'>
{audioTracks.map((data, i) =>
_renderMicrophoneEntry(data, i, audioTracks.length)
)}
</ul>
</ContextMenuItemGroup>
{outputDevices.length > 0 && (
<ContextMenuItemGroup
aria-labelledby = { speakerHeaderId }
role = 'group'>
<ContextMenuItem
className = { classes.header }
icon = { IconVolumeUp }
id = { speakerHeaderId }
text = { t('settings.speakers') } />
<ul
className = { classes.list }
role = 'presentation'>
{outputDevices.map((data: any, i: number) =>
_renderSpeakerEntry(data, i, outputDevices.length)
)}
</ul>
</ContextMenuItemGroup>)
}
{!prejoinVisible && (
<ContextMenuItemGroup>
<div
className = { classes.checkboxContainer }
// eslint-disable-next-line react/jsx-no-bind
onClick = { e => e.stopPropagation() }>
<Checkbox
checked = { noiseSuppressionEnabled }
label = { t('toolbar.noiseSuppression') }
onChange = { toggleSuppression } />
</div>
</ContextMenuItemGroup>
)}
</ContextMenu>
);
};
const mapStateToProps = (state: IReduxState) => {
return {
noiseSuppressionEnabled: isNoiseSuppressionEnabled(state),
prejoinVisible: isPrejoinPageVisible(state)
};
};
const mapDispatchToProps = (dispatch: IStore['dispatch']) => {
return {
toggleSuppression() {
dispatch(toggleNoiseSuppression());
}
};
};
export default connect(mapStateToProps, mapDispatchToProps)(AudioSettingsContent);

View File

@@ -0,0 +1,164 @@
import React, { ReactNode } from 'react';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../../app/types';
import { areAudioLevelsEnabled } from '../../../../base/config/functions.web';
import {
setAudioInputDeviceAndUpdateSettings,
setAudioOutputDevice as setAudioOutputDeviceAction
} from '../../../../base/devices/actions.web';
import {
getAudioInputDeviceData,
getAudioOutputDeviceData
} from '../../../../base/devices/functions.web';
import Popover from '../../../../base/popover/components/Popover.web';
import { SMALL_MOBILE_WIDTH } from '../../../../base/responsive-ui/constants';
import {
getCurrentMicDeviceId,
getCurrentOutputDeviceId
} from '../../../../base/settings/functions.web';
import { toggleAudioSettings } from '../../../actions';
import { getAudioSettingsVisibility } from '../../../functions.web';
import AudioSettingsContent from './AudioSettingsContent';
interface IProps {
/**
* Component's children (the audio button).
*/
children: ReactNode;
/**
* The deviceId of the microphone in use.
*/
currentMicDeviceId: string;
/**
* The deviceId of the output device in use.
*/
currentOutputDeviceId?: string;
/**
* Flag controlling the visibility of the popup.
*/
isOpen: boolean;
/**
* Used to decide whether to measure audio levels for microphone devices.
*/
measureAudioLevels: boolean;
/**
* A list with objects containing the labels and deviceIds
* of all the input devices.
*/
microphoneDevices: Array<{ deviceId: string; label: string; }>;
/**
* Callback executed when the popup closes.
*/
onClose: Function;
/**
* A list of objects containing the labels and deviceIds
* of all the output devices.
*/
outputDevices: Array<{ deviceId: string; label: string; }>;
/**
* The popup placement enum value.
*/
popupPlacement: string;
/**
* Used to set a new microphone as the current one.
*/
setAudioInputDevice: Function;
/**
* Used to set a new output device as the current one.
*/
setAudioOutputDevice: Function;
}
const useStyles = makeStyles()(() => {
return {
container: {
display: 'inline-block'
}
};
});
/**
* Popup with audio settings.
*
* @returns {ReactElement}
*/
function AudioSettingsPopup({
children,
currentMicDeviceId,
currentOutputDeviceId,
isOpen,
microphoneDevices,
setAudioInputDevice,
setAudioOutputDevice,
onClose,
outputDevices,
popupPlacement,
measureAudioLevels
}: IProps) {
const { classes, cx } = useStyles();
return (
<div className = { cx(classes.container, 'audio-preview') }>
<Popover
allowClick = { true }
content = { <AudioSettingsContent
currentMicDeviceId = { currentMicDeviceId }
currentOutputDeviceId = { currentOutputDeviceId }
measureAudioLevels = { measureAudioLevels }
microphoneDevices = { microphoneDevices }
outputDevices = { outputDevices }
setAudioInputDevice = { setAudioInputDevice }
setAudioOutputDevice = { setAudioOutputDevice } /> }
headingId = 'audio-settings-button'
onPopoverClose = { onClose }
position = { popupPlacement }
trigger = 'click'
visible = { isOpen }>
{children}
</Popover>
</div>
);
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @returns {Object}
*/
function mapStateToProps(state: IReduxState) {
const { videoSpaceWidth } = state['features/base/responsive-ui'];
return {
popupPlacement: videoSpaceWidth <= Number(SMALL_MOBILE_WIDTH) ? 'auto' : 'top-end',
currentMicDeviceId: getCurrentMicDeviceId(state),
currentOutputDeviceId: getCurrentOutputDeviceId(state),
isOpen: Boolean(getAudioSettingsVisibility(state)),
microphoneDevices: getAudioInputDeviceData(state) ?? [],
outputDevices: getAudioOutputDeviceData(state) ?? [],
measureAudioLevels: areAudioLevelsEnabled(state)
};
}
const mapDispatchToProps = {
onClose: toggleAudioSettings,
setAudioInputDevice: setAudioInputDeviceAndUpdateSettings,
setAudioOutputDevice: setAudioOutputDeviceAction
};
export default connect(mapStateToProps, mapDispatchToProps)(AudioSettingsPopup);

View File

@@ -0,0 +1,45 @@
import React from 'react';
import Icon from '../../../../base/icons/components/Icon';
import { IconMeter } from '../../../../base/icons/svg';
interface IProps {
/**
* Own class name for the component.
*/
className: string;
/**
* Flag indicating whether the component is greyed out/disabled.
*/
isDisabled?: boolean;
/**
* The level of the meter.
* Should be between 0 and 7 as per the used SVG.
*/
level: number;
}
/**
* React {@code Component} representing an audio level meter.
*
* @returns { ReactElement}
*/
export default function({ className, isDisabled, level }: IProps) {
let ownClassName;
if (level > -1) {
ownClassName = `metr metr-l-${level}`;
} else {
ownClassName = `metr ${isDisabled ? 'metr--disabled' : ''}`;
}
return (
<Icon
className = { `${ownClassName} ${className}` }
size = { 12 }
src = { IconMeter } />
);
}

View File

@@ -0,0 +1,223 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../../base/icons/components/Icon';
import { IconCheck, IconExclamationSolid } from '../../../../base/icons/svg';
import JitsiMeetJS from '../../../../base/lib-jitsi-meet/_';
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
import { TEXT_OVERFLOW_TYPES } from '../../../../base/ui/constants.any';
import Meter from './Meter';
const JitsiTrackEvents = JitsiMeetJS.events.track;
interface IProps {
/**
* The text for this component.
*/
children: string;
/**
* The deviceId of the microphone.
*/
deviceId: string;
/**
* Flag indicating if there is a problem with the device.
*/
hasError?: boolean;
/**
* Index of the device item used to generate this entry.
* Indexes are 0 based.
*/
index: number;
/**
* Flag indicating the selection state.
*/
isSelected: boolean;
/**
* The audio track for the current entry.
*/
jitsiTrack: any;
/**
* The id for the label, that contains the item text.
*/
labelId?: string;
/**
* The length of the microphone list.
*/
length: number;
/**
* Used to decide whether to listen to audio level changes.
*/
measureAudioLevels: boolean;
/**
* Click handler for component.
*/
onClick: Function;
}
const useStyles = makeStyles()(theme => {
return {
container: {
position: 'relative'
},
entryText: {
maxWidth: '238px',
'&.withMeter': {
maxWidth: '178px'
},
'&.left-margin': {
marginLeft: '36px'
}
},
icon: {
borderRadius: '50%',
display: 'inline-block',
width: '14px',
marginLeft: '6px',
'& svg': {
fill: theme.palette.iconError
}
},
meter: {
position: 'absolute',
right: '16px',
top: '14px'
}
};
});
const MicrophoneEntry = ({
deviceId,
children,
hasError,
index,
isSelected,
length,
jitsiTrack,
measureAudioLevels,
onClick: propsClick
}: IProps) => {
const [ level, setLevel ] = useState(-1);
const activeTrackRef = useRef(jitsiTrack);
const { classes, cx } = useStyles();
/**
* Click handler for the entry.
*
* @returns {void}
*/
const onClick = useCallback(() => {
propsClick(deviceId);
}, [ propsClick, deviceId ]);
/**
* Key pressed handler for the entry.
*
* @param {Object} e - The event.
* @private
*
* @returns {void}
*/
const onKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
propsClick(deviceId);
}
}, [ propsClick, deviceId ]);
/**
* Updates the level of the meter.
*
* @param {number} num - The audio level provided by the jitsiTrack.
* @returns {void}
*/
const updateLevel = useCallback((num: number) => {
setLevel(Math.floor(num / 0.125));
}, []);
/**
* Subscribes to audio level changes coming from the jitsiTrack.
*
* @returns {void}
*/
const startListening = () => {
jitsiTrack && measureAudioLevels && jitsiTrack.on(
JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED,
updateLevel);
};
/**
* Unsubscribes from changes coming from the jitsiTrack.
*
* @param {Object} track - The jitsiTrack to unsubscribe from.
* @returns {void}
*/
const stopListening = (track?: any) => {
track?.off(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, updateLevel);
setLevel(-1);
};
useEffect(() => {
startListening();
return () => {
stopListening(jitsiTrack);
};
}, []);
useEffect(() => {
stopListening(activeTrackRef.current);
startListening();
activeTrackRef.current = jitsiTrack;
}, [ jitsiTrack ]);
return (
<li
aria-checked = { isSelected }
aria-posinset = { index + 1 } // Add one to offset the 0 based index.
aria-setsize = { length }
className = { classes.container }
onClick = { onClick }
onKeyPress = { onKeyPress }
role = 'menuitemradio'
tabIndex = { 0 }>
<ContextMenuItem
icon = { isSelected ? IconCheck : undefined }
overflowType = { TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER }
selected = { isSelected }
text = { children }
textClassName = { cx(classes.entryText,
measureAudioLevels && 'withMeter',
!isSelected && 'left-margin') }>
{hasError && <Icon
className = { classes.icon }
size = { 16 }
src = { IconExclamationSolid } />}
</ContextMenuItem>
{Boolean(jitsiTrack) && measureAudioLevels && <Meter
className = { classes.meter }
isDisabled = { hasError }
level = { level } />
}
</li>
);
};
export default MicrophoneEntry;

View File

@@ -0,0 +1,176 @@
import React, { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { IconCheck } from '../../../../base/icons/svg';
import Button from '../../../../base/ui/components/web/Button';
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
import { BUTTON_TYPES, TEXT_OVERFLOW_TYPES } from '../../../../base/ui/constants.any';
import logger from '../../../logger';
const TEST_SOUND_PATH = 'sounds/ring.mp3';
/**
* The type of the React {@code Component} props of {@link SpeakerEntry}.
*/
interface IProps {
/**
* The text label for the entry.
*/
children: string;
/**
* The deviceId of the speaker.
*/
deviceId: string;
/**
* Index of the device item used to generate this entry.
* Indexes are 0 based.
*/
index: number;
/**
* Flag controlling the selection state of the entry.
*/
isSelected: boolean;
/**
* Flag controlling the selection state of the entry.
*/
length: number;
/**
* Click handler for the component.
*/
onClick: Function;
}
const useStyles = makeStyles()(() => {
return {
container: {
position: 'relative',
[[ '&:hover', '&:focus', '&:focus-within' ] as any]: {
'& .entryText': {
maxWidth: '178px',
marginRight: 0
},
'& .testButton': {
display: 'inline-block'
}
}
},
entryText: {
maxWidth: '238px',
'&.left-margin': {
marginLeft: '36px'
}
},
testButton: {
display: 'none',
padding: '4px 10px',
position: 'absolute',
right: '16px',
top: '6px'
}
};
});
/**
* Implements a React {@link Component} which displays an audio
* output settings entry. The user can click and play a test sound.
*
* @param {IProps} props - Component props.
* @returns {JSX.Element}
*/
const SpeakerEntry = (props: IProps) => {
const audioRef = useRef<HTMLAudioElement | null>(null);
const { classes, cx } = useStyles();
const { t } = useTranslation();
/**
* Click handler for the entry.
*
* @returns {void}
*/
function _onClick() {
props.onClick(props.deviceId);
}
/**
* Key pressed handler for the entry.
*
* @param {Object} e - The event.
* @private
*
* @returns {void}
*/
function _onKeyPress(e: React.KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
props.onClick(props.deviceId);
}
}
/**
* Click handler for Test button.
* Sets the current audio output id and plays a sound.
*
* @param {Object} e - The synthetic event.
* @returns {void}
*/
async function _onTestButtonClick(e: React.KeyboardEvent | React.MouseEvent) {
e.stopPropagation();
try {
await audioRef.current?.setSinkId(props.deviceId);
audioRef.current?.play();
} catch (err) {
logger.log('Could not set sink id', err);
}
}
const { children, isSelected, index, length } = props;
const testLabel = t('deviceSelection.testAudio');
/* eslint-disable react/jsx-no-bind */
return (
<li
aria-checked = { isSelected }
aria-label = { children }
aria-posinset = { index + 1 } // Add one to offset the 0 based index.
aria-setsize = { length }
className = { classes.container }
onClick = { _onClick }
onKeyPress = { _onKeyPress }
role = 'menuitemradio'
tabIndex = { 0 }>
<ContextMenuItem
icon = { isSelected ? IconCheck : undefined }
overflowType = { TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER }
selected = { isSelected }
text = { children }
textClassName = { cx(classes.entryText, 'entryText', !isSelected && 'left-margin') } />
<audio
preload = 'auto'
ref = { audioRef }
src = { TEST_SOUND_PATH } />
<Button
accessibilityLabel = { `${testLabel} ${children}` }
className = { cx(classes.testButton, 'testButton') }
label = { testLabel }
onClick = { _onTestButtonClick }
onKeyPress = { _onTestButtonClick }
type = { BUTTON_TYPES.SECONDARY } />
</li>
);
};
export default SpeakerEntry;