Improve waiting room alerts, audio reliability, and mobile toolbar.
Deploy Ladill Meet / deploy (push) Successful in 52s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 14:12:41 +00:00
co-authored by Cursor
parent 5e85a4eaa1
commit 70d556799e
3 changed files with 245 additions and 44 deletions
+62
View File
@@ -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 {
+146 -40
View File
@@ -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 || [];
+37 -4
View File
@@ -91,8 +91,12 @@
<span x-show="isRecording" @unless($activeRecording) style="display: none" @endunless class="rounded bg-red-600 px-2 py-0.5 font-medium text-white">REC</span>
<span x-show="isLocked" @unless($session->is_locked) style="display: none" @endunless class="rounded bg-amber-600 px-2 py-0.5 font-medium text-white">Locked</span>
<button @click="toggleParticipants()" type="button"
class="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
class="relative inline-flex items-center gap-2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
:class="isHost && waitingCount > 0 ? 'ring-2 ring-amber-400/60' : ''"
title="View participants">
<span x-show="isHost && waitingCount > 0"
x-text="waitingCount > 9 ? '9+' : waitingCount"
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-amber-950"></span>
<span x-show="hostParticipant()" class="relative flex h-7 w-7 shrink-0">
<img x-show="hostParticipant()?.avatar_url"
:src="hostParticipant()?.avatar_url"
@@ -108,6 +112,29 @@
</div>
</header>
<div x-show="isHost && waitingCount > 0" x-cloak
class="flex shrink-0 items-center justify-between gap-3 border-b border-amber-500/30 bg-amber-500/15 px-4 py-2.5">
<div class="flex min-w-0 items-center gap-2 text-sm text-amber-100">
<span class="flex h-2 w-2 shrink-0 animate-pulse rounded-full bg-amber-400"></span>
<span class="truncate"
x-text="waitingCount === 1 ? '1 person is waiting to be admitted' : waitingCount + ' people are waiting to be admitted'"></span>
</div>
<button @click="participantsOpen = true" type="button"
class="shrink-0 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-amber-950 transition-colors hover:bg-amber-400">
Review
</button>
</div>
<div x-show="audioPlaybackBlocked || needsMediaUnlock" x-cloak
class="flex shrink-0 items-center justify-between gap-3 border-b border-indigo-500/30 bg-indigo-500/15 px-4 py-2.5">
<p class="text-sm text-indigo-100"
x-text="needsMediaUnlock ? 'Microphone blocked — click to allow speaking and hearing others.' : 'Audio playback blocked — click to hear other participants.'"></p>
<button @click="unlockAllAudio()" type="button"
class="shrink-0 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-indigo-400">
Enable audio
</button>
</div>
<div x-show="shareOpen" x-cloak
@click.outside="if (! $event.target.closest('[data-share-trigger]')) shareOpen = false"
class="absolute right-4 top-14 z-50 w-80 rounded-xl bg-slate-900 p-4 shadow-2xl">
@@ -152,8 +179,9 @@
</div>
</main>
<footer class="relative shrink-0 bg-slate-950 px-4 py-4">
<div class="mx-auto flex max-w-5xl flex-wrap items-center justify-center gap-2 sm:gap-3">
<footer class="relative shrink-0 bg-slate-950 py-4 sm:px-4">
<div class="meet-toolbar mx-auto max-w-5xl">
<div class="meet-toolbar__track sm:px-0">
<button @click="toggleMute()" type="button"
class="rounded-full p-3 transition-colors"
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
@@ -208,6 +236,7 @@
@csrf
</form>
@endif
</div>
</div>
<div x-show="featuresOpen" x-cloak @click.outside="featuresOpen = false"
@@ -389,7 +418,11 @@
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto rounded-2xl bg-slate-900/90 p-2">
<template x-if="isHost && waitingParticipants().length">
<div class="mb-3 border-b border-slate-800 pb-3">
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-amber-300">Waiting to join</p>
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-amber-300">
Waiting to join
<span class="ml-1 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-200"
x-text="waitingParticipants().length"></span>
</p>
<template x-for="person in waitingParticipants()" :key="'wait-'+person.uuid">
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-sm font-semibold text-amber-200"