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,96 @@
import { createDeviceChangedEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import {
setAudioInputDevice,
setVideoInputDevice
} 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 { getAudioDeviceSelectionDialogProps, getVideoDeviceSelectionDialogProps } from './functions';
import logger from './logger';
/**
* 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 submitAudioDeviceSelectionTab(newState: any, isDisplayedOnWelcomePage: boolean) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const currentState = getAudioDeviceSelectionDialogProps(getState(), isDisplayedOnWelcomePage);
if (newState.selectedAudioInputId && newState.selectedAudioInputId !== currentState.selectedAudioInputId) {
dispatch(updateSettings({
userSelectedMicDeviceId: newState.selectedAudioInputId,
userSelectedMicDeviceLabel:
getDeviceLabelById(getState(), newState.selectedAudioInputId, 'audioInput')
}));
dispatch(setAudioInputDevice(newState.selectedAudioInputId));
}
if (newState.selectedAudioOutputId
&& newState.selectedAudioOutputId
!== currentState.selectedAudioOutputId) {
sendAnalytics(createDeviceChangedEvent('audio', 'output'));
setAudioOutputDeviceId(
newState.selectedAudioOutputId,
dispatch,
true,
getDeviceLabelById(getState(), newState.selectedAudioOutputId, 'audioOutput'))
.then(() => logger.log('changed audio output device'))
.catch(err => {
logger.warn(
'Failed to change audio output device.',
'Default or previously set audio output device will',
' be used instead.',
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));
}
};
}

View File

@@ -0,0 +1,396 @@
import { Theme } from '@mui/material';
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 { 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 { iAmVisitor as iAmVisitorCheck } from '../../visitors/functions';
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?: 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 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}.
*/
interface IState {
/**
* 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<IProps, IState> {
/**
* 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
*/
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;
this._disposeAudioInputPreview();
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const {
hasAudioPermission,
hideAudioInputPreview,
hideAudioOutputPreview,
hideDeviceHIDContainer,
hideNoiseSuppression,
iAmVisitor,
noiseSuppressionEnabled,
selectedAudioOutputId,
t
} = this.props;
const { audioInput, audioOutput } = this._getSelectors();
const classes = withStyles.getClasses(this.props);
return (
<div className = { classes.container }>
{!iAmVisitor && <div
aria-live = 'polite'
className = { classes.inputContainer }>
{this._renderSelector(audioInput)}
</div>}
{!hideAudioInputPreview && hasAudioPermission && !iAmVisitor
&& <AudioInputPreview
track = { this.state.previewAudioTrack } />}
<div
aria-live = 'polite'
className = { classes.outputContainer }>
{this._renderSelector(audioOutput)}
{!hideAudioOutputPreview && hasAudioPermission
&& <AudioOutputPreview
className = { classes.outputButton }
deviceId = { selectedAudioOutputId } />}
</div>
{!hideNoiseSuppression && !iAmVisitor && (
<div className = { classes.noiseSuppressionContainer }>
<Checkbox
checked = { noiseSuppressionEnabled }
label = { t('toolbar.enableNoiseSuppression') }
// eslint-disable-next-line react/jsx-no-bind
onChange = { () => super._onChange({
noiseSuppressionEnabled: !noiseSuppressionEnabled
}) } />
</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 } = 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<any> {
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 ? (
<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.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 ?? {},
iAmVisitor: iAmVisitorCheck(state)
};
};
export default connect(mapStateToProps)(withStyles(translate(AudioDevicesSelection), styles));

View File

@@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react';
import { makeStyles } from 'tss-react/mui';
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 (
<div className = { classes.container } >
{new Array(NO_OF_PREVIEW_SECTIONS).fill(0)
.map((_, idx) =>
(<div
className = { cx(classes.section, idx < audioMeterFill && classes.activeSection) }
key = { idx } />)
)}
</div>
);
};
export default AudioInputPreview;

View File

@@ -0,0 +1,136 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { translate } from '../../base/i18n/functions';
import { Audio } from '../../base/media/components/index';
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}.
*/
interface IProps extends WithTranslation {
/**
* Button className.
*/
className?: string;
/**
* The device id of the audio output device to use.
*/
deviceId: string;
}
/**
* React component for playing a test sound through a specified audio device.
*
* @augments Component
*/
class AudioOutputPreview extends Component<IProps> {
_audioElement: HTMLAudioElement | null;
/**
* Initializes a new AudioOutputPreview 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._audioElement = null;
this._audioElementReady = this._audioElementReady.bind(this);
this._onClick = this._onClick.bind(this);
this._onKeyPress = this._onKeyPress.bind(this);
}
/**
* Updates the audio element when the target output device changes and the
* audio element has re-rendered.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate() {
this._setAudioSink();
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<>
<Button
accessibilityLabel = { this.props.t('deviceSelection.testAudio') }
className = { this.props.className }
labelKey = 'deviceSelection.testAudio'
onClick = { this._onClick }
onKeyPress = { this._onKeyPress }
type = { BUTTON_TYPES.SECONDARY } />
<Audio
setRef = { this._audioElementReady }
src = { TEST_SOUND_PATH } />
</>
);
}
/**
* Sets the instance variable for the component's audio element so it can be
* accessed directly.
*
* @param {Object} element - The DOM element for the component's audio.
* @private
* @returns {void}
*/
_audioElementReady(element: HTMLAudioElement) {
this._audioElement = element;
this._setAudioSink();
}
/**
* Plays a test sound.
*
* @private
* @returns {void}
*/
_onClick() {
this._audioElement?.play();
}
/**
* KeyPress handler for accessibility.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this._onClick();
}
}
/**
* Updates the target output device for playing the test sound.
*
* @private
* @returns {void}
*/
_setAudioSink() {
this._audioElement
&& this.props.deviceId
&& this._audioElement.setSinkId(this.props.deviceId);
}
}
export default translate(AudioOutputPreview);

View File

@@ -0,0 +1,107 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../base/icons/components/Icon';
import { IconTrash } from '../../base/icons/svg';
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()(theme => {
return {
callControlContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
},
label: {
...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'
}
}
};
});
/**
* Device hid container.
*
* @param {IProps} props - The props of the component.
* @returns {ReactElement}
*/
function DeviceHidContainer() {
const { t } = useTranslation();
const deviceInfo = useSelector(getDeviceInfo);
const showRequestDeviceInfo = shouldRequestHIDDevice(deviceInfo);
const { classes } = useStyles();
const dispatch = useDispatch();
const onRequestControl = useCallback(() => {
dispatch(requestHidDevice());
}, [ dispatch ]);
const onDeleteHid = useCallback(() => {
dispatch(closeHidDevice());
}, [ dispatch ]);
return (
<div
className = { classes.callControlContainer }
key = 'callControl'>
<label
className = { classes.label }
htmlFor = 'callControl'>
{t('deviceSelection.hid.callControl')}
</label>
{showRequestDeviceInfo && (
<Button
accessibilityLabel = { t('deviceSelection.hid.pairDevice') }
id = 'request-control-btn'
key = 'request-control-btn'
label = { t('deviceSelection.hid.pairDevice') }
onClick = { onRequestControl }
type = { BUTTON_TYPES.SECONDARY } />
)}
{!showRequestDeviceInfo && (
<div className = { classes.hidContainer }>
<p className = { classes.headerConnectedDevice }>{t('deviceSelection.hid.connectedDevices')}</p>
<div className = { classes.deviceRow }>
<span>{deviceInfo.device?.productName}</span>
<Icon
ariaLabel = { t('deviceSelection.hid.deleteDevice') }
className = { classes.deleteDevice }
onClick = { onDeleteHid }
role = 'button'
src = { IconTrash }
tabIndex = { 0 } />
</div>
</div>
)}
</div>
);
}
export default DeviceHidContainer;

