☰

Chaturbate Nav Buttons

Navigate between models from a captured live listing

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Chaturbate Nav Buttons
// @namespace    local.cb.nextcamera
// @version      0.2
// @description  Navigate between models from a captured live listing
// @match        https://chaturbate.com/*
// @match        https://www.chaturbate.com/*
// @match        https://*.chaturbate.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    const KEY = 'cbNextCameraRoomsV2';
    const EXCLUDED = new Set([
        'messages', 'discover', 'tags', 'spy-on-cams',
        'followed-cams', 'security', 'accounts', 'auth',
        'about', 'terms', 'privacy', 'support', 'login',
        'logout', 'signup', 'api', 'images', 'embed'
    ]);

    const pathOf = href => {
        try {
            const u = new URL(href, location.href);
            if (!/(^|\.)chaturbate\.com$/i.test(u.hostname)) return null;
            const p = u.pathname.split('/').filter(Boolean);
            if (p.length !== 1 || EXCLUDED.has(p[0].toLowerCase())) return null;
            if (/\./.test(p[0])) return null;
            return '/' + p[0] + '/';
        } catch {
            return null;
        }
    };

    function capture() {
        // Thumbnail images are a stronger signal than ordinary navigation links.
        const found = [];
        const seen = new Set();

        document.querySelectorAll('a[href] img').forEach(img => {
            const a = img.closest('a[href]');
            if (!a) return;

            const path = pathOf(a.href);
            if (!path || seen.has(path)) return;

            seen.add(path);
            found.push(path);
        });

        // Some cards use a separate model-name link rather than the image link.
        document.querySelectorAll('a[href]').forEach(a => {
            const path = pathOf(a.href);
            if (!path || seen.has(path)) return;

            const text = (a.innerText || a.textContent || '').trim();
            if (!text || text.length > 60) return;

            // Only accept a name link if its room also has a thumbnail somewhere.
            if (found.includes(path)) return;
            const hasThumbnail = [...document.querySelectorAll('a[href] img')]
            .some(img => pathOf(img.closest('a')?.href || '') === path);

            if (hasThumbnail) {
                seen.add(path);
                found.push(path);
            }
        });

        if (found.length >= 2) {
            localStorage.setItem(KEY, JSON.stringify({
                rooms: found,
                source: location.href,
                saved: Date.now()
            }));
        }
        return found.length;
    }

    function readList() {
        try {
            const data = JSON.parse(localStorage.getItem(KEY) || 'null');
            return Array.isArray(data?.rooms) ? data.rooms : [];
        } catch {
            return [];
        }
    }

    const current = pathOf(location.href);
    let rooms = readList();

    // If this is a listing page, keep capturing as its thumbnails load.
    if (!current || !rooms.includes(current)) {
        capture();
        const observer = new MutationObserver(() => capture());
        observer.observe(document.documentElement, {
            childList: true,
            subtree: true
        });
    }


    function addPanel() {
        if (document.getElementById('cb-next-camera-panel')) return;

        const panel = document.createElement('div');
        panel.id = 'cb-next-camera-panel';
        panel.style.cssText =
            'position:fixed;right:18px;bottom:22px;z-index:2147483647;' +
            'display:flex;align-items:center;gap:7px;padding:9px;' +
            'border-radius:10px;background:#202124;color:#fff;' +
            'font:14px Arial,sans-serif;box-shadow:0 2px 12px #0008';

        const status = document.createElement('span');
        status.style.cssText = 'font-size:12px;margin-right:4px';
        panel.appendChild(status);

        function go(step) {
            let savedRooms = [];

            try {
                const data = JSON.parse(
                    localStorage.getItem('cbNextCameraRoomsV2') || 'null'
                );
                savedRooms = Array.isArray(data?.rooms) ? data.rooms : [];
            } catch (err) {
                console.error('Next Camera: failed to read list', err);
            }

            const currentPath =
                '/' + location.pathname.split('/').filter(Boolean).join('/') + '/';
            const index = savedRooms.indexOf(currentPath);

            status.textContent = 'Cameras: ' + savedRooms.length;

            if (savedRooms.length < 2 || index < 0) {
                status.textContent = 'Room not found in list';
                console.warn('Next Camera:', {
                    currentPath,
                    savedCount: savedRooms.length,
                    index
                });
                return;
            }

            location.href =
                savedRooms[(index + step + savedRooms.length) % savedRooms.length];
        }

        function addButton(label, step) {
            const b = document.createElement('button');
            b.type = 'button';
            b.textContent = label;
            b.style.cssText =
                'cursor:pointer;border:0;border-radius:6px;padding:9px 12px;' +
                'background:#3c4043;color:#fff;font-weight:bold';
            b.addEventListener('click', () => go(step));
            panel.appendChild(b);
        }

        addButton('← Previous', -1);
        addButton('Next →', 1);

        status.textContent = 'Cameras: ' +
            (JSON.parse(localStorage.getItem('cbNextCameraRoomsV2') || 'null')?.rooms?.length || 0);

        document.body.appendChild(panel);
    }

    if (document.body) {
        addPanel();
    } else {
        document.addEventListener('DOMContentLoaded', addPanel, { once: true });
    }

    document.addEventListener('keydown', function (e) {
        if (!e.ctrlKey || e.altKey || e.metaKey) return;

        let step = 0;
        if (e.key === 'ArrowRight') step = 1;
        if (e.key === 'ArrowLeft') step = -1;
        if (step === 0) return;

        e.preventDefault();
        e.stopImmediatePropagation();

        let savedRooms = [];
        try {
            const data = JSON.parse(
                localStorage.getItem('cbNextCameraRoomsV2') || 'null'
            );
            savedRooms = Array.isArray(data?.rooms) ? data.rooms : [];
        } catch (err) {
            console.error('Next Camera: could not read saved list', err);
        }

        const currentPath =
            '/' + location.pathname.split('/').filter(Boolean).join('/') + '/';
        const index = savedRooms.indexOf(currentPath);

        if (savedRooms.length < 2 || index < 0) {
            console.warn('Next Camera: no matching saved room list', {
                currentPath,
                savedRoomCount: savedRooms.length
            });
            return;
        }

        location.href =
            savedRooms[(index + step + savedRooms.length) % savedRooms.length];
    }, true);
})();