Hover over a room card to preview the stream in a floating popup with volume, opacity, and PiP controls.
// ==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)');
})();