Fix plenary spotlight for guest speakers without user accounts.
Deploy Ladill Meet / deploy (push) Successful in 52s

Spotlight by participant uuid, add on-stage picker in stage layout, and show who is currently spotlighted in the plenary status bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 22:22:07 +00:00
co-authored by Cursor
parent ee3f7410f4
commit 775c81f4ee
3 changed files with 156 additions and 33 deletions
+72 -28
View File
@@ -675,6 +675,31 @@ function meetRoom() {
return Boolean(person.user_ref && this.presenterRefs.includes(person.user_ref));
},
/** Stable key for spotlight and panel assignment (user_ref when logged in, else participant uuid). */
spotlightKey(person) {
if (!person) {
return '';
}
return person.user_ref || person.uuid || '';
},
matchesSpotlightKey(key, person) {
if (!key || !person) {
return false;
}
return key === person.user_ref || key === person.uuid;
},
findSpeakerBySpotlightKey(key) {
if (!key) {
return null;
}
return this.speakerParticipants().find((person) => this.matchesSpotlightKey(key, person)) || null;
},
speakerParticipants() {
return this.inCallParticipants().filter((person) => this.isSpeakerPerson(person));
},
@@ -712,9 +737,9 @@ function meetRoom() {
},
resolveSpotlightParticipantUuid() {
const spotlightRef = this.resolveSpotlightSpeakerRef();
if (spotlightRef) {
const match = this.speakerParticipants().find((person) => person.user_ref === spotlightRef);
const spotlightKey = this.resolveSpotlightKey();
if (spotlightKey) {
const match = this.findSpeakerBySpotlightKey(spotlightKey);
if (match) {
return match.uuid;
}
@@ -732,7 +757,7 @@ function meetRoom() {
},
panelStripParticipants() {
const onPanelRefs = new Set(this.getActivePanelSpeakerRefs());
const onPanelKeys = new Set(this.getActivePanelSpeakerKeys());
return this.inCallParticipants()
.filter((person) => {
@@ -740,7 +765,9 @@ function meetRoom() {
return true;
}
return !person.user_ref || !onPanelRefs.has(person.user_ref);
const key = this.spotlightKey(person);
return !key || !onPanelKeys.has(key);
})
.sort((a, b) => this.compareStripParticipants(a, b));
},
@@ -800,26 +827,31 @@ function meetRoom() {
strip.appendChild(item);
},
resolveSpotlightSpeakerRef() {
resolveSpotlightKey() {
const speakers = this.speakerParticipants();
if (this.spotlightSpeakerRef && speakers.some((person) => person.user_ref === this.spotlightSpeakerRef)) {
return this.spotlightSpeakerRef;
if (this.spotlightSpeakerRef) {
const match = this.findSpeakerBySpotlightKey(this.spotlightSpeakerRef);
if (match) {
return this.spotlightKey(match);
}
}
return speakers[0]?.user_ref || null;
const fallback = this.speakerParticipantsSorted()[0];
return fallback ? this.spotlightKey(fallback) : '';
},
getActivePanelSpeakerRefs() {
getActivePanelSpeakerKeys() {
if (!this.panelDiscussions || this.stageLayout !== 'panel') {
return this.speakerParticipants()
.map((person) => person.user_ref)
.map((person) => this.spotlightKey(person))
.filter(Boolean);
}
if (!this.activePanelId) {
return this.speakerParticipants()
.map((person) => person.user_ref)
.map((person) => this.spotlightKey(person))
.filter(Boolean);
}
@@ -873,12 +905,12 @@ function meetRoom() {
return null;
}
const spotlightRef = this.resolveSpotlightSpeakerRef();
if (person?.user_ref && spotlightRef && person.user_ref === spotlightRef) {
const spotlightKey = this.resolveSpotlightKey();
if (spotlightKey && this.matchesSpotlightKey(spotlightKey, person)) {
return spotlight;
}
if (!spotlightRef && this.speakerParticipantsSorted()[0]?.uuid === identity) {
if (!spotlightKey && this.speakerParticipantsSorted()[0]?.uuid === identity) {
return spotlight;
}
@@ -890,8 +922,9 @@ function meetRoom() {
return null;
}
const refs = this.getActivePanelSpeakerRefs();
if (!person?.user_ref || !refs.includes(person.user_ref)) {
const keys = this.getActivePanelSpeakerKeys();
const personKey = this.spotlightKey(person);
if (!personKey || !keys.includes(personKey)) {
return null;
}
@@ -968,7 +1001,7 @@ function meetRoom() {
}
if (grid) {
grid.dataset.panelCount = String(this.getActivePanelSpeakerRefs().length || this.speakerCount());
grid.dataset.panelCount = String(this.getActivePanelSpeakerKeys().length || this.speakerCount());
}
this.renderAudienceStrip();
@@ -1008,12 +1041,12 @@ function meetRoom() {
}
},
async setSpotlight(userRef) {
if (!userRef) {
async setSpotlight(speakerKey) {
if (!speakerKey) {
return;
}
this.spotlightSpeakerRef = userRef;
this.spotlightSpeakerRef = speakerKey;
this.stageLayout = 'plenary';
this.applyStageLayout();
@@ -1026,7 +1059,7 @@ function meetRoom() {
headers: this.headers(),
body: JSON.stringify({
stage_layout: 'plenary',
spotlight_speaker_ref: userRef,
spotlight_speaker_ref: speakerKey,
}),
});
},
@@ -1052,16 +1085,16 @@ function meetRoom() {
this.panelDiscussions = true;
},
togglePanelSpeaker(panel, userRef) {
if (!panel || !userRef) {
togglePanelSpeaker(panel, speakerKey) {
if (!panel || !speakerKey) {
return;
}
const refs = panel.speaker_refs || [];
if (refs.includes(userRef)) {
panel.speaker_refs = refs.filter((ref) => ref !== userRef);
if (refs.includes(speakerKey)) {
panel.speaker_refs = refs.filter((ref) => ref !== speakerKey);
} else {
panel.speaker_refs = [...refs, userRef];
panel.speaker_refs = [...refs, speakerKey];
}
},
@@ -2384,7 +2417,16 @@ function meetRoom() {
return 'Multiple speakers shown together in a split-screen grid.';
}
return '';
const spotlight = this.findSpeakerBySpotlightKey(this.resolveSpotlightKey());
if (spotlight) {
return `${spotlight.display_name} is on stage. Other speakers appear in the strip below.`;
}
return 'Choose who fills the stage from Stage layout or Spotlight on a speaker.';
},
spotlightSpeakerName() {
return this.findSpeakerBySpotlightKey(this.resolveSpotlightKey())?.display_name || '';
},
activePanelName() {
@@ -2403,6 +2445,7 @@ function meetRoom() {
panels: JSON.parse(JSON.stringify(this.panels)),
activePanelId: this.activePanelId,
panelDiscussions: this.panelDiscussions,
spotlightSpeakerRef: this.spotlightSpeakerRef,
};
this.panelSetupOpen = true;
},
@@ -2413,6 +2456,7 @@ function meetRoom() {
this.panels = JSON.parse(JSON.stringify(this._stageSnapshot.panels));
this.activePanelId = this._stageSnapshot.activePanelId;
this.panelDiscussions = this._stageSnapshot.panelDiscussions;
this.spotlightSpeakerRef = this._stageSnapshot.spotlightSpeakerRef || '';
this.applyStageLayout();
}
+24 -5
View File
@@ -640,8 +640,8 @@
<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">
<button x-show="usesStageLayout && isHost && person.user_ref && spotlightSpeakerRef !== person.user_ref"
type="button" @click="setSpotlight(person.user_ref)"
<button x-show="usesStageLayout && isHost && isSpeakerPerson(person) && !matchesSpotlightKey(resolveSpotlightKey(), person)"
type="button" @click="setSpotlight(spotlightKey(person))"
class="rounded-lg bg-indigo-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-indigo-500">
Spotlight
</button>
@@ -722,6 +722,25 @@
</button>
</div>
<p x-show="stageLayout === 'plenary'" x-cloak class="mt-3 rounded-lg border border-sky-500/30 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
Plenary shows one speaker on the main stage. Everyone else appears in the strip below.
</p>
<div x-show="stageLayout === 'plenary'" x-cloak class="mt-5 space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-400">On stage</p>
<div class="flex flex-wrap gap-2">
<template x-for="speaker in speakerParticipantsSorted()" :key="'plenary-' + speaker.uuid">
<button type="button" @click="setSpotlight(spotlightKey(speaker))"
class="rounded-full px-3 py-1.5 text-xs font-medium transition-colors"
:class="matchesSpotlightKey(resolveSpotlightKey(), speaker)
? 'bg-sky-600 text-white ring-1 ring-sky-400/60'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
<span x-text="speaker.display_name"></span>
</button>
</template>
</div>
</div>
<p x-show="stageLayout === 'panel'" x-cloak class="mt-3 rounded-lg border border-violet-500/30 bg-violet-500/10 px-3 py-2 text-xs text-violet-100">
Switching to panel discussion changes the live stage to a multi-speaker grid for everyone in the room.
</p>
@@ -744,10 +763,10 @@
<div class="mt-3 flex flex-wrap gap-2">
<template x-for="speaker in speakerParticipantsSorted()" :key="panel.id + '-' + speaker.uuid">
<label class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs"
:class="panel.speaker_refs.includes(speaker.user_ref) ? 'bg-violet-600/30 text-violet-100 ring-1 ring-violet-500/50' : 'bg-slate-800 text-slate-300'">
:class="panel.speaker_refs.includes(spotlightKey(speaker)) ? 'bg-violet-600/30 text-violet-100 ring-1 ring-violet-500/50' : 'bg-slate-800 text-slate-300'">
<input type="checkbox" class="sr-only"
:checked="panel.speaker_refs.includes(speaker.user_ref)"
@change="togglePanelSpeaker(panel, speaker.user_ref)">
:checked="panel.speaker_refs.includes(spotlightKey(speaker))"
@change="togglePanelSpeaker(panel, spotlightKey(speaker))">
<span x-text="speaker.display_name"></span>
</label>
</template>
+60
View File
@@ -1048,6 +1048,66 @@ class MeetWebTest extends TestCase
$this->assertCount(1, $room->setting('panels'));
}
public function test_host_can_spotlight_guest_panelist_by_participant_uuid(): void
{
$room = Room::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'host_user_ref' => $this->user->public_id,
'title' => 'Summit keynote',
'type' => 'webinar',
'status' => 'live',
'scheduled_at' => now()->addHour(),
'timezone' => 'UTC',
'settings' => array_merge(config('meet.default_settings'), [
'stage_layout' => 'plenary',
]),
]);
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'meet-'.$room->uuid,
'status' => 'live',
'started_at' => now(),
]);
$hostParticipant = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'user_ref' => $this->user->public_id,
'display_name' => $this->user->name,
'role' => 'host',
'status' => 'joined',
'joined_at' => now(),
]);
$guestSpeaker = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'display_name' => 'Guest Speaker',
'email' => 'speaker@example.com',
'role' => 'panelist',
'status' => 'joined',
'joined_at' => now(),
]);
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $hostParticipant->uuid])
->postJson('/room/'.$session->uuid.'/stage', [
'stage_layout' => 'plenary',
'spotlight_speaker_ref' => $guestSpeaker->uuid,
])
->assertOk()
->assertJson([
'stage_layout' => 'plenary',
'spotlight_speaker_ref' => $guestSpeaker->uuid,
]);
$room->refresh();
$this->assertSame($guestSpeaker->uuid, $room->setting('spotlight_speaker_ref'));
}
public function test_poll_includes_stage_config_for_conference(): void
{
$room = Room::create([