☰

Chaturbate Live Preview

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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

})();