Group calling: add overflow area

This commit is contained in:
Evan Hahn 2021-01-08 12:58:28 -06:00 committed by Scott Nonnenberg
parent 8e1391c70c
commit fbfcdbf84e
13 changed files with 713 additions and 116 deletions

View file

@ -1306,6 +1306,14 @@
"message": "You won't receive their audio or video and they won't receive yours.",
"description": "Shown in the modal dialog to describe how blocking works in a gorup call"
},
"calling__overflow__scroll-up": {
"message": "Scroll up",
"description": "Label for the \"scroll up\" button in a call's overflow area"
},
"calling__overflow__scroll-down": {
"message": "Scroll down",
"description": "Label for the \"scroll down\" button in a call's overflow area"
},
"alwaysRelayCallsDescription": {
"message": "Always relay calls",
"description": "Description of the always relay calls setting"

View file

@ -5842,7 +5842,7 @@ button.module-image__border-overlay:focus {
.module-calling {
&__container {
align-items: center;
background-color: $color-gray-95;
background-color: $calling-background-color;
display: flex;
flex-direction: column;
height: 100vh;
@ -6253,6 +6253,7 @@ button.module-image__border-overlay:focus {
top: 0;
width: 100%;
z-index: 2;
padding-bottom: 1rem;
}
&__header-message {
@ -6262,10 +6263,110 @@ button.module-image__border-overlay:focus {
letter-spacing: -0.0025em;
}
&__grid {
flex-grow: 1;
&__participants {
display: flex;
flex: 1 1 0;
width: 100%;
position: relative;
&__grid {
flex-grow: 1;
position: relative;
}
&__overflow {
flex: 0 0 auto;
position: relative;
&__inner {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
max-height: 100%;
overflow-y: scroll;
&::-webkit-scrollbar,
&::-webkit-scrollbar-thumb {
width: 0;
background: transparent;
}
}
& .module-ongoing-call__group-call-remote-participant {
width: 100%;
margin-bottom: 1rem;
}
&__scroll-marker {
$scroll-marker-selector: &;
display: flex;
justify-content: center;
left: 0;
opacity: 1;
position: absolute;
scroll-behavior: smooth;
transition: opacity 200ms ease-out;
width: 100%;
z-index: 1;
&--hidden {
opacity: 0;
}
&__button {
&::before {
@include color-svg(
'../images/icons/v2/arrow-down-24.svg',
$color-white
);
content: '';
display: block;
height: 100%;
width: 100%;
}
background: $color-gray-60;
border-radius: 100%;
border: 0;
box-shadow: 0 0 5px rgba($color-gray-95, 0.5);
height: 28px;
margin: 12px 0;
opacity: 0;
outline: none;
transition: opacity 200ms ease-out;
width: 28px;
}
&--top {
top: 0;
background: linear-gradient(
$calling-background-color,
transparent 20px,
transparent
);
#{$scroll-marker-selector}__button {
transform: rotate(180deg);
}
}
&--bottom {
bottom: 0;
background: linear-gradient(
to top,
$calling-background-color,
transparent 20px,
transparent
);
}
}
&:hover &__scroll-marker__button {
opacity: 1;
}
}
}
&__group-call-remote-participant {

View file

@ -1,4 +1,4 @@
// Copyright 2015-2020 Signal Messenger, LLC
// Copyright 2015-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
$inter: Inter, 'Helvetica Neue', 'Source Sans Pro', 'Source Han Sans SC',
@ -194,3 +194,5 @@ $left-pane-width: 320px;
$header-height: 52px;
$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
$calling-background-color: $color-gray-95;

View file

@ -1,8 +1,7 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// 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 { boolean, select, text } from '@storybook/addon-knobs';
@ -18,6 +17,7 @@ import {
import { ConversationTypeType } from '../state/ducks/conversations';
import { Colors, ColorType } from '../types/Colors';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { setup as setupI18n } from '../../js/modules/i18n';
import { Props as SafetyNumberViewerProps } from '../state/smart/SafetyNumberViewer';
import enMessages from '../../_locales/en/messages.json';
@ -64,10 +64,8 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
cancelCall: action('cancel-call'),
closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'),
// We allow `any` here because this is fake and actually comes from RingRTC, which we
// can't import.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
getGroupCallVideoFrameSource: (_: string, demuxId: number) =>
fakeGetGroupCallVideoFrameSource(demuxId),
hangUp: action('hang-up'),
i18n,
keyChangeOk: action('key-change-ok'),

View file

@ -1,10 +1,11 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { noop } from 'lodash';
import { times } from 'lodash';
import { v4 as generateUuid } from 'uuid';
import { storiesOf } from '@storybook/react';
import { boolean, select } from '@storybook/addon-knobs';
import { boolean, select, number } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import {
@ -20,8 +21,11 @@ import { CallScreen, PropsType } from './CallScreen';
import { setup as setupI18n } from '../../js/modules/i18n';
import { missingCaseError } from '../util/missingCaseError';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import enMessages from '../../_locales/en/messages.json';
const MAX_PARTICIPANTS = 32;
const i18n = setupI18n('en', enMessages);
const conversation = {
@ -130,10 +134,7 @@ const createProps = (
}
): PropsType => ({
activeCall: createActiveCallProp(overrideProps),
// We allow `any` here because this is fake and actually comes from RingRTC, which we
// can't import.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
hangUp: action('hang-up'),
i18n,
me: {
@ -257,48 +258,37 @@ story.add('Group call - 1', () => (
/>
));
story.add('Group call - Many', () => (
<CallScreen
{...createProps({
callMode: CallMode.Group,
remoteParticipants: [
{
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,
title: 'Amy',
uuid: '094586f5-8fc2-4ce2-a152-2dfcc99f4630',
}),
},
{
demuxId: 1,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,
title: 'Bob',
uuid: 'cb5bdb24-4cbb-4650-8a7a-1a2807051e74',
}),
},
{
demuxId: 2,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: true,
title: 'Alice',
uuid: '2d7d13ae-53dc-4a51-8dc7-976cd85e0b57',
}),
},
],
})}
/>
));
// We generate these upfront so that the list is stable when you move the slider.
const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,
title: `Participant ${index + 1}`,
uuid: generateUuid(),
}),
}));
story.add('Group call - Many', () => {
return (
<CallScreen
{...createProps({
callMode: CallMode.Group,
remoteParticipants: allRemoteParticipants.slice(
0,
number('Participant count', 3, {
range: true,
min: 0,
max: MAX_PARTICIPANTS,
step: 1,
})
),
})}
/>
);
});
story.add('Group call - reconnecting', () => (
<CallScreen

View file

@ -1,8 +1,7 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { noop } from 'lodash';
import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
@ -17,6 +16,7 @@ import {
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
@ -55,8 +55,7 @@ const defaultCall: ActiveCallType = {
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
activeCall: overrideProps.activeCall || defaultCall,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
hangUp: action('hang-up'),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
i18n,

View file

@ -0,0 +1,93 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FC } from 'react';
import { memoize, times } from 'lodash';
import { v4 as generateUuid } from 'uuid';
import { storiesOf } from '@storybook/react';
import { number } from '@storybook/addon-knobs';
import { GroupCallOverflowArea } from './GroupCallOverflowArea';
import { setup as setupI18n } from '../../js/modules/i18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { FRAME_BUFFER_SIZE } from '../calling/constants';
import enMessages from '../../_locales/en/messages.json';
const MAX_PARTICIPANTS = 32;
const i18n = setupI18n('en', enMessages);
const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,
title: `Participant ${index + 1}`,
uuid: generateUuid(),
}),
}));
const story = storiesOf('Components/GroupCallOverflowArea', module);
const defaultProps = {
getFrameBuffer: memoize(() => new ArrayBuffer(FRAME_BUFFER_SIZE)),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
i18n,
};
// This component is usually rendered on a call screen.
const Container: FC = ({ children }) => (
<div
style={{
background: 'black',
display: 'inline-flex',
height: '80vh',
}}
>
{children}
</div>
);
story.add('No overflowed participants', () => (
<Container>
<GroupCallOverflowArea {...defaultProps} overflowedParticipants={[]} />
</Container>
));
story.add('One overflowed participant', () => (
<Container>
<GroupCallOverflowArea
{...defaultProps}
overflowedParticipants={allRemoteParticipants.slice(0, 1)}
/>
</Container>
));
story.add('Three overflowed participants', () => (
<Container>
<GroupCallOverflowArea
{...defaultProps}
overflowedParticipants={allRemoteParticipants.slice(0, 3)}
/>
</Container>
));
story.add('Many overflowed participants', () => (
<Container>
<GroupCallOverflowArea
{...defaultProps}
overflowedParticipants={allRemoteParticipants.slice(
0,
number('Participant count', MAX_PARTICIPANTS, {
range: true,
min: 0,
max: MAX_PARTICIPANTS,
step: 1,
})
)}
/>
</Container>
));

