Highlight the active speaker on a video call
2022-04-07: The code snippets in this post reference an older version of the examples repo prior to the release of Daily React Hooks. Any code not using Daily React Hooks is still valid and will work; however, we strongly suggest used Daily React Hooks in any React apps using Daily. To see the pre-Daily hooks version of the examples repo, please refer to the pre-daily-hooks branch. To learn more about Daily React Hooks, read our announcement post.

As the number of participants on a video call grows, it becomes harder to keep track of who is currently speaking if everybody is rendered at the same size. During virtual events like fitness classes and webinars, attendees often prefer that instructors take up most of the screen, with other participants minimized.

This post walks through how to build that type of experience, putting active speakers in the spotlight.

Video call participant speaks and is shown on the largest tile in center of app, other participants displayed smaller

While Daily Prebuilt, our ready-to-use embeddable video chat interface, comes with a built-in, togglable active speaker mode, this tutorial implements the feature in a custom app.

It goes over how to use the Daily call object to:

  • Listen for the active-speaker-change event to update app state
  • Render the current speaker more prominently than other participants

Before jumping into those steps, let’s set up the demo locally.

⚠️  Like the other demos in our /examples monorepo, this one is in React. The code samples in this post will be as well, but we tried to write it with folks who have a read-only relationship with React in mind. Nonetheless, if you’d rather get to building in an environment of your choice, all you need to do is listen for the active-speaker-change event.

Run the demo locally

