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,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);
}
}