Files
ladill-meet/app/Http/Controllers/Meet/MeetingRoomController.php
T
isaaccladandCursor 1037cd6ee6
Deploy Ladill Meet / deploy (push) Successful in 37s
Add conference stage layouts for plenary and panel discussions.
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>
2026-07-01 21:03:02 +00:00

409 lines
15 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\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\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}");
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);
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' => $room->isSpace()
? $spaces->canPublish($room, $participant)
: $webinars->canPublish($room, $participant),
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
'usesStageLayout' => $stageLayout->usesStageLayout($room),
'stageConfig' => $stageLayout->config($room),
]);
}
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 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()) {
return response()->json([
'ended' => true,
'redirect' => route('meet.ended', $session),
]);
}
$this->currentParticipant($request, $session);
$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,
]),
], app(StageLayoutService::class)->config($session->room)));
}
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();
}
}