Grid layout, model hiding, token tracking, chat timestamps, thumbnail previews, search filtering, keyboard shortcuts
// ==UserScript==
// @name Chaturbate Enhancer
// @namespace http://tampermonkey.net/
// @version 6.0.1
// @description Grid layout, model hiding, token tracking, chat timestamps, thumbnail previews, search filtering, keyboard shortcuts
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @run-at document-start
// @author shadow
// @grant none
// ==/UserScript==
console.log('✅ Chaturbate Enhancer v6.0.0 loaded');
(function () {
'use strict';
/* ─────────────────────────────────────────
CONFIG
───────────────────────────────────────── */
const CONFIG = Object.freeze({
VERSION: '6.0.0',
STORAGE: Object.freeze({
PREFIX: 'chaturbateEnhancer:',
HIDDEN_KEY: 'hiddenModels',
TOKENS_KEY: 'tokensSpent',
SETTINGS_KEY: 'settings',
BACKUP_KEY: 'backup',
FAVORITES_KEY: 'favorites',
NOTES_KEY: 'notes',
GRID_KEY: 'gridCols',
TAGS_KEY: 'tags',
BACKUP_TS_KEY: 'lastBackupTs'
}),
TIMERS: Object.freeze({
PATH_CHECK_INTERVAL: 900,
MUTATION_DEBOUNCE: 150,
THUMB_HOVER_INTERVAL_NORMAL: 200,
THUMB_HOVER_INTERVAL_PERF: 500,
THUMB_REQUEST_GAP_NORMAL: 180,
THUMB_REQUEST_GAP_PERF: 400,
THUMB_FETCH_TIMEOUT_NORMAL: 3000,
THUMB_FETCH_TIMEOUT_PERF: 2000,
AUTO_BACKUP_INTERVAL_MS: 86400000 // 24 h
}),
SELECTORS: Object.freeze({
ROOM_CARDS: 'li.roomCard.camBgColor',
TOKEN_BALANCE: ['span.balance', 'span.token_balance', 'span.tokencount'],
SCAN_CAMS: '[data-testid="scan-cams"]',
CHAT_MESSAGES: '[data-testid="chat-message"]',
GRID_LIST: 'ul.list.endless_page_template.show-location',
NAV_CONTAINER: 'ul.advanced-search-button-container, ul.top-nav, nav ul, ul#nav'
}),
EXTERNAL: Object.freeze({
RECU_ME: 'https://recu.me/performer/',
CAMWHORES_TV: 'https://www.camwhores.tv/search/',
CAMGIRLFINDER: 'https://camgirlfinder.net/models/cb/'
}),
DEFAULT_SETTINGS: Object.freeze({
hideGenderTabs: true,
showTimestamps: true,
autoBackup: false,
performanceMode: false,
animateThumbnails: true,
showFavoritesBar: true,
showSearchFilter: true,
highlightFavorites: true,
compactNotifications: false,
showViewerCount: true
})
});
/* ─────────────────────────────────────────
EARLY STYLE INJECTION
───────────────────────────────────────── */
(function injectCriticalStyles() {
try {
const style = document.createElement('style');
style.id = 'ce-critical-styles';
style.textContent = `
a.gender-tab[href*="/trans-cams/"],a[href*="/male-cams"],a[href*="/trans-cams"],li a#merch,li a#merch+li{display:none!important}
ul.list.endless_page_template.show-location{display:grid!important;gap:12px!important}
ul.list.endless_page_template.show-location li.roomCard.camBgColor{width:100%!important;max-width:100%!important;position:relative!important}
ul.list.endless_page_template.show-location li.roomCard.camBgColor img{width:100%!important;height:auto!important;object-fit:cover!important}
.ce-hide-btn{position:absolute;top:6px;left:6px;background:rgba(0,0,0,.65);color:#fff;border:none;border-radius:50%;width:22px;height:22px;cursor:pointer;font-size:12px;font-weight:700;line-height:22px;text-align:center;transition:background .15s;z-index:5;padding:0}
.ce-hide-btn:hover{background:rgba(220,38,38,.9)}
.ce-fav-btn{position:absolute;bottom:6px;left:6px;background:rgba(0,0,0,.65);border:none;border-radius:50%;width:22px;height:22px;cursor:pointer;font-size:12px;line-height:22px;text-align:center;transition:background .15s,transform .15s;z-index:5;padding:0}
.ce-fav-btn:hover{transform:scale(1.2)}
.ce-fav-btn.active{background:rgba(234,179,8,.8)}
.ce-overlay{position:fixed;inset:0;background:#000;z-index:99999;opacity:1;transition:opacity .35s ease-out;pointer-events:all}
.ce-overlay.hidden{opacity:0;pointer-events:none}
.ce-card-favorite{outline:2px solid rgba(234,179,8,.7)!important;outline-offset:-2px}
.ce-card-hidden{display:none!important}
.ce-viewer-badge{position:absolute;bottom:6px;right:6px;background:rgba(0,0,0,.7);color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:99px;z-index:5;pointer-events:none}
`;
(document.head || document.documentElement).appendChild(style);
} catch (e) { console.error('[CE] critical styles failed', e); }
})();
/* ─────────────────────────────────────────
LOADING OVERLAY
───────────────────────────────────────── */
let _overlay = null;
function showOverlay() {
if (_overlay) return;
try {
_overlay = document.createElement('div');
_overlay.className = 'ce-overlay';
(document.body || document.documentElement).appendChild(_overlay);
} catch (e) { /* silent */ }
}
function hideOverlay() {
try {
if (!_overlay) return;
_overlay.classList.add('hidden');
const el = _overlay;
_overlay = null;
setTimeout(() => { try { el.remove(); } catch {} }, 400);
} catch (e) { /* silent */ }
}
/* ─────────────────────────────────────────
LOGGER (singleton)
───────────────────────────────────────── */
const logger = (() => {
const errors = [];
const MAX = 300;
const ts = () => new Date().toISOString();
const fmt = (msg) => `[CE ${ts()}] ${msg}`;
return {
error(m, o = '') { console.error(fmt(m), o); if (errors.length < MAX) errors.push({ ts: Date.now(), m, stack: o && o.stack }); },
warn(m, o = '') { console.warn(fmt(m), o); },
info(m, o = '') { console.info(fmt(m), o); },
debug(m, o = '') { console.debug(fmt(m), o); },
getErrors() { return errors.slice(); }
};
})();
/* ─────────────────────────────────────────
UTILS
───────────────────────────────────────── */
const Utils = {
/** Returns the room slug when on a /username page, else null. */
getCurrentModel() {
try {
const parts = location.pathname.split('/').filter(Boolean);
if (parts.length !== 1) return null;
const s = parts[0];
if (['female','male','couple','trans'].includes(s) || s.includes('-')) return null;
return s;
} catch { return null; }
},
safeInt(str, fallback = 0) {
const n = parseInt(String(str || '').replace(/[^\d\-]/g, ''), 10);
return isNaN(n) ? fallback : n;
},
isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); },
el(tag, cls = '', attrs = {}) {
const e = document.createElement(tag);
if (cls) e.className = cls;
for (const k in attrs) {
const v = attrs[k];
if (k === 'textContent' || k === 'innerHTML') { e[k] = v; continue; }
if (k.startsWith('data-') || /^(id|href|title|aria-label|role|type|value|style|tabindex)$/.test(k)) {
e.setAttribute(k, v);
} else {
e[k] = v;
}
}
return e;
},
qs(sel, ctx = document) { try { return ctx.querySelector(sel); } catch { return null; } },
qsAll(sel, ctx = document) { try { return Array.from(ctx.querySelectorAll(sel)); } catch { return []; } },
findEl(sels, ctx = document) {
for (const s of sels) { const e = Utils.qs(s, ctx); if (e) return e; }
return null;
},
debounce(fn, wait = 100) {
let t;
return function(...args) {
clearTimeout(t);
t = setTimeout(() => { try { fn.apply(this, args); } catch (e) { logger.error('debounce cb', e); } }, wait);
};
},
throttle(fn, limit = 200) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= limit) { last = now; try { fn.apply(this, args); } catch (e) { logger.error('throttle cb', e); } }
};
},
parseJSON(raw, fallback = null) { try { return JSON.parse(raw); } catch { return fallback; } },
download(content, filename = 'export.json') {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = Utils.el('a', '', { href: url });
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
},
blobToDataURL(blob) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('FileReader failed'));
r.readAsDataURL(blob);
});
},
/**
* Run fn when selector first appears in DOM, with optional timeout.
* Returns a cleanup function.
*/
waitFor(selector, fn, timeoutMs = 8000) {
const existing = Utils.qs(selector);
if (existing) { try { fn(existing); } catch (e) { logger.error('waitFor cb', e); } return () => {}; }
let obs, timer;
obs = new MutationObserver(() => {
const el = Utils.qs(selector);
if (el) {
clearTimeout(timer);
obs.disconnect();
try { fn(el); } catch (e) { logger.error('waitFor cb (late)', e); }
}
});
obs.observe(document.documentElement, { childList: true, subtree: true });
timer = setTimeout(() => obs.disconnect(), timeoutMs);
return () => { obs.disconnect(); clearTimeout(timer); };
}
};
/* ─────────────────────────────────────────
STORAGE MANAGER
───────────────────────────────────────── */
const Store = (() => {
const P = CONFIG.STORAGE.PREFIX;
const key = k => P + k;
function get(k, fallback = null) {
try {
const raw = localStorage.getItem(key(k));
if (raw === null) return fallback;
return JSON.parse(raw) ?? fallback;
} catch { return fallback; }
}
function set(k, v) {
try { localStorage.setItem(key(k), JSON.stringify(v)); return true; }
catch (e) { logger.error('Store.set ' + k, e); return false; }
}
function del(k) { try { localStorage.removeItem(key(k)); } catch {} }
// Migrate legacy keys (one-time)
function migrate() {
const map = {
hiddenModels: CONFIG.STORAGE.HIDDEN_KEY,
tokensSpent: CONFIG.STORAGE.TOKENS_KEY,
chaturbateHiderSettings: CONFIG.STORAGE.SETTINGS_KEY,
chaturbate_backup: CONFIG.STORAGE.BACKUP_KEY
};
for (const [old, newK] of Object.entries(map)) {
const v = localStorage.getItem(old);
if (v && !localStorage.getItem(key(newK))) {
localStorage.setItem(key(newK), v);
logger.info(`Migrated ${old} → ${key(newK)}`);
}
if (v) localStorage.removeItem(old);
}
// Also migrate old gridCols key
const oldGrid = localStorage.getItem('chaturbateEnhancer:gridCols');
if (oldGrid && !get(CONFIG.STORAGE.GRID_KEY)) {
set(CONFIG.STORAGE.GRID_KEY, JSON.parse(oldGrid));
}
}
return {
migrate,
getHidden() { return get(CONFIG.STORAGE.HIDDEN_KEY, []); },
setHidden(v) { return set(CONFIG.STORAGE.HIDDEN_KEY, v); },
getTokens() { return get(CONFIG.STORAGE.TOKENS_KEY, {}); },
setTokens(v) { return set(CONFIG.STORAGE.TOKENS_KEY, v); },
getFavorites() { return get(CONFIG.STORAGE.FAVORITES_KEY, []); },
setFavorites(v) { return set(CONFIG.STORAGE.FAVORITES_KEY, v); },
getNotes() { return get(CONFIG.STORAGE.NOTES_KEY, {}); },
setNotes(v) { return set(CONFIG.STORAGE.NOTES_KEY, v); },
getTags() { return get(CONFIG.STORAGE.TAGS_KEY, {}); },
setTags(v) { return set(CONFIG.STORAGE.TAGS_KEY, v); },
getGridCols() { return get(CONFIG.STORAGE.GRID_KEY, 4); },
setGridCols(v) { return set(CONFIG.STORAGE.GRID_KEY, v); },
getSettings() {
const saved = get(CONFIG.STORAGE.SETTINGS_KEY, {});
return Object.assign({}, CONFIG.DEFAULT_SETTINGS, saved);
},
setSettings(v) { return set(CONFIG.STORAGE.SETTINGS_KEY, v); },
backup() {
return {
hiddenModels: this.getHidden(),
tokensSpent: this.getTokens(),
favorites: this.getFavorites(),
notes: this.getNotes(),
tags: this.getTags(),
settings: this.getSettings(),
timestamp: Date.now(),
version: CONFIG.VERSION
};
},
saveBackup() { return set(CONFIG.STORAGE.BACKUP_KEY, this.backup()); },
loadBackup() { return get(CONFIG.STORAGE.BACKUP_KEY, null); },
resetAll() {
for (const k of Object.values(CONFIG.STORAGE)) del(k);
return true;
}
};
})();
/* ─────────────────────────────────────────
NOTIFICATION MANAGER
───────────────────────────────────────── */
const Notify = (() => {
const toasts = [];
const COLORS = { success: '#10b981', error: '#ef4444', info: '#3b82f6', warning: '#f59e0b' };
const GAP = 48;
function reposition() {
toasts.forEach((el, i) => { el.style.top = (16 + i * GAP) + 'px'; });
}
function remove(el) {
el.style.opacity = '0';
setTimeout(() => {
el.remove();
const idx = toasts.indexOf(el);
if (idx !== -1) toasts.splice(idx, 1);
reposition();
}, 250);
}
return {
show(message, type = 'info', duration = 2800) {
// In compact mode, skip info toasts
if (Store.getSettings().compactNotifications && type === 'info') return null;
try {
const el = Utils.el('div', 'ce-toast', {
role: 'alert',
'aria-live': 'polite'
});
el.style.cssText = `
position:fixed;top:16px;right:16px;z-index:10001;
padding:9px 16px;background:rgba(15,15,20,.95);color:#fff;
border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.45);
transition:opacity .25s,top .2s;font-size:13px;font-weight:600;
max-width:300px;border-left:3px solid ${COLORS[type] || COLORS.info};
opacity:0;cursor:pointer;user-select:none;
`;
el.textContent = message;
el.addEventListener('click', () => remove(el));
document.body.appendChild(el);
toasts.push(el);
reposition();
requestAnimationFrame(() => { el.style.opacity = '1'; });
setTimeout(() => remove(el), duration);
return el;
} catch (e) { logger.error('Notify.show', e); return null; }
},
clearAll() { [...toasts].forEach(remove); }
};
})();
/* ─────────────────────────────────────────
MODEL MANAGER (username extraction)
───────────────────────────────────────── */
const ModelManager = {
extractUsername(card) {
if (!card) return null;
try {
const slug = card.querySelector('[data-slug]');
if (slug) return slug.getAttribute('data-slug') || null;
const a = card.querySelector('a[href]');
if (a) {
const parts = (a.getAttribute('href') || '').split('/').filter(Boolean);
return parts[0] || null;
}
} catch (e) { logger.error('extractUsername', e); }
return null;
},
getViewerCount(card) {
if (!card) return null;
try {
const el = card.querySelector('[data-room-viewers], .roomViewers, .viewerCount');
if (el) return Utils.safeInt(el.textContent);
// fallback: look for numbers in aria labels
const a = card.querySelector('a[aria-label]');
if (a) {
const m = (a.getAttribute('aria-label') || '').match(/(\d+)\s*viewer/i);
if (m) return Utils.safeInt(m[1]);
}
} catch {}
return null;
}
};
/* ─────────────────────────────────────────
GRID MANAGER
───────────────────────────────────────── */
const GridManager = (() => {
let obs = null;
function applyToGrid(cols) {
const grid = Utils.qs(CONFIG.SELECTORS.GRID_LIST);
if (!grid) return false;
grid.style.cssText = `display:grid!important;grid-template-columns:repeat(${cols},1fr);gap:12px`;
Utils.qsAll('li.roomCard.camBgColor', grid).forEach(c => {
c.style.width = '100%';
c.style.maxWidth = '100%';
});
return true;
}
function waitAndApply(cols) {
if (applyToGrid(cols)) return;
if (obs) obs.disconnect();
obs = new MutationObserver(() => {
if (applyToGrid(cols)) { obs.disconnect(); obs = null; }
});
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { if (obs) { obs.disconnect(); obs = null; } }, 6000);
}
function init() {
if (Utils.getCurrentModel()) return; // skip on room pages
if (Utils.qs('#ce-grid-controls')) return; // already init
const cols = Store.getGridCols();
waitAndApply(cols);
const container = Utils.findEl([
'ul.advanced-search-button-container',
'ul.top-nav',
'nav ul',
'ul#nav'
]);
if (!container) return;
const li = Utils.el('li', '', { id: 'ce-grid-controls' });
const wrap = Utils.el('div', '', { style: 'display:flex;gap:4px;align-items:center' });
const options = [
{ cols: 2, title: '2 columns', svg: icon2 },
{ cols: 3, title: '3 columns', svg: icon3 },
{ cols: 4, title: '4 columns', svg: icon4 },
{ cols: 6, title: '6 columns', svg: icon6 }
];
options.forEach(opt => {
const btn = Utils.el('button', 'ce-grid-btn', {
type: 'button',
title: opt.title,
innerHTML: opt.svg
});
if (cols === opt.cols) btn.classList.add('active');
btn.addEventListener('click', () => {
wrap.querySelectorAll('.ce-grid-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyToGrid(opt.cols);
Store.setGridCols(opt.cols);
Notify.show(`Grid: ${opt.cols} columns`, 'success');
});
wrap.appendChild(btn);
});
li.appendChild(wrap);
const filterDiv = container.querySelector('[data-testid="filter-button"]');
if (filterDiv) container.insertBefore(li, filterDiv);
else container.appendChild(li);
}
return { init, applyToGrid, waitAndApply };
})();
/* ─────────────────────────────────────────
SVG ICONS (grid buttons)
───────────────────────────────────────── */
const icon2 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="6" height="6" fill="#3b82f6"/><rect x="9" y="1" width="6" height="6" fill="#f97316"/><rect x="1" y="9" width="6" height="6" fill="#3b82f6"/><rect x="9" y="9" width="6" height="6" fill="#f97316"/></svg>`;
const icon3 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="4" height="4" fill="#3b82f6"/><rect x="6" y="1" width="4" height="4" fill="#f97316"/><rect x="11" y="1" width="4" height="4" fill="#3b82f6"/><rect x="1" y="6" width="4" height="4" fill="#f97316"/><rect x="6" y="6" width="4" height="4" fill="#3b82f6"/><rect x="11" y="6" width="4" height="4" fill="#f97316"/><rect x="1" y="11" width="4" height="4" fill="#3b82f6"/><rect x="6" y="11" width="4" height="4" fill="#f97316"/><rect x="11" y="11" width="4" height="4" fill="#3b82f6"/></svg>`;
const icon4 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="3" height="3" fill="#3b82f6"/><rect x="5" y="1" width="3" height="3" fill="#f97316"/><rect x="9" y="1" width="3" height="3" fill="#3b82f6"/><rect x="13" y="1" width="3" height="3" fill="#f97316"/><rect x="1" y="5" width="3" height="3" fill="#f97316"/><rect x="5" y="5" width="3" height="3" fill="#3b82f6"/><rect x="9" y="5" width="3" height="3" fill="#f97316"/><rect x="13" y="5" width="3" height="3" fill="#3b82f6"/><rect x="1" y="9" width="3" height="3" fill="#3b82f6"/><rect x="5" y="9" width="3" height="3" fill="#f97316"/><rect x="9" y="9" width="3" height="3" fill="#3b82f6"/><rect x="13" y="9" width="3" height="3" fill="#f97316"/><rect x="1" y="13" width="3" height="3" fill="#f97316"/><rect x="5" y="13" width="3" height="3" fill="#3b82f6"/><rect x="9" y="13" width="3" height="3" fill="#f97316"/><rect x="13" y="13" width="3" height="3" fill="#3b82f6"/></svg>`;
const icon6 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="2" height="2" fill="#3b82f6"/><rect x="4" y="1" width="2" height="2" fill="#f97316"/><rect x="7" y="1" width="2" height="2" fill="#3b82f6"/><rect x="10" y="1" width="2" height="2" fill="#f97316"/><rect x="13" y="1" width="2" height="2" fill="#3b82f6"/><rect x="1" y="4" width="2" height="2" fill="#f97316"/><rect x="4" y="4" width="2" height="2" fill="#3b82f6"/><rect x="7" y="4" width="2" height="2" fill="#f97316"/><rect x="10" y="4" width="2" height="2" fill="#3b82f6"/><rect x="13" y="4" width="2" height="2" fill="#f97316"/></svg>`;
/* ─────────────────────────────────────────
THUMBNAIL MANAGER
Uses data: URLs (CSP-safe) instead of blob: URLs
───────────────────────────────────────── */
const ThumbnailManager = (() => {
const hoverTimers = new Map();
const lastReqTime = new Map();
let cleanupObs = null;
function clearTimer(img) {
const id = hoverTimers.get(img);
if (id !== undefined) { clearInterval(id); hoverTimers.delete(img); }
}
async function fetchThumb(img) {
try {
if (!img || !img.isConnected) return;
if (!img.dataset.originalSrc) img.dataset.originalSrc = img.src;
const card = img.closest('li.roomCard');
const username = ModelManager.extractUsername(card) ||
(img.parentElement && img.parentElement.dataset.room) ||
null;
if (!username) return;
const settings = Store.getSettings();
const gap = settings.performanceMode
? CONFIG.TIMERS.THUMB_REQUEST_GAP_PERF
: CONFIG.TIMERS.THUMB_REQUEST_GAP_NORMAL;
const now = Date.now();
if (now - (lastReqTime.get(username) || 0) < gap) return;
lastReqTime.set(username, now);
const url = `https://thumb.live.mmcdn.com/minifwap/${encodeURIComponent(username)}.jpg?_=${now}`;
const ctrl = new AbortController();
const timeout = settings.performanceMode
? CONFIG.TIMERS.THUMB_FETCH_TIMEOUT_PERF
: CONFIG.TIMERS.THUMB_FETCH_TIMEOUT_NORMAL;
const timer = setTimeout(() => { try { ctrl.abort(); } catch {} }, timeout);
const res = await fetch(url, { cache: 'no-cache', signal: ctrl.signal });
clearTimeout(timer);
if (!res.ok) return;
const blob = await res.blob();
const dataUrl = await Utils.blobToDataURL(blob);
if (img.isConnected) img.src = dataUrl;
} catch (e) {
if (e && e.name !== 'AbortError') logger.error('fetchThumb', e);
}
}
function setupListeners() {
const onEnter = Utils.debounce(function (ev) {
const img = ev.target;
if (!(img instanceof HTMLImageElement)) return;
if (!img.matches('.room_list_room img, .roomElement img, .roomCard img')) return;
if (!Store.getSettings().animateThumbnails) return;
clearTimer(img);
fetchThumb(img);
const interval = Store.getSettings().performanceMode
? CONFIG.TIMERS.THUMB_HOVER_INTERVAL_PERF
: CONFIG.TIMERS.THUMB_HOVER_INTERVAL_NORMAL;
hoverTimers.set(img, setInterval(() => fetchThumb(img), interval));
}, 60);
const onLeave = function (ev) {
const img = ev.target;
if (!(img instanceof HTMLImageElement)) return;
if (!img.matches('.room_list_room img, .roomElement img, .roomCard img')) return;
clearTimer(img);
if (img.dataset.originalSrc) {
img.src = img.dataset.originalSrc;
delete img.dataset.originalSrc;
}
};
document.addEventListener('mouseenter', onEnter, true);
document.addEventListener('mouseleave', onLeave, true);
}
function observeCleanup() {
if (cleanupObs) cleanupObs.disconnect();
cleanupObs = new MutationObserver(muts => {
for (const mut of muts) {
if (!mut.removedNodes) continue;
for (const node of mut.removedNodes) {
if (node && node.querySelectorAll) {
node.querySelectorAll('img').forEach(clearTimer);
}
}
}
});
cleanupObs.observe(document.body, { childList: true, subtree: true });
}
function stopAll() {
for (const id of hoverTimers.values()) clearInterval(id);
hoverTimers.clear();
Utils.qsAll('img[data-original-src]').forEach(img => {
img.src = img.dataset.originalSrc;
delete img.dataset.originalSrc;
});
if (cleanupObs) { cleanupObs.disconnect(); cleanupObs = null; }
}
return {
init() {
setupListeners();
observeCleanup();
},
stopAll
};
})();
/* ─────────────────────────────────────────
BUTTON MANAGER (hide + favorite)
───────────────────────────────────────── */
const ButtonManager = (() => {
let processed = new WeakSet();
function processCards() {
const cards = Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS);
if (!cards.length) return;
const hidden = new Set(Store.getHidden());
const favorites = new Set(Store.getFavorites());
const settings = Store.getSettings();
for (const card of cards) {
if (processed.has(card)) continue;
processed.add(card);
const username = ModelManager.extractUsername(card);
if (!username) continue;
// Hide known-hidden cards immediately
if (hidden.has(username)) {
card.classList.add('ce-card-hidden');
card.setAttribute('data-hidden', '1');
continue;
}
// Find the image wrapper (anchor tag wrapping the thumbnail) for correct overlay positioning
const imgWrapper = card.querySelector('a') || card;
if (getComputedStyle(imgWrapper).position === 'static') imgWrapper.style.position = 'relative';
// Hide button
const hideBtn = Utils.el('button', 'ce-hide-btn', {
'aria-label': `Hide ${username}`,
title: `Hide ${username}`,
textContent: '✕',
type: 'button'
});
hideBtn.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
card.classList.add('ce-card-hidden');
card.setAttribute('data-hidden', '1');
const list = Store.getHidden();
if (!list.includes(username)) {
list.push(username);
Store.setHidden(list);
Notify.show(`Hidden: ${username}`, 'success');
StatsManager.updateStat();
}
});
imgWrapper.appendChild(hideBtn);
// Favorite button
const favBtn = Utils.el('button', 'ce-fav-btn' + (favorites.has(username) ? ' active' : ''), {
'aria-label': `Favorite ${username}`,
title: `Favorite ${username}`,
textContent: '★',
type: 'button'
});
if (favorites.has(username) && settings.highlightFavorites) {
card.classList.add('ce-card-favorite');
}
favBtn.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
const favList = Store.getFavorites();
const idx = favList.indexOf(username);
if (idx === -1) {
favList.push(username);
Store.setFavorites(favList);
favBtn.classList.add('active');
if (settings.highlightFavorites) card.classList.add('ce-card-favorite');
Notify.show(`★ Favorited: ${username}`, 'success');
} else {
favList.splice(idx, 1);
Store.setFavorites(favList);
favBtn.classList.remove('active');
card.classList.remove('ce-card-favorite');
Notify.show(`Unfavorited: ${username}`, 'info');
}
});
// Favorite button — append inside the image wrapper so it overlays the thumbnail
imgWrapper.appendChild(favBtn);
// Viewer count badge
if (settings.showViewerCount) {
const count = ModelManager.getViewerCount(card);
if (count !== null) {
const badge = Utils.el('div', 'ce-viewer-badge', { textContent: `👁 ${count.toLocaleString()}` });
imgWrapper.appendChild(badge);
}
}
}
StatsManager.updateStat();
}
return {
processCards,
reset() { processed = new WeakSet(); }
};
})();
/* ─────────────────────────────────────────
SEARCH FILTER (live filter on listing page)
───────────────────────────────────────── */
const SearchFilter = (() => {
let inputEl = null;
function inject() {
if (!Store.getSettings().showSearchFilter) return;
if (Utils.getCurrentModel()) return;
if (Utils.qs('#ce-search-filter')) return;
const nav = Utils.qs(CONFIG.SELECTORS.NAV_CONTAINER);
if (!nav) return;
const li = Utils.el('li', '', { id: 'ce-search-filter' });
inputEl = Utils.el('input', '', {
type: 'text',
placeholder: '🔍 Filter rooms…',
'aria-label': 'Filter rooms',
autocomplete: 'off',
style: `
background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);
border-radius:20px;color:#fff;font-size:12px;padding:4px 12px;
outline:none;width:140px;transition:width .2s,border-color .2s;
`,
id: 'ce-filter-input'
});
inputEl.addEventListener('focus', () => { inputEl.style.width = '200px'; inputEl.style.borderColor = 'rgba(59,130,246,.6)'; });
inputEl.addEventListener('blur', () => { inputEl.style.width = '140px'; inputEl.style.borderColor = 'rgba(255,255,255,.2)'; });
inputEl.addEventListener('input', Utils.debounce(filterCards, 120));
// Keyboard shortcut: Shift+F to focus
document.addEventListener('keydown', ev => {
if (ev.shiftKey && ev.key === 'F' && document.activeElement !== inputEl) {
ev.preventDefault();
inputEl.focus();
}
});
li.appendChild(inputEl);
const filterDiv = nav.querySelector('[data-testid="filter-button"]');
const filterAnchor = filterDiv ? (filterDiv.closest('li') || filterDiv) : null;
const gridControls = nav.querySelector('#ce-grid-controls');
if (filterAnchor && filterAnchor.parentNode === nav) nav.insertBefore(li, filterAnchor);
else if (gridControls) gridControls.insertAdjacentElement('afterend', li);
else nav.appendChild(li);
}
function filterCards() {
const q = (inputEl ? inputEl.value.trim().toLowerCase() : '');
Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS).forEach(card => {
if (card.getAttribute('data-hidden') === '1') return; // always keep hidden state
const username = (ModelManager.extractUsername(card) || '').toLowerCase();
const subject = card.querySelector('.subject, .roomSubject, [data-testid="room-subject"]');
const subjectText = subject ? subject.textContent.toLowerCase() : '';
const match = !q || username.includes(q) || subjectText.includes(q);
card.style.display = match ? '' : 'none';
});
}
return { inject, filterCards };
})();
/* ─────────────────────────────────────────
STATS MANAGER
───────────────────────────────────────── */
const StatsManager = (() => {
function calcHiddenStats() {
const hidden = new Set(Store.getHidden());
const cards = Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS);
let currentHidden = 0;
for (const c of cards) {
const u = ModelManager.extractUsername(c);
if (u && hidden.has(u) && c.getAttribute('data-hidden') === '1') currentHidden++;
}
return { total: hidden.size, current: currentHidden };
}
function updateStat() {
try {
const stats = calcHiddenStats();
let statA = Utils.qs('#ce-hidden-stat');
if (!statA) {
const merch = Utils.findEl(['li a#merch', 'a#merch']);
const merchLi = merch ? merch.closest('li') : null;
const parent = (merchLi && merchLi.parentNode) ||
Utils.qs('ul.top-nav, ul.main-nav, nav ul, ul#nav');
if (!parent) return;
const li = Utils.el('li', '', { id: 'ce-stat-li' });
statA = Utils.el('a', '', {
id: 'ce-hidden-stat',
href: 'javascript:void(0)',
style: 'color:#fff;margin-right:8px;font-size:12px;cursor:pointer;'
});
statA.addEventListener('click', ev => {
ev.preventDefault();
ModalManager.open('Hidden Models', buildHiddenList(false));
});
const settingsBtn = Utils.el('a', '', {
id: 'ce-settings-btn',
href: 'javascript:void(0)',
style: 'color:#fff;font-weight:600;margin-left:6px;font-size:12px;cursor:pointer;'
});
settingsBtn.textContent = '⚙ Settings';
settingsBtn.addEventListener('click', ev => { ev.preventDefault(); SettingsModal.show(); });
li.appendChild(statA);
li.appendChild(settingsBtn);
if (merchLi) merchLi.insertAdjacentElement('afterend', li);
else parent.appendChild(li);
}
statA.textContent = `Hidden: ${stats.current}/${stats.total}`;
} catch (e) { logger.error('updateStat', e); }
}
function buildHiddenList(showAll) {
const container = Utils.el('div', '', { style: 'padding:8px' });
const toggleBtn = Utils.el('button', 'ce-modal-btn', {
textContent: showAll ? 'Show current only' : 'Show all hidden',
type: 'button',
style: 'margin-bottom:12px'
});
toggleBtn.addEventListener('click', () => {
ModalManager.open('Hidden Models', buildHiddenList(!showAll));
});
container.appendChild(toggleBtn);
const hidden = Store.getHidden();
const hiddenSet = new Set(hidden);
let models;
if (showAll) {
models = hidden;
} else {
models = Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS)
.map(c => {
const u = ModelManager.extractUsername(c);
return (u && hiddenSet.has(u) && c.getAttribute('data-hidden') === '1') ? u : null;
})
.filter(Boolean);
}
if (!models.length) {
container.appendChild(Utils.el('p', '', { textContent: 'No models to show', style: 'color:#9ca3af' }));
return container;
}
const ul = Utils.el('ul', '', { style: 'list-style:none;padding:0;margin:0' });
models.forEach(name => {
const li = Utils.el('li', '', {
style: 'display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid rgba(255,255,255,.08)'
});
const nameSpan = Utils.el('span', '', { textContent: name, style: 'color:#e5e7eb' });
const unhideBtn = Utils.el('button', 'ce-modal-btn danger', { textContent: 'Unhide', type: 'button' });
unhideBtn.addEventListener('click', () => {
Store.setHidden(Store.getHidden().filter(n => n !== name));
Notify.show(`${name} unhidden`, 'success');
li.remove();
updateStat();
});
li.appendChild(nameSpan);
li.appendChild(unhideBtn);
ul.appendChild(li);
});
container.appendChild(ul);
return container;
}
function showTokenStats() {
try {
const tokens = Store.getTokens();
const entries = Object.entries(tokens).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
if (!entries.length) { Notify.show('No token data', 'info'); return; }
const total = entries.reduce((s, [, v]) => s + v, 0);
const container = Utils.el('div');
const table = Utils.el('table', '', { style: 'width:100%;border-collapse:collapse;font-size:13px' });
const thead = Utils.el('thead');
thead.innerHTML = '<tr><th style="text-align:left;padding:6px;color:#9ca3af">Model</th><th style="text-align:right;padding:6px;color:#9ca3af">Tokens</th><th style="padding:6px"></th></tr>';
table.appendChild(thead);
const tbody = Utils.el('tbody');
for (const [model, spent] of entries) {
const tr = Utils.el('tr', '', { style: 'border-bottom:1px solid rgba(255,255,255,.07)' });
tr.innerHTML = `<td style="padding:6px;color:#e5e7eb">${model}</td>`;
const tdSpent = Utils.el('td', '', { textContent: spent.toLocaleString(), style: 'padding:6px;text-align:right;color:#10b981' });
const tdEdit = Utils.el('td', '', { style: 'padding:6px;text-align:right' });
const editBtn = Utils.el('button', 'ce-modal-btn', { textContent: '✎', type: 'button', title: 'Edit' });
editBtn.addEventListener('click', () => {
const val = prompt(`Tokens for ${model}`, spent);
if (val !== null && !isNaN(val)) {
const newVal = Utils.safeInt(val);
const data = Store.getTokens();
data[model] = newVal;
Store.setTokens(data);
tdSpent.textContent = newVal.toLocaleString();
Notify.show(`Updated: ${model}`, 'success');
}
});
tdEdit.appendChild(editBtn);
tr.appendChild(tdSpent);
tr.appendChild(tdEdit);
tbody.appendChild(tr);
}
const totalTr = Utils.el('tr', '', { style: 'border-top:2px solid rgba(255,255,255,.15)' });
totalTr.innerHTML = `<td style="padding:6px;font-weight:700">Total</td><td style="padding:6px;text-align:right;font-weight:700;color:#f59e0b" colspan="2">${total.toLocaleString()}</td>`;
tbody.appendChild(totalTr);
table.appendChild(tbody);
container.appendChild(table);
ModalManager.open('Token Stats', container);
} catch (e) { logger.error('showTokenStats', e); }
}
function showFavorites() {
const favorites = Store.getFavorites();
const container = Utils.el('div', '', { style: 'padding:8px' });
if (!favorites.length) {
container.appendChild(Utils.el('p', '', { textContent: 'No favorites yet. Click ★ on a model card.', style: 'color:#9ca3af' }));
ModalManager.open('Favorites', container);
return;
}
const ul = Utils.el('ul', '', { style: 'list-style:none;padding:0;margin:0' });
favorites.forEach(name => {
const li = Utils.el('li', '', {
style: 'display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid rgba(255,255,255,.08)'
});
const a = Utils.el('a', '', {
href: `https://chaturbate.com/${name}/`,
target: '_blank',
rel: 'noopener noreferrer',
textContent: name,
style: 'color:#f59e0b;text-decoration:none;font-weight:600'
});
const removeBtn = Utils.el('button', 'ce-modal-btn danger', { textContent: '✕', type: 'button', title: 'Remove from favorites' });
removeBtn.addEventListener('click', () => {
Store.setFavorites(Store.getFavorites().filter(n => n !== name));
Notify.show(`${name} removed from favorites`, 'info');
li.remove();
});
li.appendChild(a);
li.appendChild(removeBtn);
ul.appendChild(li);
});
container.appendChild(ul);
ModalManager.open('Favorites ★', container);
}
return { updateStat, showTokenStats, showFavorites };
})();
/* ─────────────────────────────────────────
MODAL MANAGER
───────────────────────────────────────── */
const ModalManager = (() => {
let active = null;
function close() {
if (!active) return;
const { overlay, escHandler, keydownTrap } = active;
document.removeEventListener('keydown', escHandler);
document.removeEventListener('keydown', keydownTrap);
overlay.style.opacity = '0';
setTimeout(() => { try { overlay.remove(); } catch {} }, 200);
active = null;
}
function open(title, bodyEl, footerBtns = []) {
if (active) close();
const overlay = Utils.el('div', 'ce-modal-overlay', {
role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'ce-modal-title'
});
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:10000;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .2s';
const modal = Utils.el('div', '', {
tabindex: '-1',
style: 'background:#111827;border-radius:10px;padding:18px;max-width:520px;width:92%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,.5);color:#e5e7eb;position:relative'
});
const header = Utils.el('div', '', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:14px' });
const h3 = Utils.el('h3', '', { textContent: title, id: 'ce-modal-title', style: 'margin:0;font-size:17px;font-weight:700;color:#fff' });
const closeBtn = Utils.el('button', '', {
type: 'button', 'aria-label': 'Close',
innerHTML: '×',
style: 'background:none;border:none;color:#9ca3af;font-size:22px;cursor:pointer;padding:2px 6px;line-height:1;border-radius:4px;transition:color .15s'
});
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = '#fff'; });
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = '#9ca3af'; });
closeBtn.addEventListener('click', close);
header.appendChild(h3);
header.appendChild(closeBtn);
const body = Utils.el('div', '', { style: 'margin-bottom:14px' });
body.appendChild(bodyEl);
const footer = Utils.el('div', '', { style: 'display:flex;gap:8px;justify-content:flex-end' });
// Always add a close button unless custom buttons provided
if (!footerBtns.length) {
footerBtns = [{ text: 'Close', cls: 'secondary', onClick: close }];
}
footerBtns.forEach(cfg => {
const btn = Utils.el('button', `ce-modal-btn ${cfg.cls || ''}`, {
type: 'button', textContent: cfg.text || 'OK'
});
btn.addEventListener('click', () => { try { cfg.onClick(); } catch (e) { logger.error('modal btn', e); } });
footer.appendChild(btn);
});
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
const escHandler = ev => { if (ev.key === 'Escape') close(); };
overlay.addEventListener('click', ev => { if (ev.target === overlay) close(); });
document.addEventListener('keydown', escHandler);
// Focus trap
const keydownTrap = ev => {
if (ev.key !== 'Tab') return;
const focusable = Array.from(modal.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
if (!focusable.length) return;
const first = focusable[0], last = focusable[focusable.length - 1];
if (ev.shiftKey && document.activeElement === first) { last.focus(); ev.preventDefault(); }
else if (!ev.shiftKey && document.activeElement === last) { first.focus(); ev.preventDefault(); }
};
document.addEventListener('keydown', keydownTrap);
active = { overlay, modal, escHandler, keydownTrap };
requestAnimationFrame(() => { overlay.style.opacity = '1'; });
setTimeout(() => { try { modal.focus(); } catch {} }, 60);
return overlay;
}
return { open, close };
})();
/* ─────────────────────────────────────────
SETTINGS MODAL
───────────────────────────────────────── */
const SettingsModal = (() => {
function makeToggle(settings, key, label) {
const wrap = Utils.el('label', '', {
style: 'display:flex;justify-content:space-between;align-items:center;background:rgba(255,255,255,.04);padding:9px 12px;border-radius:6px;cursor:pointer;transition:background .15s'
});
wrap.addEventListener('mouseenter', () => { wrap.style.background = 'rgba(255,255,255,.08)'; });
wrap.addEventListener('mouseleave', () => { wrap.style.background = 'rgba(255,255,255,.04)'; });
const sp = Utils.el('span', '', { textContent: label, style: 'font-size:13px;color:#d1d5db' });
const checkbox = Utils.el('input', '', { type: 'checkbox' });
checkbox.checked = !!settings[key];
checkbox.style.cssText = 'appearance:none;width:36px;height:20px;border-radius:10px;cursor:pointer;position:relative;transition:background .2s;background:' + (checkbox.checked ? '#3b82f6' : '#4b5563');
checkbox.addEventListener('change', () => {
settings[key] = checkbox.checked;
checkbox.style.background = checkbox.checked ? '#3b82f6' : '#4b5563';
Store.setSettings(settings);
Notify.show(`${label}: ${checkbox.checked ? 'on' : 'off'}`, 'info');
});
wrap.appendChild(sp);
wrap.appendChild(checkbox);
return wrap;
}
function show() {
try {
const settings = Store.getSettings();
const container = Utils.el('div', '', { style: 'display:flex;flex-direction:column;gap:8px' });
const toggles = [
['hideGenderTabs', 'Hide gender tabs'],
['showTimestamps', 'Chat timestamps'],
['animateThumbnails', 'Animate thumbnails on hover'],
['showViewerCount', 'Show viewer count badge'],
['showSearchFilter', 'Show search filter bar'],
['showFavoritesBar', 'Show favorites button in nav'],
['highlightFavorites', 'Highlight favorite cards'],
['autoBackup', 'Auto-backup daily'],
['performanceMode', 'Performance mode (lower load)'],
['compactNotifications','Suppress info notifications']
];
for (const [key, label] of toggles) {
container.appendChild(makeToggle(settings, key, label));
}
// Actions row
const actionsRow = Utils.el('div', '', { style: 'display:flex;flex-wrap:wrap;gap:8px;margin-top:8px' });
const mkBtn = (text, cls, onClick) => {
const b = Utils.el('button', `ce-modal-btn ${cls}`, { textContent: text, type: 'button' });
b.addEventListener('click', onClick);
return b;
};
actionsRow.appendChild(mkBtn('Export Backup', 'primary', () => DataManager.export()));
actionsRow.appendChild(mkBtn('Import Backup', '', () => DataManager.showImportDialog()));
actionsRow.appendChild(mkBtn('Token Stats', '', () => { ModalManager.close(); StatsManager.showTokenStats(); }));
actionsRow.appendChild(mkBtn('Favorites', '', () => { ModalManager.close(); StatsManager.showFavorites(); }));
actionsRow.appendChild(mkBtn('Reset All Data', 'danger', () => {
if (confirm('Reset ALL data? This cannot be undone.')) {
Store.resetAll();
Notify.show('Data reset', 'success');
setTimeout(() => location.reload(), 600);
}
}));
container.appendChild(actionsRow);
// Keyboard shortcuts reference
const shortcuts = Utils.el('div', '', {
innerHTML: `<div style="margin-top:10px;font-size:11px;color:#6b7280;line-height:1.8">
<strong style="color:#9ca3af">Keyboard shortcuts</strong><br>
<kbd style="background:#374151;padding:1px 5px;border-radius:3px">Shift+F</kbd> Focus search filter
<kbd style="background:#374151;padding:1px 5px;border-radius:3px">Esc</kbd> Close modal
</div>`
});
container.appendChild(shortcuts);
ModalManager.open('⚙ Settings', container);
} catch (e) { logger.error('SettingsModal.show', e); }
}
return { show };
})();
/* ─────────────────────────────────────────
DATA MANAGER (export / import)
───────────────────────────────────────── */
const DataManager = {
export(filename = null) {
try {
const backup = Store.backup();
Utils.download(
JSON.stringify(backup, null, 2),
filename || `cb_backup_${new Date().toISOString().slice(0, 10)}.json`
);
Notify.show('Backup exported', 'success');
} catch (e) {
logger.error('DataManager.export', e);
Notify.show('Export failed: ' + e.message, 'error');
}
},
async import(fileOrString, mergeMode = 'replace') {
try {
let payload;
if (typeof fileOrString === 'string') {
payload = Utils.parseJSON(fileOrString, null);
if (!payload) throw new Error('Invalid JSON');
} else {
const text = await fileOrString.text();
payload = Utils.parseJSON(text, null);
if (!payload) throw new Error('Invalid JSON file');
}
if (!Utils.isObj(payload)) throw new Error('Payload is not an object');
const inHidden = Array.isArray(payload.hiddenModels) ? payload.hiddenModels : [];
const inTokens = Utils.isObj(payload.tokensSpent) ? payload.tokensSpent : {};
const inFavs = Array.isArray(payload.favorites) ? payload.favorites : [];
const inNotes = Utils.isObj(payload.notes) ? payload.notes : {};
const inSettings = Utils.isObj(payload.settings) ? payload.settings : {};
if (mergeMode === 'merge') {
const h = new Set(Store.getHidden());
inHidden.forEach(x => h.add(x));
Store.setHidden(Array.from(h));
const t = Store.getTokens();
for (const k in inTokens) t[k] = (t[k] || 0) + Utils.safeInt(inTokens[k]);
Store.setTokens(t);
const f = new Set(Store.getFavorites());
inFavs.forEach(x => f.add(x));
Store.setFavorites(Array.from(f));
const n = Store.getNotes();
Object.assign(n, inNotes);
Store.setNotes(n);
Store.setSettings(Object.assign(Store.getSettings(), inSettings));
} else {
Store.setHidden(inHidden);
Store.setTokens(inTokens);
Store.setFavorites(inFavs);
Store.setNotes(inNotes);
Store.setSettings(Object.assign(Store.getSettings(), inSettings));
}
Store.saveBackup();
Notify.show('Import successful', 'success');
return true;
} catch (e) {
logger.error('DataManager.import', e);
Notify.show('Import failed: ' + e.message, 'error');
return false;
}
},
showImportDialog() {
const container = Utils.el('div', '', { style: 'display:flex;flex-direction:column;gap:12px' });
const fileInput = Utils.el('input', '', { type: 'file', accept: 'application/json' });
const radioWrap = Utils.el('div', '', { style: 'display:flex;gap:16px;font-size:13px;color:#d1d5db' });
radioWrap.innerHTML = `
<label style="cursor:pointer"><input type="radio" name="ce-import-mode" value="replace" checked /> Replace</label>
<label style="cursor:pointer"><input type="radio" name="ce-import-mode" value="merge" /> Merge (recommended)</label>
`;
const hint = Utils.el('p', '', {
textContent: 'Merge adds hidden models and sums token counts. Replace overwrites everything.',
style: 'font-size:12px;color:#6b7280;margin:0'
});
container.appendChild(fileInput);
container.appendChild(radioWrap);
container.appendChild(hint);
ModalManager.open('Import Backup', container, [
{ text: 'Cancel', cls: 'secondary', onClick: () => ModalManager.close() },
{
text: 'Import', cls: 'primary',
onClick: async () => {
const f = fileInput.files && fileInput.files[0];
if (!f) { Notify.show('Select a file first', 'error'); return; }
const modeEl = document.querySelector('input[name="ce-import-mode"]:checked');
const mode = modeEl ? modeEl.value : 'replace';
const ok = await DataManager.import(f, mode);
if (ok) { ModalManager.close(); setTimeout(() => location.reload(), 500); }
}
}
]);
}
};
/* ─────────────────────────────────────────
CHAT TIMESTAMP MANAGER
───────────────────────────────────────── */
class ChatTimestampManager {
constructor() {
this.observer = null;
this.processed = new WeakSet();
this.counter = 0;
}
start() {
if (!Store.getSettings().showTimestamps) return;
this._processAll();
this._observe();
}
stop() {
if (this.observer) { this.observer.disconnect(); this.observer = null; }
this.processed = new WeakSet();
this.counter = 0;
}
_processAll() {
this._processMessages();
this._processNotices();
}
_fmtTime(ts) {
const d = isNaN(ts) ? new Date() : new Date(ts);
const opts = Store.getSettings().performanceMode
? { hour: '2-digit', minute: '2-digit' }
: { hour: '2-digit', minute: '2-digit', second: '2-digit' };
return d.toLocaleTimeString([], opts);
}
_tsColor(el) {
try {
let cur = el;
while (cur && cur !== document.body) {
const bg = getComputedStyle(cur).backgroundColor;
if (bg && !bg.includes('rgba(0, 0, 0, 0)') && !bg.includes('transparent')) {
const m = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (m) {
const lum = (parseInt(m[1]) * 299 + parseInt(m[2]) * 587 + parseInt(m[3]) * 114) / 1000;
return lum > 175 ? 'rgba(0,0,0,.7)' : 'rgba(255,255,255,.75)';
}
}
cur = cur.parentElement;
}
} catch {}
return 'rgba(255,255,255,.75)';
}
_injectSpan(target, timeStr, el) {
if (target.querySelector('.ce-ts')) return;
const sp = Utils.el('span', 'ce-ts', {
textContent: `[${timeStr}] `,
style: `color:${this._tsColor(el)};font-size:11px;margin-right:4px;font-weight:400;display:inline`
});
target.insertBefore(sp, target.firstChild);
}
_processMessages() {
Utils.qsAll(CONFIG.SELECTORS.CHAT_MESSAGES).forEach(msg => {
if (this.processed.has(msg)) return;
if (msg.querySelector('[data-testid="room-notice"]')) { this._mark(msg); return; }
const ts = Utils.safeInt(msg.getAttribute('data-ts'), NaN);
const timeStr = this._fmtTime(ts);
const container = msg.querySelector('div[dm-adjust-bg],div[dm-adjust-fg]') || msg;
this._injectSpan(container, timeStr, msg);
this._mark(msg);
});
}
_processNotices() {
Utils.qsAll('[data-testid="room-notice"]').forEach(notice => {
if (this.processed.has(notice)) return;
const target = notice.querySelector('[data-testid="room-notice-viewport"] span') ||
notice.querySelector('span') || notice;
if (!target.querySelector('.ce-ts')) {
this._injectSpan(target, this._fmtTime(NaN), notice);
}
this._mark(notice);
});
}
_observe() {
const chatContainer = Utils.findEl(['#chat-messages', '.chat-messages', '#messages', '.messages-list', '#message-list']);
if (!chatContainer) return;
this.observer = new MutationObserver(Utils.debounce(() => this._processAll(), CONFIG.TIMERS.MUTATION_DEBOUNCE));
this.observer.observe(chatContainer, { childList: true, subtree: true });
}
_mark(node) {
this.processed.add(node);
if (++this.counter > 5000) { this.processed = new WeakSet(); this.counter = 0; }
}
}
/* ─────────────────────────────────────────
TAB MANAGER
───────────────────────────────────────── */
const TabManager = {
hideGenderTabs() {
if (!Store.getSettings().hideGenderTabs) return;
Utils.qsAll('a.gender-tab[href*="/trans-cams/"],a[href*="/male-cams"],a[href*="/trans-cams"]').forEach(el => { el.style.display = 'none'; });
const merch = Utils.qs('li a#merch');
if (merch && merch.closest('li')) merch.closest('li').style.display = 'none';
}
};
/* ─────────────────────────────────────────
TOKEN MONITOR
───────────────────────────────────────── */
class TokenMonitor {
constructor() {
this.observer = null;
this.lastCount = null;
this.active = false;
this.model = null;
}
async start() {
if (this.active) return;
this.active = true;
const tokenSpan = Utils.findEl(CONFIG.SELECTORS.TOKEN_BALANCE);
const scanSpan = Utils.qs(CONFIG.SELECTORS.SCAN_CAMS);
const model = Utils.getCurrentModel();
if (!tokenSpan || !scanSpan || !model) return;
this.model = model;
this._track(model, tokenSpan);
this._buildUI(model, scanSpan);
}
_track(model, span) {
const tokens = Store.getTokens();
if (!(model in tokens)) { tokens[model] = 0; Store.setTokens(tokens); }
this.lastCount = Utils.safeInt(span.textContent);
this.observer = new MutationObserver(() => {
if (!this.active) return;
const current = Utils.safeInt(span.textContent);
if (current < this.lastCount) {
const spent = this.lastCount - current;
const data = Store.getTokens();
data[model] = (data[model] || 0) + spent;
Store.setTokens(data);
// Broadcast to other tabs
localStorage.setItem('chaturbateEnhancer:lastTokenUpdate', JSON.stringify({ model, total: data[model], ts: Date.now() }));
this._updateDisplay(model, data[model]);
}
this.lastCount = current;
});
this.observer.observe(span, { childList: true, characterData: true, subtree: true });
window.addEventListener('storage', ev => {
if (ev.key !== 'chaturbateEnhancer:lastTokenUpdate') return;
try {
const p = Utils.parseJSON(ev.newValue, {});
if (p.model === model) this._updateDisplay(model, p.total);
} catch {}
});
}
_buildUI(model, anchor) {
if (Utils.qs('#ce-tokens-bar')) return;
const bar = Utils.el('div', '', { id: 'ce-tokens-bar' });
bar.style.cssText = 'display:flex;justify-content:space-between;align-items:center;background:rgba(0,0,0,.45);padding:7px 12px;border-radius:8px;margin:6px 0;gap:12px;flex-wrap:wrap';
const left = Utils.el('span', '', { id: 'ce-token-display', style: 'font-size:13px;font-weight:600;color:#e5e7eb' });
const right = Utils.el('div', '', { style: 'display:flex;gap:8px;flex-wrap:wrap' });
const links = [
{ label: 'RecuMe', url: CONFIG.EXTERNAL.RECU_ME + encodeURIComponent(model), color: 'linear-gradient(135deg,#f97316,#fbbf24)', textColor: '#000' },
{ label: 'CamWhoresTV', url: CONFIG.EXTERNAL.CAMWHORES_TV + encodeURIComponent(model) + '/', color: 'linear-gradient(135deg,#dc2626,#2563eb)', textColor: '#fff' },
{ label: 'CamGirlFinder',url: CONFIG.EXTERNAL.CAMGIRLFINDER + encodeURIComponent(model) + '#1', color: 'linear-gradient(135deg,#10b981,#059669)', textColor: '#fff' }
];
links.forEach(({ label, url, color, textColor }) => {
const btn = Utils.el('button', '', {
type: 'button', title: label, textContent: label,
style: `background:${color};color:${textColor};font-weight:700;padding:5px 12px;border-radius:99px;border:none;cursor:pointer;font-size:12px;transition:transform .15s,box-shadow .15s;box-shadow:0 2px 6px rgba(0,0,0,.2)`
});
btn.addEventListener('mouseenter', () => { btn.style.transform = 'translateY(-2px) scale(1.05)'; btn.style.boxShadow = '0 4px 10px rgba(0,0,0,.3)'; });
btn.addEventListener('mouseleave', () => { btn.style.transform = ''; btn.style.boxShadow = '0 2px 6px rgba(0,0,0,.2)'; });
btn.addEventListener('click', () => { try { window.open(url, '_blank', 'noopener,noreferrer'); } catch {} });
right.appendChild(btn);
});
bar.appendChild(left);
bar.appendChild(right);
const toolbar = Utils.qs('.genderTabs');
if (toolbar && toolbar.parentElement) toolbar.parentElement.insertBefore(bar, toolbar);
else document.body.prepend(bar);
this._updateDisplay(model);
}
_updateDisplay(model, count = null) {
const el = Utils.qs('#ce-token-display');
if (!el) return;
if (count === null) count = Store.getTokens()[model] || 0;
el.textContent = `Tokens spent on ${model}: ${count.toLocaleString()}`;
}
stop() {
this.active = false;
this.model = null;
if (this.observer) { this.observer.disconnect(); this.observer = null; }
}
}
/* ─────────────────────────────────────────
AUTO BACKUP
───────────────────────────────────────── */
function maybeAutoBackup() {
if (!Store.getSettings().autoBackup) return;
const lastTs = Utils.safeInt(localStorage.getItem(CONFIG.STORAGE.PREFIX + CONFIG.STORAGE.BACKUP_TS_KEY), 0);
if (Date.now() - lastTs >= CONFIG.TIMERS.AUTO_BACKUP_INTERVAL_MS) {
Store.saveBackup();
localStorage.setItem(CONFIG.STORAGE.PREFIX + CONFIG.STORAGE.BACKUP_TS_KEY, String(Date.now()));
logger.info('Auto-backup saved');
}
}
/* ─────────────────────────────────────────
MAIN STYLES
───────────────────────────────────────── */
function injectMainStyles() {
const style = Utils.el('style');
style.id = 'ce-main-styles';
style.textContent = `
/* Modal */
.ce-modal-btn{padding:6px 12px;border:none;border-radius:6px;font-weight:600;cursor:pointer;background:#374151;color:#fff;transition:background .15s,transform .1s;font-size:13px}
.ce-modal-btn:hover{background:#4b5563;transform:translateY(-1px)}
.ce-modal-btn.primary{background:#3b82f6}
.ce-modal-btn.primary:hover{background:#2563eb}
.ce-modal-btn.secondary{background:#6b7280}
.ce-modal-btn.secondary:hover{background:#4b5563}
.ce-modal-btn.danger{background:#dc2626}
.ce-modal-btn.danger:hover{background:#b91c1c}
/* Grid controls */
#ce-grid-controls{display:inline-flex;align-items:center;vertical-align:middle;margin-right:6px}
.ce-grid-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border:none;border-radius:4px;background:#1a252f;cursor:pointer;padding:0;transition:background .15s;box-shadow:0 1px 3px rgba(0,0,0,.15)}
.ce-grid-btn:hover{background:#2a3b4f}
.ce-grid-btn.active{background:#3b82f6;box-shadow:0 2px 8px rgba(59,130,246,.35)}
.ce-grid-btn svg{pointer-events:none}
.ce-grid-btn:focus{outline:none;box-shadow:0 0 0 2px rgba(59,130,246,.5)}
/* Chat timestamps */
.ce-ts{font-size:11px;font-weight:400;opacity:.85;margin-right:5px;display:inline;vertical-align:middle}
/* Search filter placeholder */
#ce-filter-input::placeholder{color:rgba(255,255,255,.75)}
/* Username row */
[data-testid="chat-message-username"]{display:inline-flex;align-items:center;gap:3px}
/* Stat bar */
#ce-stat-li a{transition:opacity .15s}
#ce-stat-li a:hover{opacity:.75}
`;
document.head.appendChild(style);
}
/* ─────────────────────────────────────────
KEYBOARD SHORTCUTS (global)
───────────────────────────────────────── */
function setupKeyboardShortcuts() {
document.addEventListener('keydown', ev => {
if (document.activeElement && ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
// Shift+S → open settings
if (ev.shiftKey && ev.key === 'S') { ev.preventDefault(); SettingsModal.show(); }
// Shift+H → show hidden models
if (ev.shiftKey && ev.key === 'H') { ev.preventDefault(); ModalManager.open('Hidden Models', StatsManager.showHiddenList && StatsManager.showHiddenList() || Utils.el('div')); }
});
}
/* ─────────────────────────────────────────
MAIN CONTROLLER
───────────────────────────────────────── */
class ChaturbateEnhancer {
constructor() {
this.tokenMonitor = null;
this.chatTs = null;
this.lastPath = location.pathname;
this.mutObs = null;
this.pathInterval = null;
this.initialized = false;
}
async init() {
if (this.initialized) return;
try {
showOverlay();
Store.migrate();
injectMainStyles();
await this._setup();
this._watchDOM();
this._watchPath();
this._exposeGlobals();
this.initialized = true;
maybeAutoBackup();
} catch (e) {
logger.error('Enhancer.init', e);
} finally {
setTimeout(hideOverlay, 80);
}
}
async _setup() {
try {
const isRoomPage = !!Utils.getCurrentModel();
if (!isRoomPage) {
GridManager.init();
ThumbnailManager.init();
SearchFilter.inject();
} else {
ThumbnailManager.stopAll();
}
if (this.tokenMonitor) { this.tokenMonitor.stop(); }
this.tokenMonitor = new TokenMonitor();
await this.tokenMonitor.start();
if (this.chatTs) { this.chatTs.stop(); }
this.chatTs = new ChatTimestampManager();
this.chatTs.start();
ButtonManager.processCards();
TabManager.hideGenderTabs();
StatsManager.updateStat();
SearchFilter.filterCards();
} catch (e) { logger.error('_setup', e); }
}
_watchDOM() {
const debounced = Utils.debounce(() => this._setup(), CONFIG.TIMERS.MUTATION_DEBOUNCE);
const targets = [
Utils.qs('#room_list, .room_list'),
Utils.qs('#main, .main-content')
].filter(Boolean);
this.mutObs = new MutationObserver(muts => {
for (const m of muts) {
if ((m.addedNodes && m.addedNodes.length) || (m.removedNodes && m.removedNodes.length)) {
debounced();
break;
}
}
});
for (const t of targets) this.mutObs.observe(t, { childList: true, subtree: true });
}
_watchPath() {
this.pathInterval = setInterval(() => {
if (location.pathname !== this.lastPath) this._onPathChange();
}, CONFIG.TIMERS.PATH_CHECK_INTERVAL);
}
_onPathChange() {
this.lastPath = location.pathname;
if (this.tokenMonitor) this.tokenMonitor.stop();
if (this.chatTs) this.chatTs.stop();
ThumbnailManager.stopAll();
ButtonManager.reset();
setTimeout(() => this._setup(), 400);
}
_exposeGlobals() {
window.ceSettings = () => SettingsModal.show();
window.ceExport = () => DataManager.export();
window.ceImport = () => DataManager.showImportDialog();
window.ceFavorites = () => StatsManager.showFavorites();
window.ceTokenStats = () => StatsManager.showTokenStats();
window.ceErrors = () => { console.table(logger.getErrors()); };
window.ceReset = () => {
if (confirm('Reset ALL Chaturbate Enhancer data?')) {
Store.resetAll();
Notify.show('Data reset', 'success');
setTimeout(() => location.reload(), 600);
}
};
}
}
/* ─────────────────────────────────────────
BOOTSTRAP
───────────────────────────────────────── */
let enhancer = null;
function boot() {
if (enhancer) return;
enhancer = new ChaturbateEnhancer();
enhancer.init();
setupKeyboardShortcuts();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
setTimeout(boot, 80);
}
})();