☰

Chaturbate Live Preview

On hover enlarges the room card and shows the live stream. Works on discover/carousels too.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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();
            }
        }
    );

})();