Files
ladill-meet/resources/js/meet-room.js
T
isaaccladandCursor eed5853c2f
Deploy Ladill Meet / deploy (push) Successful in 1m16s
Add share meeting link control to the live room UI.
Expose join URL and passcode in the room shell with header and toolbar share buttons, native share on supported devices, and a copy-link popover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 08:00:22 +00:00

672 lines
27 KiB
JavaScript

import Alpine from 'alpinejs';
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 {
isMuted: true,
isVideoOff: true,
isScreenSharing: false,
isRecording: false,
isLocked: false,
isHost: false,
watermark: false,
liveCaptions: false,
captionText: '',
displayName: '',
canPublish: true,
isWebinar: false,
qaItems: [],
activePolls: [],
breakoutBroadcast: '',
featuresOpen: false,
whiteboardOpen: false,
qaInput: '',
chatOpen: false,
chatInput: '',
shareOpen: false,
shareCopied: false,
joinUrl: '',
passcode: '',
roomTitle: '',
connectionStatus: 'Connecting…',
participants: [],
floatingReactions: [],
pollTimer: null,
config: {},
init() {
if (initialized) return;
initialized = true;
const el = document.getElementById('meet-config');
if (!el) return;
this.config = {
token: el.dataset.token,
url: el.dataset.url,
configured: el.dataset.configured === '1',
pollUrl: el.dataset.pollUrl,
chatUrl: el.dataset.chatUrl,
reactionUrl: el.dataset.reactionUrl,
raiseHandUrl: el.dataset.raiseHandUrl,
recordingStartUrl: el.dataset.recordingStartUrl,
recordingStopUrl: el.dataset.recordingStopUrl,
lockUrl: el.dataset.lockUrl,
unlockUrl: el.dataset.unlockUrl,
captionUrl: el.dataset.captionUrl,
qaUrl: el.dataset.qaUrl,
pollsCreateUrl: el.dataset.pollsCreateUrl,
breakoutsCreateUrl: el.dataset.breakoutsCreateUrl,
breakoutsCloseUrl: el.dataset.breakoutsCloseUrl,
breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl,
filesUploadUrl: el.dataset.filesUploadUrl,
whiteboardUrl: el.dataset.whiteboardUrl,
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
csrf: el.dataset.csrf,
};
this.joinUrl = el.dataset.joinUrl || '';
this.passcode = el.dataset.passcode || '';
this.roomTitle = el.dataset.roomTitle || '';
this.isHost = el.dataset.isHost === '1';
this.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '1';
this.isRecording = el.dataset.recordingActive === '1';
this.isLocked = el.dataset.locked === '1';
this.watermark = el.dataset.watermark === '1';
this.liveCaptions = el.dataset.liveCaptions === '1';
this.displayName = el.dataset.displayName || '';
if (this.config.configured && this.config.token) {
this.connectLiveKit();
} else {
this.connectionStatus = 'Video unavailable — configure LiveKit';
}
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 {
await connectPromise;
} catch (e) {
connectPromise = null;
throw e;
}
},
async startLiveKitSession() {
try {
room = new Room({
adaptiveStream: true,
dynacast: true,
disconnectOnPageLeave: true,
});
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);
}
});
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 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.enableLocalMedia(room);
} else {
this.connectionStatus = 'View-only audience mode';
this.isMuted = true;
this.isVideoOff = true;
}
this.setupCaptionPipeline();
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 = 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;
if (!SpeechRecognition) return;
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.onresult = (event) => {
const text = event.results[event.results.length - 1][0].transcript.trim();
if (!text) return;
this.captionText = text;
fetch(this.config.captionUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ text }),
}).catch(() => {});
};
recognition.onerror = () => {};
try {
recognition.start();
} catch (e) {
// browser may block without gesture
}
},
async toggleRecording() {
if (!this.isHost) return;
const url = this.isRecording ? this.config.recordingStopUrl : this.config.recordingStartUrl;
const res = await fetch(url, { method: 'POST', headers: this.headers() });
if (res.ok) {
this.isRecording = !this.isRecording;
}
},
async toggleLock() {
if (!this.isHost) return;
const url = this.isLocked ? this.config.unlockUrl : this.config.lockUrl;
const res = await fetch(url, { method: 'POST', headers: this.headers() });
if (res.ok) {
this.isLocked = !this.isLocked;
}
},
async submitQuestion() {
const question = this.qaInput.trim();
if (!question || !this.config.qaUrl) return;
this.qaInput = '';
await fetch(this.config.qaUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ question }),
});
},
async createBreakouts() {
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
const count = parseInt(prompt('Number of breakout rooms?', '4') || '0', 10);
if (!count) return;
await fetch(this.config.breakoutsCreateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ count }),
});
},
async closeBreakouts() {
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
},
async uploadFile(event) {
const file = event.target.files?.[0];
if (!file || !this.config.filesUploadUrl) return;
const form = new FormData();
form.append('file', file);
await fetch(this.config.filesUploadUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' },
body: form,
});
},
attachTrack(track, publication, participant) {
if (track.kind !== Track.Kind.Video) return;
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 = '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 (!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 (!room) return;
const enabled = room.localParticipant.isMicrophoneEnabled;
await room.localParticipant.setMicrophoneEnabled(!enabled);
this.syncLocalMediaState();
this.updateParticipantTile(room.localParticipant);
},
async toggleVideo() {
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 (!room) return;
this.isScreenSharing = !this.isScreenSharing;
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!this.isScreenSharing) {
const screen = document.getElementById('screen-share');
if (screen) {
screen.classList.add('hidden');
screen.innerHTML = '';
}
}
},
async raiseHand() {
await fetch(this.config.raiseHandUrl, {
method: 'POST',
headers: this.headers(),
});
},
async openShare() {
if (navigator.share && this.joinUrl) {
try {
await navigator.share({
title: this.roomTitle || 'Ladill Meet',
text: 'Join my meeting on Ladill Meet',
url: this.joinUrl,
});
return;
} catch (e) {
if (e?.name === 'AbortError') return;
}
}
this.shareOpen = true;
this.shareCopied = false;
},
async copyJoinLink() {
if (!this.joinUrl) return;
try {
await navigator.clipboard.writeText(this.joinUrl);
this.shareCopied = true;
setTimeout(() => {
this.shareCopied = false;
}, 2000);
} catch (e) {
console.error(e);
}
},
async sendChat() {
const body = this.chatInput.trim();
if (!body) return;
this.chatInput = '';
const res = await fetch(this.config.chatUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ body }),
});
const data = await res.json();
if (data.message) this.appendChatMessage(data.message);
},
async sendReaction(kind) {
const emoji = kind === 'heart' ? '❤️' : '👍';
await fetch(this.config.reactionUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ emoji }),
});
this.showFloatingReaction(kind);
},
async poll() {
try {
const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } });
const data = await res.json();
this.updateChatFromPoll(data.messages || []);
this.qaItems = data.qa || [];
this.activePolls = data.polls || [];
if (data.broadcasts?.length) {
this.breakoutBroadcast = data.broadcasts[0].body;
}
} catch (e) {
// ignore poll errors
}
},
updateChatFromPoll(messages) {
const container = document.getElementById('chat-messages');
if (!container) return;
const existing = new Set([...container.querySelectorAll('[data-uuid]')].map((el) => el.dataset.uuid));
messages.forEach((m) => {
if (!existing.has(m.uuid)) this.appendChatMessage(m);
});
},
appendChatMessage(message) {
const container = document.getElementById('chat-messages');
if (!container) return;
const div = document.createElement('div');
div.className = 'text-sm';
div.dataset.uuid = message.uuid;
div.innerHTML = `<span class="font-medium text-sky-400">${this.escape(message.sender_name)}</span>
<p class="mt-0.5 text-slate-200">${this.escape(message.body)}</p>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
},
showFloatingReaction(kind) {
const id = Date.now();
this.floatingReactions.push({ id, kind });
setTimeout(() => {
this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id);
}, 2000);
},
headers() {
return {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': this.config.csrf,
'X-Requested-With': 'XMLHttpRequest',
};
},
escape(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
};
}
window.meetRoom = meetRoom;
Alpine.data('meetRoom', meetRoom);
Alpine.start();