Make sure you have a Daily account and have created a Daily room (either from the dashboard or via a POST to the /rooms endpoint. Then:

  1. Fork and clone the daily-demos/examples repository
  2. cd examples/custom/active-speaker
  3. Set your DAILY_API_KEY and DAILY_DOMAIN variables
💡 Side note: You might notice the MANUAL_TRACK_SUBS variable. When true, this turns off Daily’s default track management and implements manual track subscriptions to optimize call performance in the <ParticipantsBar />. This means we only subscribe to a participant’s video track when it’s their turn to be displayed (based on scroll position).
We won’t dive into the track subscriptions part of the demo since we’re focused on highlighting active speakers here, but you can read more about how and why to implement manual track subscriptions in our previous post or in our docs.

4.   yarn
5.   yarn workspace @custom/active-speaker dev

Head to http://localhost:3000/, and you should see a screen that prompts you to enter a room name under your domain.

Home screen reads Basic Call + Active Speaker and prompts viewer to enter a room to join

If you’ve read our first Daily and Next.js post, you’ll know our /examples monorepo reuses shared components and contexts for different use cases. You can review those fundamentals (like how calls are created and joined) in that post. Today, we’re just focused on putting the speaker in the spotlight.

Channing Tatum jumps into the spotlight with Fabio-like long hair as the character Dash McMan

Listen for the active-speaker-change event to update app state

The Daily active-speaker-change event is the key tool we’ll use to implement this feature. Under the hood, Daily determines whose microphone is the loudest and pinpoints that participant as the current active speaker. When that value changes, active-speaker-change emits.

In our demo app, ParticipantProvider.js listens for active-speaker-change. We made the design choice to never display the local participant in the prominent tile, so the listener first checks to make sure that the participant id associated with the event is not the local participant’s. The Provider then dispatches an ACTIVE_SPEAKER action to the participantsReducer in response:

// ParticipantsProvider.js 

useEffect(() => {
  if (!callObject) return false;
  const handleActiveSpeakerChange = ({ activeSpeaker }) => {

    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]);


The participantsReducer updates the app’s active speaker state. We’ll walk through this code step by step, but here it is all at once first:

// participantsState.js 

function participantsReducer(prevState, action) {
  switch (action.type) {
    case ACTIVE_SPEAKER: {
      const { participants, ...state } = prevState;
      if (!action.id)
        return {
          ...prevState,
          lastPendingUnknownActiveSpeaker: null,
        };
      const date = new Date();
      const isParticipantKnown = participants.some((p) => p.id === action.id);
      return {
        ...state,
        lastPendingUnknownActiveSpeaker: isParticipantKnown
          ? null
          : {
              date,
              id: action.id,
            },
        participants: participants.map((p) => ({
          ...p,
          isActiveSpeaker: p.id === action.id,
          lastActiveDate: p.id === action.id ? date : p?.lastActiveDate,
        })),
      };
    }
    // Other cases here 
  }
}

First, the reducer gets the previous app state, then makes sure a participant id was included in the dispatched action, so it’s possible to determine who the active participant is. If there is no id, the function returns the previous state:

// participantsState.js 

const { participants, ...state } = prevState;
if (!action.id)
  return {
    ...prevState,
    lastPendingUnknownActiveSpeaker: null,
  };

If there is an id, the participantsReducer creates a new date, and confirms if the participant is already included in the app’s participant list:

// participantsState.js 

return {
  ...state,
  lastPendingUnknownActiveSpeaker: isParticipantKnown
    ? null
    : {
        date,
        id: action.id,
      },
  participants: participants.map((p) => ({
    ...p,
    isActiveSpeaker: p.id === action.id,
    lastActiveDate: p.id === action.id ? date : p?.lastActiveDate,
  })),
};

If the participant is unknown, the reducer updates the lastUnknownParticipant object to the new date and participant id. If the speaker is known, this value is set to null.

Then, it updates the participants list. It maps over the previous participants list, and sets a given participant’s isActiveSpeaker value to true if that id matches the id from the ACTIVE_SPEAKER action. Finally, if the participant is the active speaker, the lastActiveDate value is updated to the newly created date.

With that, we have successfully updated our state! Time to celebrate.

Channing Tatum vogueing

And by that, we mean head back to ParticipantsProvider.js, where we’re listening for state changes.

With an updated participants list, the ParticipantsProvider looks through the new list to find the participant with isActiveSpeaker set to true. The provider then sets activeParticipant to that value.

// ParticipantsProvider.js 

const activeParticipant = useMemo(
  () => participants.find(({ isActiveSpeaker }) => isActiveSpeaker),
  [participants]
);


activeParticipant is then used to calculate currentSpeaker.

Channing Tatum says I'm so confused right now

Bear with us! We need both values. If the last activeParticipant leaves while everybody else is muted, somebody else still needs to be displayed prominently (at least in the way we designed our demo). The currentSpeaker value, representing the participant to be highlighted, handles this edge case.

// ParticipantsProvider.js

const currentSpeaker = useMemo(() => {

    const isPresent = participants.some((p) => p?.id === activeParticipant?.id);
    if (isPresent) {
      return activeParticipant;
    }

    const displayableParticipants = participants.filter((p) => !p?.isLocal);

    if (
      displayableParticipants.length > 0 &&
      displayableParticipants.every((p) => p.isMicMuted && !p.lastActiveDate)
    ) {
      return (
        displayableParticipants.find((p) => !p.isCamMuted) ??
        displayableParticipants?.[0]
      );
    }

    const sorted = displayableParticipants
      .sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
      .reverse();

    const lastActiveSpeaker = sorted?.[0]; 

    return lastActiveSpeaker || localParticipant;
  }, [activeParticipant, localParticipant, participants]);

If the activeParticipant is present, the currentSpeaker is the activeParticipant, so the function returns that value.

If the activeParticipant has left the call:

  • The function gets the list of displayable participants, then checks for the edge case that no other participants ever unmuted. If that’s the case, the function returns the first participant with a camera on.
  • If all cameras are off, it returns the first remote participant.
  • If neither of those cases is true, meaning at least one participant has spoken, the function sorts the displayableParticipants by their last active date, then reverses the list to get the participant who most recently spoke. It then returns that value as lastActiveSpeaker (or the localParticipant value if something went wrong).

Now that we have all the values we need, we’re ready to display them in the UI.

Render the current speaker more prominently than other participants

The <SpeakerView /> component not only imports the values calculated in ParticipantsProvider, but also imports a <SpeakerTile /> component. <SpeakerTile /> takes the currentSpeaker as a prop.

// SpeakerView.js 

<SpeakerTile participant={currentSpeaker} screenRef={activeRef} />

<SpeakerTile /> then dynamically determines the height and aspect ratio of the tile. Dive into the codebase for those details.

The remaining or "other" participants are passed as props to <ParticipantBar />, as are any fixedItems that won’t move in the UI, like the local participant. A width value for styling is also passed.

// SpeakerView.js 

<ParticipantBar
    fixed={fixedItems}
    others={otherItems}
    width={SIDEBAR_WIDTH}
/>

If the MANUAL_TRACK_SUBS env value was enabled, <ParticipantBar /> handles manual track subscriptions. Like <SpeakerTile />, it also performs some dynamic sizing and styling that is beyond the scope of this post, but we welcome you to check that out in the full demo code.

What matters to the active speaker feature in this demo is that we not only render the current speaker prominently, but also make sure that <ParticipantBar /> identifies the current speaker too. Our app places that participant at the topmost available position and highlights them.

Speaker displayed in center on video call is also seen in side participant bar that arrow points to

We do this in the maybePromoteActiveSpeaker() function whenever the currentSpeakerId changes. Except in the cases of screen sharing, if the current speaker is not already at the topmost position, this function swaps whatever participant is in that place with the current speaker. It also adds an activeTile class, and controls scroll positioning.

// ParticipantBar.js

useEffect(() => {
    const scrollEl = scrollRef.current;
    
    if (!hasScreenshares || !scrollEl) return false;

    const maybePromoteActiveSpeaker = () => {
      const fixedOther = fixed.find((f) => !f.isLocal);

      if (!fixedOther || fixedOther?.id === currentSpeakerId || !scrollEl) {
        return false;
      }

      if (
        visibleOthers.every((p) => p.id !== currentSpeakerId) &&
        !isLocalId(currentSpeakerId)
      ) {
        swapParticipantPosition(fixedOther.id, currentSpeakerId);
        return false;
      }

      const activeTile = othersRef.current?.querySelector(
        `[id="${currentSpeakerId}"]`
      );

      if (!activeTile) return false;

      if (currentSpeakerId === pinnedId) return false;

      const { height: tileHeight } = activeTile.getBoundingClientRect();
      const othersVisibleHeight =
        scrollEl?.clientHeight - othersRef.current?.offsetTop;

      const scrolledOffsetTop = activeTile.offsetTop - scrollEl?.scrollTop;

      if (
        scrolledOffsetTop + tileHeight / 2 < othersVisibleHeight &&
        scrolledOffsetTop > -tileHeight / 2
      ) {
        return false;
      }
      return swapParticipantPosition(fixedOther.id, currentSpeakerId);
    };
    maybePromoteActiveSpeaker();
    const throttledHandler = debounce(maybePromoteActiveSpeaker, 100);
    scrollEl.addEventListener('scroll', throttledHandler);

    return () => {
      scrollEl?.removeEventListener('scroll', throttledHandler);
    };
  }, [
    currentSpeakerId,
    fixed,
    hasScreenshares,
    pinnedId,
    swapParticipantPosition,
    visibleOthers,
  ]);

Stay active

With that, we’ve built a spotlight for our active speaker! To keep exploring the demo, here are some questions to consider:

  • How did we account for screen sharing? How would you do it?
  • What about those positioning calculations? What do you think about how we did the scrollbar?
  • Can you think of any ways to optimize this demo’s performance? (Hint: check out this recent blog post).

Or, if you’ve come this far and you’ve decided you don’t want to build your own active speaker feature after all, test out Daily Prebuilt's.

Never miss a story

Get the latest direct to your inbox.