☰

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.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

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

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

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

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

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

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

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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
  };
})();