On hover enlarges the room card and shows the live stream. Works on discover/carousels too.
// ==UserScript== // @name Chaturbate Live Preview // @namespace https://ru.chaturbate.com // @version 1.9.1 // @description On hover enlarges the room card and shows the live stream. Works on discover/carousels too. // @author jndl11 // @match https://*.chaturbate.com/* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js // @license MIT // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const SCALE = 1.7; const HOVER_DELAY = 150; const Z_INDEX = 99999; const EDGE_PADDING = 8; let currentCard = null; let currentVideo = null; let currentHls = null; let originalStyles = null; let originalImage = null; let imageParent = null; let overflowParents = []; let hoverTimer = null; let previewSession = 0; let hoveredCard = null; function getCookie(name) { const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); return match ? decodeURIComponent(match[1]) : null; } async function getStreamUrl(roomSlug) { const csrf = getCookie('csrftoken'); if (!csrf) throw new Error('No CSRF'); const body = new URLSearchParams(); body.append('room_slug', roomSlug); body.append('bandwidth', 'high'); 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 }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); if (!data.url) throw new Error('No stream URL'); return data.url; } function getRoomSlug(card) { const anchor = card.querySelector( '[data-room], a[data-testid="room-card-image-anchor"], .RoomCardThumbnail, a.room_thumbnail_container' ); if (anchor?.dataset?.room) { return anchor.dataset.room; } const link = card.querySelector('a[href^="/"]'); if (link) { const href = link.getAttribute('href') || ''; const slug = href .replace(/^\/+|\/+$/g, '') .split('/')[0] .split('?')[0]; if (/^[a-z0-9_]+$/i.test(slug)) { return slug.toLowerCase(); } } return null; } function getThumbContainer(card) { return card.querySelector( '.RoomCardThumbnail, [data-testid="room-card-image-anchor"], .room_thumbnail_container, a[data-room]' ) || card; } function getImage(container) { return container.querySelector( 'img.RoomCardThumbnail__image, img[data-testid="room-card-image"], img.room_thumbnail, img' ); } function isClippedOverflow(value) { return value === 'hidden' || value === 'auto' || value === 'scroll' || value === 'clip'; } function unlockOverflow(card) { overflowParents = []; let el = card.parentElement; while (el && el !== document.body) { const style = getComputedStyle(el); const ox = style.overflowX; const oy = style.overflowY; const o = style.overflow; if ( isClippedOverflow(ox) || isClippedOverflow(oy) || isClippedOverflow(o) || ( typeof o === 'string' && ( o.includes('hidden') || o.includes('auto') || o.includes('scroll') ) ) ) { overflowParents.push({ el, overflow: el.style.overflow, overflowX: el.style.overflowX, overflowY: el.style.overflowY }); el.style.overflow = 'visible'; el.style.overflowX = 'visible'; el.style.overflowY = 'visible'; } el = el.parentElement; } } function restoreOverflow() { overflowParents.forEach(({ el, overflow, overflowX, overflowY }) => { el.style.overflow = overflow; el.style.overflowX = overflowX; el.style.overflowY = overflowY; }); overflowParents = []; } function getSafeTransform(card) { const rect = card.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; const scaledW = rect.width * SCALE; const scaledH = rect.height * SCALE; const extraW = (scaledW - rect.width) / 2; const extraH = (scaledH - rect.height) / 2; let originX = 50; let originY = 50; let translateX = 0; let translateY = 0; if (rect.left - extraW < EDGE_PADDING) { originX = 0; translateX = Math.max(0, EDGE_PADDING - rect.left); } else if (rect.right + extraW > vw - EDGE_PADDING) { originX = 100; translateX = Math.min( 0, vw - EDGE_PADDING - rect.right ); } if (rect.top - extraH < EDGE_PADDING) { originY = 0; translateY = Math.max(0, EDGE_PADDING - rect.top); } else if (rect.bottom + extraH > vh - EDGE_PADDING) { originY = 100; translateY = Math.min( 0, vh - EDGE_PADDING - rect.bottom ); } return { origin: `${originX}% ${originY}%`, translate: `translate(${translateX}px, ${translateY}px) scale(${SCALE})` }; } function cleanup() { previewSession++; if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } if (currentHls) { try { currentHls.destroy(); } catch (e) {} currentHls = null; } if (currentVideo && originalImage && imageParent) { try { if (currentVideo.parentNode) { currentVideo.replaceWith(originalImage); } else { imageParent.appendChild(originalImage); } } catch (e) {} currentVideo = null; originalImage = null; imageParent = null; } else if (currentVideo) { currentVideo.remove(); currentVideo = null; } if (currentCard && originalStyles) { Object.assign(currentCard.style, originalStyles); currentCard = null; originalStyles = null; } restoreOverflow(); } async function startPreview(card) { cleanup(); const mySession = previewSession; const myCard = card; const roomSlug = getRoomSlug(card); if (!roomSlug) return; const container = getThumbContainer(card); const img = getImage(container); if (!img) return; const width = img.clientWidth || img.width || 180; const height = img.clientHeight || img.height || 101; const posterSrc = img.currentSrc || img.src || ''; originalStyles = { transform: card.style.transform || '', transformOrigin: card.style.transformOrigin || '', zIndex: card.style.zIndex || '', position: card.style.position || '', transition: card.style.transition || '', boxShadow: card.style.boxShadow || '' }; unlockOverflow(card); const { origin, translate } = getSafeTransform(card); card.style.transition = 'transform 0.2s ease, box-shadow 0.2s ease'; card.style.position = card.style.position || 'relative'; card.style.zIndex = Z_INDEX; card.style.transformOrigin = origin; card.style.transform = translate; card.style.boxShadow = '0 12px 40px rgba(0,0,0,0.55)'; currentCard = card; const isCurrent = () => mySession === previewSession && currentCard === myCard && myCard.matches(':hover'); try { const url = await getStreamUrl(roomSlug); if (!isCurrent()) { return; } originalImage = img.cloneNode(true); imageParent = img.parentNode; const video = document.createElement('video'); video.muted = true; video.autoplay = true; video.playsInline = true; video.poster = posterSrc; video.className = img.className; video.setAttribute( 'data-testid', img.getAttribute('data-testid') || '' ); video.style.cssText = ` display: block; width: ${width}px; height: ${height}px; object-fit: cover; border-radius: inherit; pointer-events: none; background: transparent; `; if ( !isCurrent() || !imageParent || !img.isConnected ) { return; } img.replaceWith(video); currentVideo = video; const computed = getComputedStyle(container); if (computed.position === 'static') { container.style.position = 'relative'; } const tryPlay = () => { if (isCurrent()) { video.play().catch(() => {}); } }; if (window.Hls && Hls.isSupported()) { const hls = new Hls({ enableWorker: true, lowLatencyMode: true, maxBufferLength: 4, maxMaxBufferLength: 8 }); hls.on( Hls.Events.MANIFEST_PARSED, () => { if (isCurrent()) { tryPlay(); } else { try { hls.destroy(); } catch (e) {} } } ); hls.on( Hls.Events.ERROR, (_, data) => { if (data.fatal && isCurrent()) { cleanup(); } } ); hls.loadSource(url); hls.attachMedia(video); currentHls = hls; } else if ( video.canPlayType( 'application/vnd.apple.mpegurl' ) ) { video.src = url; video.addEventListener( 'loadedmetadata', tryPlay ); } } catch (e) { if (isCurrent()) { cleanup(); } } } function onEnter(e) { const card = e.currentTarget; hoveredCard = card; if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } previewSession++; hoverTimer = setTimeout(() => { hoverTimer = null; if (card.matches(':hover')) { startPreview(card); } }, HOVER_DELAY); } function onLeave(e) { const card = e.currentTarget; if ( e.relatedTarget && card.contains(e.relatedTarget) ) { return; } if (hoveredCard === card) { hoveredCard = null; } else { return; } if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } setTimeout(() => { if ( hoveredCard === card && !card.matches(':hover') ) { cleanup(); } else if ( !hoveredCard && !card.matches(':hover') ) { cleanup(); } }, 0); } function attachToCards() { const cards = document.querySelectorAll( 'li[data-testid="room-card"], ' + 'li.roomCard, ' + 'li.RoomCard, ' + '.room_list_room, ' + '.roomElement' ); cards.forEach(card => { if ( card.classList.contains('placeholder') || card.getAttribute('data-testid') === 'room-card-placeholder' ) { return; } if (card.dataset.hoverEnlargeBound) { return; } card.dataset.hoverEnlargeBound = '1'; card.addEventListener( 'mouseenter', onEnter ); card.addEventListener( 'mouseleave', onLeave ); }); } const observer = new MutationObserver(() => { attachToCards(); }); observer.observe( document.body, { childList: true, subtree: true } ); attachToCards(); window.addEventListener( 'visibilitychange', () => { if (document.hidden) { cleanup(); } } ); })();