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.
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:
- Fork and clone the
daily-demos/examples
repository - cd
examples/custom/active-speaker
- Set your
DAILY_API_KEY
andDAILY_DOMAIN
variables
💡 Side note: You might notice theMANUAL_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.
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.
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.
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
.
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 aslastActiveSpeaker
(or thelocalParticipant
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.
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.