UPDATE: Daily now supports 100,000 person interactive live streams. Up to 100,000 participants can join a session in real-time, with 25 cams and mics on. We also support 1,000 person large calls, where all 1,000 cams/mics are on.
This post is the first in a series about building custom video chat applications that support large meetings using the Daily call object. The features we’ll add specifically target use cases where dozens (or hundreds!) of participants in an up to 1,000 person call might be turning on their cameras.
Naming may be one of the hardest problems in computer science, but when it comes to building video chat applications, scaling can be pretty difficult too. The more participants who join a call, the more audio, video, and screen media needs to be routed to everyone on the call, and then handled by each client. For users on older devices especially, this can eat up CPU and cause problems pretty quickly.
This is the first in a series of blog posts about improving the user experience during large meetings. At the end of the series, we’ll have added three features to a custom video chat app:
- A paginated participant video grid
- Dynamic track management
- Selective simulcast encoding display
Getting started with pagination
Pagination allows all participants to be viewable, but limits the number of videos that are on the screen at a time. This reduces the load on any individual user’s CPU and network bandwidth.
In this tutorial, we’ll cover how to:
- Add a paginated grid component
- Manage participants and sort their positions depending on when they last spoke
We’ll reference this /daily-demos/tracks-subscriptions
repository throughout the post. This demo isn’t quite production-ready, because it doesn’t include audio track management; it just illustrates pagination and video track subscriptions. It also doesn’t distinguish between video tracks from cameras and video tracks from screen sharing.
We built the demo on Next 11 to generate Daily rooms dynamically server-side with API routes, but pagination, in addition to the other features we’ll add in this series, can be implemented in non-React codebases as well.
No matter the stack you choose to build on, you’ll definitely need to sign up for a Daily account if you don’t have one already.
To set up the repo locally, clone the daily-demos/track-subscriptions
repository, and then:
- Create an
.env.local
and add your ownDAILY_DOMAIN
andDAILY_API_KEY
(you can find these in the Daily dashboard). cd
into the directory and run:
yarn
yarn dev
You should now be able to click "Create and join room" and join a call:
For details on how that room is created and the call joined, check out our previous post on generating Daily rooms dynamically with Next API routes. The only thing we do differently in daily-demos/track-subscriptions
is pass the subscribeToTracksAutomatically: false
property to the Daily call object’s join()
method. This turns off Daily’s default track management so that we can later control the tracks ourselves (stay tuned for that in the next post!).
// components/Call.js
const join = useCallback(
async (callObject) => {
await callObject.join({
url: roomUrl,
subscribeToTracksAutomatically: false,
});
},
[roomUrl]
);
Click "Add fake participant", and you’ll see another colored tile join the call. Click it many more times, and you’ll be able to test out pagination in action:
Let’s look at the component behind this.
Add a paginated grid component
<PaginatedGrid />
renders participant tiles and enables pagination. The component passes the total number of call participants (from a ParticipantProvider
, more on that later) and a ref
to where the grid will be rendered to a useAspectGrid
hook.
// components/PaginatedGrid.js
// Other things here
const { page, pages, pageSize, setPage, tileWidth, tileHeight } =
useAspectGrid(gridRef, participants?.length || 0);
// Other things here
return (
// Other components here
<div ref={gridRef} className="grid">
<div className="tiles">{tiles}</div>
</div>
// Other components here
);
useAspectGrid
calculates the current page, total number of pages, number of tiles per page (pageSize
), and tile dimensions that will be used throughout <PaginatedGrid />
.
// useAspectGrid.js
const [dimensions, setDimensions] = useState({ width: 1, height: 1 }); // Grid dimensions
const [page, setPage] = useState(1); // Current page a participant is viewing
const [pages, setPages] = useState(1); // Number of pages
const [maxTilesPerPage] = useState(customMaxTilesPerPage);
useAspectGrid
gets the width and height of the window, both on load and on any window resize:
// useAspectGrid.js
useEffect(() => {
if (!gridRef.current) {
return;
}
let frame;
const handleResize = () => {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const width = gridRef.current?.clientWidth;
const height = gridRef.current?.clientHeight;
setDimensions({ width, height });
});
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [gridRef]);
It then uses those dimensions and the MIN_TILE_WIDTH
constant to calculate the maxColumns
and maxRows
per page:
// useAspectGrid.js
const [maxColumns, maxRows] = useMemo(() => {
const { width, height } = dimensions;
const columns = Math.max(1, Math.floor(width / MIN_TILE_WIDTH));
const widthPerTile = width / columns;
const rows = Math.max(1, Math.floor(height / (widthPerTile * (9 / 16))));
return [columns, rows];
}, [dimensions]);
maxColumns
and maxRows
can then be used to calculate pageSize
, the number of tiles that can be shown per page.
useAspectGrid
first checks to make sure that maxColumns * maxRows
does not exceed the constant maxTilesPerPage
value that has already been set.
// useAspectGrid.js
const pageSize = useMemo(
() => Math.min(maxColumns * maxRows, maxTilesPerPage),
[maxColumns, maxRows, maxTilesPerPage]
);
It can then dynamically update the number of pages as participants join and leave the call. numTiles
represents the participants
value passed to the hook:
// useAspectGrid.js
useEffect(() => {
setPages(Math.ceil(numTiles / pageSize));
}, [pageSize, numTiles]);
Finally, useAspectGrid
can use pageSize
to calculate participant tile dimensions:
// useAspectGrid.js
const [tileWidth, tileHeight] = useMemo(() => {
const { width, height } = dimensions;
const n = Math.min(pageSize, numTiles);
if (n === 0) return [width, height];
const dims = [];
for (let i = 1; i <= n; i += 1) {
let maxWidthPerTile = (width - (i - 1)) / i;
let maxHeightPerTile = maxWidthPerTile / DEFAULT_ASPECT_RATIO;
const rows = Math.ceil(n / i);
if (rows * maxHeightPerTile > height) {
maxHeightPerTile = (height - (rows - 1)) / rows;
maxWidthPerTile = maxHeightPerTile * DEFAULT_ASPECT_RATIO;
dims.push([maxWidthPerTile, maxHeightPerTile]);
} else {
dims.push([maxWidthPerTile, maxHeightPerTile]);
}
}
return dims.reduce(
([rw, rh], [w, h]) => {
if (w * h < rw * rh) return [rw, rh];
return [w, h];
},
[0, 0]
);
}, [dimensions, pageSize, numTiles]);
With that, all UI values are calculated. They are returned back to <PaginatedGrid />
. <PaginatedGrid />
uses the page
and the pageSize
to find the visible participants on a current page:
// PaginatedGrid.js
const visibleParticipants = useMemo(() => {
return participants.length - page * pageSize > 0
? participants.slice((page - 1) * pageSize, page * pageSize)
: participants.slice(-pageSize);
}, [page, pageSize, participants]);
It then maps over those participants and renders their <Tile />
components, passing the tileHeight
and tileWidth
from the useAspectGrid
hook as props:
// PaginatedGrid.js
const tiles = useDeepCompareMemo(
() =>
visibleParticipants.map((p) => (
<Tile
participant={p}
key={p.id}
autoLayers={autoLayers}
style={{
maxHeight: tileHeight,
maxWidth: tileWidth,
}}
/>
)),
[tileWidth, tileHeight, autoLayers, visibleParticipants]
);
<PaginatedGrid />
uses pages
and setPage
, the last two values returned from useAspectGrid
, to handle clicking through different participant pages.
The previous and next buttons are enabled or disabled depending on the number of current pages
:
<IconButton
className="page-button prev"
icon={ArrowLeftIcon}
onClick={handlePrevClick}
disabled={pages <= 1 || page <= 1}
/>
<IconButton
className="page-button next"
icon={ArrowRightIcon}
onClick={handleNextClick}
disabled={pages <= 1 || page >= pages}
/>
The handlePrevClick
and handleNextClick
functions update the current page using the setPage
state handler:
// PaginatedGrid.js
const handlePrevClick = () => {
setPage((p) => p - 1);
};
const handleNextClick = () => {
setPage((p) => p + 1);
};
Now that it’s possible to page through participants, we’re ready to sort them.
Manage participants and sort their positions
We mentioned that <PaginatedGrid />
got the number of participants on the call from a ParticipantProvider
.
The ParticipantProvider
context wraps our <Call />
component:
// components/Call.js
return (
<ParticipantProvider callObject={callObject}>
// TracksProvider here, covered in next post
<main>{renderCallState}</main>
</ParticipantProvider>
);
ParticipantProvider
ensures all participant updates are available across the app. It listens for participant events like "participant-joined"
and "participant-left"
, and dispatches actions to a reducer accordingly.
For this paginated grid feature, we’ll focus on how ParticipantProvider
and participantReducer
sort participants based on the last time they spoke on the call.
The participantReducer
tracks participant state in an object that contains details about every participant on the call, including isActiveSpeaker
(boolean
) and lastActiveDate
(Date
) values:
// participantReducer.js
export const initialState = {
participants: [
{
id: "local",
name: "",
layer: undefined,
isCamMuted: false,
isMicMuted: false,
isLoading: true,
isLocal: true,
isOwner: false,
isActiveSpeaker: false,
lastActiveDate: null,
},
],
};
When an "active-speaker-change" event happens, ParticipantProvider
dispatches an ACTIVE_SPEAKER
action, along with the id
of the participant who triggered the event, to the reducer:
// ParticipantProvider.js
useEffect(() => {
if (!callObject) return false;
const handleActiveSpeakerChange = ({ activeSpeaker }) => {
// Ignore active-speaker-change events for the local user
const localId = callObject.participants().local.session_id;
if (localId === activeSpeaker?.peerId) return;
dispatch({
type: ACTIVE_SPEAKER,
id: activeSpeaker?.peerId,
});
};
callObject.on("active-speaker-change", handleActiveSpeakerChange);
return () =>
callObject.off("active-speaker-change", handleActiveSpeakerChange);
}, [callObject]);
Once the action is received, the reducer updates the participant state. It maps through prevState
and marks the participant with the id
from the dispatched action as isActiveSpeaker: true
, and everyone else to isActiveSpeaker: false
. It also updates the lastActiveDate
for the active participant.
// participantReducer.js
export function participantsReducer(prevState, action) {
switch (action.type) {
case ACTIVE_SPEAKER: {
const { participants, ...state } = prevState;
const date = new Date();
return {
...state,
participants: participants.map((p) => ({
...p,
isActiveSpeaker: p.id === action.id,
lastActiveDate: p.id === action.id ? date : p?.lastActiveDate,
})),
};
}
// Other handlers here
}
With a lastActiveDate
saved in state for every participant, it’s possible to sort based on that date.
<PaginatedGrid />
calls on the swapParticipantPosition
helper from ParticipantProvider
to reflect the sorted order in the UI.
The component listens for changes to the activeParticipantId
(from ParticipantProvider
). When a change happens, <PaginatedGrid />
passes the new activeParticipantId
to its handleActiveSpeakerChange()
function.
// PaginatedGrid.js
useEffect(() => {
if (page > 1 || !activeParticipantId) return;
handleActiveSpeakerChange(activeParticipantId);
}, [activeParticipantId, handleActiveSpeakerChange, page]);
This function checks to see if the participant is already visible on the first page. If the participant is not visible, the function finds the id
of the least recently active participant on the first page. It then passes that id
and the new activeParticipantId
to swapParticipantPosition
.
// PaginatedGrid.js
const handleActiveSpeakerChange = useCallback(
(peerId) => {
if (!peerId) return;
// active participant is already visible
if (visibleParticipants.some(({ id }) => id === peerId)) return;
// ignore repositioning when viewing page > 1
if (page > 1) return;
const sortedVisibleRemoteParticipants = visibleParticipants
.filter(({ isLocal }) => !isLocal)
.sort((a, b) => sortByKey(a, b, "lastActiveDate"));
if (!sortedVisibleRemoteParticipants.length) return;
swapParticipantPosition(sortedVisibleRemoteParticipants[0].id, peerId);
},
[page, swapParticipantPosition, visibleParticipants]
);
swapParticipantPosition
receives the two id
’s and dispatches a SWAP_POSITION
action to the reducer:
// ParticipantProvider.js, edge cases removed
const swapParticipantPosition = (id1, id2) => {
dispatch({
type: SWAP_POSITION,
id1,
id2,
});
};
The reducer swaps the participants’ positions in the array:
// participantReducer.js
case SWAP_POSITION: {
const participants = [...prevState.participants];
if (!action.id1 || !action.id2) return prevState;
const idx1 = participants.findIndex((p) => p.id === action.id1);
const idx2 = participants.findIndex((p) => p.id === action.id2);
if (idx1 === -1 || idx2 === -1) return prevState;
const tmp = participants[idx1];
participants[idx1] = participants[idx2];
participants[idx2] = tmp;
return {
...prevState,
participants,
};
}
Coming up next
With that, we’ve added pagination and smart participant sorting to our app! Stay tuned for the next post in this series about dynamically subscribing to participant tracks. Or, if you’re eager to get a head start, all the source code for the next post is already in the repository.