Add conference stage layouts for plenary and panel discussions.
Deploy Ladill Meet / deploy (push) Successful in 37s
Deploy Ladill Meet / deploy (push) Successful in 37s
Speakers get a full-screen spotlight with overlapping audience avatars; hosts can configure panels in the green room and switch layouts live. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -98,6 +98,7 @@ class ConferenceController extends Controller
|
|||||||
'live_captions' => ['sometimes', 'boolean'],
|
'live_captions' => ['sometimes', 'boolean'],
|
||||||
'green_room' => ['sometimes', 'boolean'],
|
'green_room' => ['sometimes', 'boolean'],
|
||||||
'practice_mode' => ['sometimes', 'boolean'],
|
'practice_mode' => ['sometimes', 'boolean'],
|
||||||
|
'panel_discussions' => ['sometimes', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$presenterRefs = collect($validated['presenter_refs'] ?? [])
|
$presenterRefs = collect($validated['presenter_refs'] ?? [])
|
||||||
@@ -114,6 +115,9 @@ class ConferenceController extends Controller
|
|||||||
'live_captions' => $request->boolean('live_captions'),
|
'live_captions' => $request->boolean('live_captions'),
|
||||||
'green_room' => $request->boolean('green_room', true),
|
'green_room' => $request->boolean('green_room', true),
|
||||||
'practice_mode' => $request->boolean('practice_mode'),
|
'practice_mode' => $request->boolean('practice_mode'),
|
||||||
|
'panel_discussions' => $request->boolean('panel_discussions'),
|
||||||
|
'stage_layout' => 'plenary',
|
||||||
|
'panels' => [],
|
||||||
'stage_mode' => true,
|
'stage_mode' => true,
|
||||||
'presenter_refs' => $presenterRefs,
|
'presenter_refs' => $presenterRefs,
|
||||||
];
|
];
|
||||||
@@ -201,6 +205,7 @@ class ConferenceController extends Controller
|
|||||||
'presenter_refs' => ['nullable', 'array'],
|
'presenter_refs' => ['nullable', 'array'],
|
||||||
'presenter_refs.*' => ['string', 'max:64'],
|
'presenter_refs.*' => ['string', 'max:64'],
|
||||||
'invite_emails' => ['nullable', 'string', 'max:2000'],
|
'invite_emails' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'panel_discussions' => ['sometimes', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$allowedRefs = Member::owned($this->ownerRef($request))
|
$allowedRefs = Member::owned($this->ownerRef($request))
|
||||||
@@ -217,6 +222,13 @@ class ConferenceController extends Controller
|
|||||||
|
|
||||||
$this->conferences->syncPresenterRefs($room, $presenterRefs);
|
$this->conferences->syncPresenterRefs($room, $presenterRefs);
|
||||||
|
|
||||||
|
if ($request->has('panel_discussions')) {
|
||||||
|
$settings = array_merge($room->settings ?? [], [
|
||||||
|
'panel_discussions' => $request->boolean('panel_discussions'),
|
||||||
|
]);
|
||||||
|
$room->update(['settings' => $settings]);
|
||||||
|
}
|
||||||
|
|
||||||
$inviteCount = 0;
|
$inviteCount = 0;
|
||||||
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
|
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
|
||||||
$invites = collect(preg_split('/[\s,;]+/', $emails))
|
$invites = collect(preg_split('/[\s,;]+/', $emails))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Services\Meet\FileSharingService;
|
|||||||
use App\Services\Meet\LiveStreamService;
|
use App\Services\Meet\LiveStreamService;
|
||||||
use App\Services\Meet\PollService;
|
use App\Services\Meet\PollService;
|
||||||
use App\Services\Meet\QaService;
|
use App\Services\Meet\QaService;
|
||||||
|
use App\Services\Meet\StageLayoutService;
|
||||||
use App\Services\Meet\TownHallService;
|
use App\Services\Meet\TownHallService;
|
||||||
use App\Services\Meet\WhiteboardService;
|
use App\Services\Meet\WhiteboardService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -32,6 +33,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
protected WhiteboardService $whiteboards,
|
protected WhiteboardService $whiteboards,
|
||||||
protected LiveStreamService $liveStreams,
|
protected LiveStreamService $liveStreams,
|
||||||
protected TownHallService $townHall,
|
protected TownHallService $townHall,
|
||||||
|
protected StageLayoutService $stageLayout,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// --- Q&A ---
|
// --- Q&A ---
|
||||||
@@ -320,6 +322,29 @@ class MeetingFeaturesController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateStage(Request $request, Session $session): JsonResponse
|
||||||
|
{
|
||||||
|
$this->hostOnly($request, $session);
|
||||||
|
$room = $session->room;
|
||||||
|
abort_unless($this->stageLayout->usesStageLayout($room), 422);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'panel_discussions' => ['sometimes', 'boolean'],
|
||||||
|
'stage_layout' => ['sometimes', 'string', 'in:plenary,panel'],
|
||||||
|
'active_panel_id' => ['nullable', 'string', 'max:64'],
|
||||||
|
'spotlight_speaker_ref' => ['nullable', 'string', 'max:64'],
|
||||||
|
'panels' => ['sometimes', 'array', 'max:12'],
|
||||||
|
'panels.*.id' => ['nullable', 'string', 'max:64'],
|
||||||
|
'panels.*.name' => ['required_with:panels', 'string', 'max:100'],
|
||||||
|
'panels.*.speaker_refs' => ['nullable', 'array'],
|
||||||
|
'panels.*.speaker_refs.*' => ['string', 'max:64'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$room = $this->stageLayout->update($room, $validated);
|
||||||
|
|
||||||
|
return response()->json($this->stageLayout->config($room));
|
||||||
|
}
|
||||||
|
|
||||||
protected function participant(Request $request, Session $session): Participant
|
protected function participant(Request $request, Session $session): Participant
|
||||||
{
|
{
|
||||||
$uuid = $request->session()->get("meet.participant.{$session->uuid}");
|
$uuid = $request->session()->get("meet.participant.{$session->uuid}");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Services\Meet\RecordingService;
|
|||||||
use App\Services\Meet\SecurityPolicyService;
|
use App\Services\Meet\SecurityPolicyService;
|
||||||
use App\Services\Meet\SessionService;
|
use App\Services\Meet\SessionService;
|
||||||
use App\Services\Meet\SpaceService;
|
use App\Services\Meet\SpaceService;
|
||||||
|
use App\Services\Meet\StageLayoutService;
|
||||||
use App\Services\Meet\TranscriptService;
|
use App\Services\Meet\TranscriptService;
|
||||||
use App\Services\Meet\WebinarService;
|
use App\Services\Meet\WebinarService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -64,6 +65,7 @@ class MeetingRoomController extends Controller
|
|||||||
$webinars = app(WebinarService::class);
|
$webinars = app(WebinarService::class);
|
||||||
$spaces = app(SpaceService::class);
|
$spaces = app(SpaceService::class);
|
||||||
$isAudioOnly = $room->isAudioOnly();
|
$isAudioOnly = $room->isAudioOnly();
|
||||||
|
$stageLayout = app(StageLayoutService::class);
|
||||||
|
|
||||||
return view('meet.room.show', [
|
return view('meet.room.show', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
@@ -86,6 +88,8 @@ class MeetingRoomController extends Controller
|
|||||||
? $spaces->canPublish($room, $participant)
|
? $spaces->canPublish($room, $participant)
|
||||||
: $webinars->canPublish($room, $participant),
|
: $webinars->canPublish($room, $participant),
|
||||||
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
|
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
|
||||||
|
'usesStageLayout' => $stageLayout->usesStageLayout($room),
|
||||||
|
'stageConfig' => $stageLayout->config($room),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +245,7 @@ class MeetingRoomController extends Controller
|
|||||||
->limit(20)
|
->limit(20)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json(array_merge([
|
||||||
'participants' => $session->participants->map(
|
'participants' => $session->participants->map(
|
||||||
fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref)
|
fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref)
|
||||||
),
|
),
|
||||||
@@ -287,7 +291,7 @@ class MeetingRoomController extends Controller
|
|||||||
'original_name' => $f->original_name,
|
'original_name' => $f->original_name,
|
||||||
'file_size' => $f->file_size,
|
'file_size' => $f->file_size,
|
||||||
]),
|
]),
|
||||||
]);
|
], app(StageLayoutService::class)->config($session->room)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function startRecording(Request $request, Session $session): JsonResponse
|
public function startRecording(Request $request, Session $session): JsonResponse
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Meet;
|
||||||
|
|
||||||
|
use App\Models\Room;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class StageLayoutService
|
||||||
|
{
|
||||||
|
public function usesStageLayout(Room $room): bool
|
||||||
|
{
|
||||||
|
return $room->isConference() || $room->isWebinar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function config(Room $room): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uses_stage_layout' => $this->usesStageLayout($room),
|
||||||
|
'panel_discussions' => (bool) $room->setting('panel_discussions', false),
|
||||||
|
'stage_layout' => (string) $room->setting('stage_layout', 'plenary'),
|
||||||
|
'panels' => array_values((array) $room->setting('panels', [])),
|
||||||
|
'active_panel_id' => $room->setting('active_panel_id'),
|
||||||
|
'spotlight_speaker_ref' => $room->setting('spotlight_speaker_ref'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function update(Room $room, array $data): Room
|
||||||
|
{
|
||||||
|
abort_unless($this->usesStageLayout($room), 422);
|
||||||
|
|
||||||
|
$settings = $room->settings ?? [];
|
||||||
|
|
||||||
|
if (array_key_exists('panel_discussions', $data)) {
|
||||||
|
$settings['panel_discussions'] = (bool) $data['panel_discussions'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('stage_layout', $data)) {
|
||||||
|
$layout = (string) $data['stage_layout'];
|
||||||
|
abort_unless(in_array($layout, ['plenary', 'panel'], true), 422);
|
||||||
|
$settings['stage_layout'] = $layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('active_panel_id', $data)) {
|
||||||
|
$settings['active_panel_id'] = $data['active_panel_id'] ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('spotlight_speaker_ref', $data)) {
|
||||||
|
$settings['spotlight_speaker_ref'] = $data['spotlight_speaker_ref'] ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('panels', $data)) {
|
||||||
|
$settings['panels'] = $this->normalizePanels((array) $data['panels']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$room->update(['settings' => $settings]);
|
||||||
|
|
||||||
|
return $room->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $panels
|
||||||
|
* @return list<array{id: string, name: string, speaker_refs: list<string>}>
|
||||||
|
*/
|
||||||
|
public function normalizePanels(array $panels): array
|
||||||
|
{
|
||||||
|
return collect($panels)
|
||||||
|
->map(function ($panel) {
|
||||||
|
$id = trim((string) ($panel['id'] ?? ''));
|
||||||
|
$name = trim((string) ($panel['name'] ?? ''));
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
$id = 'panel-'.Str::lower(Str::random(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$speakerRefs = collect($panel['speaker_refs'] ?? [])
|
||||||
|
->filter(fn ($ref) => is_string($ref) && $ref !== '')
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => Str::limit($name, 100, ''),
|
||||||
|
'speaker_refs' => $speakerRefs,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
|
||||||
|
<path d="M19.848,14.296l-.977-.975c.17-.851,.17-1.791,0-2.642l.978-.972c1.524-1.461,1.525-4.092,0-5.553-1.46-1.523-4.092-1.523-5.551,0l-.977,.974c-.852-.171-1.791-.171-2.643,0l-.977-.973c-1.461-1.524-4.09-1.524-5.553-.001-1.524,1.463-1.522,4.092,.004,5.555l.976,.97c-.17,.851-.17,1.79,0,2.641l-.978,.977c-1.524,1.461-1.525,4.091-.002,5.553,1.463,1.524,4.091,1.523,5.554-.001l.975-.978c.851,.17,1.79,.17,2.642,0l.977,.977c1.461,1.525,4.091,1.526,5.554,.002,.742-.742,1.149-1.728,1.149-2.777,0-1.049-.409-2.035-1.152-2.776ZM15.713,5.569c.716-.748,2.006-.748,2.724,0,.746,.717,.746,2.007,0,2.723l-.318,.317c-.635-1.142-1.581-2.087-2.722-2.724l.316-.315Zm-10.147,2.723c-.748-.716-.749-2.006-.002-2.723,.751-.75,1.974-.75,2.727,.002l.314,.312c-1.141,.636-2.086,1.581-2.722,2.723l-.317-.315Zm2.723,10.143c-.717,.748-2.007,.748-2.725,0-.747-.718-.746-2.007,.002-2.724l.318-.318c.636,1.141,1.581,2.087,2.722,2.723l-.317,.318Zm-1.288-6.435c0-2.757,2.243-5,5-5s5,2.243,5,5-2.243,5-5,5-5-2.243-5-5Zm11.436,6.436c-.717,.747-2.006,.747-2.724-.001l-.318-.318c1.141-.636,2.086-1.581,2.722-2.722l.318,.317c.749,.717,.749,2.007,0,2.724Zm1.564-16.436c0-1.105,.895-2,2-2s2,.895,2,2-.895,2-2,2-2-.895-2-2ZM0,2C0,.895,.895,0,2,0s2,.895,2,2-.895,2-2,2S0,3.105,0,2ZM24,22c0,1.105-.895,2-2,2s-2-.895-2-2,.895-2,2-2,2,.895,2,2Zm-20,0c0,1.105-.895,2-2,2s-2-.895-2-2,.895-2,2-2,2,.895,2,2Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -45,6 +45,102 @@ html.meet-room-page {
|
|||||||
box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9);
|
box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meet-room-page #meet-stage:not(.meet-stage) #meet-stage-spotlight,
|
||||||
|
.meet-room-page #meet-stage:not(.meet-stage) #meet-audience-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-stage {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-stage--plenary #video-grid {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-stage--plenary .meet-stage-spotlight {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: min(70vh, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-stage--plenary .meet-stage-spotlight-tile {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-stage--panel #meet-stage-spotlight,
|
||||||
|
.meet-room-page .meet-stage--panel #meet-audience-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-stage-panel-grid {
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||||
|
grid-auto-rows: minmax(0, 1fr);
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-stage-panel-tile {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-audience-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-audience-avatar {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: 2.25rem;
|
||||||
|
width: 2.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -0.45rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid rgb(15 23 42);
|
||||||
|
background: rgb(30 41 59);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-audience-avatar:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-audience-avatar__image {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-audience-avatar__initials {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meet-room-page .meet-audience-avatar--more {
|
||||||
|
z-index: 0;
|
||||||
|
width: auto;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(203 213 225);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.meet-room-page .meet-audio-grid {
|
.meet-room-page .meet-audio-grid {
|
||||||
place-content: center;
|
place-content: center;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
+372
-17
@@ -44,6 +44,14 @@ function meetRoom() {
|
|||||||
presenterRefs: [],
|
presenterRefs: [],
|
||||||
audioOnly: false,
|
audioOnly: false,
|
||||||
isSpace: false,
|
isSpace: false,
|
||||||
|
usesStageLayout: false,
|
||||||
|
panelDiscussions: false,
|
||||||
|
stageLayout: 'plenary',
|
||||||
|
panels: [],
|
||||||
|
activePanelId: '',
|
||||||
|
spotlightSpeakerRef: '',
|
||||||
|
panelSetupOpen: false,
|
||||||
|
savingStageLayout: false,
|
||||||
qaItems: [],
|
qaItems: [],
|
||||||
activePolls: [],
|
activePolls: [],
|
||||||
breakoutBroadcast: '',
|
breakoutBroadcast: '',
|
||||||
@@ -102,6 +110,7 @@ function meetRoom() {
|
|||||||
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
|
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
|
||||||
goLiveUrl: el.dataset.goLiveUrl,
|
goLiveUrl: el.dataset.goLiveUrl,
|
||||||
addPresenterUrl: el.dataset.addPresenterUrl,
|
addPresenterUrl: el.dataset.addPresenterUrl,
|
||||||
|
stageUpdateUrl: el.dataset.stageUpdateUrl,
|
||||||
csrf: el.dataset.csrf,
|
csrf: el.dataset.csrf,
|
||||||
sessionUuid: el.dataset.sessionUuid,
|
sessionUuid: el.dataset.sessionUuid,
|
||||||
};
|
};
|
||||||
@@ -131,6 +140,22 @@ function meetRoom() {
|
|||||||
this.presenterRefs = [];
|
this.presenterRefs = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.usesStageLayout = el.dataset.usesStageLayout === '1';
|
||||||
|
this.panelDiscussions = el.dataset.panelDiscussions === '1';
|
||||||
|
this.stageLayout = el.dataset.stageLayout || 'plenary';
|
||||||
|
this.activePanelId = el.dataset.activePanelId || '';
|
||||||
|
this.spotlightSpeakerRef = el.dataset.spotlightSpeakerRef || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.panels = JSON.parse(el.dataset.panels || '[]');
|
||||||
|
} catch {
|
||||||
|
this.panels = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.panelDiscussions && this.stageLayout === 'panel') {
|
||||||
|
this.stageLayout = 'plenary';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]');
|
this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -152,6 +177,7 @@ function meetRoom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.pollTimer = setInterval(() => this.poll(), 3000);
|
this.pollTimer = setInterval(() => this.poll(), 3000);
|
||||||
|
this.applyStageLayout();
|
||||||
},
|
},
|
||||||
|
|
||||||
connectionErrorMessage(error) {
|
connectionErrorMessage(error) {
|
||||||
@@ -514,6 +540,325 @@ function meetRoom() {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isSpeakerPerson(person) {
|
||||||
|
if (!person) return false;
|
||||||
|
|
||||||
|
if (['host', 'co_host', 'panelist'].includes(person.role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(person.user_ref && this.presenterRefs.includes(person.user_ref));
|
||||||
|
},
|
||||||
|
|
||||||
|
speakerParticipants() {
|
||||||
|
return this.inCallParticipants().filter((person) => this.isSpeakerPerson(person));
|
||||||
|
},
|
||||||
|
|
||||||
|
speakerParticipantsSorted() {
|
||||||
|
const order = { host: 0, co_host: 1, panelist: 2 };
|
||||||
|
|
||||||
|
return [...this.speakerParticipants()].sort((a, b) => {
|
||||||
|
const aSpeaking = this.isSpeaking(a.uuid);
|
||||||
|
const bSpeaking = this.isSpeaking(b.uuid);
|
||||||
|
|
||||||
|
if (aSpeaking !== bSpeaking) {
|
||||||
|
return aSpeaking ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDiff = (order[a.role] ?? 9) - (order[b.role] ?? 9);
|
||||||
|
if (roleDiff !== 0) {
|
||||||
|
return roleDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.display_name.localeCompare(b.display_name);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
speakerCount() {
|
||||||
|
return this.speakerParticipants().length;
|
||||||
|
},
|
||||||
|
|
||||||
|
audienceParticipants() {
|
||||||
|
return this.inCallParticipants().filter((person) => !this.isSpeakerPerson(person));
|
||||||
|
},
|
||||||
|
|
||||||
|
audienceParticipantsSorted() {
|
||||||
|
return [...this.audienceParticipants()].sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveSpotlightSpeakerRef() {
|
||||||
|
const speakers = this.speakerParticipants();
|
||||||
|
|
||||||
|
if (this.spotlightSpeakerRef && speakers.some((person) => person.user_ref === this.spotlightSpeakerRef)) {
|
||||||
|
return this.spotlightSpeakerRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
return speakers[0]?.user_ref || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getActivePanelSpeakerRefs() {
|
||||||
|
if (!this.panelDiscussions || this.stageLayout !== 'panel') {
|
||||||
|
return this.speakerParticipants()
|
||||||
|
.map((person) => person.user_ref)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.activePanelId) {
|
||||||
|
return this.speakerParticipants()
|
||||||
|
.map((person) => person.user_ref)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = this.panels.find((entry) => entry.id === this.activePanelId);
|
||||||
|
|
||||||
|
return panel?.speaker_refs || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeStageConfig(data) {
|
||||||
|
if (typeof data.stage_layout === 'string') {
|
||||||
|
this.stageLayout = data.stage_layout;
|
||||||
|
}
|
||||||
|
if (typeof data.panel_discussions === 'boolean') {
|
||||||
|
this.panelDiscussions = data.panel_discussions;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.panels)) {
|
||||||
|
this.panels = data.panels;
|
||||||
|
}
|
||||||
|
if (data.active_panel_id !== undefined) {
|
||||||
|
this.activePanelId = data.active_panel_id || '';
|
||||||
|
}
|
||||||
|
if (data.spotlight_speaker_ref !== undefined) {
|
||||||
|
this.spotlightSpeakerRef = data.spotlight_speaker_ref || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.panelDiscussions && this.stageLayout === 'panel') {
|
||||||
|
this.stageLayout = 'plenary';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
findParticipantTile(identity) {
|
||||||
|
return document.querySelector(`[data-participant-tile="${identity}"]`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTileContainerForIdentity(identity) {
|
||||||
|
if (!this.usesStageLayout) {
|
||||||
|
return document.getElementById('video-grid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = this.sessionParticipants.find((entry) => entry.uuid === identity);
|
||||||
|
const spotlight = document.getElementById('meet-stage-spotlight');
|
||||||
|
const grid = document.getElementById('video-grid');
|
||||||
|
|
||||||
|
if (this.stageLayout === 'plenary') {
|
||||||
|
if (!this.isSpeakerPerson(person)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotlightRef = this.resolveSpotlightSpeakerRef();
|
||||||
|
if (person?.user_ref && spotlightRef && person.user_ref === spotlightRef) {
|
||||||
|
return spotlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spotlightRef && this.speakerParticipantsSorted()[0]?.uuid === identity) {
|
||||||
|
return spotlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.stageLayout === 'panel') {
|
||||||
|
if (!this.isSpeakerPerson(person)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = this.getActivePanelSpeakerRefs();
|
||||||
|
if (!person?.user_ref || !refs.includes(person.user_ref)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAudienceStrip() {
|
||||||
|
const strip = document.getElementById('meet-audience-strip');
|
||||||
|
if (!strip) return;
|
||||||
|
|
||||||
|
strip.replaceChildren();
|
||||||
|
|
||||||
|
if (!this.usesStageLayout || this.stageLayout !== 'plenary') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audience = this.audienceParticipantsSorted();
|
||||||
|
if (audience.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVisible = 8;
|
||||||
|
const visible = audience.slice(0, maxVisible);
|
||||||
|
const overflow = audience.length - visible.length;
|
||||||
|
|
||||||
|
visible.forEach((person, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'meet-audience-avatar';
|
||||||
|
item.style.zIndex = String(visible.length - index);
|
||||||
|
item.title = person.display_name;
|
||||||
|
|
||||||
|
if (person.avatar_url) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = person.avatar_url;
|
||||||
|
img.alt = '';
|
||||||
|
img.className = 'meet-audience-avatar__image';
|
||||||
|
item.appendChild(img);
|
||||||
|
} else {
|
||||||
|
const initials = document.createElement('span');
|
||||||
|
initials.className = 'meet-audience-avatar__initials';
|
||||||
|
initials.style.background = this.avatarStyle(person.display_name).background;
|
||||||
|
initials.textContent = this.participantInitials(person.display_name);
|
||||||
|
item.appendChild(initials);
|
||||||
|
}
|
||||||
|
|
||||||
|
strip.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overflow > 0) {
|
||||||
|
const more = document.createElement('div');
|
||||||
|
more.className = 'meet-audience-avatar meet-audience-avatar--more';
|
||||||
|
more.style.zIndex = '0';
|
||||||
|
more.textContent = `+${overflow} others`;
|
||||||
|
strip.appendChild(more);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyStageLayout() {
|
||||||
|
if (!this.usesStageLayout) {
|
||||||
|
document.getElementById('meet-stage-spotlight')?.replaceChildren();
|
||||||
|
document.getElementById('meet-audience-strip')?.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotlight = document.getElementById('meet-stage-spotlight');
|
||||||
|
const grid = document.getElementById('video-grid');
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
const all = [room.localParticipant, ...room.remoteParticipants.values()];
|
||||||
|
all.forEach((participant) => {
|
||||||
|
const tile = this.findParticipantTile(participant.identity);
|
||||||
|
if (!tile) return;
|
||||||
|
|
||||||
|
const target = this.getTileContainerForIdentity(participant.identity);
|
||||||
|
if (target) {
|
||||||
|
if (tile.parentElement !== target) {
|
||||||
|
target.appendChild(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
tile.classList.toggle('meet-stage-spotlight-tile', target === spotlight);
|
||||||
|
tile.classList.toggle('meet-stage-panel-tile', target === grid && this.stageLayout === 'panel');
|
||||||
|
} else {
|
||||||
|
tile.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid) {
|
||||||
|
grid.dataset.panelCount = String(this.getActivePanelSpeakerRefs().length || this.speakerCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderAudienceStrip();
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveStageLayout() {
|
||||||
|
if (!this.config.stageUpdateUrl || this.savingStageLayout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.savingStageLayout = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.config.stageUpdateUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
stage_layout: this.stageLayout,
|
||||||
|
panels: this.panels,
|
||||||
|
active_panel_id: this.activePanelId || null,
|
||||||
|
spotlight_speaker_ref: this.spotlightSpeakerRef || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
this.mergeStageConfig(data);
|
||||||
|
this.panelSetupOpen = false;
|
||||||
|
this.applyStageLayout();
|
||||||
|
} finally {
|
||||||
|
this.savingStageLayout = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setSpotlight(userRef) {
|
||||||
|
if (!userRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.spotlightSpeakerRef = userRef;
|
||||||
|
this.stageLayout = 'plenary';
|
||||||
|
this.applyStageLayout();
|
||||||
|
|
||||||
|
if (!this.config.stageUpdateUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(this.config.stageUpdateUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
stage_layout: 'plenary',
|
||||||
|
spotlight_speaker_ref: userRef,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addPanel() {
|
||||||
|
this.panels.push({
|
||||||
|
id: `panel-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
name: `Panel ${this.panels.length + 1}`,
|
||||||
|
speaker_refs: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removePanel(index) {
|
||||||
|
const removed = this.panels.splice(index, 1)[0];
|
||||||
|
if (removed && this.activePanelId === removed.id) {
|
||||||
|
this.activePanelId = this.panels[0]?.id || '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActivePanel(panelId) {
|
||||||
|
this.activePanelId = panelId;
|
||||||
|
this.stageLayout = 'panel';
|
||||||
|
this.applyStageLayout();
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePanelSpeaker(panel, userRef) {
|
||||||
|
if (!panel || !userRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = panel.speaker_refs || [];
|
||||||
|
if (refs.includes(userRef)) {
|
||||||
|
panel.speaker_refs = refs.filter((ref) => ref !== userRef);
|
||||||
|
} else {
|
||||||
|
panel.speaker_refs = [...refs, userRef];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
hostParticipant() {
|
hostParticipant() {
|
||||||
const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host');
|
const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host');
|
||||||
if (hosts.length) {
|
if (hosts.length) {
|
||||||
@@ -620,10 +965,7 @@ function meetRoom() {
|
|||||||
syncActiveSpeakers(speakers) {
|
syncActiveSpeakers(speakers) {
|
||||||
this.activeSpeakerIds = speakers.map((p) => p.identity);
|
this.activeSpeakerIds = speakers.map((p) => p.identity);
|
||||||
|
|
||||||
const grid = document.getElementById('video-grid');
|
document.querySelectorAll('[data-participant-tile]').forEach((tile) => {
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
grid.querySelectorAll('[data-participant-tile]').forEach((tile) => {
|
|
||||||
const identity = tile.dataset.participantTile;
|
const identity = tile.dataset.participantTile;
|
||||||
const speaking = this.activeSpeakerIds.includes(identity);
|
const speaking = this.activeSpeakerIds.includes(identity);
|
||||||
|
|
||||||
@@ -653,18 +995,20 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeParticipantTile(identity) {
|
removeParticipantTile(identity) {
|
||||||
const grid = document.getElementById('video-grid');
|
this.findParticipantTile(identity)?.remove();
|
||||||
if (!grid || !identity) return;
|
|
||||||
|
|
||||||
grid.querySelector(`[data-participant-tile="${identity}"]`)?.remove();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getOrCreateParticipantTile(participant) {
|
getOrCreateParticipantTile(participant) {
|
||||||
const grid = document.getElementById('video-grid');
|
const container = this.getTileContainerForIdentity(participant.identity);
|
||||||
if (!grid) return null;
|
|
||||||
|
|
||||||
const identity = participant.identity;
|
const identity = participant.identity;
|
||||||
let tile = grid.querySelector(`[data-participant-tile="${identity}"]`);
|
const existing = this.findParticipantTile(identity);
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
existing?.remove();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tile = existing;
|
||||||
|
|
||||||
if (!tile) {
|
if (!tile) {
|
||||||
tile = document.createElement('div');
|
tile = document.createElement('div');
|
||||||
@@ -695,9 +1039,15 @@ function meetRoom() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
grid.appendChild(tile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tile.parentElement !== container) {
|
||||||
|
container.appendChild(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
tile.classList.toggle('meet-stage-spotlight-tile', container.id === 'meet-stage-spotlight');
|
||||||
|
tile.classList.toggle('meet-stage-panel-tile', container.id === 'video-grid' && this.stageLayout === 'panel');
|
||||||
|
|
||||||
const displayName = participant.name || participant.identity;
|
const displayName = participant.name || participant.identity;
|
||||||
const hue = this.avatarHue(displayName);
|
const hue = this.avatarHue(displayName);
|
||||||
const circle = tile.querySelector('[data-tile-avatar-circle]');
|
const circle = tile.querySelector('[data-tile-avatar-circle]');
|
||||||
@@ -1128,6 +1478,7 @@ function meetRoom() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.applyStageLayout();
|
||||||
this.syncActiveSpeakers(room.activeSpeakers ?? []);
|
this.syncActiveSpeakers(room.activeSpeakers ?? []);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1138,16 +1489,15 @@ function meetRoom() {
|
|||||||
.map((p) => p.uuid),
|
.map((p) => p.uuid),
|
||||||
);
|
);
|
||||||
|
|
||||||
const grid = document.getElementById('video-grid');
|
document.querySelectorAll('[data-participant-tile]').forEach((tile) => {
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
grid.querySelectorAll('[data-participant-tile]').forEach((tile) => {
|
|
||||||
const identity = tile.dataset.participantTile;
|
const identity = tile.dataset.participantTile;
|
||||||
if (room?.localParticipant?.identity === identity) return;
|
if (room?.localParticipant?.identity === identity) return;
|
||||||
if (!joinedIds.has(identity)) {
|
if (!joinedIds.has(identity)) {
|
||||||
tile.remove();
|
tile.remove();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.applyStageLayout();
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleMute() {
|
async toggleMute() {
|
||||||
@@ -1260,6 +1610,7 @@ function meetRoom() {
|
|||||||
this.sessionParticipants = this.sessionParticipants.map((person) => (
|
this.sessionParticipants = this.sessionParticipants.map((person) => (
|
||||||
person.user_ref === userRef ? { ...person, role: 'panelist' } : person
|
person.user_ref === userRef ? { ...person, role: 'panelist' } : person
|
||||||
));
|
));
|
||||||
|
this.applyStageLayout();
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleChat() {
|
toggleChat() {
|
||||||
@@ -1371,6 +1722,10 @@ function meetRoom() {
|
|||||||
this.sessionParticipants = data.participants;
|
this.sessionParticipants = data.participants;
|
||||||
this.pruneTilesFromSession();
|
this.pruneTilesFromSession();
|
||||||
}
|
}
|
||||||
|
if (typeof data.uses_stage_layout === 'boolean' || typeof data.stage_layout === 'string') {
|
||||||
|
this.mergeStageConfig(data);
|
||||||
|
this.applyStageLayout();
|
||||||
|
}
|
||||||
if (typeof data.waiting_count === 'number') {
|
if (typeof data.waiting_count === 'number') {
|
||||||
const previous = this.waitingCount;
|
const previous = this.waitingCount;
|
||||||
this.waitingCount = data.waiting_count;
|
this.waitingCount = data.waiting_count;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@props([
|
@props([
|
||||||
'show',
|
'show',
|
||||||
'title',
|
'title' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
<aside x-show="{{ $show }}" x-cloak
|
<aside x-show="{{ $show }}" x-cloak
|
||||||
@@ -29,7 +29,11 @@
|
|||||||
|
|
||||||
<div class="meet-room-panel__header">
|
<div class="meet-room-panel__header">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
@isset($header)
|
||||||
|
{{ $header }}
|
||||||
|
@elseif ($title)
|
||||||
<h2 class="text-sm font-semibold text-white">{{ $title }}</h2>
|
<h2 class="text-sm font-semibold text-white">{{ $title }}</h2>
|
||||||
|
@endif
|
||||||
@isset($subtitle)
|
@isset($subtitle)
|
||||||
<div class="text-xs text-slate-400">{{ $subtitle }}</div>
|
<div class="text-xs text-slate-400">{{ $subtitle }}</div>
|
||||||
@endisset
|
@endisset
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<fieldset class="space-y-2">
|
<fieldset class="space-y-2">
|
||||||
<legend class="text-sm font-medium text-slate-700">Options</legend>
|
<legend class="text-sm font-medium text-slate-700">Options</legend>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="green_room" value="1" @checked(old('green_room', true)) class="rounded border-slate-300"> Green room for hosts & speakers before going live</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="green_room" value="1" @checked(old('green_room', true)) class="rounded border-slate-300"> Green room for hosts & speakers before going live</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="panel_discussions" value="1" @checked(old('panel_discussions')) class="rounded border-slate-300"> Enable panel discussions (multi-speaker stage layout)</label>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="practice_mode" value="1" @checked(old('practice_mode')) class="rounded border-slate-300"> Practice mode in green room</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="practice_mode" value="1" @checked(old('practice_mode')) class="rounded border-slate-300"> Practice mode in green room</label>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="waiting_room" value="1" @checked(old('waiting_room', true)) class="rounded border-slate-300"> Waiting room for attendees</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="waiting_room" value="1" @checked(old('waiting_room', true)) class="rounded border-slate-300"> Waiting room for attendees</label>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="mute_on_join" value="1" @checked(old('mute_on_join', true)) class="rounded border-slate-300"> Mute attendees on join</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="mute_on_join" value="1" @checked(old('mute_on_join', true)) class="rounded border-slate-300"> Mute attendees on join</label>
|
||||||
|
|||||||
@@ -119,6 +119,12 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="panel_discussions" value="1"
|
||||||
|
@checked(old('panel_discussions', $room->setting('panel_discussions'))) class="rounded border-slate-300">
|
||||||
|
Enable panel discussions (multi-speaker stage layout)
|
||||||
|
</label>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary btn-primary-sm">Save speakers</button>
|
<button type="submit" class="btn-primary btn-primary-sm">Save speakers</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
'leave',
|
'leave',
|
||||||
'lock',
|
'lock',
|
||||||
'personal',
|
'personal',
|
||||||
|
'panel',
|
||||||
'raise-hand',
|
'raise-hand',
|
||||||
'react',
|
'react',
|
||||||
'schedule',
|
'schedule',
|
||||||
|
|||||||
@@ -61,6 +61,13 @@
|
|||||||
data-session-mode="{{ $session->session_mode }}"
|
data-session-mode="{{ $session->session_mode }}"
|
||||||
data-go-live-url="{{ ($isConference ?? false) && ($isGreenRoom ?? false) && $participant->isHost() ? route('meet.room.townhall.live', $session) : '' }}"
|
data-go-live-url="{{ ($isConference ?? false) && ($isGreenRoom ?? false) && $participant->isHost() ? route('meet.room.townhall.live', $session) : '' }}"
|
||||||
data-add-presenter-url="{{ ($isConference ?? false) && $participant->isHost() ? route('meet.room.townhall.presenters', $session) : '' }}"
|
data-add-presenter-url="{{ ($isConference ?? false) && $participant->isHost() ? route('meet.room.townhall.presenters', $session) : '' }}"
|
||||||
|
data-stage-update-url="{{ ($usesStageLayout ?? false) && $participant->isHost() ? route('meet.room.stage.update', $session) : '' }}"
|
||||||
|
data-uses-stage-layout="{{ ($usesStageLayout ?? false) ? '1' : '0' }}"
|
||||||
|
data-panel-discussions="{{ ($stageConfig['panel_discussions'] ?? false) ? '1' : '0' }}"
|
||||||
|
data-stage-layout="{{ $stageConfig['stage_layout'] ?? 'plenary' }}"
|
||||||
|
data-panels='@json($stageConfig['panels'] ?? [])'
|
||||||
|
data-active-panel-id="{{ $stageConfig['active_panel_id'] ?? '' }}"
|
||||||
|
data-spotlight-speaker-ref="{{ $stageConfig['spotlight_speaker_ref'] ?? '' }}"
|
||||||
data-presenter-refs='@json($presenterRefs ?? [])'
|
data-presenter-refs='@json($presenterRefs ?? [])'
|
||||||
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
|
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
|
||||||
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
||||||
@@ -116,7 +123,7 @@
|
|||||||
<button @click="toggleParticipants()" type="button"
|
<button @click="toggleParticipants()" type="button"
|
||||||
class="relative inline-flex items-center gap-2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
class="relative inline-flex items-center gap-2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
||||||
:class="isHost && waitingCount > 0 ? 'ring-2 ring-amber-400/60' : ''"
|
:class="isHost && waitingCount > 0 ? 'ring-2 ring-amber-400/60' : ''"
|
||||||
title="View participants">
|
:title="usesStageLayout ? 'View speakers' : 'View participants'">
|
||||||
<span x-show="isHost && waitingCount > 0"
|
<span x-show="isHost && waitingCount > 0"
|
||||||
x-text="waitingCount > 9 ? '9+' : waitingCount"
|
x-text="waitingCount > 9 ? '9+' : waitingCount"
|
||||||
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-amber-950"></span>
|
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-amber-950"></span>
|
||||||
@@ -130,7 +137,7 @@
|
|||||||
:style="avatarStyle(hostParticipant()?.display_name)"
|
:style="avatarStyle(hostParticipant()?.display_name)"
|
||||||
x-text="participantInitials(hostParticipant()?.display_name || '')"></span>
|
x-text="participantInitials(hostParticipant()?.display_name || '')"></span>
|
||||||
</span>
|
</span>
|
||||||
<span x-text="participantCount() + ' in call'"></span>
|
<span x-text="usesStageLayout ? speakerCount() + (speakerCount() === 1 ? ' speaker' : ' speakers') : participantCount() + ' in call'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -141,11 +148,27 @@
|
|||||||
<p class="font-medium">Green room</p>
|
<p class="font-medium">Green room</p>
|
||||||
<p class="truncate text-xs text-violet-200/90">Only hosts and speakers are visible. Attendees join after you go live.</p>
|
<p class="truncate text-xs text-violet-200/90">Only hosts and speakers are visible. Attendees join after you go live.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button x-show="isHost && usesStageLayout && panelDiscussions" @click="panelSetupOpen = true" type="button"
|
||||||
|
class="shrink-0 rounded-lg border border-violet-400/40 bg-violet-500/20 px-3 py-1.5 text-xs font-semibold text-violet-100 transition-colors hover:bg-violet-500/30">
|
||||||
|
@include('meet.room.partials.meet-icon', ['icon' => 'panel', 'class' => 'mr-1 inline h-3.5 w-3.5'])
|
||||||
|
Panels
|
||||||
|
</button>
|
||||||
<button x-show="isHost" @click="goLive()" type="button" :disabled="goingLive"
|
<button x-show="isHost" @click="goLive()" type="button" :disabled="goingLive"
|
||||||
class="shrink-0 rounded-lg bg-violet-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-violet-400 disabled:opacity-60">
|
class="shrink-0 rounded-lg bg-violet-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-violet-400 disabled:opacity-60">
|
||||||
<span x-text="goingLive ? 'Starting…' : 'Go live'">Go live</span>
|
<span x-text="goingLive ? 'Starting…' : 'Go live'">Go live</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="isHost && usesStageLayout && panelDiscussions && !isGreenRoom" x-cloak
|
||||||
|
class="flex shrink-0 items-center justify-end gap-2 border-b border-violet-500/20 bg-violet-500/10 px-4 py-2">
|
||||||
|
<button @click="panelSetupOpen = true" type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-violet-400/40 bg-violet-500/20 px-3 py-1.5 text-xs font-semibold text-violet-100 hover:bg-violet-500/30">
|
||||||
|
@include('meet.room.partials.meet-icon', ['icon' => 'panel', 'class' => 'h-3.5 w-3.5'])
|
||||||
|
<span x-text="stageLayout === 'panel' ? 'Panel layout' : 'Plenary layout'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div x-show="isHost && waitingCount > 0" x-cloak
|
<div x-show="isHost && waitingCount > 0" x-cloak
|
||||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-amber-500/30 bg-amber-500/15 px-4 py-2.5">
|
class="flex shrink-0 items-center justify-between gap-3 border-b border-amber-500/30 bg-amber-500/15 px-4 py-2.5">
|
||||||
@@ -195,7 +218,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="relative min-h-0 min-w-0 flex-1 p-4">
|
<main class="relative min-h-0 min-w-0 flex-1 p-4">
|
||||||
<div id="video-grid" class="grid h-full gap-2 sm:grid-cols-2 lg:grid-cols-3" :class="audioOnly ? 'meet-audio-grid' : ''"></div>
|
<div id="meet-stage" class="flex h-full min-h-0 flex-col gap-3" :class="usesStageLayout ? 'meet-stage meet-stage--' + stageLayout : ''">
|
||||||
|
<div id="meet-stage-spotlight" class="meet-stage-spotlight relative min-h-0 flex-1 overflow-hidden rounded-xl bg-slate-900"></div>
|
||||||
|
<div id="video-grid" class="grid min-h-0 gap-2 sm:grid-cols-2 lg:grid-cols-3" :class="audioOnly ? 'meet-audio-grid' : (usesStageLayout ? 'meet-stage-panel-grid flex-1' : 'h-full')"></div>
|
||||||
|
<div id="meet-audience-strip" class="meet-audience-strip shrink-0"></div>
|
||||||
|
</div>
|
||||||
<div id="screen-share" class="absolute inset-4 hidden rounded-xl bg-black"></div>
|
<div id="screen-share" class="absolute inset-4 hidden rounded-xl bg-black"></div>
|
||||||
|
|
||||||
<div x-show="watermark" class="pointer-events-none absolute right-6 top-6 rounded bg-black/40 px-3 py-1 text-xs text-white/80" x-text="displayName"></div>
|
<div x-show="watermark" class="pointer-events-none absolute right-6 top-6 rounded bg-black/40 px-3 py-1 text-xs text-white/80" x-text="displayName"></div>
|
||||||
@@ -376,11 +403,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</x-meet.room.panel>
|
</x-meet.room.panel>
|
||||||
|
|
||||||
<x-meet.room.panel show="participantsOpen" title="Participants">
|
<x-meet.room.panel show="participantsOpen" :title="null">
|
||||||
<x-slot:subtitle>
|
<x-slot:subtitle>
|
||||||
|
<template x-if="usesStageLayout">
|
||||||
|
<div>
|
||||||
|
<p x-text="speakerCount() + (speakerCount() === 1 ? ' speaker' : ' speakers') + ' on stage'"></p>
|
||||||
|
<p x-show="activeSpeakerCount() >= 1" x-cloak class="text-emerald-400" x-text="activeSpeakersMessage()"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!usesStageLayout">
|
||||||
|
<div>
|
||||||
<p x-text="participantCount() + (participantCount() === 1 ? ' person' : ' people') + ' in this call'"></p>
|
<p x-text="participantCount() + (participantCount() === 1 ? ' person' : ' people') + ' in this call'"></p>
|
||||||
<p x-show="activeSpeakerCount() >= 2" x-cloak class="text-emerald-400" x-text="activeSpeakersMessage()"></p>
|
<p x-show="activeSpeakerCount() >= 2" x-cloak class="text-emerald-400" x-text="activeSpeakersMessage()"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</x-slot:subtitle>
|
</x-slot:subtitle>
|
||||||
|
<x-slot:header>
|
||||||
|
<h2 class="text-base font-semibold text-white" x-text="usesStageLayout ? 'Speakers' : 'Participants'"></h2>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
<template x-if="isHost && waitingParticipants().length">
|
<template x-if="isHost && waitingParticipants().length">
|
||||||
@@ -408,7 +448,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-for="person in inCallParticipantsSorted()" :key="person.uuid">
|
<template x-for="person in (usesStageLayout ? speakerParticipantsSorted() : inCallParticipantsSorted())" :key="person.uuid">
|
||||||
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5"
|
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5"
|
||||||
:class="isSpeaking(person.uuid) ? 'bg-emerald-500/10 ring-1 ring-emerald-500/40' : ''">
|
:class="isSpeaking(person.uuid) ? 'bg-emerald-500/10 ring-1 ring-emerald-500/40' : ''">
|
||||||
<span class="relative flex h-10 w-10 shrink-0">
|
<span class="relative flex h-10 w-10 shrink-0">
|
||||||
@@ -429,6 +469,11 @@
|
|||||||
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
|
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex shrink-0 items-center gap-1">
|
<span class="flex shrink-0 items-center gap-1">
|
||||||
|
<button x-show="usesStageLayout && isHost && person.user_ref && spotlightSpeakerRef !== person.user_ref"
|
||||||
|
type="button" @click="setSpotlight(person.user_ref)"
|
||||||
|
class="rounded-lg bg-indigo-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-indigo-500">
|
||||||
|
Spotlight
|
||||||
|
</button>
|
||||||
<button x-show="isConference && isHost && isGreenRoom && person.user_ref && !['host', 'co_host', 'panelist'].includes(person.role)"
|
<button x-show="isConference && isHost && isGreenRoom && person.user_ref && !['host', 'co_host', 'panelist'].includes(person.role)"
|
||||||
type="button" @click="makeSpeaker(person.user_ref)"
|
type="button" @click="makeSpeaker(person.user_ref)"
|
||||||
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
|
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
|
||||||
@@ -455,10 +500,82 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p x-show="inCallParticipants().length === 0 && waitingParticipants().length === 0" class="px-2 py-4 text-center text-xs text-slate-500">No participants yet.</p>
|
<p x-show="(usesStageLayout ? speakerParticipantsSorted().length : inCallParticipants().length) === 0 && waitingParticipants().length === 0" class="px-2 py-4 text-center text-xs text-slate-500" x-text="usesStageLayout ? 'No speakers yet.' : 'No participants yet.'"></p>
|
||||||
</div>
|
</div>
|
||||||
</x-meet.room.panel>
|
</x-meet.room.panel>
|
||||||
|
|
||||||
|
<div x-show="panelSetupOpen" x-cloak
|
||||||
|
class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 backdrop-blur-sm sm:items-center"
|
||||||
|
@keydown.escape.window="panelSetupOpen = false">
|
||||||
|
<div class="w-full max-w-lg rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="panelSetupOpen = false">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-white">Stage & panels</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Switch between plenary and panel layouts. Assign speakers to each panel before going live.</p>
|
||||||
|
</div>
|
||||||
|
<button @click="panelSetupOpen = false" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-800 hover:text-white">
|
||||||
|
<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 18 18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex gap-2">
|
||||||
|
<button type="button" @click="stageLayout = 'plenary'; applyStageLayout()"
|
||||||
|
class="flex-1 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors"
|
||||||
|
:class="stageLayout === 'plenary' ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
|
||||||
|
Plenary
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="stageLayout = 'panel'; applyStageLayout()"
|
||||||
|
class="flex-1 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors"
|
||||||
|
:class="stageLayout === 'panel' ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
|
||||||
|
Panel discussion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="stageLayout === 'panel'" x-cloak class="mt-5 space-y-3">
|
||||||
|
<template x-for="(panel, index) in panels" :key="panel.id">
|
||||||
|
<div class="rounded-xl border border-slate-700/60 bg-slate-950/60 p-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" x-model="panel.name" placeholder="Panel name"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border-0 bg-slate-900 px-3 py-2 text-sm text-white ring-1 ring-slate-700/60">
|
||||||
|
<button type="button" @click="setActivePanel(panel.id)"
|
||||||
|
class="rounded-lg px-2.5 py-1.5 text-xs font-medium"
|
||||||
|
:class="activePanelId === panel.id ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-300'">
|
||||||
|
<span x-text="activePanelId === panel.id ? 'Active' : 'Show'"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="removePanel(index)" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-red-300">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<template x-for="speaker in speakerParticipantsSorted()" :key="panel.id + '-' + speaker.uuid">
|
||||||
|
<label class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs"
|
||||||
|
:class="panel.speaker_refs.includes(speaker.user_ref) ? 'bg-violet-600/30 text-violet-100 ring-1 ring-violet-500/50' : 'bg-slate-800 text-slate-300'">
|
||||||
|
<input type="checkbox" class="sr-only"
|
||||||
|
:checked="panel.speaker_refs.includes(speaker.user_ref)"
|
||||||
|
@change="togglePanelSpeaker(panel, speaker.user_ref)">
|
||||||
|
<span x-text="speaker.display_name"></span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="addPanel()"
|
||||||
|
class="w-full rounded-xl border border-dashed border-slate-600 px-3 py-2.5 text-sm font-medium text-slate-300 hover:border-violet-500/50 hover:text-violet-200">
|
||||||
|
+ Add panel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button type="button" @click="panelSetupOpen = false"
|
||||||
|
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-800">Cancel</button>
|
||||||
|
<button type="button" @click="saveStageLayout()" :disabled="savingStageLayout"
|
||||||
|
class="rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-60">
|
||||||
|
<span x-text="savingStageLayout ? 'Saving…' : 'Save layout'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<x-meet.room.panel show="featuresOpen" title="More options">
|
<x-meet.room.panel show="featuresOpen" title="More options">
|
||||||
<x-slot:subtitle>
|
<x-slot:subtitle>
|
||||||
<p>{{ $sessionNounTitle }} tools and extras</p>
|
<p>{{ $sessionNounTitle }} tools and extras</p>
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ Route::post('/room/{session}/town-hall/green-room', [MeetingFeaturesController::
|
|||||||
Route::post('/room/{session}/town-hall/go-live', [MeetingFeaturesController::class, 'goLiveTownHall'])->name('meet.room.townhall.live');
|
Route::post('/room/{session}/town-hall/go-live', [MeetingFeaturesController::class, 'goLiveTownHall'])->name('meet.room.townhall.live');
|
||||||
Route::post('/room/{session}/town-hall/presenters', [MeetingFeaturesController::class, 'addPresenter'])->name('meet.room.townhall.presenters');
|
Route::post('/room/{session}/town-hall/presenters', [MeetingFeaturesController::class, 'addPresenter'])->name('meet.room.townhall.presenters');
|
||||||
Route::post('/room/{session}/town-hall/handoff', [MeetingFeaturesController::class, 'handoffCoHost'])->name('meet.room.townhall.handoff');
|
Route::post('/room/{session}/town-hall/handoff', [MeetingFeaturesController::class, 'handoffCoHost'])->name('meet.room.townhall.handoff');
|
||||||
|
Route::post('/room/{session}/stage', [MeetingFeaturesController::class, 'updateStage'])->name('meet.room.stage.update');
|
||||||
|
|
||||||
Route::middleware(['auth', 'platform.session'])->group(function () {
|
Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||||
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
|
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
|
||||||
|
|||||||
@@ -772,6 +772,138 @@ class MeetWebTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_host_can_update_conference_stage_layout(): void
|
||||||
|
{
|
||||||
|
$room = Room::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'host_user_ref' => $this->user->public_id,
|
||||||
|
'title' => 'NextGen Summit',
|
||||||
|
'type' => 'town_hall',
|
||||||
|
'status' => 'live',
|
||||||
|
'scheduled_at' => now()->addHour(),
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => array_merge(config('meet.default_settings'), [
|
||||||
|
'panel_discussions' => true,
|
||||||
|
'stage_layout' => 'plenary',
|
||||||
|
'panels' => [],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = Session::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'media_room_name' => 'meet-'.$room->uuid,
|
||||||
|
'status' => 'live',
|
||||||
|
'session_mode' => 'green_room',
|
||||||
|
'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])
|
||||||
|
->postJson('/room/'.$session->uuid.'/stage', [
|
||||||
|
'stage_layout' => 'panel',
|
||||||
|
'panels' => [
|
||||||
|
['name' => 'Opening panel', 'speaker_refs' => [$this->user->public_id]],
|
||||||
|
],
|
||||||
|
'active_panel_id' => null,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'stage_layout' => 'panel',
|
||||||
|
'panel_discussions' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$room->refresh();
|
||||||
|
$this->assertSame('panel', $room->setting('stage_layout'));
|
||||||
|
$this->assertCount(1, $room->setting('panels'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_poll_includes_stage_config_for_conference(): void
|
||||||
|
{
|
||||||
|
$room = Room::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'host_user_ref' => $this->user->public_id,
|
||||||
|
'title' => 'NextGen Summit',
|
||||||
|
'type' => 'town_hall',
|
||||||
|
'status' => 'live',
|
||||||
|
'scheduled_at' => now()->addHour(),
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => array_merge(config('meet.default_settings'), [
|
||||||
|
'panel_discussions' => true,
|
||||||
|
'stage_layout' => 'plenary',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
|
||||||
|
->getJson('/room/'.$session->uuid.'/poll')
|
||||||
|
->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'uses_stage_layout' => true,
|
||||||
|
'stage_layout' => 'plenary',
|
||||||
|
'panel_discussions' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_regular_meeting_rejects_stage_update(): void
|
||||||
|
{
|
||||||
|
$room = $this->createRoom();
|
||||||
|
$session = Session::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'media_room_name' => 'meet-'.$room->uuid,
|
||||||
|
'status' => '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])
|
||||||
|
->postJson('/room/'.$session->uuid.'/stage', [
|
||||||
|
'stage_layout' => 'panel',
|
||||||
|
])
|
||||||
|
->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_host_can_create_space_room(): void
|
public function test_host_can_create_space_room(): void
|
||||||
{
|
{
|
||||||
$this->actingAs($this->user)
|
$this->actingAs($this->user)
|
||||||
|
|||||||
Reference in New Issue
Block a user