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 1000 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, and to use daily-js 0.17.0 or higher.

To set up the repo locally, clone the daily-demos/track-subscriptions repository, and then:

  1. Create an .env.local and add your own DAILY_DOMAIN and DAILY_API_KEY (you can find these in the Daily dashboard).
  2. cd into the directory and run:
yarn 
yarn dev 

You should now be able to click "Create and join room" and join a call:

Create room button is clicked and then a loading spinner appears until a video call starts with one participant waving

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:

Colored tiles represent different participants on a video call and clicking through arrows changes the tiles on screen

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.

Grid of colored tiles with blue vertical arrow for columns, red horizontal arrow for rows, purple border for page size

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.