Chaturbate Hover Preview

Replaces the card image with a stream.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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