From 70d556799ecb6b945f32a08e43e02b3b206c3311 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 14:12:41 +0000 Subject: [PATCH] Improve waiting room alerts, audio reliability, and mobile toolbar. Show host-visible waiting badges and enable-audio prompts, harden mic playback with LiveKit attach patterns, and swipe-scroll meeting controls on mobile. Co-authored-by: Cursor --- resources/css/app.css | 62 ++++++++ resources/js/meet-room.js | 186 ++++++++++++++++++----- resources/views/meet/room/show.blade.php | 41 ++++- 3 files changed, 245 insertions(+), 44 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index af0e60b..2807d77 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -28,6 +28,68 @@ margin-left: auto; } +/* Meeting room footer: horizontal carousel on mobile */ +@media (max-width: 639px) { + .meet-toolbar { + position: relative; + overflow: hidden; + } + + .meet-toolbar::before, + .meet-toolbar::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + width: 1.25rem; + pointer-events: none; + } + + .meet-toolbar::before { + left: 0; + background: linear-gradient(to right, rgb(2 6 23), transparent); + } + + .meet-toolbar::after { + right: 0; + background: linear-gradient(to left, rgb(2 6 23), transparent); + } + + .meet-toolbar__track { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 0.5rem; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; + scrollbar-width: none; + padding-inline: 1rem; + padding-bottom: max(0.25rem, env(safe-area-inset-bottom, 0px)); + } + + .meet-toolbar__track::-webkit-scrollbar { + display: none; + } + + .meet-toolbar__track > * { + flex-shrink: 0; + scroll-snap-align: center; + } +} + +@media (min-width: 640px) { + .meet-toolbar__track { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.75rem; + } +} + /* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */ @media (max-width: 1023px) { .mobile-action-bar { diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 37eda69..15b7aee 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -33,6 +33,8 @@ function meetRoom() { muteOnJoin: true, videoOffOnJoin: false, audioPlaybackBlocked: false, + needsMediaUnlock: false, + waitingCount: 0, isWebinar: false, qaItems: [], activePolls: [], @@ -112,6 +114,8 @@ function meetRoom() { this.sessionParticipants = []; } + this.waitingCount = this.sessionParticipants.filter((p) => p.status === 'waiting').length; + try { this.roomHost = JSON.parse(el.dataset.roomHost || 'null'); } catch { @@ -220,6 +224,12 @@ function meetRoom() { } this.syncLocalMediaState(); }); + room.on(RoomEvent.MediaDevicesError, (error) => { + console.error(error); + this.needsMediaUnlock = true; + this.connectionStatus = this.connectionErrorMessage(error); + this.registerAudioUnlockGesture(); + }); room.on(RoomEvent.ParticipantConnected, (participant) => { this.attachExistingTracks(participant); this.syncParticipants(); @@ -298,37 +308,78 @@ function meetRoom() { async enableLocalMedia(activeRoom, attempts = 3) { const wantMic = !this.muteOnJoin || this.isHost; const wantCamera = !this.videoOffOnJoin; + const audioOptions = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }; for (let attempt = 1; attempt <= attempts; attempt += 1) { + let lastError = null; + try { - await activeRoom.localParticipant.setMicrophoneEnabled(wantMic); - await activeRoom.localParticipant.setCameraEnabled(wantCamera); - this.syncLocalMediaState(); - this.updateParticipantTile(activeRoom.localParticipant); + if (wantMic && wantCamera) { + await activeRoom.localParticipant.enableCameraAndMicrophone(); + } else if (wantCamera) { + await activeRoom.localParticipant.setCameraEnabled(true); + } else if (wantMic) { + await activeRoom.localParticipant.setMicrophoneEnabled(true, audioOptions); + } + } catch (error) { + lastError = error; if (wantCamera) { - activeRoom.localParticipant.videoTrackPublications.forEach((pub) => { - if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) { - this.attachTrack(pub.track, pub, activeRoom.localParticipant); - } - }); + try { + await activeRoom.localParticipant.setCameraEnabled(true); + } catch (cameraError) { + lastError = cameraError; + } } - this.connectionStatus = 'Connected'; - return; - } catch (mediaError) { - console.error(mediaError); - const retryable = mediaError?.message?.includes('engine not connected within timeout'); - - if (!retryable || attempt === attempts) { - this.syncLocalMediaState(); - this.updateParticipantTile(activeRoom.localParticipant); - this.connectionStatus = this.connectionErrorMessage(mediaError); - return; + if (wantMic) { + try { + await activeRoom.localParticipant.setMicrophoneEnabled(true, audioOptions); + } catch (micError) { + lastError = micError; + } } - - await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); } + + this.syncLocalMediaState(); + this.updateParticipantTile(activeRoom.localParticipant); + + if (wantCamera) { + activeRoom.localParticipant.videoTrackPublications.forEach((pub) => { + if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) { + this.attachTrack(pub.track, pub, activeRoom.localParticipant); + } + }); + } + + const micMissing = wantMic && !activeRoom.localParticipant.isMicrophoneEnabled; + const cameraMissing = wantCamera && !activeRoom.localParticipant.isCameraEnabled; + + if (micMissing) { + this.needsMediaUnlock = true; + this.connectionStatus = 'Connected — click mic to enable your microphone'; + this.registerAudioUnlockGesture(); + } else if (cameraMissing && lastError) { + this.connectionStatus = this.connectionErrorMessage(lastError); + } else { + this.needsMediaUnlock = false; + this.connectionStatus = 'Connected'; + } + + if (!micMissing && !cameraMissing) { + return; + } + + const retryable = lastError?.message?.includes('engine not connected within timeout'); + if (!retryable || attempt === attempts) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); } }, @@ -356,7 +407,10 @@ function meetRoom() { const unlock = async () => { await this.ensureAudioPlayback(); - if (!this.audioPlaybackBlocked) { + if (this.needsMediaUnlock) { + await this.unlockLocalMicrophone(); + } + if (!this.audioPlaybackBlocked && !this.needsMediaUnlock) { document.removeEventListener('click', unlock, true); document.removeEventListener('keydown', unlock, true); } @@ -366,6 +420,39 @@ function meetRoom() { document.addEventListener('keydown', unlock, true); }, + async unlockAllAudio() { + await this.ensureAudioPlayback(); + if (this.needsMediaUnlock) { + await this.unlockLocalMicrophone(); + } + }, + + async unlockLocalMicrophone() { + if (!room || !this.canPublish || !this.needsMediaUnlock) { + if (room?.localParticipant.isMicrophoneEnabled) { + this.needsMediaUnlock = false; + } + return; + } + + try { + await room.localParticipant.setMicrophoneEnabled(true, { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }); + this.needsMediaUnlock = false; + this.syncLocalMediaState(); + this.updateParticipantTile(room.localParticipant); + if (this.connectionStatus.includes('microphone') || this.connectionStatus.includes('enable audio')) { + this.connectionStatus = 'Connected'; + } + } catch (error) { + console.error(error); + this.needsMediaUnlock = true; + } + }, + syncLocalMediaState() { if (!room) return; @@ -702,7 +789,7 @@ function meetRoom() { if (!isLocal) { participant.audioTrackPublications.forEach((pub) => { - if (pub.track && !pub.isMuted) { + if (pub.track) { this.attachTrack(pub.track, pub, participant); } }); @@ -720,11 +807,18 @@ function meetRoom() { attachTrack(track, publication, participant) { if (track.kind === Track.Kind.Audio) { if (participant === room?.localParticipant) return; - if (publication?.isMuted) return; const element = track.attach(); element.className = 'meet-remote-audio'; - this.getAudioContainer().appendChild(element); + element.style.display = 'none'; + if (!element.parentElement) { + document.body.appendChild(element); + } + + element.play().catch(() => { + this.audioPlaybackBlocked = true; + this.registerAudioUnlockGesture(); + }); this.ensureAudioPlayback(); return; } @@ -753,19 +847,6 @@ function meetRoom() { this.attachVideoToTile(participant, track); }, - getAudioContainer() { - let container = document.getElementById('meet-audio-elements'); - if (!container) { - container = document.createElement('div'); - container.id = 'meet-audio-elements'; - container.className = 'sr-only'; - container.setAttribute('aria-hidden', 'true'); - document.body.appendChild(container); - } - - return container; - }, - syncParticipants() { if (!room) return; @@ -807,8 +888,26 @@ function meetRoom() { async toggleMute() { if (!room) return; await this.ensureAudioPlayback(); + const enabled = room.localParticipant.isMicrophoneEnabled; - await room.localParticipant.setMicrophoneEnabled(!enabled); + if (enabled) { + await room.localParticipant.setMicrophoneEnabled(false); + } else { + try { + await room.localParticipant.setMicrophoneEnabled(true, { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }); + this.needsMediaUnlock = false; + } catch (error) { + console.error(error); + this.connectionStatus = this.connectionErrorMessage(error); + this.needsMediaUnlock = true; + return; + } + } + this.syncLocalMediaState(); this.updateParticipantTile(room.localParticipant); }, @@ -950,6 +1049,13 @@ function meetRoom() { this.sessionParticipants = data.participants; this.pruneTilesFromSession(); } + if (typeof data.waiting_count === 'number') { + const previous = this.waitingCount; + this.waitingCount = data.waiting_count; + if (this.isHost && data.waiting_count > previous) { + this.participantsOpen = true; + } + } this.updateChatFromPoll(data.messages || []); this.qaItems = data.qa || []; this.activePolls = data.polls || []; diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index aed7cc3..17953ed 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -91,8 +91,12 @@ is_locked) style="display: none" @endunless class="rounded bg-amber-600 px-2 py-0.5 font-medium text-white">Locked + + +
+

+ +
+
@@ -152,8 +179,9 @@
-