☰

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

})();