☰

Chaturbate Live Preview

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

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Userscripts installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey installieren.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

(Ich habe bereits einen Benutzerstil Verwaltung, ich möchte ihn installieren!)

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

})();