View file

@ -0,0 +1,174 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useState, useEffect, FC, ReactElement } from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import {
GroupCallRemoteParticipantType,
VideoFrameSource,
} from '../types/Calling';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
const OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD = 20;
const OVERFLOW_SCROLL_BUTTON_RATIO = 0.75;
// This should be an integer, as sub-pixel widths can cause performance issues.
export const OVERFLOW_PARTICIPANT_WIDTH = 140;
interface PropsType {
getFrameBuffer: () => ArrayBuffer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
overflowedParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
}
export const GroupCallOverflowArea: FC<PropsType> = ({
getFrameBuffer,
getGroupCallVideoFrameSource,
i18n,
overflowedParticipants,
}) => {
const overflowRef = useRef<HTMLDivElement | null>(null);
const [overflowScrollTop, setOverflowScrollTop] = useState(0);
// This assumes that these values will change along with re-renders. If that's not true,
// we should add these values to the component's state.
let visibleHeight: number;
let scrollMax: number;
if (overflowRef.current) {
visibleHeight = overflowRef.current.clientHeight;
scrollMax = overflowRef.current.scrollHeight - visibleHeight;
} else {
visibleHeight = 0;
scrollMax = 0;
}
const hasOverflowedParticipants = Boolean(overflowedParticipants.length);
useEffect(() => {
// If there aren't any overflowed participants, we want to clear the scroll position
// so we don't hold onto stale values.
if (!hasOverflowedParticipants) {
setOverflowScrollTop(0);
}
}, [hasOverflowedParticipants]);
if (!hasOverflowedParticipants) {
return null;
}
const isScrolledToTop =
overflowScrollTop < OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD;
const isScrolledToBottom =
overflowScrollTop > scrollMax - OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD;
return (
<div
className="module-ongoing-call__participants__overflow"
style={{
// This width could live in CSS but we put it here to avoid having to keep two
// values in sync.
width: OVERFLOW_PARTICIPANT_WIDTH,
}}
>
<OverflowAreaScrollMarker
i18n={i18n}
isHidden={isScrolledToTop}
onClick={() => {
const el = overflowRef.current;
if (!el) {
return;
}
el.scrollTo({
top: Math.max(
el.scrollTop - visibleHeight * OVERFLOW_SCROLL_BUTTON_RATIO,
0
),
left: 0,
behavior: 'smooth',
});
}}
placement="top"
/>
<div
className="module-ongoing-call__participants__overflow__inner"
ref={overflowRef}
onScroll={() => {
// Ideally this would use `event.target.scrollTop`, but that does not seem to be
// available, so we use the ref.
const el = overflowRef.current;
if (!el) {
return;
}
setOverflowScrollTop(el.scrollTop);
}}
>
{overflowedParticipants.map(remoteParticipant => (
<GroupCallRemoteParticipant
key={remoteParticipant.demuxId}
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
width={OVERFLOW_PARTICIPANT_WIDTH}
height={Math.floor(
OVERFLOW_PARTICIPANT_WIDTH / remoteParticipant.videoAspectRatio
)}
remoteParticipant={remoteParticipant}
/>
))}
</div>
<OverflowAreaScrollMarker
i18n={i18n}
isHidden={isScrolledToBottom}
onClick={() => {
const el = overflowRef.current;
if (!el) {
return;
}
el.scrollTo({
top: Math.min(
el.scrollTop + visibleHeight * OVERFLOW_SCROLL_BUTTON_RATIO,
scrollMax
),
left: 0,
behavior: 'smooth',
});
}}
placement="bottom"
/>
</div>
);
};
function OverflowAreaScrollMarker({
i18n,
isHidden,
onClick,
placement,
}: {
i18n: LocalizerType;
isHidden: boolean;
onClick: () => void;
placement: 'top' | 'bottom';
}): ReactElement {
const baseClassName =
'module-ongoing-call__participants__overflow__scroll-marker';
return (
<div
className={classNames(baseClassName, `${baseClassName}--${placement}`, {
[`${baseClassName}--hidden`]: isHidden,
})}
>
<button
type="button"
className={`${baseClassName}__button`}
onClick={onClick}
aria-label={i18n(
`calling__overflow__scroll-${placement === 'top' ? 'up' : 'down'}`
)}
/>
</div>
);
}

