signal-desktop/ts/components/GroupCallRemoteParticipants.tsx
2023-12-11 10:10:31 -05:00

1028 lines
33 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { clamp, chunk, maxBy, flatten, noop } from 'lodash';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
import {
GroupCallOverflowArea,
OVERFLOW_PARTICIPANT_WIDTH,
} from './GroupCallOverflowArea';
import type {
GroupCallRemoteParticipantType,
GroupCallVideoRequest,
} from '../types/Calling';
import { CallViewMode } from '../types/Calling';
import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer';
import type { LocalizerType } from '../types/Util';
import { usePageVisibility } from '../hooks/usePageVisibility';
import { useDevicePixelRatio } from '../hooks/useDevicePixelRatio';
import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant';
import { missingCaseError } from '../util/missingCaseError';
import { SECOND } from '../util/durations';
import { filter, join } from '../util/iterables';
import * as setUtil from '../util/setUtil';
import * as log from '../logging/log';
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
import { SizeObserver } from '../hooks/useSizeObserver';
import { strictAssert } from '../util/assert';
const SMALL_TILES_MIN_HEIGHT = 80;
const LARGE_TILES_MIN_HEIGHT = 200;
const PARTICIPANT_MARGIN = 12;
const TIME_TO_STOP_REQUESTING_VIDEO_WHEN_PAGE_INVISIBLE = 20 * SECOND;
const PAGINATION_BUTTON_ASPECT_RATIO = 1;
const MAX_PARTICIPANTS_PER_PAGE = 49; // 49 remote + 1 self-video = 50 total
// We scale our video requests down for performance. This number is somewhat arbitrary.
const VIDEO_REQUEST_SCALAR = 0.75;
type Dimensions = {
width: number;
height: number;
};
type GridArrangement = {
rows: Array<Array<ParticipantTileType>>;
scalar: number;
};
type PaginationButtonType = {
isPaginationButton: true;
videoAspectRatio: number;
paginationButtonType: 'prev' | 'next';
numParticipants: number;
};
type ParticipantTileType =
| GroupCallRemoteParticipantType
| PaginationButtonType;
type PropsType = {
callViewMode: CallViewMode;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
isCallReconnecting: boolean;
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
speakerHeight: number
) => void;
remoteAudioLevels: Map<number, number>;
onClickRaisedHand?: () => void;
};
enum VideoRequestMode {
Normal = 'Normal',
LowResolution = 'LowResolution',
NoVideo = 'NoVideo',
}
// 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, or on subsequent pages. This is because participants
// have a minimum rendered height.
// * Participant videos may have different aspect ratios
// * We want to ensure that presenters and recent speakers are shown on the first page,
// but we also want to minimize tiles jumping around as much as possible.
//
// 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 a page; this is
// `maxRowsPerPage`.
// 2. Sort the participants in priority order: we want to fit presenters and recent
// speakers in the grid first
// 3. Figure out which participants should go on each page -- for non-paginated views,
// this is just one page, but for paginated views, we could have many pages. The
// general idea here is to fill up each page row-by-row, with each video as small
// as we allow.
// 4. Try to distribute the videos throughout the grid to find the largest "scalar":
// how much can we scale these boxes up while still fitting them on the screen?
// The biggest scalar wins as the "best arrangement".
// 5. Lay out this arrangement on the screen.
export function GroupCallRemoteParticipants({
callViewMode,
getGroupCallVideoFrameSource,
i18n,
isCallReconnecting,
remoteParticipants,
setGroupCallVideoRequest,
remoteAudioLevels,
onClickRaisedHand,
}: PropsType): JSX.Element {
const [gridDimensions, setGridDimensions] = useState<Dimensions>({
width: 0,
height: 0,
});
const [pageIndex, setPageIndex] = useState(0);
const devicePixelRatio = useDevicePixelRatio();
const getFrameBuffer = useGetCallingFrameBuffer();
const { invisibleDemuxIds, onParticipantVisibilityChanged } =
useInvisibleParticipants(remoteParticipants);
const minRenderedHeight =
callViewMode === CallViewMode.Paginated
? SMALL_TILES_MIN_HEIGHT
: LARGE_TILES_MIN_HEIGHT;
const isInSpeakerView =
callViewMode === CallViewMode.Speaker ||
callViewMode === CallViewMode.Presentation;
const isInPaginationView = callViewMode === CallViewMode.Paginated;
const shouldShowOverflow = !isInPaginationView;
const maxRowWidth = gridDimensions.width;
const maxGridHeight = gridDimensions.height;
// 1. Figure out the maximum number of possible rows that could fit on the page.
// Could be 0 if (a) there are no participants (b) the container's height is small.
const maxRowsPerPage = Math.floor(
maxGridHeight / (minRenderedHeight + PARTICIPANT_MARGIN)
);
// 2. Sort the participants in priority order: by `presenting` first, since presenters
// should be on the main grid, then by `speakerTime` so that the most recent speakers
// are next in line for the first pages of the grid
const prioritySortedParticipants: Array<GroupCallRemoteParticipantType> =
useMemo(
() =>
remoteParticipants
.concat()
.sort(
(a, b) =>
Number(b.presenting || 0) - Number(a.presenting || 0) ||
(b.speakerTime || -Infinity) - (a.speakerTime || -Infinity)
),
[remoteParticipants]
);
// 3. Layout the participants on each page. The general algorithm is: first, try to fill
// up each page with as many participants as possible at the smallest acceptable video
// height. Second, sort the participants that fit on each page by a stable sort order,
// and make sure they still fit on the page! Third, add tiles at the beginning and end
// of each page (if paginated) to act as back and next buttons.
const gridParticipantsByPage: Array<ParticipantsInPageType> = useMemo(() => {
if (!prioritySortedParticipants.length) {
return [];
}
if (!maxRowsPerPage) {
return [];
}
if (isInSpeakerView) {
return [
{
rows: [[prioritySortedParticipants[0]]],
hasSpaceRemaining: false,
numParticipants: 1,
},
];
}
return getGridParticipantsByPage({
participants: prioritySortedParticipants,
maxRowWidth,
maxPages: isInPaginationView ? Infinity : 1,
maxRowsPerPage,
minRenderedHeight,
maxParticipantsPerPage: MAX_PARTICIPANTS_PER_PAGE,
currentPage: pageIndex,
});
}, [
maxRowWidth,
isInPaginationView,
isInSpeakerView,
maxRowsPerPage,
minRenderedHeight,
pageIndex,
prioritySortedParticipants,
]);
// Make sure we're not on a page that no longer exists (e.g. if people left the call)
if (
pageIndex >= gridParticipantsByPage.length &&
gridParticipantsByPage.length > 0
) {
setPageIndex(gridParticipantsByPage.length - 1);
}
const totalParticipantsInGrid = gridParticipantsByPage.reduce(
(pageCount, { numParticipants }) => pageCount + numParticipants,
0
);
// In speaker or overflow views, not all participants will be on the grid; they'll
// get put in the overflow zone.
const overflowedParticipants: Array<GroupCallRemoteParticipantType> = useMemo(
() =>
isInPaginationView
? []
: prioritySortedParticipants
.slice(totalParticipantsInGrid)
.sort(stableParticipantComparator),
[isInPaginationView, prioritySortedParticipants, totalParticipantsInGrid]
);
const participantsOnOtherPages = useMemo(
() =>
gridParticipantsByPage
.map((page, index) => {
if (index === pageIndex) {
return [];
}
return page.rows.flat();
})
.flat()
.filter(isGroupCallRemoteParticipant),
[gridParticipantsByPage, pageIndex]
);
const currentPage = gridParticipantsByPage.at(pageIndex) ?? {
rows: [],
};
// 4. Try to arrange the current page such that we can scale the videos up
// as much as possible.
const gridArrangement = arrangeParticipantsInGrid({
participantsInRows: currentPage.rows,
maxRowsPerPage,
maxRowWidth,
maxGridHeight,
minRenderedHeight,
});
const nextPage = () => {
setPageIndex(index => index + 1);
};
const prevPage = () => {
setPageIndex(index => Math.max(0, index - 1));
};
// 5. Lay out the current page on the screen.
const gridParticipantHeight = Math.round(
gridArrangement.scalar * minRenderedHeight
);
const gridParticipantHeightWithMargin =
gridParticipantHeight + PARTICIPANT_MARGIN;
const gridTotalRowHeightWithMargin =
gridParticipantHeightWithMargin * gridArrangement.rows.length -
PARTICIPANT_MARGIN;
const gridTopOffset = Math.max(
0,
Math.round((gridDimensions.height - gridTotalRowHeightWithMargin) / 2)
);
const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
(tiles, index) => {
const top = gridTopOffset + index * gridParticipantHeightWithMargin;
const totalRowWidthWithoutMargins =
totalRowWidthAtHeight(tiles, minRenderedHeight) *
gridArrangement.scalar;
const totalRowWidth =
totalRowWidthWithoutMargins + PARTICIPANT_MARGIN * (tiles.length - 1);
const leftOffset = Math.max(
0,
Math.round((gridDimensions.width - totalRowWidth) / 2)
);
let rowWidthSoFar = 0;
return tiles.map(tile => {
const left = rowWidthSoFar + leftOffset;
const renderedWidth = Math.round(
tile.videoAspectRatio * gridParticipantHeight
);
rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN;
if (isPaginationButton(tile)) {
const isNextButton = tile.paginationButtonType === 'next';
const isPrevButton = tile.paginationButtonType === 'prev';
return (
<button
key={
isNextButton ? 'next-pagination-tile' : 'prev-pagination-tile'
}
onClick={isNextButton ? nextPage : prevPage}
style={{
insetInlineStart: left,
insetBlockStart: top,
width: renderedWidth,
height: gridParticipantHeight,
}}
type="button"
className="module-ongoing-call__group-call--pagination-tile"
>
{isPrevButton ? (
<div className="module-ongoing-call__group-call--pagination-tile--prev-arrow" />
) : null}
+{tile.numParticipants}
{isNextButton ? (
<div className="module-ongoing-call__group-call--pagination-tile--next-arrow" />
) : null}
</button>
);
}
return (
<GroupCallRemoteParticipant
key={tile.demuxId}
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
onClickRaisedHand={onClickRaisedHand}
height={gridParticipantHeight}
i18n={i18n}
audioLevel={remoteAudioLevels.get(tile.demuxId) ?? 0}
left={left}
remoteParticipant={tile}
top={top}
width={renderedWidth}
remoteParticipantsCount={remoteParticipants.length}
isActiveSpeakerInSpeakerView={isInSpeakerView}
isCallReconnecting={isCallReconnecting}
/>
);
});
}
);
const videoRequestMode = useVideoRequestMode();
useEffect(() => {
log.info(`Group call now using ${videoRequestMode} video request mode`);
}, [videoRequestMode]);
useEffect(() => {
let videoRequest: Array<GroupCallVideoRequest>;
switch (videoRequestMode) {
case VideoRequestMode.Normal:
videoRequest = [
...currentPage.rows
.flat()
// Filter out any next/previous page buttons
.filter(isGroupCallRemoteParticipant)
.map(participant => {
let scalar: number;
if (participant.sharingScreen) {
// We want best-resolution video if someone is sharing their screen.
scalar = Math.max(devicePixelRatio, 1);
} else {
scalar = VIDEO_REQUEST_SCALAR;
}
return {
demuxId: participant.demuxId,
width: clamp(
Math.round(
gridParticipantHeight *
participant.videoAspectRatio *
scalar
),
1,
MAX_FRAME_WIDTH
),
height: clamp(
Math.round(gridParticipantHeight * scalar),
1,
MAX_FRAME_HEIGHT
),
};
}),
...participantsOnOtherPages.map(nonRenderedRemoteParticipant),
...overflowedParticipants.map(participant => {
if (invisibleDemuxIds.has(participant.demuxId)) {
return nonRenderedRemoteParticipant(participant);
}
return {
demuxId: participant.demuxId,
width: clamp(
Math.round(OVERFLOW_PARTICIPANT_WIDTH * VIDEO_REQUEST_SCALAR),
1,
MAX_FRAME_WIDTH
),
height: clamp(
Math.round(
(OVERFLOW_PARTICIPANT_WIDTH / participant.videoAspectRatio) *
VIDEO_REQUEST_SCALAR
),
1,
MAX_FRAME_HEIGHT
),
};
}),
];
break;
case VideoRequestMode.LowResolution:
videoRequest = remoteParticipants.map(participant =>
participant.hasRemoteVideo
? {
demuxId: participant.demuxId,
width: 1,
height: 1,
}
: nonRenderedRemoteParticipant(participant)
);
break;
case VideoRequestMode.NoVideo:
videoRequest = remoteParticipants.map(nonRenderedRemoteParticipant);
break;
default:
log.error(missingCaseError(videoRequestMode));
videoRequest = remoteParticipants.map(nonRenderedRemoteParticipant);
break;
}
setGroupCallVideoRequest(
videoRequest,
clamp(gridParticipantHeight, 0, MAX_FRAME_HEIGHT)
);
}, [
devicePixelRatio,
currentPage.rows,
gridParticipantHeight,
invisibleDemuxIds,
overflowedParticipants,
remoteParticipants,
setGroupCallVideoRequest,
videoRequestMode,
participantsOnOtherPages,
]);
return (
<div className="module-ongoing-call__participants">
<div className="module-ongoing-call__participants__grid--wrapper">
<SizeObserver
onSizeChange={size => {
setGridDimensions(size);
}}
>
{gridRef => (
<div
className="module-ongoing-call__participants__grid"
ref={gridRef}
>
{flatten(rowElements)}
{isInPaginationView && (
<>
{pageIndex > 0 ? (
<button
aria-label="Prev"
className="module-ongoing-call__prev-page"
onClick={prevPage}
type="button"
>
<div className="module-ongoing-call__prev-page--arrow" />
</button>
) : null}
{pageIndex < gridParticipantsByPage.length - 1 ? (
<button
aria-label="Next"
className="module-ongoing-call__next-page"
onClick={nextPage}
type="button"
>
<div className="module-ongoing-call__next-page--arrow" />
</button>
) : null}
</>
)}
</div>
)}
</SizeObserver>
</div>
{shouldShowOverflow && overflowedParticipants.length > 0 ? (
<GroupCallOverflowArea
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
isCallReconnecting={isCallReconnecting}
onClickRaisedHand={onClickRaisedHand}
onParticipantVisibilityChanged={onParticipantVisibilityChanged}
overflowedParticipants={overflowedParticipants}
remoteAudioLevels={remoteAudioLevels}
remoteParticipantsCount={remoteParticipants.length}
/>
) : null}
</div>
);
}
// This function is only meant for use with `useInvisibleParticipants`. It helps avoid
// returning new set instances when the underlying values are equal.
function pickDifferentSet<T>(a: Readonly<Set<T>>, b: Readonly<Set<T>>): Set<T> {
return a.size === b.size ? a : b;
}
function useInvisibleParticipants(
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>
): Readonly<{
invisibleDemuxIds: Set<number>;
onParticipantVisibilityChanged: (demuxId: number, isVisible: boolean) => void;
}> {
const [invisibleDemuxIds, setInvisibleDemuxIds] = useState(new Set<number>());
const onParticipantVisibilityChanged = useCallback(
(demuxId: number, isVisible: boolean) => {
setInvisibleDemuxIds(oldInvisibleDemuxIds => {
const toggled = setUtil.toggle(
oldInvisibleDemuxIds,
demuxId,
!isVisible
);
return pickDifferentSet(oldInvisibleDemuxIds, toggled);
});
},
[]
);
useEffect(() => {
log.info(
`Invisible demux IDs changed to [${join(invisibleDemuxIds, ',')}]`
);
}, [invisibleDemuxIds]);
useEffect(() => {
const remoteParticipantDemuxIds = new Set<number>(
remoteParticipants.map(r => r.demuxId)
);
setInvisibleDemuxIds(oldInvisibleDemuxIds => {
const staleIds = filter(
oldInvisibleDemuxIds,
id => !remoteParticipantDemuxIds.has(id)
);
const withoutStaleIds = setUtil.remove(oldInvisibleDemuxIds, ...staleIds);
return pickDifferentSet(oldInvisibleDemuxIds, withoutStaleIds);
});
}, [remoteParticipants]);
return {
invisibleDemuxIds,
onParticipantVisibilityChanged,
};
}
function useVideoRequestMode(): VideoRequestMode {
const isPageVisible = usePageVisibility();
const [result, setResult] = useState<VideoRequestMode>(
isPageVisible ? VideoRequestMode.Normal : VideoRequestMode.LowResolution
);
useEffect(() => {
if (isPageVisible) {
setResult(VideoRequestMode.Normal);
return noop;
}
setResult(VideoRequestMode.LowResolution);
const timeout = setTimeout(() => {
setResult(VideoRequestMode.NoVideo);
}, TIME_TO_STOP_REQUESTING_VIDEO_WHEN_PAGE_INVISIBLE);
return () => {
clearTimeout(timeout);
};
}, [isPageVisible]);
return result;
}
function totalRowWidthAtHeight(
participantsInRow: ReadonlyArray<
Pick<ParticipantTileType, 'videoAspectRatio'>
>,
height: number
): number {
return participantsInRow.reduce(
(result, participant) =>
result + participantWidthAtHeight(participant, height),
0
);
}
function participantWidthAtHeight(
participant: Pick<ParticipantTileType, 'videoAspectRatio'>,
height: number
) {
return participant.videoAspectRatio * height;
}
function stableParticipantComparator(
a: Readonly<{ demuxId: number }>,
b: Readonly<{ demuxId: number }>
): number {
return a.demuxId - b.demuxId;
}
type ParticipantsInPageType<
T extends { videoAspectRatio: number } = ParticipantTileType
> = {
rows: Array<Array<T>>;
numParticipants: number;
};
type PageLayoutPropsType = {
maxRowWidth: number;
minRenderedHeight: number;
maxRowsPerPage: number;
maxParticipantsPerPage: number;
};
function getGridParticipantsByPage({
participants,
maxPages,
currentPage,
...pageLayoutProps
}: PageLayoutPropsType & {
participants: Array<GroupCallRemoteParticipantType>;
maxPages: number;
currentPage?: number;
}): Array<ParticipantsInPageType> {
if (!participants.length) {
return [];
}
const pages: Array<ParticipantsInPageType> = [];
function getTotalParticipantsOnGrid() {
return pages.reduce((count, page) => count + page.numParticipants, 0);
}
let remainingParticipants = [...participants];
while (remainingParticipants.length) {
if (currentPage === pages.length - 1) {
// Optimization: we can stop early, we don't have to lay out the remainder of the
// pages
pages.push({
rows: [remainingParticipants],
numParticipants: remainingParticipants.length,
});
return pages;
}
const nextPageInPriorityOrder = getNextPage({
participants: remainingParticipants,
leaveRoomForPrevPageButton: pages.length > 0,
leaveRoomForNextPageButton: pages.length + 1 < maxPages,
...pageLayoutProps,
});
// We got the next page, but it's in priority order; let's see if these participants
// also fit in sorted order
const priorityParticipantsOnNextPage = nextPageInPriorityOrder.rows.flat();
let sortedParticipantsHopingToFitOnPage = [
...priorityParticipantsOnNextPage,
].sort(stableParticipantComparator);
let nextPageInSortedOrder = getNextPage({
participants: sortedParticipantsHopingToFitOnPage,
leaveRoomForPrevPageButton: pages.length > 0,
leaveRoomForNextPageButton: pages.length + 1 < maxPages,
isSubsetOfAllParticipants:
sortedParticipantsHopingToFitOnPage.length <
remainingParticipants.length,
...pageLayoutProps,
});
let nextPage: ParticipantsInPageType<ParticipantTileType> | undefined;
if (
nextPageInSortedOrder.numParticipants ===
nextPageInPriorityOrder.numParticipants
) {
// Great, we're able to show everyone. It's possible that there is now extra space
// and we could show more people, but let's leave it here for simplicity
nextPage = nextPageInSortedOrder;
} else {
// We weren't able to fit everyone. Let's remove the least-prioritized person and
// try again. It's pretty unlikely this will take more than 1 attempt, but
// let's take more and more participants off the screen if it takes a lot of
// attempts so we don't have to iterate dozens of times.
const PARTICIPANTS_TO_REMOVE_PER_ATTEMPT = [1, 1, 1, 2, 5];
const MAX_ATTEMPTS = 5;
let attemptNumber = 0;
while (
sortedParticipantsHopingToFitOnPage.length &&
attemptNumber < MAX_ATTEMPTS
) {
const numLeastPrioritizedParticipantsToRemove =
PARTICIPANTS_TO_REMOVE_PER_ATTEMPT[
Math.min(
attemptNumber,
PARTICIPANTS_TO_REMOVE_PER_ATTEMPT.length - 1
)
];
const leastPrioritizedParticipantIds = new Set(
priorityParticipantsOnNextPage
.splice(
-1 * numLeastPrioritizedParticipantsToRemove,
numLeastPrioritizedParticipantsToRemove
)
.map(participant => participant.demuxId)
);
sortedParticipantsHopingToFitOnPage =
sortedParticipantsHopingToFitOnPage.filter(
participant =>
!leastPrioritizedParticipantIds.has(participant.demuxId)
);
nextPageInSortedOrder = getNextPage({
participants: sortedParticipantsHopingToFitOnPage,
leaveRoomForPrevPageButton: pages.length > 0,
leaveRoomForNextPageButton: pages.length + 1 < maxPages,
...pageLayoutProps,
});
// Are we able to fill all of them now? Great, let's ship it.
if (
nextPageInSortedOrder.numParticipants ===
sortedParticipantsHopingToFitOnPage.length
) {
nextPage = nextPageInSortedOrder;
break;
}
attemptNumber += 1;
}
if (!nextPage) {
log.warn(
`GroupCallRemoteParticipants: failed after ${attemptNumber} attempts to layout
the page; pageIndex: ${pages.length}, \
# fit in priority order: ${nextPageInPriorityOrder.numParticipants}, \
# fit in sorted order: ${nextPageInSortedOrder.numParticipants}`
);
nextPage = nextPageInSortedOrder;
}
}
if (!nextPage) {
break;
}
const nextPageTiles =
nextPage as ParticipantsInPageType<ParticipantTileType>;
// Add a previous page tile if needed
if (pages.length > 0) {
nextPageTiles.rows[0].unshift({
isPaginationButton: true,
paginationButtonType: 'prev',
videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO,
numParticipants: getTotalParticipantsOnGrid(),
});
}
if (!nextPage.numParticipants) {
break;
}
remainingParticipants = remainingParticipants.slice(
nextPage.numParticipants
);
pages.push(nextPage);
if (pages.length === maxPages) {
break;
}
// Add a next page tile if needed
if (remainingParticipants.length) {
nextPageTiles.rows.at(-1)?.push({
isPaginationButton: true,
paginationButtonType: 'next',
videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO,
numParticipants: remainingParticipants.length,
});
}
}
return pages;
}
/**
* Attempt to fill a new page with as many participants as will fit, leaving room for
* next/prev page buttons as needed. Participants will be added in the order provided.
*
* @returns ParticipantsInPageType, representing the participants that fit on this page
* assuming they are rendered at minimum height. Does not include prev/next buttons,
* but will leave space for them if needed. Participants are not necessarily
* returned in the row-distribution that will maximize video scaling; that should
* be done subsequently.
*/
function getNextPage({
participants,
maxRowWidth,
minRenderedHeight,
maxRowsPerPage,
maxParticipantsPerPage,
leaveRoomForPrevPageButton,
leaveRoomForNextPageButton,
isSubsetOfAllParticipants,
}: PageLayoutPropsType & {
participants: Array<GroupCallRemoteParticipantType>;
leaveRoomForPrevPageButton: boolean;
leaveRoomForNextPageButton: boolean;
isSubsetOfAllParticipants?: boolean;
}): ParticipantsInPageType<GroupCallRemoteParticipantType> {
const paginationButtonWidth = participantWidthAtHeight(
{
videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO,
},
minRenderedHeight
);
let rowWidth = leaveRoomForPrevPageButton
? paginationButtonWidth + PARTICIPANT_MARGIN
: 0;
// Initialize fresh page with empty first row
const rows: Array<Array<GroupCallRemoteParticipantType>> = [[]];
let row = rows[0];
let numParticipants = 0;
// Start looping through participants and adding them to the rows one-by-one
for (let i = 0; i < participants.length; i += 1) {
const participant = participants[i];
const isLastParticipant =
!isSubsetOfAllParticipants && i === participants.length - 1;
const participantWidth = participantWidthAtHeight(
participant,
minRenderedHeight
);
const isLastRow = rows.length === maxRowsPerPage;
const shouldShowNextButtonInThisRow =
isLastRow && !isLastParticipant && leaveRoomForNextPageButton;
const currentRowMaxWidth = shouldShowNextButtonInThisRow
? maxRowWidth - (paginationButtonWidth + PARTICIPANT_MARGIN)
: maxRowWidth;
const participantFitsOnRow =
rowWidth + participantWidth + (row.length ? PARTICIPANT_MARGIN : 0) <=
currentRowMaxWidth;
if (participantFitsOnRow) {
rowWidth += participantWidth + (row.length ? PARTICIPANT_MARGIN : 0);
row.push(participant);
numParticipants += 1;
if (numParticipants === maxParticipantsPerPage) {
return { rows, numParticipants };
}
} else {
if (isLastRow) {
return { rows, numParticipants };
}
// Start a new row!
row = [participant];
rows.push(row);
numParticipants += 1;
rowWidth = participantWidth;
}
}
return {
rows,
numParticipants,
};
}
/**
* Given an arrangement of participants in rows that we know fits on a page at minimum
* rendered height, try to find an arrangement that maximizes video size, or return the
* provided arrangement with maximal video size. The result of this is ready to be
* laid out on the screen.
*
* @returns GridArrangement: {
* rows: participants in rows,
* scalar: the scalar by which can scale every video on the page and still fit
* }
*/
function arrangeParticipantsInGrid({
participantsInRows,
maxRowWidth,
minRenderedHeight,
maxRowsPerPage,
maxGridHeight,
}: {
participantsInRows: Array<Array<ParticipantTileType>>;
maxRowWidth: number;
minRenderedHeight: number;
maxRowsPerPage: number;
maxGridHeight: number;
}): GridArrangement {
// Start out with the arrangement that was prepared by getGridParticipantsByPage.
// We know this arrangement (added one-by-one) fits, so its scalar is
// guaranteed to be >= 1. Our chunking strategy below might not arrive at such
// an arrangement.
let bestArrangement: GridArrangement = {
scalar: getMaximumScaleForRows({
maxRowWidth,
minRenderedHeight,
rows: participantsInRows,
maxGridHeight,
}),
rows: participantsInRows,
};
const participants = participantsInRows.flat();
// 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".
for (let rowCount = 1; rowCount <= maxRowsPerPage; rowCount += 1) {
// 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(participants.length / rowCount);
const rows = chunk(participants, numberOfParticipantsInRow);
const scalar = getMaximumScaleForRows({
maxRowWidth,
minRenderedHeight,
rows,
maxGridHeight,
});
if (scalar > bestArrangement.scalar) {
bestArrangement = {
scalar,
rows,
};
}
}
return bestArrangement;
}
// 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.
function getMaximumScaleForRows({
maxRowWidth,
minRenderedHeight,
maxGridHeight,
rows,
}: {
maxRowWidth: number;
minRenderedHeight: number;
maxGridHeight: number;
rows: Array<Array<ParticipantTileType>>;
}): number {
if (!rows.length) {
return 0;
}
const widestRow = maxBy(rows, x =>
totalRowWidthAtHeight(x, minRenderedHeight)
);
strictAssert(widestRow, 'Could not find widestRow');
// 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 widthScalar =
(maxRowWidth - (widestRow.length - 1) * PARTICIPANT_MARGIN) /
totalRowWidthAtHeight(widestRow, minRenderedHeight);
const heightScalar =
(maxGridHeight - (rows.length - 1) * PARTICIPANT_MARGIN) /
(rows.length * minRenderedHeight);
return Math.min(widthScalar, heightScalar);
}
function isGroupCallRemoteParticipant(
tile: ParticipantTileType
): tile is GroupCallRemoteParticipantType {
return 'demuxId' in tile;
}
function isPaginationButton(
tile: ParticipantTileType
): tile is PaginationButtonType {
return 'isPaginationButton' in tile;
}