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
+34
View File
@@ -28,6 +28,40 @@
margin-left: auto;
}
.meet-tile-speaking {
box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9);
}
.meet-speaking-bar {
transform-origin: center bottom;
animation: meet-speaking-pulse 0.9s ease-in-out infinite;
}
.meet-speaking-bar--1 {
animation-delay: 0s;
}
.meet-speaking-bar--2 {
animation-delay: 0.15s;
}
.meet-speaking-bar--3 {
animation-delay: 0.3s;
}
@keyframes meet-speaking-pulse {
0%,
100% {
transform: scaleY(0.45);
opacity: 0.65;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
/* Meeting room footer */
@media (max-width: 639px) {
.meet-toolbar__track {
+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() {
+20 -4
View File
@@ -81,6 +81,10 @@
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold">{{ $room->title }}</h1>
<p class="text-xs text-slate-400" x-text="connectionStatus"></p>
<p x-show="activeSpeakersMessage()" x-cloak
class="truncate text-xs font-medium text-emerald-400"
:title="activeSpeakersMessage()"
x-text="activeSpeakersMessage()"></p>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-slate-400">
<button @click.stop="openShare()" data-share-trigger type="button"
@@ -343,6 +347,7 @@
<x-meet.room.panel show="participantsOpen" title="Participants">
<x-slot:subtitle>
<p x-text="participantCount() + (participantCount() === 1 ? ' person' : ' people') + ' in this call'"></p>
<p x-show="activeSpeakerCount() >= 2" x-cloak class="text-emerald-400" x-text="activeSpeakersMessage()"></p>
</x-slot:subtitle>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
@@ -371,23 +376,34 @@
</template>
</div>
</template>
<template x-for="person in inCallParticipants()" :key="person.uuid">
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5">
<template x-for="person in inCallParticipantsSorted()" :key="person.uuid">
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5"
:class="isSpeaking(person.uuid) ? 'bg-emerald-500/10 ring-1 ring-emerald-500/40' : ''">
<span class="relative flex h-10 w-10 shrink-0">
<img x-show="person.avatar_url"
:src="person.avatar_url"
alt=""
class="h-10 w-10 rounded-full object-cover">
class="h-10 w-10 rounded-full object-cover"
:class="isSpeaking(person.uuid) ? 'ring-2 ring-emerald-400 ring-offset-2 ring-offset-slate-900' : ''">
<span x-show="!person.avatar_url"
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold text-white"
:class="isSpeaking(person.uuid) ? 'ring-2 ring-emerald-400 ring-offset-2 ring-offset-slate-900' : ''"
:style="avatarStyle(person.display_name)"
x-text="participantInitials(person.display_name)"></span>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-white" x-text="person.display_name"></span>
<span class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
<span x-show="isSpeaking(person.uuid)" class="block text-xs font-medium text-emerald-400">Speaking</span>
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
</span>
<span class="flex shrink-0 items-center gap-1 text-slate-400">
<span x-show="isSpeaking(person.uuid)" class="meet-speaking-badge rounded-full bg-emerald-500/20 p-1 text-emerald-300" 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 x-show="person.hand_raised" class="rounded-full bg-amber-500/20 p-1 text-amber-300" title="Hand raised">
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand', 'class' => 'h-3.5 w-3.5'])
</span>