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,
breakoutModalOpen: false,
breakoutCount: '4',
endMeetingConfirmOpen: 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 = `
`;
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;
this.breakoutCount = '4';
this.breakoutModalOpen = true;
this.featuresOpen = false;
},
async submitBreakouts() {
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
const count = parseInt(this.breakoutCount, 10);
if (!count || count < 1) return;
await fetch(this.config.breakoutsCreateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ count }),
});
this.breakoutModalOpen = false;
},
async closeBreakouts() {
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
this.featuresOpen = false;
},
openUploadPicker() {
document.getElementById('meet-file-input')?.click();
this.featuresOpen = false;
},
async toggleRecordingFromMenu() {
await this.toggleRecording();
this.featuresOpen = false;
},
async toggleLockFromMenu() {
await this.toggleLock();
this.featuresOpen = false;
},
openEndMeetingConfirm() {
this.endMeetingConfirmOpen = true;
},
submitEndMeeting() {
document.getElementById('meet-end-form')?.requestSubmit();
this.endMeetingConfirmOpen = false;
},
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(),
});
},
toggleChat() {
this.chatOpen = !this.chatOpen;
if (this.chatOpen) {
this.featuresOpen = false;
this.shareOpen = false;
}
},
toggleFeatures() {
this.featuresOpen = !this.featuresOpen;
if (this.featuresOpen) {
this.chatOpen = false;
this.shareOpen = false;
}
},
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 isOwn = message.sender_name === this.displayName;
const div = document.createElement('div');
div.className = `meet-chat-bubble${isOwn ? ' meet-chat-bubble--own' : ''}`;
div.dataset.uuid = message.uuid;
const time = message.created_at
? new Date(message.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: '';
div.innerHTML = `
${isOwn ? '' : `
${this.escape(message.sender_name)}
`}
${this.escape(message.body)}
${this.escape(time)}
`;
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();