Improve waiting room alerts, audio reliability, and mobile toolbar.
Deploy Ladill Meet / deploy (push) Successful in 52s
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:
@@ -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
@@ -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 || [];
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user