View File

@@ -0,0 +1,154 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import Select from '../../base/ui/components/web/Select';
/**
* The type of the React {@code Component} props of {@link DeviceSelector}.
*/
interface IProps {
/**
* MediaDeviceInfos used for display in the select element.
*/
devices: Array<MediaDeviceInfo> | undefined;
/**
* If false, will return a selector with no selection options.
*/
hasPermission: boolean;
/**
* CSS class for the icon to the left of the dropdown trigger.
*/
icon: string;
/**
* The id of the dropdown element.
*/
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 useStyles = makeStyles()(theme => {
return {
textSelector: {
width: '100%',
boxSizing: 'border-box',
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.uiBackground,
padding: '10px 16px',
textAlign: 'center',
...theme.typography.bodyShortRegular,
border: `1px solid ${theme.palette.ui03}`
}
};
});
const DeviceSelector = ({
devices,
hasPermission,
id,
isDisabled,
label,
onSelect,
selectedDeviceId
}: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const _onSelect = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const deviceId = e.target.value;
if (selectedDeviceId !== deviceId) {
onSelect(deviceId);
}
}, [ selectedDeviceId, onSelect ]);
const _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;
if (options.isDisabled || !options.items?.length) {
return (
<div className = { classes.textSelector }>
{triggerText}
</div>
);
}
return (
<Select
id = { id }
label = { t(label) }
onChange = { _onSelect }
options = { options.items }
value = { selectedDeviceId } />
);
};
const _renderNoDevices = () => _createDropdown({
isDisabled: true,
placeholder: t('settings.noDevice')
});
const _renderNoPermission = () => _createDropdown({
isDisabled: true,
placeholder: t('deviceSelection.noPermission')
});
if (hasPermission === undefined) {
return null;
}
if (!hasPermission) {
return _renderNoPermission();
}
if (!devices?.length) {
return _renderNoDevices();
}
const items = devices.map(device => {
return {
value: device.deviceId,
label: device.label || device.deviceId
};
});
const defaultSelected = devices.find(item =>
item.deviceId === selectedDeviceId
);
return _createDropdown({
defaultSelected,
isDisabled,
items,
placeholder: t('deviceSelection.selectADevice')
});
};
export default DeviceSelector;

