CB Stream Preview

Hover over a room card to preview the stream in a floating popup with volume, opacity, and PiP controls.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         CB Stream Preview
// @namespace    https://sleazyfork.org/en/users/1592930-lucieee
// @version      1.012
// @description  Hover over a room card to preview the stream in a floating popup with volume, opacity, and PiP controls.
// @author       Lucieee
// @match        https://chaturbate.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chaturbate.com
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // === CONFIGURATION ===
    const DEBUG = false;
    const HOVER_DELAY = 400;
    const PREVIEW_WIDTH = 480;
    const PREVIEW_HEIGHT = 270;
    const DEFAULT_VOLUME = 0.2;

    // --- Feature Toggles ---
    const FEATURES = {
        VOLUME_TOGGLE:        true,
        OPACITY_SLIDER:       true,
        LOADING_INDICATOR:    true,
        FADE_ANIMATION:       true,
        ROOM_LABEL:           true,
        STICKY_ON_CLICK:      true,
        SCROLL_TRACKING:      true,
        AUTO_QUALITY:         true,
        DEBOUNCE_HOVER:       true,
        SPA_NAVIGATION_GUARD: true,
        ERROR_STATE:          true,
        PICTURE_IN_PICTURE:   true,
    };
    // =======================

    let currentPlayer  = null;
    let currentPopup   = null;
    let currentContainer = null;
    let currentRoomSlug  = null;
    let hoverTimeout   = null;
    let pendingFetch   = null;
    let isSticky       = false;
    let isMuted        = true;
    let followedPanelObserver = null;
    let isFollowedPanelOpen   = false;

    function log(...args) {
        if (DEBUG) console.log('[CB-PREVIEW]', ...args);
    }

    // ── Utilities ──────────────────────────────────────────────────────────────

    function getCookie(name) {
        if (!document.cookie) return null;
        for (const raw of document.cookie.split(';')) {
            const c = raw.trim();
            if (c.startsWith(name + '='))
                return decodeURIComponent(c.slice(name.length + 1));
        }
        return null;
    }

    function getBandwidth() {
        if (!FEATURES.AUTO_QUALITY) return 'high';
        try {
            const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
            if (conn && conn.downlink) {
                if (conn.downlink < 2) return 'low';
                if (conn.downlink < 5) return 'medium';
            }
        } catch(e) {}
        return 'high';
    }

    async function getStreamUrl(roomSlug, signal) {
        log('Fetching stream for:', roomSlug);
        const csrf = getCookie('csrftoken');
        if (!csrf) throw new Error('No CSRF token');

        const body = new URLSearchParams();
        body.append('room_slug', roomSlug);
        body.append('bandwidth', getBandwidth());

        const res = await fetch('/get_edge_hls_url_ajax/', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'X-CSRFToken': csrf,
                'X-Requested-With': 'XMLHttpRequest'
            },
            body,
            signal
        });
        if (!res.ok) throw new Error('HTTP ' + res.status);
        const data = await res.json();
        if (data.url) return data.url;
        throw new Error('No stream URL in response');
    }

    // ── Inject global styles once ──────────────────────────────────────────────

    function ensureStyles() {
        if (document.getElementById('cb-preview-styles')) return;
        const s = document.createElement('style');
        s.id = 'cb-preview-styles';
        s.textContent = `
            @keyframes cb-spin { to { transform: rotate(360deg); } }
            .cb-range-slider {
                -webkit-appearance: none;
                appearance: none;
                background: transparent;
                cursor: pointer;
            }
            .cb-range-slider::-webkit-slider-runnable-track {
                background: rgba(255,255,255,0.3);
                height: 4px;
                border-radius: 2px;
            }
            .cb-range-slider::-webkit-slider-thumb {
                -webkit-appearance: none;
                height: 12px;
                width: 12px;
                background: #fff;
                border-radius: 50%;
                margin-top: -4px;
            }
        `;
        document.head.appendChild(s);
    }

    // ── SVG icons ──────────────────────────────────────────────────────────────

    const iconMute = () => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>';
    const iconUnmute = () => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>';
    const iconPin = () => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L9 9H2l5.5 4-2 7L12 16l6.5 4-2-7L22 9h-7z"/></svg>';
    const iconPinActive = () => '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L9 9H2l5.5 4-2 7L12 16l6.5 4-2-7L22 9h-7z"/></svg>';
    const iconOpacity = () => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2v20a10 10 0 0 0 0-20z" fill="currentColor"/></svg>';

    // ── Popup positioning ──────────────────────────────────────────────────────

    function getAbsolutePosition(rect) {
        const scrollY = window.scrollY;
        const scrollX = window.scrollX;
        let top, left;

        if (rect.bottom + PREVIEW_HEIGHT + 8 > window.innerHeight) {
            top = rect.top + scrollY - PREVIEW_HEIGHT - 8;
        } else {
            top = rect.bottom + scrollY + 8;
        }

        left = rect.left + scrollX;
        left = Math.min(left, scrollX + window.innerWidth - PREVIEW_WIDTH - 8);
        left = Math.max(left, scrollX + 8);
        top = Math.max(scrollY + 4, top);

        return { top, left };
    }

    // ── Build popup shell ──────────────────────────────────────────────────────

    function buildPopup(rect, roomSlug) {
        ensureStyles();
        const pos = getAbsolutePosition(rect);

        const popup = document.createElement('div');
        popup.id = 'cb-preview-popup';
        popup.style.cssText = [
            'position:absolute',
            'z-index:99999',
            `width:${PREVIEW_WIDTH}px`,
            `height:${PREVIEW_HEIGHT}px`,
            `top:${pos.top}px`,
            `left:${pos.left}px`,
            'border-radius:10px',
            'overflow:hidden',
            'background:#111',
            'box-shadow:0 8px 40px rgba(0,0,0,0.65)',
            'border:2px solid rgba(255,255,255,0.12)',
            'pointer-events:auto; outline:none;',
            FEATURES.FADE_ANIMATION ? 'opacity:0;transition:opacity 150ms ease' : ''
        ].filter(Boolean).join(';');

        if (FEATURES.LOADING_INDICATOR) {
            const spinner = document.createElement('div');
            spinner.className = 'cb-spinner';
            spinner.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:2;pointer-events:none';
            spinner.innerHTML = '<svg width="40" height="40" viewBox="0 0 40 40" fill="none" style="animation:cb-spin 0.8s linear infinite"><circle cx="20" cy="20" r="16" stroke="rgba(255,255,255,0.15)" stroke-width="3"/><path d="M20 4 A16 16 0 0 1 36 20" stroke="#fff" stroke-width="3" stroke-linecap="round"/></svg>';
            popup.appendChild(spinner);
        }

        if (FEATURES.ROOM_LABEL && roomSlug) {
            const label = document.createElement('div');
            label.style.cssText = 'position:absolute;bottom:0;left:0;right:0;padding:24px 10px 8px;background:linear-gradient(transparent,rgba(0,0,0,0.8));color:#fff;font-size:13px;font-weight:600;font-family:system-ui,sans-serif;z-index:3;pointer-events:auto;display:flex;align-items:center;gap:6px';
            const dot = document.createElement('span');
            dot.style.cssText = 'width:7px;height:7px;border-radius:50%;background:#f04;display:inline-block;flex-shrink:0';
            const nameLink = document.createElement('a');
            nameLink.textContent = roomSlug;
            nameLink.href = `https://chaturbate.com/${roomSlug}/`;
            nameLink.target = '_blank';
            nameLink.rel = 'noopener noreferrer';
            nameLink.style.cssText = 'color:#fff;text-decoration:none;border-bottom:1px solid rgba(255,255,255,0.4);padding-bottom:1px;cursor:pointer;transition:border-color 150ms ease';
            nameLink.addEventListener('mouseenter', () => nameLink.style.borderBottomColor = 'rgba(255,255,255,0.9)');
            nameLink.addEventListener('mouseleave', () => nameLink.style.borderBottomColor = 'rgba(255,255,255,0.4)');
            nameLink.addEventListener('click', e => e.stopPropagation());
            label.appendChild(dot);
            label.appendChild(nameLink);
            popup.appendChild(label);
        }

        if (FEATURES.VOLUME_TOGGLE) {
            const btn = document.createElement('button');
            btn.className = 'cb-vol-btn';
            btn.setAttribute('aria-label', 'Toggle mute');
            btn.style.cssText = 'position:absolute;top:8px;right:8px;z-index:4;width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;padding:0;transition:background 150ms ease';
            btn.innerHTML = isMuted ? iconMute() : iconUnmute();
            btn.addEventListener('click', e => {
                e.stopPropagation();
                const vid = popup.querySelector('video');
                if (!vid) return;
                isMuted = !isMuted;
                vid.muted = isMuted;
                if (!isMuted) vid.volume = DEFAULT_VOLUME;
                btn.innerHTML = isMuted ? iconMute() : iconUnmute();
            });
            popup.appendChild(btn);
        }

        if (FEATURES.OPACITY_SLIDER) {
            const opWrap = document.createElement('div');
            // Placed 44px from the right to sit right next to the mute button
            opWrap.style.cssText = 'position:absolute;top:8px;right:44px;z-index:4;height:30px;width:30px;border-radius:15px;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);display:flex;align-items:center;overflow:hidden;transition:all 200ms ease;cursor:pointer;';

            const opIcon = document.createElement('div');
            opIcon.style.cssText = 'width:30px;height:30px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:#fff;';
            opIcon.innerHTML = iconOpacity();

            const opSlider = document.createElement('input');
            opSlider.type = 'range';
            opSlider.className = 'cb-range-slider';
            opSlider.min = '20'; // Prevent it from becoming entirely invisible
            opSlider.max = '100';
            opSlider.value = '100';
            opSlider.style.cssText = 'width:60px;margin:0 8px 0 2px;opacity:0;transition:opacity 200ms ease;';

            opWrap.appendChild(opIcon);
            opWrap.appendChild(opSlider);

            // Expand on hover
            opWrap.addEventListener('mouseenter', () => {
                opWrap.style.width = '105px';
                opWrap.style.background = 'rgba(0,0,0,0.85)';
                setTimeout(() => opSlider.style.opacity = '1', 100);
            });

            // Collapse on leave
            opWrap.addEventListener('mouseleave', () => {
                opWrap.style.width = '30px';
                opWrap.style.background = 'rgba(0,0,0,0.55)';
                opSlider.style.opacity = '0';
            });

            // Prevent drag from moving the window
            opSlider.addEventListener('mousedown', e => e.stopPropagation());

            // Adjust opacity in real-time
            opSlider.addEventListener('input', e => {
                popup.style.opacity = e.target.value / 100;
            });

            popup.appendChild(opWrap);
        }

        if (FEATURES.STICKY_ON_CLICK) {
            const pinBtn = document.createElement('button');
            pinBtn.className = 'cb-pin-btn';
            pinBtn.setAttribute('aria-label', 'Pin preview');
            pinBtn.style.cssText = 'position:absolute;top:8px;left:8px;z-index:4;width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;padding:0;transition:background 150ms ease';
            pinBtn.innerHTML = iconPin();
            pinBtn.addEventListener('mouseenter', () => pinBtn.style.background = 'rgba(0,0,0,0.85)');
            pinBtn.addEventListener('mouseleave', () => pinBtn.style.background = isSticky ? 'rgba(30,100,30,0.8)' : 'rgba(0,0,0,0.55)');
            pinBtn.addEventListener('click', e => {
                e.stopPropagation();
                if (isSticky) {
                    isSticky = false;
                    forceCleanup();
                } else {
                    pinPopup();
                    pinBtn.innerHTML = iconPinActive();
                    pinBtn.style.background = 'rgba(30,100,30,0.8)';
                    pinBtn.setAttribute('aria-label', 'Unpin preview');
                }
            });
            popup.appendChild(pinBtn);
        }

        if (FEATURES.PICTURE_IN_PICTURE) {
            const pipBtn = document.createElement('button');
            pipBtn.className = 'cb-pip-btn';
            pipBtn.setAttribute('aria-label', 'Picture in picture');
            pipBtn.style.cssText = 'position:absolute;bottom:8px;right:8px;z-index:4;width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;padding:0;transition:background 150ms ease';
            pipBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2"/><rect x="12" y="12" width="8" height="6" rx="1" fill="currentColor"/></svg>';
            pipBtn.addEventListener('mouseenter', () => pipBtn.style.background = 'rgba(0,0,0,0.85)');
            pipBtn.addEventListener('mouseleave', () => pipBtn.style.background = 'rgba(0,0,0,0.55)');
            pipBtn.addEventListener('click', e => {
                e.stopPropagation();
                const vid = popup.querySelector('video');
                if (!vid || !document.pictureInPictureEnabled) return;
                if (document.pictureInPictureElement) {
                    document.exitPictureInPicture().catch(() => {});
                } else {
                    vid.requestPictureInPicture().then(() => {
                        if (currentPlayer) {
                            currentPlayer.detachMedia();
                            currentPlayer.destroy();
                            currentPlayer = null;
                        }
                        currentPopup = null;
                        currentContainer = null;
                        currentRoomSlug = null;
                        isSticky = false;
                        popup.remove();
                        vid.addEventListener('leavepictureinpicture', () => {
                            vid.src = '';
                            vid.remove();
                        });
                    }).catch(err => log('PiP failed:', err));
                }
            });
            popup.appendChild(pipBtn);
        }

        popup.addEventListener('mouseleave', e => {
            if (isSticky) return;

            if (currentContainer && e.relatedTarget instanceof Node && currentContainer.contains(e.relatedTarget)) {
                return;
            }

            // Otherwise, if we leave the popup, clean up
            softCleanup();
        });
        return popup;
    }

    // ── Show error state ──────────────────────────────────────────────

    function showErrorInPopup(popup, message) {
        const spinner = popup.querySelector('.cb-spinner');
        if (spinner) spinner.remove();
        const vid = popup.querySelector('video');
        if (vid) vid.remove();
        const err = document.createElement('div');
        err.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:rgba(255,255,255,0.55);font-family:system-ui,sans-serif;font-size:13px;z-index:2;pointer-events:none';
        err.innerHTML = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span>${message}</span>`;
        popup.appendChild(err);
    }

    // ── Cleanup ────────────────────────────────────────────────────────────────

    function forceCleanup() {
        if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; }
        if (pendingFetch)  { pendingFetch.abort(); pendingFetch = null; }
        if (currentPlayer) { currentPlayer.destroy(); currentPlayer = null; }
        if (currentPopup) {
            if (FEATURES.FADE_ANIMATION) {
                const ref = currentPopup;
                ref.style.opacity = '0';
                setTimeout(() => ref.remove(), 160);
            } else {
                currentPopup.remove();
            }
            currentPopup = null;
        }
        currentContainer = null;
        currentRoomSlug  = null;
        isSticky = false;
    }

    function softCleanup() {
        if (!isSticky) forceCleanup();
    }

    // ── Create video player ────────────────────────────────────────────────────

    function createVideoPlayer(containerNode, m3u8Url, roomSlug) {
        const rect  = containerNode.getBoundingClientRect();
        const popup = buildPopup(rect, roomSlug);

        document.body.appendChild(popup);
        currentPopup     = popup;
        currentContainer = containerNode;
        currentRoomSlug  = roomSlug;

        if (FEATURES.FADE_ANIMATION) {
            requestAnimationFrame(() => {
                requestAnimationFrame(() => popup.style.opacity = '1');
            });
        }

        const video = document.createElement('video');
        video.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;z-index:1;opacity:0;transition:opacity 200ms ease';
        video.muted      = isMuted;
        video.volume     = DEFAULT_VOLUME;
        video.autoplay   = true;
        video.playsInline = true;
        popup.appendChild(video);

        function onReady() {
            const spinner = popup.querySelector('.cb-spinner');
            if (spinner) spinner.style.display = 'none';
            video.style.opacity = '1';
        }

        if (typeof Hls !== 'undefined' && Hls.isSupported()) {
            const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
            hls.loadSource(m3u8Url);
            hls.attachMedia(video);
            hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().then(onReady).catch(onReady));
            hls.on(Hls.Events.ERROR, (e, data) => {
                if (data.fatal) {
                    showErrorInPopup(popup, 'Stream unavailable');
                    hls.destroy();
                    currentPlayer = null;
                    setTimeout(softCleanup, 2000);
                }
            });
            currentPlayer = hls;
        } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
            video.src = m3u8Url;
            video.addEventListener('loadedmetadata', () => video.play().then(onReady).catch(onReady));
            video.addEventListener('error', () => {
                showErrorInPopup(popup, 'Stream unavailable');
                setTimeout(softCleanup, 2000);
            });
        } else {
            showErrorInPopup(popup, 'HLS not supported');
        }
    }

    // ── Pin popup ──────────────────────────────────────────────────────────────

    function makeDraggable(el) {
        let startX, startY, startLeft, startTop;
        el.addEventListener('mousedown', e => {
            if (e.target.closest('button') || e.target.closest('input') || e.target === resizeHandle) return;
            e.preventDefault();
            startX    = e.clientX;
            startY    = e.clientY;
            startLeft = parseInt(el.style.left, 10) || 0;
            startTop  = parseInt(el.style.top,  10) || 0;
            el.style.cursor = 'grabbing';

            function onMove(e) {
                let newLeft = startLeft + (e.clientX - startX);
                let newTop  = startTop  + (e.clientY - startY);
                newLeft = Math.max(0, Math.min(newLeft, window.innerWidth  - el.offsetWidth));
                newTop  = Math.max(0, Math.min(newTop,  window.innerHeight - el.offsetHeight));
                el.style.left = `${newLeft}px`;
                el.style.top  = `${newTop}px`;
            }
            function onUp() {
                el.style.cursor = 'grab';
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup',   onUp);
            }
            document.addEventListener('mousemove', onMove);
            document.addEventListener('mouseup',   onUp);
        });
        el.style.cursor = 'grab';

        const resizeHandle = document.createElement('div');
        resizeHandle.style.cssText = 'position:absolute;bottom:0;right:0;width:18px;height:18px;cursor:se-resize;z-index:10;background:linear-gradient(135deg,transparent 50%,rgba(255,255,255,0.25) 50%);border-radius:0 0 8px 0';
        el.appendChild(resizeHandle);

        resizeHandle.addEventListener('mousedown', e => {
            e.preventDefault();
            e.stopPropagation();
            const startX  = e.clientX;
            const startW  = el.offsetWidth;
            const startH  = el.offsetHeight;
            const aspect  = startH / startW;

            function onMove(e) {
                const newW = Math.max(240, Math.min(window.innerWidth - 16, startW + (e.clientX - startX)));
                const newH = Math.round(newW * aspect);
                el.style.width  = `${newW}px`;
                el.style.height = `${newH}px`;
                const curLeft = parseInt(el.style.left, 10) || 0;
                const curTop  = parseInt(el.style.top,  10) || 0;
                el.style.left = `${Math.min(curLeft, window.innerWidth  - newW - 4)}px`;
                el.style.top  = `${Math.min(curTop,  window.innerHeight - newH - 4)}px`;
            }
            function onUp() {
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup',   onUp);
            }
            document.addEventListener('mousemove', onMove);
            document.addEventListener('mouseup',   onUp);
        });

        el.addEventListener('wheel', e => {
            e.preventDefault();
            const currentW = el.offsetWidth;
            const currentH = el.offsetHeight;
            const aspect = currentH / currentW;
            const delta = e.deltaY > 0 ? -20 : 20;
            const newW = Math.max(240, Math.min(window.innerWidth - 16, currentW + delta));
            const newH = Math.round(newW * aspect);
            el.style.width  = `${newW}px`;
            el.style.height = `${newH}px`;
            const curLeft = parseInt(el.style.left, 10) || 0;
            const curTop  = parseInt(el.style.top,  10) || 0;
            el.style.left = `${Math.min(curLeft, window.innerWidth  - newW - 4)}px`;
            el.style.top  = `${Math.min(curTop,  window.innerHeight - newH - 4)}px`;
        }, { passive: false });
    }

    function pinPopup() {
        if (!currentPopup || isSticky) return;
        isSticky = true;

        const pinnedPopup  = currentPopup;
        const pinnedPlayer = currentPlayer;

        currentPopup     = null;
        currentContainer = null;
        currentRoomSlug  = null;
        currentPlayer    = null;
        isSticky         = false;

        if (FEATURES.SCROLL_TRACKING) {
            const curTop  = parseInt(pinnedPopup.style.top,  10) - window.scrollY;
            const curLeft = parseInt(pinnedPopup.style.left, 10) - window.scrollX;
            pinnedPopup.style.position = 'fixed';
            pinnedPopup.style.top  = `${Math.max(0, curTop)}px`;
            pinnedPopup.style.left = `${Math.max(0, curLeft)}px`;
        }

        pinnedPopup.style.pointerEvents = 'auto';
        pinnedPopup.style.cursor = 'grab';

        const pinBtn = pinnedPopup.querySelector('.cb-pin-btn');
        if (pinBtn) {
            pinBtn.innerHTML = iconPinActive();
            pinBtn.style.background = 'rgba(30,100,30,0.8)';
            const newPinBtn = pinBtn.cloneNode(true);
            pinBtn.parentNode.replaceChild(newPinBtn, pinBtn);
            newPinBtn.addEventListener('click', e => {
                e.stopPropagation();
                if (pinnedPlayer) pinnedPlayer.destroy();
                if (FEATURES.FADE_ANIMATION) {
                    pinnedPopup.style.opacity = '0';
                    setTimeout(() => pinnedPopup.remove(), 160);
                } else {
                    pinnedPopup.remove();
                }
            });
        }

        makeDraggable(pinnedPopup);
        log('Popup pinned and released from tracker');
    }

    // ── Hover events ───────────────────────────────────────────────────────────

    function attachHoverEvents(card, containerNode, roomSlug) {
        if (!roomSlug) return;
        card.addEventListener('mouseenter', () => {
            if (isSticky) return;

            if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; }
            if (FEATURES.DEBOUNCE_HOVER && pendingFetch) {
                pendingFetch.abort();
                pendingFetch = null;
            }

            hoverTimeout = setTimeout(() => {
                hoverTimeout = null;
                if (currentPopup) forceCleanup();

                const controller = FEATURES.DEBOUNCE_HOVER ? new AbortController() : null;
                if (controller) pendingFetch = controller;

                getStreamUrl(roomSlug, controller ? controller.signal : null).then(url => {
                    pendingFetch = null;
                    const stillHovered = card.matches(':hover') ||
                        card.contains(document.activeElement) ||
                        (() => {
                            const rect = containerNode.getBoundingClientRect();
                            const el = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
                            return el && (card.contains(el) || el === card);
                        })();

                    if (stillHovered && !isSticky) {
                        createVideoPlayer(containerNode, url, roomSlug);
                    }
                }).catch(err => {
                    pendingFetch = null;
                    if (err.name === 'AbortError') return log('Fetch aborted');
                    log('Stream fetch error:', err.message);

                    const rect = containerNode.getBoundingClientRect();
                    const el = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
                    const stillHovered = card.matches(':hover') || (el && card.contains(el));

                    if (FEATURES.ERROR_STATE && !isSticky && stillHovered) {
                        const popup = buildPopup(rect, roomSlug);
                        document.body.appendChild(popup);
                        currentPopup = popup;
                        currentContainer = containerNode;
                        currentRoomSlug = roomSlug;
                        if (FEATURES.FADE_ANIMATION) {
                            requestAnimationFrame(() => requestAnimationFrame(() => popup.style.opacity = '1'));
                        }
                        showErrorInPopup(popup, 'Offline or unavailable');
                        setTimeout(softCleanup, 2000);
                    }
                });
            }, HOVER_DELAY);
        });

        card.addEventListener('mouseleave', e => {
            if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; }

            // NEW: Don't close if we move into the popup
            if (currentPopup && e.relatedTarget instanceof Node && currentPopup.contains(e.relatedTarget)) {
                return;
            }

            // NEW: Don't close if we move into a child of the card itself
            if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) {
                return;
            }

            softCleanup();
        });
    }

    // ── Register listeners ────────────────────────────────────────────

    function addListeners() {
        document.querySelectorAll('li.roomCard, li[data-testid="room-card"]').forEach(card => {
            if (card.dataset.previewBound) return;
            const thumbContainer = card.querySelector('.room_thumbnail_container, [data-testid="room-card-image-anchor"]');
            if (!thumbContainer) return;
            const roomSlug = thumbContainer.getAttribute('data-room');
            if (!roomSlug) return;
            card.dataset.previewBound = 'true';
            attachHoverEvents(card, thumbContainer, roomSlug);
        });

        document.querySelectorAll('div.FollowedDropdown__room').forEach(card => {
            if (card.dataset.previewBound) return;
            const anchor = card.querySelector('a.FollowedDropdown__room-link');
            if (!anchor) return;
            const href = anchor.getAttribute('href');
            if (!href) return;
            const roomSlug = href.replace(/\//g, '');
            if (!roomSlug) return;
            card.dataset.previewBound = 'true';
            attachHoverEvents(card, anchor, roomSlug);
        });
    }

    // ── Followed panel state ───────────────────────────────────────────────────

    function addFollowedListeners(panel) {
        panel.querySelectorAll('div.roomElement, li[data-testid="room-card"]').forEach(card => {
            if (card.dataset.previewBound) return;
            const anchor = card.querySelector('a.roomElementAnchor[data-room], a[data-testid="room-card-image-anchor"][data-room]');
            if (!anchor) return;
            const roomSlug = anchor.getAttribute('data-room');
            if (!roomSlug) return;
            card.dataset.previewBound = 'true';
            attachHoverEvents(card, anchor, roomSlug);
        });
    }

    function onFollowedPanelOpen(panel) {
        log('Followed panel opened');
        addFollowedListeners(panel);
        if (followedPanelObserver) followedPanelObserver.disconnect();
        followedPanelObserver = new MutationObserver(() => addFollowedListeners(panel));
        followedPanelObserver.observe(panel, { childList: true, subtree: true });
    }

    function onFollowedPanelClose() {
        log('Followed panel closed');
        forceCleanup();
        if (followedPanelObserver) { followedPanelObserver.disconnect(); followedPanelObserver = null; }
    }

    function checkPanelState() {
        const panel = document.querySelector('[data-testid="followed-rooms-list"], .FollowedDropdown__rooms');
        const isVisible = panel && panel.getBoundingClientRect().height > 0;
        if (isVisible && !isFollowedPanelOpen) {
            isFollowedPanelOpen = true;
            onFollowedPanelOpen(panel);
        } else if (!isVisible && isFollowedPanelOpen) {
            isFollowedPanelOpen = false;
            onFollowedPanelClose();
        }
    }

    // ── SPA navigation guard ───────────────────────────────────────────────────

    if (FEATURES.SPA_NAVIGATION_GUARD) {
        const _origPushState = history.pushState;
        history.pushState = function(...args) {
            forceCleanup();
            return _origPushState.apply(history, args);
        };
        window.addEventListener('popstate', forceCleanup);
    }

    // ── DOM observer ───────────────────────────────────────────────────────────

    let observerTimer = null;
    const domObserver = new MutationObserver(mutations => {
        const shouldUpdate = mutations.some(m => m.addedNodes.length > 0 || m.type === 'attributes');
        if (shouldUpdate && !observerTimer) {
            // Throttle to prevent browser lag on heavy DOM churn
            observerTimer = setTimeout(() => {
                addListeners();
                checkPanelState();
                observerTimer = null;
            }, 250);
        }
    });

    domObserver.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['data-room', 'href']
    });

    document.addEventListener('click', () => setTimeout(checkPanelState, 100));

    addListeners();
    checkPanelState();

    log('Loaded (v1.011)');
})();