☰

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

})();