diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index b069b15..2d81f9f 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -23,6 +23,8 @@ use App\Services\Meet\WebinarService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Facades\Cache; use Illuminate\View\View; class MeetingRoomController extends Controller @@ -387,7 +389,12 @@ class MeetingRoomController extends Controller ]); } - public function poll(Request $request, Session $session): JsonResponse + /** + * 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); @@ -419,77 +426,128 @@ class MeetingRoomController extends Controller $participant = $this->currentParticipant($request, $session); - app(BreakoutService::class)->closeExpired($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(); - $session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]); + $shared = $this->sharedPollPayload($session); + $media = $this->sessions->mediaSessionState($participant); - $avatars = ParticipantPresenter::avatarMap($session->participants, $session->room->host_user_ref); + $etag = '"'.md5(($shared['_etag'] ?? '').'|'.$participant->id.'|'.json_encode($media)).'"'; - $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(); + if ($request->headers->get('If-None-Match') === $etag) { + return response('', 304)->withHeaders([ + 'ETag' => $etag, + 'Cache-Control' => 'private, no-cache', + ]); + } - 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))); + 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 diff --git a/resources/js/meet-public.js b/resources/js/meet-public.js index 73e1eb5..6ea85bd 100644 --- a/resources/js/meet-public.js +++ b/resources/js/meet-public.js @@ -6,7 +6,7 @@ Alpine.data('waitingRoom', () => ({ pollTimer: null, init() { - this.pollTimer = setInterval(() => this.poll(), 2500); + this.pollTimer = setInterval(() => this.poll(), 5000); this.poll(); }, diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 59acdd7..9b4eb85 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -180,6 +180,11 @@ function meetRoom() { pictureInPictureActive: false, pictureInPictureSupported: false, pollTimer: null, + pollInFlight: false, + pollEtag: null, + pollIntervalMs: 6000, + pollHiddenIntervalMs: 20000, + _lastCaptionSentAt: 0, config: {}, init() { @@ -308,7 +313,7 @@ function meetRoom() { this.connectionStatus = 'Video unavailable — configure LiveKit'; } - this.pollTimer = setInterval(() => this.poll(), 3000); + this.startPolling(); this.applyStageLayout(); this.$nextTick(() => { @@ -319,6 +324,34 @@ function meetRoom() { this.setupPictureInPicture(); }, + currentPollInterval() { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return this.pollHiddenIntervalMs; + } + + return this.pollIntervalMs; + }, + + startPolling() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + + this.poll(); + this.pollTimer = setInterval(() => this.poll(), this.currentPollInterval()); + + if (typeof document !== 'undefined' && !this._pollVisibilityBound) { + this._pollVisibilityBound = true; + document.addEventListener('visibilitychange', () => { + if (this.goingLive) { + return; + } + this.startPolling(); + }); + } + }, + connectionErrorMessage(error) { const name = error?.name || ''; const message = error?.message || ''; @@ -1739,13 +1772,20 @@ function meetRoom() { return; } + this.captionText = normalized; + if (normalized === this._lastCaptionSent) { - this.captionText = normalized; + return; + } + + // Throttle caption posts — speech recognition fires very frequently. + const now = Date.now(); + if (now - (this._lastCaptionSentAt || 0) < 2000) { return; } this._lastCaptionSent = normalized; - this.captionText = normalized; + this._lastCaptionSentAt = now; fetch(this.config.captionUrl, { method: 'POST', @@ -3079,7 +3119,7 @@ function meetRoom() { window.location.replace(data.room_url || `/room/${data.session_uuid}`); } catch { this.goingLive = false; - this.pollTimer = setInterval(() => this.poll(), 3000); + this.startPolling(); } }, @@ -3201,8 +3241,29 @@ function meetRoom() { }, async poll() { + if (this.pollInFlight || !this.config.pollUrl) { + return; + } + + this.pollInFlight = true; + try { - const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } }); + const headers = { Accept: 'application/json' }; + if (this.pollEtag) { + headers['If-None-Match'] = this.pollEtag; + } + + const res = await fetch(this.config.pollUrl, { headers, credentials: 'same-origin' }); + + const responseEtag = res.headers.get('ETag'); + if (responseEtag) { + this.pollEtag = responseEtag; + } + + if (res.status === 304) { + return; + } + if (!res.ok) { if (res.status === 404 && this.config.endedUrl) { this.redirectToMeetingEnded(this.config.endedUrl); @@ -3253,6 +3314,8 @@ function meetRoom() { this.processReactionsFromPoll(data.reactions || []); } catch (e) { // ignore poll errors + } finally { + this.pollInFlight = false; } },