-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
/
GroupCallRemoteParticipants.tsx
245 lines (222 loc) · 9.45 KB
/
GroupCallRemoteParticipants.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
// 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 { VideoFrameSource } from '../types/Calling';
import { GroupCallParticipantInfoType } 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<GroupCallParticipantInfoType>>;
scalar: number;
}
interface PropsType {
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
remoteParticipants: ReadonlyArray<GroupCallParticipantInfoType>;
}
// 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> = ({
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<GroupCallParticipantInfoType> = 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 = Math.floor(
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 = Math.floor(
(containerDimensions.width - totalRowWidth) / 2
);
let rowWidthSoFar = 0;
return remoteParticipantsInRow.map(remoteParticipant => {
const renderedWidth = Math.floor(
remoteParticipant.videoAspectRatio * gridParticipantHeight
);
const left = rowWidthSoFar + leftOffset;
rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN;
return (
<GroupCallRemoteParticipant
key={remoteParticipant.demuxId}
demuxId={remoteParticipant.demuxId}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
hasRemoteAudio={remoteParticipant.hasRemoteAudio}
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<GroupCallParticipantInfoType>
): number {
return remoteParticipants.reduce(
(result, { videoAspectRatio }) =>
result + videoAspectRatio * MIN_RENDERED_HEIGHT,
0
);
}