View File

@@ -0,0 +1,384 @@
import { Theme } from '@mui/material';
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 { 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?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The currently selected desktop share frame rate in the frame rate select dropdown.
*/
currentFramerate: string;
/**
* All available desktop capture frame rates.
*/
desktopShareFramerates: Array<number>;
/**
* True if desktop share settings should be hidden (mobile browsers).
*/
disableDesktopShareSettings: boolean;
/**
* True if device changing is configured to be disallowed. Selectors
* will display as disabled.
*/
disableDeviceChange: boolean;
/**
* Whether the local video can be flipped or not.
*/
disableLocalVideoFlip: boolean | undefined;
/**
* 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}.
*/
interface IState {
/**
* 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<IProps, IState> {
/**
* 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
*/
override 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}
*/
override componentDidUpdate(prevProps: IProps) {
if (prevProps.selectedVideoInputId
!== this.props.selectedVideoInputId) {
this._createVideoInputTrack(this.props.selectedVideoInputId);
}
}
/**
* Ensure preview tracks are destroyed to prevent continued use.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._unMounted = true;
this._disposeVideoInputPreview();
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const {
disableDesktopShareSettings,
disableLocalVideoFlip,
hideAdditionalSettings,
hideVideoInputPreview,
localFlipX,
t
} = this.props;
const classes = withStyles.getClasses(this.props);
return (
<div className = { classes.container }>
{ !hideVideoInputPreview
&& <VideoInputPreview
error = { this.state.previewVideoTrackError }
localFlipX = { localFlipX }
track = { this.state.previewVideoTrack } />
}
<div
aria-live = 'polite'>
{this._renderVideoSelector()}
</div>
{!hideAdditionalSettings && (
<>
{!disableLocalVideoFlip && (
<div className = { classes.checkboxContainer }>
<Checkbox
checked = { localFlipX }
label = { t('videothumbnail.mirrorVideo') }
// eslint-disable-next-line react/jsx-no-bind
onChange = { () => super._onChange({ localFlipX: !localFlipX }) } />
</div>
)}
{!disableDesktopShareSettings && this._renderFramerateSelect()}
</>
)}
</div>
);
}
/**
* 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<any> {
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 (
<DeviceSelector
{ ...videoConfig }
key = { videoConfig.id } />
);
}
/**
* 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<HTMLSelectElement>) {
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 (
<Select
bottomLabel = { parseInt(currentFramerate, 10) > SS_DEFAULT_FRAME_RATE
? t('settings.desktopShareHighFpsWarning')
: t('settings.desktopShareWarning') }
id = 'more-framerate-select'
label = { t('settings.desktopShareFramerate') }
onChange = { this._onFramerateItemSelect }
options = { frameRateItems }
value = { currentFramerate } />
);
}
}
const mapStateToProps = (state: IReduxState) => {
return {
availableDevices: state['features/base/devices'].availableDevices ?? {}
};
};
export default connect(mapStateToProps)(withStyles(translate(VideoDeviceSelection), styles));

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import { Video } from '../../base/media/components/index';
/**
* The type of the React {@code Component} props of {@link VideoInputPreview}.
*/
interface IProps {
/**
* An error message to display instead of a preview. Displaying an error
* will take priority over displaying a video preview.
*/
error: string | null;
/**
* Whether or not the local video is flipped.
*/
localFlipX: boolean;
/**
* The JitsiLocalTrack to display.
*/
track: Object;
}
const useStyles = makeStyles()(theme => {
return {
container: {
position: 'relative',
borderRadius: '3px',
overflow: 'hidden',
marginBottom: theme.spacing(4),
backgroundColor: theme.palette.uiBackground
},
video: {
height: 'auto',
width: '100%',
overflow: 'hidden'
},
errorText: {
color: theme.palette.text01,
left: 0,
position: 'absolute',
right: 0,
textAlign: 'center',
top: '50%'
}
};
});
const VideoInputPreview = ({ error, localFlipX, track }: IProps) => {
const { classes, cx } = useStyles();
return (
<div className = { classes.container }>
<Video
className = { cx(classes.video, localFlipX && 'flipVideoX') }
id = 'settings_video_input_preview'
playsinline = { true }
videoTrack = {{ jitsiTrack: track }} />
{error && (
<div className = { classes.errorText }>
{error}
</div>
)}
</div>
);
};
export default VideoInputPreview;

View File

@@ -0,0 +1,284 @@
import { IStore } from '../app/types';
import { IStateful } from '../base/app/types';
import { getWebHIDFeatureConfig } from '../base/config/functions.web';
import {
addPendingDeviceRequest,
getAvailableDevices,
setAudioInputDeviceAndUpdateSettings,
setAudioOutputDevice,
setVideoInputDeviceAndUpdateSettings
} from '../base/devices/actions.web';
import {
areDeviceLabelsInitialized,
getAudioOutputDeviceId,
getDeviceIdByLabel,
groupDevicesByKind
} from '../base/devices/functions.web';
import { isIosMobileBrowser, isMobileBrowser } from '../base/environment/utils';
import JitsiMeetJS from '../base/lib-jitsi-meet';
import { toState } from '../base/redux/functions';
import {
getUserSelectedCameraDeviceId,
getUserSelectedMicDeviceId,
getUserSelectedOutputDeviceId
} from '../base/settings/functions.web';
import { isNoiseSuppressionEnabled } from '../noise-suppression/functions';
import { isPrejoinPageVisible } from '../prejoin/functions';
import { SS_DEFAULT_FRAME_RATE, SS_SUPPORTED_FRAMERATES } from '../settings/constants';
import { isDeviceHidSupported } from '../web-hid/functions';
/**
* Returns the properties for the audio device selection dialog from Redux state.
*
* @param {IStateful} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {boolean} isDisplayedOnWelcomePage - Indicates whether the device selection dialog is displayed on the
* welcome page or not.
* @returns {Object} - The properties for the audio device selection dialog.
*/
export function getAudioDeviceSelectionDialogProps(stateful: IStateful, isDisplayedOnWelcomePage: boolean) {
// On mobile Safari because of https://bugs.webkit.org/show_bug.cgi?id=179363#c30, the old track is stopped
// by the browser when a new track is created for preview. That's why we are disabling all previews.
const disablePreviews = isIosMobileBrowser();
const state = toState(stateful);
const settings = state['features/base/settings'];
const { permissions } = state['features/base/devices'];
const inputDeviceChangeSupported = JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('input');
const speakerChangeSupported = JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output');
const userSelectedMic = getUserSelectedMicDeviceId(state);
const deviceHidSupported = isDeviceHidSupported() && getWebHIDFeatureConfig(state);
const noiseSuppressionEnabled = isNoiseSuppressionEnabled(state);
const hideNoiseSuppression = isPrejoinPageVisible(state) || isDisplayedOnWelcomePage;
// When the previews are disabled we don't need multiple audio input support in order to change the mic. This is the
// case for Safari on iOS.
let disableAudioInputChange
= !JitsiMeetJS.mediaDevices.isMultipleAudioInputSupported() && !(disablePreviews && inputDeviceChangeSupported);
let selectedAudioInputId = settings.micDeviceId;
let selectedAudioOutputId = getAudioOutputDeviceId();
// audio input change will be a problem only when we are in a
// conference and this is not supported, when we open device selection on
// welcome page changing input devices will not be a problem
// on welcome page we also show only what we have saved as user selected devices
if (isDisplayedOnWelcomePage) {
disableAudioInputChange = false;
selectedAudioInputId = userSelectedMic;
selectedAudioOutputId = getUserSelectedOutputDeviceId(state);
}
// we fill the device selection dialog with the devices that are currently
// used or if none are currently used with what we have in settings(user selected)
return {
disableAudioInputChange,
disableDeviceChange: !JitsiMeetJS.mediaDevices.isDeviceChangeAvailable(),
hasAudioPermission: permissions.audio,
hideAudioInputPreview: disableAudioInputChange || !JitsiMeetJS.isCollectingLocalStats() || disablePreviews,
hideAudioOutputPreview: !speakerChangeSupported || disablePreviews,
hideAudioOutputSelect: !speakerChangeSupported,
hideDeviceHIDContainer: !deviceHidSupported,
hideNoiseSuppression,
noiseSuppressionEnabled,
selectedAudioInputId,
selectedAudioOutputId
};
}
/**
* Returns the properties for the device selection dialog from Redux state.
*
* @param {IStateful} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {boolean} isDisplayedOnWelcomePage - Indicates whether the device selection dialog is displayed on the
* welcome page or not.
* @returns {Object} - The properties for the device selection dialog.
*/
export function getVideoDeviceSelectionDialogProps(stateful: IStateful, isDisplayedOnWelcomePage: boolean) {
// On mobile Safari because of https://bugs.webkit.org/show_bug.cgi?id=179363#c30, the old track is stopped
// by the browser when a new track is created for preview. That's why we are disabling all previews.
const disablePreviews = isMobileBrowser();
const state = toState(stateful);
const settings = state['features/base/settings'];
const { permissions } = state['features/base/devices'];
const inputDeviceChangeSupported = JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('input');
const userSelectedCamera = getUserSelectedCameraDeviceId(state);
const { localFlipX } = state['features/base/settings'];
const { disableLocalVideoFlip } = state['features/base/config'];
const hideAdditionalSettings = isPrejoinPageVisible(state) || isDisplayedOnWelcomePage;
const framerate = state['features/screen-share'].captureFrameRate ?? SS_DEFAULT_FRAME_RATE;
let disableVideoInputSelect = !inputDeviceChangeSupported;
let selectedVideoInputId = settings.cameraDeviceId || userSelectedCamera;
// audio input change will be a problem only when we are in a
// conference and this is not supported, when we open device selection on
// welcome page changing input devices will not be a problem
// on welcome page we also show only what we have saved as user selected devices
if (isDisplayedOnWelcomePage) {
disableVideoInputSelect = false;
selectedVideoInputId = userSelectedCamera;
}
// we fill the device selection dialog with the devices that are currently
// used or if none are currently used with what we have in settings(user selected)
return {
currentFramerate: framerate,
desktopShareFramerates: SS_SUPPORTED_FRAMERATES,
disableDesktopShareSettings: isMobileBrowser(),
disableDeviceChange: !JitsiMeetJS.mediaDevices.isDeviceChangeAvailable(),
disableVideoInputSelect,
disableLocalVideoFlip,
hasVideoPermission: permissions.video,
hideAdditionalSettings,
hideVideoInputPreview: !inputDeviceChangeSupported || disablePreviews,
localFlipX: Boolean(localFlipX),
selectedVideoInputId
};
}
/**
* Processes device requests from external applications.
*
* @param {Dispatch} dispatch - The redux {@code dispatch} function.
* @param {Function} getState - The redux function that gets/retrieves the redux
* state.
* @param {Object} request - The request to be processed.
* @param {Function} responseCallback - The callback that will send the
* response.
* @returns {boolean} - True if the request has been processed and false otherwise.
*/
export function processExternalDeviceRequest( // eslint-disable-line max-params
dispatch: IStore['dispatch'],
getState: IStore['getState'],
request: any,
responseCallback: Function) {
if (request.type !== 'devices') {
return false;
}
const state = getState();
const settings = state['features/base/settings'];
let result = true;
switch (request.name) {
case 'isDeviceListAvailable':
// TODO(saghul): remove this, eventually.
responseCallback(true);
break;
case 'isDeviceChangeAvailable':
responseCallback(
JitsiMeetJS.mediaDevices.isDeviceChangeAvailable(
request.deviceType));
break;
case 'isMultipleAudioInputSupported':
responseCallback(JitsiMeetJS.isMultipleAudioInputSupported());
break;
case 'getCurrentDevices': // @ts-ignore
dispatch(getAvailableDevices()).then((devices: MediaDeviceInfo[]) => {
if (areDeviceLabelsInitialized(state)) {
const deviceDescriptions: any = {
audioInput: undefined,
audioOutput: undefined,
videoInput: undefined
};
const currentlyUsedDeviceIds = new Set([
getAudioOutputDeviceId(),
settings.micDeviceId ?? getUserSelectedMicDeviceId(state),
settings.cameraDeviceId ?? getUserSelectedCameraDeviceId(state)
]);
devices.forEach(device => {
const { deviceId, kind } = device;
if (currentlyUsedDeviceIds.has(deviceId)) {
switch (kind) {
case 'audioinput':
deviceDescriptions.audioInput = device;
break;
case 'audiooutput':
deviceDescriptions.audioOutput = device;
break;
case 'videoinput':
deviceDescriptions.videoInput = device;
break;
}
}
});
responseCallback(deviceDescriptions);
} else {
// The labels are not available if the A/V permissions are
// not yet granted.
dispatch(addPendingDeviceRequest({
type: 'devices',
name: 'getCurrentDevices',
responseCallback
}));
}
});
break;
case 'getAvailableDevices': // @ts-ignore
dispatch(getAvailableDevices()).then((devices: MediaDeviceInfo[]) => {
if (areDeviceLabelsInitialized(state)) {
responseCallback(groupDevicesByKind(devices));
} else {
// The labels are not available if the A/V permissions are
// not yet granted.
dispatch(addPendingDeviceRequest({
type: 'devices',
name: 'getAvailableDevices',
responseCallback
}));
}
});
break;
case 'setDevice': {
const { device } = request;
if (!areDeviceLabelsInitialized(state)) {
dispatch(addPendingDeviceRequest({
type: 'devices',
name: 'setDevice',
device,
responseCallback
}));
return true;
}
const { label, id } = device;
const deviceId = label
? getDeviceIdByLabel(state, device.label, device.kind)
: id;
if (deviceId) {
switch (device.kind) {
case 'audioinput':
dispatch(setAudioInputDeviceAndUpdateSettings(deviceId));
break;
case 'audiooutput':
dispatch(setAudioOutputDevice(deviceId));
break;
case 'videoinput':
dispatch(setVideoInputDeviceAndUpdateSettings(deviceId));
break;
default:
result = false;
}
} else {
result = false;
}
responseCallback(result);
break;
}
default:
return false;
}
return true;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/device-selection');

View File

@@ -0,0 +1,24 @@
import { UPDATE_DEVICE_LIST } from '../base/devices/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
/**
* Implements the middleware of the feature device-selection.
*
* @param {Store} store - Redux store.
* @returns {Function}
*/
// eslint-disable-next-line no-unused-vars
MiddlewareRegistry.register(store => next => action => {
const result = next(action);
if (action.type === UPDATE_DEVICE_LIST) {
const state = store.getState();
const { availableDevices } = state['features/base/devices'] || {};
if (typeof APP !== 'undefined') {
APP.API.notifyDeviceListChanged(availableDevices);
}
}
return result;
});