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 @@
REC
is_locked) style="display: none" @endunless class="rounded bg-amber-600 px-2 py-0.5 font-medium text-white">Locked