Calling: Device Selection

This commit is contained in:
Josh Perez 2020-08-26 20:03:42 -04:00 committed by Josh Perez
parent 8b34294c97
commit 8ab1013f70
17 changed files with 1038 additions and 135 deletions

View file

@ -31,17 +31,18 @@ const defaultProps = {
callDetails,
callState: CallState.Accepted,
declineCall: action('decline-call'),
getVideoCapturer: () => ({}),
getVideoRenderer: () => ({}),
hangUp: action('hang-up'),
hasLocalAudio: true,
hasLocalVideo: true,
hasRemoteVideo: true,
i18n,
renderDeviceSelection: () => <div />,
setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
setVideoCapturer: action('set-video-capturer'),
setVideoRenderer: action('set-video-renderer'),
setRendererCanvas: action('set-renderer-canvas'),
settingsDialogOpen: false,
toggleSettings: action('toggle-settings'),
};
const permutations = [

View file

@ -10,7 +10,10 @@ import { CallDetailsType } from '../state/ducks/calling';
type CallManagerPropsType = {
callDetails?: CallDetailsType;
callState?: CallState;
renderDeviceSelection: () => JSX.Element;
settingsDialogOpen: boolean;
};
type PropsType = IncomingCallBarPropsType &
CallScreenPropsType &
CallManagerPropsType;
@ -20,17 +23,18 @@ export const CallManager = ({
callDetails,
callState,
declineCall,
getVideoCapturer,
getVideoRenderer,
hangUp,
hasLocalAudio,
hasLocalVideo,
hasRemoteVideo,
i18n,
renderDeviceSelection,
setLocalAudio,
setLocalPreview,
setLocalVideo,
setVideoCapturer,
setVideoRenderer,
setRendererCanvas,
settingsDialogOpen,
toggleSettings,
}: PropsType): JSX.Element | null => {
if (!callDetails || !callState) {
return null;
@ -43,21 +47,23 @@ export const CallManager = ({
if (outgoing || ongoing) {
return (
<CallScreen
callDetails={callDetails}
callState={callState}
getVideoCapturer={getVideoCapturer}
getVideoRenderer={getVideoRenderer}
hangUp={hangUp}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
hasRemoteVideo={hasRemoteVideo}
setVideoCapturer={setVideoCapturer}
setVideoRenderer={setVideoRenderer}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
/>
<>
<CallScreen
callDetails={callDetails}
callState={callState}
hangUp={hangUp}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
hasRemoteVideo={hasRemoteVideo}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
</>
);
}

View file

@ -30,17 +30,16 @@ const callDetails = {
const defaultProps = {
callDetails,
callState: CallState.Accepted,
getVideoCapturer: () => ({}),
getVideoRenderer: () => ({}),
hangUp: action('hang-up'),
hasLocalAudio: true,
hasLocalVideo: true,
hasRemoteVideo: true,
i18n,
setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
setVideoCapturer: action('set-video-capturer'),
setVideoRenderer: action('set-video-renderer'),
setRendererCanvas: action('set-renderer-canvas'),
toggleSettings: action('toggle-settings'),
};
const permutations = [

View file

@ -4,14 +4,13 @@ import {
CallDetailsType,
HangUpType,
SetLocalAudioType,
SetLocalPreviewType,
SetLocalVideoType,
SetVideoCapturerType,
SetVideoRendererType,
SetRendererCanvasType,
} from '../state/ducks/calling';
import { Avatar } from './Avatar';
import { CallState } from '../types/Calling';
import { LocalizerType } from '../types/Util';
import { CanvasVideoRenderer, GumVideoCapturer } from '../window.d';
type CallingButtonProps = {
classNameSuffix: string;
@ -37,12 +36,6 @@ const CallingButton = ({
export type PropsType = {
callDetails?: CallDetailsType;
callState?: CallState;
getVideoCapturer: (
ref: React.RefObject<HTMLVideoElement>
) => GumVideoCapturer;
getVideoRenderer: (
ref: React.RefObject<HTMLCanvasElement>
) => CanvasVideoRenderer;
hangUp: (_: HangUpType) => void;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
@ -50,8 +43,9 @@ export type PropsType = {
i18n: LocalizerType;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setVideoCapturer: (_: SetVideoCapturerType) => void;
setVideoRenderer: (_: SetVideoRendererType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
toggleSettings: () => void;
};
type StateType = {
@ -79,11 +73,6 @@ export class CallScreen extends React.Component<PropsType, StateType> {
this.controlsFadeTimer = null;
this.localVideoRef = React.createRef();
this.remoteVideoRef = React.createRef();
this.setVideoCapturerAndRenderer(
props.getVideoCapturer(this.localVideoRef),
props.getVideoRenderer(this.remoteVideoRef)
);
}
public componentDidMount() {
@ -92,6 +81,9 @@ export class CallScreen extends React.Component<PropsType, StateType> {
this.fadeControls();
document.addEventListener('keydown', this.handleKeyDown);
this.props.setLocalPreview({ element: this.localVideoRef });
this.props.setRendererCanvas({ element: this.remoteVideoRef });
}
public componentWillUnmount() {
@ -103,7 +95,8 @@ export class CallScreen extends React.Component<PropsType, StateType> {
if (this.controlsFadeTimer) {
clearTimeout(this.controlsFadeTimer);
}
this.setVideoCapturerAndRenderer(null, null);
this.props.setLocalPreview({ element: undefined });
this.props.setRendererCanvas({ element: undefined });
}
updateAcceptedTimer = () => {
@ -203,6 +196,8 @@ export class CallScreen extends React.Component<PropsType, StateType> {
hasLocalAudio,
hasLocalVideo,
hasRemoteVideo,
i18n,
toggleSettings,
} = this.props;
const { showControls } = this.state;
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
@ -241,6 +236,13 @@ export class CallScreen extends React.Component<PropsType, StateType> {
{callDetails.title}
</div>
{this.renderMessage(callState)}
<div className="module-ongoing-call__settings">
<button
aria-label={i18n('callingDeviceSelection__settings')}
className="module-ongoing-call__settings--button"
onClick={toggleSettings}
/>
</div>
</div>
{hasRemoteVideo
? this.renderRemoteVideo()
@ -356,27 +358,4 @@ export class CallScreen extends React.Component<PropsType, StateType> {
}
return `${mins}:${secs}`;
}
private setVideoCapturerAndRenderer(
capturer: GumVideoCapturer | null,
renderer: CanvasVideoRenderer | null
) {
const { callDetails, setVideoCapturer, setVideoRenderer } = this.props;
if (!callDetails) {
return;
}
const { callId } = callDetails;
setVideoCapturer({
callId,
capturer,
});
setVideoRenderer({
callId,
renderer,
});
}
}

View file

@ -0,0 +1,145 @@
import * as React from 'react';
import { CallingDeviceSelection, Props } from './CallingDeviceSelection';
// @ts-ignore
import { setup as setupI18n } from '../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../_locales/en/messages.json';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
const i18n = setupI18n('en', enMessages);
const audioDevice = {
name: '',
index: 0,
same_name_index: 0,
};
const createProps = ({
availableMicrophones = [],
availableSpeakers = [],
selectedMicrophone = audioDevice,
selectedSpeaker = audioDevice,
availableCameras = [],
selectedCamera = '',
}: Partial<Props> = {}): Props => ({
availableCameras,
availableMicrophones,
availableSpeakers,
changeIODevice: action('change-io-device'),
i18n,
selectedCamera,
selectedMicrophone,
selectedSpeaker,
toggleSettings: action('toggle-settings'),
});
const stories = storiesOf('Components/CallingDeviceSelection', module);
stories.add('Default', () => {
return <CallingDeviceSelection {...createProps()} />;
});
stories.add('Some Devices', () => {
const availableSpeakers = [
{
name: 'Default - Internal Microphone',
index: 0,
same_name_index: 0,
},
{
name: "Natalie's Airpods (Bluetooth)",
index: 1,
same_name_index: 1,
},
{
name: 'UE Boom (Bluetooth)',
index: 2,
same_name_index: 2,
},
];
const selectedSpeaker = availableSpeakers[0];
const props = createProps({
availableSpeakers,
selectedSpeaker,
});
return <CallingDeviceSelection {...props} />;
});
stories.add('All Devices', () => {
const availableSpeakers = [
{
name: 'Default - Internal Speakers',
index: 0,
same_name_index: 0,
},
{
name: "Natalie's Airpods (Bluetooth)",
index: 1,
same_name_index: 1,
},
{
name: 'UE Boom (Bluetooth)',
index: 2,
same_name_index: 2,
},
];
const selectedSpeaker = availableSpeakers[0];
const availableMicrophones = [
{
name: 'Default - Internal Microphone',
index: 0,
same_name_index: 0,
},
{
name: "Natalie's Airpods (Bluetooth)",
index: 1,
same_name_index: 1,
},
];
const selectedMicrophone = availableMicrophones[0];
const availableCameras = [
{
deviceId:
'dfbe6effe70b0611ba0fdc2a9ea3f39f6cb110e6687948f7e5f016c111b7329c',
groupId:
'63ee218d2446869e40adfc958ff98263e51f74382b0143328ee4826f20a76f47',
kind: 'videoinput' as MediaDeviceKind,
label: 'FaceTime HD Camera (Built-in) (9fba:bced)',
toJSON() {
return '';
},
},
{
deviceId:
'e2db196a31d50ff9b135299dc0beea67f65b1a25a06d8a4ce76976751bb7a08d',
groupId:
'218ba7f00d7b1239cca15b9116769e5e7d30cc01104ebf84d667643661e0ecf9',
kind: 'videoinput' as MediaDeviceKind,
label: 'Logitech Webcam (4e72:9058)',
toJSON() {
return '';
},
},
];
const selectedCamera =
'dfbe6effe70b0611ba0fdc2a9ea3f39f6cb110e6687948f7e5f016c111b7329c';
const props = createProps({
availableCameras,
availableMicrophones,
availableSpeakers,
selectedCamera,
selectedMicrophone,
selectedSpeaker,
});
return <CallingDeviceSelection {...props} />;
});

View file

@ -0,0 +1,192 @@
import * as React from 'react';
import { ConfirmationModal } from './ConfirmationModal';
import { LocalizerType } from '../types/Util';
import {
AudioDevice,
CallingDeviceType,
ChangeIODevicePayloadType,
MediaDeviceSettings,
} from '../types/Calling';
export type Props = MediaDeviceSettings & {
changeIODevice: (payload: ChangeIODevicePayloadType) => void;
i18n: LocalizerType;
toggleSettings: () => void;
};
function renderAudioOptions(
devices: Array<AudioDevice>,
i18n: LocalizerType,
selectedDevice: AudioDevice | undefined
): JSX.Element {
if (!devices.length) {
return (
<option aria-selected={true}>
{i18n('callingDeviceSelection__select--no-device')}
</option>
);
}
return (
<>
{devices.map((device: AudioDevice) => {
const isSelected =
selectedDevice && selectedDevice.index === device.index;
return (
<option
aria-selected={isSelected}
key={device.index}
value={device.index}
>
{device.name}
</option>
);
})}
</>
);
}
function renderVideoOptions(
devices: Array<MediaDeviceInfo>,
i18n: LocalizerType,
selectedCamera: string | undefined
): JSX.Element {
if (!devices.length) {
return (
<option aria-selected={true}>
{i18n('callingDeviceSelection__select--no-device')}
</option>
);
}
return (
<>
{devices.map((device: MediaDeviceInfo) => {
const isSelected = selectedCamera === device.deviceId;
return (
<option
aria-selected={isSelected}
key={device.deviceId}
value={device.deviceId}
>
{device.label}
</option>
);
})}
</>
);
}
function createAudioChangeHandler(
devices: Array<AudioDevice>,
changeIODevice: (payload: ChangeIODevicePayloadType) => void,
type: CallingDeviceType.SPEAKER | CallingDeviceType.MICROPHONE
) {
return (ev: React.FormEvent<HTMLSelectElement>): void => {
changeIODevice({
type,
selectedDevice: devices[Number(ev.currentTarget.value)],
});
};
}
function createCameraChangeHandler(
changeIODevice: (payload: ChangeIODevicePayloadType) => void
) {
return (ev: React.FormEvent<HTMLSelectElement>): void => {
changeIODevice({
type: CallingDeviceType.CAMERA,
selectedDevice: String(ev.currentTarget.value),
});
};
}
export const CallingDeviceSelection = ({
availableCameras,
availableMicrophones,
availableSpeakers,
changeIODevice,
i18n,
selectedCamera,
selectedMicrophone,
selectedSpeaker,
toggleSettings,
}: Props): JSX.Element => {
const selectedMicrophoneIndex = selectedMicrophone
? selectedMicrophone.index
: undefined;
const selectedSpeakerIndex = selectedSpeaker
? selectedSpeaker.index
: undefined;
return (
<ConfirmationModal actions={[]} i18n={i18n} onClose={toggleSettings}>
<div className="module-calling-device-selection">
<button
className="module-calling-device-selection__close-button"
onClick={toggleSettings}
tabIndex={0}
/>
</div>
<h1 className="module-calling-device-selection__title">
{i18n('callingDeviceSelection__settings')}
</h1>
<label className="module-calling-device-selection__label">
{i18n('callingDeviceSelection__label--video')}
</label>
<div className="module-calling-device-selection__select">
<select
disabled={!availableCameras.length}
name="video"
// tslint:disable-next-line react-a11y-no-onchange
onChange={createCameraChangeHandler(changeIODevice)}
value={selectedCamera}
>
{renderVideoOptions(availableCameras, i18n, selectedCamera)}
</select>
</div>
<label className="module-calling-device-selection__label">
{i18n('callingDeviceSelection__label--audio-input')}
</label>
<div className="module-calling-device-selection__select">
<select
disabled={!availableMicrophones.length}
name="audio-input"
// tslint:disable-next-line react-a11y-no-onchange
onChange={createAudioChangeHandler(
availableMicrophones,
changeIODevice,
CallingDeviceType.MICROPHONE
)}
value={selectedMicrophoneIndex}
>
{renderAudioOptions(availableMicrophones, i18n, selectedMicrophone)}
</select>
</div>
<label className="module-calling-device-selection__label">
{i18n('callingDeviceSelection__label--audio-output')}
</label>
<div className="module-calling-device-selection__select">
<select
disabled={!availableSpeakers.length}
name="audio-output"
// tslint:disable-next-line react-a11y-no-onchange
onChange={createAudioChangeHandler(
availableSpeakers,
changeIODevice,
CallingDeviceType.SPEAKER
)}
value={selectedSpeakerIndex}
>
{renderAudioOptions(availableSpeakers, i18n, selectedSpeaker)}
</select>
</div>
</ConfirmationModal>
);
};