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
@@ -7,6 +7,7 @@ use App\Models\Participant;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Services\Meet\BreakoutService;
use App\Services\Meet\ChatService;
use App\Services\Meet\Media\MediaProviderInterface;
use App\Services\Meet\ParticipantPresenter;
@@ -69,6 +70,8 @@ class MeetingRoomController extends Controller
$authUser = $request->user();
$isRoomHost = $authUser && $authUser->ownerRef() === $room->host_user_ref;
$canManageConference = $participant->isHost() || $isRoomHost;
$breakoutService = app(BreakoutService::class);
$inBreakout = $breakoutService->isInActiveBreakout($participant);
return view('meet.room.show', [
'session' => $session,
@@ -87,13 +90,14 @@ class MeetingRoomController extends Controller
'presenterRefs' => (array) $session->presenter_refs,
'isAudioOnly' => $isAudioOnly,
'isSpace' => $room->isSpace(),
'canPublish' => $room->isSpace()
? $spaces->canPublish($room, $participant)
: $webinars->canPublish($room, $participant),
'canPublish' => $this->sessions->canParticipantPublish($participant),
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
'usesStageLayout' => $stageLayout->usesStageLayout($room),
'usesStageLayout' => $stageLayout->usesStageLayout($room) && ! $inBreakout,
'inBreakout' => $inBreakout,
'stageConfig' => $stageLayout->config($room),
'canManageConference' => $canManageConference,
'programmeSnapshot' => $room->setting('programme_snapshot'),
'linkedProgrammeItems' => (array) $room->setting('linked_programme_items', []),
]);
}
@@ -229,7 +233,11 @@ class MeetingRoomController extends Controller
]);
}
$this->currentParticipant($request, $session);
$participant = $this->currentParticipant($request, $session);
app(BreakoutService::class)->closeExpired($session);
$participant->refresh();
$session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]);
@@ -295,9 +303,24 @@ class MeetingRoomController extends Controller
'original_name' => $f->original_name,
'file_size' => $f->file_size,
]),
'media' => $this->sessions->mediaSessionState($participant),
'breakouts_active' => app(BreakoutService::class)->hasActiveBreakouts($session),
], app(StageLayoutService::class)->config($session->room)));
}
public function mediaToken(Request $request, Session $session): JsonResponse
{
abort_unless($session->isLive(), 404);
$participant = $this->currentParticipant($request, $session);
return response()->json(array_merge(
['token' => $this->sessions->generateMediaToken($participant)],
$this->sessions->mediaSessionState($participant->fresh()),
['breakouts_active' => app(BreakoutService::class)->hasActiveBreakouts($session)],
));
}
public function startRecording(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
+32 -1
View File
@@ -40,7 +40,10 @@ class BreakoutService
]));
}
$participants = $session->participants()->where('status', 'joined')->get();
$participants = $session->participants()
->where('status', 'joined')
->whereNotIn('role', ['host', 'co_host'])
->get();
if ($assignments) {
foreach ($assignments as $assignment) {
@@ -95,6 +98,34 @@ class BreakoutService
BreakoutAssignment::whereIn('breakout_room_id', $session->breakoutRooms()->pluck('id'))->delete();
}
public function hasActiveBreakouts(Session $session): bool
{
return $session->breakoutRooms()->where('status', 'open')->exists();
}
public function closeExpired(Session $session): bool
{
if (! $session->breakoutRooms()->where('status', 'open')->where('closes_at', '<=', now())->exists()) {
return false;
}
$this->closeAll($session);
return true;
}
public function isInActiveBreakout(Participant $participant): bool
{
if (! $participant->breakout_room_id) {
return false;
}
return BreakoutRoom::query()
->where('id', $participant->breakout_room_id)
->where('status', 'open')
->exists();
}
public function mediaRoomForParticipant(Participant $participant): string
{
if ($participant->breakout_room_id) {
+52 -12
View File
@@ -263,30 +263,70 @@ class SessionService
public function generateMediaToken(Participant $participant): string
{
$session = $participant->session;
$room = $session->room;
$isHost = $participant->isHost();
$breakoutService = app(BreakoutService::class);
$webinarService = app(WebinarService::class);
$spaceService = app(\App\Services\Meet\SpaceService::class);
$mediaRoom = $breakoutService->mediaRoomForParticipant($participant);
$canPublish = ($room->isSpace()
? $spaceService->canPublish($room, $participant)
: $webinarService->canPublish($room, $participant)) && $participant->canPublishMedia();
$participant->loadMissing('breakoutRoom');
$state = $this->mediaSessionState($participant);
$mediaRoom = app(BreakoutService::class)->mediaRoomForParticipant($participant);
return $this->media->generateToken(
$mediaRoom,
$participant->uuid,
$participant->display_name,
[
'can_publish' => $canPublish,
'can_publish' => $state['can_publish'],
'can_subscribe' => true,
'can_publish_data' => true,
'room_admin' => $isHost,
'room_admin' => $participant->isHost(),
],
);
}
/** @return array{can_publish: bool, uses_stage_layout: bool, in_breakout: bool, breakout_room_id: ?int, breakout: ?array{uuid: string, name: string, closes_at: ?string}} */
public function mediaSessionState(Participant $participant): array
{
$participant->loadMissing(['breakoutRoom', 'session.room']);
$room = $participant->session->room;
$breakoutService = app(BreakoutService::class);
$inBreakout = $breakoutService->isInActiveBreakout($participant);
$usesStageLayout = app(StageLayoutService::class)->usesStageLayout($room) && ! $inBreakout;
$breakout = null;
if ($inBreakout && $participant->breakoutRoom) {
$breakout = [
'uuid' => $participant->breakoutRoom->uuid,
'name' => $participant->breakoutRoom->name,
'closes_at' => $participant->breakoutRoom->closes_at?->toIso8601String(),
];
}
return [
'can_publish' => $this->canParticipantPublish($participant, $inBreakout),
'uses_stage_layout' => $usesStageLayout,
'in_breakout' => $inBreakout,
'breakout_room_id' => $inBreakout ? $participant->breakout_room_id : null,
'breakout' => $breakout,
];
}
public function canParticipantPublish(Participant $participant, ?bool $inBreakout = null): bool
{
$breakoutService = app(BreakoutService::class);
$inBreakout ??= $breakoutService->isInActiveBreakout($participant);
if ($inBreakout) {
return true;
}
$room = $participant->session->room;
if ($room->isSpace()) {
return app(\App\Services\Meet\SpaceService::class)->canPublish($room, $participant)
&& $participant->canPublishMedia();
}
return app(WebinarService::class)->canPublish($room, $participant)
&& $participant->canPublishMedia();
}
public function getOrStartSession(Room $room, User $host): Session
{
if ($room->activeSession()) {
+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>
+1
View File
@@ -68,6 +68,7 @@ Route::post('/room/{session}/raise-hand', [MeetingRoomController::class, 'raiseH
Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat');
Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction');
Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll');
Route::get('/room/{session}/media-token', [MeetingRoomController::class, 'mediaToken'])->name('meet.room.media-token');
Route::post('/room/{session}/recording/start', [MeetingRoomController::class, 'startRecording'])->name('meet.room.recording.start');
Route::post('/room/{session}/recording/stop', [MeetingRoomController::class, 'stopRecording'])->name('meet.room.recording.stop');
Route::post('/room/{session}/recordings/{recording}/upload', [MeetingRoomController::class, 'uploadRecording'])->name('meet.room.recording.upload');
+134
View File
@@ -1032,6 +1032,140 @@ class MeetWebTest extends TestCase
]);
}
public function test_breakouts_exclude_host_and_enable_meeting_mode_for_attendees(): void
{
config([
'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx',
'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12',
]);
$room = Room::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'host_user_ref' => $this->user->public_id,
'title' => 'Summit',
'type' => 'town_hall',
'status' => 'live',
'scheduled_at' => now()->addHour(),
'timezone' => 'UTC',
'settings' => config('meet.default_settings'),
]);
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'meet-'.$room->uuid,
'status' => 'live',
'session_mode' => 'live',
'started_at' => now(),
]);
$host = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'user_ref' => $this->user->public_id,
'display_name' => 'Host',
'role' => 'host',
'status' => 'joined',
'joined_at' => now(),
]);
$attendee = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'display_name' => 'Attendee',
'role' => 'attendee',
'status' => 'joined',
'joined_at' => now(),
]);
$sessions = app(\App\Services\Meet\SessionService::class);
$this->assertFalse($sessions->canParticipantPublish($attendee));
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
->postJson('/room/'.$session->uuid.'/breakouts', ['count' => 2])
->assertOk();
$host->refresh();
$attendee->refresh();
$this->assertNull($host->breakout_room_id);
$this->assertNotNull($attendee->breakout_room_id);
$this->assertTrue($sessions->canParticipantPublish($attendee));
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid])
->getJson('/room/'.$session->uuid.'/media-token')
->assertOk()
->assertJsonPath('in_breakout', true)
->assertJsonPath('can_publish', true)
->assertJsonPath('uses_stage_layout', false);
}
public function test_live_room_shows_programme_when_linked_from_events(): void
{
config([
'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx',
'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12',
]);
$programme = [
'title' => 'Summit programme',
'days' => [
[
'label' => 'Day 1',
'items' => [
['time' => '09:00', 'title' => 'Opening keynote', 'host' => 'Alex'],
],
],
],
];
$room = Room::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'host_user_ref' => $this->user->public_id,
'title' => 'Summit',
'type' => 'town_hall',
'status' => 'live',
'scheduled_at' => now()->addHour(),
'timezone' => 'UTC',
'settings' => array_merge(config('meet.default_settings'), [
'programme_snapshot' => $programme,
'linked_programme_items' => [
['time' => '09:00', 'title' => 'Opening keynote', 'host' => 'Alex'],
],
]),
]);
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'meet-'.$room->uuid,
'status' => 'live',
'session_mode' => 'live',
'started_at' => now(),
]);
$participant = \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(),
]);
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
->get('/room/'.$session->uuid)
->assertOk()
->assertSee('Opening keynote', false)
->assertSee('Programme', false);
}
public function test_afia_chat_returns_reply_when_ai_configured(): void
{
config([