Navigate between models from a captured live listing
// ==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);
})();