View file

@ -21,6 +21,7 @@ import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationModal } from './ConfirmationModal';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
import { useIntersectionObserver } from '../util/hooks';
import { MAX_FRAME_SIZE } from '../calling/constants';
interface BasePropsType {
@ -34,15 +35,19 @@ interface InPipPropsType {
isInPip: true;
}
interface NotInPipPropsType {
interface InOverflowAreaPropsType {
height: number;
isInPip?: false;
left: number;
top: number;
width: number;
}
export type PropsType = BasePropsType & (InPipPropsType | NotInPipPropsType);
interface InGridPropsType extends InOverflowAreaPropsType {
left: number;
top: number;
}
export type PropsType = BasePropsType &
(InPipPropsType | InOverflowAreaPropsType | InGridPropsType);
export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
props => {
@ -57,15 +62,26 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
isBlocked,
profileName,
title,
videoAspectRatio,
} = props.remoteParticipant;
const [isWide, setIsWide] = useState(true);
const [isWide, setIsWide] = useState<boolean>(
videoAspectRatio ? videoAspectRatio >= 1 : true
);
const [hasHover, setHover] = useState(false);
const [showBlockInfo, setShowBlockInfo] = useState(false);
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null);
const [
intersectionRef,
intersectionObserverEntry,
] = useIntersectionObserver();
const isVisible = intersectionObserverEntry
? intersectionObserverEntry.isIntersecting
: true;
const videoFrameSource = useMemo(
() => getGroupCallVideoFrameSource(demuxId),
[getGroupCallVideoFrameSource, demuxId]
@ -118,7 +134,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
}, [getFrameBuffer, videoFrameSource]);
useEffect(() => {
if (!hasRemoteVideo) {
if (!hasRemoteVideo || !isVisible) {
return noop;
}
@ -132,7 +148,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
return () => {
cancelAnimationFrame(rafId);
};
}, [hasRemoteVideo, renderVideoFrame, videoFrameSource]);
}, [hasRemoteVideo, isVisible, renderVideoFrame, videoFrameSource]);
let canvasStyles: CSSProperties;
let containerStyles: CSSProperties;
@ -155,7 +171,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
containerStyles = canvasStyles;
avatarSize = AvatarSize.FIFTY_TWO;
} else {
const { top, left, width, height } = props;
const { width, height } = props;
const shorterDimension = Math.min(width, height);
if (shorterDimension >= 240) {
@ -168,15 +184,18 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
containerStyles = {
height,
left,
position: 'absolute',
top,
width,
};
if ('top' in props) {
containerStyles.position = 'absolute';
containerStyles.top = props.top;
containerStyles.left = props.left;
}
}
const showHover = hasHover && !props.isInPip;
const canShowVideo = hasRemoteVideo && !isBlocked;
const canShowVideo = hasRemoteVideo && !isBlocked && isVisible;
return (
<>
@ -218,6 +237,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
<div
className="module-ongoing-call__group-call-remote-participant"
ref={intersectionRef}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={containerStyles}

View file

@ -5,6 +5,10 @@ import React, { useState, useMemo, useEffect } from 'react';
import Measure from 'react-measure';
import { takeWhile, chunk, maxBy, flatten } from 'lodash';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
import {
GroupCallOverflowArea,
OVERFLOW_PARTICIPANT_WIDTH,
} from './GroupCallOverflowArea';
import {
GroupCallRemoteParticipantType,
GroupCallVideoRequest,
@ -15,7 +19,7 @@ import { LocalizerType } from '../types/Util';
import { usePageVisibility } from '../util/hooks';
import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant';
const MIN_RENDERED_HEIGHT = 10;
const MIN_RENDERED_HEIGHT = 180;
const PARTICIPANT_MARGIN = 10;
// We scale our video requests down for performance. This number is somewhat arbitrary.
@ -54,9 +58,8 @@ interface PropsType {
//
// 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.
// 2. Split the participants into two groups: ones in the main grid and ones in the
// overflow area. The grid should prioritize participants who have recently spoken.
// 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
@ -72,6 +75,11 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
width: 0,
height: 0,
});
const [gridDimensions, setGridDimensions] = useState<Dimensions>({
width: 0,
height: 0,
});
const isPageVisible = usePageVisibility();
const getFrameBuffer = useGetCallingFrameBuffer();
@ -92,13 +100,28 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
)
);
// 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.
// 2. Split participants into two groups: ones in the main grid and ones in the overflow
// sidebar.
//
// This is primarily memoized for clarity, not performance. We only need the result,
// not any of the "intermediate" values.
const visibleParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => {
// We start by sorting by `speakerTime` so that the most recent speakers are first in
// line for the main grid. Then we split the list in two: one for the grid and one for
// the overflow area.
//
// Once we've sorted participants into their respective groups, we sort them on
// something stable (the `demuxId`, but we could choose something else) so that people
// don't jump around within the group.
//
// These are primarily memoized for clarity, not performance.
const sortedParticipants: Array<GroupCallRemoteParticipantType> = useMemo(
() =>
remoteParticipants
.concat()
.sort(
(a, b) => (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity)
),
[remoteParticipants]
);
const gridParticipants: 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.
@ -107,13 +130,17 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
// 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 => {
return takeWhile(sortedParticipants, remoteParticipant => {
totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
return totalWidth < maxTotalWidth;
});
}, [maxRowCount, containerDimensions.width, remoteParticipants]);
const overflowedParticipants: Array<GroupCallRemoteParticipantType> = remoteParticipants.slice(
visibleParticipants.length
}).sort(stableParticipantComparator);
}, [maxRowCount, containerDimensions.width, sortedParticipants]);
const overflowedParticipants: Array<GroupCallRemoteParticipantType> = useMemo(
() =>
sortedParticipants
.slice(gridParticipants.length)
.sort(stableParticipantComparator),
[sortedParticipants, gridParticipants.length]
);
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
@ -126,22 +153,22 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
rows: [],
};
if (!visibleParticipants.length) {
if (!gridParticipants.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
// We do something pretty naïve here and chunk the grid's participants into rows.
// For example, if there were 12 grid 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
gridParticipants.length / rowCount
);
const rows = chunk(visibleParticipants, numberOfParticipantsInRow);
const rows = chunk(gridParticipants, 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
@ -158,11 +185,10 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
continue;
}
const widthScalar =
(containerDimensions.width -
(widestRow.length + 1) * PARTICIPANT_MARGIN) /
(gridDimensions.width - (widestRow.length + 1) * PARTICIPANT_MARGIN) /
totalRemoteParticipantWidthAtMinHeight(widestRow);
const heightScalar =
(containerDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
(gridDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
(rowCount * MIN_RENDERED_HEIGHT);
const scalar = Math.min(widthScalar, heightScalar);
@ -174,10 +200,10 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
return bestArrangement;
}, [
visibleParticipants,
gridParticipants,
maxRowCount,
containerDimensions.width,
containerDimensions.height,
gridDimensions.width,
gridDimensions.height,
]);
// 4. Lay out this arrangement on the screen.
@ -189,7 +215,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
const gridTotalRowHeightWithMargin =
gridParticipantHeightWithMargin * gridArrangement.rows.length;
const gridTopOffset = Math.floor(
(containerDimensions.height - gridTotalRowHeightWithMargin) / 2
(gridDimensions.height - gridTotalRowHeightWithMargin) / 2
);
const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
@ -202,9 +228,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
const totalRowWidth =
totalRowWidthWithoutMargins +
PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1);
const leftOffset = Math.floor(
(containerDimensions.width - totalRowWidth) / 2
);
const leftOffset = Math.floor((gridDimensions.width - totalRowWidth) / 2);
let rowWidthSoFar = 0;
return remoteParticipantsInRow.map(remoteParticipant => {
@ -235,7 +259,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
useEffect(() => {
if (isPageVisible) {
setGroupCallVideoRequest([
...visibleParticipants.map(participant => {
...gridParticipants.map(participant => {
if (participant.hasRemoteVideo) {
return {
demuxId: participant.demuxId,
@ -249,7 +273,21 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
}
return nonRenderedRemoteParticipant(participant);
}),
...overflowedParticipants.map(nonRenderedRemoteParticipant),
...overflowedParticipants.map(participant => {
if (participant.hasRemoteVideo) {
return {
demuxId: participant.demuxId,
width: Math.floor(
OVERFLOW_PARTICIPANT_WIDTH * VIDEO_REQUEST_SCALAR
),
height: Math.floor(
(OVERFLOW_PARTICIPANT_WIDTH / participant.videoAspectRatio) *
VIDEO_REQUEST_SCALAR
),
};
}
return nonRenderedRemoteParticipant(participant);
}),
]);
} else {
setGroupCallVideoRequest(
@ -262,7 +300,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
overflowedParticipants,
remoteParticipants,
setGroupCallVideoRequest,
visibleParticipants,
gridParticipants,
]);
return (
@ -276,9 +314,37 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
setContainerDimensions(bounds);
}}
>
{({ measureRef }) => (
<div className="module-ongoing-call__grid" ref={measureRef}>
{flatten(rowElements)}
{containerMeasure => (
<div
className="module-ongoing-call__participants"
ref={containerMeasure.measureRef}
>
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
window.log.error('We should be measuring the bounds');
return;
}
setGridDimensions(bounds);
}}
>
{gridMeasure => (
<div
className="module-ongoing-call__participants__grid"
ref={gridMeasure.measureRef}
>
{flatten(rowElements)}
</div>
)}
</Measure>
<GroupCallOverflowArea
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
overflowedParticipants={overflowedParticipants}
/>
</div>
)}
</Measure>
@ -294,3 +360,10 @@ function totalRemoteParticipantWidthAtMinHeight(
0
);
}
function stableParticipantComparator(
a: Readonly<{ demuxId: number }>,
b: Readonly<{ demuxId: number }>
): number {
return a.demuxId - b.demuxId;
}

View file

@ -0,0 +1,62 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { VideoFrameSource } from '../../types/Calling';
const COLORS: Array<[number, number, number]> = [
[0xff, 0x00, 0x00],
[0xff, 0x99, 0x00],
[0xff, 0xff, 0x00],
[0x00, 0xff, 0x00],
[0x00, 0x99, 0xff],
[0xff, 0x00, 0xff],
[0x99, 0x33, 0xff],
];
class FakeGroupCallVideoFrameSource implements VideoFrameSource {
private readonly sourceArray: Uint8Array;
private readonly dimensions: [number, number];
constructor(width: number, height: number, r: number, g: number, b: number) {
const length = width * height * 4;
this.sourceArray = new Uint8Array(length);
for (let i = 0; i < length; i += 4) {
this.sourceArray[i] = r;
this.sourceArray[i + 1] = g;
this.sourceArray[i + 2] = b;
this.sourceArray[i + 3] = 255;
}
this.dimensions = [width, height];
}
receiveVideoFrame(
destinationBuffer: ArrayBuffer
): [number, number] | undefined {
// Simulate network jitter. Also improves performance when testing.
if (Math.random() < 0.5) {
return undefined;
}
new Uint8Array(destinationBuffer).set(this.sourceArray);
return this.dimensions;
}
}
/**
* This produces a fake video frame source that is a single color.
*
* The aspect ratio is fixed at 1.3 because that matches many of our stories.
*/
export function fakeGetGroupCallVideoFrameSource(
demuxId: number
): VideoFrameSource {
const color = COLORS[demuxId % COLORS.length];
if (!color) {
throw new Error('Expected a color, but it was not found');
}
const [r, g, b] = color;
return new FakeGroupCallVideoFrameSource(13, 10, r, g, b);
}

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -66,3 +66,62 @@ export const usePageVisibility = (): boolean => {
return result;
};
/**
* A light hook wrapper around `IntersectionObserver`.
*
* Example usage:
*
* function MyComponent() {
* const [intersectionRef, intersectionEntry] = useIntersectionObserver();
* const isVisible = intersectionEntry
* ? intersectionEntry.isIntersecting
* : true;
*
* return (
* <div ref={intersectionRef}>
* I am {isVisible ? 'on the screen' : 'invisible'}
* </div>
* );
* }
*/
export function useIntersectionObserver(): [
(el?: Element | null) => void,
IntersectionObserverEntry | null
] {
const [
intersectionObserverEntry,
setIntersectionObserverEntry,
] = React.useState<IntersectionObserverEntry | null>(null);
const unobserveRef = React.useRef<(() => unknown) | null>(null);
const setRef = React.useCallback((el?: Element | null) => {
if (unobserveRef.current) {
unobserveRef.current();
unobserveRef.current = null;
}
if (!el) {
return;
}
const observer = new IntersectionObserver(entries => {
if (entries.length !== 1) {
window.log.error(
'IntersectionObserverWrapper was observing the wrong number of elements'
);
return;
}
entries.forEach(entry => {
setIntersectionObserverEntry(entry);
});
});
unobserveRef.current = observer.unobserve.bind(observer, el);
observer.observe(el);
}, []);
return [setRef, intersectionObserverEntry];
}

View file

@ -14523,11 +14523,20 @@
"updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Needed to render the remote video element."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupCallOverflowArea.js",
"line": " const overflowRef = react_1.useRef(null);",
"lineNumber": 36,
"reasonCategory": "usageTrusted",
"updated": "2021-01-08T15:48:46.313Z",
"reasonDetail": "Used to deal with scroll position."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupCallRemoteParticipant.js",
"line": " const remoteVideoRef = react_1.useRef(null);",
"lineNumber": 43,
"lineNumber": 44,
"reasonCategory": "usageTrusted",
"updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Needed to render the remote video element."
@ -14536,7 +14545,7 @@
"rule": "React-useRef",
"path": "ts/components/GroupCallRemoteParticipant.js",
"line": " const canvasContextRef = react_1.useRef(null);",
"lineNumber": 44,
"lineNumber": 45,
"reasonCategory": "usageTrusted",
"updated": "2020-11-17T23:29:38.698Z",
"reasonDetail": "Doesn't touch the DOM."
@ -15204,5 +15213,14 @@
"lineNumber": 2177,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
},
{
"rule": "React-useRef",
"path": "ts/util/hooks.js",
"line": " const unobserveRef = React.useRef(null);",
"lineNumber": 95,
"reasonCategory": "usageTrusted",
"updated": "2021-01-08T15:46:32.143Z",
"reasonDetail": "Doesn't manipulate the DOM. This is just a function."
}
]
]