Chaturbate Hover Preview

Replaces the card image with a stream.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Chaturbate Hover Preview
// @namespace    https://github.com/danmaclann
// @version      1.001
// @license      MIT
// @description  Replaces the card image with a stream.
// @author       danmaclann
// @match        https://chaturbate.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.8/hls.min.js
// @run-at       document-idle
// @icon         https://web.static.mmcdn.com/favicons/favicon.ico
// ==/UserScript==

(function() {
    'use strict';

    // === CONFIGURATION ===
    const DEBUG = false;         // Set to true for console logs
    const VIDEO_SCALE = 1.0;     // 1.0 = Exact fit. 1.2 = Zoomed in 20%.
    const HOVER_DELAY = 400;     // Delay before loading
    // =====================

    let currentPlayer = null;
    let currentContainer = null;
    let originalImage = null;
    let hoverTimeout = null;

    function log(...args) { if (DEBUG) console.log('[CB-PREVIEW]', ...args); }

    function getCookie(name) {
        let cookieValue = null;
        if (document.cookie && document.cookie !== '') {
            const cookies = document.cookie.split(';');
            for (let i = 0; i < cookies.length; i++) {
                const cookie = cookies[i].trim();
                if (cookie.substring(0, name.length + 1) === (name + '=')) {
                    cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                    break;
                }
            }
        }
        return cookieValue;
    }

    async function getStreamUrl(roomSlug) {
        log(`Fetching: ${roomSlug}`);
        const csrftoken = getCookie('csrftoken');
        if (!csrftoken) throw new Error('No CSRF token');

        const formData = new URLSearchParams();
        formData.append('room_slug', roomSlug);
        formData.append('bandwidth', 'high');

        try {
            const response = await fetch('/get_edge_hls_url_ajax/', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'X-CSRFToken': csrftoken,
                    'X-Requested-With': 'XMLHttpRequest'
                },
                body: formData
            });
            if (!response.ok) throw new Error(`Status ${response.status}`);
            const data = await response.json();
            if (data.url) return data.url;
            throw new Error('No URL');
        } catch (error) {
            log('Fetch failed', error);
            throw error;
        }
    }

    function createVideoPlayer(container, m3u8Url) {
        const img = container.querySelector('img.room_thumbnail');
        if (!img) return;

        // 1. LOCK DIMENSIONS: Get the exact current size of the image
        const width = img.clientWidth;
        const height = img.clientHeight;

        originalImage = img.cloneNode(true);
        currentContainer = container;

        // Save original overflow style to restore later
        container.dataset.originalOverflow = container.style.overflow || '';

        const video = document.createElement('video');

        // 2. APPLY DIMENSIONS: Force video to be exactly that size
        video.style.width = `${width}px`;
        video.style.height = `${height}px`;

        // 3. SCALING: Only affects the video content
        video.style.objectFit = 'cover';
        if (VIDEO_SCALE !== 1.0) {
            video.style.transform = `scale(${VIDEO_SCALE})`;
            video.style.transformOrigin = 'center center';
            // Clip the container so zoomed video doesn't bleed out
            container.style.overflow = 'hidden';
        }

        video.className = img.className; // Keep rounded corners/borders
        video.style.display = 'block';
        video.muted = true;
        video.autoplay = true;
        video.playsInline = true;

        img.replaceWith(video);

        if (Hls.isSupported()) {
            const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
            hls.loadSource(m3u8Url);
            hls.attachMedia(video);
            hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
            hls.on(Hls.Events.ERROR, (e, data) => { if (data.fatal) cleanupPlayer(); });
            currentPlayer = hls;
        } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
            video.src = m3u8Url;
            video.addEventListener('loadedmetadata', () => video.play());
        }
    }

    function cleanupPlayer() {
        if (hoverTimeout) clearTimeout(hoverTimeout);
        if (currentPlayer) { currentPlayer.destroy(); currentPlayer = null; }

        if (currentContainer && originalImage) {
            const video = currentContainer.querySelector('video');
            if (video) video.replaceWith(originalImage);

            // Restore original overflow (important!)
            currentContainer.style.overflow = currentContainer.dataset.originalOverflow;

            currentContainer = null;
            originalImage = null;
        }
    }

    function addListeners() {
        const cards = document.querySelectorAll('li.roomCard');
        cards.forEach(card => {
            if (card.dataset.previewBound) return;
            card.dataset.previewBound = 'true';

            const thumbContainer = card.querySelector('.room_thumbnail_container');
            if (!thumbContainer) return;

            const roomSlug = thumbContainer.getAttribute('data-room');
            if (!roomSlug) return;

            card.addEventListener('mouseenter', () => {
                hoverTimeout = setTimeout(async () => {
                    if (currentContainer) cleanupPlayer();
                    try {
                        const url = await getStreamUrl(roomSlug);
                        // Check if mouse is still hovering THIS card
                        if (card.matches(':hover')) createVideoPlayer(thumbContainer, url);
                    } catch (e) {}
                }, HOVER_DELAY);
            });

            card.addEventListener('mouseleave', () => cleanupPlayer());
        });
    }

    const observer = new MutationObserver((mutations) => {
        let shouldUpdate = false;
        for (const m of mutations) {
            if (m.addedNodes.length > 0) {
                shouldUpdate = true;
                break;
            }
        }
        if (shouldUpdate) addListeners();
    });

    observer.observe(document.body, { childList: true, subtree: true });

    addListeners();
    log('Loaded (Stable v7.0)');
})();