Initial group calling support
This commit is contained in:
parent
e398520db0
commit
022c4bd0f4
31 changed files with 2530 additions and 414 deletions
|
@ -2,11 +2,19 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { CallManager } from './CallManager';
|
||||
import { CallEndedReason, CallState } from '../types/Calling';
|
||||
import { CallManager, PropsType } from './CallManager';
|
||||
import {
|
||||
CallEndedReason,
|
||||
CallMode,
|
||||
CallState,
|
||||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import { ConversationTypeType } from '../state/ducks/conversations';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
@ -21,6 +29,9 @@ const conversation = {
|
|||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
markedUnread: false,
|
||||
type: 'direct' as ConversationTypeType,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
|
@ -29,6 +40,17 @@ const defaultProps = {
|
|||
cancelCall: action('cancel-call'),
|
||||
closeNeedPermissionScreen: action('close-need-permission-screen'),
|
||||
declineCall: action('decline-call'),
|
||||
// We allow `any` here because these are fake and actually come from RingRTC, which we
|
||||
// can't import.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
createCanvasVideoRenderer: () =>
|
||||
({
|
||||
setCanvas: noop,
|
||||
enable: noop,
|
||||
disable: noop,
|
||||
} as any),
|
||||
getGroupCallVideoFrameSource: noop as any,
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
hangUp: action('hang-up'),
|
||||
i18n,
|
||||
me: {
|
||||
|
@ -52,10 +74,11 @@ const permutations = [
|
|||
props: {},
|
||||
},
|
||||
{
|
||||
title: 'Call Manager (ongoing)',
|
||||
title: 'Call Manager (ongoing direct call)',
|
||||
props: {
|
||||
activeCall: {
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: CallState.Accepted,
|
||||
isIncoming: false,
|
||||
|
@ -75,11 +98,36 @@ const permutations = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Call Manager (ongoing group call)',
|
||||
props: {
|
||||
activeCall: {
|
||||
call: {
|
||||
callMode: CallMode.Group as CallMode.Group,
|
||||
conversationId: '3051234567',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
remoteParticipants: [],
|
||||
},
|
||||
activeCallState: {
|
||||
conversationId: '3051234567',
|
||||
joinedAt: Date.now(),
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
participantsList: false,
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
},
|
||||
conversation,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Call Manager (ringing)',
|
||||
props: {
|
||||
incomingCall: {
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: CallState.Ringing,
|
||||
isIncoming: true,
|
||||
|
@ -95,6 +143,7 @@ const permutations = [
|
|||
props: {
|
||||
activeCall: {
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: CallState.Ended,
|
||||
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
|
||||
|
@ -118,10 +167,12 @@ const permutations = [
|
|||
];
|
||||
|
||||
storiesOf('Components/CallManager', module).add('Iterations', () => {
|
||||
return permutations.map(({ props, title }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<CallManager {...defaultProps} {...props} />
|
||||
</>
|
||||
));
|
||||
return permutations.map(
|
||||
({ props, title }: { props: Partial<PropsType>; title: string }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<CallManager {...defaultProps} {...props} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,56 +1,58 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { CallingPip } from './CallingPip';
|
||||
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
|
||||
import { CallingLobby } from './CallingLobby';
|
||||
import { CallScreen } from './CallScreen';
|
||||
import { IncomingCallBar } from './IncomingCallBar';
|
||||
import { CallState, CallEndedReason } from '../types/Calling';
|
||||
import {
|
||||
ActiveCallStateType,
|
||||
CallMode,
|
||||
CallState,
|
||||
CallEndedReason,
|
||||
CanvasVideoRenderer,
|
||||
VideoFrameSource,
|
||||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import {
|
||||
AcceptCallType,
|
||||
ActiveCallStateType,
|
||||
CancelCallType,
|
||||
DeclineCallType,
|
||||
DirectCallStateType,
|
||||
StartCallType,
|
||||
SetLocalAudioType,
|
||||
GroupCallStateType,
|
||||
HangUpType,
|
||||
SetLocalAudioType,
|
||||
SetLocalPreviewType,
|
||||
SetLocalVideoType,
|
||||
SetRendererCanvasType,
|
||||
StartCallType,
|
||||
} from '../state/ducks/calling';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
interface PropsType {
|
||||
activeCall?: {
|
||||
call: DirectCallStateType;
|
||||
activeCallState: ActiveCallStateType;
|
||||
conversation: {
|
||||
id: string;
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
};
|
||||
interface ActiveCallType {
|
||||
call: DirectCallStateType | GroupCallStateType;
|
||||
activeCallState: ActiveCallStateType;
|
||||
conversation: ConversationType;
|
||||
}
|
||||
|
||||
export interface PropsType {
|
||||
activeCall?: ActiveCallType;
|
||||
availableCameras: Array<MediaDeviceInfo>;
|
||||
cancelCall: () => void;
|
||||
cancelCall: (_: CancelCallType) => void;
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
closeNeedPermissionScreen: () => void;
|
||||
getGroupCallVideoFrameSource: (
|
||||
conversationId: string,
|
||||
demuxId: number
|
||||
) => VideoFrameSource;
|
||||
incomingCall?: {
|
||||
call: DirectCallStateType;
|
||||
conversation: {
|
||||
id: string;
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
conversation: ConversationType;
|
||||
};
|
||||
renderDeviceSelection: () => JSX.Element;
|
||||
startCall: (payload: StartCallType) => void;
|
||||
|
@ -75,16 +77,19 @@ interface PropsType {
|
|||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export const CallManager = ({
|
||||
acceptCall,
|
||||
interface ActiveCallManagerPropsType extends PropsType {
|
||||
activeCall: ActiveCallType;
|
||||
}
|
||||
|
||||
const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
|
||||
activeCall,
|
||||
availableCameras,
|
||||
cancelCall,
|
||||
closeNeedPermissionScreen,
|
||||
declineCall,
|
||||
createCanvasVideoRenderer,
|
||||
hangUp,
|
||||
i18n,
|
||||
incomingCall,
|
||||
getGroupCallVideoFrameSource,
|
||||
me,
|
||||
renderDeviceSelection,
|
||||
setLocalAudio,
|
||||
|
@ -95,21 +100,46 @@ export const CallManager = ({
|
|||
toggleParticipants,
|
||||
togglePip,
|
||||
toggleSettings,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
if (activeCall) {
|
||||
const { call, activeCallState, conversation } = activeCall;
|
||||
const { callState, callEndedReason } = call;
|
||||
const {
|
||||
joinedAt,
|
||||
}) => {
|
||||
const { call, activeCallState, conversation } = activeCall;
|
||||
const {
|
||||
joinedAt,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
settingsDialogOpen,
|
||||
pip,
|
||||
} = activeCallState;
|
||||
|
||||
const cancelActiveCall = useCallback(() => {
|
||||
cancelCall({ conversationId: conversation.id });
|
||||
}, [cancelCall, conversation.id]);
|
||||
|
||||
const joinActiveCall = useCallback(() => {
|
||||
startCall({
|
||||
callMode: call.callMode,
|
||||
conversationId: conversation.id,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
settingsDialogOpen,
|
||||
pip,
|
||||
} = activeCallState;
|
||||
});
|
||||
}, [startCall, call.callMode, conversation.id, hasLocalAudio, hasLocalVideo]);
|
||||
|
||||
const ended = callState === CallState.Ended;
|
||||
if (ended) {
|
||||
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) {
|
||||
const getGroupCallVideoFrameSourceForActiveCall = useCallback(
|
||||
(demuxId: number) => {
|
||||
return getGroupCallVideoFrameSource(conversation.id, demuxId);
|
||||
},
|
||||
[getGroupCallVideoFrameSource, conversation.id]
|
||||
);
|
||||
|
||||
let showCallLobby: boolean;
|
||||
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct: {
|
||||
const { callState, callEndedReason } = call;
|
||||
const ended = callState === CallState.Ended;
|
||||
if (
|
||||
ended &&
|
||||
callEndedReason === CallEndedReason.RemoteHangupNeedPermission
|
||||
) {
|
||||
return (
|
||||
<CallNeedPermissionScreen
|
||||
close={closeNeedPermissionScreen}
|
||||
|
@ -118,72 +148,36 @@ export const CallManager = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
showCallLobby = !callState;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!callState) {
|
||||
return (
|
||||
<>
|
||||
<CallingLobby
|
||||
availableCameras={availableCameras}
|
||||
conversation={conversation}
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
isGroupCall={false}
|
||||
me={me}
|
||||
onCallCanceled={cancelCall}
|
||||
onJoinCall={() => {
|
||||
startCall({
|
||||
conversationId: conversation.id,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
});
|
||||
}}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalVideo={setLocalVideo}
|
||||
toggleParticipants={toggleParticipants}
|
||||
toggleSettings={toggleSettings}
|
||||
/>
|
||||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
|
||||
|
||||
if (pip) {
|
||||
return (
|
||||
<CallingPip
|
||||
conversation={conversation}
|
||||
hangUp={hangUp}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
i18n={i18n}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
togglePip={togglePip}
|
||||
/>
|
||||
);
|
||||
case CallMode.Group: {
|
||||
showCallLobby = call.joinState === GroupCallJoinState.NotJoined;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
|
||||
if (showCallLobby) {
|
||||
return (
|
||||
<>
|
||||
<CallScreen
|
||||
<CallingLobby
|
||||
availableCameras={availableCameras}
|
||||
conversation={conversation}
|
||||
callState={callState}
|
||||
hangUp={hangUp}
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
joinedAt={joinedAt}
|
||||
// TODO: Set this to `true` for group calls. We can get away with this for
|
||||
// now because it only affects rendering. See DESKTOP-888 and DESKTOP-889.
|
||||
isGroupCall={false}
|
||||
me={me}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
onCallCanceled={cancelActiveCall}
|
||||
onJoinCall={joinActiveCall}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalVideo={setLocalVideo}
|
||||
togglePip={togglePip}
|
||||
toggleParticipants={toggleParticipants}
|
||||
toggleSettings={toggleSettings}
|
||||
/>
|
||||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
|
@ -191,6 +185,58 @@ export const CallManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: Group calls should also support the PiP. See DESKTOP-886.
|
||||
if (pip && call.callMode === CallMode.Direct) {
|
||||
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
|
||||
|
||||
return (
|
||||
<CallingPip
|
||||
conversation={conversation}
|
||||
hangUp={hangUp}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
i18n={i18n}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
togglePip={togglePip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallScreen
|
||||
call={call}
|
||||
conversation={conversation}
|
||||
createCanvasVideoRenderer={createCanvasVideoRenderer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
|
||||
hangUp={hangUp}
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
joinedAt={joinedAt}
|
||||
me={me}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalVideo={setLocalVideo}
|
||||
togglePip={togglePip}
|
||||
toggleSettings={toggleSettings}
|
||||
/>
|
||||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CallManager: React.FC<PropsType> = props => {
|
||||
const { activeCall, incomingCall, acceptCall, declineCall, i18n } = props;
|
||||
|
||||
if (activeCall) {
|
||||
// `props` should logically have an `activeCall` at this point, but TypeScript can't
|
||||
// figure that out, so we pass it in again.
|
||||
return <ActiveCallManager {...props} activeCall={activeCall} />;
|
||||
}
|
||||
|
||||
// In the future, we may want to show the incoming call bar when a call is active.
|
||||
if (incomingCall) {
|
||||
return (
|
||||
|
|
|
@ -2,11 +2,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { boolean, select } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { CallState } from '../types/Calling';
|
||||
import { CallMode, CallState } from '../types/Calling';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { CallScreen, PropsType } from './CallScreen';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
|
@ -14,7 +15,29 @@ import enMessages from '../../_locales/en/messages.json';
|
|||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
const createProps = (
|
||||
overrideProps: {
|
||||
callState?: CallState;
|
||||
hasLocalAudio?: boolean;
|
||||
hasLocalVideo?: boolean;
|
||||
hasRemoteVideo?: boolean;
|
||||
} = {}
|
||||
): PropsType => ({
|
||||
call: {
|
||||
callMode: CallMode.Direct as CallMode.Direct,
|
||||
conversationId: '3051234567',
|
||||
callState: select(
|
||||
'callState',
|
||||
CallState,
|
||||
overrideProps.callState || CallState.Accepted
|
||||
),
|
||||
isIncoming: false,
|
||||
isVideoCall: true,
|
||||
hasRemoteVideo: boolean(
|
||||
'hasRemoteVideo',
|
||||
overrideProps.hasRemoteVideo || false
|
||||
),
|
||||
},
|
||||
conversation: {
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
|
@ -23,19 +46,24 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
markedUnread: false,
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
callState: select(
|
||||
'callState',
|
||||
CallState,
|
||||
overrideProps.callState || CallState.Accepted
|
||||
),
|
||||
// We allow `any` here because these are fake and actually come from RingRTC, which we
|
||||
// can't import.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
createCanvasVideoRenderer: () =>
|
||||
({
|
||||
setCanvas: noop,
|
||||
enable: noop,
|
||||
disable: noop,
|
||||
} as any),
|
||||
getGroupCallVideoFrameSource: noop as any,
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
hangUp: action('hang-up'),
|
||||
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false),
|
||||
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
|
||||
hasRemoteVideo: boolean(
|
||||
'hasRemoteVideo',
|
||||
overrideProps.hasRemoteVideo || false
|
||||
),
|
||||
i18n,
|
||||
joinedAt: Date.now(),
|
||||
me: {
|
||||
|
|
|
@ -4,7 +4,10 @@
|
|||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import {
|
||||
DirectCallStateType,
|
||||
GroupCallStateType,
|
||||
HangUpType,
|
||||
SetLocalAudioType,
|
||||
SetLocalPreviewType,
|
||||
|
@ -14,25 +17,27 @@ import {
|
|||
import { Avatar } from './Avatar';
|
||||
import { CallingButton, CallingButtonType } from './CallingButton';
|
||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||
import { CallState } from '../types/Calling';
|
||||
import {
|
||||
CallMode,
|
||||
CallState,
|
||||
GroupCallConnectionState,
|
||||
CanvasVideoRenderer,
|
||||
VideoFrameSource,
|
||||
} from '../types/Calling';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
|
||||
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
|
||||
|
||||
export type PropsType = {
|
||||
conversation: {
|
||||
id: string;
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
callState: CallState;
|
||||
call: DirectCallStateType | GroupCallStateType;
|
||||
conversation: ConversationType;
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
hangUp: (_: HangUpType) => void;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
hasRemoteVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
joinedAt?: number;
|
||||
me: {
|
||||
|
@ -52,12 +57,13 @@ export type PropsType = {
|
|||
};
|
||||
|
||||
export const CallScreen: React.FC<PropsType> = ({
|
||||
callState,
|
||||
call,
|
||||
conversation,
|
||||
createCanvasVideoRenderer,
|
||||
getGroupCallVideoFrameSource,
|
||||
hangUp,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
hasRemoteVideo,
|
||||
i18n,
|
||||
joinedAt,
|
||||
me,
|
||||
|
@ -84,15 +90,11 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
const [showControls, setShowControls] = useState(true);
|
||||
|
||||
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPreview({ element: localVideoRef });
|
||||
setRendererCanvas({ element: remoteVideoRef });
|
||||
|
||||
return () => {
|
||||
setLocalPreview({ element: undefined });
|
||||
setRendererCanvas({ element: undefined });
|
||||
};
|
||||
}, [setLocalPreview, setRendererCanvas]);
|
||||
|
||||
|
@ -142,14 +144,39 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
};
|
||||
}, [toggleAudio, toggleVideo]);
|
||||
|
||||
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
|
||||
let hasRemoteVideo: boolean;
|
||||
let isConnected: boolean;
|
||||
let remoteParticipants: JSX.Element;
|
||||
|
||||
const controlsFadeClass = classNames({
|
||||
'module-ongoing-call__controls--fadeIn':
|
||||
(showControls || isAudioOnly) && callState !== CallState.Accepted,
|
||||
'module-ongoing-call__controls--fadeOut':
|
||||
!showControls && !isAudioOnly && callState === CallState.Accepted,
|
||||
});
|
||||
switch (call.callMode) {
|
||||
case CallMode.Direct:
|
||||
hasRemoteVideo = Boolean(call.hasRemoteVideo);
|
||||
isConnected = call.callState === CallState.Accepted;
|
||||
remoteParticipants = (
|
||||
<DirectCallRemoteParticipant
|
||||
conversation={conversation}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
i18n={i18n}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case CallMode.Group:
|
||||
hasRemoteVideo = call.remoteParticipants.some(
|
||||
remoteParticipant => remoteParticipant.hasRemoteVideo
|
||||
);
|
||||
isConnected = call.connectionState === GroupCallConnectionState.Connected;
|
||||
remoteParticipants = (
|
||||
<GroupCallRemoteParticipants
|
||||
remoteParticipants={call.remoteParticipants}
|
||||
createCanvasVideoRenderer={createCanvasVideoRenderer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
|
||||
const videoButtonType = hasLocalVideo
|
||||
? CallingButtonType.VIDEO_ON
|
||||
|
@ -158,9 +185,23 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
? CallingButtonType.AUDIO_ON
|
||||
: CallingButtonType.AUDIO_OFF;
|
||||
|
||||
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
|
||||
|
||||
const controlsFadeClass = classNames({
|
||||
'module-ongoing-call__controls--fadeIn':
|
||||
(showControls || isAudioOnly) && !isConnected,
|
||||
'module-ongoing-call__controls--fadeOut':
|
||||
!showControls && !isAudioOnly && isConnected,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-calling__container"
|
||||
className={classNames(
|
||||
'module-calling__container',
|
||||
`module-ongoing-call__container--${getCallModeClassSuffix(
|
||||
call.callMode
|
||||
)}`
|
||||
)}
|
||||
onMouseMove={() => {
|
||||
setShowControls(true);
|
||||
}}
|
||||
|
@ -176,7 +217,12 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
<div className="module-calling__header--header-name">
|
||||
{conversation.title}
|
||||
</div>
|
||||
{renderHeaderMessage(i18n, callState, acceptedDuration)}
|
||||
{call.callMode === CallMode.Direct &&
|
||||
renderHeaderMessage(
|
||||
i18n,
|
||||
call.callState || CallState.Prering,
|
||||
acceptedDuration
|
||||
)}
|
||||
<div className="module-calling-tools">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -184,22 +230,18 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
className="module-calling-tools__button module-calling-button__settings"
|
||||
onClick={toggleSettings}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={i18n('calling__pip')}
|
||||
className="module-calling-tools__button module-calling-button__pip"
|
||||
onClick={togglePip}
|
||||
/>
|
||||
{/* TODO: Group calls should also support the PiP. See DESKTOP-886. */}
|
||||
{call.callMode === CallMode.Direct && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={i18n('calling__pip')}
|
||||
className="module-calling-tools__button module-calling-button__pip"
|
||||
onClick={togglePip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasRemoteVideo ? (
|
||||
<canvas
|
||||
className="module-ongoing-call__remote-video-enabled"
|
||||
ref={remoteVideoRef}
|
||||
/>
|
||||
) : (
|
||||
renderAvatar(i18n, conversation)
|
||||
)}
|
||||
{remoteParticipants}
|
||||
<div className="module-ongoing-call__footer">
|
||||
{/* This layout-only element is not ideal.
|
||||
See the comment in _modules.css for more. */}
|
||||
|
@ -260,40 +302,17 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
);
|
||||
};
|
||||
|
||||
function renderAvatar(
|
||||
i18n: LocalizerType,
|
||||
{
|
||||
avatarPath,
|
||||
color,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
title,
|
||||
}: {
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
function getCallModeClassSuffix(
|
||||
callMode: CallMode.Direct | CallMode.Group
|
||||
): string {
|
||||
switch (callMode) {
|
||||
case CallMode.Direct:
|
||||
return 'direct';
|
||||
case CallMode.Group:
|
||||
return 'group';
|
||||
default:
|
||||
throw missingCaseError(callMode);
|
||||
}
|
||||
): JSX.Element {
|
||||
return (
|
||||
<div className="module-ongoing-call__remote-video-disabled">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
size={112}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeaderMessage(
|
||||
|
|
77
ts/components/DirectCallRemoteParticipant.tsx
Normal file
77
ts/components/DirectCallRemoteParticipant.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { SetRendererCanvasType } from '../state/ducks/calling';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface PropsType {
|
||||
conversation: ConversationType;
|
||||
hasRemoteVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
||||
}
|
||||
|
||||
export const DirectCallRemoteParticipant: React.FC<PropsType> = ({
|
||||
conversation,
|
||||
hasRemoteVideo,
|
||||
i18n,
|
||||
setRendererCanvas,
|
||||
}) => {
|
||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRendererCanvas({ element: remoteVideoRef });
|
||||
return () => {
|
||||
setRendererCanvas({ element: undefined });
|
||||
};
|
||||
}, [setRendererCanvas]);
|
||||
|
||||
return hasRemoteVideo ? (
|
||||
<canvas
|
||||
className="module-ongoing-call__remote-video-enabled"
|
||||
ref={remoteVideoRef}
|
||||
/>
|
||||
) : (
|
||||
renderAvatar(i18n, conversation)
|
||||
);
|
||||
};
|
||||
|
||||
function renderAvatar(
|
||||
i18n: LocalizerType,
|
||||
{
|
||||
avatarPath,
|
||||
color,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
title,
|
||||
}: {
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
title: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
}
|
||||
): JSX.Element {
|
||||
return (
|
||||
<div className="module-ongoing-call__remote-video-disabled">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
size={112}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
86
ts/components/GroupCallRemoteParticipant.tsx
Normal file
86
ts/components/GroupCallRemoteParticipant.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useEffect, CSSProperties } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
|
||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||
|
||||
interface PropsType {
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
demuxId: number;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
hasRemoteVideo: boolean;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const GroupCallRemoteParticipant: React.FC<PropsType> = ({
|
||||
createCanvasVideoRenderer,
|
||||
demuxId,
|
||||
getGroupCallVideoFrameSource,
|
||||
hasRemoteVideo,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
}) => {
|
||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const canvasVideoRendererRef = useRef(createCanvasVideoRenderer());
|
||||
|
||||
useEffect(() => {
|
||||
const canvasVideoRenderer = canvasVideoRendererRef.current;
|
||||
|
||||
if (hasRemoteVideo) {
|
||||
canvasVideoRenderer.setCanvas(remoteVideoRef);
|
||||
canvasVideoRenderer.enable(getGroupCallVideoFrameSource(demuxId));
|
||||
return () => {
|
||||
canvasVideoRenderer.disable();
|
||||
};
|
||||
}
|
||||
|
||||
canvasVideoRenderer.disable();
|
||||
return noop;
|
||||
}, [hasRemoteVideo, getGroupCallVideoFrameSource, demuxId]);
|
||||
|
||||
// If our `width` and `height` props don't match the canvas's aspect ratio, we want to
|
||||
// fill the container. This can happen when RingRTC gives us an inaccurate
|
||||
// `videoAspectRatio`.
|
||||
const canvasStyles: CSSProperties = {};
|
||||
const canvasEl = remoteVideoRef.current;
|
||||
if (hasRemoteVideo && canvasEl) {
|
||||
if (canvasEl.width > canvasEl.height) {
|
||||
canvasStyles.width = '100%';
|
||||
} else {
|
||||
canvasStyles.height = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-ongoing-call__group-call-remote-participant"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
left,
|
||||
}}
|
||||
>
|
||||
{hasRemoteVideo ? (
|
||||
<canvas
|
||||
className="module-ongoing-call__group-call-remote-participant__remote-video"
|
||||
style={canvasStyles}
|
||||
ref={remoteVideoRef}
|
||||
/>
|
||||
) : (
|
||||
<CallBackgroundBlur>
|
||||
{/* TODO: Improve the styling here. See DESKTOP-894. */}
|
||||
<span />
|
||||
</CallBackgroundBlur>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
242
ts/components/GroupCallRemoteParticipants.tsx
Normal file
242
ts/components/GroupCallRemoteParticipants.tsx
Normal file
|
@ -0,0 +1,242 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Measure from 'react-measure';
|
||||
import { takeWhile, chunk, maxBy, flatten } from 'lodash';
|
||||
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
|
||||
import { GroupCallRemoteParticipantType } from '../state/ducks/calling';
|
||||
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
|
||||
|
||||
const MIN_RENDERED_HEIGHT = 10;
|
||||
const PARTICIPANT_MARGIN = 10;
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GridArrangement {
|
||||
rows: Array<Array<GroupCallRemoteParticipantType>>;
|
||||
scalar: number;
|
||||
}
|
||||
|
||||
interface PropsType {
|
||||
createCanvasVideoRenderer: () => CanvasVideoRenderer;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
|
||||
}
|
||||
|
||||
// This component lays out group call remote participants. It uses a custom layout
|
||||
// algorithm (in other words, nothing that the browser provides, like flexbox) in
|
||||
// order to animate the boxes as they move around, and to figure out the right fits.
|
||||
//
|
||||
// It's worth looking at the UI (or a design of it) to get an idea of how it works. Some
|
||||
// things to notice:
|
||||
//
|
||||
// * Participants are arranged in 0 or more rows.
|
||||
// * Each row is the same height, but each participant may have a different width.
|
||||
// * It's possible, on small screens with lots of participants, to have participants
|
||||
// removed from the grid. This is because participants have a minimum rendered height.
|
||||
//
|
||||
// There should be more specific comments throughout, but the high-level steps are:
|
||||
//
|
||||
// 1. Figure out the maximum number of possible rows that could fit on the screen; this is
|
||||
// `maxRowCount`.
|
||||
// 2. Figure out how many participants should be visible if all participants were rendered
|
||||
// at the minimum height. Most of the time, we'll be able to render all of them, but on
|
||||
// full calls with lots of participants, there could be some lost.
|
||||
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
|
||||
// distribute participants across the rows at the minimum height. Then find the
|
||||
// "scalar": how much can we scale these boxes up while still fitting them on the
|
||||
// screen? The biggest scalar wins as the "best arrangement".
|
||||
// 4. Lay out this arrangement on the screen.
|
||||
export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
|
||||
createCanvasVideoRenderer,
|
||||
getGroupCallVideoFrameSource,
|
||||
remoteParticipants,
|
||||
}) => {
|
||||
const [containerDimensions, setContainerDimensions] = useState<Dimensions>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
// 1. Figure out the maximum number of possible rows that could fit on the screen.
|
||||
//
|
||||
// We choose the smaller of these two options:
|
||||
//
|
||||
// - The number of participants, which means there'd be one participant per row.
|
||||
// - The number of possible rows in the container, assuming all participants were
|
||||
// rendered at minimum height. Doesn't rely on the number of participants—it's some
|
||||
// simple division.
|
||||
//
|
||||
// Could be 0 if (a) there are no participants (b) the container's height is small.
|
||||
const maxRowCount = Math.min(
|
||||
remoteParticipants.length,
|
||||
Math.floor(
|
||||
containerDimensions.height / (MIN_RENDERED_HEIGHT + PARTICIPANT_MARGIN)
|
||||
)
|
||||
);
|
||||
|
||||
// 2. Figure out how many participants should be visible if all participants were
|
||||
// rendered at the minimum height. Most of the time, we'll be able to render all of
|
||||
// them, but on full calls with lots of participants, there could be some lost.
|
||||
//
|
||||
// This is primarily memoized for clarity, not performance. We only need the result,
|
||||
// not any of the "intermediate" values.
|
||||
const visibleParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => {
|
||||
// Imagine that we laid out all of the rows end-to-end. That's the maximum total
|
||||
// width. So if there were 5 rows and the container was 100px wide, then we can't
|
||||
// possibly fit more than 500px of participants.
|
||||
const maxTotalWidth = maxRowCount * containerDimensions.width;
|
||||
|
||||
// We do the same thing for participants, "laying them out end-to-end" until they
|
||||
// exceed the maximum total width.
|
||||
let totalWidth = 0;
|
||||
return takeWhile(remoteParticipants, remoteParticipant => {
|
||||
totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
|
||||
return totalWidth < maxTotalWidth;
|
||||
});
|
||||
}, [maxRowCount, containerDimensions.width, remoteParticipants]);
|
||||
|
||||
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
|
||||
// distribute participants across the rows at the minimum height. Then find the
|
||||
// "scalar": how much can we scale these boxes up while still fitting them on the
|
||||
// screen? The biggest scalar wins as the "best arrangement".
|
||||
const gridArrangement: GridArrangement = useMemo(() => {
|
||||
let bestArrangement: GridArrangement = {
|
||||
scalar: -1,
|
||||
rows: [],
|
||||
};
|
||||
|
||||
if (!visibleParticipants.length) {
|
||||
return bestArrangement;
|
||||
}
|
||||
|
||||
for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) {
|
||||
// We do something pretty naïve here and chunk the visible participants into rows.
|
||||
// For example, if there were 12 visible participants and `rowCount === 3`, there
|
||||
// would be 4 participants per row.
|
||||
//
|
||||
// This naïve chunking is suboptimal in terms of absolute best fit, but it is much
|
||||
// faster and simpler than trying to do this perfectly. In practice, this works
|
||||
// fine in the UI from our testing.
|
||||
const numberOfParticipantsInRow = Math.ceil(
|
||||
visibleParticipants.length / rowCount
|
||||
);
|
||||
const rows = chunk(visibleParticipants, numberOfParticipantsInRow);
|
||||
|
||||
// We need to find the scalar for this arrangement. Imagine that we have these
|
||||
// participants at the minimum heights, and we want to scale everything up until
|
||||
// it's about to overflow.
|
||||
//
|
||||
// We don't want it to overflow horizontally or vertically, so we calculate a
|
||||
// "width scalar" and "height scalar" and choose the smaller of the two. (Choosing
|
||||
// the LARGER of the two could cause overflow.)
|
||||
const widestRow = maxBy(rows, totalRemoteParticipantWidthAtMinHeight);
|
||||
if (!widestRow) {
|
||||
window.log.error(
|
||||
'Unable to find the widest row, which should be impossible'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const widthScalar =
|
||||
(containerDimensions.width -
|
||||
(widestRow.length + 1) * PARTICIPANT_MARGIN) /
|
||||
totalRemoteParticipantWidthAtMinHeight(widestRow);
|
||||
const heightScalar =
|
||||
(containerDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
|
||||
(rowCount * MIN_RENDERED_HEIGHT);
|
||||
const scalar = Math.min(widthScalar, heightScalar);
|
||||
|
||||
// If this scalar is the best one so far, we use that.
|
||||
if (scalar > bestArrangement.scalar) {
|
||||
bestArrangement = { scalar, rows };
|
||||
}
|
||||
}
|
||||
|
||||
return bestArrangement;
|
||||
}, [
|
||||
visibleParticipants,
|
||||
maxRowCount,
|
||||
containerDimensions.width,
|
||||
containerDimensions.height,
|
||||
]);
|
||||
|
||||
// 4. Lay out this arrangement on the screen.
|
||||
const gridParticipantHeight = gridArrangement.scalar * MIN_RENDERED_HEIGHT;
|
||||
const gridParticipantHeightWithMargin =
|
||||
gridParticipantHeight + PARTICIPANT_MARGIN;
|
||||
const gridTotalRowHeightWithMargin =
|
||||
gridParticipantHeightWithMargin * gridArrangement.rows.length;
|
||||
const gridTopOffset = Math.floor(
|
||||
(containerDimensions.height - gridTotalRowHeightWithMargin) / 2
|
||||
);
|
||||
|
||||
const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
|
||||
(remoteParticipantsInRow, index) => {
|
||||
const top = gridTopOffset + index * gridParticipantHeightWithMargin;
|
||||
|
||||
const totalRowWidthWithoutMargins =
|
||||
totalRemoteParticipantWidthAtMinHeight(remoteParticipantsInRow) *
|
||||
gridArrangement.scalar;
|
||||
const totalRowWidth =
|
||||
totalRowWidthWithoutMargins +
|
||||
PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1);
|
||||
const leftOffset = (containerDimensions.width - totalRowWidth) / 2;
|
||||
|
||||
let rowWidthSoFar = 0;
|
||||
return remoteParticipantsInRow.map(remoteParticipant => {
|
||||
const renderedWidth =
|
||||
remoteParticipant.videoAspectRatio * gridParticipantHeight;
|
||||
const left = rowWidthSoFar + leftOffset;
|
||||
|
||||
rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN;
|
||||
|
||||
return (
|
||||
<GroupCallRemoteParticipant
|
||||
key={remoteParticipant.demuxId}
|
||||
createCanvasVideoRenderer={createCanvasVideoRenderer}
|
||||
demuxId={remoteParticipant.demuxId}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
hasRemoteVideo={remoteParticipant.hasRemoteVideo}
|
||||
height={gridParticipantHeight}
|
||||
left={left}
|
||||
top={top}
|
||||
width={renderedWidth}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
const remoteParticipantElements = flatten(rowElements);
|
||||
|
||||
return (
|
||||
<Measure
|
||||
bounds
|
||||
onResize={({ bounds }) => {
|
||||
if (!bounds) {
|
||||
window.log.error('We should be measuring the bounds');
|
||||
return;
|
||||
}
|
||||
setContainerDimensions(bounds);
|
||||
}}
|
||||
>
|
||||
{({ measureRef }) => (
|
||||
<div className="module-ongoing-call__grid" ref={measureRef}>
|
||||
{remoteParticipantElements}
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
);
|
||||
};
|
||||
|
||||
function totalRemoteParticipantWidthAtMinHeight(
|
||||
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>
|
||||
): number {
|
||||
return remoteParticipants.reduce(
|
||||
(result, { videoAspectRatio }) =>
|
||||
result + videoAspectRatio * MIN_RENDERED_HEIGHT,
|
||||
0
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue