Chaturbate Enhancer (compat build)

Lag fixes, safer observers, ES2018 syntax, seamless loading, no notification spam

// ==UserScript==
// @name         Chaturbate Enhancer (compat build)
// @namespace    http://tampermonkey.net/
// @version      5.3.5
// @description  Lag fixes, safer observers, ES2018 syntax, seamless loading, no notification spam
// @match        https://chaturbate.com/*
// @match        https://*.chaturbate.com/*
// @run-at       document-start
// @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/'
    })
  });

  /* ======================
     Inject Critical Styles Early
     ====================== */
  function injectCriticalStyles() {
    try {
      const style = document.createElement('style');
      style.textContent = `
        /* Hide gender tabs if enabled */
        a.gender-tab[href*="/trans-cams/"], a[href*="/male-cams"], a[href*="/trans-cams"], li a#merch, li a#merch + li {
          display: none !important;
        }

        /* Ensure grid layout is applied early */
        ul.list.endless_page_template.show-location {
          display: grid !important;
          gap: 12px !important;
        }
        ul.list.endless_page_template.show-location li.roomCard.camBgColor {
          width: 100% !important;
          max-width: 100% !important;
          position: relative !important;
        }
        ul.list.endless_page_template.show-location li.roomCard.camBgColor img {
          width: 100% !important;
          height: auto !important;
          object-fit: cover !important;
        }

        /* Hide model buttons (to be shown after script applies them) */
        .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;
          display: none;
        }
        .hide-model-button:hover, .hide-model-button:focus {
          background: rgba(220, 38, 38, 0.85);
        }

        /* Temporary loading overlay */
        .chaturbate-loading-overlay {
          position: fixed;
          inset: 0;
          background: #000;
          z-index: 99999;
          opacity: 1;
          transition: opacity 0.3s ease-out;
        }
        .chaturbate-loading-overlay.hidden {
          opacity: 0;
          pointer-events: none;
        }
      `;
      (document.head || document.documentElement).appendChild(style);
    } catch (e) {
      console.error('[ChaturbateEnhancer] Failed to inject critical styles:', e);
    }
  }

  // Inject critical styles immediately
  injectCriticalStyles();

  /* ======================
     Loading Overlay
     ====================== */
  function addLoadingOverlay() {
    try {
      const overlay = document.createElement('div');
      overlay.className = 'chaturbate-loading-overlay';
      (document.body || document.documentElement).appendChild(overlay);
      return overlay;
    } catch (e) {
      console.error('[ChaturbateEnhancer] Failed to add loading overlay:', e);
      return null;
    }
  }

  function removeLoadingOverlay(overlay) {
    try {
      if (overlay && overlay.parentNode) {
        overlay.classList.add('hidden');
        setTimeout(() => {
          if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
        }, 300);
      }
    } catch (e) {
      console.error('[ChaturbateEnhancer] Failed to remove loading overlay:', e);
    }
  }

  /* ======================
     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);
        if (parts.length !== 1) return null;
        const segment = parts[0];
        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
  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"/>
    </svg>
  `;


/* =========================
   GridManager
   ========================= */
class GridManager {
  static init() {
    try {
      if (Utils.getCurrentModelFromPath()) return; // Skip model pages
      if (document.querySelector("#grid-size-controls")) return; // Prevent re-initialization
      const saved = Utils.safeParseInt(localStorage.getItem("chaturbateEnhancer:gridCols"));
      const defaultCols = saved > 0 ? saved : 4; // Default to 4 columns if no saved value
      // Apply grid size after DOM is ready
      GridManager.applyGridWhenReady(defaultCols);
      // Set up grid control buttons
      const container = Utils.findElement(["ul.advanced-search-button-container", "ul.top-nav", "nav ul"]);
      if (!container) return;
      const li = Utils.createElement("li", "", { id: "grid-size-controls" });
      const wrap = Utils.createElement("div", "grid-buttons-wrap");
      li.appendChild(wrap);
      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;"
        });
        if (defaultCols === opt.cols) {
          btn.classList.add("active");
        }
        btn.addEventListener("click", () => {
          wrap.querySelectorAll(".grid-btn").forEach(b => b.classList.remove("active"));
          btn.classList.add("active");
          GridManager.setColumns(opt.cols, false); // Notify on user action
        });
        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 applyGridWhenReady(cols) {
    const applyGrid = () => {
      const grid = document.querySelector("ul.list.endless_page_template.show-location");
      if (grid) {
        GridManager.setColumns(cols, true); // Silent mode for initial load
        if (GridManager.observer) {
          GridManager.observer.disconnect();
          GridManager.observer = null;
        }
      }
    };
    // Try applying immediately
    applyGrid();
    // If grid not found, set up observer to wait for it
    if (!document.querySelector("ul.list.endless_page_template.show-location")) {
      GridManager.observer = new MutationObserver((mutations, observer) => {
        if (document.querySelector("ul.list.endless_page_template.show-location")) {
          applyGrid();
        }
      });
      GridManager.observer.observe(document.body, { childList: true, subtree: true });
      // Fallback: stop observer after 5 seconds
      setTimeout(() => {
        if (GridManager.observer) {
          GridManager.observer.disconnect();
          GridManager.observer = null;
        }
      }, 5000);
    }
  }

