// ==UserScript==
// @name Chaturbate Enhancer (compat build)
// @namespace http://tampermonkey.net/
// @version 5.2.0
// @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); }
getErrorReport() {
return { errors: this.errors.slice(), userAgent: navigator.userAgent, url: location.href, ts: Date.now() };
}
}
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; } },
setCookie(name, value, options = {}) {
try {
let cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value);
if (options.expires) cookie += '; expires=' + new Date(options.expires).toUTCString();
cookie += '; path=' + (options.path || '/');
if (options.domain) cookie += '; domain=' + options.domain;
document.cookie = cookie;
return true;
} catch (e) { logger.error('setCookie failed', e); return false; }
},
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);
}
};
/* =========================
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'; });
}
}
// expose some helpers for debugging
window.__ChaturbateEnhancer = window.__ChaturbateEnhancer || {};
Object.assign(window.__ChaturbateEnhancer, { Logger, Utils, StorageManager, DataManager });
/* =========================
PerformanceMonitor
========================= */
class PerformanceMonitor {
static start(label) { PerformanceMonitor.timers = PerformanceMonitor.timers || new Map(); PerformanceMonitor.timers.set(label, performance.now()); }
static end(label) {
PerformanceMonitor.timers = PerformanceMonitor.timers || new Map();
const start = PerformanceMonitor.timers.get(label);
if (!start) return 0;
const dur = performance.now() - start;
PerformanceMonitor.timers.delete(label);
if (dur > 12) logger.debug(label + ' took ' + dur.toFixed(2) + 'ms');
return dur;
}
}
/* =========================
ThumbnailManager (animated previews) — no private fields
========================= */
/* =========================
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 = []) {
if (ModalManager._active) 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 };
requestAnimationFrame(function(){ overlay.classList.add('visible'); });
setTimeout(function(){ try { modal.focus(); } catch {} }, 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
========================= */
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;
// Prefer username label (works for normal rows and tip bubbles);
// fall back to legacy selector or the message itself
const usernameEl =
message.querySelector('[data-testid="username-label"]') ||
message.querySelector(CONFIG.SELECTORS.CHAT_USERNAME) ||
message;
// 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;`
});
usernameEl.insertBefore(sp, usernameEl.firstChild);
}
/* ---------- color helpers ---------- */
// Choose the element whose styles define contrast: prefer the colored notice bubble
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();
}
}
/* =========================
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();
} else {
ThumbnailManager.stopAll(); // ✅ disable previews inside model pages
}
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 = `
/* Modal + buttons + lists */
.chaturbate-hider-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display:flex; align-items:flex-start; justify-content:center; padding:20px; overflow:auto; z-index:10000; opacity:0; transition:opacity .25s; }
.chaturbate-hider-overlay.visible { opacity:1; }
.chaturbate-hider-modal { background:#1f2937; color:#e5e7eb; border-radius:12px; width:min(95vw,760px); max-height:calc(100vh - 40px); overflow:hidden; display:flex; flex-direction:column; transform:translateY(8px); transition:transform .2s,opacity .2s; }
.chaturbate-hider-header { display:flex; justify-content:space-between; align-items:center; padding:8px 12px; }
.chaturbate-hider-body { flex:1 1 auto; overflow-y:auto; padding:14px 18px; font-family: system-ui, sans-serif; font-size:14px; line-height:1.5; }
.chaturbate-hider-footer { padding:10px 16px; border-top:1px solid rgba(255,255,255,0.08); position:sticky; bottom:0; background:rgba(0,0,0,0.25); }
#enhancer-settings-btn { transition: color .2s; } #enhancer-settings-btn:hover { color: #3b82f6; }
.hidden-models-list { list-style:none; padding:0; margin:0; }
.hidden-models-item { display:flex; justify-content:space-between; align-items:center; padding:6px 10px; border-bottom:1px solid rgba(255,255,255,0.1); }
.hidden-models-item:nth-child(even) { background: rgba(255,255,255,0.03); }
.hidden-models-item:hover { background: rgba(59,130,246,0.15); }
.chaturbate-hider-btn { padding:6px 12px; border:none; border-radius:6px; font-weight:600; cursor:pointer; transition: background .2s; background:#4b5563; color:#fff; }
.chaturbate-hider-btn.primary { background:#3b82f6; color:#fff; }
.chaturbate-hider-btn.primary:hover { background:#2563eb; }
.chaturbate-hider-btn.secondary { background:#6b7280; color:#fff; }
.chaturbate-hider-btn.secondary:hover { background:#4b5563; }
.chaturbate-hider-btn.danger { background:#dc2626; color:#fff; }
.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 .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 .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 .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 .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 .2s; }
.hide-model-button:hover, .hide-model-button:focus { background:rgba(220,38,38,0.85); }
.toast-notification { transition:opacity .25s; }
`;
document.head.appendChild(style);
}
}
/* =========================
Bootstrap
========================= */
let enhancer = null;
function initEnhancer() {
if (enhancer) return;
enhancer = new ChaturbateEnhancer();
enhancer.init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initEnhancer);
} else {
setTimeout(initEnhancer, 80);
}
})();