This commit is contained in:
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user