Add Conferences and audio-only Rooms, plus mobile schedule icon.
Deploy Ladill Meet / deploy (push) Successful in 1m29s

Split town_hall into a Conferences sidebar flow and Spaces-style Rooms with host/speaker/listener roles; enforce audio-only LiveKit UI for rooms; show schedule icon on mobile meetings index.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 19:16:54 +00:00
co-authored by Cursor
parent 1705e6b435
commit 63af522380
25 changed files with 897 additions and 37 deletions
@@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Room;
use App\Models\Template;
use App\Services\Meet\CalendarService;
use App\Services\Meet\InvitationService;
use App\Services\Meet\RoomService;
use App\Services\Meet\SessionService;
use App\Services\Meet\TemplateService;
use App\Services\Meet\TownHallService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ConferenceController extends Controller
{
use ScopesToAccount;
public function __construct(
protected RoomService $rooms,
protected SessionService $sessions,
protected TemplateService $templates,
protected InvitationService $invitations,
protected CalendarService $calendar,
protected TownHallService $townHalls,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Room::owned($owner)
->where('organization_id', $organization->id)
->where('type', 'town_hall');
$this->scopeToBranch($request, $query);
$conferences = $query->orderByDesc('scheduled_at')->paginate(20);
return view('meet.conferences.index', compact('conferences', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$templates = Template::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('meet.conferences.create', [
'organization' => $organization,
'templates' => $templates,
'timezones' => timezone_identifiers_list(),
'defaultSettings' => config('meet.default_settings'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'scheduled_at' => ['required', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
'invite_emails' => ['nullable', 'string'],
'waiting_room' => ['sometimes', 'boolean'],
'join_before_host' => ['sometimes', 'boolean'],
'mute_on_join' => ['sometimes', 'boolean'],
'auto_record' => ['sometimes', 'boolean'],
'live_captions' => ['sometimes', 'boolean'],
'green_room' => ['sometimes', 'boolean'],
'practice_mode' => ['sometimes', 'boolean'],
]);
$settings = [
'waiting_room' => $request->boolean('waiting_room', true),
'join_before_host' => $request->boolean('join_before_host'),
'mute_on_join' => $request->boolean('mute_on_join', true),
'auto_record' => $request->boolean('auto_record'),
'live_captions' => $request->boolean('live_captions'),
'green_room' => $request->boolean('green_room', true),
'practice_mode' => $request->boolean('practice_mode'),
'stage_mode' => true,
];
$data = array_merge($validated, [
'settings' => $settings,
'timezone' => $validated['timezone'] ?? $organization->timezone,
'type' => 'town_hall',
'status' => 'scheduled',
]);
if (! empty($validated['template_id'])) {
$template = Template::findOrFail($validated['template_id']);
$data = $this->templates->applyToRoomData($template, $data);
}
$room = $this->rooms->create($request->user(), $organization, $data);
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if ($room->scheduled_at) {
$this->calendar->syncRoomCreated($room, $request->user());
}
return redirect()->route('meet.conferences.show', $room)->with('success', 'Conference created.');
}
public function show(Request $request, Room $room): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
abort_unless($room->isConference(), 404);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
return view('meet.conferences.show', compact('room'));
}
public function start(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.host');
$this->authorizeOwner($request, $room);
abort_unless($room->isConference(), 404);
if ($room->status === 'cancelled') {
return redirect()->route('meet.conferences.show', $room)
->withErrors(['start' => 'Conference was cancelled.']);
}
if ($room->setting('green_room')) {
$session = $this->townHalls->startGreenRoom($room, $request->user());
} else {
$session = $this->sessions->start($room, $request->user());
}
$participant = $this->sessions->joinedParticipantForUser($session, $request->user());
abort_unless($participant, 500, 'Unable to join as host.');
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
}
+5 -1
View File
@@ -9,6 +9,7 @@ use App\Models\Session;
use App\Services\Meet\InvitationService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\WebinarService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -22,6 +23,7 @@ class JoinController extends Controller
protected SessionService $sessions,
protected SecurityPolicyService $security,
protected WebinarService $webinars,
protected SpaceService $spaces,
) {}
public function show(Request $request, Room $room): View|RedirectResponse
@@ -155,7 +157,9 @@ class JoinController extends Controller
$role = ($user && $user->ownerRef() === $room->host_user_ref) ? 'host' : 'guest';
if ($room->isWebinar() && $role !== 'host') {
if ($room->isSpace()) {
$role = $this->spaces->resolveJoinRole($room, $user, $role);
} elseif ($room->isWebinar() && $role !== 'host') {
$token = $request->session()->get("meet.webinar.{$room->uuid}");
$reg = $token
? $this->webinars->findByAccessToken($token)
@@ -12,7 +12,9 @@ use App\Services\Meet\ParticipantPresenter;
use App\Services\Meet\RecordingService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\TranscriptService;
use App\Services\Meet\WebinarService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -54,19 +56,28 @@ class MeetingRoomController extends Controller
$session->load(['room', 'participants']);
$room = $session->room;
$webinars = app(WebinarService::class);
$spaces = app(SpaceService::class);
$isAudioOnly = $room->isAudioOnly();
return view('meet.room.show', [
'session' => $session,
'room' => $session->room,
'room' => $room,
'participant' => $participant,
'mediaToken' => $token,
'mediaUrl' => $this->media->serverUrl(),
'mediaConfigured' => $this->media->isConfigured(),
'messages' => $this->chat->recentMessages($session),
'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
'watermark' => $session->room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $session->room->isWebinar(),
'canPublish' => app(\App\Services\Meet\WebinarService::class)->canPublish($session->room, $participant),
'isAttendee' => $participant->isAttendee(),
'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(),
'isAudioOnly' => $isAudioOnly,
'isSpace' => $room->isSpace(),
'canPublish' => $room->isSpace()
? $spaces->canPublish($room, $participant)
: $webinars->canPublish($room, $participant),
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
]);
}
+10 -12
View File
@@ -45,7 +45,7 @@ class RoomController extends Controller
$owner = $this->ownerRef($request);
$query = Room::owned($owner)->where('organization_id', $organization->id)
->where('type', '!=', 'webinar');
->whereNotIn('type', ['webinar', 'town_hall', 'space']);
$this->scopeToBranch($request, $query);
$rooms = $query->orderByDesc('scheduled_at')->paginate(20);
@@ -80,7 +80,7 @@ class RoomController extends Controller
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'type' => ['nullable', 'string', 'in:scheduled,town_hall'],
'type' => ['nullable', 'string', 'in:scheduled'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
@@ -122,16 +122,6 @@ class RoomController extends Controller
if (! empty($validated['recurrence_rule']) && ! $isInstant) {
$room = $this->recurring->createSeries($request->user(), $organization, $data);
} elseif (($validated['type'] ?? '') === 'town_hall') {
abort_if($isInstant, 422, 'Town halls must be scheduled.');
$room = $this->rooms->create($request->user(), $organization, array_merge($data, [
'type' => 'town_hall',
'status' => 'scheduled',
'settings' => array_merge($settings, [
'green_room' => true,
'practice_mode' => $request->boolean('practice_mode'),
]),
]));
} elseif ($isInstant) {
$room = $this->rooms->createInstant($request->user(), $organization, $data);
} else {
@@ -162,6 +152,14 @@ class RoomController extends Controller
return redirect()->route('meet.webinars.show', $room);
}
if ($room->isConference()) {
return redirect()->route('meet.conferences.show', $room);
}
if ($room->isSpace()) {
return redirect()->route('meet.spaces.show', $room);
}
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']);
return view('meet.rooms.show', compact('room'));
@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Member;
use App\Models\Room;
use App\Models\Template;
use App\Services\Meet\CalendarService;
use App\Services\Meet\InvitationService;
use App\Services\Meet\RoomService;
use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\TemplateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SpaceController extends Controller
{
use ScopesToAccount;
public function __construct(
protected RoomService $rooms,
protected SessionService $sessions,
protected TemplateService $templates,
protected InvitationService $invitations,
protected CalendarService $calendar,
protected SpaceService $spaces,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Room::owned($owner)
->where('organization_id', $organization->id)
->where('type', 'space');
$this->scopeToBranch($request, $query);
$spaces = $query->orderByDesc('updated_at')->paginate(20);
return view('meet.spaces.index', compact('spaces', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$templates = Template::owned($owner)
->where('organization_id', $organization->id)
->orderBy('name')
->get();
$members = Member::owned($owner)
->where('organization_id', $organization->id)
->where('user_ref', '!=', $owner)
->orderBy('user_ref')
->get();
return view('meet.spaces.create', [
'organization' => $organization,
'templates' => $templates,
'members' => $members,
'timezones' => timezone_identifiers_list(),
'defaultSettings' => config('meet.default_settings'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
'invite_emails' => ['nullable', 'string'],
'speaker_refs' => ['nullable', 'array'],
'speaker_refs.*' => ['string', 'max:64'],
'invite_only' => ['sometimes', 'boolean'],
'mute_listeners' => ['sometimes', 'boolean'],
]);
$speakerRefs = collect($validated['speaker_refs'] ?? [])
->filter()
->unique()
->values()
->all();
$settings = [
'waiting_room' => false,
'join_before_host' => false,
'mute_on_join' => $request->boolean('mute_listeners', true),
'video_off_on_join' => true,
'audio_only' => true,
'space_mode' => true,
'stage_mode' => true,
'invite_only' => $request->boolean('invite_only', true),
'speaker_refs' => $speakerRefs,
];
$data = array_merge($validated, [
'settings' => $settings,
'timezone' => $validated['timezone'] ?? $organization->timezone,
'type' => 'space',
'status' => empty($validated['scheduled_at']) ? 'scheduled' : 'scheduled',
]);
unset($data['speaker_refs'], $data['invite_only'], $data['mute_listeners']);
if (! empty($validated['template_id'])) {
$template = Template::findOrFail($validated['template_id']);
$data = $this->templates->applyToRoomData($template, $data);
}
$room = $this->rooms->create($request->user(), $organization, $data);
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if (! empty($room->scheduled_at)) {
$this->calendar->syncRoomCreated($room, $request->user());
}
return redirect()->route('meet.spaces.show', $room)->with('success', 'Room created.');
}
public function show(Request $request, Room $room): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
abort_unless($room->isSpace(), 404);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
$speakerRefs = (array) $room->setting('speaker_refs', []);
$speakers = Member::owned($this->ownerRef($request))
->where('organization_id', $room->organization_id)
->whereIn('user_ref', $speakerRefs)
->get();
return view('meet.spaces.show', compact('room', 'speakers'));
}
public function start(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.host');
$this->authorizeOwner($request, $room);
abort_unless($room->isSpace(), 404);
if ($room->status === 'cancelled') {
return redirect()->route('meet.spaces.show', $room)
->withErrors(['start' => 'Room was closed.']);
}
$session = $this->sessions->start($room, $request->user());
$participant = $this->sessions->joinedParticipantForUser($session, $request->user());
abort_unless($participant, 500, 'Unable to open room as host.');
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
}