☰

iXXX Network Category Mixer

Pick any category (checkboxes on the /a-z page or in a built-in picker) and merge their videos into one combined, searchable, filterable grid. Works across the iXXX network: ixxx.com, sambaporno.com, tiava.com and tubepornstars.com. Decodes /out/ links to the real video URLs everywhere on the page — not just in the mixed grid.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         iXXX Network Category Mixer
// @version      1.5.0
// @license      none
// @description  Pick any category (checkboxes on the /a-z page or in a built-in picker) and merge their videos into one combined, searchable, filterable grid. Works across the iXXX network: ixxx.com, sambaporno.com, tiava.com and tubepornstars.com. Decodes /out/ links to the real video URLs everywhere on the page — not just in the mixed grid.
// @match        https://www.ixxx.com/*
// @match        https://ixxx.com/*
// @match        https://www.sambaporno.com/*
// @match        https://sambaporno.com/*
// @match        https://www.tiava.com/*
// @match        https://tiava.com/*
// @match        https://www.tubepornstars.com/*
// @match        https://tubepornstars.com/*
// @grant        none
// @run-at       document-idle
// @namespace https://greasyfork.org/users/373977
// ==/UserScript==

(function () {
  'use strict';

  /* ============================= CONFIG ============================= */

  // One CMS, four sites: ixxx.com's own /network/ page lists exactly these.
  // Each site keeps its video collections under a different path, and the
  // "tube" sites add a locale segment (e.g. sambaporno -> /en/cat/<slug>).
  const NETWORK = {
    'ixxx.com': { name: 'iXXX', keyword: 'c', cat: '/c/' },
    'sambaporno.com': { name: 'SambaPorno', keyword: 'cat', cat: '/en/cat/' },
    'tiava.com': { name: 'Tiava', keyword: 'category', cat: '/category/' },
    'tubepornstars.com': { name: 'TubePornstars', keyword: 'pornstar', cat: '/pornstar/' }
  };

  // The categories this script was built for (shared by the three category
  // sites; tubepornstars lists pornstars instead, so it pre-selects nothing).
  const DEFAULT_SLUGS = [
    'perfect-body', 'perfect', 'threesome', 'stripper', 'latina-threesome',
    'colombian', 'big-ass', 'orgy', 'lingeriethreesome', 'lingerie',
    'thong', 'skirt', 'dress', 'lace', 'seduce', 'maid',
    'latina-big-ass', 'perfect-tits', 'brazilian', 'big-booty',
    'big-ass-anal', 'massage', 'riding', 'party'
  ];

  const siteKey = (() => {
    const h = location.hostname.replace(/^www\./, '');
    return Object.prototype.hasOwnProperty.call(NETWORK, h) ? h : 'ixxx.com';
  })();
  const SITE = NETWORK[siteKey];
  const SITE_DEFAULTS = [];

  // The current origin, so the script follows whichever network site it runs on.
  const ORIGIN = location.origin;
  // iXXX serves a different (free) view with this query param; keep it on every
  // request by default so premium-only layouts never interfere with parsing.
  const PRICING = 'pricing=free';
  const CATEGORY_LIST_URL = ORIGIN + '/a-z?' + PRICING;

  const DEFAULTS = {
    pages: 1,
    concurrency: 4,
    delayMs: 500,
    sort: 'default',
    hidden: [],
    selected: SITE_DEFAULTS.slice(),
    decodeLinks: true,          // open the real video URL instead of /out/
    blockedSources: ''          // comma separated, matched against the source site
  };
  const MAX_PAGES = 10;
  const BIG_SELECTION_WARN = 60;   // ask for confirmation above this many categories
  const STORE_KEY = 'ixxx-mixer-settings-v1';
  const CAT_STORE_KEY = 'ixxx-mixer-categorylist-v1';

  /* ============================= SETTINGS =========================== */

  let settings = Object.assign({}, DEFAULTS);
  try {
    Object.assign(settings, JSON.parse(localStorage.getItem(STORE_KEY) || '{}'));
  } catch (e) { /* corrupted -> defaults */ }
  if (!Array.isArray(settings.selected) || !settings.selected.length) settings.selected = [];

  function saveSettings() {
    try { localStorage.setItem(STORE_KEY, JSON.stringify(settings)); } catch (e) { /* ignore */ }
  }

  function readJson(key) {
    try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
  }

  /* ============================= HELPERS ============================ */

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  /* ======================= RATE-LIMIT SAFETY (HTTP 429) =================== */
  // These sites throttle aggressively: fetching many categories at once can
  // return HTTP 429 and, without care, every in-flight category fails. This
  // adds automatic retry with exponential backoff, honours the server's
  // Retry-After hint, and — once a 429 is seen — pauses ALL fetches (drops
  // effective concurrency to 1) so the remaining categories still complete.

  const RETRY_MAX = 3;              // extra attempts per request (so up to 4 tries)
  const THROTTLE_CAP_MS = 60000;    // never wait more than a minute per 429

  let throttleUntil = 0;            // timestamp: hold all fetches until then
  let throttleCount = 0;            // how many 429s we hit during the current load

  function backoffMs(attempt) {
    // 1s, 2s, 4s ... capped, with jitter so simultaneous workers don't re-collide
    return Math.min(1000 * Math.pow(2, attempt), 15000) + Math.floor(Math.random() * 500);
  }

  // fetch() with retry/backoff for 429 + 5xx, and a shared cooldown for 429s.
  // Returns the Response on success; throws only after retries are exhausted.
  async function fetchWithRetry(url, opts) {
    for (let attempt = 0; ; attempt++) {
      // Respect a global cooldown set by a previous 429 (this serializes fetches).
      if (Date.now() < throttleUntil) await sleep(throttleUntil - Date.now());

      let res;
      try {
        res = await fetch(url, opts);
      } catch (err) {
        if (attempt < RETRY_MAX) { await sleep(backoffMs(attempt)); continue; }
        throw err;
      }

      if (res.status === 429) {
        const ra = parseInt(res.headers.get('Retry-After') || '', 10);
        const waitMs = (ra > 0 ? ra : Math.min(2 * (attempt + 1), 20)) * 1000;
        throttleCount++;
        throttleUntil = Date.now() + Math.min(waitMs, THROTTLE_CAP_MS);
        if (attempt < RETRY_MAX) { await sleep(waitMs); continue; }
        throw new Error('HTTP 429 — rate limited; the mixer slowed down and will finish the rest when allowed.');
      }

      if (res.status >= 500) {
        if (attempt < RETRY_MAX) { await sleep(backoffMs(attempt)); continue; }
        throw new Error('HTTP ' + res.status + ' (server error)');
      }

      return res; // 2xx, or a permanent 4xx other than 429 — let the caller decide
    }
  }

  function slugOf(url) {
    try {
      // Works for every site: /c/lace, /en/cat/lace, /category/lace, /pornstar/lace
      const parts = new URL(url, ORIGIN).pathname.split('/').filter(Boolean);
      return parts[parts.length - 1] || url;
    } catch (e) { return url; }
  }

  // Where this site keeps a single collection's videos ("/c/", "/en/cat/",
  // "/category/", "/pornstar/"). Read it off the page when possible so a locale
  // segment (e.g. sambaporno's "/en/") is picked up automatically, otherwise
  // fall back to the per-site table.
  function baseFromHref(href) {
    const i = String(href || '').lastIndexOf('/');
    return i > 0 ? href.slice(0, i + 1) : '';
  }

  function detectCatBase() {
    const own = document.querySelector('li.category a.anchor-link[href], li.category a[href]');
    if (own) {
      const b = baseFromHref(own.getAttribute('href'));
      if (b) return b;
    }
    const segs = location.pathname.split('/').filter(Boolean);
    const idx = segs.lastIndexOf(SITE.keyword);
    if (idx >= 0 && idx < segs.length - 1) return '/' + segs.slice(0, idx + 1).join('/') + '/';
    return settings.catBase || SITE.cat;
  }

  let catBase = settings.catBase || SITE.cat;

  function setCatBase(b) {
    if (b && b !== catBase) {
      catBase = b;
      if (settings.catBase !== b) { settings.catBase = b; saveSettings(); }
    }
    return catBase;
  }

  function refreshCatBase() { return setCatBase(detectCatBase()); }

  const urlForSlug = (slug) => ORIGIN + catBase + slug + '?' + PRICING;

  // Every network site wraps outbound video links as /out/?l=<base64url
  // payload>. The
  // payload is a msgpack blob that carries the real destination URL as a
  // string, so it can be decoded locally — no extra request, no redirect hop.
  // (Same idea as the "Link Decoder" in the PornTube Helper userscript, but
  // reading the msgpack string length instead of guessing with a regex.)
  function decodeOutUrl(href) {
    try {
      const payload = new URL(href, ORIGIN).searchParams.get('l');
      if (!payload) return null;

      let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
      while (b64.length % 4) b64 += '=';
      const bin = atob(b64); // one char per byte

      // msgpack str8 / str16 / str32 whose value is the URL (exact length,
      // so nothing after the URL can leak into the result).
      for (let i = 0; i + 2 < bin.length; i++) {
        const tag = bin.charCodeAt(i);
        let size = -1;
        let start = -1;
        if (tag === 0xd9) { size = bin.charCodeAt(i + 1); start = i + 2; }
        else if (tag === 0xda) { size = (bin.charCodeAt(i + 1) << 8) | bin.charCodeAt(i + 2); start = i + 3; }
        else if (tag === 0xdb) {
          size = bin.charCodeAt(i + 1) * 16777216 + (bin.charCodeAt(i + 2) << 16) +
            (bin.charCodeAt(i + 3) << 8) + bin.charCodeAt(i + 4);
          start = i + 5;
        } else continue;
        if (size < 8 || start + size > bin.length) continue;
        const s = bin.substr(start, size);
        if (/^https?:\/\/[^\u0000-\u001f]+$/.test(s)) return s;
      }

      // Fallback: first http(s) run in the blob.
      const m = bin.match(/https?:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&*+;=%]+/);
      return m ? m[0] : null;
    } catch (e) {
      return null;
    }
  }

  function hostOf(url) {
    try { return new URL(url).host.replace(/^www\./, ''); } catch (e) { return ''; }
  }

  function toSeconds(dur) {
    if (!dur) return null;
    const parts = dur.split(':').map(Number);
    if (parts.some(isNaN)) return null;
    return parts.reduce((acc, p) => acc * 60 + p, 0);
  }

  // "5 months ago" -> approximate age in days (null when unparseable)
  function parseAge(text) {
    if (!text) return null;
    if (/just now/i.test(text)) return 0;
    const m = /(\d+)\s*(minute|hour|day|week|month|year)s?\s+ago/i.exec(text);
    if (!m) return null;
    const per = { minute: 1 / 1440, hour: 1 / 24, day: 1, week: 7, month: 30, year: 365 };
    return Number(m[1]) * per[m[2].toLowerCase()];
  }

  function isGate(doc) {
    const canonical = doc.querySelector('link[rel="canonical"]');
    return !!canonical && /age-verification/.test(canonical.getAttribute('href') || '');
  }

  function el(tag, cls, text) {
    const node = document.createElement(tag);
    if (cls) node.className = cls;
    if (text !== undefined) node.textContent = text;
    return node;
  }

  function uniqueSlugs(list) {
    const seen = new Set();
    const out = [];
    for (const s of list) {
      if (s && !seen.has(s)) { seen.add(s); out.push(s); }
    }
    return out;
  }

  // "latina-threesome" -> "Latina Threesome"
  function prettify(slug) {
    const words = String(slug || '').split(/[-_]+/).filter(Boolean);
    return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') || String(slug || '');
  }

  // Accepts "lace", "latina-threesome", "/c/lace", "/en/cat/lace", a full URL
  // or "Lace" and returns a bare slug, or '' when nothing usable is left.
  function normalizeSlug(value) {
    const cleaned = String(value || '').trim().toLowerCase()
      .replace(/^https?:\/\/[^/]+/, '')
      .replace(/[?#].*$/, '')
      .split('/').filter(Boolean).pop() || '';
    return cleaned
      .replace(/\s+/g, '-')
      .replace(/[^a-z0-9-]/g, '')
      .replace(/-+/g, '-')
      .replace(/^-|-$/g, '');
  }

  // Locale-independent thousands formatting, so counts always read "2,782".
  const fmt = (n) => Number(n || 0).toLocaleString('en-US');

  /* ============================= PARSING ============================ */
  // Category grid: `div.cards-container` holds `div.card[data-public-id]` cards.

  function parseDoc(doc, slug) {
    let nodes = doc.querySelectorAll('.cards-container .card[data-public-id]');
    if (!nodes.length) nodes = doc.querySelectorAll('div.card[data-public-id]');
    const out = [];

    nodes.forEach((card) => {
      const id = card.getAttribute('data-public-id');
      if (!id) return;

      const linkEl = card.querySelector('a.item-link[href]');
      const imgEl = card.querySelector('img.item-image');
      const titleEl = card.querySelector('a.item-title');
      const metaEl = card.querySelector('.item-meta-container');
      const scoreEl = card.querySelector('.item-score');
      const srcEl = card.querySelector('.item-source-rating-container a.item-source');
      const ageEl = card.querySelector('.item-source-rating-container span.item-source');

      const metaText = metaEl ? metaEl.textContent : '';
      const scoreText = scoreEl ? scoreEl.textContent : '';
      const rawHref = linkEl ? (linkEl.getAttribute('href') || '') : '';
      const href = rawHref ? new URL(rawHref, ORIGIN).href : '';
      const real = href ? (decodeOutUrl(href) || '') : '';
      const durMatch = /(\d{1,3}:\d{2}(?::\d{2})?)/.exec(metaText);
      const qualMatch = /\b(HD|4K|FHD|UHD|SD)\b/.exec(metaText);
      const scoreMatch = /(\d{1,3})\s*%/.exec(scoreText);

      let title = '';
      if (titleEl) title = titleEl.getAttribute('title') || titleEl.textContent.trim();
      if (!title && imgEl) title = imgEl.getAttribute('alt') || '';
      if (!title && titleEl) title = titleEl.getAttribute('data-error-title') || '';
      if (!title) title = id;

      const dur = durMatch ? durMatch[1] : '';

      out.push({
        id: id,
        slug: slug,
        href: href,
        real: real,
        realHost: real ? hostOf(real) : '',
        title: title,
        thumb: imgEl ? (imgEl.getAttribute('src') || '') : '',
        dur: dur,
        durSec: toSeconds(dur),
        qual: qualMatch ? qualMatch[1] : '',
        score: scoreMatch ? scoreMatch[1] + '%' : '',
        scorePct: scoreMatch ? Number(scoreMatch[1]) : null,
        source: srcEl ? srcEl.textContent.trim() : '',
        age: ageEl ? ageEl.textContent.trim() : '',
        ageDays: parseAge(ageEl ? ageEl.textContent : ''),
        cats: [slug]
      });
    });

    return out;
  }

  // The /a-z page lists every collection as li.category with a link whose last
  // path segment is the slug (works on all four network sites).
  function parseCategoryList(doc) {
    const out = [];
    const seen = new Set();

    const first = doc.querySelector('li.category a.anchor-link[href], li.category a[href]');
    if (first) setCatBase(baseFromHref(first.getAttribute('href')));

    const push = (a, li) => {
      const slug = slugOf(a.getAttribute('href'));
      if (!slug || seen.has(slug)) return;
      seen.add(slug);
      const nameEl = li ? li.querySelector('.category-title') : null;
      const badge = li ? li.querySelector('.badge') : null;
      const name = ((li && li.getAttribute('data-title')) ||
        (nameEl ? nameEl.textContent.trim() : '') ||
        (a.textContent || '').trim() || slug).trim();
      out.push({ slug: slug, name: name, count: badge ? badge.textContent.trim() : '' });
    };

    doc.querySelectorAll('li.category').forEach((li) => {
      const a = li.querySelector('a.anchor-link[href]') || li.querySelector('a[href]');
      if (a) push(a, li);
    });
    if (out.length) return out;

    // fallback: any link that looks like a collection link on this site
    doc.querySelectorAll('a[href]').forEach((a) => {
      const href = a.getAttribute('href') || '';
      if (href.indexOf(SITE.cat) === 0 || /(^|\/)(c|cat|category|pornstar)\/[^/]+$/.test(href)) push(a, null);
    });
    return out;
  }

  // results: [{slug, items, error}] already ordered by selection order.
  function mergeCategoryResults(results) {
    const map = new Map();
    for (const res of results) {
      for (const it of res.items) {
        const prev = map.get(it.id);
        if (prev) {
          if (!prev.cats.includes(res.slug)) prev.cats.push(res.slug);
        } else {
          it.cats = [res.slug];
          map.set(it.id, it);
        }
      }
    }
    const list = Array.from(map.values());
    list.forEach((it, i) => { it.seq = i; });
    return list;
  }

  /* ============================= FETCHING =========================== */

  async function fetchPages(slug) {
    const url = urlForSlug(slug);
    const out = [];
    const n = Math.max(1, Math.min(Number(settings.pages) || 1, MAX_PAGES));

    for (let p = 1; p <= n; p++) {
      const u = p === 1 ? url : url + '&page=' + p;
      const res = await fetchWithRetry(u, { credentials: 'include', headers: { 'Accept': 'text/html' } });
      if (!res.ok) throw new Error('HTTP ' + res.status);

      const html = await res.text();
      const doc = new DOMParser().parseFromString(html, 'text/html');
      if (isGate(doc)) {
        throw new Error('age-gate: open ' + SITE.name + ' in this browser and verify your age first');
      }

      const items = parseDoc(doc, slug);
      if (!items.length) break; // no cards -> last page or empty category
      out.push.apply(out, items);
      if (p < n) await sleep(settings.delayMs);
    }
    return out;
  }

  // Full category catalogue for the picker (cached in localStorage).
  async function loadCategoryList(force) {
    if (!force) {
      const cached = readJson(CAT_STORE_KEY);
      if (cached && Array.isArray(cached.items) && cached.items.length) {
        state.catList = cached.items;
        state.catListLoaded = true;
        return cached.items;
      }
    }
    const res = await fetchWithRetry(CATEGORY_LIST_URL, { credentials: 'include', headers: { 'Accept': 'text/html' } });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
    const items = parseCategoryList(doc);
    if (!items.length) throw new Error('no categories found on ' + CATEGORY_LIST_URL);
    state.catList = items;
    state.catListLoaded = true;
    try { localStorage.setItem(CAT_STORE_KEY, JSON.stringify({ ts: Date.now(), items: items })); } catch (e) { /* ignore */ }
    return items;
  }

  /* ============================= STATE ============================== */

  const state = {
    items: [],
    catCounts: new Map(),
    hidden: new Set(Array.isArray(settings.hidden) ? settings.hidden : []),
    query: '',
    sort: settings.sort || 'default',
    loading: false,
    errors: [],
    randomKey: null,
    selected: uniqueSlugs(settings.selected),
    decodeLinks: settings.decodeLinks !== false,
    blocked: String(settings.blockedSources || ''),
    catList: [],          // [{slug,name,count}] full catalogue
    catListLoaded: false,
    pickerOpen: false,
    pickerQuery: '',
    pending: null         // Set of slugs while the picker is open
  };

  function setSelection(slugs) {
    state.selected = uniqueSlugs(slugs);
    settings.selected = state.selected.slice();
    // forget "hidden" entries that are no longer selected
    for (const s of Array.from(state.hidden)) if (!state.selected.includes(s)) state.hidden.delete(s);
    settings.hidden = Array.from(state.hidden);
    saveSettings();
  }

  function blockedTerms() {
    return String(state.blocked || '').split(',')
      .map((s) => s.trim().toLowerCase())
      .filter(Boolean);
  }

  function visibleItems() {
    const q = state.query.trim().toLowerCase();
    const blocked = blockedTerms();
    let list = state.items.filter((it) => {
      const visibleCat = it.cats.some((c) => !state.hidden.has(c));
      if (!visibleCat) return false;
      if (blocked.length) {
        const src = (it.source || '').toLowerCase();
        if (blocked.some((b) => src.indexOf(b) !== -1)) return false;
      }
      if (!q) return true;
      return (it.title + ' ' + it.source + ' ' + (it.realHost || '')).toLowerCase().indexOf(q) !== -1;
    });

    switch (state.sort) {
      case 'score':
        list = list.slice().sort((a, b) => (b.scorePct === null ? -1 : b.scorePct) - (a.scorePct === null ? -1 : a.scorePct));
        break;
      case 'duration':
        list = list.slice().sort((a, b) => (b.durSec || -1) - (a.durSec || -1));
        break;
      case 'newest':
        list = list.slice().sort((a, b) => (a.ageDays === null ? Infinity : a.ageDays) - (b.ageDays === null ? Infinity : b.ageDays));
        break;
      case 'title':
        list = list.slice().sort((a, b) => a.title.localeCompare(b.title));
        break;
      case 'random':
        if (!state.randomKey) {
          state.randomKey = new Map();
          list.forEach((it) => state.randomKey.set(it.id, Math.random()));
        }
        list = list.slice().sort((a, b) => state.randomKey.get(a.id) - state.randomKey.get(b.id));
        break;
      default:
        list = list.slice().sort((a, b) => a.seq - b.seq);
    }
    return list;
  }

  /* ============================= STYLES ============================= */

  const STYLE = `
#ixmx-overlay { position: fixed; inset: 0; z-index: 2147483000; background: #0e0e10; color: #e4e4e7;
  overflow-y: auto; overflow-x: hidden; font-family: inherit; line-height: 1.4; }
#ixmx-overlay * { box-sizing: border-box; }
.ixmx-header { position: sticky; top: 0; z-index: 6; background: #18181b; border-bottom: 1px solid #2e2e33;
  padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; }
.ixmx-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ixmx-title { font-size: 17px; font-weight: 700; color: #fafafa; margin: 0; }
.ixmx-title small { font-weight: 400; color: #a1a1aa; font-size: 13px; }
.ixmx-btn { background: #27272a; color: #e4e4e7; border: 1px solid #3f3f46; border-radius: 6px;
  padding: 5px 12px; font-size: 13px; cursor: pointer; }
.ixmx-btn:hover { background: #3f3f46; }
.ixmx-btn:disabled { opacity: .5; cursor: default; }
.ixmx-btn.primary { background: #facc15; color: #18181b; border-color: #facc15; font-weight: 600; }
.ixmx-input, .ixmx-select { background: #0e0e10; color: #e4e4e7; border: 1px solid #3f3f46;
  border-radius: 6px; padding: 5px 10px; font-size: 13px; }
.ixmx-input { min-width: 200px; }
.ixmx-label { font-size: 12px; color: #a1a1aa; }
.ixmx-chips { display: flex; gap: 6px; flex-wrap: wrap; max-height: 96px; overflow-y: auto; }
.ixmx-chip { background: #27272a; border: 1px solid #3f3f46; color: #d4d4d8; border-radius: 999px;
  padding: 3px 10px; font-size: 12px; cursor: pointer; user-select: none; white-space: nowrap; }
.ixmx-chip:hover { border-color: #facc15; }
.ixmx-chip.off { opacity: .38; text-decoration: line-through; }
.ixmx-chip b { color: #facc15; font-weight: 600; }
.ixmx-status { font-size: 13px; color: #a1a1aa; }
.ixmx-bar { height: 3px; background: #27272a; border-radius: 2px; overflow: hidden; }
.ixmx-bar > i { display: block; height: 100%; width: 0; background: #facc15; transition: width .2s; }
.ixmx-errors { font-size: 12px; color: #f87171; white-space: pre-wrap; }
.ixmx-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 10px; padding: 14px 14px 60px; }
.ixmx-card { display: block; background: #18181b; border: 1px solid #27272a; border-radius: 8px;
  overflow: hidden; text-decoration: none; color: inherit; }
.ixmx-card:hover { border-color: #facc15; }
.ixmx-card:hover .ixmx-thumb img { opacity: .85; }
.ixmx-thumb { position: relative; padding-top: 56.25%; background: #000; }
.ixmx-thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.ixmx-badge { position: absolute; bottom: 4px; background: rgba(0,0,0,.72); color: #fff;
  font-size: 12px; padding: 1px 5px; border-radius: 3px; }
.ixmx-badge.left { left: 5px; color: #facc15; }
.ixmx-badge.right { right: 5px; }
.ixmx-badge.host { top: 4px; left: 5px; bottom: auto; color: #86bbff;
  max-width: calc(100% - 10px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ixmx-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #a1a1aa;
  cursor: pointer; user-select: none; }
.ixmx-check input { accent-color: #facc15; width: 14px; height: 14px; cursor: pointer; }
.ixmx-card h3 { font-size: 13.5px; font-weight: 500; margin: 0; padding: 7px 9px 2px;
  color: #f4f4f5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; line-height: 1.35; min-height: 2.7em; }
.ixmx-card p { font-size: 12px; color: #a1a1aa; margin: 0; padding: 0 9px 8px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ixmx-card p .ixmx-cats { color: #86bbff; }
.ixmx-empty { padding: 40px; text-align: center; color: #71717a; grid-column: 1 / -1; }

/* picker panel */
.ixmx-picker { position: fixed; left: 50%; transform: translateX(-50%); top: 76px; bottom: 70px;
  width: min(880px, calc(100vw - 32px)); z-index: 10; background: #18181b; border: 1px solid #3f3f46;
  border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.65); display: flex; flex-direction: column; overflow: hidden; }
.ixmx-picker[hidden] { display: none; }
.ixmx-picker-head { padding: 10px 12px; border-bottom: 1px solid #2e2e33; display: flex;
  flex-direction: column; gap: 8px; }
.ixmx-picker-list { flex: 1; overflow-y: auto; padding: 6px 4px; column-gap: 8px; }
.ixmx-cat-row { display: flex; align-items: center; gap: 8px; padding: 3px 10px; border-radius: 5px;
  font-size: 13px; cursor: pointer; }
.ixmx-cat-row:hover { background: #27272a; }
.ixmx-cat-row input { accent-color: #facc15; width: 15px; height: 15px; cursor: pointer; flex: none; }
.ixmx-cat-row .n { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ixmx-cat-row .c { color: #a1a1aa; font-size: 12px; flex: none; }
.ixmx-cat-row.default .n::before { content: '★ '; color: #facc15; }
.ixmx-cat-row.custom .n::before { content: '☆ '; color: #86bbff; }
.ixmx-picker-foot { padding: 10px 12px; border-top: 1px solid #2e2e33; display: flex; gap: 10px;
  align-items: center; background: #18181b; }
.ixmx-picker-foot .grow { flex: 1; }

/* trigger + /a-z page bar */
#ixmx-trigger { position: fixed; right: 18px; bottom: 18px; z-index: 2147482000; background: #facc15;
  color: #18181b; border: none; border-radius: 999px; padding: 11px 16px; font-size: 14px;
  font-weight: 700; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.5); }
#ixmx-trigger:hover { background: #fde047; }
.ixmx-cb { width: 15px; height: 15px; accent-color: #facc15; cursor: pointer; flex: none; margin: 0; }
#ixmx-azbar { position: fixed; left: 50%; transform: translateX(-50%); bottom: 14px; z-index: 2147482500;
  background: #18181b; border: 1px solid #3f3f46; border-radius: 999px; padding: 8px 14px;
  display: flex; gap: 10px; align-items: center; color: #e4e4e7; font-size: 14px;
  box-shadow: 0 10px 30px rgba(0,0,0,.6); }
`;

  /* ============================= OVERLAY ============================ */

  let overlay = null;
  let trigger = null;
  let pickerRows = null;      // Map slug -> row element
  let renderToken = 0;

  function buildPicker() {
    const p = el('div', 'ixmx-picker');
    p.id = 'ixmx-picker';
    p.hidden = true;

    const head = el('div', 'ixmx-picker-head');

    const row1 = el('div', 'ixmx-row');
    const search = el('input', 'ixmx-input');
    search.id = 'ixmx-picker-search';
    search.type = 'search';
    search.placeholder = 'Find a category …';
    search.style.flex = '1';
    search.addEventListener('input', () => {
      state.pickerQuery = search.value;
      filterPickerRows();
    });
    row1.appendChild(search);
    const reloadCats = el('button', 'ixmx-btn', 'Refresh list');
    reloadCats.addEventListener('click', async () => {
      reloadCats.disabled = true;
      reloadCats.textContent = 'Refreshing…';
      try {
        await loadCategoryList(true);
        renderPickerRows();
        filterPickerRows();
      } catch (e) {
        alert('Could not load the category list: ' + e.message);
      } finally {
        reloadCats.disabled = false;
        reloadCats.textContent = 'Refresh list';
      }
    });
    row1.appendChild(reloadCats);
    head.appendChild(row1);

    const row2 = el('div', 'ixmx-row');
    const mk = (label, fn) => {
      const b = el('button', 'ixmx-btn', label);
      b.addEventListener('click', fn);
      return b;
    };
    row2.appendChild(mk('Select all', () => { state.catList.forEach((c) => state.pending.add(c.slug)); syncPicker(); }));
    row2.appendChild(mk('Select none', () => { state.pending.clear(); syncPicker(); }));
    row2.appendChild(mk('Invert', () => {
      state.catList.forEach((c) => { state.pending.has(c.slug) ? state.pending.delete(c.slug) : state.pending.add(c.slug); });
      syncPicker();
    }));
    if (SITE_DEFAULTS.length) {
      row2.appendChild(mk('My ' + SITE_DEFAULTS.length + ' defaults', () => {
        state.pending = new Set(SITE_DEFAULTS);
        syncPicker();
      }));
      row2.appendChild(el('span', 'ixmx-label', '★ your original ' + SITE_DEFAULTS.length + ' · ☆ not on /a-z'));
    }
    head.appendChild(row2);

    // add any category slug by hand (covers categories missing from /a-z)
    const row3 = el('div', 'ixmx-row');
    row3.appendChild(el('span', 'ixmx-label', 'Add by slug'));
    const addInput = el('input', 'ixmx-input');
    addInput.id = 'ixmx-picker-add';
    addInput.type = 'text';
    addInput.placeholder = 'e.g. ' + (SITE.keyword === 'pornstar' ? 'mia-khalifa' : 'lace') + '  (name, slug or URL)';
    addInput.style.minWidth = '220px';
    const addBtn = el('button', 'ixmx-btn', 'Add');
    const doAdd = () => {
      const slug = normalizeSlug(addInput.value);
      if (!slug) { addInput.focus(); return; }
      addInput.value = '';
      state.pending.add(slug);
      if (pickerRows && !pickerRows.has(slug)) {
        const list = document.getElementById('ixmx-picker-list');
        if (list) list.insertBefore(makePickerRow(slug, prettify(slug), '', 'custom'), list.firstChild);
      }
      syncPicker();
      filterPickerRows();
      const row = pickerRows && pickerRows.get(slug);
      if (row && typeof row.scrollIntoView === 'function') row.scrollIntoView({ block: 'nearest' });
    };
    addBtn.addEventListener('click', doAdd);
    addInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doAdd(); } });
    row3.appendChild(addInput);
    row3.appendChild(addBtn);
    head.appendChild(row3);
    p.appendChild(head);

    const list = el('div', 'ixmx-picker-list');
    list.id = 'ixmx-picker-list';
    p.appendChild(list);

    const foot = el('div', 'ixmx-picker-foot');
    const count = el('span', 'grow ixmx-label');
    count.id = 'ixmx-picker-count';
    foot.appendChild(count);

    const cancel = el('button', 'ixmx-btn', 'Cancel');
    cancel.addEventListener('click', closePicker);
    foot.appendChild(cancel);

    const mix = el('button', 'ixmx-btn primary');
    mix.id = 'ixmx-picker-mix';
    mix.addEventListener('click', () => {
      const chosen = uniqueSlugs(state.pending ? Array.from(state.pending) : []);
      if (!chosen.length) { alert('Select at least one category.'); return; }
      if (chosen.length > BIG_SELECTION_WARN &&
          !confirm(chosen.length + ' categories are selected — that is a lot of videos to fetch. Continue?')) return;
      closePicker();
      setSelection(chosen);
      renderChips();
      runLoad();
    });
    foot.appendChild(mix);
    p.appendChild(foot);

    return p;
  }

  // kind: 'default' (one of the original 24), 'custom' (not on /a-z), or ''
  function makePickerRow(slug, name, count, kind) {
    const row = el('label', 'ixmx-cat-row' + (kind ? ' ' + kind : ''));
    row.dataset.slug = slug;
    row.dataset.search = (name + ' ' + slug).toLowerCase();
    if (kind === 'custom') row.title = 'Kept because you selected it, but it is not listed on /a-z';

    const cb = el('input');
    cb.type = 'checkbox';
    cb.checked = state.pending.has(slug);
    cb.addEventListener('change', () => {
      if (cb.checked) state.pending.add(slug); else state.pending.delete(slug);
      syncPicker();
    });
    row.appendChild(cb);
    row.appendChild(el('span', 'n', name));
    if (count) row.appendChild(el('span', 'c', count));
    pickerRows.set(slug, row);
    return row;
  }

  function renderPickerRows() {
    const list = document.getElementById('ixmx-picker-list');
    if (!list) return;
    pickerRows = new Map();
    const frag = document.createDocumentFragment();

    const cats = state.catList.slice().sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
    const defaults = new Set(SITE_DEFAULTS);
    const catalogue = new Set(state.catList.map((c) => c.slug));

    // Categories the user selected that the /a-z catalogue does not list.
    const custom = uniqueSlugs(Array.from(state.pending)).filter((s) => !catalogue.has(s));
    custom.forEach((slug) => frag.appendChild(makePickerRow(slug, prettify(slug), '', 'custom')));

    cats.forEach((c) => frag.appendChild(
      makePickerRow(c.slug, c.name, c.count, defaults.has(c.slug) ? 'default' : '')));

    list.replaceChildren(frag);
    syncPicker();
  }

  function filterPickerRows() {
    if (!pickerRows) return;
    const q = state.pickerQuery.trim().toLowerCase();
    pickerRows.forEach((row) => {
      row.style.display = (!q || row.dataset.search.indexOf(q) !== -1) ? '' : 'none';
    });
  }

  function syncPicker() {
    if (!pickerRows) return;
    let n = 0;
    pickerRows.forEach((row, slug) => {
      const cb = row.querySelector('input');
      const on = state.pending.has(slug);
      if (cb.checked !== on) cb.checked = on;
      if (on) n++;
    });
    const count = document.getElementById('ixmx-picker-count');
    const mix = document.getElementById('ixmx-picker-mix');
    if (count) count.textContent = n + ' categor' + (n === 1 ? 'y' : 'ies') + ' selected';
    if (mix) { mix.textContent = 'Mix selected (' + n + ')'; mix.disabled = n === 0; }
  }

  function openPicker() {
    if (!overlay) return;
    const p = document.getElementById('ixmx-picker');
    if (!p) return;
    state.pending = new Set(state.selected);
    state.pickerOpen = true;
    p.hidden = false;

    if (state.catListLoaded) {
      renderPickerRows();
      filterPickerRows();
      return;
    }
    const list = document.getElementById('ixmx-picker-list');
    if (list) list.textContent = 'Loading the full category list from /a-z …';
    loadCategoryList(false)
      .then(() => { renderPickerRows(); filterPickerRows(); const s = document.getElementById('ixmx-picker-search'); if (s) s.focus(); })
      .catch((e) => { if (list) list.textContent = 'Could not load categories: ' + e.message; });
  }

  function closePicker() {
    const p = document.getElementById('ixmx-picker');
    if (p) p.hidden = true;
    state.pickerOpen = false;
    state.pending = null;
  }

  function buildOverlay() {
    const ov = el('div');
    ov.id = 'ixmx-overlay';

    const header = el('div', 'ixmx-header');

    const row1 = el('div', 'ixmx-row');
    const title = el('div', 'ixmx-title');
    title.appendChild(document.createTextNode(SITE.name + ' Mix '));
    title.appendChild(el('small', '', '— selected categories in one page'));
    row1.appendChild(title);

    const spacer = el('span'); spacer.style.flex = '1'; row1.appendChild(spacer);

    const catsBtn = el('button', 'ixmx-btn');
    catsBtn.id = 'ixmx-cats-btn';
    catsBtn.textContent = 'Categories…';
    catsBtn.title = 'Choose which categories to merge (full list from /a-z)';
    catsBtn.addEventListener('click', () => (state.pickerOpen ? closePicker() : openPicker()));
    row1.appendChild(catsBtn);

    const reload = el('button', 'ixmx-btn primary', 'Reload');
    reload.id = 'ixmx-reload';
    reload.addEventListener('click', runLoad);
    row1.appendChild(reload);

    const closeBtn = el('button', 'ixmx-btn', '✕ Close');
    closeBtn.addEventListener('click', closeOverlay);
    row1.appendChild(closeBtn);
    header.appendChild(row1);

    const row2 = el('div', 'ixmx-row');
    const search = el('input', 'ixmx-input');
    search.type = 'search';
    search.placeholder = 'Filter loaded videos by title or source …';
    search.value = state.query;
    let deb = null;
    search.addEventListener('input', () => {
      state.query = search.value;
      clearTimeout(deb);
      deb = setTimeout(() => { renderGrid(); updateSummary(); }, 150);
    });
    row2.appendChild(search);

    row2.appendChild(el('span', 'ixmx-label', 'Sort'));
    const sortSel = el('select', 'ixmx-select');
    [['default', 'Category order'], ['newest', 'Newest'], ['score', 'Top rated %'],
     ['duration', 'Longest'], ['title', 'Title A–Z'], ['random', 'Random']
    ].forEach(([v, t]) => {
      const o = el('option', '', t); o.value = v;
      if (v === state.sort) o.selected = true;
      sortSel.appendChild(o);
    });
    sortSel.addEventListener('change', () => {
      state.sort = sortSel.value;
      settings.sort = state.sort;
      if (state.sort === 'random') state.randomKey = null;
      saveSettings(); renderGrid(); updateSummary();
    });
    row2.appendChild(sortSel);

    row2.appendChild(el('span', 'ixmx-label', 'Pages per category'));
    const pagesInput = el('input', 'ixmx-input');
    pagesInput.type = 'number'; pagesInput.min = '1'; pagesInput.max = String(MAX_PAGES);
    pagesInput.value = String(settings.pages);
    pagesInput.style.minWidth = '60px';
    pagesInput.addEventListener('change', () => {
      const v = Math.max(1, Math.min(MAX_PAGES, Number(pagesInput.value) || 1));
      pagesInput.value = String(v);
      if (v !== settings.pages) { settings.pages = v; saveSettings(); runLoad(); }
    });
    row2.appendChild(pagesInput);

    const summary = el('span', 'ixmx-status');
    summary.id = 'ixmx-summary';
    summary.style.marginLeft = 'auto';
    row2.appendChild(summary);
    header.appendChild(row2);

    // row: link decoding + source blocking
    const row2b = el('div', 'ixmx-row');
    const directWrap = el('label', 'ixmx-check');
    const direct = el('input');
    direct.type = 'checkbox';
    direct.id = 'ixmx-direct';
    direct.checked = state.decodeLinks;
    direct.addEventListener('change', () => {
      state.decodeLinks = direct.checked;
      settings.decodeLinks = direct.checked;
      saveSettings();
      if (direct.checked) startPageDecoder();
      else { stopPageDecoder(); revertPageLinks(); }
      renderGrid();
    });
    directWrap.appendChild(direct);
    directWrap.appendChild(document.createTextNode('Open the real video URL (decode /out/ links)'));
    directWrap.title = 'On: links across the whole site point straight to the destination. Off: they use the site’s /out/ redirect.';
    row2b.appendChild(directWrap);

    row2b.appendChild(el('span', 'ixmx-label', 'Hide sources containing'));
    const blockInput = el('input', 'ixmx-input');
    blockInput.id = 'ixmx-block';
    blockInput.type = 'text';
    blockInput.placeholder = 'comma separated, e.g. faphouse, teamskeet';
    blockInput.value = state.blocked;
    blockInput.style.minWidth = '240px';
    const applyBlock = () => {
      state.blocked = blockInput.value;
      settings.blockedSources = blockInput.value;
      saveSettings();
      renderGrid();
      updateSummary();
    };
    let blockDeb = null;
    blockInput.addEventListener('input', () => { clearTimeout(blockDeb); blockDeb = setTimeout(applyBlock, 250); });
    blockInput.addEventListener('change', applyBlock);
    row2b.appendChild(blockInput);
    header.appendChild(row2b);

    const status = el('div', 'ixmx-status');
    status.id = 'ixmx-status';
    header.appendChild(status);
    const bar = el('div', 'ixmx-bar');
    const fill = el('i'); fill.id = 'ixmx-bar-fill';
    bar.appendChild(fill);
    header.appendChild(bar);

    const chips = el('div', 'ixmx-chips');
    chips.id = 'ixmx-chips';
    header.appendChild(chips);

    const errors = el('div', 'ixmx-errors');
    errors.id = 'ixmx-errors';
    header.appendChild(errors);

    ov.appendChild(header);
    ov.appendChild(buildPicker());

    const grid = el('div', 'ixmx-grid');
    grid.id = 'ixmx-grid';
    ov.appendChild(grid);

    return ov;
  }

  function renderChips() {
    const box = document.getElementById('ixmx-chips');
    if (!box) return;
    box.textContent = '';
    state.selected.forEach((slug) => {
      const chip = el('button', 'ixmx-chip');
      if (state.hidden.has(slug)) chip.classList.add('off');
      const count = state.catCounts.has(slug) ? state.catCounts.get(slug) : '';
      chip.appendChild(document.createTextNode('#' + slug + (count === '' ? '' : ' ')));
      if (count !== '') chip.appendChild(el('b', '', String(count)));
      chip.title = (count === '' ? '' : count + ' videos from this category — ') + 'click to show/hide';
      chip.addEventListener('click', () => {
        if (state.hidden.has(slug)) state.hidden.delete(slug);
        else state.hidden.add(slug);
        settings.hidden = Array.from(state.hidden);
        saveSettings();
        renderChips(); renderGrid(); updateSummary();
      });
      box.appendChild(chip);
    });
  }

  function cardNode(it) {
    const a = el('a', 'ixmx-card');
    const direct = state.decodeLinks && it.real;
    a.href = direct ? it.real : it.href;
    a.target = '_blank';
    a.rel = 'nofollow noopener noreferrer';
    a.title = direct ? it.title + '\n→ ' + it.real : it.title;
    if (direct) {
      a.dataset.linkDecoded = 'true';            // tells the Link Decoder scripts to skip it
      a.dataset.realUrl = it.real;
      a.dataset.outUrl = it.href;
    }

    const th = el('div', 'ixmx-thumb');
    if (it.thumb) {
      const img = el('img');
      img.src = it.thumb;
      img.alt = '';
      img.loading = 'lazy';
      th.appendChild(img);
    }
    if (it.qual) th.appendChild(el('span', 'ixmx-badge left', it.qual));
    if (it.dur) th.appendChild(el('span', 'ixmx-badge right', it.dur));
    if (direct && it.realHost) th.appendChild(el('span', 'ixmx-badge host', '↗ ' + it.realHost));
    a.appendChild(th);

    a.appendChild(el('h3', '', it.title));

    const sub = el('p');
    const bits = [];
    if (it.source) bits.push(it.source);
    if (it.age) bits.push(it.age);
    if (it.score) bits.push(it.score);
    sub.appendChild(document.createTextNode(bits.join(' · ')));
    sub.appendChild(document.createTextNode(bits.length ? '  ' : ''));
    sub.appendChild(el('span', 'ixmx-cats',
      it.cats.slice(0, 3).map((c) => '#' + c).join(' ') + (it.cats.length > 3 ? ' +' + (it.cats.length - 3) : '')));
    a.appendChild(sub);

    return a;
  }

  // Chunked rendering keeps very large selections responsive.
  function renderGrid() {
    const grid = document.getElementById('ixmx-grid');
    if (!grid) return;
    const list = visibleItems();
    const token = ++renderToken;
    grid.replaceChildren();

    if (!list.length) {
      grid.appendChild(el('div', 'ixmx-empty',
        state.items.length ? 'No videos match the current filter.' : 'Choose categories and press Mix / Reload.'));
      return;
    }

    const CHUNK = 400;
    let i = 0;
    (function step() {
      if (token !== renderToken) return;
      const frag = document.createDocumentFragment();
      const end = Math.min(i + CHUNK, list.length);
      for (; i < end; i++) frag.appendChild(cardNode(list[i]));
      grid.appendChild(frag);
      if (i < list.length) requestAnimationFrame(step);
    })();
  }

  function updateSummary() {
    const s = document.getElementById('ixmx-summary');
    if (!s) return;
    const showing = visibleItems().length;
    const active = state.selected.filter((c) => !state.hidden.has(c)).length;
    s.textContent = fmt(showing) + ' of ' + fmt(state.items.length) +
      ' videos · ' + active + '/' + state.selected.length + ' categories';
    const catsBtn = document.getElementById('ixmx-cats-btn');
    if (catsBtn) catsBtn.textContent = 'Categories (' + state.selected.length + ')…';
  }

  function setStatus(text, pct) {
    const s = document.getElementById('ixmx-status');
    if (s) s.textContent = text;
    const fill = document.getElementById('ixmx-bar-fill');
    if (fill) fill.style.width = (pct || 0) + '%';
  }

  function setErrors(errs) {
    const box = document.getElementById('ixmx-errors');
    if (!box) return;
    box.textContent = errs.length
      ? 'Problems: ' + errs.map((e) => e.slug + ' → ' + e.error).join(' | ')
      : '';
  }

  /* ============================= LOAD =============================== */

  async function runLoad() {
    if (state.loading) return;
    const slugs = state.selected.slice();
    if (!slugs.length) { openPicker(); return; }

    state.loading = true;
    throttleCount = 0;
    const reloadBtn = document.getElementById('ixmx-reload');
    if (reloadBtn) reloadBtn.disabled = true;
    setStatus('Loading 0/' + slugs.length + ' categories …', 0);
    setErrors([]);

    try {
      const queue = slugs.map((slug, i) => ({ slug: slug, i: i }));
      const results = [];
      let done = 0;

      const workers = Array.from({ length: Math.max(1, settings.concurrency) }, async () => {
        while (queue.length) {
          const job = queue.shift();
          let res;
          try {
            res = { i: job.i, slug: job.slug, items: await fetchPages(job.slug), error: null };
          } catch (e) {
            res = { i: job.i, slug: job.slug, items: [], error: (e && e.message) || String(e) };
          }
          results.push(res);
          done++;
          setStatus('Loading ' + done + '/' + slugs.length + ' categories …', Math.round(done / slugs.length * 100));
          await sleep(settings.delayMs);
        }
      });
      await Promise.all(workers);

      results.sort((a, b) => a.i - b.i);
      state.catCounts = new Map(results.map((r) => [r.slug, r.items.length]));
      state.errors = results.filter((r) => r.error);
      state.randomKey = null;
      state.items = mergeCategoryResults(results);

      renderChips();
      renderGrid();
      updateSummary();
      setErrors(state.errors);
      const throttleNote = throttleCount > 0
        ? ' (rate-limited ' + throttleCount + '× — retried automatically)'
        : '';
      setStatus('Done — ' + results.filter((r) => !r.error).length + '/' + slugs.length +
        ' categories merged.' + throttleNote, 100);
    } finally {
      state.loading = false;
      const btn = document.getElementById('ixmx-reload');
      if (btn) btn.disabled = false;
    }
  }

  /* ============================= OPEN / CLOSE ======================= */

  function openOverlay() {
    if (overlay) { overlay.style.display = ''; return; }
    if (!document.getElementById('ixmx-style')) {
      const style = document.createElement('style');
      style.id = 'ixmx-style';
      style.textContent = STYLE;
      document.head.appendChild(style);
    }
    overlay = buildOverlay();
    document.body.appendChild(overlay);
    if (trigger) trigger.style.display = 'none';
    renderChips();
    renderGrid();
    updateSummary();
    runLoad();
  }

  function closeOverlay() {
    if (overlay) { overlay.remove(); overlay = null; }
    if (trigger) trigger.style.display = '';
  }

  /* ===================== CHECKBOXES ON THE /a-z PAGE ================ */
  // Adds a checkbox to every category row on the /a-z page and a
  // sticky bottom bar with a "Mix selected" button — same result as the picker.

  function updateAzBar() {
    const n = document.querySelectorAll('.ixmx-cb:checked').length;
    const span = document.getElementById('ixmx-azcount');
    if (span) span.textContent = n + ' selected';
    const mix = document.getElementById('ixmx-azmix');
    if (mix) mix.disabled = n === 0;
  }

  function collectAzSelection() {
    const out = [];
    document.querySelectorAll('li.category .ixmx-cb:checked').forEach((cb) => {
      const li = cb.closest('li.category');
      if (li && li.dataset.slug) out.push(li.dataset.slug);
    });
    return uniqueSlugs(out);
  }

  // Make a row behave like the site's own rows: checkbox on the left, link filling the rest.
  function applyRowLayout(li, a) {
    li.style.display = 'flex';
    li.style.alignItems = 'center';
    li.style.gap = '7px';
    a.style.flex = '1';
    a.style.minWidth = '0';
  }

  function makeCheckbox(slug, name) {
    const cb = el('input', 'ixmx-cb');
    cb.type = 'checkbox';
    cb.checked = state.selected.includes(slug);
    cb.title = 'Select "' + name + '" for mixing';
    cb.addEventListener('change', updateAzBar);
    cb.addEventListener('click', (e) => e.stopPropagation());
    return cb;
  }

  // A full row matching the site's markup, used for categories that exist but
  // are not listed on /a-z (the page does not list every category).
  function makeCategoryRow(slug, name, checked) {
    const li = el('li', 'category px-2 py-1 w-full break-inside-avoid hover:bg-zinc-200 dark:hover:bg-zinc-800 rounded-sm');
    li.dataset.slug = slug;
    li.dataset.custom = '1';
    li.setAttribute('data-title', name);

    const cb = makeCheckbox(slug, name);
    if (checked !== undefined) cb.checked = checked;
    li.appendChild(cb);

    const a = el('a', 'anchor-link flex gap-1 justify-between');
    a.href = catBase + slug + '?' + PRICING;
    const left = el('span', 'flex gap-1 justify-center items-center');
    left.appendChild(el('span', 'category-title', name));
    a.appendChild(left);
    let count = '';
    if (state.catListLoaded) {
      const found = state.catList.find((c) => c.slug === slug);
      if (found && found.count) count = found.count;
    }
    a.appendChild(el('span', 'badge badge-text text-xsm bg-transparent text-zinc-600 dark:text-zinc-300',
      count || '☆'));
    li.appendChild(a);

    applyRowLayout(li, a);
    return li;
  }

  // Categories selected by the user that /a-z does not list get their own group
  // at the top, so "Mix selected" on this page keeps the whole selection.
  function injectMissingExtras() {
    const nativeLis = document.querySelectorAll('li.category:not([data-custom])');
    if (!nativeLis.length) return;

    const nativeSlugs = new Set();
    nativeLis.forEach((li) => { if (li.dataset.slug) nativeSlugs.add(li.dataset.slug); });
    const missing = uniqueSlugs(state.selected).filter((s) => !nativeSlugs.has(s));

    const existing = document.getElementById('ixmx-extras-list');
    if (!missing.length) {
      if (existing) {
        const group = existing.closest('.ixmx-extras-group');
        if (group) group.remove();
      }
      return;
    }

    if (existing) {
      // preserve anything the user unchecked/checked before re-injecting
      const stateInDom = new Map();
      existing.querySelectorAll(':scope > li[data-custom]').forEach((li) => {
        const cb = li.querySelector('.ixmx-cb');
        if (cb) stateInDom.set(li.dataset.slug, cb.checked);
      });
      existing.replaceChildren.apply(existing, missing.map((slug) =>
        makeCategoryRow(slug, prettify(slug),
          stateInDom.has(slug) ? stateInDom.get(slug) : state.selected.includes(slug))));
      return;
    }

    const ul = el('ul', 'category-group columns-4 mobile:columns-1 gap-x-2');
    ul.id = 'ixmx-extras-list';
    missing.forEach((slug) => ul.appendChild(makeCategoryRow(slug, prettify(slug))));

    const inner = el('div', 'flex flex-col gap-2 px-2 py-6 mobile:px-0 mobile:py-4 border-b border-zinc-200 dark:border-zinc-800');
    const heading = el('a', 'font-bold category-sidebar-header m-0 text-lg size-10 items-center justify-center bg-zinc-200 dark:bg-zinc-800 rounded-md flex', '☆');
    heading.href = '#';
    heading.title = 'Categories you selected that are not listed on this page';
    heading.addEventListener('click', (e) => e.preventDefault());
    inner.appendChild(heading);
    inner.appendChild(ul);

    const group = el('div', 'category-group-container ixmx-extras-group');
    group.appendChild(inner);

    const first = document.querySelector('.category-group-container');
    if (first && first.parentNode) first.parentNode.insertBefore(group, first);
    else document.body.appendChild(group);
  }

  function injectCategoryPage() {
    const lis = document.querySelectorAll('li.category:not([data-custom])');
    if (!lis.length) return;

    refreshCatBase();

    lis.forEach((li) => {
      if (li.querySelector(':scope > .ixmx-cb')) return;
      const a = li.querySelector('a.anchor-link[href]') || li.querySelector('a[href]');
      if (!a) return;
      const slug = slugOf(a.getAttribute('href'));
      const name = li.getAttribute('data-title') || slug;
      li.dataset.slug = slug;
      applyRowLayout(li, a);
      li.insertBefore(makeCheckbox(slug, name), li.firstChild);
    });

    injectMissingExtras();

    if (document.getElementById('ixmx-azbar')) { updateAzBar(); return; }

    const bar = el('div');
    bar.id = 'ixmx-azbar';
    const count = el('span');
    count.id = 'ixmx-azcount';
    bar.appendChild(count);

    const mix = el('button', 'ixmx-btn primary');
    mix.id = 'ixmx-azmix';
    mix.textContent = 'Mix selected';
    mix.addEventListener('click', () => {
      const chosen = collectAzSelection();
      if (!chosen.length) return;
      setSelection(chosen);
      if (!overlay) openOverlay(); else runLoad();
    });
    bar.appendChild(mix);

    const clear = el('button', 'ixmx-btn', 'Clear');
    clear.addEventListener('click', () => {
      document.querySelectorAll('.ixmx-cb').forEach((cb) => { cb.checked = false; });
      updateAzBar();
    });
    bar.appendChild(clear);

    const hint = el('span', 'ixmx-label', 'or pick with “⧉ Mix categories”');
    bar.appendChild(hint);

    document.body.appendChild(bar);
    updateAzBar();
  }

  /* ================= PAGE-WIDE /out/ LINK DECODING ================== */
  // The mix overlay decodes the links of its own cards. This does the same for
  // the site's regular video links (/out/?l=…) so ordinary browsing skips the
  // redirect hop too. It re-scans as cards are added (infinite scroll, filters).

  const OUT_LINK_SELECTOR = 'a[href*="/out/"]';
  let pageDecodeObserver = null;
  let pageDecodeTimer = null;

  function decodeOnePageLink(a) {
    if (!a || a.dataset.ixmxDecoded) return false;
    const raw = a.getAttribute('href') || '';
    if (raw.indexOf('/out/') === -1) return false;
    const real = decodeOutUrl(raw);
    if (!real) return false;
    a.dataset.ixmxOut = raw;          // keep the original so we can revert
    a.dataset.ixmxDecoded = '1';
    a.dataset.linkDecoded = 'true';   // same convention as the mix cards
    a.href = real;
    return true;
  }

  function decodePageLinks(root) {
    if (!settings.decodeLinks) return 0;
    const scope = root || document;
    let n = 0;
    if (scope.nodeType === 1 && scope.matches && scope.matches(OUT_LINK_SELECTOR)) {
      if (!scope.closest('#ixmx-overlay') && decodeOnePageLink(scope)) n++;
    }
    if (scope.querySelectorAll) {
      scope.querySelectorAll(OUT_LINK_SELECTOR).forEach((a) => {
        if (a.closest('#ixmx-overlay')) return; // the overlay decodes its own cards
        if (decodeOnePageLink(a)) n++;
      });
    }
    return n;
  }

  function revertPageLinks() {
    document.querySelectorAll('a[data-ixmx-decoded]').forEach((a) => {
      const raw = a.dataset.ixmxOut;
      if (raw) a.setAttribute('href', raw);
      delete a.dataset.ixmxDecoded;
      delete a.dataset.ixmxOut;
      if (a.dataset.linkDecoded === 'true') delete a.dataset.linkDecoded;
    });
  }

  function startPageDecoder() {
    if (pageDecodeObserver) return;
    decodePageLinks(document);
    if (!document.body) return;
    pageDecodeObserver = new MutationObserver((muts) => {
      let hit = false;
      for (const m of muts) {
        for (const node of m.addedNodes) {
          if (node.nodeType !== 1) continue;
          if ((node.matches && node.matches(OUT_LINK_SELECTOR)) ||
              (node.querySelector && node.querySelector(OUT_LINK_SELECTOR))) { hit = true; break; }
        }
        if (hit) break;
      }
      if (!hit || pageDecodeTimer) return;
      // batch bursts (a whole page of lazily-added cards) into one pass
      pageDecodeTimer = setTimeout(() => {
        pageDecodeTimer = null;
        decodePageLinks(document);
      }, 120);
    });
    pageDecodeObserver.observe(document.body, { childList: true, subtree: true });
  }

  function stopPageDecoder() {
    if (pageDecodeObserver) { pageDecodeObserver.disconnect(); pageDecodeObserver = null; }
    if (pageDecodeTimer) { clearTimeout(pageDecodeTimer); pageDecodeTimer = null; }
  }

  /* ============================= INIT ============================== */

  function init() {
    if (window.top !== window.self) return; // top frame only
    if (!document.getElementById('ixmx-style')) {
      const style = document.createElement('style');
      style.id = 'ixmx-style';
      style.textContent = STYLE;
      document.head.appendChild(style);
    }

    // floating trigger on every page (on /a-z it sits above the Mix bar)
    if (!document.getElementById('ixmx-trigger')) {
      const onAz = /(^|\/)a-z\/?$/.test(location.pathname);
      trigger = el('button', '', '⧉ Mix categories');
      trigger.id = 'ixmx-trigger';
      trigger.title = onAz
        ? 'Open the picker to choose categories from the full catalogue'
        : 'Merge the selected categories into one grid';
      if (onAz) trigger.style.bottom = '74px';
      trigger.addEventListener('click', openOverlay);
      document.body.appendChild(trigger);
    }

    // checkboxes + Mix bar on the categories page
    if (/(^|\/)a-z\/?$/.test(location.pathname)) {
      injectCategoryPage();
      // the page may render rows progressively
      setTimeout(injectCategoryPage, 800);
      setTimeout(injectCategoryPage, 2500);
    }

    // rewrite the site's own /out/ video links to the real destinations
    if (settings.decodeLinks) startPageDecoder();
  }

  init();

  // Test/debug hook (harmless in normal use).
  globalThis.__IXMX__ = {
    ORIGIN: ORIGIN,
    SITE: SITE,
    NETWORK: NETWORK,
    DEFAULT_SLUGS: DEFAULT_SLUGS,
    SITE_DEFAULTS: SITE_DEFAULTS,
    getCatBase: () => catBase,
    setCatBase: setCatBase,
    detectCatBase: detectCatBase,
    refreshCatBase: refreshCatBase,
    baseFromHref: baseFromHref,
    urlForSlug: urlForSlug,
    fetchWithRetry: fetchWithRetry,
    backoffMs: backoffMs,
    PRICING: PRICING,
    getThrottle: () => ({ until: throttleUntil, count: throttleCount }),
    decodePageLinks: decodePageLinks,
    revertPageLinks: revertPageLinks,
    decodeOnePageLink: decodeOnePageLink,
    startPageDecoder: startPageDecoder,
    stopPageDecoder: stopPageDecoder,
    parseDoc: parseDoc,
    parseCategoryList: parseCategoryList,
    parseAge: parseAge,
    toSeconds: toSeconds,
    isGate: isGate,
    mergeCategoryResults: mergeCategoryResults,
    slugOf: slugOf,
    uniqueSlugs: uniqueSlugs,
    prettify: prettify,
    normalizeSlug: normalizeSlug,
    fmt: fmt,
    decodeOutUrl: decodeOutUrl,
    hostOf: hostOf
  };
})();