Initial group calling support

This commit is contained in:
Evan Hahn 2020-11-13 13:57:55 -06:00 committed by Josh Perez
parent e398520db0
commit 022c4bd0f4
31 changed files with 2530 additions and 414 deletions

View file

@ -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} />
</>
)
);
});

View file

@ -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 (

View file

@ -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: {

View file

@ -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(

View 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>
);
}

View 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>
);
};

View 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
);
}