From fa9b4138af908b3e2a7e1577bf6ce1ea502a177a Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 2 Jul 2026 00:06:45 +0000 Subject: [PATCH] Implement conference breakouts with meeting mode and programme lineup UI. 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 --- .../Meet/MeetingRoomController.php | 33 +++- app/Services/Meet/BreakoutService.php | 33 +++- app/Services/Meet/SessionService.php | 64 +++++-- resources/js/meet-room.js | 163 +++++++++++++++++- .../room/partials/programme-panel.blade.php | 66 +++++++ resources/views/meet/room/show.blade.php | 73 +++++++- routes/web.php | 1 + tests/Feature/MeetWebTest.php | 134 ++++++++++++++ 8 files changed, 545 insertions(+), 22 deletions(-) create mode 100644 resources/views/meet/room/partials/programme-panel.blade.php diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 53b7cdf..960c216 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -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); diff --git a/app/Services/Meet/BreakoutService.php b/app/Services/Meet/BreakoutService.php index 89a22e9..7bbbbf9 100644 --- a/app/Services/Meet/BreakoutService.php +++ b/app/Services/Meet/BreakoutService.php @@ -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) { diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index 99d9193..d723deb 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -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()) { diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 7dee5cd..620e5c2 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -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'; } diff --git a/resources/views/meet/room/partials/programme-panel.blade.php b/resources/views/meet/room/partials/programme-panel.blade.php new file mode 100644 index 0000000..25796bb --- /dev/null +++ b/resources/views/meet/room/partials/programme-panel.blade.php @@ -0,0 +1,66 @@ +@php + $programme = $programmeSnapshot ?? null; + $linkedItems = $linkedProgrammeItems ?? []; +@endphp + +@if(is_array($programme) && ! empty($programme['days'])) +
+ @if(! empty($programme['title'])) +

{{ $programme['title'] }}

+ @endif + + @if($linkedItems !== []) +
+

This session

+
    + @foreach($linkedItems as $item) +
  • + @if(! empty($item['time'])) + {{ $item['time'] }} + @endif + {{ $item['title'] ?? 'Item' }} + @if(! empty($item['host'])) + · {{ $item['host'] }} + @endif +
  • + @endforeach +
+
+ @endif + + @foreach((array) ($programme['days'] ?? []) as $day) +
+ @if(! empty($day['label']) || ! empty($day['date'])) +

+ {{ trim(($day['label'] ?? '').' '.($day['date'] ?? '')) }} +

+ @endif +
    + @foreach((array) ($day['items'] ?? []) as $item) +
  • +
    + @if(! empty($item['time'])) + {{ $item['time'] }} + @endif +
    +

    {{ $item['title'] ?? '' }}

    + @if(! empty($item['description'])) +

    {{ $item['description'] }}

    + @endif + @if(! empty($item['location']) || ! empty($item['host'])) +

    + @if(! empty($item['location'])){{ $item['location'] }}@endif + @if(! empty($item['host'])) · {{ $item['host'] }}@endif +

    + @endif +
    +
    +
  • + @endforeach +
+
+ @endforeach +
+@else +

No programme linked to this conference yet.

+@endif diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index d885fa9..3e4c36a 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -27,6 +27,7 @@ $isConferenceSession = $isConference ?? false; $sessionNoun = $isConferenceSession ? 'conference' : 'meeting'; $sessionNounTitle = $isConferenceSession ? 'Conference' : 'Meeting'; + $hasProgramme = is_array($programmeSnapshot ?? null) && ! empty(($programmeSnapshot ?? [])['days']); @endphp
-
+
+

Breakout session

+

+
+

+
+ +
+
+

Breakouts are active

+

Attendees are in smaller discussion groups. You remain on the main stage.

+
+ +
+ +
@@ -325,6 +350,14 @@ + @if ($hasProgramme) + + @endif