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}."); $participant = $this->sessions->syncEventsSpeakerRole($request, $participant); $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(), 'serverEgressAvailable' => app(LiveKitEgressService::class)->isAvailable(), 'watermark' => $room->organization?->securitySetting('watermark_enabled', false), 'isWebinar' => $room->isWebinar() || $room->isConference(), '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 inviteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse { $actor = $this->currentParticipant($request, $session); abort_unless($participant->session_id === $session->id, 404); abort_unless($session->room->isSpace(), 404); abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); $updated = app(SpaceService::class)->inviteToSpeak($session->room, $participant); $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); return response()->json([ 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), ]); } public function acceptSpaceSpeakInvitation(Request $request, Session $session): JsonResponse { $participant = $this->currentParticipant($request, $session); abort_unless($session->room->isSpace(), 404); $updated = app(SpaceService::class)->acceptSpeakInvitation($session->room, $participant); $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); return response()->json([ 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), ]); } public function declineSpaceSpeakInvitation(Request $request, Session $session): JsonResponse { $participant = $this->currentParticipant($request, $session); abort_unless($session->room->isSpace(), 404); $updated = app(SpaceService::class)->declineSpeakInvitation($session->room, $participant); $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); return response()->json([ 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), ]); } public function dismissSpaceSpeakInvitation(Request $request, Session $session, Participant $participant): JsonResponse { $actor = $this->currentParticipant($request, $session); abort_unless($participant->session_id === $session->id, 404); abort_unless($session->room->isSpace(), 404); abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); $updated = app(SpaceService::class)->dismissSpeakInvitation($session->room, $participant); $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); return response()->json([ 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), ]); } public function promoteSpaceCoHost(Request $request, Session $session, Participant $participant): JsonResponse { $actor = $this->currentParticipant($request, $session); abort_unless($participant->session_id === $session->id, 404); abort_unless($session->room->isSpace(), 404); abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); $updated = app(SpaceService::class)->promoteToCoHost($session->room, $participant); $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); return response()->json([ 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), ]); } public function demoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse { $actor = $this->currentParticipant($request, $session); abort_unless($participant->session_id === $session->id, 404); abort_unless($session->room->isSpace(), 404); abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); $updated = app(SpaceService::class)->demoteFromSpeaker($session->room, $participant); $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); return response()->json([ 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), ]); } public function sendMessage(Request $request, Session $session): JsonResponse { abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'chat'), 422); $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(), ], ]); } /** * Room state for clients. Shared payload is cached briefly so concurrent * pollers (every participant every few seconds) do not each re-run the full * query fan-out. Per-participant media is still computed fresh. */ public function poll(Request $request, Session $session): JsonResponse|Response { 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); // Breakout expiry is not latency-critical; run at most once per 30s/session. if (Cache::add("meet:breakout-close:{$session->id}", 1, 30)) { app(BreakoutService::class)->closeExpired($session); } $participant->refresh(); $shared = $this->sharedPollPayload($session); $media = $this->sessions->mediaSessionState($participant); $etag = '"'.md5(($shared['_etag'] ?? '').'|'.$participant->id.'|'.json_encode($media)).'"'; if ($request->headers->get('If-None-Match') === $etag) { return response('', 304)->withHeaders([ 'ETag' => $etag, 'Cache-Control' => 'private, no-cache', ]); } unset($shared['_etag']); return response() ->json(array_merge($shared, ['media' => $media])) ->withHeaders([ 'ETag' => $etag, 'Cache-Control' => 'private, no-cache', ]); } /** * Session-wide poll fields shared by every participant (2s TTL). * * @return array */ protected function sharedPollPayload(Session $session): array { $cacheKey = "meet:poll:shared:{$session->id}"; return Cache::remember($cacheKey, 2, function () use ($session) { // Ensure room relation is available without re-querying when already loaded. $session->loadMissing('room'); $session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]); $hostRef = $session->room->host_user_ref; $avatars = ParticipantPresenter::avatarMap($session->participants, $hostRef); $messages = $this->chat->recentMessages($session, 30); $reactions = $session->reactions() ->where('created_at', '>=', now()->subMinutes(2)) ->latest() ->limit(15) ->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(); $payload = array_merge([ 'participants' => $session->participants->map( fn ($p) => ParticipantPresenter::toArray($p, $avatars, $hostRef) )->values()->all(), '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(), ])->values()->all(), 'reactions' => $reactions->map(fn ($r) => [ 'sender_name' => $r->sender_name, 'emoji' => $r->emoji, 'created_at' => $r->created_at->toIso8601String(), ])->values()->all(), '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, ])->values()->all(), 'polls' => $polls->map(fn ($p) => [ 'uuid' => $p->uuid, 'question' => $p->question, 'options' => $p->options, 'votes' => $p->votes->count(), ])->values()->all(), 'breakouts' => $breakouts->map(fn ($b) => [ 'uuid' => $b->uuid, 'name' => $b->name, 'closes_at' => $b->closes_at?->toIso8601String(), ])->values()->all(), 'broadcasts' => $broadcasts->map(fn ($m) => [ 'body' => $m->body, 'sender_name' => $m->sender_name, 'created_at' => $m->created_at->toIso8601String(), ])->values()->all(), 'files' => $files->map(fn ($f) => [ 'uuid' => $f->uuid, 'original_name' => $f->original_name, 'file_size' => $f->file_size, ])->values()->all(), 'breakouts_active' => $breakouts->isNotEmpty(), ], app(StageLayoutService::class)->config($session->room)); $payload['_etag'] = md5(json_encode($payload)); return $payload; }); } 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, 'capture_mode' => $this->recordings->captureMode($recording), ]); } public function stopRecording(Request $request, Session $session): JsonResponse { $participant = $this->recordingHost($request, $session); $recording = $session->recordings()->where('status', 'recording')->firstOrFail(); $actorRef = $participant->user_ref ?? $participant->uuid; if ($request->boolean('failed')) { $recording = $this->recordings->fail( $recording, (string) $request->input('reason', 'Recording capture did not complete.'), $actorRef, ); return response()->json([ 'status' => 'failed', 'recording_uuid' => $recording->uuid, ]); } $recording = $this->recordings->stop($recording, $actorRef); return response()->json([ 'status' => 'processing', 'recording_uuid' => $recording->uuid, 'capture_mode' => $this->recordings->captureMode($recording), ]); } public function failRecording(Request $request, Session $session, Recording $recording): JsonResponse { $participant = $this->recordingHost($request, $session); abort_unless($recording->session_id === $session->id, 404); abort_if(! in_array($recording->status, ['recording', 'processing'], true), 422); $recording = $this->recordings->fail( $recording, (string) $request->input('reason', 'Recording capture did not complete.'), $participant->user_ref ?? $participant->uuid, ); return response()->json([ 'status' => $recording->status, 'recording_uuid' => $recording->uuid, ]); } public function uploadRecording(Request $request, Session $session, Recording $recording): JsonResponse { $participant = $this->recordingHost($request, $session); 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 { abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422); $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 { abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422); $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 = $this->transcripts->getOrCreateForSession($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 $this->sessions->syncEventsSpeakerRole( $request, Participant::where('uuid', $participantUuid) ->where('session_id', $session->id) ->firstOrFail() ); } /** Host/co-host in the room session, or the room host after the meeting ended. */ protected function recordingHost(Request $request, Session $session): Participant { $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); if ($participantUuid) { $participant = Participant::query() ->where('uuid', $participantUuid) ->where('session_id', $session->id) ->first(); if ($participant?->isHost()) { return $this->sessions->syncEventsSpeakerRole($request, $participant); } } $user = $request->user(); abort_unless($user, 403); $hostParticipant = $session->participants() ->where('user_ref', $user->ownerRef()) ->whereIn('role', ['host', 'co_host']) ->orderByRaw("case when role = 'host' then 0 else 1 end") ->first(); abort_unless($hostParticipant, 403); return $hostParticipant; } }