Implement conference breakouts with meeting mode and programme lineup UI.
Deploy Ladill Meet / deploy (push) Successful in 1m3s

Attendees reconnect to breakout rooms with publish access while hosts stay on stage; poll and media-token endpoints drive LiveKit reconnection, and the live room shows Events-linked programme when available.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-02 00:06:45 +00:00
co-authored by Cursor
parent e698e75f17
commit fa9b4138af
8 changed files with 545 additions and 22 deletions
+161 -2
View File
@@ -65,7 +65,15 @@ function meetRoom() {
qaItems: [],
activePolls: [],
breakoutBroadcast: '',
breakoutBroadcastInput: '',
inBreakout: false,
breakoutsActive: false,
activeBreakouts: [],
assignedBreakoutRoomId: null,
breakoutRoomName: '',
breakoutClosesAt: '',
featuresOpen: false,
programmeOpen: false,
whiteboardOpen: false,
qaInput: '',
chatOpen: false,
@@ -115,7 +123,9 @@ function meetRoom() {
pollsCreateUrl: el.dataset.pollsCreateUrl,
breakoutsCreateUrl: el.dataset.breakoutsCreateUrl,
breakoutsCloseUrl: el.dataset.breakoutsCloseUrl,
breakoutsReturnUrl: el.dataset.breakoutsReturnUrl,
breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl,
mediaTokenUrl: el.dataset.mediaTokenUrl,
filesUploadUrl: el.dataset.filesUploadUrl,
whiteboardUrl: el.dataset.whiteboardUrl,
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
@@ -133,7 +143,9 @@ function meetRoom() {
this.initApplauseAudio(el.dataset.applauseAudio || '');
this.isHost = el.dataset.isHost === '1';
this.config.participantUuid = el.dataset.participant || '';
this.canPublish = el.dataset.canPublish !== '0';
this.inBreakout = el.dataset.inBreakout === '1';
this.isWebinar = el.dataset.isWebinar === '1';
this.isConference = el.dataset.isConference === '1';
this.isGreenRoom = el.dataset.isGreenRoom === '1';
@@ -177,6 +189,9 @@ function meetRoom() {
this.waitingCount = this.sessionParticipants.filter((p) => p.status === 'waiting').length;
const selfParticipant = this.sessionParticipants.find((p) => p.uuid === this.config.participantUuid);
this.assignedBreakoutRoomId = selfParticipant?.breakout_room_id ?? null;
try {
this.roomHost = JSON.parse(el.dataset.roomHost || 'null');
} catch {
@@ -371,7 +386,7 @@ function meetRoom() {
},
async enableLocalMedia(activeRoom, attempts = 3) {
const wantMic = !this.muteOnJoin || this.isHost;
const wantMic = this.inBreakout ? !this.muteOnJoin : (!this.muteOnJoin || this.isHost);
const wantCamera = !this.videoOffOnJoin && !this.audioOnly;
const audioOptions = {
echoCancellation: true,
@@ -1510,19 +1525,151 @@ function meetRoom() {
const count = parseInt(this.breakoutCount, 10);
if (!count || count < 1) return;
await fetch(this.config.breakoutsCreateUrl, {
const res = await fetch(this.config.breakoutsCreateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ count }),
});
if (!res.ok) {
return;
}
this.breakoutModalOpen = false;
this.breakoutsActive = true;
},
async closeBreakouts() {
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
this.featuresOpen = false;
this.breakoutsActive = false;
this.activeBreakouts = [];
},
async broadcastToBreakouts() {
const message = this.breakoutBroadcastInput.trim();
if (!this.isHost || !message || !this.config.breakoutsBroadcastUrl) {
return;
}
const res = await fetch(this.config.breakoutsBroadcastUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ message }),
});
if (!res.ok) {
return;
}
this.breakoutBroadcastInput = '';
this.breakoutBroadcast = message;
},
applyMediaSessionState(media) {
if (!media) {
return;
}
this.inBreakout = Boolean(media.in_breakout);
this.assignedBreakoutRoomId = media.breakout_room_id ?? null;
this.canPublish = Boolean(media.can_publish);
this.usesStageLayout = Boolean(media.uses_stage_layout);
this.breakoutRoomName = media.breakout?.name || '';
this.breakoutClosesAt = media.breakout?.closes_at || '';
},
async syncMediaSessionFromPoll(data) {
const media = data.media;
if (!media) {
return;
}
const previousBreakoutId = this.assignedBreakoutRoomId;
const previousCanPublish = this.canPublish;
const previousUsesStageLayout = this.usesStageLayout;
this.applyMediaSessionState(media);
this.breakoutsActive = Boolean(data.breakouts_active);
this.activeBreakouts = Array.isArray(data.breakouts) ? data.breakouts : [];
const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId
|| previousCanPublish !== this.canPublish
|| previousUsesStageLayout !== this.usesStageLayout;
if (needsReconnect && this.config.configured) {
await this.reconnectLiveKit();
}
},
async reconnectLiveKit() {
if (!this.config.mediaTokenUrl) {
return;
}
try {
const res = await fetch(this.config.mediaTokenUrl, { headers: { Accept: 'application/json' } });
if (!res.ok) {
return;
}
const data = await res.json();
this.config.token = data.token;
this.applyMediaSessionState(data);
this.breakoutsActive = Boolean(data.breakouts_active);
if (room) {
await room.disconnect();
room = null;
}
connectPromise = null;
document.getElementById('video-grid')?.replaceChildren();
document.getElementById('meet-stage-spotlight')?.replaceChildren();
document.getElementById('meet-audience-strip')?.replaceChildren();
this.connectionStatus = this.inBreakout ? 'Joining breakout…' : 'Returning to main room…';
await this.connectLiveKit();
this.applyStageLayout();
} catch (error) {
console.error(error);
this.connectionStatus = this.connectionErrorMessage(error);
}
},
breakoutTimeRemaining() {
if (!this.breakoutClosesAt) {
return '';
}
const closes = Date.parse(this.breakoutClosesAt);
if (Number.isNaN(closes)) {
return '';
}
const seconds = Math.max(0, Math.floor((closes - Date.now()) / 1000));
if (seconds === 0) {
return 'Ending soon';
}
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return minutes > 0
? `${minutes}m ${remainder}s remaining`
: `${remainder}s remaining`;
},
toggleProgramme() {
this.programmeOpen = !this.programmeOpen;
if (this.programmeOpen) {
this.chatOpen = false;
this.participantsOpen = false;
this.featuresOpen = false;
this.shareOpen = false;
}
},
openUploadPicker() {
@@ -1797,6 +1944,7 @@ function meetRoom() {
if (this.chatOpen) {
this.participantsOpen = false;
this.featuresOpen = false;
this.programmeOpen = false;
this.shareOpen = false;
}
},
@@ -1806,6 +1954,7 @@ function meetRoom() {
if (this.participantsOpen) {
this.chatOpen = false;
this.featuresOpen = false;
this.programmeOpen = false;
this.shareOpen = false;
}
},
@@ -1815,6 +1964,7 @@ function meetRoom() {
if (this.featuresOpen) {
this.chatOpen = false;
this.participantsOpen = false;
this.programmeOpen = false;
this.shareOpen = false;
}
},
@@ -1924,6 +2074,7 @@ function meetRoom() {
if (data.broadcasts?.length) {
this.breakoutBroadcast = data.broadcasts[0].body;
}
await this.syncMediaSessionFromPoll(data);
this.processReactionsFromPoll(data.reactions || []);
} catch (e) {
// ignore poll errors
@@ -1939,6 +2090,10 @@ function meetRoom() {
},
conferenceStatusLabel() {
if (this.inBreakout) {
return 'Breakout · meeting mode';
}
if (this.isGreenRoom) {
if (this.usesStageLayout) {
return `Green room · ${this.stageLayoutLabel()}`;
@@ -1955,6 +2110,10 @@ function meetRoom() {
},
conferenceStatusClass() {
if (this.inBreakout) {
return 'text-amber-400/90';
}
if (this.isGreenRoom) {
return 'text-violet-400/90';
}
@@ -0,0 +1,66 @@
@php
$programme = $programmeSnapshot ?? null;
$linkedItems = $linkedProgrammeItems ?? [];
@endphp
@if(is_array($programme) && ! empty($programme['days']))
<div class="space-y-4 text-sm">
@if(! empty($programme['title']))
<p class="text-xs text-slate-400">{{ $programme['title'] }}</p>
@endif
@if($linkedItems !== [])
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-violet-400">This session</p>
<ul class="mt-2 space-y-2">
@foreach($linkedItems as $item)
<li class="rounded-lg border border-violet-500/30 bg-violet-500/10 px-3 py-2 text-slate-100">
@if(! empty($item['time']))
<span class="font-mono text-xs text-violet-200/80">{{ $item['time'] }}</span>
@endif
<span class="font-medium">{{ $item['title'] ?? 'Item' }}</span>
@if(! empty($item['host']))
<span class="text-slate-400"> · {{ $item['host'] }}</span>
@endif
</li>
@endforeach
</ul>
</div>
@endif
@foreach((array) ($programme['days'] ?? []) as $day)
<div>
@if(! empty($day['label']) || ! empty($day['date']))
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{{ trim(($day['label'] ?? '').' '.($day['date'] ?? '')) }}
</h3>
@endif
<ul class="mt-2 divide-y divide-slate-800">
@foreach((array) ($day['items'] ?? []) as $item)
<li class="py-2.5">
<div class="flex gap-3">
@if(! empty($item['time']))
<span class="w-14 shrink-0 font-mono text-xs text-slate-500">{{ $item['time'] }}</span>
@endif
<div class="min-w-0">
<p class="font-medium text-slate-100">{{ $item['title'] ?? '' }}</p>
@if(! empty($item['description']))
<p class="mt-0.5 text-xs text-slate-400">{{ $item['description'] }}</p>
@endif
@if(! empty($item['location']) || ! empty($item['host']))
<p class="mt-0.5 text-xs text-slate-500">
@if(! empty($item['location'])){{ $item['location'] }}@endif
@if(! empty($item['host'])) · {{ $item['host'] }}@endif
</p>
@endif
</div>
</div>
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
@else
<p class="px-2 py-4 text-center text-xs text-slate-500">No programme linked to this conference yet.</p>
@endif
+71 -2
View File
@@ -27,6 +27,7 @@
$isConferenceSession = $isConference ?? false;
$sessionNoun = $isConferenceSession ? 'conference' : 'meeting';
$sessionNounTitle = $isConferenceSession ? 'Conference' : 'Meeting';
$hasProgramme = is_array($programmeSnapshot ?? null) && ! empty(($programmeSnapshot ?? [])['days']);
@endphp
<div id="meet-config"
data-token="{{ $mediaToken }}"
@@ -70,11 +71,14 @@
data-spotlight-speaker-ref="{{ $stageConfig['spotlight_speaker_ref'] ?? '' }}"
data-presenter-refs='@json($presenterRefs ?? [])'
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
data-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}"
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
data-breakouts-create-url="{{ route('meet.room.breakouts.create', $session) }}"
data-breakouts-close-url="{{ route('meet.room.breakouts.close', $session) }}"
data-breakouts-return-url="{{ route('meet.room.breakouts.return', $session) }}"
data-breakouts-broadcast-url="{{ route('meet.room.breakouts.broadcast', $session) }}"
data-media-token-url="{{ route('meet.room.media-token', $session) }}"
data-files-upload-url="{{ route('meet.room.files.upload', $session) }}"
data-whiteboard-url="{{ route('meet.room.whiteboard.show', $session) }}"
data-whiteboard-save-url="{{ route('meet.room.whiteboard.save', $session) }}"
@@ -167,7 +171,28 @@
</div>
</div>
<div x-show="usesStageLayout && !isGreenRoom" x-cloak
<div x-show="inBreakout" x-cloak
class="flex shrink-0 flex-col gap-1 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="text-sm font-semibold text-amber-100">Breakout session</p>
<p class="truncate text-xs text-amber-200/90" x-text="breakoutRoomName ? 'You are in ' + breakoutRoomName : 'You are in a breakout room'"></p>
</div>
<p x-show="breakoutTimeRemaining()" class="shrink-0 text-xs font-medium text-amber-200/90" x-text="breakoutTimeRemaining()"></p>
</div>
<div x-show="isHost && breakoutsActive && !inBreakout" x-cloak
class="flex shrink-0 flex-col gap-2 border-b border-amber-500/25 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 text-sm text-amber-100">
<p class="font-medium">Breakouts are active</p>
<p class="text-xs text-amber-200/90">Attendees are in smaller discussion groups. You remain on the main stage.</p>
</div>
<button @click="closeBreakouts()" type="button"
class="shrink-0 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-amber-950 hover:bg-amber-400">
End breakouts
</button>
</div>
<div x-show="usesStageLayout && !isGreenRoom && !inBreakout" x-cloak
class="flex shrink-0 flex-col gap-2 border-b px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
:class="stageLayout === 'panel' ? 'border-violet-500/25 bg-violet-500/10' : 'border-sky-500/25 bg-sky-500/10'">
<div class="flex min-w-0 items-start gap-2.5">
@@ -325,6 +350,14 @@
<button @click="sendReaction('heart')" type="button" x-show="canPublish" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="React">
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
</button>
@if ($hasProgramme)
<button @click="toggleProgramme()" type="button"
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
:class="programmeOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="Programme">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M4.5 8.25h15M4.5 19.5h15a1.5 1.5 0 0 0 1.5-1.5V8.25a1.5 1.5 0 0 0-1.5-1.5h-15a1.5 1.5 0 0 0-1.5 1.5v9.75a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
</button>
@endif
<button @click="toggleChat()" type="button"
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
:class="chatOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
@@ -379,7 +412,7 @@
@keydown.escape.window="breakoutModalOpen = false">
<div class="w-full max-w-sm rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="breakoutModalOpen = false">
<h3 class="text-base font-semibold text-white">Start breakout rooms</h3>
<p class="mt-1 text-sm text-slate-400">How many breakout rooms should be created?</p>
<p class="mt-1 text-sm text-slate-400">Attendees and speakers are split into smaller groups with full meeting controls. You stay on the main stage.</p>
<form @submit.prevent="submitBreakouts()" class="mt-4 space-y-4">
<div>
<label for="breakout-count" class="sr-only">Number of rooms</label>
@@ -444,6 +477,21 @@
</div>
</x-meet.room.panel>
@if ($hasProgramme)
<x-meet.room.panel show="programmeOpen" title="Programme">
<x-slot:subtitle>
<p>Full conference lineup from Events</p>
</x-slot:subtitle>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
@include('meet.room.partials.programme-panel', [
'programmeSnapshot' => $programmeSnapshot,
'linkedProgrammeItems' => $linkedProgrammeItems ?? [],
])
</div>
</x-meet.room.panel>
@endif
<x-meet.room.panel show="participantsOpen" :title="null">
<x-slot:subtitle>
<template x-if="usesStageLayout">
@@ -641,6 +689,18 @@
@click="sendReaction('thumbs'); featuresOpen = false" />
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
@click="sendReaction('heart'); featuresOpen = false" />
@if ($hasProgramme)
<button @click="toggleProgramme(); featuresOpen = false" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M4.5 8.25h15M4.5 19.5h15a1.5 1.5 0 0 0 1.5-1.5V8.25a1.5 1.5 0 0 0-1.5-1.5h-15a1.5 1.5 0 0 0-1.5 1.5v9.75a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
</span>
<span class="min-w-0">
<span class="block font-medium">Programme</span>
<span class="block text-xs text-slate-400">View the conference lineup</span>
</span>
</button>
@endif
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
@click="toggleChat(); featuresOpen = false" />
<div class="my-1 border-t border-slate-800"></div>
@@ -690,6 +750,15 @@
<x-meet.room.menu-item icon="close-breakout" title="Close breakouts" subtitle="Bring everyone back to the main room"
@click="closeBreakouts()" />
<div x-show="breakoutsActive" x-cloak class="mt-3 space-y-2 border-t border-slate-800 pt-3">
<p class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Broadcast to breakouts</p>
<form @submit.prevent="broadcastToBreakouts()" class="flex gap-2">
<input type="text" x-model="breakoutBroadcastInput" placeholder="Message all breakout rooms…"
class="flex-1 rounded-lg border-0 bg-slate-950 px-3 py-2 text-sm text-white placeholder:text-slate-500 ring-1 ring-slate-700/60">
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">Send</button>
</form>
</div>
@endif
</div>