Sleazy Fork is available in English.

Chaturbate Enhancer

Grid layout, model hiding, token tracking, chat timestamps, thumbnail previews, search filtering, keyboard shortcuts

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Chaturbate Enhancer
// @namespace    http://tampermonkey.net/
// @version      6.0.0
// @description  Grid layout, model hiding, token tracking, chat timestamps, thumbnail previews, search filtering, keyboard shortcuts
// @match        https://chaturbate.com/*
// @match        https://*.chaturbate.com/*
// @run-at       document-start
// @author       shadow
// @grant        none
// ==/UserScript==

console.log('✅ Chaturbate Enhancer v6.0.0 loaded');

(function () {
  'use strict';

  /* ─────────────────────────────────────────
     CONFIG
  ───────────────────────────────────────── */
  const CONFIG = Object.freeze({
    VERSION: '6.0.0',
    STORAGE: Object.freeze({
      PREFIX: 'chaturbateEnhancer:',
      HIDDEN_KEY: 'hiddenModels',
      TOKENS_KEY: 'tokensSpent',
      SETTINGS_KEY: 'settings',
      BACKUP_KEY: 'backup',
      FAVORITES_KEY: 'favorites',
      NOTES_KEY: 'notes',
      GRID_KEY: 'gridCols',
      TAGS_KEY: 'tags',
      BACKUP_TS_KEY: 'lastBackupTs'
    }),
    TIMERS: Object.freeze({
      PATH_CHECK_INTERVAL: 900,
      MUTATION_DEBOUNCE: 150,
      THUMB_HOVER_INTERVAL_NORMAL: 200,
      THUMB_HOVER_INTERVAL_PERF: 500,
      THUMB_REQUEST_GAP_NORMAL: 180,
      THUMB_REQUEST_GAP_PERF: 400,
      THUMB_FETCH_TIMEOUT_NORMAL: 3000,
      THUMB_FETCH_TIMEOUT_PERF: 2000,
      AUTO_BACKUP_INTERVAL_MS: 86400000  // 24 h
    }),
    SELECTORS: Object.freeze({
      ROOM_CARDS: 'li.roomCard.camBgColor',
      TOKEN_BALANCE: ['span.balance', 'span.token_balance', 'span.tokencount'],
      SCAN_CAMS: '[data-testid="scan-cams"]',
      CHAT_MESSAGES: '[data-testid="chat-message"]',
      GRID_LIST: 'ul.list.endless_page_template.show-location',
      NAV_CONTAINER: 'ul.advanced-search-button-container, ul.top-nav, nav ul, ul#nav'
    }),
    EXTERNAL: Object.freeze({
      RECU_ME: 'https://recu.me/performer/',
      CAMWHORES_TV: 'https://www.camwhores.tv/search/',
      CAMGIRLFINDER: 'https://camgirlfinder.net/models/cb/'
    }),
    DEFAULT_SETTINGS: Object.freeze({
      hideGenderTabs: true,
      showTimestamps: true,
      autoBackup: false,
      performanceMode: false,
      animateThumbnails: true,
      showFavoritesBar: true,
      showSearchFilter: true,
      highlightFavorites: true,
      compactNotifications: false,
      showViewerCount: true
    })
  });

  /* ─────────────────────────────────────────
     EARLY STYLE INJECTION
  ───────────────────────────────────────── */
  (function injectCriticalStyles() {
    try {
      const style = document.createElement('style');
      style.id = 'ce-critical-styles';
      style.textContent = `
        a.gender-tab[href*="/trans-cams/"],a[href*="/male-cams"],a[href*="/trans-cams"],li a#merch,li a#merch+li{display:none!important}
        ul.list.endless_page_template.show-location{display:grid!important;gap:12px!important}
        ul.list.endless_page_template.show-location li.roomCard.camBgColor{width:100%!important;max-width:100%!important;position:relative!important}
        ul.list.endless_page_template.show-location li.roomCard.camBgColor img{width:100%!important;height:auto!important;object-fit:cover!important}
        .ce-hide-btn{position:absolute;top:6px;left:6px;background:rgba(0,0,0,.65);color:#fff;border:none;border-radius:50%;width:22px;height:22px;cursor:pointer;font-size:12px;font-weight:700;line-height:22px;text-align:center;transition:background .15s;z-index:5;padding:0}
        .ce-hide-btn:hover{background:rgba(220,38,38,.9)}
        .ce-fav-btn{position:absolute;top:6px;right:6px;background:rgba(0,0,0,.65);border:none;border-radius:50%;width:22px;height:22px;cursor:pointer;font-size:12px;line-height:22px;text-align:center;transition:background .15s,transform .15s;z-index:5;padding:0}
        .ce-fav-btn:hover{transform:scale(1.2)}
        .ce-fav-btn.active{background:rgba(234,179,8,.8)}
        .ce-overlay{position:fixed;inset:0;background:#000;z-index:99999;opacity:1;transition:opacity .35s ease-out;pointer-events:all}
        .ce-overlay.hidden{opacity:0;pointer-events:none}
        .ce-card-favorite{outline:2px solid rgba(234,179,8,.7)!important;outline-offset:-2px}
        .ce-card-hidden{display:none!important}
        .ce-viewer-badge{position:absolute;bottom:6px;right:6px;background:rgba(0,0,0,.7);color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:99px;z-index:5;pointer-events:none}
      `;
      (document.head || document.documentElement).appendChild(style);
    } catch (e) { console.error('[CE] critical styles failed', e); }
  })();

  /* ─────────────────────────────────────────
     LOADING OVERLAY
  ───────────────────────────────────────── */
  let _overlay = null;
  function showOverlay() {
    if (_overlay) return;
    try {
      _overlay = document.createElement('div');
      _overlay.className = 'ce-overlay';
      (document.body || document.documentElement).appendChild(_overlay);
    } catch (e) { /* silent */ }
  }
  function hideOverlay() {
    try {
      if (!_overlay) return;
      _overlay.classList.add('hidden');
      const el = _overlay;
      _overlay = null;
      setTimeout(() => { try { el.remove(); } catch {} }, 400);
    } catch (e) { /* silent */ }
  }

  /* ─────────────────────────────────────────
     LOGGER  (singleton)
  ───────────────────────────────────────── */
  const logger = (() => {
    const errors = [];
    const MAX = 300;
    const ts = () => new Date().toISOString();
    const fmt = (msg) => `[CE ${ts()}] ${msg}`;
    return {
      error(m, o = '') { console.error(fmt(m), o); if (errors.length < MAX) errors.push({ ts: Date.now(), m, stack: o && o.stack }); },
      warn(m, o = '')  { console.warn(fmt(m), o); },
      info(m, o = '')  { console.info(fmt(m), o); },
      debug(m, o = '') { console.debug(fmt(m), o); },
      getErrors()      { return errors.slice(); }
    };
  })();

  /* ─────────────────────────────────────────
     UTILS
  ───────────────────────────────────────── */
  const Utils = {
    /** Returns the room slug when on a /username page, else null. */
    getCurrentModel() {
      try {
        const parts = location.pathname.split('/').filter(Boolean);
        if (parts.length !== 1) return null;
        const s = parts[0];
        if (['female','male','couple','trans'].includes(s) || s.includes('-')) return null;
        return s;
      } catch { return null; }
    },

    safeInt(str, fallback = 0) {
      const n = parseInt(String(str || '').replace(/[^\d\-]/g, ''), 10);
      return isNaN(n) ? fallback : n;
    },

    isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); },

    el(tag, cls = '', attrs = {}) {
      const e = document.createElement(tag);
      if (cls) e.className = cls;
      for (const k in attrs) {
        const v = attrs[k];
        if (k === 'textContent' || k === 'innerHTML') { e[k] = v; continue; }
        if (k.startsWith('data-') || /^(id|href|title|aria-label|role|type|value|style|tabindex)$/.test(k)) {
          e.setAttribute(k, v);
        } else {
          e[k] = v;
        }
      }
      return e;
    },

    qs(sel, ctx = document) { try { return ctx.querySelector(sel); } catch { return null; } },
    qsAll(sel, ctx = document) { try { return Array.from(ctx.querySelectorAll(sel)); } catch { return []; } },

    findEl(sels, ctx = document) {
      for (const s of sels) { const e = Utils.qs(s, ctx); if (e) return e; }
      return null;
    },

    debounce(fn, wait = 100) {
      let t;
      return function(...args) {
        clearTimeout(t);
        t = setTimeout(() => { try { fn.apply(this, args); } catch (e) { logger.error('debounce cb', e); } }, wait);
      };
    },

    throttle(fn, limit = 200) {
      let last = 0;
      return function(...args) {
        const now = Date.now();
        if (now - last >= limit) { last = now; try { fn.apply(this, args); } catch (e) { logger.error('throttle cb', e); } }
      };
    },

    parseJSON(raw, fallback = null) { try { return JSON.parse(raw); } catch { return fallback; } },

    download(content, filename = 'export.json') {
      const blob = new Blob([content], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = Utils.el('a', '', { href: url });
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    },

    blobToDataURL(blob) {
      return new Promise((res, rej) => {
        const r = new FileReader();
        r.onload = () => res(r.result);
        r.onerror = () => rej(new Error('FileReader failed'));
        r.readAsDataURL(blob);
      });
    },

    /**
     * Run fn when selector first appears in DOM, with optional timeout.
     * Returns a cleanup function.
     */
    waitFor(selector, fn, timeoutMs = 8000) {
      const existing = Utils.qs(selector);
      if (existing) { try { fn(existing); } catch (e) { logger.error('waitFor cb', e); } return () => {}; }
      let obs, timer;
      obs = new MutationObserver(() => {
        const el = Utils.qs(selector);
        if (el) {
          clearTimeout(timer);
          obs.disconnect();
          try { fn(el); } catch (e) { logger.error('waitFor cb (late)', e); }
        }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
      timer = setTimeout(() => obs.disconnect(), timeoutMs);
      return () => { obs.disconnect(); clearTimeout(timer); };
    }
  };

  /* ─────────────────────────────────────────
     STORAGE MANAGER
  ───────────────────────────────────────── */
  const Store = (() => {
    const P = CONFIG.STORAGE.PREFIX;
    const key = k => P + k;

    function get(k, fallback = null) {
      try {
        const raw = localStorage.getItem(key(k));
        if (raw === null) return fallback;
        return JSON.parse(raw) ?? fallback;
      } catch { return fallback; }
    }

    function set(k, v) {
      try { localStorage.setItem(key(k), JSON.stringify(v)); return true; }
      catch (e) { logger.error('Store.set ' + k, e); return false; }
    }

    function del(k) { try { localStorage.removeItem(key(k)); } catch {} }

    // Migrate legacy keys (one-time)
    function migrate() {
      const map = {
        hiddenModels: CONFIG.STORAGE.HIDDEN_KEY,
        tokensSpent: CONFIG.STORAGE.TOKENS_KEY,
        chaturbateHiderSettings: CONFIG.STORAGE.SETTINGS_KEY,
        chaturbate_backup: CONFIG.STORAGE.BACKUP_KEY
      };
      for (const [old, newK] of Object.entries(map)) {
        const v = localStorage.getItem(old);
        if (v && !localStorage.getItem(key(newK))) {
          localStorage.setItem(key(newK), v);
          logger.info(`Migrated ${old} → ${key(newK)}`);
        }
        if (v) localStorage.removeItem(old);
      }
      // Also migrate old gridCols key
      const oldGrid = localStorage.getItem('chaturbateEnhancer:gridCols');
      if (oldGrid && !get(CONFIG.STORAGE.GRID_KEY)) {
        set(CONFIG.STORAGE.GRID_KEY, JSON.parse(oldGrid));
      }
    }

    return {
      migrate,
      getHidden()     { return get(CONFIG.STORAGE.HIDDEN_KEY, []); },
      setHidden(v)    { return set(CONFIG.STORAGE.HIDDEN_KEY, v); },
      getTokens()     { return get(CONFIG.STORAGE.TOKENS_KEY, {}); },
      setTokens(v)    { return set(CONFIG.STORAGE.TOKENS_KEY, v); },
      getFavorites()  { return get(CONFIG.STORAGE.FAVORITES_KEY, []); },
      setFavorites(v) { return set(CONFIG.STORAGE.FAVORITES_KEY, v); },
      getNotes()      { return get(CONFIG.STORAGE.NOTES_KEY, {}); },
      setNotes(v)     { return set(CONFIG.STORAGE.NOTES_KEY, v); },
      getTags()       { return get(CONFIG.STORAGE.TAGS_KEY, {}); },
      setTags(v)      { return set(CONFIG.STORAGE.TAGS_KEY, v); },
      getGridCols()   { return get(CONFIG.STORAGE.GRID_KEY, 4); },
      setGridCols(v)  { return set(CONFIG.STORAGE.GRID_KEY, v); },

      getSettings() {
        const saved = get(CONFIG.STORAGE.SETTINGS_KEY, {});
        return Object.assign({}, CONFIG.DEFAULT_SETTINGS, saved);
      },
      setSettings(v)  { return set(CONFIG.STORAGE.SETTINGS_KEY, v); },

      backup() {
        return {
          hiddenModels: this.getHidden(),
          tokensSpent: this.getTokens(),
          favorites: this.getFavorites(),
          notes: this.getNotes(),
          tags: this.getTags(),
          settings: this.getSettings(),
          timestamp: Date.now(),
          version: CONFIG.VERSION
        };
      },

      saveBackup() { return set(CONFIG.STORAGE.BACKUP_KEY, this.backup()); },
      loadBackup()  { return get(CONFIG.STORAGE.BACKUP_KEY, null); },

      resetAll() {
        for (const k of Object.values(CONFIG.STORAGE)) del(k);
        return true;
      }
    };
  })();

  /* ─────────────────────────────────────────
     NOTIFICATION MANAGER
  ───────────────────────────────────────── */
  const Notify = (() => {
    const toasts = [];
    const COLORS = { success: '#10b981', error: '#ef4444', info: '#3b82f6', warning: '#f59e0b' };
    const GAP = 48;

    function reposition() {
      toasts.forEach((el, i) => { el.style.top = (16 + i * GAP) + 'px'; });
    }

    function remove(el) {
      el.style.opacity = '0';
      setTimeout(() => {
        el.remove();
        const idx = toasts.indexOf(el);
        if (idx !== -1) toasts.splice(idx, 1);
        reposition();
      }, 250);
    }

    return {
      show(message, type = 'info', duration = 2800) {
        // In compact mode, skip info toasts
        if (Store.getSettings().compactNotifications && type === 'info') return null;
        try {
          const el = Utils.el('div', 'ce-toast', {
            role: 'alert',
            'aria-live': 'polite'
          });
          el.style.cssText = `
            position:fixed;top:16px;right:16px;z-index:10001;
            padding:9px 16px;background:rgba(15,15,20,.95);color:#fff;
            border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.45);
            transition:opacity .25s,top .2s;font-size:13px;font-weight:600;
            max-width:300px;border-left:3px solid ${COLORS[type] || COLORS.info};
            opacity:0;cursor:pointer;user-select:none;
          `;
          el.textContent = message;
          el.addEventListener('click', () => remove(el));
          document.body.appendChild(el);
          toasts.push(el);
          reposition();
          requestAnimationFrame(() => { el.style.opacity = '1'; });
          setTimeout(() => remove(el), duration);
          return el;
        } catch (e) { logger.error('Notify.show', e); return null; }
      },

      clearAll() { [...toasts].forEach(remove); }
    };
  })();

  /* ─────────────────────────────────────────
     MODEL MANAGER  (username extraction)
  ───────────────────────────────────────── */
  const ModelManager = {
    extractUsername(card) {
      if (!card) return null;
      try {
        const slug = card.querySelector('[data-slug]');
        if (slug) return slug.getAttribute('data-slug') || null;
        const a = card.querySelector('a[href]');
        if (a) {
          const parts = (a.getAttribute('href') || '').split('/').filter(Boolean);
          return parts[0] || null;
        }
      } catch (e) { logger.error('extractUsername', e); }
      return null;
    },

    getViewerCount(card) {
      if (!card) return null;
      try {
        const el = card.querySelector('[data-room-viewers], .roomViewers, .viewerCount');
        if (el) return Utils.safeInt(el.textContent);
        // fallback: look for numbers in aria labels
        const a = card.querySelector('a[aria-label]');
        if (a) {
          const m = (a.getAttribute('aria-label') || '').match(/(\d+)\s*viewer/i);
          if (m) return Utils.safeInt(m[1]);
        }
      } catch {}
      return null;
    }
  };

  /* ─────────────────────────────────────────
     GRID MANAGER
  ───────────────────────────────────────── */
  const GridManager = (() => {
    let obs = null;

    function applyToGrid(cols) {
      const grid = Utils.qs(CONFIG.SELECTORS.GRID_LIST);
      if (!grid) return false;
      grid.style.cssText = `display:grid!important;grid-template-columns:repeat(${cols},1fr);gap:12px`;
      Utils.qsAll('li.roomCard.camBgColor', grid).forEach(c => {
        c.style.width = '100%';
        c.style.maxWidth = '100%';
      });
      return true;
    }

    function waitAndApply(cols) {
      if (applyToGrid(cols)) return;
      if (obs) obs.disconnect();
      obs = new MutationObserver(() => {
        if (applyToGrid(cols)) { obs.disconnect(); obs = null; }
      });
      obs.observe(document.body, { childList: true, subtree: true });
      setTimeout(() => { if (obs) { obs.disconnect(); obs = null; } }, 6000);
    }

    function init() {
      if (Utils.getCurrentModel()) return;       // skip on room pages
      if (Utils.qs('#ce-grid-controls')) return;  // already init

      const cols = Store.getGridCols();
      waitAndApply(cols);

      const container = Utils.findEl([
        'ul.advanced-search-button-container',
        'ul.top-nav',
        'nav ul',
        'ul#nav'
      ]);
      if (!container) return;

      const li = Utils.el('li', '', { id: 'ce-grid-controls' });
      const wrap = Utils.el('div', '', { style: 'display:flex;gap:4px;align-items:center' });

      const options = [
        { cols: 2, title: '2 columns', svg: icon2 },
        { cols: 3, title: '3 columns', svg: icon3 },
        { cols: 4, title: '4 columns', svg: icon4 },
        { cols: 6, title: '6 columns', svg: icon6 }
      ];

      options.forEach(opt => {
        const btn = Utils.el('button', 'ce-grid-btn', {
          type: 'button',
          title: opt.title,
          innerHTML: opt.svg
        });
        if (cols === opt.cols) btn.classList.add('active');
        btn.addEventListener('click', () => {
          wrap.querySelectorAll('.ce-grid-btn').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
          applyToGrid(opt.cols);
          Store.setGridCols(opt.cols);
          Notify.show(`Grid: ${opt.cols} columns`, 'success');
        });
        wrap.appendChild(btn);
      });

      li.appendChild(wrap);
      const filterDiv = container.querySelector('[data-testid="filter-button"]');
      if (filterDiv) container.insertBefore(li, filterDiv);
      else container.appendChild(li);
    }

    return { init, applyToGrid, waitAndApply };
  })();

  /* ─────────────────────────────────────────
     SVG ICONS (grid buttons)
  ───────────────────────────────────────── */
  const icon2 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="6" height="6" fill="#3b82f6"/><rect x="9" y="1" width="6" height="6" fill="#f97316"/><rect x="1" y="9" width="6" height="6" fill="#3b82f6"/><rect x="9" y="9" width="6" height="6" fill="#f97316"/></svg>`;
  const icon3 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="4" height="4" fill="#3b82f6"/><rect x="6" y="1" width="4" height="4" fill="#f97316"/><rect x="11" y="1" width="4" height="4" fill="#3b82f6"/><rect x="1" y="6" width="4" height="4" fill="#f97316"/><rect x="6" y="6" width="4" height="4" fill="#3b82f6"/><rect x="11" y="6" width="4" height="4" fill="#f97316"/><rect x="1" y="11" width="4" height="4" fill="#3b82f6"/><rect x="6" y="11" width="4" height="4" fill="#f97316"/><rect x="11" y="11" width="4" height="4" fill="#3b82f6"/></svg>`;
  const icon4 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="3" height="3" fill="#3b82f6"/><rect x="5" y="1" width="3" height="3" fill="#f97316"/><rect x="9" y="1" width="3" height="3" fill="#3b82f6"/><rect x="13" y="1" width="3" height="3" fill="#f97316"/><rect x="1" y="5" width="3" height="3" fill="#f97316"/><rect x="5" y="5" width="3" height="3" fill="#3b82f6"/><rect x="9" y="5" width="3" height="3" fill="#f97316"/><rect x="13" y="5" width="3" height="3" fill="#3b82f6"/><rect x="1" y="9" width="3" height="3" fill="#3b82f6"/><rect x="5" y="9" width="3" height="3" fill="#f97316"/><rect x="9" y="9" width="3" height="3" fill="#3b82f6"/><rect x="13" y="9" width="3" height="3" fill="#f97316"/><rect x="1" y="13" width="3" height="3" fill="#f97316"/><rect x="5" y="13" width="3" height="3" fill="#3b82f6"/><rect x="9" y="13" width="3" height="3" fill="#f97316"/><rect x="13" y="13" width="3" height="3" fill="#3b82f6"/></svg>`;
  const icon6 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="2" height="2" fill="#3b82f6"/><rect x="4" y="1" width="2" height="2" fill="#f97316"/><rect x="7" y="1" width="2" height="2" fill="#3b82f6"/><rect x="10" y="1" width="2" height="2" fill="#f97316"/><rect x="13" y="1" width="2" height="2" fill="#3b82f6"/><rect x="1" y="4" width="2" height="2" fill="#f97316"/><rect x="4" y="4" width="2" height="2" fill="#3b82f6"/><rect x="7" y="4" width="2" height="2" fill="#f97316"/><rect x="10" y="4" width="2" height="2" fill="#3b82f6"/><rect x="13" y="4" width="2" height="2" fill="#f97316"/></svg>`;

  /* ─────────────────────────────────────────
     THUMBNAIL MANAGER
     Uses data: URLs (CSP-safe) instead of blob: URLs
  ───────────────────────────────────────── */
  const ThumbnailManager = (() => {
    const hoverTimers = new Map();
    const lastReqTime = new Map();
    let cleanupObs = null;

    function clearTimer(img) {
      const id = hoverTimers.get(img);
      if (id !== undefined) { clearInterval(id); hoverTimers.delete(img); }
    }

    async function fetchThumb(img) {
      try {
        if (!img || !img.isConnected) return;
        if (!img.dataset.originalSrc) img.dataset.originalSrc = img.src;

        const card = img.closest('li.roomCard');
        const username = ModelManager.extractUsername(card) ||
                         (img.parentElement && img.parentElement.dataset.room) ||
                         null;
        if (!username) return;

        const settings = Store.getSettings();
        const gap = settings.performanceMode
          ? CONFIG.TIMERS.THUMB_REQUEST_GAP_PERF
          : CONFIG.TIMERS.THUMB_REQUEST_GAP_NORMAL;

        const now = Date.now();
        if (now - (lastReqTime.get(username) || 0) < gap) return;
        lastReqTime.set(username, now);

        const url = `https://thumb.live.mmcdn.com/minifwap/${encodeURIComponent(username)}.jpg?_=${now}`;
        const ctrl = new AbortController();
        const timeout = settings.performanceMode
          ? CONFIG.TIMERS.THUMB_FETCH_TIMEOUT_PERF
          : CONFIG.TIMERS.THUMB_FETCH_TIMEOUT_NORMAL;
        const timer = setTimeout(() => { try { ctrl.abort(); } catch {} }, timeout);

        const res = await fetch(url, { cache: 'no-cache', signal: ctrl.signal });
        clearTimeout(timer);
        if (!res.ok) return;

        const blob = await res.blob();
        const dataUrl = await Utils.blobToDataURL(blob);

        if (img.isConnected) img.src = dataUrl;
      } catch (e) {
        if (e && e.name !== 'AbortError') logger.error('fetchThumb', e);
      }
    }

    function setupListeners() {
      const onEnter = Utils.debounce(function (ev) {
        const img = ev.target;
        if (!(img instanceof HTMLImageElement)) return;
        if (!img.matches('.room_list_room img, .roomElement img, .roomCard img')) return;
        if (!Store.getSettings().animateThumbnails) return;
        clearTimer(img);
        fetchThumb(img);
        const interval = Store.getSettings().performanceMode
          ? CONFIG.TIMERS.THUMB_HOVER_INTERVAL_PERF
          : CONFIG.TIMERS.THUMB_HOVER_INTERVAL_NORMAL;
        hoverTimers.set(img, setInterval(() => fetchThumb(img), interval));
      }, 60);

      const onLeave = function (ev) {
        const img = ev.target;
        if (!(img instanceof HTMLImageElement)) return;
        if (!img.matches('.room_list_room img, .roomElement img, .roomCard img')) return;
        clearTimer(img);
        if (img.dataset.originalSrc) {
          img.src = img.dataset.originalSrc;
          delete img.dataset.originalSrc;
        }
      };

      document.addEventListener('mouseenter', onEnter, true);
      document.addEventListener('mouseleave', onLeave, true);
    }

    function observeCleanup() {
      if (cleanupObs) cleanupObs.disconnect();
      cleanupObs = new MutationObserver(muts => {
        for (const mut of muts) {
          if (!mut.removedNodes) continue;
          for (const node of mut.removedNodes) {
            if (node && node.querySelectorAll) {
              node.querySelectorAll('img').forEach(clearTimer);
            }
          }
        }
      });
      cleanupObs.observe(document.body, { childList: true, subtree: true });
    }

    function stopAll() {
      for (const id of hoverTimers.values()) clearInterval(id);
      hoverTimers.clear();
      Utils.qsAll('img[data-original-src]').forEach(img => {
        img.src = img.dataset.originalSrc;
        delete img.dataset.originalSrc;
      });
      if (cleanupObs) { cleanupObs.disconnect(); cleanupObs = null; }
    }

    return {
      init() {
        setupListeners();
        observeCleanup();
      },
      stopAll
    };
  })();

  /* ─────────────────────────────────────────
     BUTTON MANAGER  (hide + favorite)
  ───────────────────────────────────────── */
  const ButtonManager = (() => {
    let processed = new WeakSet();

    function processCards() {
      const cards = Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS);
      if (!cards.length) return;

      const hidden = new Set(Store.getHidden());
      const favorites = new Set(Store.getFavorites());
      const settings = Store.getSettings();

      for (const card of cards) {
        if (processed.has(card)) continue;
        processed.add(card);

        const username = ModelManager.extractUsername(card);
        if (!username) continue;

        // Hide known-hidden cards immediately
        if (hidden.has(username)) {
          card.classList.add('ce-card-hidden');
          card.setAttribute('data-hidden', '1');
          continue;
        }

        if (getComputedStyle(card).position === 'static') card.style.position = 'relative';

        // Hide button
        const hideBtn = Utils.el('button', 'ce-hide-btn', {
          'aria-label': `Hide ${username}`,
          title: `Hide ${username}`,
          textContent: '✕',
          type: 'button'
        });
        hideBtn.addEventListener('click', ev => {
          ev.stopPropagation();
          ev.preventDefault();
          card.classList.add('ce-card-hidden');
          card.setAttribute('data-hidden', '1');
          const list = Store.getHidden();
          if (!list.includes(username)) {
            list.push(username);
            Store.setHidden(list);
            Notify.show(`Hidden: ${username}`, 'success');
            StatsManager.updateStat();
          }
        });
        card.appendChild(hideBtn);

        // Favorite button
        const favBtn = Utils.el('button', 'ce-fav-btn' + (favorites.has(username) ? ' active' : ''), {
          'aria-label': `Favorite ${username}`,
          title: `Favorite ${username}`,
          textContent: '★',
          type: 'button'
        });
        if (favorites.has(username) && settings.highlightFavorites) {
          card.classList.add('ce-card-favorite');
        }

        favBtn.addEventListener('click', ev => {
          ev.stopPropagation();
          ev.preventDefault();
          const favList = Store.getFavorites();
          const idx = favList.indexOf(username);
          if (idx === -1) {
            favList.push(username);
            Store.setFavorites(favList);
            favBtn.classList.add('active');
            if (settings.highlightFavorites) card.classList.add('ce-card-favorite');
            Notify.show(`★ Favorited: ${username}`, 'success');
          } else {
            favList.splice(idx, 1);
            Store.setFavorites(favList);
            favBtn.classList.remove('active');
            card.classList.remove('ce-card-favorite');
            Notify.show(`Unfavorited: ${username}`, 'info');
          }
        });
        card.appendChild(favBtn);

        // Viewer count badge
        if (settings.showViewerCount) {
          const count = ModelManager.getViewerCount(card);
          if (count !== null) {
            const badge = Utils.el('div', 'ce-viewer-badge', { textContent: `👁 ${count.toLocaleString()}` });
            card.appendChild(badge);
          }
        }
      }

      StatsManager.updateStat();
    }

    return {
      processCards,
      reset() { processed = new WeakSet(); }
    };
  })();

  /* ─────────────────────────────────────────
     SEARCH FILTER  (live filter on listing page)
  ───────────────────────────────────────── */
  const SearchFilter = (() => {
    let inputEl = null;

    function inject() {
      if (!Store.getSettings().showSearchFilter) return;
      if (Utils.getCurrentModel()) return;
      if (Utils.qs('#ce-search-filter')) return;

      const nav = Utils.qs(CONFIG.SELECTORS.NAV_CONTAINER);
      if (!nav) return;

      const li = Utils.el('li', '', { id: 'ce-search-filter' });
      inputEl = Utils.el('input', '', {
        type: 'text',
        placeholder: '🔍 Filter rooms…',
        'aria-label': 'Filter rooms',
        autocomplete: 'off',
        style: `
          background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);
          border-radius:20px;color:#fff;font-size:12px;padding:4px 12px;
          outline:none;width:140px;transition:width .2s,border-color .2s;
        `,
        id: 'ce-filter-input'
      });
      inputEl.addEventListener('focus', () => { inputEl.style.width = '200px'; inputEl.style.borderColor = 'rgba(59,130,246,.6)'; });
      inputEl.addEventListener('blur', () => { inputEl.style.width = '140px'; inputEl.style.borderColor = 'rgba(255,255,255,.2)'; });
      inputEl.addEventListener('input', Utils.debounce(filterCards, 120));

      // Keyboard shortcut: Shift+F to focus
      document.addEventListener('keydown', ev => {
        if (ev.shiftKey && ev.key === 'F' && document.activeElement !== inputEl) {
          ev.preventDefault();
          inputEl.focus();
        }
      });

      li.appendChild(inputEl);
      const filterDiv = nav.querySelector('[data-testid="filter-button"]');
      const filterAnchor = filterDiv ? (filterDiv.closest('li') || filterDiv) : null;
      const gridControls = nav.querySelector('#ce-grid-controls');
      if (filterAnchor && filterAnchor.parentNode === nav) nav.insertBefore(li, filterAnchor);
      else if (gridControls) gridControls.insertAdjacentElement('afterend', li);
      else nav.appendChild(li);
    }

    function filterCards() {
      const q = (inputEl ? inputEl.value.trim().toLowerCase() : '');
      Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS).forEach(card => {
        if (card.getAttribute('data-hidden') === '1') return; // always keep hidden state
        const username = (ModelManager.extractUsername(card) || '').toLowerCase();
        const subject = card.querySelector('.subject, .roomSubject, [data-testid="room-subject"]');
        const subjectText = subject ? subject.textContent.toLowerCase() : '';
        const match = !q || username.includes(q) || subjectText.includes(q);
        card.style.display = match ? '' : 'none';
      });
    }

    return { inject, filterCards };
  })();

  /* ─────────────────────────────────────────
     STATS MANAGER
  ───────────────────────────────────────── */
  const StatsManager = (() => {
    function calcHiddenStats() {
      const hidden = new Set(Store.getHidden());
      const cards = Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS);
      let currentHidden = 0;
      for (const c of cards) {
        const u = ModelManager.extractUsername(c);
        if (u && hidden.has(u) && c.getAttribute('data-hidden') === '1') currentHidden++;
      }
      return { total: hidden.size, current: currentHidden };
    }

    function updateStat() {
      try {
        const stats = calcHiddenStats();
        let statA = Utils.qs('#ce-hidden-stat');

        if (!statA) {
          const merch = Utils.findEl(['li a#merch', 'a#merch']);
          const merchLi = merch ? merch.closest('li') : null;
          const parent = (merchLi && merchLi.parentNode) ||
                         Utils.qs('ul.top-nav, ul.main-nav, nav ul, ul#nav');
          if (!parent) return;

          const li = Utils.el('li', '', { id: 'ce-stat-li' });
          statA = Utils.el('a', '', {
            id: 'ce-hidden-stat',
            href: 'javascript:void(0)',
            style: 'color:#fff;margin-right:8px;font-size:12px;cursor:pointer;'
          });
          statA.addEventListener('click', ev => {
            ev.preventDefault();
            ModalManager.open('Hidden Models', buildHiddenList(false));
          });

          const settingsBtn = Utils.el('a', '', {
            id: 'ce-settings-btn',
            href: 'javascript:void(0)',
            style: 'color:#fff;font-weight:600;margin-left:6px;font-size:12px;cursor:pointer;'
          });
          settingsBtn.textContent = '⚙ Settings';
          settingsBtn.addEventListener('click', ev => { ev.preventDefault(); SettingsModal.show(); });

          li.appendChild(statA);
          li.appendChild(settingsBtn);

          if (merchLi) merchLi.insertAdjacentElement('afterend', li);
          else parent.appendChild(li);
        }

        statA.textContent = `Hidden: ${stats.current}/${stats.total}`;
      } catch (e) { logger.error('updateStat', e); }
    }

    function buildHiddenList(showAll) {
      const container = Utils.el('div', '', { style: 'padding:8px' });

      const toggleBtn = Utils.el('button', 'ce-modal-btn', {
        textContent: showAll ? 'Show current only' : 'Show all hidden',
        type: 'button',
        style: 'margin-bottom:12px'
      });
      toggleBtn.addEventListener('click', () => {
        ModalManager.open('Hidden Models', buildHiddenList(!showAll));
      });
      container.appendChild(toggleBtn);

      const hidden = Store.getHidden();
      const hiddenSet = new Set(hidden);

      let models;
      if (showAll) {
        models = hidden;
      } else {
        models = Utils.qsAll(CONFIG.SELECTORS.ROOM_CARDS)
          .map(c => {
            const u = ModelManager.extractUsername(c);
            return (u && hiddenSet.has(u) && c.getAttribute('data-hidden') === '1') ? u : null;
          })
          .filter(Boolean);
      }

      if (!models.length) {
        container.appendChild(Utils.el('p', '', { textContent: 'No models to show', style: 'color:#9ca3af' }));
        return container;
      }

      const ul = Utils.el('ul', '', { style: 'list-style:none;padding:0;margin:0' });
      models.forEach(name => {
        const li = Utils.el('li', '', {
          style: 'display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid rgba(255,255,255,.08)'
        });
        const nameSpan = Utils.el('span', '', { textContent: name, style: 'color:#e5e7eb' });
        const unhideBtn = Utils.el('button', 'ce-modal-btn danger', { textContent: 'Unhide', type: 'button' });
        unhideBtn.addEventListener('click', () => {
          Store.setHidden(Store.getHidden().filter(n => n !== name));
          Notify.show(`${name} unhidden`, 'success');
          li.remove();
          updateStat();
        });
        li.appendChild(nameSpan);
        li.appendChild(unhideBtn);
        ul.appendChild(li);
      });
      container.appendChild(ul);
      return container;
    }

    function showTokenStats() {
      try {
        const tokens = Store.getTokens();
        const entries = Object.entries(tokens).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
        if (!entries.length) { Notify.show('No token data', 'info'); return; }

        const total = entries.reduce((s, [, v]) => s + v, 0);
        const container = Utils.el('div');
        const table = Utils.el('table', '', { style: 'width:100%;border-collapse:collapse;font-size:13px' });
        const thead = Utils.el('thead');
        thead.innerHTML = '<tr><th style="text-align:left;padding:6px;color:#9ca3af">Model</th><th style="text-align:right;padding:6px;color:#9ca3af">Tokens</th><th style="padding:6px"></th></tr>';
        table.appendChild(thead);

        const tbody = Utils.el('tbody');
        for (const [model, spent] of entries) {
          const tr = Utils.el('tr', '', { style: 'border-bottom:1px solid rgba(255,255,255,.07)' });
          tr.innerHTML = `<td style="padding:6px;color:#e5e7eb">${model}</td>`;
          const tdSpent = Utils.el('td', '', { textContent: spent.toLocaleString(), style: 'padding:6px;text-align:right;color:#10b981' });
          const tdEdit = Utils.el('td', '', { style: 'padding:6px;text-align:right' });
          const editBtn = Utils.el('button', 'ce-modal-btn', { textContent: '✎', type: 'button', title: 'Edit' });
          editBtn.addEventListener('click', () => {
            const val = prompt(`Tokens for ${model}`, spent);
            if (val !== null && !isNaN(val)) {
              const newVal = Utils.safeInt(val);
              const data = Store.getTokens();
              data[model] = newVal;
              Store.setTokens(data);
              tdSpent.textContent = newVal.toLocaleString();
              Notify.show(`Updated: ${model}`, 'success');
            }
          });
          tdEdit.appendChild(editBtn);
          tr.appendChild(tdSpent);
          tr.appendChild(tdEdit);
          tbody.appendChild(tr);
        }

        const totalTr = Utils.el('tr', '', { style: 'border-top:2px solid rgba(255,255,255,.15)' });
        totalTr.innerHTML = `<td style="padding:6px;font-weight:700">Total</td><td style="padding:6px;text-align:right;font-weight:700;color:#f59e0b" colspan="2">${total.toLocaleString()}</td>`;
        tbody.appendChild(totalTr);
        table.appendChild(tbody);
        container.appendChild(table);
        ModalManager.open('Token Stats', container);
      } catch (e) { logger.error('showTokenStats', e); }
    }

    function showFavorites() {
      const favorites = Store.getFavorites();
      const container = Utils.el('div', '', { style: 'padding:8px' });
      if (!favorites.length) {
        container.appendChild(Utils.el('p', '', { textContent: 'No favorites yet. Click ★ on a model card.', style: 'color:#9ca3af' }));
        ModalManager.open('Favorites', container);
        return;
      }
      const ul = Utils.el('ul', '', { style: 'list-style:none;padding:0;margin:0' });
      favorites.forEach(name => {
        const li = Utils.el('li', '', {
          style: 'display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid rgba(255,255,255,.08)'
        });
        const a = Utils.el('a', '', {
          href: `https://chaturbate.com/${name}/`,
          target: '_blank',
          rel: 'noopener noreferrer',
          textContent: name,
          style: 'color:#f59e0b;text-decoration:none;font-weight:600'
        });
        const removeBtn = Utils.el('button', 'ce-modal-btn danger', { textContent: '✕', type: 'button', title: 'Remove from favorites' });
        removeBtn.addEventListener('click', () => {
          Store.setFavorites(Store.getFavorites().filter(n => n !== name));
          Notify.show(`${name} removed from favorites`, 'info');
          li.remove();
        });
        li.appendChild(a);
        li.appendChild(removeBtn);
        ul.appendChild(li);
      });
      container.appendChild(ul);
      ModalManager.open('Favorites ★', container);
    }

    return { updateStat, showTokenStats, showFavorites };
  })();

  /* ─────────────────────────────────────────
     MODAL MANAGER
  ───────────────────────────────────────── */
  const ModalManager = (() => {
    let active = null;

    function close() {
      if (!active) return;
      const { overlay, escHandler, keydownTrap } = active;
      document.removeEventListener('keydown', escHandler);
      document.removeEventListener('keydown', keydownTrap);
      overlay.style.opacity = '0';
      setTimeout(() => { try { overlay.remove(); } catch {} }, 200);
      active = null;
    }

    function open(title, bodyEl, footerBtns = []) {
      if (active) close();

      const overlay = Utils.el('div', 'ce-modal-overlay', {
        role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'ce-modal-title'
      });
      overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:10000;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .2s';

      const modal = Utils.el('div', '', {
        tabindex: '-1',
        style: 'background:#111827;border-radius:10px;padding:18px;max-width:520px;width:92%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,.5);color:#e5e7eb;position:relative'
      });

      const header = Utils.el('div', '', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:14px' });
      const h3 = Utils.el('h3', '', { textContent: title, id: 'ce-modal-title', style: 'margin:0;font-size:17px;font-weight:700;color:#fff' });
      const closeBtn = Utils.el('button', '', {
        type: 'button', 'aria-label': 'Close',
        innerHTML: '&times;',
        style: 'background:none;border:none;color:#9ca3af;font-size:22px;cursor:pointer;padding:2px 6px;line-height:1;border-radius:4px;transition:color .15s'
      });
      closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = '#fff'; });
      closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = '#9ca3af'; });
      closeBtn.addEventListener('click', close);
      header.appendChild(h3);
      header.appendChild(closeBtn);

      const body = Utils.el('div', '', { style: 'margin-bottom:14px' });
      body.appendChild(bodyEl);

      const footer = Utils.el('div', '', { style: 'display:flex;gap:8px;justify-content:flex-end' });
      // Always add a close button unless custom buttons provided
      if (!footerBtns.length) {
        footerBtns = [{ text: 'Close', cls: 'secondary', onClick: close }];
      }
      footerBtns.forEach(cfg => {
        const btn = Utils.el('button', `ce-modal-btn ${cfg.cls || ''}`, {
          type: 'button', textContent: cfg.text || 'OK'
        });
        btn.addEventListener('click', () => { try { cfg.onClick(); } catch (e) { logger.error('modal btn', e); } });
        footer.appendChild(btn);
      });

      modal.appendChild(header);
      modal.appendChild(body);
      modal.appendChild(footer);
      overlay.appendChild(modal);
      document.body.appendChild(overlay);

      const escHandler = ev => { if (ev.key === 'Escape') close(); };
      overlay.addEventListener('click', ev => { if (ev.target === overlay) close(); });
      document.addEventListener('keydown', escHandler);

      // Focus trap
      const keydownTrap = ev => {
        if (ev.key !== 'Tab') return;
        const focusable = Array.from(modal.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
        if (!focusable.length) return;
        const first = focusable[0], last = focusable[focusable.length - 1];
        if (ev.shiftKey && document.activeElement === first) { last.focus(); ev.preventDefault(); }
        else if (!ev.shiftKey && document.activeElement === last) { first.focus(); ev.preventDefault(); }
      };
      document.addEventListener('keydown', keydownTrap);

      active = { overlay, modal, escHandler, keydownTrap };
      requestAnimationFrame(() => { overlay.style.opacity = '1'; });
      setTimeout(() => { try { modal.focus(); } catch {} }, 60);

      return overlay;
    }

    return { open, close };
  })();

  /* ─────────────────────────────────────────
     SETTINGS MODAL
  ───────────────────────────────────────── */
  const SettingsModal = (() => {
    function makeToggle(settings, key, label) {
      const wrap = Utils.el('label', '', {
        style: 'display:flex;justify-content:space-between;align-items:center;background:rgba(255,255,255,.04);padding:9px 12px;border-radius:6px;cursor:pointer;transition:background .15s'
      });
      wrap.addEventListener('mouseenter', () => { wrap.style.background = 'rgba(255,255,255,.08)'; });
      wrap.addEventListener('mouseleave', () => { wrap.style.background = 'rgba(255,255,255,.04)'; });
      const sp = Utils.el('span', '', { textContent: label, style: 'font-size:13px;color:#d1d5db' });
      const checkbox = Utils.el('input', '', { type: 'checkbox' });
      checkbox.checked = !!settings[key];
      checkbox.style.cssText = 'appearance:none;width:36px;height:20px;border-radius:10px;cursor:pointer;position:relative;transition:background .2s;background:' + (checkbox.checked ? '#3b82f6' : '#4b5563');
      checkbox.addEventListener('change', () => {
        settings[key] = checkbox.checked;
        checkbox.style.background = checkbox.checked ? '#3b82f6' : '#4b5563';
        Store.setSettings(settings);
        Notify.show(`${label}: ${checkbox.checked ? 'on' : 'off'}`, 'info');
      });
      wrap.appendChild(sp);
      wrap.appendChild(checkbox);
      return wrap;
    }

    function show() {
      try {
        const settings = Store.getSettings();
        const container = Utils.el('div', '', { style: 'display:flex;flex-direction:column;gap:8px' });

        const toggles = [
          ['hideGenderTabs',      'Hide gender tabs'],
          ['showTimestamps',      'Chat timestamps'],
          ['animateThumbnails',   'Animate thumbnails on hover'],
          ['showViewerCount',     'Show viewer count badge'],
          ['showSearchFilter',    'Show search filter bar'],
          ['showFavoritesBar',    'Show favorites button in nav'],
          ['highlightFavorites',  'Highlight favorite cards'],
          ['autoBackup',          'Auto-backup daily'],
          ['performanceMode',     'Performance mode (lower load)'],
          ['compactNotifications','Suppress info notifications']
        ];

        for (const [key, label] of toggles) {
          container.appendChild(makeToggle(settings, key, label));
        }

        // Actions row
        const actionsRow = Utils.el('div', '', { style: 'display:flex;flex-wrap:wrap;gap:8px;margin-top:8px' });

        const mkBtn = (text, cls, onClick) => {
          const b = Utils.el('button', `ce-modal-btn ${cls}`, { textContent: text, type: 'button' });
          b.addEventListener('click', onClick);
          return b;
        };

        actionsRow.appendChild(mkBtn('Export Backup', 'primary', () => DataManager.export()));
        actionsRow.appendChild(mkBtn('Import Backup', '', () => DataManager.showImportDialog()));
        actionsRow.appendChild(mkBtn('Token Stats', '', () => { ModalManager.close(); StatsManager.showTokenStats(); }));
        actionsRow.appendChild(mkBtn('Favorites', '', () => { ModalManager.close(); StatsManager.showFavorites(); }));
        actionsRow.appendChild(mkBtn('Reset All Data', 'danger', () => {
          if (confirm('Reset ALL data? This cannot be undone.')) {
            Store.resetAll();
            Notify.show('Data reset', 'success');
            setTimeout(() => location.reload(), 600);
          }
        }));

        container.appendChild(actionsRow);

        // Keyboard shortcuts reference
        const shortcuts = Utils.el('div', '', {
          innerHTML: `<div style="margin-top:10px;font-size:11px;color:#6b7280;line-height:1.8">
            <strong style="color:#9ca3af">Keyboard shortcuts</strong><br>
            <kbd style="background:#374151;padding:1px 5px;border-radius:3px">Shift+F</kbd> Focus search filter &nbsp;
            <kbd style="background:#374151;padding:1px 5px;border-radius:3px">Esc</kbd> Close modal
          </div>`
        });
        container.appendChild(shortcuts);

        ModalManager.open('⚙ Settings', container);
      } catch (e) { logger.error('SettingsModal.show', e); }
    }

    return { show };
  })();

  /* ─────────────────────────────────────────
     DATA MANAGER  (export / import)
  ───────────────────────────────────────── */
  const DataManager = {
    export(filename = null) {
      try {
        const backup = Store.backup();
        Utils.download(
          JSON.stringify(backup, null, 2),
          filename || `cb_backup_${new Date().toISOString().slice(0, 10)}.json`
        );
        Notify.show('Backup exported', 'success');
      } catch (e) {
        logger.error('DataManager.export', e);
        Notify.show('Export failed: ' + e.message, 'error');
      }
    },

    async import(fileOrString, mergeMode = 'replace') {
      try {
        let payload;
        if (typeof fileOrString === 'string') {
          payload = Utils.parseJSON(fileOrString, null);
          if (!payload) throw new Error('Invalid JSON');
        } else {
          const text = await fileOrString.text();
          payload = Utils.parseJSON(text, null);
          if (!payload) throw new Error('Invalid JSON file');
        }
        if (!Utils.isObj(payload)) throw new Error('Payload is not an object');

        const inHidden   = Array.isArray(payload.hiddenModels) ? payload.hiddenModels : [];
        const inTokens   = Utils.isObj(payload.tokensSpent) ? payload.tokensSpent : {};
        const inFavs     = Array.isArray(payload.favorites) ? payload.favorites : [];
        const inNotes    = Utils.isObj(payload.notes) ? payload.notes : {};
        const inSettings = Utils.isObj(payload.settings) ? payload.settings : {};

        if (mergeMode === 'merge') {
          const h = new Set(Store.getHidden());
          inHidden.forEach(x => h.add(x));
          Store.setHidden(Array.from(h));

          const t = Store.getTokens();
          for (const k in inTokens) t[k] = (t[k] || 0) + Utils.safeInt(inTokens[k]);
          Store.setTokens(t);

          const f = new Set(Store.getFavorites());
          inFavs.forEach(x => f.add(x));
          Store.setFavorites(Array.from(f));

          const n = Store.getNotes();
          Object.assign(n, inNotes);
          Store.setNotes(n);

          Store.setSettings(Object.assign(Store.getSettings(), inSettings));
        } else {
          Store.setHidden(inHidden);
          Store.setTokens(inTokens);
          Store.setFavorites(inFavs);
          Store.setNotes(inNotes);
          Store.setSettings(Object.assign(Store.getSettings(), inSettings));
        }

        Store.saveBackup();
        Notify.show('Import successful', 'success');
        return true;
      } catch (e) {
        logger.error('DataManager.import', e);
        Notify.show('Import failed: ' + e.message, 'error');
        return false;
      }
    },

    showImportDialog() {
      const container = Utils.el('div', '', { style: 'display:flex;flex-direction:column;gap:12px' });
      const fileInput = Utils.el('input', '', { type: 'file', accept: 'application/json' });
      const radioWrap = Utils.el('div', '', { style: 'display:flex;gap:16px;font-size:13px;color:#d1d5db' });
      radioWrap.innerHTML = `
        <label style="cursor:pointer"><input type="radio" name="ce-import-mode" value="replace" checked /> Replace</label>
        <label style="cursor:pointer"><input type="radio" name="ce-import-mode" value="merge" /> Merge (recommended)</label>
      `;
      const hint = Utils.el('p', '', {
        textContent: 'Merge adds hidden models and sums token counts. Replace overwrites everything.',
        style: 'font-size:12px;color:#6b7280;margin:0'
      });

      container.appendChild(fileInput);
      container.appendChild(radioWrap);
      container.appendChild(hint);

      ModalManager.open('Import Backup', container, [
        { text: 'Cancel', cls: 'secondary', onClick: () => ModalManager.close() },
        {
          text: 'Import', cls: 'primary',
          onClick: async () => {
            const f = fileInput.files && fileInput.files[0];
            if (!f) { Notify.show('Select a file first', 'error'); return; }
            const modeEl = document.querySelector('input[name="ce-import-mode"]:checked');
            const mode = modeEl ? modeEl.value : 'replace';
            const ok = await DataManager.import(f, mode);
            if (ok) { ModalManager.close(); setTimeout(() => location.reload(), 500); }
          }
        }
      ]);
    }
  };

  /* ─────────────────────────────────────────
     CHAT TIMESTAMP MANAGER
  ───────────────────────────────────────── */
  class ChatTimestampManager {
    constructor() {
      this.observer = null;
      this.processed = new WeakSet();
      this.counter = 0;
    }

    start() {
      if (!Store.getSettings().showTimestamps) return;
      this._processAll();
      this._observe();
    }

    stop() {
      if (this.observer) { this.observer.disconnect(); this.observer = null; }
      this.processed = new WeakSet();
      this.counter = 0;
    }

    _processAll() {
      this._processMessages();
      this._processNotices();
    }

    _fmtTime(ts) {
      const d = isNaN(ts) ? new Date() : new Date(ts);
      const opts = Store.getSettings().performanceMode
        ? { hour: '2-digit', minute: '2-digit' }
        : { hour: '2-digit', minute: '2-digit', second: '2-digit' };
      return d.toLocaleTimeString([], opts);
    }

    _tsColor(el) {
      try {
        let cur = el;
        while (cur && cur !== document.body) {
          const bg = getComputedStyle(cur).backgroundColor;
          if (bg && !bg.includes('rgba(0, 0, 0, 0)') && !bg.includes('transparent')) {
            const m = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
            if (m) {
              const lum = (parseInt(m[1]) * 299 + parseInt(m[2]) * 587 + parseInt(m[3]) * 114) / 1000;
              return lum > 175 ? 'rgba(0,0,0,.7)' : 'rgba(255,255,255,.75)';
            }
          }
          cur = cur.parentElement;
        }
      } catch {}
      return 'rgba(255,255,255,.75)';
    }

    _injectSpan(target, timeStr, el) {
      if (target.querySelector('.ce-ts')) return;
      const sp = Utils.el('span', 'ce-ts', {
        textContent: `[${timeStr}] `,
        style: `color:${this._tsColor(el)};font-size:11px;margin-right:4px;font-weight:400;display:inline`
      });
      target.insertBefore(sp, target.firstChild);
    }

    _processMessages() {
      Utils.qsAll(CONFIG.SELECTORS.CHAT_MESSAGES).forEach(msg => {
        if (this.processed.has(msg)) return;
        if (msg.querySelector('[data-testid="room-notice"]')) { this._mark(msg); return; }
        const ts = Utils.safeInt(msg.getAttribute('data-ts'), NaN);
        const timeStr = this._fmtTime(ts);
        const container = msg.querySelector('div[dm-adjust-bg],div[dm-adjust-fg]') || msg;
        this._injectSpan(container, timeStr, msg);
        this._mark(msg);
      });
    }

    _processNotices() {
      Utils.qsAll('[data-testid="room-notice"]').forEach(notice => {
        if (this.processed.has(notice)) return;
        const target = notice.querySelector('[data-testid="room-notice-viewport"] span') ||
                       notice.querySelector('span') || notice;
        if (!target.querySelector('.ce-ts')) {
          this._injectSpan(target, this._fmtTime(NaN), notice);
        }
        this._mark(notice);
      });
    }

    _observe() {
      const chatContainer = Utils.findEl(['#chat-messages', '.chat-messages', '#messages', '.messages-list', '#message-list']);
      if (!chatContainer) return;
      this.observer = new MutationObserver(Utils.debounce(() => this._processAll(), CONFIG.TIMERS.MUTATION_DEBOUNCE));
      this.observer.observe(chatContainer, { childList: true, subtree: true });
    }

    _mark(node) {
      this.processed.add(node);
      if (++this.counter > 5000) { this.processed = new WeakSet(); this.counter = 0; }
    }
  }

  /* ─────────────────────────────────────────
     TAB MANAGER
  ───────────────────────────────────────── */
  const TabManager = {
    hideGenderTabs() {
      if (!Store.getSettings().hideGenderTabs) return;
      Utils.qsAll('a.gender-tab[href*="/trans-cams/"],a[href*="/male-cams"],a[href*="/trans-cams"]').forEach(el => { el.style.display = 'none'; });
      const merch = Utils.qs('li a#merch');
      if (merch && merch.closest('li')) merch.closest('li').style.display = 'none';
    }
  };

  /* ─────────────────────────────────────────
     TOKEN MONITOR
  ───────────────────────────────────────── */
  class TokenMonitor {
    constructor() {
      this.observer = null;
      this.lastCount = null;
      this.active = false;
      this.model = null;
    }

    async start() {
      if (this.active) return;
      this.active = true;

      const tokenSpan = Utils.findEl(CONFIG.SELECTORS.TOKEN_BALANCE);
      const scanSpan  = Utils.qs(CONFIG.SELECTORS.SCAN_CAMS);
      const model     = Utils.getCurrentModel();

      if (!tokenSpan || !scanSpan || !model) return;

      this.model = model;
      this._track(model, tokenSpan);
      this._buildUI(model, scanSpan);
    }

    _track(model, span) {
      const tokens = Store.getTokens();
      if (!(model in tokens)) { tokens[model] = 0; Store.setTokens(tokens); }
      this.lastCount = Utils.safeInt(span.textContent);

      this.observer = new MutationObserver(() => {
        if (!this.active) return;
        const current = Utils.safeInt(span.textContent);
        if (current < this.lastCount) {
          const spent = this.lastCount - current;
          const data = Store.getTokens();
          data[model] = (data[model] || 0) + spent;
          Store.setTokens(data);
          // Broadcast to other tabs
          localStorage.setItem('chaturbateEnhancer:lastTokenUpdate', JSON.stringify({ model, total: data[model], ts: Date.now() }));
          this._updateDisplay(model, data[model]);
        }
        this.lastCount = current;
      });
      this.observer.observe(span, { childList: true, characterData: true, subtree: true });

      window.addEventListener('storage', ev => {
        if (ev.key !== 'chaturbateEnhancer:lastTokenUpdate') return;
        try {
          const p = Utils.parseJSON(ev.newValue, {});
          if (p.model === model) this._updateDisplay(model, p.total);
        } catch {}
      });
    }

    _buildUI(model, anchor) {
      if (Utils.qs('#ce-tokens-bar')) return;
      const bar = Utils.el('div', '', { id: 'ce-tokens-bar' });
      bar.style.cssText = 'display:flex;justify-content:space-between;align-items:center;background:rgba(0,0,0,.45);padding:7px 12px;border-radius:8px;margin:6px 0;gap:12px;flex-wrap:wrap';

      const left = Utils.el('span', '', { id: 'ce-token-display', style: 'font-size:13px;font-weight:600;color:#e5e7eb' });
      const right = Utils.el('div', '', { style: 'display:flex;gap:8px;flex-wrap:wrap' });

      const links = [
        { label: 'RecuMe',       url: CONFIG.EXTERNAL.RECU_ME      + encodeURIComponent(model), color: 'linear-gradient(135deg,#f97316,#fbbf24)', textColor: '#000' },
        { label: 'CamWhoresTV',  url: CONFIG.EXTERNAL.CAMWHORES_TV + encodeURIComponent(model) + '/', color: 'linear-gradient(135deg,#dc2626,#2563eb)', textColor: '#fff' },
        { label: 'CamGirlFinder',url: CONFIG.EXTERNAL.CAMGIRLFINDER + encodeURIComponent(model) + '#1', color: 'linear-gradient(135deg,#10b981,#059669)', textColor: '#fff' }
      ];

      links.forEach(({ label, url, color, textColor }) => {
        const btn = Utils.el('button', '', {
          type: 'button', title: label, textContent: label,
          style: `background:${color};color:${textColor};font-weight:700;padding:5px 12px;border-radius:99px;border:none;cursor:pointer;font-size:12px;transition:transform .15s,box-shadow .15s;box-shadow:0 2px 6px rgba(0,0,0,.2)`
        });
        btn.addEventListener('mouseenter', () => { btn.style.transform = 'translateY(-2px) scale(1.05)'; btn.style.boxShadow = '0 4px 10px rgba(0,0,0,.3)'; });
        btn.addEventListener('mouseleave', () => { btn.style.transform = ''; btn.style.boxShadow = '0 2px 6px rgba(0,0,0,.2)'; });
        btn.addEventListener('click', () => { try { window.open(url, '_blank', 'noopener,noreferrer'); } catch {} });
        right.appendChild(btn);
      });

      bar.appendChild(left);
      bar.appendChild(right);

      const toolbar = Utils.qs('.genderTabs');
      if (toolbar && toolbar.parentElement) toolbar.parentElement.insertBefore(bar, toolbar);
      else document.body.prepend(bar);

      this._updateDisplay(model);
    }

    _updateDisplay(model, count = null) {
      const el = Utils.qs('#ce-token-display');
      if (!el) return;
      if (count === null) count = Store.getTokens()[model] || 0;
      el.textContent = `Tokens spent on ${model}: ${count.toLocaleString()}`;
    }

    stop() {
      this.active = false;
      this.model = null;
      if (this.observer) { this.observer.disconnect(); this.observer = null; }
    }
  }

  /* ─────────────────────────────────────────
     AUTO BACKUP
  ───────────────────────────────────────── */
  function maybeAutoBackup() {
    if (!Store.getSettings().autoBackup) return;
    const lastTs = Utils.safeInt(localStorage.getItem(CONFIG.STORAGE.PREFIX + CONFIG.STORAGE.BACKUP_TS_KEY), 0);
    if (Date.now() - lastTs >= CONFIG.TIMERS.AUTO_BACKUP_INTERVAL_MS) {
      Store.saveBackup();
      localStorage.setItem(CONFIG.STORAGE.PREFIX + CONFIG.STORAGE.BACKUP_TS_KEY, String(Date.now()));
      logger.info('Auto-backup saved');
    }
  }

  /* ─────────────────────────────────────────
     MAIN STYLES
  ───────────────────────────────────────── */
  function injectMainStyles() {
    const style = Utils.el('style');
    style.id = 'ce-main-styles';
    style.textContent = `
      /* Modal */
      .ce-modal-btn{padding:6px 12px;border:none;border-radius:6px;font-weight:600;cursor:pointer;background:#374151;color:#fff;transition:background .15s,transform .1s;font-size:13px}
      .ce-modal-btn:hover{background:#4b5563;transform:translateY(-1px)}
      .ce-modal-btn.primary{background:#3b82f6}
      .ce-modal-btn.primary:hover{background:#2563eb}
      .ce-modal-btn.secondary{background:#6b7280}
      .ce-modal-btn.secondary:hover{background:#4b5563}
      .ce-modal-btn.danger{background:#dc2626}
      .ce-modal-btn.danger:hover{background:#b91c1c}

      /* Grid controls */
      #ce-grid-controls{display:inline-flex;align-items:center;vertical-align:middle;margin-right:6px}
      .ce-grid-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border:none;border-radius:4px;background:#1a252f;cursor:pointer;padding:0;transition:background .15s;box-shadow:0 1px 3px rgba(0,0,0,.15)}
      .ce-grid-btn:hover{background:#2a3b4f}
      .ce-grid-btn.active{background:#3b82f6;box-shadow:0 2px 8px rgba(59,130,246,.35)}
      .ce-grid-btn svg{pointer-events:none}
      .ce-grid-btn:focus{outline:none;box-shadow:0 0 0 2px rgba(59,130,246,.5)}

      /* Chat timestamps */
      .ce-ts{font-size:11px;font-weight:400;opacity:.85;margin-right:5px;display:inline;vertical-align:middle}

      /* Search filter placeholder */
      #ce-filter-input::placeholder{color:rgba(255,255,255,.75)}

      /* Username row */
      [data-testid="chat-message-username"]{display:inline-flex;align-items:center;gap:3px}

      /* Stat bar */
      #ce-stat-li a{transition:opacity .15s}
      #ce-stat-li a:hover{opacity:.75}
    `;
    document.head.appendChild(style);
  }

  /* ─────────────────────────────────────────
     KEYBOARD SHORTCUTS (global)
  ───────────────────────────────────────── */
  function setupKeyboardShortcuts() {
    document.addEventListener('keydown', ev => {
      if (document.activeElement && ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
      // Shift+S → open settings
      if (ev.shiftKey && ev.key === 'S') { ev.preventDefault(); SettingsModal.show(); }
      // Shift+H → show hidden models
      if (ev.shiftKey && ev.key === 'H') { ev.preventDefault(); ModalManager.open('Hidden Models', StatsManager.showHiddenList && StatsManager.showHiddenList() || Utils.el('div')); }
    });
  }

  /* ─────────────────────────────────────────
     MAIN CONTROLLER
  ───────────────────────────────────────── */
  class ChaturbateEnhancer {
    constructor() {
      this.tokenMonitor  = null;
      this.chatTs        = null;
      this.lastPath      = location.pathname;
      this.mutObs        = null;
      this.pathInterval  = null;
      this.initialized   = false;
    }

    async init() {
      if (this.initialized) return;
      try {
        showOverlay();
        Store.migrate();
        injectMainStyles();
        await this._setup();
        this._watchDOM();
        this._watchPath();
        this._exposeGlobals();
        this.initialized = true;
        maybeAutoBackup();
      } catch (e) {
        logger.error('Enhancer.init', e);
      } finally {
        setTimeout(hideOverlay, 80);
      }
    }

    async _setup() {
      try {
        const isRoomPage = !!Utils.getCurrentModel();

        if (!isRoomPage) {
          GridManager.init();
          ThumbnailManager.init();
          SearchFilter.inject();
        } else {
          ThumbnailManager.stopAll();
        }

        if (this.tokenMonitor) { this.tokenMonitor.stop(); }
        this.tokenMonitor = new TokenMonitor();
        await this.tokenMonitor.start();

        if (this.chatTs) { this.chatTs.stop(); }
        this.chatTs = new ChatTimestampManager();
        this.chatTs.start();

        ButtonManager.processCards();
        TabManager.hideGenderTabs();
        StatsManager.updateStat();
        SearchFilter.filterCards();
      } catch (e) { logger.error('_setup', e); }
    }

    _watchDOM() {
      const debounced = Utils.debounce(() => this._setup(), CONFIG.TIMERS.MUTATION_DEBOUNCE);
      const targets = [
        Utils.qs('#room_list, .room_list'),
        Utils.qs('#main, .main-content')
      ].filter(Boolean);

      this.mutObs = new MutationObserver(muts => {
        for (const m of muts) {
          if ((m.addedNodes && m.addedNodes.length) || (m.removedNodes && m.removedNodes.length)) {
            debounced();
            break;
          }
        }
      });

      for (const t of targets) this.mutObs.observe(t, { childList: true, subtree: true });
    }

    _watchPath() {
      this.pathInterval = setInterval(() => {
        if (location.pathname !== this.lastPath) this._onPathChange();
      }, CONFIG.TIMERS.PATH_CHECK_INTERVAL);
    }

    _onPathChange() {
      this.lastPath = location.pathname;
      if (this.tokenMonitor) this.tokenMonitor.stop();
      if (this.chatTs) this.chatTs.stop();
      ThumbnailManager.stopAll();
      ButtonManager.reset();
      setTimeout(() => this._setup(), 400);
    }

    _exposeGlobals() {
      window.ceSettings    = () => SettingsModal.show();
      window.ceExport      = () => DataManager.export();
      window.ceImport      = () => DataManager.showImportDialog();
      window.ceFavorites   = () => StatsManager.showFavorites();
      window.ceTokenStats  = () => StatsManager.showTokenStats();
      window.ceErrors      = () => { console.table(logger.getErrors()); };
      window.ceReset       = () => {
        if (confirm('Reset ALL Chaturbate Enhancer data?')) {
          Store.resetAll();
          Notify.show('Data reset', 'success');
          setTimeout(() => location.reload(), 600);
        }
      };
    }
  }

  /* ─────────────────────────────────────────
     BOOTSTRAP
  ───────────────────────────────────────── */
  let enhancer = null;

  function boot() {
    if (enhancer) return;
    enhancer = new ChaturbateEnhancer();
    enhancer.init();
    setupKeyboardShortcuts();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    setTimeout(boot, 80);
  }

})();