import Alpine from 'alpinejs'; import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client'; function readMeetBootState() { const el = document.getElementById('meet-config'); return { isRecording: el?.dataset.recordingActive === '1', isLocked: el?.dataset.locked === '1', }; } function meetRoom() { const boot = readMeetBootState(); // 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; let recordingMediaRecorder = null; let recordingChunks = []; return { isMuted: true, isVideoOff: true, isScreenSharing: false, isRecording: boot.isRecording, isLocked: boot.isLocked, isHost: false, watermark: false, liveCaptions: false, captionText: '', displayName: '', canPublish: true, muteOnJoin: true, videoOffOnJoin: false, audioPlaybackBlocked: false, needsMediaUnlock: false, waitingCount: 0, isWebinar: false, isConference: false, isGreenRoom: false, goingLive: false, presenterRefs: [], audioOnly: false, isSpace: false, usesStageLayout: false, panelDiscussions: false, stageLayout: 'plenary', panels: [], activePanelId: '', spotlightSpeakerRef: '', panelSetupOpen: false, savingStageLayout: false, qaItems: [], activePolls: [], breakoutBroadcast: '', featuresOpen: false, whiteboardOpen: false, qaInput: '', chatOpen: false, participantsOpen: false, chatInput: '', shareOpen: false, shareCopied: false, breakoutModalOpen: false, breakoutCount: '4', endMeetingConfirmOpen: false, activeRecordingUuid: null, joinUrl: '', passcode: '', roomTitle: '', connectionStatus: 'Connecting…', participants: [], sessionParticipants: [], activeSpeakerIds: [], roomHost: null, 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, endedUrl: el.dataset.endedUrl, 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, goLiveUrl: el.dataset.goLiveUrl, addPresenterUrl: el.dataset.addPresenterUrl, stageUpdateUrl: el.dataset.stageUpdateUrl, csrf: el.dataset.csrf, sessionUuid: el.dataset.sessionUuid, }; 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.isConference = el.dataset.isConference === '1'; this.isGreenRoom = el.dataset.isGreenRoom === '1'; this.audioOnly = el.dataset.audioOnly === '1'; this.isSpace = el.dataset.isSpace === '1'; this.muteOnJoin = el.dataset.muteOnJoin === '1'; this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly; 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 || ''; try { this.presenterRefs = JSON.parse(el.dataset.presenterRefs || '[]'); } catch { this.presenterRefs = []; } this.usesStageLayout = el.dataset.usesStageLayout === '1'; this.panelDiscussions = el.dataset.panelDiscussions === '1'; this.stageLayout = el.dataset.stageLayout || 'plenary'; this.activePanelId = el.dataset.activePanelId || ''; this.spotlightSpeakerRef = el.dataset.spotlightSpeakerRef || ''; try { this.panels = JSON.parse(el.dataset.panels || '[]'); } catch { this.panels = []; } if (!this.panelDiscussions && this.stageLayout === 'panel') { this.stageLayout = 'plenary'; } try { this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]'); } catch { this.sessionParticipants = []; } this.waitingCount = this.sessionParticipants.filter((p) => p.status === 'waiting').length; try { this.roomHost = JSON.parse(el.dataset.roomHost || 'null'); } catch { this.roomHost = null; } if (this.config.configured && this.config.token) { this.connectLiveKit(); } else { this.connectionStatus = 'Video unavailable — configure LiveKit'; } this.pollTimer = setInterval(() => this.poll(), 3000); this.applyStageLayout(); }, 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) { publication.track?.detach(); 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 && publication.track) { this.attachTrack(publication.track, publication, participant); 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.MediaDevicesError, (error) => { console.error(error); this.needsMediaUnlock = true; this.connectionStatus = this.connectionErrorMessage(error); this.registerAudioUnlockGesture(); }); room.on(RoomEvent.ParticipantConnected, (participant) => { this.attachExistingTracks(participant); this.syncParticipants(); }); room.on(RoomEvent.ParticipantDisconnected, (participant) => { this.removeParticipantTile(participant.identity); this.syncParticipants(); }); 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'; } }); room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => { this.syncActiveSpeakers(speakers); }); 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 = this.audioOnly ? 'Listening' : 'View-only audience mode'; this.isMuted = true; this.isVideoOff = true; } await this.ensureAudioPlayback(); this.setupCaptionPipeline(); this.attachExistingTracks(room.localParticipant); room.remoteParticipants.forEach((participant) => this.attachExistingTracks(participant)); this.syncParticipants(); } 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) { const wantMic = !this.muteOnJoin || this.isHost; const wantCamera = !this.videoOffOnJoin && !this.audioOnly; const audioOptions = { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }; for (let attempt = 1; attempt <= attempts; attempt += 1) { let lastError = null; try { if (wantMic && wantCamera) { await activeRoom.localParticipant.enableCameraAndMicrophone(); } else if (wantCamera) { await activeRoom.localParticipant.setCameraEnabled(true); } else if (wantMic) { await activeRoom.localParticipant.setMicrophoneEnabled(true, audioOptions); } } catch (error) { lastError = error; if (wantCamera) { try { await activeRoom.localParticipant.setCameraEnabled(true); } catch (cameraError) { lastError = cameraError; } } if (wantMic) { try { await activeRoom.localParticipant.setMicrophoneEnabled(true, audioOptions); } catch (micError) { lastError = micError; } } } 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); } }); } const micMissing = wantMic && !activeRoom.localParticipant.isMicrophoneEnabled; const cameraMissing = wantCamera && !activeRoom.localParticipant.isCameraEnabled; if (micMissing) { this.needsMediaUnlock = true; this.connectionStatus = 'Connected — click mic to enable your microphone'; this.registerAudioUnlockGesture(); } else if (cameraMissing && lastError) { this.connectionStatus = this.connectionErrorMessage(lastError); } else { this.needsMediaUnlock = false; this.connectionStatus = 'Connected'; } if (!micMissing && !cameraMissing) { return; } const retryable = lastError?.message?.includes('engine not connected within timeout'); if (!retryable || attempt === attempts) { return; } await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); } }, 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.needsMediaUnlock) { await this.unlockLocalMicrophone(); } if (!this.audioPlaybackBlocked && !this.needsMediaUnlock) { document.removeEventListener('click', unlock, true); document.removeEventListener('keydown', unlock, true); } }; document.addEventListener('click', unlock, true); document.addEventListener('keydown', unlock, true); }, async unlockAllAudio() { await this.ensureAudioPlayback(); if (this.needsMediaUnlock) { await this.unlockLocalMicrophone(); } }, async unlockLocalMicrophone() { if (!room || !this.canPublish || !this.needsMediaUnlock) { if (room?.localParticipant.isMicrophoneEnabled) { this.needsMediaUnlock = false; } return; } try { await room.localParticipant.setMicrophoneEnabled(true, { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }); this.needsMediaUnlock = false; this.syncLocalMediaState(); this.updateParticipantTile(room.localParticipant); if (this.connectionStatus.includes('microphone') || this.connectionStatus.includes('enable audio')) { this.connectionStatus = 'Connected'; } } catch (error) { console.error(error); this.needsMediaUnlock = true; } }, 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; }, avatarStyle(name) { if (!name) return {}; const hue = this.avatarHue(name); return { background: `linear-gradient(135deg, hsl(${hue} 70% 45%), hsl(${hue} 70% 32%))`, }; }, isSpeakerPerson(person) { if (!person) return false; if (['host', 'co_host', 'panelist'].includes(person.role)) { return true; } return Boolean(person.user_ref && this.presenterRefs.includes(person.user_ref)); }, speakerParticipants() { return this.inCallParticipants().filter((person) => this.isSpeakerPerson(person)); }, speakerParticipantsSorted() { const order = { host: 0, co_host: 1, panelist: 2 }; return [...this.speakerParticipants()].sort((a, b) => { const aSpeaking = this.isSpeaking(a.uuid); const bSpeaking = this.isSpeaking(b.uuid); if (aSpeaking !== bSpeaking) { return aSpeaking ? -1 : 1; } const roleDiff = (order[a.role] ?? 9) - (order[b.role] ?? 9); if (roleDiff !== 0) { return roleDiff; } return a.display_name.localeCompare(b.display_name); }); }, speakerCount() { return this.speakerParticipants().length; }, audienceParticipants() { return this.inCallParticipants().filter((person) => !this.isSpeakerPerson(person)); }, audienceParticipantsSorted() { return [...this.audienceParticipants()].sort((a, b) => a.display_name.localeCompare(b.display_name)); }, resolveSpotlightSpeakerRef() { const speakers = this.speakerParticipants(); if (this.spotlightSpeakerRef && speakers.some((person) => person.user_ref === this.spotlightSpeakerRef)) { return this.spotlightSpeakerRef; } return speakers[0]?.user_ref || null; }, getActivePanelSpeakerRefs() { if (!this.panelDiscussions || this.stageLayout !== 'panel') { return this.speakerParticipants() .map((person) => person.user_ref) .filter(Boolean); } if (!this.activePanelId) { return this.speakerParticipants() .map((person) => person.user_ref) .filter(Boolean); } const panel = this.panels.find((entry) => entry.id === this.activePanelId); return panel?.speaker_refs || []; }, mergeStageConfig(data) { if (typeof data.stage_layout === 'string') { this.stageLayout = data.stage_layout; } if (typeof data.panel_discussions === 'boolean') { this.panelDiscussions = data.panel_discussions; } if (Array.isArray(data.panels)) { this.panels = data.panels; } if (data.active_panel_id !== undefined) { this.activePanelId = data.active_panel_id || ''; } if (data.spotlight_speaker_ref !== undefined) { this.spotlightSpeakerRef = data.spotlight_speaker_ref || ''; } if (!this.panelDiscussions && this.stageLayout === 'panel') { this.stageLayout = 'plenary'; } }, findParticipantTile(identity) { return document.querySelector(`[data-participant-tile="${identity}"]`); }, getTileContainerForIdentity(identity) { if (!this.usesStageLayout) { return document.getElementById('video-grid'); } const person = this.sessionParticipants.find((entry) => entry.uuid === identity); const spotlight = document.getElementById('meet-stage-spotlight'); const grid = document.getElementById('video-grid'); if (this.stageLayout === 'plenary') { if (!this.isSpeakerPerson(person)) { return null; } const spotlightRef = this.resolveSpotlightSpeakerRef(); if (person?.user_ref && spotlightRef && person.user_ref === spotlightRef) { return spotlight; } if (!spotlightRef && this.speakerParticipantsSorted()[0]?.uuid === identity) { return spotlight; } return null; } if (this.stageLayout === 'panel') { if (!this.isSpeakerPerson(person)) { return null; } const refs = this.getActivePanelSpeakerRefs(); if (!person?.user_ref || !refs.includes(person.user_ref)) { return null; } return grid; } return grid; }, renderAudienceStrip() { const strip = document.getElementById('meet-audience-strip'); if (!strip) return; strip.replaceChildren(); if (!this.usesStageLayout || this.stageLayout !== 'plenary') { return; } const audience = this.audienceParticipantsSorted(); if (audience.length === 0) { return; } const maxVisible = 8; const visible = audience.slice(0, maxVisible); const overflow = audience.length - visible.length; visible.forEach((person, index) => { const item = document.createElement('div'); item.className = 'meet-audience-avatar'; item.style.zIndex = String(visible.length - index); item.title = person.display_name; if (person.avatar_url) { const img = document.createElement('img'); img.src = person.avatar_url; img.alt = ''; img.className = 'meet-audience-avatar__image'; item.appendChild(img); } else { const initials = document.createElement('span'); initials.className = 'meet-audience-avatar__initials'; initials.style.background = this.avatarStyle(person.display_name).background; initials.textContent = this.participantInitials(person.display_name); item.appendChild(initials); } strip.appendChild(item); }); if (overflow > 0) { const more = document.createElement('div'); more.className = 'meet-audience-avatar meet-audience-avatar--more'; more.style.zIndex = '0'; more.textContent = `+${overflow} others`; strip.appendChild(more); } }, applyStageLayout() { if (!this.usesStageLayout) { document.getElementById('meet-stage-spotlight')?.replaceChildren(); document.getElementById('meet-audience-strip')?.replaceChildren(); return; } const spotlight = document.getElementById('meet-stage-spotlight'); const grid = document.getElementById('video-grid'); if (room) { const all = [room.localParticipant, ...room.remoteParticipants.values()]; all.forEach((participant) => { const tile = this.findParticipantTile(participant.identity); if (!tile) return; const target = this.getTileContainerForIdentity(participant.identity); if (target) { if (tile.parentElement !== target) { target.appendChild(tile); } tile.classList.toggle('meet-stage-spotlight-tile', target === spotlight); tile.classList.toggle('meet-stage-panel-tile', target === grid && this.stageLayout === 'panel'); } else { tile.remove(); } }); } if (grid) { grid.dataset.panelCount = String(this.getActivePanelSpeakerRefs().length || this.speakerCount()); } this.renderAudienceStrip(); }, async saveStageLayout() { if (!this.config.stageUpdateUrl || this.savingStageLayout) { return; } this.savingStageLayout = true; try { const res = await fetch(this.config.stageUpdateUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ stage_layout: this.stageLayout, panels: this.panels, active_panel_id: this.activePanelId || null, spotlight_speaker_ref: this.spotlightSpeakerRef || null, }), }); if (!res.ok) { return; } const data = await res.json(); this.mergeStageConfig(data); this.panelSetupOpen = false; this.applyStageLayout(); } finally { this.savingStageLayout = false; } }, async setSpotlight(userRef) { if (!userRef) { return; } this.spotlightSpeakerRef = userRef; this.stageLayout = 'plenary'; this.applyStageLayout(); if (!this.config.stageUpdateUrl) { return; } await fetch(this.config.stageUpdateUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ stage_layout: 'plenary', spotlight_speaker_ref: userRef, }), }); }, addPanel() { this.panels.push({ id: `panel-${Math.random().toString(36).slice(2, 10)}`, name: `Panel ${this.panels.length + 1}`, speaker_refs: [], }); }, removePanel(index) { const removed = this.panels.splice(index, 1)[0]; if (removed && this.activePanelId === removed.id) { this.activePanelId = this.panels[0]?.id || ''; } }, setActivePanel(panelId) { this.activePanelId = panelId; this.stageLayout = 'panel'; this.applyStageLayout(); }, togglePanelSpeaker(panel, userRef) { if (!panel || !userRef) { return; } const refs = panel.speaker_refs || []; if (refs.includes(userRef)) { panel.speaker_refs = refs.filter((ref) => ref !== userRef); } else { panel.speaker_refs = [...refs, userRef]; } }, hostParticipant() { const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host'); if (hosts.length) { return hosts[0]; } if (this.roomHost?.user_ref) { const inCall = this.sessionParticipants.find((p) => p.user_ref === this.roomHost.user_ref); if (inCall) { return inCall; } return this.roomHost; } return null; }, participantCount() { return this.inCallParticipants().length; }, inCallParticipants() { return this.sessionParticipants.filter((p) => p.status === 'joined'); }, waitingParticipants() { return this.sessionParticipants.filter((p) => p.status === 'waiting'); }, sortedParticipants() { const order = { host: 0, co_host: 1, panelist: 2, participant: 3, guest: 4, attendee: 5 }; return [...this.inCallParticipants()].sort((a, b) => { const diff = (order[a.role] ?? 9) - (order[b.role] ?? 9); return diff !== 0 ? diff : a.display_name.localeCompare(b.display_name); }); }, roleLabel(role) { const labels = { host: 'Host', co_host: 'Co-host', panelist: 'Panelist', attendee: 'Attendee', guest: 'Guest', participant: 'Participant', }; return labels[role] || 'Participant'; }, isSpeaking(uuid) { return this.activeSpeakerIds.includes(uuid); }, activeSpeakerNames() { return this.activeSpeakerIds .map((id) => { const person = this.sessionParticipants.find((p) => p.uuid === id); if (person?.display_name) { return person.display_name; } if (room?.localParticipant?.identity === id) { return room.localParticipant.name || 'You'; } return room?.remoteParticipants.get(id)?.name || 'Someone'; }) .filter(Boolean); }, activeSpeakersMessage() { const names = this.activeSpeakerNames(); if (names.length === 0) return ''; if (names.length === 1) return `${names[0]} is speaking`; if (names.length === 2) return `${names[0]} and ${names[1]} are speaking`; const last = names[names.length - 1]; const rest = names.slice(0, -1).join(', '); return `${rest}, and ${last} are speaking`; }, activeSpeakerCount() { return this.activeSpeakerIds.length; }, inCallParticipantsSorted() { return [...this.inCallParticipants()].sort((a, b) => { const aSpeaking = this.isSpeaking(a.uuid); const bSpeaking = this.isSpeaking(b.uuid); if (aSpeaking !== bSpeaking) { return aSpeaking ? -1 : 1; } return a.display_name.localeCompare(b.display_name); }); }, syncActiveSpeakers(speakers) { this.activeSpeakerIds = speakers.map((p) => p.identity); document.querySelectorAll('[data-participant-tile]').forEach((tile) => { const identity = tile.dataset.participantTile; const speaking = this.activeSpeakerIds.includes(identity); tile.classList.toggle('meet-tile-speaking', speaking); tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking); }); }, async admitParticipant(uuid) { if (!this.isHost || !this.config.sessionUuid) return; await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/admit`, { method: 'POST', headers: this.headers(), }); await this.poll(); }, async denyParticipant(uuid) { if (!this.isHost || !this.config.sessionUuid) return; await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/deny`, { method: 'POST', headers: this.headers(), }); await this.poll(); }, removeParticipantTile(identity) { this.findParticipantTile(identity)?.remove(); }, getOrCreateParticipantTile(participant) { const container = this.getTileContainerForIdentity(participant.identity); const identity = participant.identity; const existing = this.findParticipantTile(identity); if (!container) { existing?.remove(); return null; } let tile = existing; if (!tile) { tile = document.createElement('div'); tile.dataset.participantTile = identity; tile.className = this.audioOnly ? 'meet-audio-tile relative flex aspect-square w-full max-w-[9rem] flex-col items-center justify-center overflow-hidden rounded-2xl bg-slate-900' : 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900'; tile.innerHTML = `
${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();