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 = `
`; 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 = `${this.escape(message.sender_name)}

${this.escape(message.body)}

`; 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();