From 3585904618be09789ab04a77a91416be8fc5bcf8 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 18:07:37 +0000 Subject: [PATCH] Add active speaker indicators to the meet room UI. 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 --- resources/css/app.css | 34 ++++++++++ resources/js/meet-room.js | 82 ++++++++++++++++++++++++ resources/views/meet/room/show.blade.php | 24 +++++-- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index 7568394..003999e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 { diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 7018d78..049ad39 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -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() {
+ @@ -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() { diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 50add25..4bb42f6 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -81,6 +81,10 @@

{{ $room->title }}

+