Show all on-stage speakers in audio rooms from session state.
Deploy Ladill Meet / deploy (push) Successful in 46s

Render host, co-host, and panelist cards from joined participants instead of LiveKit connections alone so listeners no longer occupy tiles and accepted speakers appear reliably.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 01:03:52 +00:00
co-authored by Cursor
parent 4dd7883763
commit 0e3e940ad4
+140 -39
View File
@@ -735,6 +735,11 @@ function meetRoom() {
},
refreshParticipantTileAvatars() {
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
return;
}
if (!room) {
return;
}
@@ -972,19 +977,121 @@ function meetRoom() {
}
},
liveKitParticipantForIdentity(identity) {
if (!room) {
return null;
}
if (room.localParticipant.identity === identity) {
return room.localParticipant;
}
return room.remoteParticipants.get(identity) || null;
},
participantTileShellHtml() {
return `
<div data-tile-body class="relative min-h-0 flex-1">
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
<div data-tile-avatar class="absolute inset-0 flex items-center justify-center">
<div data-tile-avatar-circle class="flex h-20 w-20 shrink-0 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg"></div>
</div>
</div>
<div data-tile-footer class="relative flex shrink-0 items-center justify-between gap-2 border-t border-slate-800/80 bg-slate-950/95 px-3 py-2">
<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>
<span data-tile-cam class="hidden rounded-full bg-black/50 p-1 text-slate-300" title="Camera off">
<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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</span>
</div>
</div>`;
},
ensureParticipantTileShell(identity) {
let tile = this.findParticipantTile(identity);
if (!tile) {
tile = document.createElement('div');
tile.dataset.participantTile = identity;
tile.className = this.audioOnly
? 'meet-audio-tile relative flex aspect-square w-full flex-col overflow-hidden rounded-2xl bg-slate-900'
: 'relative flex aspect-video w-full flex-col overflow-hidden rounded-xl bg-slate-900 min-h-0';
tile.innerHTML = this.participantTileShellHtml();
}
return tile;
},
updateSessionParticipantTile(person, liveParticipant = null, tile = null) {
tile = tile || this.findParticipantTile(person.uuid);
if (!tile || !person) {
return;
}
const pseudoParticipant = { identity: person.uuid, name: person.display_name };
this.updateTileAvatarCircle(tile, pseudoParticipant);
tile.querySelector('[data-tile-name]').textContent = person.display_name;
const speaking = this.isSpeaking(person.uuid);
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
const muted = liveParticipant
? !liveParticipant.isMicrophoneEnabled
: Boolean(person.is_muted);
tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', !muted);
tile.querySelector('[data-tile-cam]')?.classList.add('hidden');
},
renderSpaceSpeakerTiles() {
if (!this.isSpace || this.usesStageLayout) {
return;
}
const grid = document.getElementById('video-grid');
if (!grid) {
return;
}
const speakers = this.speakerParticipantsSorted();
const speakerIds = new Set(speakers.map((person) => person.uuid));
speakers.forEach((person) => {
const liveParticipant = this.liveKitParticipantForIdentity(person.uuid);
const tile = this.ensureParticipantTileShell(person.uuid);
if (tile.parentElement !== grid) {
grid.appendChild(tile);
}
this.updateSessionParticipantTile(person, liveParticipant, tile);
});
grid.querySelectorAll('[data-participant-tile]').forEach((tile) => {
if (!speakerIds.has(tile.dataset.participantTile)) {
tile.remove();
}
});
this.updateAudioGridLayout();
},
findParticipantTile(identity) {
return document.querySelector(`[data-participant-tile="${identity}"]`);
},
getTileContainerForIdentity(identity) {
if (!this.usesStageLayout) {
if (this.isSpace) {
const person = this.sessionParticipants.find((entry) => entry.uuid === identity);
if (!person || !['host', 'co_host', 'panelist'].includes(person.role)) {
return null;
}
}
return document.getElementById('video-grid');
}
@@ -1063,7 +1170,9 @@ function meetRoom() {
document.getElementById('meet-stage-spotlight')?.replaceChildren();
document.getElementById('meet-audience-strip')?.replaceChildren();
if (room) {
if (this.isSpace) {
this.renderSpaceSpeakerTiles();
} else if (room) {
const all = [room.localParticipant, ...room.remoteParticipants.values()];
all.forEach((participant) => {
const target = this.getTileContainerForIdentity(participant.identity);
@@ -1085,7 +1194,9 @@ function meetRoom() {
});
}
this.updateAudioGridLayout();
if (!this.isSpace) {
this.updateAudioGridLayout();
}
return;
}
@@ -1395,36 +1506,7 @@ function meetRoom() {
let tile = existing;
if (!tile) {
tile = document.createElement('div');
tile.dataset.participantTile = identity;
tile.className = this.audioOnly
? 'meet-audio-tile relative flex aspect-square w-full flex-col overflow-hidden rounded-2xl bg-slate-900'
: 'relative flex aspect-video w-full flex-col overflow-hidden rounded-xl bg-slate-900 min-h-0';
tile.innerHTML = `
<div data-tile-body class="relative min-h-0 flex-1">
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
<div data-tile-avatar class="absolute inset-0 flex items-center justify-center">
<div data-tile-avatar-circle class="flex h-20 w-20 shrink-0 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg"></div>
</div>
</div>
<div data-tile-footer class="relative flex shrink-0 items-center justify-between gap-2 border-t border-slate-800/80 bg-slate-950/95 px-3 py-2">
<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>
<span data-tile-cam class="hidden rounded-full bg-black/50 p-1 text-slate-300" title="Camera off">
<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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</span>
</div>
</div>`;
tile = this.ensureParticipantTileShell(identity);
}
if (tile.parentElement !== container) {
@@ -2119,6 +2201,12 @@ function meetRoom() {
syncParticipants() {
if (!room) return;
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
this.syncActiveSpeakers(room.activeSpeakers ?? []);
return;
}
const all = [room.localParticipant, ...room.remoteParticipants.values()];
const activeIdentities = new Set(all.map((p) => p.identity));
@@ -2179,12 +2267,21 @@ function meetRoom() {
if (room) {
[room.localParticipant, ...room.remoteParticipants.values()].forEach((participant) => {
if (this.isSpace && !this.usesStageLayout) {
return;
}
if (!this.getTileContainerForIdentity(participant.identity)) {
this.removeParticipantTile(participant.identity);
}
});
}
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
return;
}
this.applyStageLayout();
},
@@ -2336,6 +2433,10 @@ function meetRoom() {
this.canPublish = ['host', 'co_host', 'panelist'].includes(participant.role);
this.speakRequested = Boolean(participant.speak_requested);
}
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
}
},
async refreshMediaAccess() {