Fix meeting audio playback and restyle room detail actions.
Deploy Ladill Meet / deploy (push) Successful in 39s

Unlock LiveKit audio after user gesture, respect join mute/video settings,
and replace plain links with btn-secondary pills on the meeting details page.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 13:38:01 +00:00
co-authored by Cursor
parent 7ae26a467c
commit dc3c6dee2d
4 changed files with 121 additions and 10 deletions
+40
View File
@@ -153,6 +153,46 @@ html.qr-mobile-page body {
padding: 0.625rem 1.25rem;
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 9999px;
border: 1px solid rgb(226 232 240);
background-color: #fff;
padding: 0.5rem 1rem;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 600;
color: rgb(51 65 85);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.btn-secondary:hover {
background-color: rgb(248 250 252);
border-color: rgb(203 213 225);
}
.btn-secondary:disabled {
opacity: 0.6;
pointer-events: none;
}
.btn-secondary-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
line-height: 1rem;
}
.btn-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.btn-fab {
display: inline-flex;
height: 2.75rem;
+71 -2
View File
@@ -30,6 +30,9 @@ function meetRoom() {
captionText: '',
displayName: '',
canPublish: true,
muteOnJoin: true,
videoOffOnJoin: false,
audioPlaybackBlocked: false,
isWebinar: false,
qaItems: [],
activePolls: [],
@@ -95,6 +98,8 @@ function meetRoom() {
this.isHost = el.dataset.isHost === '1';
this.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '1';
this.muteOnJoin = el.dataset.muteOnJoin === '1';
this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1';
this.isRecording = el.dataset.recordingActive === '1';
this.isLocked = el.dataset.locked === '1';
this.watermark = el.dataset.watermark === '1';
@@ -170,6 +175,9 @@ function meetRoom() {
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
this.attachTrack(track, publication, participant);
if (track.kind === Track.Kind.Audio) {
this.ensureAudioPlayback();
}
});
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
track.detach();
@@ -218,6 +226,15 @@ function meetRoom() {
room.on(RoomEvent.Disconnected, () => {
this.connectionStatus = 'Disconnected';
});
room.on(RoomEvent.AudioPlaybackStatusChanged, () => {
this.audioPlaybackBlocked = room ? !room.canPlaybackAudio : false;
if (this.audioPlaybackBlocked) {
this.connectionStatus = 'Connected — click anywhere to enable audio';
this.registerAudioUnlockGesture();
} else if (this.connectionStatus.includes('enable audio')) {
this.connectionStatus = 'Connected';
}
});
await room.prepareConnection(this.config.url, this.config.token);
@@ -240,6 +257,8 @@ function meetRoom() {
this.isVideoOff = true;
}
await this.ensureAudioPlayback();
this.setupCaptionPipeline();
this.updateParticipantTile(room.localParticipant);
@@ -275,12 +294,24 @@ function meetRoom() {
},
async enableLocalMedia(activeRoom, attempts = 3) {
const wantMic = !this.muteOnJoin || this.isHost;
const wantCamera = !this.videoOffOnJoin;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await activeRoom.localParticipant.setMicrophoneEnabled(true);
await activeRoom.localParticipant.setCameraEnabled(false);
await activeRoom.localParticipant.setMicrophoneEnabled(wantMic);
await activeRoom.localParticipant.setCameraEnabled(wantCamera);
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);
}
});
}
this.connectionStatus = 'Connected';
return;
} catch (mediaError) {
@@ -299,6 +330,40 @@ function meetRoom() {
}
},
async ensureAudioPlayback() {
if (!room || room.canPlaybackAudio) {
this.audioPlaybackBlocked = false;
return;
}
try {
await room.startAudio();
this.audioPlaybackBlocked = false;
if (this.connectionStatus.includes('enable audio')) {
this.connectionStatus = 'Connected';
}
} catch (e) {
this.audioPlaybackBlocked = true;
this.registerAudioUnlockGesture();
}
},
registerAudioUnlockGesture() {
if (this._audioUnlockRegistered) return;
this._audioUnlockRegistered = true;
const unlock = async () => {
await this.ensureAudioPlayback();
if (!this.audioPlaybackBlocked) {
document.removeEventListener('click', unlock, true);
document.removeEventListener('keydown', unlock, true);
}
};
document.addEventListener('click', unlock, true);
document.addEventListener('keydown', unlock, true);
},
syncLocalMediaState() {
if (!room) return;
@@ -502,6 +567,8 @@ function meetRoom() {
setupCaptionPipeline() {
if (!this.liveCaptions || !this.config.captionUrl) return;
if (room?.localParticipant?.isMicrophoneEnabled) return;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) return;
@@ -653,6 +720,7 @@ function meetRoom() {
async toggleMute() {
if (!room) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isMicrophoneEnabled;
await room.localParticipant.setMicrophoneEnabled(!enabled);
this.syncLocalMediaState();
@@ -661,6 +729,7 @@ function meetRoom() {
async toggleVideo() {
if (!room) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isCameraEnabled;
await room.localParticipant.setCameraEnabled(!enabled);
this.syncLocalMediaState();
+2
View File
@@ -44,6 +44,8 @@
data-unlock-url="{{ route('meet.room.unlock', $session) }}"
data-caption-url="{{ route('meet.room.caption', $session) }}"
data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}"
data-mute-on-join="{{ $room->setting('mute_on_join', true) ? '1' : '0' }}"
data-video-off-on-join="{{ $room->setting('video_off_on_join', false) ? '1' : '0' }}"
data-watermark="{{ $watermark ? '1' : '0' }}"
data-locked="{{ $session->is_locked ? '1' : '0' }}"
data-recording-active="{{ $activeRecording ? '1' : '0' }}"
+8 -8
View File
@@ -23,19 +23,19 @@
<h2 class="text-sm font-medium text-slate-700">Join link</h2>
<div class="mt-2 flex gap-2">
<input type="text" readonly value="{{ $room->joinUrl() }}" class="flex-1 rounded-lg border-slate-300 bg-slate-50 text-sm font-mono">
<button type="button" onclick="navigator.clipboard.writeText('{{ $room->joinUrl() }}')" class="rounded-lg border border-slate-300 px-3 py-2 text-sm hover:bg-slate-50">Copy</button>
<button type="button" onclick="navigator.clipboard.writeText('{{ $room->joinUrl() }}')" class="btn-secondary btn-secondary-sm shrink-0">Copy</button>
</div>
<div class="mt-4 flex flex-wrap gap-3 text-sm">
<a href="{{ route('meet.rooms.ical', $room) }}" class="text-indigo-600 hover:underline">Download iCal</a>
<a href="{{ route('meet.rooms.qr', $room) }}" target="_blank" class="text-indigo-600 hover:underline">QR code</a>
<div class="btn-group mt-4">
<a href="{{ route('meet.rooms.ical', $room) }}" class="btn-secondary btn-secondary-sm">Download iCal</a>
<a href="{{ route('meet.rooms.qr', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">QR code</a>
@if ($room->sessions->isNotEmpty())
<a href="{{ route('meet.rooms.attendance', $room) }}" class="text-indigo-600 hover:underline">Attendance CSV</a>
<a href="{{ route('meet.rooms.attendance', $room) }}" class="btn-secondary btn-secondary-sm">Attendance CSV</a>
@endif
<a href="{{ route('meet.invitations.index', $room) }}" class="text-indigo-600 hover:underline">Manage invitations</a>
<a href="{{ route('meet.invitations.index', $room) }}" class="btn-secondary btn-secondary-sm">Manage invitations</a>
@if ($room->isWebinar())
<a href="{{ route('meet.webinar.registrations', $room) }}" class="text-indigo-600 hover:underline">Registrations</a>
<a href="{{ route('meet.webinar.register', $room) }}" target="_blank" class="text-indigo-600 hover:underline">Registration page</a>
<a href="{{ route('meet.webinar.registrations', $room) }}" class="btn-secondary btn-secondary-sm">Registrations</a>
<a href="{{ route('meet.webinar.register', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">Registration page</a>
@endif
</div>