Files
ladill-meet/app/Http/Controllers/Meet/MeetingRoomController.php
T
isaaccladandCursor d648f6c862
Deploy Ladill Meet / deploy (push) Successful in 33s
Add speak access for webinar/conference attendees and fix Events linking UX.
Let attendees request host-granted microphone access with LiveKit reconnect,
participant panel controls, and toolbar UI that only shows mic when allowed.
Fix Events create URL, surface API key setup guidance, and align the Webinars
sidebar icon with other nav items using currentColor strokes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 07:59:18 +00:00

528 lines
20 KiB
PHP

<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
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;
use App\Services\Meet\RecordingService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\StageLayoutService;
use App\Services\Meet\TownHallService;
use App\Services\Meet\TranscriptService;
use App\Services\Meet\WebinarService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MeetingRoomController extends Controller
{
public function __construct(
protected SessionService $sessions,
protected ChatService $chat,
protected MediaProviderInterface $media,
protected RecordingService $recordings,
protected TranscriptService $transcripts,
protected SecurityPolicyService $security,
) {}
public function show(Request $request, Session $session): View|RedirectResponse
{
if (! $session->isLive()) {
return redirect()->route('meet.ended', $session);
}
$room = $session->room;
$kind = $room->isConference() ? 'conference' : 'meeting';
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
if (! $participantUuid && $request->user() && $request->user()->ownerRef() === $room->host_user_ref) {
$hostParticipant = $this->sessions->ensureJoinedParticipant($request, $session, $request->user(), 'host');
$participantUuid = $hostParticipant->uuid;
}
abort_unless($participantUuid, 403, "Please join the {$kind} first.");
$participant = Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->firstOrFail();
if ($participant->status === 'waiting') {
return redirect()->route('meet.join.waiting', $session->room);
}
abort_unless($participant->status === 'joined', 403, "You are not in this {$kind}.");
$token = $this->media->isConfigured()
? $this->sessions->generateMediaToken($participant)
: '';
$session->load(['room', 'participants']);
$room = $session->room;
$webinars = app(WebinarService::class);
$spaces = app(SpaceService::class);
$isAudioOnly = $room->isAudioOnly();
$stageLayout = app(StageLayoutService::class);
$authUser = $request->user();
$isRoomHost = $authUser && $authUser->ownerRef() === $room->host_user_ref;
$canManageConference = $participant->isHost() || $isRoomHost;
$canManageSpeakAccess = app(\App\Services\Meet\SpeakAccessService::class)->canManageSpeakAccess($participant);
$usesSpeakAccess = $room->isWebinar() || $room->isConference();
$mediaState = $this->sessions->mediaSessionState($participant);
$breakoutService = app(BreakoutService::class);
$inBreakout = $breakoutService->isInActiveBreakout($participant);
return view('meet.room.show', [
'session' => $session,
'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' => $room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(),
'isConference' => $room->isConference(),
'isGreenRoom' => $session->session_mode === 'green_room',
'sessionMode' => $session->session_mode,
'presenterRefs' => (array) $session->presenter_refs,
'isAudioOnly' => $isAudioOnly,
'isSpace' => $room->isSpace(),
'canPublish' => $mediaState['can_publish'],
'audioOnlyPublish' => $mediaState['audio_only_publish'],
'speakGranted' => $mediaState['speak_granted'],
'speakRequested' => $mediaState['speak_requested'],
'usesSpeakAccess' => $usesSpeakAccess,
'canManageSpeakAccess' => $canManageSpeakAccess,
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
'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', []),
]);
}
public function end(Request $request, Session $session): RedirectResponse
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
$participant = Participant::where('uuid', $participantUuid)->first();
abort_unless($participant?->isHost(), 403);
$this->sessions->end($session, $participant->user_ref ?? $participant->uuid);
$room = $session->room;
return redirect()->route($this->roomShowRoute($room), $room)
->with('success', $this->roomEndedMessage($room));
}
protected function roomShowRoute(Room $room): string
{
if ($room->isWebinar()) {
return 'meet.webinars.show';
}
if ($room->isConference()) {
return 'meet.conferences.show';
}
if ($room->isSpace()) {
return 'meet.spaces.show';
}
return 'meet.rooms.show';
}
protected function roomEndedMessage(Room $room): string
{
if ($room->isWebinar()) {
return 'Webinar ended.';
}
if ($room->isConference()) {
return 'Conference ended.';
}
if ($room->isSpace()) {
return 'Room closed.';
}
return 'Meeting ended.';
}
public function leave(Request $request, Session $session): RedirectResponse
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
$participant = Participant::where('uuid', $participantUuid)->first();
if ($participant) {
$this->sessions->leaveParticipant($participant);
$request->session()->put('meet.left.feedback', [
'session_uuid' => $session->uuid,
'participant_uuid' => $participant->uuid,
'display_name' => $participant->display_name,
'user_ref' => $participant->user_ref,
]);
}
$request->session()->forget("meet.participant.{$session->uuid}");
return redirect()->route('meet.left', $session);
}
public function raiseHand(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$participant->update(['hand_raised' => ! $participant->hand_raised]);
return response()->json(['hand_raised' => $participant->hand_raised]);
}
public function requestSpeak(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->requestSpeak($participant),
]);
}
public function cancelSpeakRequest(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->cancelSpeakRequest($participant),
]);
}
public function grantSpeak(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->grantSpeak($participant, $actor),
]);
}
public function revokeSpeak(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->revokeSpeak($participant, $actor),
]);
}
public function dismissSpeakRequest(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->dismissSpeakRequest($participant, $actor),
]);
}
public function sendMessage(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['body' => ['required', 'string', 'max:2000']]);
$message = $this->chat->sendPublicMessage(
$session,
$participant->display_name,
$validated['body'],
$participant->user_ref,
);
return response()->json([
'message' => [
'uuid' => $message->uuid,
'sender_name' => $message->sender_name,
'body' => $message->body,
'created_at' => $message->created_at->toIso8601String(),
],
]);
}
public function sendReaction(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['emoji' => ['required', 'string', 'max:32']]);
$reaction = $this->chat->sendReaction(
$session,
$participant->display_name,
$validated['emoji'],
$participant->uuid,
);
return response()->json([
'reaction' => [
'sender_name' => $reaction->sender_name,
'emoji' => $reaction->emoji,
'created_at' => $reaction->created_at->toIso8601String(),
],
]);
}
public function poll(Request $request, Session $session): JsonResponse
{
if (! $session->isLive()) {
$townHall = app(TownHallService::class);
$liveSession = $townHall->successorLiveSession($session);
if ($liveSession) {
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
if ($participantUuid) {
$greenParticipant = Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->first();
if ($greenParticipant && $this->sessions->transitionGreenRoomParticipant($request, $greenParticipant, $liveSession)) {
return response()->json([
'ended' => true,
'redirect' => route('meet.room', $liveSession),
'transition' => 'go_live',
]);
}
}
}
return response()->json([
'ended' => true,
'redirect' => route('meet.ended', $session),
]);
}
$participant = $this->currentParticipant($request, $session);
app(BreakoutService::class)->closeExpired($session);
$participant->refresh();
$session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]);
$avatars = ParticipantPresenter::avatarMap($session->participants, $session->room->host_user_ref);
$messages = $this->chat->recentMessages($session, 50);
$reactions = $session->reactions()->where('created_at', '>=', now()->subMinutes(5))->latest()->limit(20)->get();
$qa = app(\App\Services\Meet\QaService::class)->forSession($session, true);
$polls = $session->polls()->where('status', 'open')->with('votes')->get();
$breakouts = $session->breakoutRooms()->where('status', 'open')->get();
$broadcasts = $session->messages()->where('type', 'breakout_broadcast')->where('created_at', '>=', now()->subMinutes(30))->latest()->limit(5)->get();
$files = $session->sessionFiles()
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->latest()
->limit(20)
->get();
return response()->json(array_merge([
'participants' => $session->participants->map(
fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref)
),
'waiting_count' => $session->participants->where('status', 'waiting')->count(),
'messages' => $messages->map(fn ($m) => [
'uuid' => $m->uuid,
'sender_name' => $m->sender_name,
'body' => $m->body,
'type' => $m->type,
'created_at' => $m->created_at->toIso8601String(),
]),
'reactions' => $reactions->map(fn ($r) => [
'sender_name' => $r->sender_name,
'emoji' => $r->emoji,
'created_at' => $r->created_at->toIso8601String(),
]),
'qa' => $qa->map(fn ($q) => [
'uuid' => $q->uuid,
'asker_name' => $q->asker_name,
'question' => $q->question,
'status' => $q->status,
'answer' => $q->answer,
'upvotes' => $q->upvotes,
]),
'polls' => $polls->map(fn ($p) => [
'uuid' => $p->uuid,
'question' => $p->question,
'options' => $p->options,
'votes' => $p->votes->count(),
]),
'breakouts' => $breakouts->map(fn ($b) => [
'uuid' => $b->uuid,
'name' => $b->name,
'closes_at' => $b->closes_at?->toIso8601String(),
]),
'broadcasts' => $broadcasts->map(fn ($m) => [
'body' => $m->body,
'sender_name' => $m->sender_name,
'created_at' => $m->created_at->toIso8601String(),
]),
'files' => $files->map(fn ($f) => [
'uuid' => $f->uuid,
'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);
abort_unless($participant->isHost(), 403);
abort_if($session->room->organization?->securitySetting('recording_host_only') && ! $participant->isHost(), 403);
$host = $request->user() ?? \App\Models\User::where('public_id', $session->room->host_user_ref)->first();
abort_unless($host, 403);
$recording = $this->recordings->start($session, $host);
return response()->json(['recording_uuid' => $recording->uuid, 'status' => $recording->status]);
}
public function stopRecording(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$recording = $session->recordings()->where('status', 'recording')->firstOrFail();
$recording = $this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid);
return response()->json([
'status' => 'processing',
'recording_uuid' => $recording->uuid,
]);
}
public function uploadRecording(Request $request, Session $session, Recording $recording): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
abort_unless($recording->session_id === $session->id, 404);
$maxKb = max(1, (int) config('meet.recordings.max_mb', 512)) * 1024;
$request->validate([
'recording' => ['required', 'file', 'mimetypes:video/webm,video/mp4,video/x-matroska,video/quicktime', 'max:'.$maxKb],
]);
$this->recordings->storeUpload($recording, $request->file('recording'));
return response()->json(['status' => 'ready', 'recording_uuid' => $recording->uuid]);
}
public function lock(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$this->sessions->lock($session, $participant->user_ref ?? $participant->uuid);
return response()->json(['locked' => true]);
}
public function unlock(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$this->sessions->unlock($session, $participant->user_ref ?? $participant->uuid);
return response()->json(['locked' => false]);
}
public function caption(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['text' => ['required', 'string', 'max:500']]);
if (! $session->room->setting('live_captions', false)) {
return response()->json(['ok' => false], 422);
}
$transcript = $session->transcripts()->latest()->first()
?? $this->transcripts->ensureForSession($session);
$this->transcripts->appendSegment($transcript, $participant->display_name, $validated['text']);
return response()->json(['ok' => true]);
}
public function admit(Request $request, Session $session, Participant $participant): JsonResponse
{
$current = $this->currentParticipant($request, $session);
abort_unless($current->isHost(), 403);
abort_unless($participant->session_id === $session->id, 404);
$this->sessions->admitParticipant($participant);
return response()->json(['ok' => true, 'status' => 'joined']);
}
public function deny(Request $request, Session $session, Participant $participant): JsonResponse
{
$current = $this->currentParticipant($request, $session);
abort_unless($current->isHost(), 403);
abort_unless($participant->session_id === $session->id, 404);
$this->sessions->denyParticipant($participant);
return response()->json(['ok' => true, 'status' => 'removed']);
}
protected function currentParticipant(Request $request, Session $session): Participant
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
abort_unless($participantUuid, 403);
return Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->firstOrFail();
}
}