import Alpine from 'alpinejs'; import { ConnectionState, Room, RoomEvent, Track, TrackEvent } from 'livekit-client'; function readMeetRoomBootState() { const el = document.getElementById('meet-config'); if (!el) { return {}; } let presenterRefs = []; let panels = []; let sessionParticipants = []; let roomHost = null; try { presenterRefs = JSON.parse(el.dataset.presenterRefs || '[]'); } catch { presenterRefs = []; } try { panels = JSON.parse(el.dataset.panels || '[]'); } catch { panels = []; } try { sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]'); } catch { sessionParticipants = []; } try { roomHost = JSON.parse(el.dataset.roomHost || 'null'); } catch { roomHost = null; } const audioOnly = el.dataset.audioOnly === '1'; let stageLayout = el.dataset.stageLayout || 'plenary'; const panelDiscussions = el.dataset.panelDiscussions === '1'; if (!panelDiscussions && stageLayout === 'panel') { stageLayout = 'plenary'; } const participantUuid = el.dataset.participant || ''; const selfParticipant = sessionParticipants.find((person) => person.uuid === participantUuid); return { isHost: el.dataset.isHost === '1', canPublish: el.dataset.canPublish !== '0', audioOnlyPublish: el.dataset.audioOnlyPublish === '1', usesSpeakAccess: el.dataset.usesSpeakAccess === '1', canManageSpeak: el.dataset.canManageSpeak === '1', speakGranted: el.dataset.speakGranted === '1', speakRequested: el.dataset.speakRequested === '1', inBreakout: el.dataset.inBreakout === '1', isWebinar: el.dataset.isWebinar === '1', isConference: el.dataset.isConference === '1', sessionNounLabel: el.dataset.sessionNoun || '', isGreenRoom: el.dataset.isGreenRoom === '1', audioOnly, isSpace: el.dataset.isSpace === '1', muteOnJoin: el.dataset.muteOnJoin === '1', videoOffOnJoin: el.dataset.videoOffOnJoin === '1' || audioOnly, isRecording: el.dataset.recordingActive === '1', activeRecordingUuid: el.dataset.activeRecordingUuid || null, recordingCaptureMode: el.dataset.captureMode || null, serverEgress: el.dataset.serverEgress === '1', isLocked: el.dataset.locked === '1', watermark: el.dataset.watermark === '1', liveCaptions: el.dataset.liveCaptions === '1', displayName: el.dataset.displayName || '', presenterRefs, usesStageLayout: el.dataset.usesStageLayout === '1', panelDiscussions, stageLayout, activePanelId: el.dataset.activePanelId || '', spotlightSpeakerRef: el.dataset.spotlightSpeakerRef || '', panels, sessionParticipants, roomHost, waitingCount: sessionParticipants.filter((person) => person.status === 'waiting').length, assignedBreakoutRoomId: selfParticipant?.breakout_room_id ?? null, }; } function meetRoom() { const roomBoot = readMeetRoomBootState(); // 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 = []; let seenReactionKeys = new Set(); let applauseClapTimes = []; let applauseAudio = null; let lastApplauseSoundAt = 0; const APPLAUSE_WINDOW_MS = 4000; const APPLAUSE_MIN_CLAPPERS = 2; const APPLAUSE_SOUND_COOLDOWN_MS = 3000; const APPLAUSE_EMOJIS = new Set(['👍', '👏', '🙌']); return { isMuted: true, isVideoOff: true, isScreenSharing: false, isRecording: roomBoot.isRecording ?? false, isLocked: roomBoot.isLocked ?? false, isHost: roomBoot.isHost ?? false, watermark: roomBoot.watermark ?? false, liveCaptions: roomBoot.liveCaptions ?? false, displayName: roomBoot.displayName ?? '', canPublish: roomBoot.canPublish ?? true, muteOnJoin: roomBoot.muteOnJoin ?? true, videoOffOnJoin: roomBoot.videoOffOnJoin ?? false, waitingCount: roomBoot.waitingCount ?? 0, isWebinar: roomBoot.isWebinar ?? false, isConference: roomBoot.isConference ?? false, sessionNounLabel: roomBoot.sessionNounLabel ?? '', isGreenRoom: roomBoot.isGreenRoom ?? false, presenterRefs: roomBoot.presenterRefs ?? [], audioOnly: roomBoot.audioOnly ?? false, audioOnlyPublish: roomBoot.audioOnlyPublish ?? false, usesSpeakAccess: roomBoot.usesSpeakAccess ?? false, canManageSpeak: roomBoot.canManageSpeak ?? false, speakGranted: roomBoot.speakGranted ?? false, speakRequested: roomBoot.speakRequested ?? false, isSpace: roomBoot.isSpace ?? false, usesStageLayout: roomBoot.usesStageLayout ?? false, panelDiscussions: roomBoot.panelDiscussions ?? false, stageLayout: roomBoot.stageLayout ?? 'plenary', panels: roomBoot.panels ?? [], activePanelId: roomBoot.activePanelId ?? '', spotlightSpeakerRef: roomBoot.spotlightSpeakerRef ?? '', inBreakout: roomBoot.inBreakout ?? false, assignedBreakoutRoomId: roomBoot.assignedBreakoutRoomId ?? null, activeRecordingUuid: roomBoot.activeRecordingUuid ?? null, recordingCaptureMode: roomBoot.recordingCaptureMode ?? null, serverEgress: roomBoot.serverEgress ?? false, sessionParticipants: roomBoot.sessionParticipants ?? [], roomHost: roomBoot.roomHost ?? null, captionText: '', audioPlaybackBlocked: false, needsMediaUnlock: false, goingLive: false, panelSetupOpen: false, savingStageLayout: false, _stageSnapshot: null, qaItems: [], activePolls: [], breakoutBroadcast: '', breakoutBroadcastInput: '', breakoutsActive: false, activeBreakouts: [], breakoutRoomName: '', breakoutClosesAt: '', sidebarPanel: null, whiteboardOpen: false, qaInput: '', chatInput: '', shareOpen: false, shareCopied: false, breakoutModalOpen: false, breakoutCount: '4', endMeetingConfirmOpen: false, joinUrl: '', passcode: '', roomTitle: '', connectionStatus: 'Connecting…', participants: [], activeSpeakerIds: [], floatingReactions: [], reactionsInitialized: false, pictureInPictureActive: false, pictureInPictureSupported: false, pollTimer: null, pollInFlight: false, pollEtag: null, pollIntervalMs: 6000, pollHiddenIntervalMs: 20000, _lastCaptionSentAt: 0, 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, speakRequestUrl: el.dataset.speakRequestUrl, speakCancelUrl: el.dataset.speakCancelUrl, speakGrantUrl: el.dataset.speakGrantUrl, speakRevokeUrl: el.dataset.speakRevokeUrl, speakDismissUrl: el.dataset.speakDismissUrl, spacePromoteUrl: el.dataset.spacePromoteUrl, spaceDemoteUrl: el.dataset.spaceDemoteUrl, spaceSpeakAcceptUrl: el.dataset.spaceSpeakAcceptUrl, spaceSpeakDeclineUrl: el.dataset.spaceSpeakDeclineUrl, spaceSpeakDismissUrl: el.dataset.spaceSpeakDismissUrl, spaceCoHostUrl: el.dataset.spaceCoHostUrl, 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, breakoutsReturnUrl: el.dataset.breakoutsReturnUrl, breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl, mediaTokenUrl: el.dataset.mediaTokenUrl, 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.initApplauseAudio(el.dataset.applauseAudio || ''); this.isHost = el.dataset.isHost === '1'; this.config.participantUuid = el.dataset.participant || ''; this.canPublish = el.dataset.canPublish !== '0'; this.audioOnlyPublish = el.dataset.audioOnlyPublish === '1'; this.usesSpeakAccess = el.dataset.usesSpeakAccess === '1'; this.canManageSpeak = el.dataset.canManageSpeak === '1'; this.speakGranted = el.dataset.speakGranted === '1'; this.speakRequested = el.dataset.speakRequested === '1'; this.inBreakout = el.dataset.inBreakout === '1'; this.isWebinar = el.dataset.isWebinar === '1'; this.isConference = el.dataset.isConference === '1'; this.sessionNounLabel = el.dataset.sessionNoun || ''; 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.activeRecordingUuid = el.dataset.activeRecordingUuid || null; this.recordingCaptureMode = el.dataset.captureMode || null; this.serverEgress = el.dataset.serverEgress === '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; const selfParticipant = this.sessionParticipants.find((p) => p.uuid === this.config.participantUuid); this.assignedBreakoutRoomId = selfParticipant?.breakout_room_id ?? null; 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.startPolling(); this.applyStageLayout(); this.$nextTick(() => { this.syncToolbarConditionalVisibility(); document.querySelector('.meet-toolbar__track')?.classList.add('is-ready'); }); this.setupPictureInPicture(); }, currentPollInterval() { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { return this.pollHiddenIntervalMs; } return this.pollIntervalMs; }, startPolling() { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } this.poll(); this.pollTimer = setInterval(() => this.poll(), this.currentPollInterval()); if (typeof document !== 'undefined' && !this._pollVisibilityBound) { this._pollVisibilityBound = true; document.addEventListener('visibilitychange', () => { if (this.goingLive) { return; } this.startPolling(); }); } }, 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({ // Keep remote video (including screen share) subscribed when a viewer // backgrounds the Meet tab — otherwise shares "disappear" for some users. adaptiveStream: { pauseVideoInBackground: false, }, 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(); const isScreen = this.isScreenShareSource(publication, track); if (isScreen) { // Temporary unsubscribes must not permanently hide the share — // re-sync from any remaining screen-share publications. this.syncScreenShareOverlay(); return; } if (track.kind === Track.Kind.Video) { this.updateParticipantTile(participant); this.reattachParticipantVideo(participant); } }); room.on(RoomEvent.TrackMuted, (publication, participant) => { // Browser/OS may briefly mute a capture when the sharer switches // apps. Keep the screen-share surface mounted so it does not vanish. if (publication.source === Track.Source.ScreenShare) { if (participant === room.localParticipant) { this.syncLocalMediaState(); } return; } if (publication.kind === Track.Kind.Video) { 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); } if (publication.source === Track.Source.ScreenShare && publication.track) { this.guardLocalScreenShare(publication.track); } this.updateParticipantTile(participant); this.syncLocalMediaState(); }); room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => { if (publication.kind === Track.Kind.Video) { if (publication.source === Track.Source.ScreenShare) { this.syncScreenShareOverlay(); } else { this.updateParticipantTile(participant); } } this.syncLocalMediaState(); }); room.on(RoomEvent.TrackPublished, (publication, participant) => { // Late joiners / reconnect: screen share is already published. if (publication.source === Track.Source.ScreenShare && publication.track) { this.attachTrack(publication.track, publication, participant); } }); 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.syncParticipants(); if (this.canPublish) { await this.enableLocalMedia(room); } else { this.isMuted = true; this.isVideoOff = true; } await this.ensureAudioPlayback(); this.setupCaptionPipeline(); this.attachExistingTracks(room.localParticipant); room.remoteParticipants.forEach((participant) => this.attachExistingTracks(participant)); this.syncParticipants(); await this.maybeStartAutoRecording(); } 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.inBreakout ? !this.muteOnJoin : (!this.muteOnJoin || this.isHost); const wantCamera = !this.videoOffOnJoin && !this.audioOnly && !this.audioOnlyPublish; 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) { if (this.canPublish && this.showMicrophoneControl()) { await this.unlockLocalMicrophone(); } else { this.needsMediaUnlock = false; } } 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) { return; } if (this.canPublish && this.showMicrophoneControl()) { await this.unlockLocalMicrophone(); } else { this.needsMediaUnlock = false; } }, 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; // Publication present = still sharing, even if the browser briefly mutes // the capture track while the sharer focuses another window/app. this.isScreenSharing = Array.from(local.videoTrackPublications.values()) .some((pub) => pub.source === Track.Source.ScreenShare && pub.track); }, isScreenShareSource(publication, track) { return publication?.source === Track.Source.ScreenShare || track?.source === Track.Source.ScreenShare; }, /** * Find an active screen-share publication in the room. * Prefers unmuted tracks but keeps muted ones so the surface does not vanish. */ findActiveScreenShare() { if (!room) { return null; } const participants = [room.localParticipant, ...room.remoteParticipants.values()]; let mutedFallback = null; for (const participant of participants) { for (const publication of participant.videoTrackPublications.values()) { if (publication.source !== Track.Source.ScreenShare || !publication.track) { continue; } const candidate = { participant, publication, track: publication.track }; if (!publication.isMuted && publication.track.mediaStreamTrack?.readyState !== 'ended') { return candidate; } mutedFallback ??= candidate; } } return mutedFallback; }, /** * Keep or restore the screen-share overlay from live room state. * Only hides when nobody is sharing anymore. */ syncScreenShareOverlay() { if (!room || this.audioOnly) { this.clearScreenShareOverlay({ force: true }); return; } const active = this.findActiveScreenShare(); if (!active) { this.clearScreenShareOverlay({ force: true }); return; } const container = document.getElementById('screen-share'); if (!container) { return; } const existingVideo = container.querySelector('video'); const activeMediaTrack = active.track.mediaStreamTrack; if (existingVideo?.srcObject instanceof MediaStream && activeMediaTrack) { const attached = existingVideo.srcObject.getVideoTracks().some((t) => t.id === activeMediaTrack.id); if (attached) { container.classList.remove('hidden'); return; } } this.attachTrack(active.track, active.publication, active.participant); }, /** * LiveKit pauses upstream ~5s after a MediaStreamTrack "mute" event (common when * the sharer alt-tabs). Immediately resume so remotes keep receiving the share. */ guardLocalScreenShare(track) { if (!track || track.source !== Track.Source.ScreenShare) { return; } if (track._ladillScreenShareGuarded) { return; } track._ladillScreenShareGuarded = true; const resume = () => { if (typeof track.resumeUpstream !== 'function') { return; } if (!track.isUpstreamPaused) { return; } track.resumeUpstream().catch((error) => { console.warn('Failed to resume screen-share upstream', error); }); }; track.on(TrackEvent.UpstreamPaused, resume); // Cancel LiveKit's debounced pause-on-mute when possible. const mediaTrack = track.mediaStreamTrack; if (mediaTrack) { mediaTrack.addEventListener('mute', () => { track.debouncedTrackMuteHandler?.cancel?.('ladill-keep-screen-share'); // If pause already started, reverse it. setTimeout(resume, 0); setTimeout(resume, 600); setTimeout(resume, 5200); }); mediaTrack.addEventListener('unmute', resume); } }, 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%))`, }; }, participantForIdentity(identity) { return this.sessionParticipants.find((entry) => entry.uuid === identity); }, displayNameForParticipant(participant) { const person = this.participantForIdentity(participant.identity); return person?.display_name || participant.name || participant.identity; }, avatarUrlForPerson(person) { if (!person) { return null; } if (person.avatar_url) { return person.avatar_url; } if (['host', 'co_host'].includes(person.role) && this.roomHost?.avatar_url) { return this.roomHost.avatar_url; } if (this.roomHost?.user_ref && person.user_ref === this.roomHost.user_ref && this.roomHost.avatar_url) { return this.roomHost.avatar_url; } return null; }, avatarUrlForParticipant(participant) { return this.avatarUrlForPerson(this.participantForIdentity(participant.identity)); }, updateTileAvatarCircle(tile, participant) { const circle = tile.querySelector('[data-tile-avatar-circle]'); if (!circle) { return; } const displayName = this.displayNameForParticipant(participant); const avatarUrl = this.avatarUrlForParticipant(participant); const isAudioTile = tile.classList.contains('meet-audio-tile'); const isSpaceTile = tile.classList.contains('meet-space-tile'); const sizeClass = isSpaceTile ? 'h-[4.75rem] w-[4.75rem] text-lg sm:h-[5.25rem] sm:w-[5.25rem] sm:text-xl' : isAudioTile ? 'h-[4.5rem] w-[4.5rem] text-xl' : 'h-20 w-20 text-2xl'; circle.replaceChildren(); if (avatarUrl) { circle.className = `flex ${sizeClass} shrink-0 items-center justify-center overflow-hidden rounded-full shadow-lg`; circle.style.background = ''; const img = document.createElement('img'); img.src = avatarUrl; img.alt = ''; img.className = 'h-full w-full object-cover'; circle.appendChild(img); return; } const hue = this.avatarHue(displayName); circle.className = `flex ${sizeClass} shrink-0 items-center justify-center rounded-full font-semibold text-white shadow-lg`; circle.style.background = `linear-gradient(135deg, hsl(${hue} 45% 42%), hsl(${(hue + 40) % 360} 50% 28%))`; circle.textContent = this.participantInitials(displayName); }, refreshParticipantTileAvatars() { if (this.isSpace && !this.usesStageLayout) { this.renderSpaceSpeakerTiles(); return; } if (!room) { return; } [room.localParticipant, ...room.remoteParticipants.values()].forEach((participant) => { const tile = this.findParticipantTile(participant.identity); if (tile) { this.updateTileAvatarCircle(tile, participant); tile.querySelector('[data-tile-name]')?.replaceChildren( document.createTextNode(this.displayNameForParticipant(participant)), ); } }); }, 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)); }, /** Stable key for spotlight and panel assignment (user_ref when logged in, else participant uuid). */ spotlightKey(person) { if (!person) { return ''; } return person.user_ref || person.uuid || ''; }, matchesSpotlightKey(key, person) { if (!key || !person) { return false; } return key === person.user_ref || key === person.uuid; }, findSpeakerBySpotlightKey(key) { if (!key) { return null; } return this.speakerParticipants().find((person) => this.matchesSpotlightKey(key, person)) || null; }, 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)); }, resolveSpotlightParticipantUuid() { const spotlightKey = this.resolveSpotlightKey(); if (spotlightKey) { const match = this.findSpeakerBySpotlightKey(spotlightKey); if (match) { return match.uuid; } } return this.speakerParticipantsSorted()[0]?.uuid || null; }, plenaryStripParticipants() { const spotlightUuid = this.resolveSpotlightParticipantUuid(); return this.inCallParticipants() .filter((person) => person.uuid !== spotlightUuid) .sort((a, b) => this.compareStripParticipants(a, b)); }, panelStripParticipants() { const onPanelKeys = new Set(this.getActivePanelSpeakerKeys()); return this.inCallParticipants() .filter((person) => { if (!this.isSpeakerPerson(person)) { return true; } const key = this.spotlightKey(person); return !key || !onPanelKeys.has(key); }) .sort((a, b) => this.compareStripParticipants(a, b)); }, compareStripParticipants(a, b) { const aSpeaker = this.isSpeakerPerson(a); const bSpeaker = this.isSpeakerPerson(b); if (aSpeaker !== bSpeaker) { return aSpeaker ? -1 : 1; } return a.display_name.localeCompare(b.display_name); }, stripParticipants() { if (this.stageLayout === 'panel') { return this.panelStripParticipants(); } return this.plenaryStripParticipants(); }, createStripInitials(person) { 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); return initials; }, appendStripAvatarItem(strip, person, index, total) { const item = document.createElement('div'); item.className = 'meet-audience-avatar'; if (this.isSpeakerPerson(person)) { item.classList.add('meet-audience-avatar--speaker'); } item.style.zIndex = String(total - index); item.title = person.display_name; const avatarUrl = this.avatarUrlForPerson(person); if (avatarUrl) { const img = document.createElement('img'); img.src = avatarUrl; img.alt = ''; img.className = 'meet-audience-avatar__image'; img.addEventListener('error', () => { img.replaceWith(this.createStripInitials(person)); }, { once: true }); item.appendChild(img); } else { item.appendChild(this.createStripInitials(person)); } strip.appendChild(item); }, resolveSpotlightKey() { const speakers = this.speakerParticipants(); if (this.spotlightSpeakerRef) { const match = this.findSpeakerBySpotlightKey(this.spotlightSpeakerRef); if (match) { return this.spotlightKey(match); } } const fallback = this.speakerParticipantsSorted()[0]; return fallback ? this.spotlightKey(fallback) : ''; }, getActivePanelSpeakerKeys() { if (!this.panelDiscussions || this.stageLayout !== 'panel') { return this.speakerParticipants() .map((person) => this.spotlightKey(person)) .filter(Boolean); } if (!this.activePanelId) { return this.speakerParticipants() .map((person) => this.spotlightKey(person)) .filter(Boolean); } const panel = this.panels.find((entry) => entry.id === this.activePanelId); return panel?.speaker_refs || []; }, mergeStageConfig(data) { const editingStage = this.panelSetupOpen; if (!editingStage) { if (typeof data.stage_layout === 'string') { this.stageLayout = data.stage_layout; } if (Array.isArray(data.panels)) { this.panels = data.panels; } if (data.active_panel_id !== undefined) { this.activePanelId = data.active_panel_id || ''; } } if (typeof data.panel_discussions === 'boolean') { this.panelDiscussions = data.panel_discussions; } if (data.spotlight_speaker_ref !== undefined) { this.spotlightSpeakerRef = data.spotlight_speaker_ref || ''; } if (!this.panelDiscussions && this.stageLayout === 'panel') { this.stageLayout = 'plenary'; } }, liveKitParticipantForIdentity(identity) { if (!room) { return null; } if (room.localParticipant.identity === identity) { return room.localParticipant; } return room.remoteParticipants.get(identity) || null; }, spaceRoleLabel(role) { return { host: 'Host', co_host: 'Co-host', panelist: 'Speaker', attendee: 'Listener', }[role] || ''; }, spaceStageParticipantsSorted() { const order = { host: 0, co_host: 1, panelist: 2, attendee: 3, guest: 4, participant: 5 }; return [...this.inCallParticipants()].sort((a, b) => { const roleDiff = (order[a.role] ?? 9) - (order[b.role] ?? 9); if (roleDiff !== 0) { return roleDiff; } return a.display_name.localeCompare(b.display_name); }); }, spaceSpeakerTileShellHtml() { return `
`; }, participantTileShellHtml() { return `
`; }, ensureParticipantTileShell(identity) { let tile = this.findParticipantTile(identity); const wantsSpaceTile = this.isSpace && !this.usesStageLayout; if (tile && wantsSpaceTile !== tile.classList.contains('meet-space-tile')) { tile.remove(); tile = null; } if (!tile) { tile = document.createElement('div'); tile.dataset.participantTile = identity; if (wantsSpaceTile) { tile.className = 'meet-space-tile flex w-full min-w-0 flex-col items-center px-1 py-1'; tile.innerHTML = this.spaceSpeakerTileShellHtml(); } else { tile.className = this.audioOnly ? 'meet-audio-tile relative flex aspect-square w-full flex-col overflow-hidden rounded-2xl bg-slate-900' : 'relative flex aspect-video w-full flex-col overflow-hidden rounded-xl bg-slate-900 min-h-0'; tile.innerHTML = this.participantTileShellHtml(); } } return tile; }, updateSessionParticipantTile(person, liveParticipant = null, tile = null) { tile = tile || this.findParticipantTile(person.uuid); if (!tile || !person) { return; } const pseudoParticipant = { identity: person.uuid, name: person.display_name }; this.updateTileAvatarCircle(tile, pseudoParticipant); const speaking = this.isSpeaking(person.uuid); const muted = liveParticipant ? !liveParticipant.isMicrophoneEnabled : Boolean(person.is_muted); if (tile.classList.contains('meet-space-tile')) { const isListener = person.role === 'attendee'; tile.querySelector('[data-tile-name]').textContent = person.display_name; tile.querySelector('[data-tile-role]').textContent = this.spaceRoleLabel(person.role); tile.classList.toggle('meet-tile-speaking', speaking && !isListener); tile.classList.toggle('meet-space-tile--listener', isListener); tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking || isListener); tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', isListener ? false : (!muted || speaking)); return; } tile.querySelector('[data-tile-name]').textContent = person.display_name; tile.classList.toggle('meet-tile-speaking', speaking); tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking); tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', !muted); tile.querySelector('[data-tile-cam]')?.classList.add('hidden'); }, renderSpaceSpeakerTiles() { if (!this.isSpace || this.usesStageLayout) { return; } const grid = document.getElementById('video-grid'); if (!grid) { return; } const people = this.spaceStageParticipantsSorted(); const peopleIds = new Set(people.map((person) => person.uuid)); people.forEach((person) => { const liveParticipant = this.liveKitParticipantForIdentity(person.uuid); const tile = this.ensureParticipantTileShell(person.uuid); if (tile.parentElement !== grid) { grid.appendChild(tile); } this.updateSessionParticipantTile(person, liveParticipant, tile); }); grid.querySelectorAll('[data-participant-tile]').forEach((tile) => { if (!peopleIds.has(tile.dataset.participantTile)) { tile.remove(); } }); this.updateAudioGridLayout(); }, 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 spotlightKey = this.resolveSpotlightKey(); if (spotlightKey && this.matchesSpotlightKey(spotlightKey, person)) { return spotlight; } if (!spotlightKey && this.speakerParticipantsSorted()[0]?.uuid === identity) { return spotlight; } return null; } if (this.stageLayout === 'panel') { if (!this.isSpeakerPerson(person)) { return null; } const keys = this.getActivePanelSpeakerKeys(); const personKey = this.spotlightKey(person); if (!personKey || !keys.includes(personKey)) { return null; } return grid; } return grid; }, renderAudienceStrip() { const strip = document.getElementById('meet-audience-strip'); if (!strip) return; strip.replaceChildren(); if (!this.usesStageLayout) { return; } const audience = this.stripParticipants(); if (audience.length === 0) { return; } const maxVisible = 12; const visible = audience.slice(0, maxVisible); const overflow = audience.length - visible.length; visible.forEach((person, index) => { this.appendStripAvatarItem(strip, person, index, visible.length); }); 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(); if (this.isSpace) { this.renderSpaceSpeakerTiles(); } else if (room) { const all = [room.localParticipant, ...room.remoteParticipants.values()]; all.forEach((participant) => { const target = this.getTileContainerForIdentity(participant.identity); if (!target) { this.removeParticipantTile(participant.identity); return; } const tile = this.getOrCreateParticipantTile(participant); if (!tile) { return; } if (tile.parentElement !== target) { target.appendChild(tile); } this.updateParticipantTile(participant); }); } if (!this.isSpace) { this.updateAudioGridLayout(); } 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 target = this.getTileContainerForIdentity(participant.identity); if (!target) { this.removeParticipantTile(participant.identity); return; } const existingTile = this.findParticipantTile(participant.identity); const previousParent = existingTile?.parentElement ?? null; const tile = this.getOrCreateParticipantTile(participant); if (!tile) { return; } const isSpotlightTile = target === spotlight; tile.classList.toggle('meet-stage-spotlight-tile', isSpotlightTile); tile.classList.toggle('meet-stage-panel-tile', target === grid && this.stageLayout === 'panel'); tile.classList.toggle('aspect-video', !isSpotlightTile && !this.audioOnly); tile.classList.toggle('rounded-xl', !isSpotlightTile && !this.audioOnly); if (!existingTile || previousParent !== target) { this.reattachParticipantVideo(participant); } else { this.updateParticipantTile(participant); } }); } if (grid) { grid.dataset.panelCount = String(this.getActivePanelSpeakerKeys().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, panel_discussions: this.panelDiscussions || this.stageLayout === 'panel', 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._stageSnapshot = null; this.panelSetupOpen = false; this.mergeStageConfig(data); this.applyStageLayout(); } finally { this.savingStageLayout = false; } }, async setSpotlight(speakerKey) { if (!speakerKey) { return; } this.spotlightSpeakerRef = speakerKey; 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: speakerKey, }), }); }, 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.panelDiscussions = true; }, togglePanelSpeaker(panel, speakerKey) { if (!panel || !speakerKey) { return; } const refs = panel.speaker_refs || []; if (refs.includes(speakerKey)) { panel.speaker_refs = refs.filter((ref) => ref !== speakerKey); } else { panel.speaker_refs = [...refs, speakerKey]; } }, 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; }, headerParticipantCount() { if (this.usesStageLayout) { return String(this.speakerCount()); } return String(this.participantCount()); }, headerParticipantSuffix() { if (this.usesStageLayout) { const count = this.speakerCount(); return count === 1 ? ' speaker' : ' speakers'; } return ' in call'; }, headerParticipantLabel() { return this.headerParticipantCount() + this.headerParticipantSuffix(); }, 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: this.isSpace ? 'Speaker' : 'Panelist', attendee: this.isSpace ? 'Listener' : '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 = this.ensureParticipantTileShell(identity); } if (tile.parentElement !== container) { container.appendChild(tile); } const isSpotlightTile = container.id === 'meet-stage-spotlight'; tile.classList.toggle('meet-stage-spotlight-tile', isSpotlightTile); tile.classList.toggle('meet-stage-panel-tile', container.id === 'video-grid' && this.stageLayout === 'panel'); tile.classList.toggle('aspect-video', !isSpotlightTile && !this.audioOnly); tile.classList.toggle('rounded-xl', !isSpotlightTile && !this.audioOnly); this.updateTileAvatarCircle(tile, participant); tile.querySelector('[data-tile-name]').textContent = this.displayNameForParticipant(participant); const speaking = this.activeSpeakerIds.includes(identity); tile.classList.toggle('meet-tile-speaking', speaking); tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking); 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; if (tile.classList.contains('meet-space-tile')) { const person = this.participantForIdentity(participant.identity); if (person) { this.updateSessionParticipantTile(person, participant, 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 || this.audioOnly); if (hasVideo) { avatar?.classList.add('hidden'); const publication = Array.from(participant.videoTrackPublications.values()) .find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted); const video = stage?.querySelector('video'); const needsAttach = !video || video.readyState === HTMLMediaElement.HAVE_NOTHING || !video.srcObject; if (publication?.track && needsAttach) { this.attachVideoToTile(participant, publication.track); } return; } avatar?.classList.remove('hidden'); this.updateTileAvatarCircle(tile, participant); stage?.replaceChildren(); }, 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 = 'meet-tile-video'; el.dataset.participant = participant.identity; stage.appendChild(el); }, reattachParticipantVideo(participant) { if (this.audioOnly || !participant) { return; } const publication = Array.from(participant.videoTrackPublications.values()) .find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted); if (publication?.track) { this.attachVideoToTile(participant, publication.track); return; } this.updateParticipantTile(participant); }, publishCaption(text) { const normalized = text.trim(); if (!normalized || !this.config.captionUrl) { return; } this.captionText = normalized; if (normalized === this._lastCaptionSent) { return; } // Throttle caption posts — speech recognition fires very frequently. const now = Date.now(); if (now - (this._lastCaptionSentAt || 0) < 2000) { return; } this._lastCaptionSent = normalized; this._lastCaptionSentAt = now; fetch(this.config.captionUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ text: normalized }), }).catch(() => {}); }, setupCaptionPipeline() { if (!this.liveCaptions || !this.config.captionUrl) return; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) return; if (this._captionRecognition) { return; } const recognition = new SpeechRecognition(); recognition.continuous = true; recognition.interimResults = true; recognition.onresult = (event) => { let interim = ''; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; const text = result[0]?.transcript?.trim() ?? ''; if (!text) { continue; } if (result.isFinal) { this.publishCaption(text); } else { interim = text; } } if (interim) { this.captionText = interim; } }; recognition.onerror = () => {}; recognition.onend = () => { if (!this.liveCaptions || !room) { return; } try { recognition.start(); } catch (e) { // browser may block without gesture } }; this._captionRecognition = recognition; try { recognition.start(); } catch (e) { // browser may block without gesture } }, usesServerEgressCapture() { return this.recordingCaptureMode === 'egress'; }, async maybeStartAutoRecording() { if (!this.isHost || !this.isRecording || recordingMediaRecorder) { return; } if (this.usesServerEgressCapture()) { return; } const el = document.getElementById('meet-config'); const existingUuid = this.activeRecordingUuid || el?.dataset.activeRecordingUuid || ''; if (existingUuid) { this.activeRecordingUuid = existingUuid; if (this.serverEgress && !this.usesServerEgressCapture()) { await this.resumeServerEgress(); if (this.usesServerEgressCapture()) { return; } } } else { await this.startServerRecording(); return; } const started = await this.tryStartClientRecordingWithRetry(); if (!started) { console.warn('Auto-record client capture could not start.'); } }, async tryStartClientRecordingWithRetry() { if (this.startClientRecording()) { return true; } await new Promise((resolve) => setTimeout(resolve, 1500)); return this.startClientRecording(); }, async toggleRecording() { if (!this.isHost) return; if (this.isRecording) { await this.stopServerAndUploadRecording(); } else { await this.startServerRecording(); } }, async resumeServerEgress() { const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() }); if (!res.ok) { return; } const data = await res.json(); this.activeRecordingUuid = data.recording_uuid || this.activeRecordingUuid; if (data.capture_mode) { this.recordingCaptureMode = data.capture_mode; } }, async startServerRecording() { const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() }); if (!res.ok) return; const data = await res.json(); this.activeRecordingUuid = data.recording_uuid; this.recordingCaptureMode = data.capture_mode || 'browser'; if (this.usesServerEgressCapture()) { this.isRecording = true; return; } if (! await this.tryStartClientRecordingWithRetry()) { await this.abortServerRecording('Could not capture meeting video in this browser.'); this.isRecording = false; this.activeRecordingUuid = null; this.recordingCaptureMode = null; window.alert(`Recording could not start because this browser could not capture the ${this.sessionNoun()} video. Try Chrome on desktop, or disable stage-only layouts temporarily.`); return; } this.isRecording = true; }, startClientRecording() { const stream = this.buildRecordingStream(); if (!stream || typeof MediaRecorder === 'undefined') { return false; } recordingChunks = []; const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus') ? 'video/webm;codecs=vp9,opus' : (MediaRecorder.isTypeSupported('video/webm') ? 'video/webm' : ''); try { recordingMediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_500_000 }) : new MediaRecorder(stream, { videoBitsPerSecond: 2_500_000 }); } catch { return false; } recordingMediaRecorder.ondataavailable = (event) => { if (event.data?.size > 0) { recordingChunks.push(event.data); } }; recordingMediaRecorder.start(1000); return true; }, buildRecordingStream() { if (!room) return null; const screenEl = document.getElementById('screen-share'); const screenVideo = screenEl && !screenEl.classList.contains('hidden') ? screenEl.querySelector('video') : null; let videoSource = screenVideo; if (!videoSource) { videoSource = this.recordingVideoCaptureElement(); } if (!videoSource?.captureStream) { return null; } const videoStream = videoSource.captureStream(25); const tracks = [...videoStream.getVideoTracks(), ...this.collectMeetingAudioTracks()]; return tracks.length ? new MediaStream(tracks) : null; }, recordingVideoCaptureElement() { if (!this.usesStageLayout) { return document.getElementById('video-grid'); } const spotlight = document.getElementById('meet-stage-spotlight'); const grid = document.getElementById('video-grid'); const stage = document.getElementById('meet-stage'); if (spotlight?.querySelector('video')) { return spotlight; } if (grid?.querySelector('video')) { return grid; } return stage || spotlight || grid; }, collectMeetingAudioTracks() { const tracks = []; const addFromParticipant = (participant) => { participant?.audioTrackPublications?.forEach((publication) => { const mediaTrack = publication.track?.mediaStreamTrack; if (mediaTrack && mediaTrack.readyState === 'live') { tracks.push(mediaTrack); } }); }; if (room?.localParticipant) { addFromParticipant(room.localParticipant); } room?.remoteParticipants?.forEach(addFromParticipant); return tracks; }, async stopServerAndUploadRecording() { const isServerEgress = this.usesServerEgressCapture(); const hadClientRecorder = !!recordingMediaRecorder; const blob = isServerEgress ? null : await this.stopClientRecording(); this.isRecording = false; let recordingUuid = this.activeRecordingUuid; const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() }); if (res.ok) { const data = await res.json().catch(() => ({})); recordingUuid = data.recording_uuid || recordingUuid; if (data.capture_mode) { this.recordingCaptureMode = data.capture_mode; } } else if (res.status !== 404 || !recordingUuid) { this.activeRecordingUuid = null; this.recordingCaptureMode = null; return; } if (isServerEgress) { this.activeRecordingUuid = null; this.recordingCaptureMode = null; return; } if (blob && recordingUuid) { const uploaded = await this.uploadRecording(blob, recordingUuid); if (!uploaded) { window.alert(`Recording upload failed. Open Recordings and refresh shortly, or re-record the ${this.sessionNoun()}.`); } } else if (recordingUuid) { await this.failServerRecording( recordingUuid, hadClientRecorder ? `No recording was captured from this ${this.sessionNoun()}.` : 'Recording capture stopped before any video was saved.', ); if (hadClientRecorder) { window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`); } } this.activeRecordingUuid = null; this.recordingCaptureMode = null; }, async abortServerRecording(reason) { await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ failed: true, reason }), }); }, async failServerRecording(recordingUuid, reason) { const url = `/room/${this.config.sessionUuid}/recordings/${recordingUuid}/fail`; await fetch(url, { method: 'POST', headers: this.headers(), body: JSON.stringify({ reason }), }); }, stopClientRecording() { return new Promise((resolve) => { const recorder = recordingMediaRecorder; if (!recorder || recorder.state === 'inactive') { recordingMediaRecorder = null; recordingChunks = []; resolve(null); return; } recorder.onstop = () => { const blob = new Blob(recordingChunks, { type: recorder.mimeType || 'video/webm', }); recordingMediaRecorder = null; recordingChunks = []; recorder.stream?.getTracks().forEach((track) => track.stop()); resolve(blob.size > 0 ? blob : null); }; recorder.stop(); }); }, async uploadRecording(blob, recordingUuid) { const form = new FormData(); form.append('recording', blob, `${recordingUuid}.webm`); const res = await fetch(`/room/${this.config.sessionUuid}/recordings/${recordingUuid}/upload`, { method: 'POST', headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' }, body: form, }); return res.ok; }, 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() { if (this.isSpace) { return; } 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.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return; this.breakoutCount = '4'; this.breakoutModalOpen = true; this.closeSidebar(); }, async submitBreakouts() { if (this.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return; const count = parseInt(this.breakoutCount, 10); if (!count || count < 1) return; const res = await fetch(this.config.breakoutsCreateUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ count }), }); if (!res.ok) { return; } this.breakoutModalOpen = false; this.breakoutsActive = true; }, async closeBreakouts() { if (this.isSpace || !this.isHost || !this.config.breakoutsCloseUrl) return; await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() }); this.closeSidebar(); this.breakoutsActive = false; this.activeBreakouts = []; }, async broadcastToBreakouts() { if (this.isSpace) { return; } const message = this.breakoutBroadcastInput.trim(); if (!this.isHost || !message || !this.config.breakoutsBroadcastUrl) { return; } const res = await fetch(this.config.breakoutsBroadcastUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ message }), }); if (!res.ok) { return; } this.breakoutBroadcastInput = ''; this.breakoutBroadcast = message; }, applyMediaSessionState(media) { if (!media) { return; } this.inBreakout = Boolean(media.in_breakout); this.assignedBreakoutRoomId = media.breakout_room_id ?? null; this.canPublish = Boolean(media.can_publish); this.audioOnlyPublish = Boolean(media.audio_only_publish); this.speakGranted = Boolean(media.speak_granted); this.speakRequested = Boolean(media.speak_requested); this.usesStageLayout = Boolean(media.uses_stage_layout); this.breakoutRoomName = media.breakout?.name || ''; this.breakoutClosesAt = media.breakout?.closes_at || ''; this.syncToolbarConditionalVisibility(); }, async syncMediaSessionFromPoll(data) { const media = data.media; if (!media) { return; } const previousBreakoutId = this.assignedBreakoutRoomId; const previousCanPublish = this.canPublish; const previousAudioOnlyPublish = this.audioOnlyPublish; const previousSpeakGranted = this.speakGranted; const previousUsesStageLayout = this.usesStageLayout; this.applyMediaSessionState(media); this.breakoutsActive = Boolean(data.breakouts_active); this.activeBreakouts = Array.isArray(data.breakouts) ? data.breakouts : []; const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId || previousCanPublish !== this.canPublish || previousAudioOnlyPublish !== this.audioOnlyPublish || previousSpeakGranted !== this.speakGranted || previousUsesStageLayout !== this.usesStageLayout; if (needsReconnect && this.config.configured) { await this.reconnectLiveKit(); } }, async reconnectLiveKit() { if (!this.config.mediaTokenUrl) { return; } try { const res = await fetch(this.config.mediaTokenUrl, { headers: { Accept: 'application/json' } }); if (!res.ok) { return; } const data = await res.json(); this.config.token = data.token; this.applyMediaSessionState(data); this.breakoutsActive = Boolean(data.breakouts_active); if (room) { await room.disconnect(); room = null; } connectPromise = null; document.getElementById('video-grid')?.replaceChildren(); document.getElementById('meet-stage-spotlight')?.replaceChildren(); document.getElementById('meet-audience-strip')?.replaceChildren(); this.connectionStatus = this.inBreakout ? 'Joining breakout…' : 'Returning to main room…'; await this.connectLiveKit(); this.applyStageLayout(); } catch (error) { console.error(error); this.connectionStatus = this.connectionErrorMessage(error); } }, breakoutTimeRemaining() { if (!this.breakoutClosesAt) { return ''; } const closes = Date.parse(this.breakoutClosesAt); if (Number.isNaN(closes)) { return ''; } const seconds = Math.max(0, Math.floor((closes - Date.now()) / 1000)); if (seconds === 0) { return 'Ending soon'; } const minutes = Math.floor(seconds / 60); const remainder = seconds % 60; return minutes > 0 ? `${minutes}m ${remainder}s remaining` : `${remainder}s remaining`; }, toggleProgramme() { if (this.isSpace) { return; } this.toggleSidebarPanel('programme'); }, openUploadPicker() { if (this.isSpace) { return; } document.getElementById('meet-file-input')?.click(); this.closeSidebar(); }, async toggleRecordingFromMenu() { await this.toggleRecording(); this.closeSidebar(); }, async toggleLockFromMenu() { if (this.isSpace) { return; } await this.toggleLock(); this.closeSidebar(); }, openEndMeetingConfirm() { this.endMeetingConfirmOpen = true; }, submitEndMeeting() { if (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder) { this.stopServerAndUploadRecording().finally(() => { document.getElementById('meet-end-form')?.requestSubmit(); }); } else { document.getElementById('meet-end-form')?.requestSubmit(); } this.endMeetingConfirmOpen = false; }, async submitLeave() { if (this.isHost && (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder)) { await this.stopServerAndUploadRecording(); } document.getElementById('meet-leave-form')?.submit(); }, 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, }); }, attachExistingTracks(participant) { if (!participant) return; const isLocal = room && participant === room.localParticipant; if (!isLocal) { participant.audioTrackPublications.forEach((pub) => { if (pub.track) { this.attachTrack(pub.track, pub, participant); } }); } participant.videoTrackPublications.forEach((pub) => { if (this.audioOnly) return; if (!pub.track) return; // Include screen share — previously skipped, so some viewers never saw it // (or could not recover after a temporary mute/unsubscribe). if (pub.source === Track.Source.ScreenShare) { this.attachTrack(pub.track, pub, participant); if (isLocal) { this.guardLocalScreenShare(pub.track); } return; } if (!pub.isMuted) { this.attachTrack(pub.track, pub, participant); } }); this.updateParticipantTile(participant); }, clearScreenShareOverlay({ force = false } = {}) { // Unless forced (nobody sharing), prefer re-syncing from room state so a // transient event cannot blank the share for remaining viewers. if (!force && this.findActiveScreenShare()) { this.syncScreenShareOverlay(); return; } const screen = document.getElementById('screen-share'); if (!screen) { return; } screen.querySelectorAll('video').forEach((element) => { element.srcObject = null; }); screen.classList.add('hidden'); screen.replaceChildren(); if (room?.localParticipant) { this.isScreenSharing = Array.from(room.localParticipant.videoTrackPublications.values()) .some((pub) => pub.source === Track.Source.ScreenShare && pub.track); } else { this.isScreenSharing = false; } if (!room || this.audioOnly) { return; } const participants = [room.localParticipant, ...room.remoteParticipants.values()]; participants.forEach((participant) => this.reattachParticipantVideo(participant)); this.syncParticipants(); }, attachTrack(track, publication, participant) { if (track.kind === Track.Kind.Audio) { if (participant === room?.localParticipant) return; const element = track.attach(); element.className = 'meet-remote-audio'; element.style.display = 'none'; if (!element.parentElement) { document.body.appendChild(element); } element.play().catch(() => { this.audioPlaybackBlocked = true; this.registerAudioUnlockGesture(); }); this.ensureAudioPlayback(); return; } if (track.kind !== Track.Kind.Video) return; if (this.audioOnly) { this.updateParticipantTile(participant); return; } const isScreen = this.isScreenShareSource(publication, track); if (isScreen) { const container = document.getElementById('screen-share'); if (!container) return; container.classList.remove('hidden'); // Avoid tearing down a healthy attachment of the same track. const existingVideo = container.querySelector('video'); const mediaTrack = track.mediaStreamTrack; if (existingVideo?.srcObject instanceof MediaStream && mediaTrack) { const alreadyAttached = existingVideo.srcObject.getVideoTracks() .some((t) => t.id === mediaTrack.id); if (alreadyAttached) { existingVideo.play?.().catch(() => {}); if (participant === room?.localParticipant) { this.isScreenSharing = true; this.guardLocalScreenShare(track); } return; } } container.innerHTML = ''; const el = track.attach(); el.className = 'h-full w-full rounded-xl object-contain'; el.playsInline = true; el.autoplay = true; container.appendChild(el); el.play?.().catch(() => {}); if (mediaTrack && !mediaTrack._ladillEndedBound) { mediaTrack._ladillEndedBound = true; mediaTrack.addEventListener('ended', () => { // User stopped sharing via the browser chrome, or capture truly ended. if (participant === room?.localParticipant) { this.isScreenSharing = false; room?.localParticipant?.setScreenShareEnabled(false).catch(() => {}); } this.syncScreenShareOverlay(); }); } if (participant === room?.localParticipant) { this.isScreenSharing = true; this.guardLocalScreenShare(track); } return; } if (publication?.isMuted) { this.updateParticipantTile(participant); return; } this.attachVideoToTile(participant, track); }, syncParticipants() { if (!room) return; if (this.isSpace && !this.usesStageLayout) { this.renderSpaceSpeakerTiles(); this.syncActiveSpeakers(room.activeSpeakers ?? []); return; } const all = [room.localParticipant, ...room.remoteParticipants.values()]; const activeIdentities = new Set(all.map((p) => p.identity)); this.participants = all.map((p) => ({ identity: p.identity, name: p.name })); all.forEach((participant) => this.updateParticipantTile(participant)); const grid = document.getElementById('video-grid'); if (!grid) return; grid.querySelectorAll('[data-participant-tile]').forEach((tile) => { if (!activeIdentities.has(tile.dataset.participantTile)) { tile.remove(); } }); this.applyStageLayout(); this.updateGalleryGridLayout(); this.updateAudioGridLayout(); this.syncActiveSpeakers(room.activeSpeakers ?? []); }, updateAudioGridLayout() { const grid = document.getElementById('video-grid'); if (!grid || !this.audioOnly || this.usesStageLayout) { return; } const count = grid.querySelectorAll('[data-participant-tile]').length; grid.classList.toggle('meet-audio-grid--solo', count === 1); grid.classList.toggle('meet-space-grid--few', count > 1 && count <= 3); }, updateGalleryGridLayout() { const grid = document.getElementById('video-grid'); if (!grid || this.usesStageLayout || this.audioOnly) { return; } const count = grid.querySelectorAll('[data-participant-tile]').length; grid.classList.toggle('meet-gallery-grid--solo', count === 1); grid.classList.toggle('meet-gallery-grid--many', count >= 5); grid.classList.toggle('meet-gallery-grid--dense', count >= 9); }, pruneTilesFromSession() { const joinedIds = new Set( this.sessionParticipants .filter((p) => p.status === 'joined') .map((p) => p.uuid), ); document.querySelectorAll('[data-participant-tile]').forEach((tile) => { const identity = tile.dataset.participantTile; if (room?.localParticipant?.identity === identity) return; if (!joinedIds.has(identity)) { tile.remove(); } }); if (room) { [room.localParticipant, ...room.remoteParticipants.values()].forEach((participant) => { if (this.isSpace && !this.usesStageLayout) { return; } if (!this.getTileContainerForIdentity(participant.identity)) { this.removeParticipantTile(participant.identity); } }); } if (this.isSpace && !this.usesStageLayout) { this.renderSpaceSpeakerTiles(); return; } this.applyStageLayout(); }, async toggleMute() { if (!room || !this.showMicrophoneControl()) return; await this.ensureAudioPlayback(); const enabled = room.localParticipant.isMicrophoneEnabled; if (enabled) { await room.localParticipant.setMicrophoneEnabled(false); } else { try { await room.localParticipant.setMicrophoneEnabled(true, { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }); this.needsMediaUnlock = false; } catch (error) { console.error(error); this.connectionStatus = this.connectionErrorMessage(error); this.needsMediaUnlock = true; return; } } this.syncLocalMediaState(); this.updateParticipantTile(room.localParticipant); }, async toggleVideo() { if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return; await this.ensureAudioPlayback(); 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 || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return; const enabling = !this.isScreenSharing; try { if (enabling) { await room.localParticipant.setScreenShareEnabled(true, { // Prefer detail over motion for slides/docs; contentHint helps some browsers // keep producing frames while the Meet tab is not focused. contentHint: 'detail', resolution: { width: 1920, height: 1080, frameRate: 15 }, }); const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare); if (pub?.track) { this.guardLocalScreenShare(pub.track); this.attachTrack(pub.track, pub, room.localParticipant); } this.isScreenSharing = Boolean(pub?.track); } else { await room.localParticipant.setScreenShareEnabled(false); this.isScreenSharing = false; this.syncScreenShareOverlay(); } } catch (error) { console.error(error); this.syncLocalMediaState(); this.syncScreenShareOverlay(); } }, setupPictureInPicture() { this.pictureInPictureSupported = typeof document !== 'undefined' && 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled !== false; if (!this.pictureInPictureSupported) { return; } document.addEventListener('visibilitychange', () => { if (document.hidden) { this.maybeAutoEnterPictureInPicture(); } }); document.addEventListener('leavepictureinpicture', () => { this.pictureInPictureActive = false; }); }, showPictureInPictureControl() { return this.pictureInPictureSupported && !this.audioOnly; }, findPictureInPictureVideo() { if (this.isScreenSharing) { const screenVideo = document.querySelector('#screen-share video'); if (screenVideo) { return screenVideo; } } const localId = this.config.participantUuid; const priorities = []; if (room?.localParticipant?.isCameraEnabled) { priorities.push(localId); } this.activeSpeakerIds.forEach((id) => { if (!priorities.includes(id)) { priorities.push(id); } }); for (const id of priorities) { const video = this.findParticipantTile(id)?.querySelector('[data-tile-stage] video'); if (video) { return video; } } return document.querySelector('[data-participant-tile] [data-tile-stage] video'); }, async maybeAutoEnterPictureInPicture() { if (!this.pictureInPictureSupported || this.pictureInPictureActive || this.audioOnly) { return; } if (document.pictureInPictureElement) { return; } await this.enterPictureInPicture({ silent: true }); }, async togglePictureInPicture() { if (!this.pictureInPictureSupported) { return; } if (document.pictureInPictureElement) { await document.exitPictureInPicture(); this.pictureInPictureActive = false; return; } await this.enterPictureInPicture(); }, async enterPictureInPicture({ silent = false } = {}) { const video = this.findPictureInPictureVideo(); if (!video) { if (!silent) { this.connectionStatus = 'No video available to pop out'; } return; } try { await video.requestPictureInPicture(); this.pictureInPictureActive = true; } catch { if (!silent) { this.connectionStatus = 'Pop-out blocked — try again from the toolbar'; } } }, async raiseHand() { await fetch(this.config.raiseHandUrl, { method: 'POST', headers: this.headers(), }); }, showMicrophoneControl() { if (!this.canPublish) { return false; } if (this.audioOnlyPublish) { return this.speakGranted; } return true; }, syncToolbarConditionalVisibility() { document.querySelectorAll('[data-toolbar-conditional]').forEach((element) => { element.removeAttribute('hidden'); }); }, showVideoControl() { return this.canPublish && !this.audioOnly && !this.audioOnlyPublish; }, showAudienceControls() { if (this.isSpace) { return !this.canPublish; } if (!this.usesSpeakAccess) { return this.canPublish; } return !this.canPublish || this.audioOnlyPublish; }, /** Mobile primary toolbar — webinars/conferences and audio room listeners. */ showPrimaryToolbarAudienceControls() { if (this.isSpace) { return !this.canPublish; } if (!this.usesSpeakAccess) { return false; } return this.showAudienceControls(); }, showStagePublisherControls() { return this.usesSpeakAccess && this.canPublish && !this.audioOnlyPublish; }, speakRequestParticipants() { return this.inCallParticipants().filter((person) => person.speak_requested && !person.speak_granted); }, handRaisedParticipants() { return this.inCallParticipants().filter((person) => ( person.hand_raised && person.role === 'attendee' && !person.speak_requested )); }, spaceSpeakInvitePendingParticipants() { return this.inCallParticipants().filter((person) => ( person.role === 'attendee' && person.speak_requested )); }, spaceSpeakerUrl(template, participantUuid) { return template?.replace('__UUID__', participantUuid) || ''; }, mergeSessionParticipant(participant) { if (!participant?.uuid) { return; } const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid); if (index >= 0) { this.sessionParticipants[index] = { ...this.sessionParticipants[index], ...participant }; } if (participant.uuid === this.config.participantUuid) { this.canPublish = ['host', 'co_host', 'panelist'].includes(participant.role); this.speakRequested = Boolean(participant.speak_requested); this.syncToolbarConditionalVisibility(); } if (this.isSpace && !this.usesStageLayout) { this.renderSpaceSpeakerTiles(); } }, async refreshMediaAccess() { if (!this.config.configured) { return; } await this.reconnectLiveKit(); }, async inviteSpaceSpeaker(participantUuid) { const url = this.spaceSpeakerUrl(this.config.spacePromoteUrl, participantUuid); if (!this.isSpace || !this.isHost || !url) { return; } const res = await fetch(url, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSessionParticipant(data.participant); }, async acceptSpaceSpeakInvitation() { if (!this.isSpace || !this.config.spaceSpeakAcceptUrl) { return; } const res = await fetch(this.config.spaceSpeakAcceptUrl, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSessionParticipant(data.participant); await this.refreshMediaAccess(); }, async declineSpaceSpeakInvitation() { if (!this.isSpace || !this.config.spaceSpeakDeclineUrl) { return; } const res = await fetch(this.config.spaceSpeakDeclineUrl, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSessionParticipant(data.participant); }, async dismissSpaceSpeakInvitation(participantUuid) { const url = this.spaceSpeakerUrl(this.config.spaceSpeakDismissUrl, participantUuid); if (!this.isSpace || !this.isHost || !url) { return; } const res = await fetch(url, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSessionParticipant(data.participant); }, async promoteSpaceCoHost(participantUuid) { const url = this.spaceSpeakerUrl(this.config.spaceCoHostUrl, participantUuid); if (!this.isSpace || !this.isHost || !url) { return; } const res = await fetch(url, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSessionParticipant(data.participant); }, async demoteSpaceSpeaker(participantUuid) { const url = this.spaceSpeakerUrl(this.config.spaceDemoteUrl, participantUuid); if (!this.isSpace || !this.isHost || !url) { return; } const res = await fetch(url, { method: 'DELETE', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSessionParticipant(data.participant); }, syncSelfSpeakState() { const self = this.sessionParticipants.find((person) => person.uuid === this.config.participantUuid); if (!self) { return; } this.speakGranted = Boolean(self.speak_granted); this.speakRequested = Boolean(self.speak_requested); }, mergeSpeakParticipant(participant) { if (!participant?.uuid) { return; } const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid); if (index >= 0) { this.sessionParticipants[index] = { ...this.sessionParticipants[index], speak_requested: Boolean(participant.speak_requested), speak_granted: Boolean(participant.speak_granted), }; } if (participant.uuid === this.config.participantUuid) { this.speakGranted = Boolean(participant.speak_granted); this.speakRequested = Boolean(participant.speak_requested); } }, speakActionUrl(template, participantUuid) { return template?.replace('__UUID__', participantUuid) || ''; }, async requestSpeak() { if (!this.config.speakRequestUrl) { return; } const res = await fetch(this.config.speakRequestUrl, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSpeakParticipant(data.participant); }, async cancelSpeakRequest() { if (!this.config.speakCancelUrl) { return; } const res = await fetch(this.config.speakCancelUrl, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSpeakParticipant(data.participant); }, async grantSpeakAccess(participantUuid) { const url = this.speakActionUrl(this.config.speakGrantUrl, participantUuid); if (!url) { return; } const res = await fetch(url, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSpeakParticipant(data.participant); await this.poll(); }, async revokeSpeakAccess(participantUuid) { const url = this.speakActionUrl(this.config.speakRevokeUrl, participantUuid); if (!url) { return; } const res = await fetch(url, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSpeakParticipant(data.participant); await this.poll(); }, async dismissSpeakRequest(participantUuid) { const url = this.speakActionUrl(this.config.speakDismissUrl, participantUuid); if (!url) { return; } const res = await fetch(url, { method: 'POST', headers: this.headers(), }); if (!res.ok) { return; } const data = await res.json(); this.mergeSpeakParticipant(data.participant); }, async goLive() { if (!this.config.goLiveUrl || this.goingLive) { return; } this.goingLive = true; if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } try { const res = await fetch(this.config.goLiveUrl, { method: 'POST', headers: this.headers(), }); if (!res.ok) { throw new Error('Go live failed'); } const data = await res.json(); window.location.replace(data.room_url || `/room/${data.session_uuid}`); } catch { this.goingLive = false; this.startPolling(); } }, async makeSpeaker(userRef) { if (!this.config.addPresenterUrl || !userRef) { return; } const res = await fetch(this.config.addPresenterUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ user_ref: userRef }), }); if (! res.ok) { return; } const data = await res.json(); this.presenterRefs = data.presenter_refs || this.presenterRefs; this.sessionParticipants = this.sessionParticipants.map((person) => ( person.user_ref === userRef ? { ...person, role: 'panelist' } : person )); this.applyStageLayout(); }, closeSidebar() { this.sidebarPanel = null; }, toggleSidebarPanel(name) { this.sidebarPanel = this.sidebarPanel === name ? null : name; if (this.sidebarPanel) { this.shareOpen = false; } }, toggleChat() { this.toggleSidebarPanel('chat'); }, toggleParticipants() { this.toggleSidebarPanel('participants'); }, toggleFeatures() { this.toggleSidebarPanel('features'); }, openParticipantsPanel() { this.sidebarPanel = 'participants'; this.shareOpen = false; }, async openShare() { const useNativeShare = navigator.share && this.joinUrl && window.matchMedia('(max-width: 767px)').matches; if (useNativeShare) { try { await navigator.share({ title: this.roomTitle || 'Ladill Meet', text: `Join my ${this.sessionNoun()} on Ladill Meet`, url: this.joinUrl, }); return; } catch (e) { // User cancelled or native share failed — fall through to copy-link panel. } } this.shareOpen = true; this.shareCopied = false; this.closeSidebar(); }, 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); if (kind === 'thumbs') { this.registerApplauseClap(this.displayName); } }, async poll() { if (this.pollInFlight || !this.config.pollUrl) { return; } this.pollInFlight = true; try { const headers = { Accept: 'application/json' }; if (this.pollEtag) { headers['If-None-Match'] = this.pollEtag; } const res = await fetch(this.config.pollUrl, { headers, credentials: 'same-origin' }); const responseEtag = res.headers.get('ETag'); if (responseEtag) { this.pollEtag = responseEtag; } if (res.status === 304) { return; } if (!res.ok) { if (res.status === 404 && this.config.endedUrl) { this.redirectToMeetingEnded(this.config.endedUrl); } return; } const data = await res.json(); if (data.ended && data.redirect) { if (this.goingLive) { return; } this.redirectToMeetingEnded(data.redirect); return; } if (data.participants) { const previousSpeakRequestCount = this.speakRequestParticipants().length; this.sessionParticipants = data.participants; if (this.usesSpeakAccess) { this.syncSelfSpeakState(); } this.pruneTilesFromSession(); this.refreshParticipantTileAvatars(); this.renderAudienceStrip(); const speakRequestCount = this.speakRequestParticipants().length; if (this.canManageSpeak && speakRequestCount > previousSpeakRequestCount) { this.openParticipantsPanel(); } } if (typeof data.uses_stage_layout === 'boolean' || typeof data.stage_layout === 'string') { this.mergeStageConfig(data); this.applyStageLayout(); } if (typeof data.waiting_count === 'number') { const previous = this.waitingCount; this.waitingCount = data.waiting_count; if (this.isHost && data.waiting_count > previous) { this.openParticipantsPanel(); } } this.updateChatFromPoll(data.messages || []); this.qaItems = data.qa || []; this.activePolls = data.polls || []; if (data.broadcasts?.length) { this.breakoutBroadcast = data.broadcasts[0].body; } await this.syncMediaSessionFromPoll(data); this.processReactionsFromPoll(data.reactions || []); } catch (e) { // ignore poll errors } finally { this.pollInFlight = false; } }, sessionNoun() { if (this.sessionNounLabel) { return this.sessionNounLabel; } return this.isConference ? 'conference' : 'meeting'; }, stageLayoutLabel() { return this.stageLayout === 'panel' ? 'Panel discussion' : 'Plenary'; }, conferenceStatusLabel() { if (this.inBreakout) { return 'Breakout · meeting mode'; } if (this.isGreenRoom) { if (this.usesStageLayout) { return `Green room · ${this.stageLayoutLabel()}`; } return 'Green room'; } if (!this.usesStageLayout) { return 'Conference live'; } return this.stageLayout === 'panel' ? 'Panel discussion live' : 'Plenary live'; }, conferenceStatusClass() { if (this.inBreakout) { return 'text-amber-400/90'; } if (this.isGreenRoom) { return 'text-violet-400/90'; } if (this.stageLayout === 'panel') { return 'text-violet-400/90'; } return 'text-sky-400/90'; }, stageLayoutDescription() { if (this.stageLayout === 'panel') { const panelName = this.activePanelName(); if (panelName) { return `Showing ${panelName} — multiple speakers in a split grid.`; } return 'Multiple speakers shown together in a split-screen grid.'; } const spotlight = this.findSpeakerBySpotlightKey(this.resolveSpotlightKey()); if (spotlight) { return `${spotlight.display_name} is on stage.`; } return 'Choose who fills the stage from Stage layout or Spotlight on a speaker.'; }, spotlightSpeakerName() { return this.findSpeakerBySpotlightKey(this.resolveSpotlightKey())?.display_name || ''; }, activePanelName() { if (!this.activePanelId) { return ''; } const panel = this.panels.find((entry) => entry.id === this.activePanelId); return panel?.name || ''; }, openPanelSetup() { this._stageSnapshot = { stageLayout: this.stageLayout, panels: JSON.parse(JSON.stringify(this.panels)), activePanelId: this.activePanelId, panelDiscussions: this.panelDiscussions, spotlightSpeakerRef: this.spotlightSpeakerRef, }; this.panelSetupOpen = true; }, cancelPanelSetup() { if (this._stageSnapshot) { this.stageLayout = this._stageSnapshot.stageLayout; this.panels = JSON.parse(JSON.stringify(this._stageSnapshot.panels)); this.activePanelId = this._stageSnapshot.activePanelId; this.panelDiscussions = this._stageSnapshot.panelDiscussions; this.spotlightSpeakerRef = this._stageSnapshot.spotlightSpeakerRef || ''; this.applyStageLayout(); } this._stageSnapshot = null; this.panelSetupOpen = false; }, selectStageLayout(layout) { this.stageLayout = layout; if (layout === 'panel') { this.panelDiscussions = true; if (this.panels.length === 0) { this.addPanel(); } if (!this.activePanelId && this.panels[0]) { this.activePanelId = this.panels[0].id; } } }, sessionNounTitle() { return this.isConference ? 'Conference' : 'Meeting'; }, redirectToMeetingEnded(url) { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } if (room) { room.disconnect().catch(() => {}); } window.location.href = url; }, 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); }, initApplauseAudio(url) { if (!url) { return; } applauseAudio = new Audio(url); applauseAudio.preload = 'auto'; }, isApplauseEmoji(emoji) { return APPLAUSE_EMOJIS.has(emoji); }, registerApplauseClap(senderName) { if (!senderName) { return; } applauseClapTimes.push({ at: Date.now(), sender: senderName }); this.maybePlayApplauseSound(); }, maybePlayApplauseSound() { if (!applauseAudio?.src) { return; } const now = Date.now(); applauseClapTimes = applauseClapTimes.filter((entry) => now - entry.at <= APPLAUSE_WINDOW_MS); const clappers = new Set(applauseClapTimes.map((entry) => entry.sender)); if (clappers.size < APPLAUSE_MIN_CLAPPERS) { return; } if (now - lastApplauseSoundAt < APPLAUSE_SOUND_COOLDOWN_MS) { return; } const volume = 0.35 + Math.min(clappers.size - APPLAUSE_MIN_CLAPPERS + 1, 4) * 0.12; this.playApplauseSound(volume); }, playApplauseSound(volume = 0.7) { if (!applauseAudio?.src) { return; } try { applauseAudio.volume = Math.min(1, Math.max(0.2, volume)); applauseAudio.currentTime = 0; applauseAudio.play().catch(() => {}); lastApplauseSoundAt = Date.now(); } catch { // Browser blocked playback until user gesture. } }, processReactionsFromPoll(reactions) { if (!Array.isArray(reactions)) { return; } if (!this.reactionsInitialized) { reactions.forEach((reaction) => { seenReactionKeys.add(`${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`); }); this.reactionsInitialized = true; return; } reactions.forEach((reaction) => { const key = `${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`; if (seenReactionKeys.has(key)) { return; } seenReactionKeys.add(key); if (this.isApplauseEmoji(reaction.emoji)) { if (reaction.sender_name !== this.displayName) { this.showFloatingReaction('thumbs'); } this.registerApplauseClap(reaction.sender_name); return; } if (reaction.emoji === '❤️' && reaction.sender_name !== this.displayName) { this.showFloatingReaction('heart'); } }); if (seenReactionKeys.size > 200) { seenReactionKeys = new Set([...seenReactionKeys].slice(-100)); } }, 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();