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()) {