Fix LiveKit connect flow and polish meeting room media UI.
Deploy Ladill Meet / deploy (push) Successful in 1m2s
Deploy Ladill Meet / deploy (push) Successful in 1m2s
Use the official LiveKit PHP SDK for tokens, harden client connect/publish timing, keep the Room outside Alpine reactivity, and replace emoji toolbar controls with synced mic/camera toggles and avatar tiles for audio-only participants. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+333
-57
@@ -1,11 +1,16 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { Room, RoomEvent, Track } from 'livekit-client';
|
||||
import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client';
|
||||
|
||||
function meetRoom() {
|
||||
// LiveKit Room must stay outside Alpine's reactive proxy — wrapping it breaks
|
||||
// structuredClone inside the SDK during connect and media setup.
|
||||
let room = null;
|
||||
let connectPromise = null;
|
||||
let initialized = false;
|
||||
|
||||
return {
|
||||
room: null,
|
||||
isMuted: false,
|
||||
isVideoOff: false,
|
||||
isMuted: true,
|
||||
isVideoOff: true,
|
||||
isScreenSharing: false,
|
||||
isRecording: false,
|
||||
isLocked: false,
|
||||
@@ -31,6 +36,9 @@ function meetRoom() {
|
||||
config: {},
|
||||
|
||||
init() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const el = document.getElementById('meet-config');
|
||||
if (!el) return;
|
||||
|
||||
@@ -76,43 +84,302 @@ function meetRoom() {
|
||||
this.pollTimer = setInterval(() => this.poll(), 3000);
|
||||
},
|
||||
|
||||
connectionErrorMessage(error) {
|
||||
const name = error?.name || '';
|
||||
const message = error?.message || '';
|
||||
|
||||
if (name === 'NotAllowedError' || message.includes('Permission denied')) {
|
||||
return 'Connected — allow camera and microphone in your browser';
|
||||
}
|
||||
if (name === 'NotFoundError' || message.includes('Requested device not found')) {
|
||||
return 'Connected — no camera or microphone found';
|
||||
}
|
||||
if (message.includes('401') || message.toLowerCase().includes('unauthorized')) {
|
||||
return 'LiveKit rejected the session token';
|
||||
}
|
||||
if (message.includes('engine not connected within timeout')) {
|
||||
return 'In call — camera/mic timed out. Use the mic/camera buttons to retry.';
|
||||
}
|
||||
if (message) {
|
||||
return `Connection failed — ${message}`;
|
||||
}
|
||||
|
||||
return 'Connection failed';
|
||||
},
|
||||
|
||||
async connectLiveKit() {
|
||||
if (connectPromise) {
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
connectPromise = this.startLiveKitSession();
|
||||
|
||||
try {
|
||||
this.room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
this.attachTrack(track, participant);
|
||||
await connectPromise;
|
||||
} catch (e) {
|
||||
connectPromise = null;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async startLiveKitSession() {
|
||||
try {
|
||||
room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
disconnectOnPageLeave: true,
|
||||
});
|
||||
this.room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
|
||||
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
this.attachTrack(track, publication, participant);
|
||||
});
|
||||
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||
track.detach();
|
||||
if (track.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
|
||||
this.updateParticipantTile(participant);
|
||||
}
|
||||
});
|
||||
this.room.on(RoomEvent.ParticipantConnected, () => this.syncParticipants());
|
||||
this.room.on(RoomEvent.ParticipantDisconnected, () => this.syncParticipants());
|
||||
this.room.on(RoomEvent.Disconnected, () => {
|
||||
room.on(RoomEvent.TrackMuted, (publication, participant) => {
|
||||
if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
|
||||
publication.track?.detach();
|
||||
this.updateParticipantTile(participant);
|
||||
}
|
||||
if (publication.kind === Track.Kind.Audio) {
|
||||
this.updateParticipantTile(participant);
|
||||
}
|
||||
if (participant === room.localParticipant) {
|
||||
this.syncLocalMediaState();
|
||||
}
|
||||
});
|
||||
room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
|
||||
if (publication.kind === Track.Kind.Video && publication.track) {
|
||||
this.attachTrack(publication.track, publication, participant);
|
||||
}
|
||||
if (publication.kind === Track.Kind.Audio) {
|
||||
this.updateParticipantTile(participant);
|
||||
}
|
||||
if (participant === room.localParticipant) {
|
||||
this.syncLocalMediaState();
|
||||
}
|
||||
});
|
||||
room.on(RoomEvent.LocalTrackPublished, (publication, participant) => {
|
||||
if (publication.track?.kind === Track.Kind.Video) {
|
||||
this.attachTrack(publication.track, publication, participant);
|
||||
}
|
||||
this.updateParticipantTile(participant);
|
||||
this.syncLocalMediaState();
|
||||
});
|
||||
room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
|
||||
if (publication.kind === Track.Kind.Video) {
|
||||
this.updateParticipantTile(participant);
|
||||
}
|
||||
this.syncLocalMediaState();
|
||||
});
|
||||
room.on(RoomEvent.ParticipantConnected, () => this.syncParticipants());
|
||||
room.on(RoomEvent.ParticipantDisconnected, () => this.syncParticipants());
|
||||
room.on(RoomEvent.Disconnected, () => {
|
||||
this.connectionStatus = 'Disconnected';
|
||||
});
|
||||
|
||||
await this.room.connect(this.config.url, this.config.token);
|
||||
await room.prepareConnection(this.config.url, this.config.token);
|
||||
|
||||
await room.connect(this.config.url, this.config.token, {
|
||||
autoSubscribe: true,
|
||||
peerConnectionTimeout: 20_000,
|
||||
websocketTimeout: 20_000,
|
||||
});
|
||||
|
||||
await this.waitForRoomConnected(room);
|
||||
|
||||
this.connectionStatus = 'Connected';
|
||||
this.syncParticipants();
|
||||
|
||||
if (this.canPublish) {
|
||||
await this.room.localParticipant.enableCameraAndMicrophone();
|
||||
this.isMuted = false;
|
||||
this.isVideoOff = false;
|
||||
await this.enableLocalMedia(room);
|
||||
} else {
|
||||
this.connectionStatus = 'View-only audience mode';
|
||||
this.isMuted = true;
|
||||
this.isVideoOff = true;
|
||||
}
|
||||
this.connectionStatus = 'Connected';
|
||||
this.syncParticipants();
|
||||
|
||||
this.setupCaptionPipeline();
|
||||
this.room.localParticipant.videoTrackPublications.forEach((pub) => {
|
||||
if (pub.track) this.attachTrack(pub.track, this.room.localParticipant);
|
||||
|
||||
this.updateParticipantTile(room.localParticipant);
|
||||
room.localParticipant.videoTrackPublications.forEach((pub) => {
|
||||
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
|
||||
this.attachTrack(pub.track, pub, room.localParticipant);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.connectionStatus = 'Connection failed';
|
||||
this.connectionStatus = this.connectionErrorMessage(e);
|
||||
}
|
||||
},
|
||||
|
||||
waitForRoomConnected(activeRoom) {
|
||||
if (activeRoom.state === ConnectionState.Connected) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
activeRoom.off(RoomEvent.Connected, onConnected);
|
||||
reject(new Error('Timed out waiting for LiveKit room connection'));
|
||||
}, 20_000);
|
||||
|
||||
const onConnected = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
|
||||
activeRoom.once(RoomEvent.Connected, onConnected);
|
||||
});
|
||||
},
|
||||
|
||||
async enableLocalMedia(activeRoom, attempts = 3) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
await activeRoom.localParticipant.setMicrophoneEnabled(true);
|
||||
await activeRoom.localParticipant.setCameraEnabled(false);
|
||||
this.syncLocalMediaState();
|
||||
this.updateParticipantTile(activeRoom.localParticipant);
|
||||
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;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
syncLocalMediaState() {
|
||||
if (!room) return;
|
||||
|
||||
const local = room.localParticipant;
|
||||
this.isMuted = !local.isMicrophoneEnabled;
|
||||
this.isVideoOff = !local.isCameraEnabled;
|
||||
},
|
||||
|
||||
participantInitials(name) {
|
||||
const parts = (name || '?').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
|
||||
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||
},
|
||||
|
||||
avatarHue(name) {
|
||||
let hash = 0;
|
||||
const value = name || 'guest';
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = value.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return Math.abs(hash) % 360;
|
||||
},
|
||||
|
||||
getOrCreateParticipantTile(participant) {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid) return null;
|
||||
|
||||
const identity = participant.identity;
|
||||
let tile = grid.querySelector(`[data-participant-tile="${identity}"]`);
|
||||
|
||||
if (!tile) {
|
||||
tile = document.createElement('div');
|
||||
tile.dataset.participantTile = identity;
|
||||
tile.className = 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900 ring-1 ring-white/10';
|
||||
tile.innerHTML = `
|
||||
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
|
||||
<div data-tile-avatar class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
|
||||
<div data-tile-avatar-circle class="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg ring-2 ring-white/10"></div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-2 pt-8">
|
||||
<span data-tile-name class="truncate text-xs font-medium text-white"></span>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<span data-tile-mic class="hidden rounded-full bg-black/50 p-1 text-red-400" title="Muted">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
|
||||
</span>
|
||||
<span data-tile-cam class="hidden rounded-full bg-black/50 p-1 text-slate-300" title="Camera off">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
|
||||
const displayName = participant.name || participant.identity;
|
||||
const hue = this.avatarHue(displayName);
|
||||
const circle = tile.querySelector('[data-tile-avatar-circle]');
|
||||
circle.style.background = `linear-gradient(135deg, hsl(${hue} 45% 42%), hsl(${(hue + 40) % 360} 50% 28%))`;
|
||||
circle.textContent = this.participantInitials(displayName);
|
||||
tile.querySelector('[data-tile-name]').textContent = displayName;
|
||||
|
||||
return tile;
|
||||
},
|
||||
|
||||
isParticipantVideoActive(participant) {
|
||||
if (!participant.isCameraEnabled) return false;
|
||||
|
||||
const publication = Array.from(participant.videoTrackPublications.values())
|
||||
.find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted);
|
||||
|
||||
return Boolean(publication?.track);
|
||||
},
|
||||
|
||||
isParticipantMuted(participant) {
|
||||
return !participant.isMicrophoneEnabled;
|
||||
},
|
||||
|
||||
updateParticipantTile(participant) {
|
||||
const tile = this.getOrCreateParticipantTile(participant);
|
||||
if (!tile) return;
|
||||
|
||||
const stage = tile.querySelector('[data-tile-stage]');
|
||||
const avatar = tile.querySelector('[data-tile-avatar]');
|
||||
const micBadge = tile.querySelector('[data-tile-mic]');
|
||||
const camBadge = tile.querySelector('[data-tile-cam]');
|
||||
const hasVideo = this.isParticipantVideoActive(participant);
|
||||
|
||||
micBadge.classList.toggle('hidden', !this.isParticipantMuted(participant));
|
||||
camBadge.classList.toggle('hidden', hasVideo);
|
||||
|
||||
if (hasVideo) {
|
||||
avatar.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
avatar.classList.remove('hidden');
|
||||
stage.innerHTML = '';
|
||||
},
|
||||
|
||||
attachVideoToTile(participant, track) {
|
||||
const tile = this.getOrCreateParticipantTile(participant);
|
||||
if (!tile) return;
|
||||
|
||||
const stage = tile.querySelector('[data-tile-stage]');
|
||||
const avatar = tile.querySelector('[data-tile-avatar]');
|
||||
const camBadge = tile.querySelector('[data-tile-cam]');
|
||||
|
||||
avatar.classList.add('hidden');
|
||||
camBadge.classList.add('hidden');
|
||||
stage.innerHTML = '';
|
||||
|
||||
const el = track.attach();
|
||||
el.className = 'h-full w-full object-cover';
|
||||
el.dataset.participant = participant.identity;
|
||||
stage.appendChild(el);
|
||||
},
|
||||
|
||||
setupCaptionPipeline() {
|
||||
if (!this.liveCaptions || !this.config.captionUrl) return;
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@@ -196,60 +463,68 @@ function meetRoom() {
|
||||
});
|
||||
},
|
||||
|
||||
attachTrack(track, participant) {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid || track.kind !== Track.Kind.Video) return;
|
||||
attachTrack(track, publication, participant) {
|
||||
if (track.kind !== Track.Kind.Video) return;
|
||||
|
||||
const isScreen = track.source === Track.Source.ScreenShare;
|
||||
const container = isScreen ? document.getElementById('screen-share') : grid;
|
||||
const isScreen = publication?.source === Track.Source.ScreenShare
|
||||
|| track.source === Track.Source.ScreenShare;
|
||||
|
||||
if (isScreen) {
|
||||
const container = document.getElementById('screen-share');
|
||||
if (!container) return;
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
const el = track.attach();
|
||||
el.className = isScreen
|
||||
? 'h-full w-full rounded-xl object-contain'
|
||||
: 'aspect-video w-full rounded-xl bg-slate-800 object-cover';
|
||||
el.dataset.participant = participant.identity;
|
||||
|
||||
if (!isScreen) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'relative overflow-hidden rounded-xl bg-slate-800';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'absolute bottom-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs';
|
||||
label.textContent = participant.name || participant.identity;
|
||||
wrapper.appendChild(el);
|
||||
wrapper.appendChild(label);
|
||||
container.appendChild(wrapper);
|
||||
} else {
|
||||
const el = track.attach();
|
||||
el.className = 'h-full w-full rounded-xl object-contain';
|
||||
container.appendChild(el);
|
||||
return;
|
||||
}
|
||||
|
||||
if (publication?.isMuted) {
|
||||
this.updateParticipantTile(participant);
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachVideoToTile(participant, track);
|
||||
},
|
||||
|
||||
syncParticipants() {
|
||||
if (!this.room) return;
|
||||
const all = [this.room.localParticipant, ...this.room.remoteParticipants.values()];
|
||||
if (!room) return;
|
||||
const all = [room.localParticipant, ...room.remoteParticipants.values()];
|
||||
this.participants = all.map((p) => ({ identity: p.identity, name: p.name }));
|
||||
all.forEach((participant) => this.updateParticipantTile(participant));
|
||||
},
|
||||
|
||||
async toggleMute() {
|
||||
if (!this.room) return;
|
||||
this.isMuted = !this.isMuted;
|
||||
await this.room.localParticipant.setMicrophoneEnabled(!this.isMuted);
|
||||
if (!room) return;
|
||||
const enabled = room.localParticipant.isMicrophoneEnabled;
|
||||
await room.localParticipant.setMicrophoneEnabled(!enabled);
|
||||
this.syncLocalMediaState();
|
||||
this.updateParticipantTile(room.localParticipant);
|
||||
},
|
||||
|
||||
async toggleVideo() {
|
||||
if (!this.room) return;
|
||||
this.isVideoOff = !this.isVideoOff;
|
||||
await this.room.localParticipant.setCameraEnabled(!this.isVideoOff);
|
||||
if (!room) return;
|
||||
const enabled = room.localParticipant.isCameraEnabled;
|
||||
await room.localParticipant.setCameraEnabled(!enabled);
|
||||
this.syncLocalMediaState();
|
||||
|
||||
const local = room.localParticipant;
|
||||
const videoPub = Array.from(local.videoTrackPublications.values())
|
||||
.find((pub) => pub.source !== Track.Source.ScreenShare);
|
||||
|
||||
if (!enabled && videoPub?.track) {
|
||||
this.attachTrack(videoPub.track, videoPub, local);
|
||||
} else {
|
||||
videoPub?.track?.detach();
|
||||
this.updateParticipantTile(local);
|
||||
}
|
||||
},
|
||||
|
||||
async toggleScreenShare() {
|
||||
if (!this.room) return;
|
||||
if (!room) return;
|
||||
this.isScreenSharing = !this.isScreenSharing;
|
||||
await this.room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
||||
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
||||
if (!this.isScreenSharing) {
|
||||
const screen = document.getElementById('screen-share');
|
||||
if (screen) {
|
||||
@@ -279,13 +554,14 @@ function meetRoom() {
|
||||
if (data.message) this.appendChatMessage(data.message);
|
||||
},
|
||||
|
||||
async sendReaction(emoji) {
|
||||
async sendReaction(kind) {
|
||||
const emoji = kind === 'heart' ? '❤️' : '👍';
|
||||
await fetch(this.config.reactionUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
this.showFloatingReaction(emoji);
|
||||
this.showFloatingReaction(kind);
|
||||
},
|
||||
|
||||
async poll() {
|
||||
@@ -324,9 +600,9 @@ function meetRoom() {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
},
|
||||
|
||||
showFloatingReaction(emoji) {
|
||||
showFloatingReaction(kind) {
|
||||
const id = Date.now();
|
||||
this.floatingReactions.push({ id, emoji });
|
||||
this.floatingReactions.push({ id, kind });
|
||||
setTimeout(() => {
|
||||
this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id);
|
||||
}, 2000);
|
||||
|
||||
Reference in New Issue
Block a user