Chaturbate Enhancer

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

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

(function () {
  'use strict';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        // Find the image wrapper (anchor tag wrapping the thumbnail) for correct overlay positioning
        const imgWrapper = card.querySelector('a') || card;
        if (getComputedStyle(imgWrapper).position === 'static') imgWrapper.style.position = 'relative';

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

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

        favBtn.addEventListener('click', ev => {
          ev.stopPropagation();
          ev.preventDefault();
          const favList = Store.getFavorites();
          const idx = favList.indexOf(username);
          if (idx === -1) {
            favList.push(username);
            Store.setFavorites(favList);
            favBtn.classList.add('active');
            if (settings.highlightFavorites) card.classList.add('ce-card-favorite');
            Notify.show(`★ Favorited: ${username}`, 'success');
          } else {
            favList.splice(idx, 1);
            Store.setFavorites(favList);
            favBtn.classList.remove('active');
            card.classList.remove('ce-card-favorite');
            Notify.show(`Unfavorited: ${username}`, 'info');
          }
        });
        // Favorite button — append inside the image wrapper so it overlays the thumbnail
        imgWrapper.appendChild(favBtn);

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

      StatsManager.updateStat();
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      const header = Utils.el('div', '', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:14px' });
      const h3 = Utils.el('h3', '', { textContent: title, id: 'ce-modal-title', style: 'margin:0;font-size:17px;font-weight:700;color:#fff' });
      const closeBtn = Utils.el('button', '', {
        type: 'button', 'aria-label': 'Close',
        innerHTML: '&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);
  }

})();