Implement conference breakouts with meeting mode and programme lineup UI.
Deploy Ladill Meet / deploy (push) Successful in 1m3s
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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user