Add active speaker indicators to the meet room UI.
Deploy Ladill Meet / deploy (push) Successful in 43s
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:
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user