This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
45
react/features/settings/components/web/audio/Meter.tsx
Normal file
45
react/features/settings/components/web/audio/Meter.tsx
Normal 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 } />
|
||||
);
|
||||
}
|
||||
223
react/features/settings/components/web/audio/MicrophoneEntry.tsx
Normal file
223
react/features/settings/components/web/audio/MicrophoneEntry.tsx
Normal 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;
|
||||
176
react/features/settings/components/web/audio/SpeakerEntry.tsx
Normal file
176
react/features/settings/components/web/audio/SpeakerEntry.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user