Fix plenary spotlight for guest speakers without user accounts.
Deploy Ladill Meet / deploy (push) Successful in 52s
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:
+72
-28
@@ -675,6 +675,31 @@ function meetRoom() {
|
|||||||
return Boolean(person.user_ref && this.presenterRefs.includes(person.user_ref));
|
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() {
|
speakerParticipants() {
|
||||||
return this.inCallParticipants().filter((person) => this.isSpeakerPerson(person));
|
return this.inCallParticipants().filter((person) => this.isSpeakerPerson(person));
|
||||||
},
|
},
|
||||||
@@ -712,9 +737,9 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resolveSpotlightParticipantUuid() {
|
resolveSpotlightParticipantUuid() {
|
||||||
const spotlightRef = this.resolveSpotlightSpeakerRef();
|
const spotlightKey = this.resolveSpotlightKey();
|
||||||
if (spotlightRef) {
|
if (spotlightKey) {
|
||||||
const match = this.speakerParticipants().find((person) => person.user_ref === spotlightRef);
|
const match = this.findSpeakerBySpotlightKey(spotlightKey);
|
||||||
if (match) {
|
if (match) {
|
||||||
return match.uuid;
|
return match.uuid;
|
||||||
}
|
}
|
||||||
@@ -732,7 +757,7 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
panelStripParticipants() {
|
panelStripParticipants() {
|
||||||
const onPanelRefs = new Set(this.getActivePanelSpeakerRefs());
|
const onPanelKeys = new Set(this.getActivePanelSpeakerKeys());
|
||||||
|
|
||||||
return this.inCallParticipants()
|
return this.inCallParticipants()
|
||||||
.filter((person) => {
|
.filter((person) => {
|
||||||
@@ -740,7 +765,9 @@ function meetRoom() {
|
|||||||
return true;
|
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));
|
.sort((a, b) => this.compareStripParticipants(a, b));
|
||||||
},
|
},
|
||||||
@@ -800,26 +827,31 @@ function meetRoom() {
|
|||||||
strip.appendChild(item);
|
strip.appendChild(item);
|
||||||
},
|
},
|
||||||
|
|
||||||
resolveSpotlightSpeakerRef() {
|
resolveSpotlightKey() {
|
||||||
const speakers = this.speakerParticipants();
|
const speakers = this.speakerParticipants();
|
||||||
|
|
||||||
if (this.spotlightSpeakerRef && speakers.some((person) => person.user_ref === this.spotlightSpeakerRef)) {
|
if (this.spotlightSpeakerRef) {
|
||||||
return 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') {
|
if (!this.panelDiscussions || this.stageLayout !== 'panel') {
|
||||||
return this.speakerParticipants()
|
return this.speakerParticipants()
|
||||||
.map((person) => person.user_ref)
|
.map((person) => this.spotlightKey(person))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.activePanelId) {
|
if (!this.activePanelId) {
|
||||||
return this.speakerParticipants()
|
return this.speakerParticipants()
|
||||||
.map((person) => person.user_ref)
|
.map((person) => this.spotlightKey(person))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,12 +905,12 @@ function meetRoom() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spotlightRef = this.resolveSpotlightSpeakerRef();
|
const spotlightKey = this.resolveSpotlightKey();
|
||||||
if (person?.user_ref && spotlightRef && person.user_ref === spotlightRef) {
|
if (spotlightKey && this.matchesSpotlightKey(spotlightKey, person)) {
|
||||||
return spotlight;
|
return spotlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!spotlightRef && this.speakerParticipantsSorted()[0]?.uuid === identity) {
|
if (!spotlightKey && this.speakerParticipantsSorted()[0]?.uuid === identity) {
|
||||||
return spotlight;
|
return spotlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,8 +922,9 @@ function meetRoom() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refs = this.getActivePanelSpeakerRefs();
|
const keys = this.getActivePanelSpeakerKeys();
|
||||||
if (!person?.user_ref || !refs.includes(person.user_ref)) {
|
const personKey = this.spotlightKey(person);
|
||||||
|
if (!personKey || !keys.includes(personKey)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,7 +1001,7 @@ function meetRoom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.dataset.panelCount = String(this.getActivePanelSpeakerRefs().length || this.speakerCount());
|
grid.dataset.panelCount = String(this.getActivePanelSpeakerKeys().length || this.speakerCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.renderAudienceStrip();
|
this.renderAudienceStrip();
|
||||||
@@ -1008,12 +1041,12 @@ function meetRoom() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async setSpotlight(userRef) {
|
async setSpotlight(speakerKey) {
|
||||||
if (!userRef) {
|
if (!speakerKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.spotlightSpeakerRef = userRef;
|
this.spotlightSpeakerRef = speakerKey;
|
||||||
this.stageLayout = 'plenary';
|
this.stageLayout = 'plenary';
|
||||||
this.applyStageLayout();
|
this.applyStageLayout();
|
||||||
|
|
||||||
@@ -1026,7 +1059,7 @@ function meetRoom() {
|
|||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
stage_layout: 'plenary',
|
stage_layout: 'plenary',
|
||||||
spotlight_speaker_ref: userRef,
|
spotlight_speaker_ref: speakerKey,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1052,16 +1085,16 @@ function meetRoom() {
|
|||||||
this.panelDiscussions = true;
|
this.panelDiscussions = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
togglePanelSpeaker(panel, userRef) {
|
togglePanelSpeaker(panel, speakerKey) {
|
||||||
if (!panel || !userRef) {
|
if (!panel || !speakerKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refs = panel.speaker_refs || [];
|
const refs = panel.speaker_refs || [];
|
||||||
if (refs.includes(userRef)) {
|
if (refs.includes(speakerKey)) {
|
||||||
panel.speaker_refs = refs.filter((ref) => ref !== userRef);
|
panel.speaker_refs = refs.filter((ref) => ref !== speakerKey);
|
||||||
} else {
|
} 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 '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() {
|
activePanelName() {
|
||||||
@@ -2403,6 +2445,7 @@ function meetRoom() {
|
|||||||
panels: JSON.parse(JSON.stringify(this.panels)),
|
panels: JSON.parse(JSON.stringify(this.panels)),
|
||||||
activePanelId: this.activePanelId,
|
activePanelId: this.activePanelId,
|
||||||
panelDiscussions: this.panelDiscussions,
|
panelDiscussions: this.panelDiscussions,
|
||||||
|
spotlightSpeakerRef: this.spotlightSpeakerRef,
|
||||||
};
|
};
|
||||||
this.panelSetupOpen = true;
|
this.panelSetupOpen = true;
|
||||||
},
|
},
|
||||||
@@ -2413,6 +2456,7 @@ function meetRoom() {
|
|||||||
this.panels = JSON.parse(JSON.stringify(this._stageSnapshot.panels));
|
this.panels = JSON.parse(JSON.stringify(this._stageSnapshot.panels));
|
||||||
this.activePanelId = this._stageSnapshot.activePanelId;
|
this.activePanelId = this._stageSnapshot.activePanelId;
|
||||||
this.panelDiscussions = this._stageSnapshot.panelDiscussions;
|
this.panelDiscussions = this._stageSnapshot.panelDiscussions;
|
||||||
|
this.spotlightSpeakerRef = this._stageSnapshot.spotlightSpeakerRef || '';
|
||||||
this.applyStageLayout();
|
this.applyStageLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -640,8 +640,8 @@
|
|||||||
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
|
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex shrink-0 items-center gap-1">
|
<span class="flex shrink-0 items-center gap-1">
|
||||||
<button x-show="usesStageLayout && isHost && person.user_ref && spotlightSpeakerRef !== person.user_ref"
|
<button x-show="usesStageLayout && isHost && isSpeakerPerson(person) && !matchesSpotlightKey(resolveSpotlightKey(), person)"
|
||||||
type="button" @click="setSpotlight(person.user_ref)"
|
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">
|
class="rounded-lg bg-indigo-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-indigo-500">
|
||||||
Spotlight
|
Spotlight
|
||||||
</button>
|
</button>
|
||||||
@@ -722,6 +722,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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.
|
Switching to panel discussion changes the live stage to a multi-speaker grid for everyone in the room.
|
||||||
</p>
|
</p>
|
||||||
@@ -744,10 +763,10 @@
|
|||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<template x-for="speaker in speakerParticipantsSorted()" :key="panel.id + '-' + speaker.uuid">
|
<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"
|
<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"
|
<input type="checkbox" class="sr-only"
|
||||||
:checked="panel.speaker_refs.includes(speaker.user_ref)"
|
:checked="panel.speaker_refs.includes(spotlightKey(speaker))"
|
||||||
@change="togglePanelSpeaker(panel, speaker.user_ref)">
|
@change="togglePanelSpeaker(panel, spotlightKey(speaker))">
|
||||||
<span x-text="speaker.display_name"></span>
|
<span x-text="speaker.display_name"></span>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1048,6 +1048,66 @@ class MeetWebTest extends TestCase
|
|||||||
$this->assertCount(1, $room->setting('panels'));
|
$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
|
public function test_poll_includes_stage_config_for_conference(): void
|
||||||
{
|
{
|
||||||
$room = Room::create([
|
$room = Room::create([
|
||||||
|
|||||||
Reference in New Issue
Block a user