  static setColumns(cols, silent = false) {
    try {
      const grid = document.querySelector("ul.list.endless_page_template.show-location");
      if (grid) {
        grid.style.display = "grid";
        grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
        grid.style.gap = "12px";
        const cards = grid.querySelectorAll("li.roomCard.camBgColor");
        cards.forEach(card => {
          card.style.width = "100%";
          card.style.maxWidth = "100%";
        });
        const thumbs = grid.querySelectorAll("li.roomCard.camBgColor img");
        thumbs.forEach(img => {
          img.style.width = "100%";
          img.style.height = "auto";
          img.style.objectFit = "cover";
        });
      }
      localStorage.setItem("chaturbateEnhancer:gridCols", cols);
      if (!silent) {
        NotificationManager.show(`Grid set to ${cols} columns`, "success");
      }
    } catch (e) {
      logger.error("GridManager.setColumns failed", e);
    }
  }

  static restoreGridIfNeeded() {
    try {
      const saved = Utils.safeParseInt(localStorage.getItem("chaturbateEnhancer:gridCols"));
      const cols = saved > 0 ? saved : 4; // Default to 4 columns
      GridManager.applyGridWhenReady(cols);
    } catch (e) {
      logger.error("GridManager.restoreGridIfNeeded failed", e);
    }
  }
}
GridManager.observer = null;

  /* =========================
     StorageManager (localStorage)
     ========================= */
  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)
     ========================= */
  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();
  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
     ========================= */
  class ModalManager {
    static createModal(title, bodyElement, buttons = []) {
      console.log('Creating modal:', title);
      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 = '&times;';
      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');
      requestAnimationFrame(function() {
        overlay.classList.add('visible');
        console.log('Visible class added to overlay');
      });
      setTimeout(function() {
        try {
          modal.focus();
          console.log('Modal focused');
        } 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);
          btn.style.display = 'block';
          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
   ========================= */
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();
  }
  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;
      if (msg.querySelector('div[data-testid="room-notice"]')) {
        this.markProcessed(msg);
        continue;
      }
      try {
        let tsNum = parseInt(msg.getAttribute('data-ts'), 10);
        if (isNaN(tsNum)) {
          tsNum = Date.now();
        }
        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);
      }
    }
  }
  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;
      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;`
      });
      // Check for white background and adjust notice text color
      const cs = getComputedStyle(notice);
      const bg = cs.backgroundColor || '';
      if (bg.includes('255, 255, 255')) {
        // Apply dark color to all child spans for better readability
        const spans = notice.querySelectorAll('span:not(.chat-timestamp)');
        spans.forEach(span => {
          span.style.color = 'rgba(0, 0, 0, 0.85)';
        });
        // Ensure the target itself has dark color if it’s the fallback
        target.style.color = 'rgba(0, 0, 0, 0.85)';
      }
      target.insertBefore(sp, target.firstChild);
      this.markProcessed(notice);
    }
  }
  addTimestampToMessage(message, timeString) {
    if (message.querySelector('.chat-timestamp')) return;
    const containerEl = 
      message.querySelector('div[dm-adjust-bg]') || 
      message.querySelector('div[dm-adjust-fg]') || 
      message;
    if (!containerEl) return;
    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;`
    });
    containerEl.insertBefore(sp, containerEl.firstChild);
  }
  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 || '';
      if (el.classList.contains('roomNotice') || el.closest('.roomNotice')) {
        if (bg.includes('255, 255, 255')) {
          return 'rgba(0, 0, 0, 0.85)'; // Dark color for white background
        }
        if (bg.includes('255, 255, 51') || bg.includes('255, 139, 69') || fg === 'rgb(0, 0, 0)') {
          return 'rgba(0, 0, 0, 0.75)';
        }
      }
      return 'rgba(255, 255, 255, 0.85)';
    } catch {
      return 'rgba(255, 255, 255, 0.85)';
    }
  }
  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); }
      const merch = document.querySelector('li a#merch');
      if (merch && merch.closest('li')) {
        merch.closest('li').style.display = 'none';
      }
    }
  }

  /* =========================
     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((a, b) => b[1] - a[1]);
        const total = entries.reduce((sum, e) => 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><th>Edit</th></tr>';
        table.appendChild(thead);
        const tbody = Utils.createElement('tbody');
        for (let i = 0; i < entries.length; i++) {
          const [model, spent] = entries[i];
          const tr = Utils.createElement('tr');
          const tdModel = Utils.createElement('td', '', { textContent: model });
          const tdSpent = Utils.createElement('td', '', { textContent: spent.toLocaleString() });
          const tdEdit = Utils.createElement('td');
          const editBtn = Utils.createElement('button', 'token-edit-btn', { textContent: '✎ Edit' });
          editBtn.style.background = 'linear-gradient(135deg, #fbbf24, #f59e0b)';
          editBtn.style.color = '#000';
          editBtn.style.fontWeight = '700';
          editBtn.style.padding = '4px 10px';
          editBtn.style.borderRadius = '999px';
          editBtn.style.border = 'none';
          editBtn.style.cursor = 'pointer';
          editBtn.style.transition = 'all 0.25s ease';
          editBtn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
          editBtn.addEventListener('mouseenter', () => {
            editBtn.style.transform = 'translateY(-2px) scale(1.05)';
            editBtn.style.boxShadow = '0 4px 10px rgba(0,0,0,0.3)';
          });
          editBtn.addEventListener('mouseleave', () => {
            editBtn.style.transform = 'translateY(0) scale(1)';
            editBtn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
          });
          editBtn.addEventListener('click', () => {
            const val = prompt('Set tokens spent for ' + model, spent);
            if (val !== null && !isNaN(val)) {
              const newVal = Utils.safeParseInt(val);
              const data = StorageManager.getTokensSpent();
              data[model] = newVal;
              StorageManager.saveTokensSpent(data);
              tdSpent.textContent = newVal.toLocaleString();
              NotificationManager.show('Updated tokens for ' + model, 'success');
            }
          });
          tdEdit.appendChild(editBtn);
          tr.appendChild(tdModel);
          tr.appendChild(tdSpent);
          tr.appendChild(tdEdit);
          tbody.appendChild(tr);
        }
        const totalTr = Utils.createElement('tr', 'total-row');
        totalTr.innerHTML = '<td><strong>Total</strong></td><td colspan="2"><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);
          localStorage.setItem(
            "chaturbateEnhancer:lastTokenUpdate",
            JSON.stringify({ model: currentModel, total: data[currentModel], ts: Date.now() })
          );
          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 });
      window.addEventListener("storage", (e) => {
        if (e.key === "chaturbateEnhancer:lastTokenUpdate") {
          try {
            const payload = JSON.parse(e.newValue || "{}");
            if (payload.model === currentModel) {
              this.updateTokenDisplay(currentModel, payload.total);
            }
          } catch {}
        }
      });
    }
    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);
      container.appendChild(left);
      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(right);
      const toolbar = document.querySelector('.genderTabs');
      if (toolbar && toolbar.parentElement) {
        toolbar.parentElement.insertBefore(container, toolbar);
      } else {
        document.body.prepend(container);
      }
      container.style.display = 'flex';
      container.style.justifyContent = 'space-between';
      container.style.alignItems = 'center';
      container.style.margin = '6px 0 6px 0';
      container.style.padding = '6px 10px';
      container.style.background = 'rgba(0,0,0,0.5)';
      container.style.borderRadius = '6px';
      this.updateTokenDisplay(currentModel);
    }
    makeLinkBtn(title, text, url) {
      const btn = Utils.createElement('button', 'token-action-btn', {
        type: 'button',
        title: title,
        'aria-label': title,
        textContent: text
      });
      if (text === 'R') {
        btn.style.background = 'linear-gradient(135deg, #f97316, #fbbf24)';
        btn.style.color = '#000';
      } else if (text === 'CW') {
        btn.style.background = 'linear-gradient(135deg, #dc2626, #2563eb)';
        btn.style.color = '#fff';
      }
      btn.style.fontWeight = '700';
      btn.style.padding = '6px 14px';
      btn.style.borderRadius = '999px';
      btn.style.border = 'none';
      btn.style.cursor = 'pointer';
      btn.style.transition = 'all 0.25s ease';
      btn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
      btn.addEventListener('mouseenter', () => {
        btn.style.transform = 'translateY(-2px) scale(1.05)';
        btn.style.boxShadow = '0 4px 10px rgba(0,0,0,0.3)';
      });
      btn.addEventListener('mouseleave', () => {
        btn.style.transform = 'translateY(0) scale(1)';
        btn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
      });
      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;
      this.loadingOverlay = null;
    }
    async init() {
      if (this.isInitialized) return;
      try {
        this.loadingOverlay = addLoadingOverlay();
        StorageManager.migrateOldKeys();
        this.injectStyles();
        await this.runInitialSetup();
        this.observeDynamicChanges();
        this.setupPathMonitoring();
        this.exposeGlobals();
        this.isInitialized = true;
        setTimeout(() => removeLoadingOverlay(this.loadingOverlay), 100);
      } catch (e) {
        logger.error('Enhancer init failed', e);
        removeLoadingOverlay(this.loadingOverlay);
      }
    }
    async runInitialSetup() {
      try {
        const isModelPage = !!Utils.getCurrentModelFromPath();
        if (!isModelPage) {
          GridManager.init(); // Handles grid restoration internally
          ThumbnailManager.init();
        } else {
          ThumbnailManager.stopAll();
        }
        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();
        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: #dc262
        }
        .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);
    }
      /* Chat timestamp style */
.chat-timestamp {
  font-size: 11px;
  font-weight: normal;
  opacity: 0.85;
  margin-right: 6px;
  display: inline-block;
  vertical-align: middle;
  color: rgba(255, 255, 255, 0.85);
}

/* Keep timestamp aligned with username */
[data-testid="chat-message-username"] {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
  `;
  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);
  }
})();