import Alpine from 'alpinejs';
import { ConnectionState, Room, RoomEvent, Track } 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,
        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,
        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,
        pollTimer: null,
        config: {},

        init() {
            if (initialized) return;
            initialized = true;

            const el = document.getElementById('meet-config');
            if (!el) return;

            this.config = {
                token: el.dataset.token,
                url: el.dataset.url,
                configured: el.dataset.configured === '1',
                pollUrl: el.dataset.pollUrl,
                endedUrl: el.dataset.endedUrl,
                chatUrl: el.dataset.chatUrl,
                reactionUrl: el.dataset.reactionUrl,
                raiseHandUrl: el.dataset.raiseHandUrl,
                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.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.pollTimer = setInterval(() => this.poll(), 3000);
            this.applyStageLayout();

            this.$nextTick(() => {
                this.syncToolbarConditionalVisibility();
                document.querySelector('.meet-toolbar__track')?.classList.add('is-ready');
            });
        },

        connectionErrorMessage(error) {
            const name = error?.name || '';
            const message = error?.message || '';

            if (name === 'NotAllowedError' || message.includes('Permission denied')) {
                return 'Connected — allow camera and microphone in your browser';
            }
            if (name === 'NotFoundError' || message.includes('Requested device not found')) {
                return 'Connected — no camera or microphone found';
            }
            if (message.includes('401') || message.toLowerCase().includes('unauthorized')) {
                return 'LiveKit rejected the session token';
            }
            if (message.includes('engine not connected within timeout')) {
                return 'In call — camera/mic timed out. Use the mic/camera buttons to retry.';
            }
            if (message) {
                return `Connection failed — ${message}`;
            }

            return 'Connection failed';
        },

        async connectLiveKit() {
            if (connectPromise) {
                return connectPromise;
            }

            connectPromise = this.startLiveKitSession();

            try {
                await connectPromise;
            } catch (e) {
                connectPromise = null;
                throw e;
            }
        },

        async startLiveKitSession() {
            try {
                room = new Room({
                    adaptiveStream: true,
                    dynacast: true,
                    disconnectOnPageLeave: true,
                });

                room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
                    this.attachTrack(track, publication, participant);
                });
                room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
                    track.detach();
                    if (track.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
                        this.updateParticipantTile(participant);
                    }
                });
                room.on(RoomEvent.TrackMuted, (publication, participant) => {
                    if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
                        publication.track?.detach();
                        this.updateParticipantTile(participant);
                    }
                    if (publication.kind === Track.Kind.Audio) {
                        publication.track?.detach();
                        this.updateParticipantTile(participant);
                    }
                    if (participant === room.localParticipant) {
                        this.syncLocalMediaState();
                    }
                });
                room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
                    if (publication.kind === Track.Kind.Video && publication.track) {
                        this.attachTrack(publication.track, publication, participant);
                    }
                    if (publication.kind === Track.Kind.Audio && publication.track) {
                        this.attachTrack(publication.track, publication, participant);
                        this.updateParticipantTile(participant);
                    }
                    if (participant === room.localParticipant) {
                        this.syncLocalMediaState();
                    }
                });
                room.on(RoomEvent.LocalTrackPublished, (publication, participant) => {
                    if (publication.track?.kind === Track.Kind.Video) {
                        this.attachTrack(publication.track, publication, participant);
                    }
                    this.updateParticipantTile(participant);
                    this.syncLocalMediaState();
                });
                room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
                    if (publication.kind === Track.Kind.Video) {
                        this.updateParticipantTile(participant);
                    }
                    this.syncLocalMediaState();
                });
                room.on(RoomEvent.MediaDevicesError, (error) => {
                    console.error(error);
                    this.needsMediaUnlock = true;
                    this.connectionStatus = this.connectionErrorMessage(error);
                    this.registerAudioUnlockGesture();
                });
                room.on(RoomEvent.ParticipantConnected, (participant) => {
                    this.attachExistingTracks(participant);
                    this.syncParticipants();
                });
                room.on(RoomEvent.ParticipantDisconnected, (participant) => {
                    this.removeParticipantTile(participant.identity);
                    this.syncParticipants();
                });
                room.on(RoomEvent.Disconnected, () => {
                    this.connectionStatus = 'Disconnected';
                });
                room.on(RoomEvent.AudioPlaybackStatusChanged, () => {
                    this.audioPlaybackBlocked = room ? !room.canPlaybackAudio : false;
                    if (this.audioPlaybackBlocked) {
                        this.connectionStatus = 'Connected — click anywhere to enable audio';
                        this.registerAudioUnlockGesture();
                    } else if (this.connectionStatus.includes('enable audio')) {
                        this.connectionStatus = 'Connected';
                    }
                });
                room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
                    this.syncActiveSpeakers(speakers);
                });

                await room.prepareConnection(this.config.url, this.config.token);

                await room.connect(this.config.url, this.config.token, {
                    autoSubscribe: true,
                    peerConnectionTimeout: 20_000,
                    websocketTimeout: 20_000,
                });

                await this.waitForRoomConnected(room);

                this.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;
        },

        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 `
                <div class="flex w-full justify-center">
                    <div data-tile-avatar-circle class="flex h-[4.75rem] w-[4.75rem] shrink-0 items-center justify-center rounded-full text-lg font-semibold text-white"></div>
                </div>
                <span data-tile-name class="mt-2 max-w-full truncate text-center text-sm font-bold leading-tight text-white"></span>
                <div data-tile-role-row class="mt-1 flex max-w-full items-center justify-center gap-1">
                    <span data-tile-speaking class="hidden shrink-0 text-sky-400" title="Speaking">
                        <svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
                            <rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
                            <rect class="meet-speaking-bar meet-speaking-bar--2" x="6.75" y="3" width="2.5" height="10" rx="1"/>
                            <rect class="meet-speaking-bar meet-speaking-bar--3" x="12.5" y="6" width="2.5" height="4" rx="1"/>
                        </svg>
                    </span>
                    <span data-tile-mic class="hidden shrink-0 text-slate-500" title="Muted">
                        <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
                    </span>
                    <span data-tile-role class="truncate text-xs text-slate-400"></span>
                </div>`;
        },

        participantTileShellHtml() {
            return `
                    <div data-tile-body class="relative min-h-0 flex-1">
                        <div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
                        <div data-tile-avatar class="absolute inset-0 flex items-center justify-center">
                            <div data-tile-avatar-circle class="flex h-20 w-20 shrink-0 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg"></div>
                        </div>
                    </div>
                    <div data-tile-footer class="relative flex shrink-0 items-center justify-between gap-2 border-t border-slate-800/80 bg-slate-950/95 px-3 py-2">
                        <span data-tile-name class="truncate text-xs font-medium text-white"></span>
                        <div class="flex shrink-0 items-center gap-1.5">
                            <span data-tile-speaking class="hidden meet-speaking-badge rounded-full bg-emerald-500/90 p-1 text-white" title="Speaking">
                                <svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
                                    <rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
                                    <rect class="meet-speaking-bar meet-speaking-bar--2" x="6.75" y="3" width="2.5" height="10" rx="1"/>
                                    <rect class="meet-speaking-bar meet-speaking-bar--3" x="12.5" y="6" width="2.5" height="4" rx="1"/>
                                </svg>
                            </span>
                            <span data-tile-mic class="hidden rounded-full bg-black/50 p-1 text-red-400" title="Muted">
                                <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
                            </span>
                            <span data-tile-cam class="hidden rounded-full bg-black/50 p-1 text-slate-300" title="Camera off">
                                <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
                            </span>
                        </div>
                    </div>`;
        },

        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');
                if (!stage?.querySelector('video')) {
                    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;
            }

            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 = 'h-full w-full object-cover';
            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);
        },

        setupCaptionPipeline() {
            if (!this.liveCaptions || !this.config.captionUrl) return;
            if (room?.localParticipant?.isMicrophoneEnabled) return;

            const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
            if (!SpeechRecognition) return;

            const recognition = new SpeechRecognition();
            recognition.continuous = true;
            recognition.interimResults = true;
            recognition.onresult = (event) => {
                const text = event.results[event.results.length - 1][0].transcript.trim();
                if (!text) return;
                this.captionText = text;
                fetch(this.config.captionUrl, {
                    method: 'POST',
                    headers: this.headers(),
                    body: JSON.stringify({ text }),
                }).catch(() => {});
            };
            recognition.onerror = () => {};
            try {
                recognition.start();
            } catch (e) {
                // browser may block without gesture
            }
        },

        async maybeStartAutoRecording() {
            if (!this.isHost || !this.isRecording || recordingMediaRecorder) {
                return;
            }

            const el = document.getElementById('meet-config');
            const existingUuid = this.activeRecordingUuid || el?.dataset.activeRecordingUuid || '';

            if (existingUuid) {
                this.activeRecordingUuid = existingUuid;
            } 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 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;
            if (! await this.tryStartClientRecordingWithRetry()) {
                await this.abortServerRecording('Could not capture meeting video in this browser.');
                this.isRecording = false;
                this.activeRecordingUuid = 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 hadClientRecorder = !!recordingMediaRecorder;
            const blob = 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;
            } else if (res.status !== 404 || !recordingUuid) {
                this.activeRecordingUuid = 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;
        },

        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 && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
                    this.attachTrack(pub.track, pub, participant);
                }
            });

            this.updateParticipantTile(participant);
        },

        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 = publication?.source === Track.Source.ScreenShare
                || track.source === Track.Source.ScreenShare;

            if (isScreen) {
                const container = document.getElementById('screen-share');
                if (!container) return;
                container.classList.remove('hidden');
                container.innerHTML = '';
                const el = track.attach();
                el.className = 'h-full w-full rounded-xl object-contain';
                container.appendChild(el);
                return;
            }

            if (publication?.isMuted) {
                this.updateParticipantTile(participant);
                return;
            }

            this.attachVideoToTile(participant, track);
        },

        syncParticipants() {
            if (!room) return;

            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;
            this.isScreenSharing = !this.isScreenSharing;
            await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
            if (!this.isScreenSharing) {
                const screen = document.getElementById('screen-share');
                if (screen) {
                    screen.classList.add('hidden');
                    screen.innerHTML = '';
                }
            }
        },

        async raiseHand() {
            await fetch(this.config.raiseHandUrl, {
                method: 'POST',
                headers: this.headers(),
            });
        },

        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.pollTimer = setInterval(() => this.poll(), 3000);
            }
        },

        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() {
            try {
                const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } });
                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
            }
        },

        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 = `
                <div class="rounded-2xl px-3 py-2 ${isOwn ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-100'}">
                    ${isOwn ? '' : `<p class="mb-0.5 text-xs font-medium text-sky-300">${this.escape(message.sender_name)}</p>`}
                    <p class="text-sm leading-relaxed">${this.escape(message.body)}</p>
                </div>
                <p class="mt-1 text-[10px] text-slate-500 ${isOwn ? 'text-right' : ''}">${this.escape(time)}</p>`;

            container.appendChild(div);
            container.scrollTop = container.scrollHeight;
        },

        showFloatingReaction(kind) {
            const id = Date.now();
            this.floatingReactions.push({ id, kind });
            setTimeout(() => {
                this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id);
            }, 2000);
        },

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