This commit is contained in:
96
react/features/device-selection/actions.web.ts
Normal file
96
react/features/device-selection/actions.web.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
284
react/features/device-selection/functions.web.ts
Normal file
284
react/features/device-selection/functions.web.ts
Normal 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;
|
||||
}
|
||||
|
||||
3
react/features/device-selection/logger.ts
Normal file
3
react/features/device-selection/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/device-selection');
|
||||
24
react/features/device-selection/middleware.ts
Normal file
24
react/features/device-selection/middleware.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user