// ==UserScript==
// @name Chaturbate Enhancer (compat build)
// @namespace http://tampermonkey.net/
// @version 5.3.1
// @description Lag fixes, safer observers, ES2018 syntax (no #private fields)
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @run-at document-end
// @author shadow
// @grant none
// ==/UserScript==
console.log("✅ Enhancer userscript loaded");
(function () {
'use strict';
/***********************
* Configuration & Constants
***********************/
const CONFIG = Object.freeze({
STORAGE: Object.freeze({
PREFIX: 'chaturbateEnhancer:',
HIDDEN_KEY: 'hiddenModels',
TOKENS_KEY: 'tokensSpent',
SETTINGS_KEY: 'settings',
BACKUP_KEY: 'backup'
}),
TIMERS: Object.freeze({
TOKEN_MONITOR_RETRY_DELAY: 500,
TOKEN_MONITOR_MAX_RETRIES: 20,
CHAT_TIMESTAMP_INTERVAL: 1000,
PATH_CHECK_INTERVAL: 900,
MUTATION_DEBOUNCE: 150
}),
SELECTORS: Object.freeze({
ROOM_CARDS: 'li.roomCard.camBgColor',
TOKEN_BALANCE_SELECTORS: ['span.balance', 'span.token_balance', 'span.tokencount'],
SCAN_CAMS: '[data-testid="scan-cams"]',
CHAT_MESSAGES: '[data-testid="chat-message"]',
CHAT_USERNAME: '[data-testid="chat-message-username"]',
ROOM_NOTICE: '[data-testid="room-notice-viewport"]'
}),
EXTERNAL_LINKS: Object.freeze({
RECU_ME: 'https://recu.me/performer/',
CAMWHORES_TV: 'https://www.camwhores.tv/search/'
})
});
/* ======================
Logger (robust & safe)
====================== */
class Logger {
constructor(maxErrors = 200) {
if (Logger._inst) return Logger._inst;
Logger._inst = this;
this.errors = [];
this.maxErrors = maxErrors;
}
static getInstance() { return new Logger(); }
_fmt(msg) { return `[ChaturbateEnhancer ${new Date().toISOString()}] ${msg}`; }
log(level, message, obj = null) {
try {
const formatted = this._fmt(message);
const extra = obj || '';
if (level === 'error') console.error(formatted, extra);
else if (level === 'warn') console.warn(formatted, extra);
else if (level === 'info') console.info(formatted, extra);
else console.debug(formatted, extra);
if (level === 'error' && this.errors.length < this.maxErrors) {
this.errors.push({ ts: Date.now(), message, extra: obj ? (obj.stack || String(obj)) : null });
}
} catch (e) { /* don't break app on logging failure */ }
}
error(m, o) { this.log('error', m, o); }
warn(m, o) { this.log('warn', m, o); }
info(m, o) { this.log('info', m, o); }
debug(m, o) { this.log('debug', m, o); }
}
Logger._inst = null;
const logger = Logger.getInstance();
/* ======================
Utils (stable helpers)
====================== */
const Utils = {
getCurrentModelFromPath() {
try {
const parts = window.location.pathname.split('/').filter(Boolean);
// Only consider it a model page if there's exactly one path segment
// and it doesn't contain common non-model patterns
if (parts.length !== 1) return null;
const segment = parts[0];
// Exclude known non-model pages
if (segment.includes('-') ||
segment === 'female' ||
segment === 'male' ||
segment === 'couple' ||
segment === 'trans') {
return null;
}
return segment;
} catch (e) {
logger.error('getCurrentModelFromPath failed', e);
return null;
}
},
safeParseInt(str) {
try { const s = String(str || '').replace(/[^\d\-]/g, ''); const n = parseInt(s, 10); return Number.isNaN(n) ? 0 : n; }
catch (e) { logger.error('safeParseInt', e); return 0; }
},
safeString(val, fallback = '') {
try { return String(val || '').trim(); } catch { return fallback; }
},
isObject(val) { return val && typeof val === 'object' && !Array.isArray(val); },
createElement(tag, className = '', attrs = {}) {
const el = document.createElement(tag);
if (className) el.className = className;
for (const k in (attrs || {})) {
const v = attrs[k];
if (k === 'textContent' || k === 'innerHTML') el[k] = v;
else if (k.startsWith('data-') || ['id','href','title','aria-label','role','type','value','style'].includes(k)) el.setAttribute(k, v);
else el[k] = v;
}
return el;
},
findElement(selectors = [], ctx = document) {
for (let i=0;i<selectors.length;i++) {
try {
const el = ctx.querySelector(selectors[i]);
if (el) return el;
} catch {}
}
return null;
},
debounce(fn, wait = 100) {
let t = null;
return function() {
const args = arguments, self = this;
clearTimeout(t);
t = setTimeout(() => { try { fn.apply(self, args); } catch (err) { logger.error('debounce handler error', err); } }, wait);
};
},
throttle(fn, limit = 200) {
let last = 0;
return function() {
const now = Date.now();
if (now - last >= limit) {
last = now;
try { fn.apply(this, arguments); } catch (err) { logger.error('throttle handler error', err); }
}
};
},
safeJSONParse(raw, fallback = null) { try { return JSON.parse(raw); } catch { return fallback; } },
downloadAsFile(content, filename = 'export.json') {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = Utils.createElement('a', '', { href: url });
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
};
// Define SVG icons at the top of the script
const gGridIconSvg2 = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<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 gGridIconSvg3 = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<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 gGridIconSvg4 = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<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 gGridIconSvg6 = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
<!-- Add more rectangles for a 6x6 grid if needed, truncated for brevity -->
</svg>
`;
// Update GridManager.init
class GridManager {
static init() {
try {
if (Utils.getCurrentModelFromPath()) return; // Skip model pages
// First, restore the saved grid setting
const saved = Utils.safeParseInt(localStorage.getItem("chaturbateEnhancer:gridCols"));
if (saved > 0) {
GridManager.setColumns(saved);
}
const container = Utils.findElement(["ul.advanced-search-button-container", "ul.top-nav", "nav ul"]);
if (!container || document.querySelector("#grid-size-controls")) return;
const li = Utils.createElement("li", "", { id: "grid-size-controls" });
const wrap = Utils.createElement("div", "grid-buttons-wrap");
li.appendChild(wrap);
// Updated icon options with new SVGs
const iconOptions = [
{ cols: 2, svg: gGridIconSvg2, title: "Large thumbnails (2 columns)" },
{ cols: 3, svg: gGridIconSvg3, title: "Medium thumbnails (3 columns)" },
{ cols: 4, svg: gGridIconSvg4, title: "Small thumbnails (4 columns)" },
{ cols: 6, svg: gGridIconSvg6, title: "Extra small thumbnails (6 columns)" },
];
iconOptions.forEach(opt => {
const btn = Utils.createElement("button", "grid-btn", {
type: "button",
title: opt.title,
innerHTML: opt.svg,
style: "margin: 0 4px;" // Add spacing between buttons
});
// Add active class if this matches the saved setting
if (saved === opt.cols) {
btn.classList.add("active");
}
btn.addEventListener("click", () => {
// Remove active class from all buttons
wrap.querySelectorAll(".grid-btn").forEach(b => b.classList.remove("active"));
// Add active class to clicked button
btn.classList.add("active");
GridManager.setColumns(opt.cols);
});
wrap.appendChild(btn);
});
const filterDiv = container.querySelector('div[data-testid="filter-button"]');
if (filterDiv) {
container.insertBefore(li, filterDiv);
} else {
container.appendChild(li);
}
} catch (e) {
logger.error("GridManager.init failed", e);
}
}
static setColumns(cols) {
try {
const grid = document.querySelector("ul.list.endless_page_template.show-location");
if (!grid) {
// If grid not found, save the setting anyway for when it loads
localStorage.setItem("chaturbateEnhancer:gridCols", cols);
return;
}
// enforce grid layout
grid.style.display = "grid";
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.style.gap = "12px";
// scale the cards themselves
const cards = grid.querySelectorAll("li.roomCard.camBgColor");
cards.forEach(card => {
card.style.width = "100%"; // take the grid cell width
card.style.maxWidth = "100%";
});
// optionally scale thumbnails inside the cards
const thumbs = grid.querySelectorAll("li.roomCard.camBgColor img");
thumbs.forEach(img => {
img.style.width = "100%";
img.style.height = "auto";
img.style.objectFit = "cover"; // so aspect ratio stays nice
});
localStorage.setItem("chaturbateEnhancer:gridCols", cols);
NotificationManager.show(`Grid set to ${cols} columns`, "success");
} catch (e) { logger.error("GridManager.setColumns failed", e); }
}
// Add a method to restore grid on dynamic content changes
static restoreGridIfNeeded() {
const saved = Utils.safeParseInt(localStorage.getItem("chaturbateEnhancer:gridCols"));
if (saved > 0) {
// Use a small delay to ensure DOM is ready
setTimeout(() => {
GridManager.setColumns(saved);
}, 100);
}
}
}
/* =========================
StorageManager (localStorage) — no private fields
========================= */
class StorageManager {
static _prefix(key) { return CONFIG.STORAGE.PREFIX + key; }
static migrateOldKeys() {
try {
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 oldKey in map) {
const newKey = map[oldKey];
const oldVal = localStorage.getItem(oldKey);
if (oldVal && !localStorage.getItem(this._prefix(newKey))) {
localStorage.setItem(this._prefix(newKey), oldVal);
logger.info('Migrated ' + oldKey + ' → ' + this._prefix(newKey));
}
if (oldVal) localStorage.removeItem(oldKey);
}
} catch (e) { logger.error('migrateOldKeys failed', e); }
}
static _safeGet(key, fallback = null) {
try {
const raw = localStorage.getItem(this._prefix(key));
if (!raw) return fallback;
return JSON.parse(raw);
} catch (e) { logger.error('StorageManager.get ' + key + ' parse failed', e); return fallback; }
}
static _safeSet(key, value) {
try { localStorage.setItem(this._prefix(key), JSON.stringify(value)); return true; }
catch (e) { logger.error('StorageManager.set ' + key + ' failed', e); return false; }
}
static getHiddenModels() { return this._safeGet(CONFIG.STORAGE.HIDDEN_KEY, []); }
static saveHiddenModels(arr) { if (!Array.isArray(arr)) return false; return this._safeSet(CONFIG.STORAGE.HIDDEN_KEY, arr); }
static getTokensSpent() { return this._safeGet(CONFIG.STORAGE.TOKENS_KEY, {}); }
static saveTokensSpent(obj) { if (!Utils.isObject(obj)) return false; return this._safeSet(CONFIG.STORAGE.TOKENS_KEY, obj); }
static getSettings() {
return this._safeGet(CONFIG.STORAGE.SETTINGS_KEY, {
hideGenderTabs: true,
showTimestamps: true,
autoBackup: false,
performanceMode: false,
animateThumbnails: true
});
}
static saveSettings(obj) { return this._safeSet(CONFIG.STORAGE.SETTINGS_KEY, obj); }
static createBackupObject() {
try {
return {
hiddenModels: this.getHiddenModels(),
tokensSpent: this.getTokensSpent(),
settings: this.getSettings(),
timestamp: Date.now(),
version: 'patched-compat'
};
} catch (e) { logger.error('createBackupObject error', e); return null; }
}
static saveBackupToLocal(backupObj) {
try {
if (!backupObj) backupObj = this.createBackupObject();
if (!backupObj) return false;
return this._safeSet(CONFIG.STORAGE.BACKUP_KEY, backupObj);
} catch (e) { logger.error('saveBackupToLocal error', e); return false; }
}
static loadBackupFromLocal() { return this._safeGet(CONFIG.STORAGE.BACKUP_KEY, null); }
static resetAll() {
try {
const vals = CONFIG.STORAGE;
for (const k in vals) localStorage.removeItem(this._prefix(vals[k]));
return true;
} catch (e) { logger.error('resetAll error', e); return false; }
}
}
/* =========================
DataManager — Backup / Import
========================= */
class DataManager {
static exportData(filename = null) {
try {
const backup = StorageManager.createBackupObject();
if (!backup) { NotificationManager.show('Nothing to export', 'error'); return; }
Utils.downloadAsFile(JSON.stringify(backup, null, 2), filename || `chaturbate_backup_${new Date().toISOString().slice(0,10)}.json`);
NotificationManager.show('Backup exported', 'success');
} catch (e) { logger.error('exportData failed', e); NotificationManager.show('Export failed: ' + e.message, 'error'); }
}
static async importData(fileOrString, options = { mergeMode: 'replace' }) {
try {
let payload = null;
if (typeof fileOrString === 'string') {
payload = Utils.safeJSONParse(fileOrString, null);
if (!payload) throw new Error('Invalid JSON string');
} else if (fileOrString && fileOrString.text) {
const t = await fileOrString.text();
payload = Utils.safeJSONParse(t, null);
if (!payload) throw new Error('Invalid JSON file');
} else throw new Error('Unsupported input for importData');
if (!Utils.isObject(payload)) throw new Error('Payload not object');
const incomingHidden = Array.isArray(payload.hiddenModels) ? payload.hiddenModels : [];
const incomingTokens = Utils.isObject(payload.tokensSpent) ? payload.tokensSpent : {};
const incomingSettings = Utils.isObject(payload.settings) ? payload.settings : {};
if (options.mergeMode === 'merge') {
const existingHidden = new Set(StorageManager.getHiddenModels());
for (let i=0;i<incomingHidden.length;i++) existingHidden.add(incomingHidden[i]);
StorageManager.saveHiddenModels(Array.from(existingHidden));
const currentTokens = StorageManager.getTokensSpent();
for (const k in incomingTokens) {
const v = Utils.safeParseInt(incomingTokens[k]);
currentTokens[k] = (currentTokens[k] || 0) + v;
}
StorageManager.saveTokensSpent(currentTokens);
const curSettings = StorageManager.getSettings();
StorageManager.saveSettings(Object.assign({}, curSettings, incomingSettings));
} else {
StorageManager.saveHiddenModels(incomingHidden);
StorageManager.saveTokensSpent(incomingTokens);
StorageManager.saveSettings(Object.assign(StorageManager.getSettings(), incomingSettings));
}
StorageManager.saveBackupToLocal();
NotificationManager.show('Data imported successfully', 'success');
try { localStorage.setItem('chaturbate_enhancer_data_imported', JSON.stringify({ ts: Date.now() })); localStorage.removeItem('chaturbate_enhancer_data_imported'); } catch {}
return true;
} catch (e) {
logger.error('importData error', e);
NotificationManager.show('Import failed: ' + e.message, 'error');
return false;
}
}
}
/* =========================
NotificationManager (stacked toasts)
========================= */
class NotificationManager {
static show(message, type = 'info', duration = 3000) {
try {
NotificationManager._toasts = NotificationManager._toasts || new Set();
const el = Utils.createElement('div', 'toast-notification ' + type, { textContent: message });
el.style.position = 'fixed';
el.style.top = (20 + NotificationManager._toasts.size * 50) + 'px';
el.style.right = '20px';
el.style.zIndex = 10001;
el.style.padding = '8px 12px';
el.style.background = 'rgba(0,0,0,0.7)';
el.style.color = '#fff';
el.style.borderRadius = '8px';
el.style.boxShadow = '0 8px 24px rgba(0,0,0,0.35)';
el.style.transition = 'opacity .25s';
document.body.appendChild(el);
NotificationManager._toasts.add(el);
setTimeout(() => NotificationManager.remove(el), duration);
return el;
} catch (e) { logger.error('Notification show failed', e); return null; }
}
static remove(el) {
try {
if (el && el.parentNode) el.parentNode.removeChild(el);
if (NotificationManager._toasts) NotificationManager._toasts.delete(el);
NotificationManager.reposition();
} catch {}
}
static clear() { if (!NotificationManager._toasts) return; for (const t of Array.from(NotificationManager._toasts)) NotificationManager.remove(t); }
static reposition() {
if (!NotificationManager._toasts) return;
Array.from(NotificationManager._toasts).forEach((el, idx) => { el.style.top = (20 + idx * 50) + 'px'; });
}
}
/* =========================
ThumbnailManager (animated previews) — fixed
========================= */
class ThumbnailManager {
static init() {
try {
const settings = StorageManager.getSettings();
if (!('animateThumbnails' in settings)) {
settings.animateThumbnails = true;
StorageManager.saveSettings(settings);
}
ThumbnailManager.setupListeners();
ThumbnailManager.observeDomCleanup();
} catch (e) { logger.error('ThumbnailManager.init failed', e); }
}
static setupListeners() {
const onEnter = Utils.debounce(function(ev) {
try {
const img = ev.target;
if (!(img instanceof Element)) return;
if (!img.matches('.room_list_room img, .roomElement img, .roomCard img')) return;
if (!StorageManager.getSettings().animateThumbnails) return;
ThumbnailManager.clearIntervalFor(img);
ThumbnailManager.updateRoomThumb(img);
const perf = StorageManager.getSettings().performanceMode;
const interval = perf ? 400 : 150;
const id = setInterval(() => ThumbnailManager.updateRoomThumb(img), interval);
ThumbnailManager._hoverIntervals.set(img, id);
} catch (e) { logger.error('thumb mouseenter', e); }
}, 60);
const onLeave = function(ev) {
try {
const img = ev.target;
if (!(img instanceof Element)) return;
if (!img.matches('.room_list_room img, .roomElement img, .roomCard img')) return;
ThumbnailManager.clearIntervalFor(img);
} catch (e) { logger.error('thumb mouseleave', e); }
};
document.addEventListener('mouseenter', onEnter, true);
document.addEventListener('mouseleave', onLeave, true);
}
static observeDomCleanup() {
if (ThumbnailManager._cleanupObserver) ThumbnailManager._cleanupObserver.disconnect();
ThumbnailManager._cleanupObserver = new MutationObserver(function(muts) {
for (let i=0;i<muts.length;i++) {
const removed = muts[i].removedNodes;
if (!removed) continue;
for (let j=0;j<removed.length;j++) {
const n = removed[j];
if (n && n.querySelectorAll) {
const imgs = n.querySelectorAll('img');
for (let k=0;k<imgs.length;k++) ThumbnailManager.clearIntervalFor(imgs[k]);
}
}
}
});
ThumbnailManager._cleanupObserver.observe(document.body, { childList: true, subtree: true });
}
static clearIntervalFor(img) {
const id = ThumbnailManager._hoverIntervals.get(img);
if (id) {
clearInterval(id);
ThumbnailManager._hoverIntervals.delete(img);
}
}
static async updateRoomThumb(img) {
try {
const parent = img.parentElement;
let username = null;
if (parent) {
username = (parent.dataset && parent.dataset.room) || null;
if (!username) {
const card = parent.closest ? parent.closest('li.roomCard') : null;
username = ModelManager.extractUsername(card);
}
}
if (!username) return;
const now = Date.now();
const perf = StorageManager.getSettings().performanceMode;
const minGap = perf ? 260 : 120;
const last = ThumbnailManager._lastReqTime.get(username) || 0;
if (now - last < minGap) return;
ThumbnailManager._lastReqTime.set(username, now);
const url = 'https://thumb.live.mmcdn.com/minifwap/' + encodeURIComponent(username) + '.jpg?' + Math.random();
const controller = new AbortController();
const timeout = setTimeout(() => { try { controller.abort(); } catch {} }, perf ? 1500 : 2500);
const resp = await fetch(url, { cache: 'no-cache', signal: controller.signal });
clearTimeout(timeout);
if (!resp || !resp.ok) return;
const blob = await resp.blob();
const prev = img.getAttribute('data-__thumb-obj-url');
if (prev) { try { URL.revokeObjectURL(prev); } catch {} }
const objUrl = URL.createObjectURL(blob);
img.src = objUrl;
img.setAttribute('data-__thumb-obj-url', objUrl);
} catch (e) { if (!e || e.name !== 'AbortError') logger.error('updateRoomThumb error', e); }
}
static stopAll() {
for (const [img, id] of ThumbnailManager._hoverIntervals.entries()) {
clearInterval(id);
}
ThumbnailManager._hoverIntervals.clear();
const imgs = document.querySelectorAll('img[data-__thumb-obj-url]');
for (let i=0;i<imgs.length;i++) {
const img = imgs[i];
const u = img.getAttribute('data-__thumb-obj-url');
if (u) { try { URL.revokeObjectURL(u); } catch {} }
img.removeAttribute('data-__thumb-obj-url');
}
if (ThumbnailManager._cleanupObserver) {
ThumbnailManager._cleanupObserver.disconnect();
ThumbnailManager._cleanupObserver = null;
}
}
}
ThumbnailManager._hoverIntervals = new Map(); // ✅ use Map, not WeakMap
ThumbnailManager._lastReqTime = new Map();
ThumbnailManager._cleanupObserver = null;
/* =========================
ModelManager
========================= */
class ModelManager {
static 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 href = a.getAttribute('href') || '';
const parts = href.split('/').filter(Boolean);
return parts[0] || null;
}
} catch (e) { logger.error('extractUsername failed', e); }
return null;
}
}
/* =========================
ModalManager (no private)
========================= */
class ModalManager {
static createModal(title, bodyElement, buttons = []) {
console.log('Creating modal:', title); // Debugging
if (ModalManager._active) {
console.log('Closing existing modal before creating new one');
ModalManager.closeModal();
}
const overlay = Utils.createElement('div', 'chaturbate-hider-overlay');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const modal = Utils.createElement('div', 'chaturbate-hider-modal', { tabindex: '-1' });
const header = Utils.createElement('div', 'chaturbate-hider-header');
const h3 = Utils.createElement('h3', '', { textContent: title, id: 'ce-modal-title' });
const closeBtn = Utils.createElement('button', 'chaturbate-hider-close', { type: 'button', 'aria-label': 'Close' });
closeBtn.innerHTML = '×';
header.appendChild(h3); header.appendChild(closeBtn);
const body = Utils.createElement('div', 'chaturbate-hider-body');
body.appendChild(bodyElement);
const footer = Utils.createElement('div', 'chaturbate-hider-footer');
for (let i = 0; i < buttons.length; i++) {
const cfg = buttons[i] || {};
const b = Utils.createElement('button', 'chaturbate-hider-btn ' + (cfg.class || ''), {
type: 'button',
textContent: cfg.text || 'OK'
});
if (cfg.ariaLabel) b.setAttribute('aria-label', cfg.ariaLabel);
if (cfg.onClick) b.addEventListener('click', function(e) {
e.preventDefault();
try { cfg.onClick(); } catch (err) { logger.error('modal btn cb', err); }
});
footer.appendChild(b);
}
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
const escHandler = function(e) { if (e.key === 'Escape') ModalManager.closeModal(); };
const overlayClick = function(e) { if (e.target === overlay) ModalManager.closeModal(); };
closeBtn.addEventListener('click', function() { ModalManager.closeModal(); });
document.addEventListener('keydown', escHandler);
overlay.addEventListener('click', overlayClick);
const focusable = function() {
return overlay.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
};
const keydownTrap = function(e) {
if (e.key !== 'Tab') return;
const f = Array.from(focusable());
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
};
document.addEventListener('keydown', keydownTrap);
ModalManager._active = { overlay, modal, escHandler, overlayClick, keydownTrap };
console.log('Modal created, adding visible class'); // Debugging
requestAnimationFrame(function() {
overlay.classList.add('visible');
console.log('Visible class added to overlay'); // Debugging
});
setTimeout(function() {
try {
modal.focus();
console.log('Modal focused'); // Debugging
} catch (e) {
logger.error('Modal focus error', e);
}
}, 80);
return overlay;
}
static closeModal() {
if (!ModalManager._active) return;
const st = ModalManager._active;
try {
document.removeEventListener('keydown', st.escHandler);
document.removeEventListener('keydown', st.keydownTrap);
st.overlay.removeEventListener('click', st.overlayClick);
st.overlay.classList.remove('visible');
setTimeout(function(){ if (st.overlay.parentNode) st.overlay.parentNode.removeChild(st.overlay); }, 200);
} catch (e) { logger.error('closeModal error', e); }
ModalManager._active = null;
}
}
ModalManager._active = null;
/* =========================
SettingsModal
========================= */
class SettingsModal {
static _makeToggle(settings, key, label) {
const wrap = Utils.createElement('label', 'enhancer-toggle');
const span = Utils.createElement('span', '', { textContent: label });
const input = Utils.createElement('input', '', { type: 'checkbox' });
input.checked = !!settings[key];
input.addEventListener('change', function() {
settings[key] = input.checked;
StorageManager.saveSettings(settings);
NotificationManager.show(label + ' ' + (input.checked ? 'enabled' : 'disabled'), 'info');
});
wrap.appendChild(span); wrap.appendChild(input);
return wrap;
}
static _makeActionsRow() {
const wrap = Utils.createElement('div', 'settings-actions');
const exportBtn = Utils.createElement('button', 'primary', { textContent: 'Export Backup', type: 'button' });
const importBtn = Utils.createElement('button', 'secondary', { textContent: 'Import Backup', type: 'button' });
exportBtn.addEventListener('click', function(){ DataManager.exportData(); });
importBtn.addEventListener('click', function(){ SettingsModal.showImportDialog(); });
wrap.appendChild(exportBtn); wrap.appendChild(importBtn);
return wrap;
}
static showImportDialog() {
try {
const container = Utils.createElement('div', '', { style: 'display:flex;flex-direction:column;gap:12px;padding:6px 0' });
const fileInput = Utils.createElement('input', '', { type: 'file', accept: 'application/json' });
const radioWrap = Utils.createElement('div', '', { style: 'display:flex;gap:8px;align-items:center' });
radioWrap.appendChild(Utils.createElement('label', '', { innerHTML: '<input type="radio" name="importMode" value="replace" checked /> Replace existing' }));
radioWrap.appendChild(Utils.createElement('label', '', { innerHTML: '<input type="radio" name="importMode" value="merge" /> Merge (recommended)' }));
const hint = Utils.createElement('div', '', {
textContent: 'Choose a backup file (.json). Merge adds hidden models and sums token counts; Replace overwrites.',
style:'font-size:12px;color:var(--ch-text-muted)'
});
const btnRow = Utils.createElement('div', '', { style: 'display:flex;gap:8px;justify-content:flex-end' });
const importBtn = Utils.createElement('button', 'chaturbate-hider-btn primary', { textContent: 'Import', type: 'button' });
const cancelBtn = Utils.createElement('button', 'chaturbate-hider-btn secondary', { textContent: 'Cancel', type: 'button' });
btnRow.appendChild(cancelBtn); btnRow.appendChild(importBtn);
container.appendChild(fileInput);
container.appendChild(radioWrap);
container.appendChild(hint);
container.appendChild(btnRow);
ModalManager.createModal('Import Backup', container, []);
cancelBtn.addEventListener('click', function(){ ModalManager.closeModal(); });
importBtn.addEventListener('click', async function() {
const f = fileInput.files && fileInput.files[0];
if (!f) { NotificationManager.show('Please select a file', 'error'); return; }
const modeEl = document.querySelector('input[name="importMode"]:checked');
const mode = (modeEl && modeEl.value) || 'replace';
importBtn.disabled = true;
const ok = await DataManager.importData(f, { mergeMode: mode });
importBtn.disabled = false;
if (ok) {
ModalManager.closeModal();
setTimeout(function(){ window.location.reload(); }, 600);
}
});
} catch (e) { logger.error('showImportDialog error', e); NotificationManager.show('Failed to open import dialog', 'error'); }
}
static show() {
try {
const settings = StorageManager.getSettings();
const container = Utils.createElement('div', 'enhancer-settings-group');
const toggles = [
['hideGenderTabs', 'Hide gender tabs'],
['showTimestamps', 'Show chat timestamps'],
['animateThumbnails', 'Animate thumbnails on hover'],
['autoBackup', 'Auto-backup data daily'],
['performanceMode', 'Performance mode (less animations/requests)']
];
for (let i=0;i<toggles.length;i++) {
const [key, label] = toggles[i];
container.appendChild(SettingsModal._makeToggle(settings, key, label));
}
container.appendChild(SettingsModal._makeActionsRow());
const tokenStatsBtn = Utils.createElement('button', 'chaturbate-hider-btn', {
textContent: 'Show Token Stats',
type: 'button',
style: 'margin-top:10px;'
});
tokenStatsBtn.addEventListener('click', function(){ StatsManager.showTokenStats(); });
container.appendChild(tokenStatsBtn);
ModalManager.createModal('Enhancer Settings', container, [
{ text: 'Close', class: 'secondary', onClick: function(){ ModalManager.closeModal(); } }
]);
} catch (e) { logger.error('SettingsModal.show failed', e); }
}
}
/* =========================
ButtonManager (hide models)
========================= */
class ButtonManager {
static addHideButtons() {
try {
ButtonManager._processed = ButtonManager._processed || new WeakSet();
const cards = document.querySelectorAll(CONFIG.SELECTORS.ROOM_CARDS);
if (!cards.length) return;
const hidden = new Set(StorageManager.getHiddenModels());
let added = 0, hiddenCount = 0;
for (let i=0;i<cards.length;i++) {
const card = cards[i];
if (ButtonManager._processed.has(card)) continue;
ButtonManager._processed.add(card);
const username = ModelManager.extractUsername(card);
if (!username) continue;
if (hidden.has(username)) {
card.style.display = 'none';
card.setAttribute('data-hidden','true');
hiddenCount++;
continue;
}
const btn = ButtonManager.createHideButton(card, username);
if (getComputedStyle(card).position === 'static') card.style.position = 'relative';
card.appendChild(btn);
added++;
}
if (added > 0 || hiddenCount > 0) StatsManager.updateHiddenModelsStat();
} catch (e) { logger.error('addHideButtons failed', e); }
}
static createHideButton(card, username) {
const button = Utils.createElement('button', 'hide-model-button', {
'aria-label': 'Hide ' + username,
title: 'Hide ' + username,
textContent: '✕',
type: 'button'
});
button.addEventListener('click', function(ev) {
ev.stopPropagation();
ev.preventDefault();
card.style.display = 'none';
card.setAttribute('data-hidden','true');
const hidden = StorageManager.getHiddenModels();
if (hidden.indexOf(username) === -1) {
hidden.push(username);
StorageManager.saveHiddenModels(hidden);
StatsManager.updateHiddenModelsStat();
NotificationManager.show('Hidden: ' + username, 'success');
}
});
return button;
}
static clearProcessedCards() { ButtonManager._processed = new WeakSet(); }
}
ButtonManager._processed = new WeakSet();
/* =========================
ChatTimestampManager — contrast-aware + notices
========================= */
/* Existing code up to ChatTimestampManager remains unchanged */
class ChatTimestampManager {
constructor() {
this.observer = null;
this.processed = new WeakSet();
this.settings = StorageManager.getSettings();
this.maxProcessed = 5000;
this.counter = 0;
}
start() {
if (!this.settings.showTimestamps) return;
this.addTimestamps();
this.setupMonitoring();
}
/* ---------- core ---------- */
addTimestamps() {
try {
this.processChatMessages();
this.processStandaloneNotices();
} catch (e) { logger.error('addTimestamps failed', e); }
}
processChatMessages() {
const messages = document.querySelectorAll(CONFIG.SELECTORS.CHAT_MESSAGES);
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (this.processed.has(msg)) continue;
// Skip notices (handled separately)
if (msg.querySelector('div[data-testid="room-notice"]')) {
this.markProcessed(msg);
continue;
}
try {
const tsAttr = msg.getAttribute('data-ts');
if (!tsAttr) { this.markProcessed(msg); continue; }
const tsNum = parseInt(tsAttr, 10);
if (isNaN(tsNum)) { this.markProcessed(msg); continue; }
const opts = this.settings.performanceMode
? { hour: '2-digit', minute: '2-digit' }
: { hour: '2-digit', minute: '2-digit', second: '2-digit' };
const timeString = new Date(tsNum).toLocaleTimeString([], opts);
this.addTimestampToMessage(msg, timeString);
this.markProcessed(msg);
} catch (e) {
logger.error('processChatMessages error', e);
this.markProcessed(msg);
}
}
}
processStandaloneNotices() {
const notices = document.querySelectorAll('div[data-testid="room-notice"]');
for (let i = 0; i < notices.length; i++) {
const notice = notices[i];
if (this.processed.has(notice)) continue;
// Find the innermost span/text container
let target = notice.querySelector('[data-testid="room-notice-viewport"] span') ||
notice.querySelector('span') ||
notice;
if (target.querySelector('.chat-timestamp')) {
this.markProcessed(notice);
continue;
}
const opts = this.settings.performanceMode
? { hour: '2-digit', minute: '2-digit' }
: { hour: '2-digit', minute: '2-digit', second: '2-digit' };
const ts = new Date().toLocaleTimeString([], opts);
const color = this.getTimestampColor(notice);
const sp = Utils.createElement('span', 'chat-timestamp', {
textContent: `[${ts}] `,
style: `color:${color}; font-size:11px; margin-right:4px; font-weight:normal; display:inline;`
});
// Insert timestamp at the start of the notice bubble text
target.insertBefore(sp, target.firstChild);
this.markProcessed(notice);
}
}
addTimestampToMessage(message, timeString) {
if (message.querySelector('.chat-timestamp')) return;
// Target the inner div that contains username and message text
const containerEl = message.querySelector('div[dm-adjust-bg]');
if (!containerEl) return;
// Use the colored bubble inside the message (if any) to decide contrast
const colorBase = this.getColorBaseFor(message, null);
const color = this.getTimestampColor(colorBase);
const sp = Utils.createElement('span', 'chat-timestamp', {
textContent: `[${timeString}] `,
style: `color:${color}; font-size:11px; margin-right:4px; font-weight:normal; display:inline;`
});
// Insert timestamp as the first child of the container div
containerEl.insertBefore(sp, containerEl.firstChild);
}
/* ---------- color helpers ---------- */
getColorBaseFor(message, notice) {
return (message && message.querySelector('div[data-testid="room-notice"]')) ||
notice ||
message ||
document.body;
}
getTimestampColor(el) {
try {
const cs = getComputedStyle(el);
const bg = cs.backgroundColor || '';
const fg = cs.color || '';
// Only override color for notice/tip bubbles
if (el.classList.contains('roomNotice') || el.closest('.roomNotice')) {
if (bg.includes('255, 255, 51') || bg.includes('255, 139, 69') || fg === 'rgb(0, 0, 0)') {
return 'rgba(0,0,0,0.75)'; // dark for yellow/orange
}
}
return 'rgba(255,255,255,0.85)'; // default light
} catch {
return 'rgba(255,255,255,0.85)';
}
}
/* ---------- observer & book-keeping ---------- */
setupMonitoring() {
const self = this;
setTimeout(function () {
const chatContainer = Utils.findElement(['#chat-messages', '.chat-messages']);
if (chatContainer) {
self.observer = new MutationObserver(
Utils.debounce(function () { self.addTimestamps(); }, CONFIG.TIMERS.MUTATION_DEBOUNCE)
);
self.observer.observe(chatContainer, { childList: true, subtree: true });
}
}, 500);
}
markProcessed(node) {
this.processed.add(node);
this.counter++;
if (this.counter > this.maxProcessed) {
this.processed = new WeakSet();
this.counter = 0;
}
}
stop() {
if (this.observer) this.observer.disconnect();
this.observer = null;
this.processed = new WeakSet();
}
}
/* Rest of the original code remains unchanged */
/* Rest of the original code remains unchanged */
/* =========================
TabManager (hide gender tabs)
========================= */
class TabManager {
static hideGenderTabs() {
try {
if (!StorageManager.getSettings().hideGenderTabs) return;
const selectors = [
'a.gender-tab[href*="/trans-cams/"]',
'a[href*="/male-cams"]',
'a[href*="/trans-cams"]'
];
const els = document.querySelectorAll(selectors.join(','));
for (let i=0;i<els.length;i++) els[i].style.display = 'none';
} catch (e) { logger.error('hideGenderTabs failed', e); }
}
}
/* =========================
StatsManager
========================= */
class StatsManager {
static updateHiddenModelsStat() {
try {
const merch = Utils.findElement(['li a#merch', 'a#merch']);
const merchLi = merch && merch.closest ? merch.closest('li') : null;
let statLi = document.querySelector('#hidden-models-stat-li');
if (!statLi) {
statLi = Utils.createElement('li', '', { id: 'hidden-models-stat-li' });
const parent = (merchLi && merchLi.parentNode) || Utils.findElement(['ul.top-nav', 'ul.main-nav', 'nav ul']);
if (parent) {
if (merchLi) merchLi.insertAdjacentElement('afterend', statLi);
else parent.appendChild(statLi);
}
}
let statA = document.querySelector('#hidden-models-stat');
if (!statA) {
statA = Utils.createElement('a', '', {
id: 'hidden-models-stat',
href: 'javascript:void(0);',
style: 'color:#fff;margin-right:12px;'
});
statA.addEventListener('click', function(e) {
e.preventDefault();
ModalManager.createModal('Hidden Models', StatsManager.createHiddenList(), [
{ text: 'Close', class: 'secondary', onClick: function(){ ModalManager.closeModal(); } }
]);
});
statLi.appendChild(statA);
const settingsBtn = Utils.createElement('a', '', {
id: 'enhancer-settings-btn',
href: 'javascript:void(0);',
style: 'color:#fff;font-weight:600;margin-left:10px;'
});
settingsBtn.textContent = '⚙ Settings';
settingsBtn.addEventListener('click', function(e){ e.preventDefault(); SettingsModal.show(); });
statLi.appendChild(settingsBtn);
}
const stats = StatsManager.calculateHiddenStats();
statA.textContent = 'Hidden Models: ' + stats.currentHidden + '/' + stats.totalHidden;
} catch (e) { logger.error('updateHiddenModelsStat failed', e); }
}
static calculateHiddenStats() {
const hidden = StorageManager.getHiddenModels();
const total = hidden.length;
const set = new Set(hidden);
const cards = document.querySelectorAll(CONFIG.SELECTORS.ROOM_CARDS);
let currentHidden = 0;
for (let i=0;i<cards.length;i++) {
const c = cards[i];
const u = ModelManager.extractUsername(c);
if (u && set.has(u) && (c.style.display === 'none' || c.getAttribute('data-hidden') === 'true')) currentHidden++;
}
return { totalHidden: total, currentHidden };
}
static createHiddenList() {
const container = Utils.createElement('div', '', { style: 'padding:10px;' });
const ul = Utils.createElement('ul', 'hidden-models-list');
const list = StorageManager.getHiddenModels();
for (let i=0;i<list.length;i++) {
const name = list[i];
const li = Utils.createElement('li', 'hidden-models-item');
li.appendChild(Utils.createElement('span', '', { textContent: name }));
const btn = Utils.createElement('button', 'chaturbate-hider-btn danger', { textContent: 'Unhide', type: 'button' });
btn.addEventListener('click', function() {
const filtered = StorageManager.getHiddenModels().filter(function(n){ return n !== name; });
StorageManager.saveHiddenModels(filtered);
NotificationManager.show(name + ' unhidden', 'success');
setTimeout(function(){ window.location.reload(); }, 500);
});
li.appendChild(btn);
ul.appendChild(li);
}
container.appendChild(ul);
return container;
}
static showTokenStats() {
try {
const tokens = StorageManager.getTokensSpent();
const entries = [];
for (const k in tokens) if (tokens[k] > 0) entries.push([k, tokens[k]]);
if (!entries.length) { NotificationManager.show('No token data available', 'info'); return; }
entries.sort(function(a,b){ return b[1]-a[1]; });
const total = entries.reduce(function(sum, e){ return sum + e[1]; }, 0);
const container = Utils.createElement('div', 'token-stats-container');
const table = Utils.createElement('table', 'token-stats-table');
const thead = Utils.createElement('thead');
thead.innerHTML = '<tr><th>Model</th><th>Tokens Spent</th></tr>';
table.appendChild(thead);
const tbody = Utils.createElement('tbody');
for (let i=0;i<entries.length;i++) {
const tr = Utils.createElement('tr');
tr.innerHTML = '<td>' + entries[i][0] + '</td><td>' + entries[i][1].toLocaleString() + '</td>';
tbody.appendChild(tr);
}
const totalTr = Utils.createElement('tr', 'total-row');
totalTr.innerHTML = '<td><strong>Total</strong></td><td><strong>' + total.toLocaleString() + '</strong></td>';
tbody.appendChild(totalTr);
table.appendChild(tbody);
container.appendChild(table);
ModalManager.createModal('Token Statistics', container, [
{ text: 'Close', class: 'secondary', onClick: function(){ ModalManager.closeModal(); } }
]);
} catch (e) { logger.error('showTokenStats failed', e); }
}
}
/* =========================
TokenMonitor (track tokens spent)
========================= */
class TokenMonitor {
constructor() {
this.observer = null;
this.lastTokenCount = null;
this.isActive = false;
this.tokenCountSpan = null;
this.currentModel = null;
}
async start() {
if (this.isActive) return;
this.isActive = true;
const tokenCountSpan = Utils.findElement(CONFIG.SELECTORS.TOKEN_BALANCE_SELECTORS);
const scanCamsSpan = document.querySelector(CONFIG.SELECTORS.SCAN_CAMS);
if (!tokenCountSpan || !scanCamsSpan) return;
const currentModel = Utils.getCurrentModelFromPath();
if (!currentModel) return;
this.tokenCountSpan = tokenCountSpan;
this.currentModel = currentModel;
this.setupTokenTracking(currentModel, tokenCountSpan);
this.createTokenInterface(currentModel, scanCamsSpan);
}
setupTokenTracking(currentModel, tokenCountSpan) {
const tokens = StorageManager.getTokensSpent();
if (!(currentModel in tokens)) { tokens[currentModel] = 0; StorageManager.saveTokensSpent(tokens); }
this.lastTokenCount = Utils.safeParseInt(tokenCountSpan.textContent);
if (this.observer) this.observer.disconnect();
const handleChange = (currentBalance) => {
if (currentBalance < this.lastTokenCount) {
const spent = this.lastTokenCount - currentBalance;
const data = StorageManager.getTokensSpent();
data[currentModel] = (data[currentModel] || 0) + spent;
StorageManager.saveTokensSpent(data);
this.updateTokenDisplay(currentModel, data[currentModel]);
}
this.lastTokenCount = currentBalance;
};
this.observer = new MutationObserver(() => {
if (!this.isActive) return;
const currentTokenCount = Utils.safeParseInt(tokenCountSpan.textContent);
handleChange(currentTokenCount);
});
this.observer.observe(tokenCountSpan, { childList: true, characterData: true, subtree: true });
}
createTokenInterface(currentModel, scanCamsSpan) {
let container = document.querySelector('#tokens-bar-container');
if (container) { this.updateTokenDisplay(currentModel); return; }
container = Utils.createElement('div', 'tokens-bar-container', { id: 'tokens-bar-container' });
const left = Utils.createElement('div', 'tokens-bar-left');
const spentDiv = Utils.createElement('span', 'tokens-spent-stat', { id: 'tokens-spent-stat' });
left.appendChild(spentDiv);
const right = Utils.createElement('div', 'tokens-bar-right');
const recu = this.makeLinkBtn('RecuMe', 'R', CONFIG.EXTERNAL_LINKS.RECU_ME + encodeURIComponent(currentModel));
const cw = this.makeLinkBtn('CamWhoresTV', 'CW', CONFIG.EXTERNAL_LINKS.CAMWHORES_TV + encodeURIComponent(currentModel) + '/');
right.appendChild(recu); right.appendChild(cw);
container.appendChild(left); container.appendChild(right);
const scanLink = scanCamsSpan.closest ? scanCamsSpan.closest('a') : null;
if (scanLink && scanLink.parentElement) {
scanLink.parentElement.insertBefore(container, scanLink);
container.style.display = 'inline-flex';
container.style.margin = '0 8px 0 0';
container.style.verticalAlign = 'middle';
} else {
document.body.prepend(container);
}
this.updateTokenDisplay(currentModel);
}
makeLinkBtn(title, text, url) {
const btn = Utils.createElement('button', 'token-action-btn', {
type: 'button',
title: title,
'aria-label': title,
textContent: text
});
btn.addEventListener('click', function() {
try { window.open(url, '_blank', 'noopener,noreferrer'); }
catch (e) { logger.error('open ' + title + ' fail', e); NotificationManager.show('Failed to open ' + title, 'error'); }
});
return btn;
}
updateTokenDisplay(currentModel, tokenCount = null) {
const el = document.querySelector('#tokens-spent-stat');
if (!el) return;
if (tokenCount === null) {
const tokens = StorageManager.getTokensSpent();
tokenCount = tokens[currentModel] || 0;
}
el.textContent = 'Tokens spent on ' + currentModel + ': ' + tokenCount.toLocaleString();
}
stop() {
this.isActive = false;
this.currentModel = null;
if (this.observer) { this.observer.disconnect(); this.observer = null; }
this.tokenCountSpan = null;
}
}
/* =========================
Main Controller
========================= */
class ChaturbateEnhancer {
constructor() {
this.tokenMonitor = null;
this.chatTimestamps = null;
this.lastPath = location.pathname;
this.mutationObserver = null;
this.pathCheckInterval = null;
this.isInitialized = false;
}
async init() {
if (this.isInitialized) return;
try {
StorageManager.migrateOldKeys();
this.injectStyles();
await this.runInitialSetup();
this.observeDynamicChanges();
this.setupPathMonitoring();
this.exposeGlobals();
this.isInitialized = true;
} catch (e) { logger.error('Enhancer init failed', e); }
}
async runInitialSetup() {
try {
if (this.tokenMonitor) this.tokenMonitor.stop();
this.tokenMonitor = new TokenMonitor();
await this.tokenMonitor.start();
if (this.chatTimestamps) this.chatTimestamps.stop();
this.chatTimestamps = new ChatTimestampManager();
this.chatTimestamps.start();
const isModelPage = !!Utils.getCurrentModelFromPath();
if (!isModelPage) {
ThumbnailManager.init();
// Restore grid settings after page changes
GridManager.restoreGridIfNeeded();
} else {
ThumbnailManager.stopAll();
}
ButtonManager.addHideButtons();
TabManager.hideGenderTabs();
StatsManager.updateHiddenModelsStat();
} catch (e) { logger.error('runInitialSetup error', e); }
}
observeDynamicChanges() {
const debounced = Utils.debounce(() => this.runInitialSetup(), CONFIG.TIMERS.MUTATION_DEBOUNCE);
const targets = [
document.querySelector('#room_list, .room_list'),
document.querySelector('#main, .main-content')
].filter(function(n){ return !!n; });
this.mutationObserver = new MutationObserver(function(mutations) {
for (let i=0;i<mutations.length;i++) {
const mut = mutations[i];
if ((mut.addedNodes && mut.addedNodes.length) || (mut.removedNodes && mut.removedNodes.length)) {
debounced();
break;
}
}
});
for (let i=0;i<targets.length;i++) this.mutationObserver.observe(targets[i], { childList: true, subtree: true });
}
setupPathMonitoring() {
const self = this;
this.pathCheckInterval = setInterval(function() {
if (location.pathname !== self.lastPath) self.handlePathChange();
}, CONFIG.TIMERS.PATH_CHECK_INTERVAL);
}
async handlePathChange() {
this.lastPath = location.pathname;
if (this.tokenMonitor) this.tokenMonitor.stop();
if (this.chatTimestamps) this.chatTimestamps.stop();
ThumbnailManager.stopAll();
ButtonManager.clearProcessedCards();
setTimeout(() => this.runInitialSetup(), 500);
}
exposeGlobals() {
window.showChaturbateStats = function(){ SettingsModal.show(); };
window.exportChaturbateData = function(){ DataManager.exportData(); };
window.importChaturbateData = function(){ SettingsModal.showImportDialog(); };
window.resetChaturbateData = function(){
if (confirm('Reset all hidden models and token data?')) {
StorageManager.resetAll();
NotificationManager.show('Data reset', 'success');
setTimeout(function(){ location.reload(); }, 600);
}
};
}
injectStyles() {
const style = document.createElement('style');
style.textContent = `
.chaturbate-hider-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.chaturbate-hider-overlay.visible {
opacity: 1;
}
.chaturbate-hider-modal {
background: #1f2937;
border-radius: 8px;
padding: 16px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
color: #fff;
}
.chaturbate-hider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chaturbate-hider-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.chaturbate-hider-close {
background: none;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
}
.chaturbate-hider-close:hover {
color: #3b82f6;
}
.chaturbate-hider-body {
margin-bottom: 12px;
}
.chaturbate-hider-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.chaturbate-hider-btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
background: #4b5563;
color: #fff;
}
.chaturbate-hider-btn.primary {
background: #3b82f6;
}
.chaturbate-hider-btn.primary:hover {
background: #2563eb;
}
.chaturbate-hider-btn.secondary {
background: #6b7280;
}
.chaturbate-hider-btn.secondary:hover {
background: #4b5563;
}
.chaturbate-hider-btn.danger {
background: #dc2626;
}
.chaturbate-hider-btn.danger:hover {
background: #b91c1c;
}
.settings-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.settings-actions button {
padding: 6px 12px;
border: none;
border-radius: 6px;
background: #3b82f6;
color: #fff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.settings-actions button:hover {
background: #2563eb;
}
.enhancer-settings-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.enhancer-toggle {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.05);
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.enhancer-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.enhancer-toggle span {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.enhancer-toggle input[type="checkbox"] {
appearance: none;
width: 18px;
height: 18px;
border: 2px solid #3b82f6;
border-radius: 4px;
background: #1f2937;
cursor: pointer;
display: grid;
place-items: center;
transition: all 0.2s;
}
.enhancer-toggle input[type="checkbox"]:checked {
background: #3b82f6;
border-color: #2563eb;
}
.enhancer-toggle input[type="checkbox"]:checked::after {
content: "✔";
font-size: 12px;
color: #fff;
}
.tokens-bar-container {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0, 0, 0, 0.4);
padding: 8px 12px;
border-radius: 8px;
margin: 8px 0;
gap: 12px;
}
.tokens-bar-left {
font-size: 13px;
font-weight: 600;
color: #fff;
}
.tokens-bar-right {
display: flex;
gap: 8px;
}
.token-action-btn {
padding: 6px 10px;
border: none;
border-radius: 6px;
font-weight: 600;
background: #3b82f6;
color: #fff;
cursor: pointer;
transition: background 0.2s;
}
.token-action-btn:hover, .token-action-btn:focus {
background: #2563eb;
outline: none;
}
.hide-model-button {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border: none;
border-radius: 50%;
width: 22px;
height: 22px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
line-height: 1;
transition: background 0.2s;
}
.hide-model-button:hover, .hide-model-button:focus {
background: rgba(220, 38, 38, 0.85);
}
.toast-notification {
transition: opacity 0.25s;
}
.hidden-models-list {
list-style: none;
padding: 0;
margin: 0;
}
.hidden-models-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.token-stats-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.token-stats-table th, .token-stats-table td {
padding: 8px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.token-stats-table th {
font-weight: 600;
}
.total-row {
font-weight: 600;
background: rgba(255, 255, 255, 0.05);
}
#grid-size-controls {
display: inline-flex;
align-items: center;
vertical-align: middle;
margin-right: 8px;
background: transparent;
}
.grid-buttons-wrap {
display: flex;
gap: 4px;
background: transparent;
}
.grid-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
background: #1a252f;
cursor: pointer;
padding: 0;
transition: background 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.grid-btn:hover {
background: #2a3b4f;
}
.grid-btn svg {
pointer-events: none;
fill: #3b82f6;
}
.grid-btn:nth-child(2) svg { fill: #f97316; }
.grid-btn:nth-child(3) svg { fill: #3b82f6; }
.grid-btn:nth-child(4) svg { fill: #f97316; }
.grid-btn:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
.grid-btn.active {
background: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
`;
document.head.appendChild(style);
}
}
/* =========================
Bootstrap
========================= */
let enhancer = null;
function initEnhancer() {
if (enhancer) return;
enhancer = new ChaturbateEnhancer();
enhancer.init();
GridManager.init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initEnhancer);
} else {
setTimeout(initEnhancer, 80);
}
})();