From 1037cd6ee64a290e3aa264b98c633657ac6f5634 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 21:03:02 +0000 Subject: [PATCH] Add conference stage layouts for plenary and panel discussions. Speakers get a full-screen spotlight with overlapping audience avatars; hosts can configure panels in the green room and switch layouts live. Co-authored-by: Cursor --- .../Controllers/Meet/ConferenceController.php | 12 + .../Meet/MeetingFeaturesController.php | 25 ++ .../Meet/MeetingRoomController.php | 8 +- app/Services/Meet/StageLayoutService.php | 99 +++++ public/images/meet-icons/panel.svg | 4 + resources/css/app.css | 96 +++++ resources/js/meet-room.js | 389 +++++++++++++++++- .../components/meet/room/panel.blade.php | 8 +- .../views/meet/conferences/create.blade.php | 1 + .../views/meet/conferences/show.blade.php | 6 + .../meet/room/partials/meet-icon.blade.php | 1 + resources/views/meet/room/show.blade.php | 139 ++++++- routes/web.php | 1 + tests/Feature/MeetWebTest.php | 132 ++++++ 14 files changed, 889 insertions(+), 32 deletions(-) create mode 100644 app/Services/Meet/StageLayoutService.php create mode 100644 public/images/meet-icons/panel.svg diff --git a/app/Http/Controllers/Meet/ConferenceController.php b/app/Http/Controllers/Meet/ConferenceController.php index c71eebd..3aa0c88 100644 --- a/app/Http/Controllers/Meet/ConferenceController.php +++ b/app/Http/Controllers/Meet/ConferenceController.php @@ -98,6 +98,7 @@ class ConferenceController extends Controller 'live_captions' => ['sometimes', 'boolean'], 'green_room' => ['sometimes', 'boolean'], 'practice_mode' => ['sometimes', 'boolean'], + 'panel_discussions' => ['sometimes', 'boolean'], ]); $presenterRefs = collect($validated['presenter_refs'] ?? []) @@ -114,6 +115,9 @@ class ConferenceController extends Controller 'live_captions' => $request->boolean('live_captions'), 'green_room' => $request->boolean('green_room', true), 'practice_mode' => $request->boolean('practice_mode'), + 'panel_discussions' => $request->boolean('panel_discussions'), + 'stage_layout' => 'plenary', + 'panels' => [], 'stage_mode' => true, 'presenter_refs' => $presenterRefs, ]; @@ -201,6 +205,7 @@ class ConferenceController extends Controller 'presenter_refs' => ['nullable', 'array'], 'presenter_refs.*' => ['string', 'max:64'], 'invite_emails' => ['nullable', 'string', 'max:2000'], + 'panel_discussions' => ['sometimes', 'boolean'], ]); $allowedRefs = Member::owned($this->ownerRef($request)) @@ -217,6 +222,13 @@ class ConferenceController extends Controller $this->conferences->syncPresenterRefs($room, $presenterRefs); + if ($request->has('panel_discussions')) { + $settings = array_merge($room->settings ?? [], [ + 'panel_discussions' => $request->boolean('panel_discussions'), + ]); + $room->update(['settings' => $settings]); + } + $inviteCount = 0; if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) { $invites = collect(preg_split('/[\s,;]+/', $emails)) diff --git a/app/Http/Controllers/Meet/MeetingFeaturesController.php b/app/Http/Controllers/Meet/MeetingFeaturesController.php index b2d50e2..1f64c0e 100644 --- a/app/Http/Controllers/Meet/MeetingFeaturesController.php +++ b/app/Http/Controllers/Meet/MeetingFeaturesController.php @@ -15,6 +15,7 @@ use App\Services\Meet\FileSharingService; use App\Services\Meet\LiveStreamService; use App\Services\Meet\PollService; use App\Services\Meet\QaService; +use App\Services\Meet\StageLayoutService; use App\Services\Meet\TownHallService; use App\Services\Meet\WhiteboardService; use Illuminate\Http\JsonResponse; @@ -32,6 +33,7 @@ class MeetingFeaturesController extends Controller protected WhiteboardService $whiteboards, protected LiveStreamService $liveStreams, protected TownHallService $townHall, + protected StageLayoutService $stageLayout, ) {} // --- Q&A --- @@ -320,6 +322,29 @@ class MeetingFeaturesController extends Controller ]); } + public function updateStage(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $room = $session->room; + abort_unless($this->stageLayout->usesStageLayout($room), 422); + + $validated = $request->validate([ + 'panel_discussions' => ['sometimes', 'boolean'], + 'stage_layout' => ['sometimes', 'string', 'in:plenary,panel'], + 'active_panel_id' => ['nullable', 'string', 'max:64'], + 'spotlight_speaker_ref' => ['nullable', 'string', 'max:64'], + 'panels' => ['sometimes', 'array', 'max:12'], + 'panels.*.id' => ['nullable', 'string', 'max:64'], + 'panels.*.name' => ['required_with:panels', 'string', 'max:100'], + 'panels.*.speaker_refs' => ['nullable', 'array'], + 'panels.*.speaker_refs.*' => ['string', 'max:64'], + ]); + + $room = $this->stageLayout->update($room, $validated); + + return response()->json($this->stageLayout->config($room)); + } + protected function participant(Request $request, Session $session): Participant { $uuid = $request->session()->get("meet.participant.{$session->uuid}"); diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 8e0d911..af11c57 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -14,6 +14,7 @@ use App\Services\Meet\RecordingService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; use App\Services\Meet\SpaceService; +use App\Services\Meet\StageLayoutService; use App\Services\Meet\TranscriptService; use App\Services\Meet\WebinarService; use Illuminate\Http\JsonResponse; @@ -64,6 +65,7 @@ class MeetingRoomController extends Controller $webinars = app(WebinarService::class); $spaces = app(SpaceService::class); $isAudioOnly = $room->isAudioOnly(); + $stageLayout = app(StageLayoutService::class); return view('meet.room.show', [ 'session' => $session, @@ -86,6 +88,8 @@ class MeetingRoomController extends Controller ? $spaces->canPublish($room, $participant) : $webinars->canPublish($room, $participant), 'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant), + 'usesStageLayout' => $stageLayout->usesStageLayout($room), + 'stageConfig' => $stageLayout->config($room), ]); } @@ -241,7 +245,7 @@ class MeetingRoomController extends Controller ->limit(20) ->get(); - return response()->json([ + return response()->json(array_merge([ 'participants' => $session->participants->map( fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref) ), @@ -287,7 +291,7 @@ class MeetingRoomController extends Controller 'original_name' => $f->original_name, 'file_size' => $f->file_size, ]), - ]); + ], app(StageLayoutService::class)->config($session->room))); } public function startRecording(Request $request, Session $session): JsonResponse diff --git a/app/Services/Meet/StageLayoutService.php b/app/Services/Meet/StageLayoutService.php new file mode 100644 index 0000000..f7c3ad0 --- /dev/null +++ b/app/Services/Meet/StageLayoutService.php @@ -0,0 +1,99 @@ +isConference() || $room->isWebinar(); + } + + /** @return array */ + public function config(Room $room): array + { + return [ + 'uses_stage_layout' => $this->usesStageLayout($room), + 'panel_discussions' => (bool) $room->setting('panel_discussions', false), + 'stage_layout' => (string) $room->setting('stage_layout', 'plenary'), + 'panels' => array_values((array) $room->setting('panels', [])), + 'active_panel_id' => $room->setting('active_panel_id'), + 'spotlight_speaker_ref' => $room->setting('spotlight_speaker_ref'), + ]; + } + + /** + * @param array $data + */ + public function update(Room $room, array $data): Room + { + abort_unless($this->usesStageLayout($room), 422); + + $settings = $room->settings ?? []; + + if (array_key_exists('panel_discussions', $data)) { + $settings['panel_discussions'] = (bool) $data['panel_discussions']; + } + + if (array_key_exists('stage_layout', $data)) { + $layout = (string) $data['stage_layout']; + abort_unless(in_array($layout, ['plenary', 'panel'], true), 422); + $settings['stage_layout'] = $layout; + } + + if (array_key_exists('active_panel_id', $data)) { + $settings['active_panel_id'] = $data['active_panel_id'] ?: null; + } + + if (array_key_exists('spotlight_speaker_ref', $data)) { + $settings['spotlight_speaker_ref'] = $data['spotlight_speaker_ref'] ?: null; + } + + if (array_key_exists('panels', $data)) { + $settings['panels'] = $this->normalizePanels((array) $data['panels']); + } + + $room->update(['settings' => $settings]); + + return $room->fresh(); + } + + /** + * @param array> $panels + * @return list}> + */ + public function normalizePanels(array $panels): array + { + return collect($panels) + ->map(function ($panel) { + $id = trim((string) ($panel['id'] ?? '')); + $name = trim((string) ($panel['name'] ?? '')); + + if ($id === '') { + $id = 'panel-'.Str::lower(Str::random(8)); + } + + if ($name === '') { + return null; + } + + $speakerRefs = collect($panel['speaker_refs'] ?? []) + ->filter(fn ($ref) => is_string($ref) && $ref !== '') + ->unique() + ->values() + ->all(); + + return [ + 'id' => $id, + 'name' => Str::limit($name, 100, ''), + 'speaker_refs' => $speakerRefs, + ]; + }) + ->filter() + ->values() + ->all(); + } +} diff --git a/public/images/meet-icons/panel.svg b/public/images/meet-icons/panel.svg new file mode 100644 index 0000000..fa290e9 --- /dev/null +++ b/public/images/meet-icons/panel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/css/app.css b/resources/css/app.css index 576908a..2833fad 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -45,6 +45,102 @@ html.meet-room-page { box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9); } +.meet-room-page #meet-stage:not(.meet-stage) #meet-stage-spotlight, +.meet-room-page #meet-stage:not(.meet-stage) #meet-audience-strip { + display: none; +} + +.meet-room-page .meet-stage { + height: 100%; +} + +.meet-room-page .meet-stage--plenary #video-grid { + display: none; +} + +.meet-room-page .meet-stage--plenary .meet-stage-spotlight { + flex: 1 1 auto; + min-height: min(70vh, 100%); +} + +.meet-room-page .meet-stage--plenary .meet-stage-spotlight-tile { + height: 100%; + width: 100%; + aspect-ratio: auto; +} + +.meet-room-page .meet-stage--panel #meet-stage-spotlight, +.meet-room-page .meet-stage--panel #meet-audience-strip { + display: none; +} + +.meet-room-page .meet-stage-panel-grid { + display: grid; + height: 100%; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + grid-auto-rows: minmax(0, 1fr); + align-content: center; +} + +.meet-room-page .meet-stage-panel-tile { + height: 100%; + min-height: 12rem; +} + +.meet-room-page .meet-audience-strip { + display: flex; + align-items: center; + min-height: 2.75rem; + padding: 0.25rem 0; +} + +.meet-room-page .meet-audience-avatar { + position: relative; + display: flex; + height: 2.25rem; + width: 2.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + margin-left: -0.45rem; + overflow: hidden; + border-radius: 9999px; + border: 2px solid rgb(15 23 42); + background: rgb(30 41 59); +} + +.meet-room-page .meet-audience-avatar:first-child { + margin-left: 0; +} + +.meet-room-page .meet-audience-avatar__image { + height: 100%; + width: 100%; + object-fit: cover; +} + +.meet-room-page .meet-audience-avatar__initials { + display: flex; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 600; + color: white; +} + +.meet-room-page .meet-audience-avatar--more { + z-index: 0; + width: auto; + min-width: 2.25rem; + padding: 0 0.5rem; + font-size: 0.65rem; + font-weight: 600; + color: rgb(203 213 225); + white-space: nowrap; +} + .meet-room-page .meet-audio-grid { place-content: center; place-items: center; diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 26ba306..770cc09 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -44,6 +44,14 @@ function meetRoom() { presenterRefs: [], audioOnly: false, isSpace: false, + usesStageLayout: false, + panelDiscussions: false, + stageLayout: 'plenary', + panels: [], + activePanelId: '', + spotlightSpeakerRef: '', + panelSetupOpen: false, + savingStageLayout: false, qaItems: [], activePolls: [], breakoutBroadcast: '', @@ -102,6 +110,7 @@ function meetRoom() { whiteboardSaveUrl: el.dataset.whiteboardSaveUrl, goLiveUrl: el.dataset.goLiveUrl, addPresenterUrl: el.dataset.addPresenterUrl, + stageUpdateUrl: el.dataset.stageUpdateUrl, csrf: el.dataset.csrf, sessionUuid: el.dataset.sessionUuid, }; @@ -131,6 +140,22 @@ function meetRoom() { this.presenterRefs = []; } + this.usesStageLayout = el.dataset.usesStageLayout === '1'; + this.panelDiscussions = el.dataset.panelDiscussions === '1'; + this.stageLayout = el.dataset.stageLayout || 'plenary'; + this.activePanelId = el.dataset.activePanelId || ''; + this.spotlightSpeakerRef = el.dataset.spotlightSpeakerRef || ''; + + try { + this.panels = JSON.parse(el.dataset.panels || '[]'); + } catch { + this.panels = []; + } + + if (!this.panelDiscussions && this.stageLayout === 'panel') { + this.stageLayout = 'plenary'; + } + try { this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]'); } catch { @@ -152,6 +177,7 @@ function meetRoom() { } this.pollTimer = setInterval(() => this.poll(), 3000); + this.applyStageLayout(); }, connectionErrorMessage(error) { @@ -514,6 +540,325 @@ function meetRoom() { }; }, + isSpeakerPerson(person) { + if (!person) return false; + + if (['host', 'co_host', 'panelist'].includes(person.role)) { + return true; + } + + return Boolean(person.user_ref && this.presenterRefs.includes(person.user_ref)); + }, + + speakerParticipants() { + return this.inCallParticipants().filter((person) => this.isSpeakerPerson(person)); + }, + + speakerParticipantsSorted() { + const order = { host: 0, co_host: 1, panelist: 2 }; + + return [...this.speakerParticipants()].sort((a, b) => { + const aSpeaking = this.isSpeaking(a.uuid); + const bSpeaking = this.isSpeaking(b.uuid); + + if (aSpeaking !== bSpeaking) { + return aSpeaking ? -1 : 1; + } + + const roleDiff = (order[a.role] ?? 9) - (order[b.role] ?? 9); + if (roleDiff !== 0) { + return roleDiff; + } + + return a.display_name.localeCompare(b.display_name); + }); + }, + + speakerCount() { + return this.speakerParticipants().length; + }, + + audienceParticipants() { + return this.inCallParticipants().filter((person) => !this.isSpeakerPerson(person)); + }, + + audienceParticipantsSorted() { + return [...this.audienceParticipants()].sort((a, b) => a.display_name.localeCompare(b.display_name)); + }, + + resolveSpotlightSpeakerRef() { + const speakers = this.speakerParticipants(); + + if (this.spotlightSpeakerRef && speakers.some((person) => person.user_ref === this.spotlightSpeakerRef)) { + return this.spotlightSpeakerRef; + } + + return speakers[0]?.user_ref || null; + }, + + getActivePanelSpeakerRefs() { + if (!this.panelDiscussions || this.stageLayout !== 'panel') { + return this.speakerParticipants() + .map((person) => person.user_ref) + .filter(Boolean); + } + + if (!this.activePanelId) { + return this.speakerParticipants() + .map((person) => person.user_ref) + .filter(Boolean); + } + + const panel = this.panels.find((entry) => entry.id === this.activePanelId); + + return panel?.speaker_refs || []; + }, + + mergeStageConfig(data) { + if (typeof data.stage_layout === 'string') { + this.stageLayout = data.stage_layout; + } + if (typeof data.panel_discussions === 'boolean') { + this.panelDiscussions = data.panel_discussions; + } + if (Array.isArray(data.panels)) { + this.panels = data.panels; + } + if (data.active_panel_id !== undefined) { + this.activePanelId = data.active_panel_id || ''; + } + if (data.spotlight_speaker_ref !== undefined) { + this.spotlightSpeakerRef = data.spotlight_speaker_ref || ''; + } + + if (!this.panelDiscussions && this.stageLayout === 'panel') { + this.stageLayout = 'plenary'; + } + }, + + findParticipantTile(identity) { + return document.querySelector(`[data-participant-tile="${identity}"]`); + }, + + getTileContainerForIdentity(identity) { + if (!this.usesStageLayout) { + return document.getElementById('video-grid'); + } + + const person = this.sessionParticipants.find((entry) => entry.uuid === identity); + const spotlight = document.getElementById('meet-stage-spotlight'); + const grid = document.getElementById('video-grid'); + + if (this.stageLayout === 'plenary') { + if (!this.isSpeakerPerson(person)) { + return null; + } + + const spotlightRef = this.resolveSpotlightSpeakerRef(); + if (person?.user_ref && spotlightRef && person.user_ref === spotlightRef) { + return spotlight; + } + + if (!spotlightRef && this.speakerParticipantsSorted()[0]?.uuid === identity) { + return spotlight; + } + + return null; + } + + if (this.stageLayout === 'panel') { + if (!this.isSpeakerPerson(person)) { + return null; + } + + const refs = this.getActivePanelSpeakerRefs(); + if (!person?.user_ref || !refs.includes(person.user_ref)) { + return null; + } + + return grid; + } + + return grid; + }, + + renderAudienceStrip() { + const strip = document.getElementById('meet-audience-strip'); + if (!strip) return; + + strip.replaceChildren(); + + if (!this.usesStageLayout || this.stageLayout !== 'plenary') { + return; + } + + const audience = this.audienceParticipantsSorted(); + if (audience.length === 0) { + return; + } + + const maxVisible = 8; + const visible = audience.slice(0, maxVisible); + const overflow = audience.length - visible.length; + + visible.forEach((person, index) => { + const item = document.createElement('div'); + item.className = 'meet-audience-avatar'; + item.style.zIndex = String(visible.length - index); + item.title = person.display_name; + + if (person.avatar_url) { + const img = document.createElement('img'); + img.src = person.avatar_url; + img.alt = ''; + img.className = 'meet-audience-avatar__image'; + item.appendChild(img); + } else { + const initials = document.createElement('span'); + initials.className = 'meet-audience-avatar__initials'; + initials.style.background = this.avatarStyle(person.display_name).background; + initials.textContent = this.participantInitials(person.display_name); + item.appendChild(initials); + } + + strip.appendChild(item); + }); + + if (overflow > 0) { + const more = document.createElement('div'); + more.className = 'meet-audience-avatar meet-audience-avatar--more'; + more.style.zIndex = '0'; + more.textContent = `+${overflow} others`; + strip.appendChild(more); + } + }, + + applyStageLayout() { + if (!this.usesStageLayout) { + document.getElementById('meet-stage-spotlight')?.replaceChildren(); + document.getElementById('meet-audience-strip')?.replaceChildren(); + return; + } + + const spotlight = document.getElementById('meet-stage-spotlight'); + const grid = document.getElementById('video-grid'); + + if (room) { + const all = [room.localParticipant, ...room.remoteParticipants.values()]; + all.forEach((participant) => { + const tile = this.findParticipantTile(participant.identity); + if (!tile) return; + + const target = this.getTileContainerForIdentity(participant.identity); + if (target) { + if (tile.parentElement !== target) { + target.appendChild(tile); + } + + tile.classList.toggle('meet-stage-spotlight-tile', target === spotlight); + tile.classList.toggle('meet-stage-panel-tile', target === grid && this.stageLayout === 'panel'); + } else { + tile.remove(); + } + }); + } + + if (grid) { + grid.dataset.panelCount = String(this.getActivePanelSpeakerRefs().length || this.speakerCount()); + } + + this.renderAudienceStrip(); + }, + + async saveStageLayout() { + if (!this.config.stageUpdateUrl || this.savingStageLayout) { + return; + } + + this.savingStageLayout = true; + + try { + const res = await fetch(this.config.stageUpdateUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ + stage_layout: this.stageLayout, + panels: this.panels, + active_panel_id: this.activePanelId || null, + spotlight_speaker_ref: this.spotlightSpeakerRef || null, + }), + }); + + if (!res.ok) { + return; + } + + const data = await res.json(); + this.mergeStageConfig(data); + this.panelSetupOpen = false; + this.applyStageLayout(); + } finally { + this.savingStageLayout = false; + } + }, + + async setSpotlight(userRef) { + if (!userRef) { + return; + } + + this.spotlightSpeakerRef = userRef; + this.stageLayout = 'plenary'; + this.applyStageLayout(); + + if (!this.config.stageUpdateUrl) { + return; + } + + await fetch(this.config.stageUpdateUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ + stage_layout: 'plenary', + spotlight_speaker_ref: userRef, + }), + }); + }, + + addPanel() { + this.panels.push({ + id: `panel-${Math.random().toString(36).slice(2, 10)}`, + name: `Panel ${this.panels.length + 1}`, + speaker_refs: [], + }); + }, + + removePanel(index) { + const removed = this.panels.splice(index, 1)[0]; + if (removed && this.activePanelId === removed.id) { + this.activePanelId = this.panels[0]?.id || ''; + } + }, + + setActivePanel(panelId) { + this.activePanelId = panelId; + this.stageLayout = 'panel'; + this.applyStageLayout(); + }, + + togglePanelSpeaker(panel, userRef) { + if (!panel || !userRef) { + return; + } + + const refs = panel.speaker_refs || []; + if (refs.includes(userRef)) { + panel.speaker_refs = refs.filter((ref) => ref !== userRef); + } else { + panel.speaker_refs = [...refs, userRef]; + } + }, + hostParticipant() { const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host'); if (hosts.length) { @@ -620,10 +965,7 @@ function meetRoom() { 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) => { + document.querySelectorAll('[data-participant-tile]').forEach((tile) => { const identity = tile.dataset.participantTile; const speaking = this.activeSpeakerIds.includes(identity); @@ -653,18 +995,20 @@ function meetRoom() { }, removeParticipantTile(identity) { - const grid = document.getElementById('video-grid'); - if (!grid || !identity) return; - - grid.querySelector(`[data-participant-tile="${identity}"]`)?.remove(); + this.findParticipantTile(identity)?.remove(); }, getOrCreateParticipantTile(participant) { - const grid = document.getElementById('video-grid'); - if (!grid) return null; - + const container = this.getTileContainerForIdentity(participant.identity); const identity = participant.identity; - let tile = grid.querySelector(`[data-participant-tile="${identity}"]`); + const existing = this.findParticipantTile(identity); + + if (!container) { + existing?.remove(); + return null; + } + + let tile = existing; if (!tile) { tile = document.createElement('div'); @@ -695,9 +1039,15 @@ function meetRoom() { `; - grid.appendChild(tile); } + if (tile.parentElement !== container) { + container.appendChild(tile); + } + + tile.classList.toggle('meet-stage-spotlight-tile', container.id === 'meet-stage-spotlight'); + tile.classList.toggle('meet-stage-panel-tile', container.id === 'video-grid' && this.stageLayout === 'panel'); + const displayName = participant.name || participant.identity; const hue = this.avatarHue(displayName); const circle = tile.querySelector('[data-tile-avatar-circle]'); @@ -1128,6 +1478,7 @@ function meetRoom() { } }); + this.applyStageLayout(); this.syncActiveSpeakers(room.activeSpeakers ?? []); }, @@ -1138,16 +1489,15 @@ function meetRoom() { .map((p) => p.uuid), ); - const grid = document.getElementById('video-grid'); - if (!grid) return; - - grid.querySelectorAll('[data-participant-tile]').forEach((tile) => { + document.querySelectorAll('[data-participant-tile]').forEach((tile) => { const identity = tile.dataset.participantTile; if (room?.localParticipant?.identity === identity) return; if (!joinedIds.has(identity)) { tile.remove(); } }); + + this.applyStageLayout(); }, async toggleMute() { @@ -1260,6 +1610,7 @@ function meetRoom() { this.sessionParticipants = this.sessionParticipants.map((person) => ( person.user_ref === userRef ? { ...person, role: 'panelist' } : person )); + this.applyStageLayout(); }, toggleChat() { @@ -1371,6 +1722,10 @@ function meetRoom() { this.sessionParticipants = data.participants; this.pruneTilesFromSession(); } + if (typeof data.uses_stage_layout === 'boolean' || typeof data.stage_layout === 'string') { + this.mergeStageConfig(data); + this.applyStageLayout(); + } if (typeof data.waiting_count === 'number') { const previous = this.waitingCount; this.waitingCount = data.waiting_count; diff --git a/resources/views/components/meet/room/panel.blade.php b/resources/views/components/meet/room/panel.blade.php index 9bdc882..6c728b5 100644 --- a/resources/views/components/meet/room/panel.blade.php +++ b/resources/views/components/meet/room/panel.blade.php @@ -1,6 +1,6 @@ @props([ 'show', - 'title', + 'title' => null, ])