Add active speaker indicators to the meet room UI.
Deploy Ladill Meet / deploy (push) Successful in 43s

Highlight speaking participants on video tiles and in the panel, with header copy that names one or multiple speakers at once.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 18:07:37 +00:00
co-authored by Cursor
parent ceb9ca192e
commit 3585904618
3 changed files with 136 additions and 4 deletions
+82
View File
@@ -59,6 +59,7 @@ function meetRoom() {
connectionStatus: 'Connecting…',
participants: [],
sessionParticipants: [],
activeSpeakerIds: [],
roomHost: null,
floatingReactions: [],
pollTimer: null,
@@ -254,6 +255,9 @@ function meetRoom() {
this.connectionStatus = 'Connected';
}
});
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
this.syncActiveSpeakers(speakers);
});
await room.prepareConnection(this.config.url, this.config.token);
@@ -545,6 +549,71 @@ function meetRoom() {
return labels[role] || 'Participant';
},
isSpeaking(uuid) {
return this.activeSpeakerIds.includes(uuid);
},
activeSpeakerNames() {
return this.activeSpeakerIds
.map((id) => {
const person = this.sessionParticipants.find((p) => p.uuid === id);
if (person?.display_name) {
return person.display_name;
}
if (room?.localParticipant?.identity === id) {
return room.localParticipant.name || 'You';
}
return room?.remoteParticipants.get(id)?.name || 'Someone';
})
.filter(Boolean);
},
activeSpeakersMessage() {
const names = this.activeSpeakerNames();
if (names.length === 0) return '';
if (names.length === 1) return `${names[0]} is speaking`;
if (names.length === 2) return `${names[0]} and ${names[1]} are speaking`;
const last = names[names.length - 1];
const rest = names.slice(0, -1).join(', ');
return `${rest}, and ${last} are speaking`;
},
activeSpeakerCount() {
return this.activeSpeakerIds.length;
},
inCallParticipantsSorted() {
return [...this.inCallParticipants()].sort((a, b) => {
const aSpeaking = this.isSpeaking(a.uuid);
const bSpeaking = this.isSpeaking(b.uuid);
if (aSpeaking !== bSpeaking) {
return aSpeaking ? -1 : 1;
}
return a.display_name.localeCompare(b.display_name);
});
},
syncActiveSpeakers(speakers) {
this.activeSpeakerIds = speakers.map((p) => p.identity);
const grid = document.getElementById('video-grid');
if (!grid) return;
grid.querySelectorAll('[data-participant-tile]').forEach((tile) => {
const identity = tile.dataset.participantTile;
const speaking = this.activeSpeakerIds.includes(identity);
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
});
},
async admitParticipant(uuid) {
if (!this.isHost || !this.config.sessionUuid) return;
@@ -591,6 +660,13 @@ function meetRoom() {
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-2 pt-8">
<span data-tile-name class="truncate text-xs font-medium text-white"></span>
<div class="flex shrink-0 items-center gap-1.5">
<span data-tile-speaking class="hidden meet-speaking-badge rounded-full bg-emerald-500/90 p-1 text-white" title="Speaking">
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--2" x="6.75" y="3" width="2.5" height="10" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--3" x="12.5" y="6" width="2.5" height="4" rx="1"/>
</svg>
</span>
<span data-tile-mic class="hidden rounded-full bg-black/50 p-1 text-red-400" title="Muted">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</span>
@@ -609,6 +685,10 @@ function meetRoom() {
circle.textContent = this.participantInitials(displayName);
tile.querySelector('[data-tile-name]').textContent = displayName;
const speaking = this.activeSpeakerIds.includes(identity);
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
return tile;
},
@@ -1020,6 +1100,8 @@ function meetRoom() {
tile.remove();
}
});
this.syncActiveSpeakers(room.activeSpeakers ?? []);
},
pruneTilesFromSession() {