Sleazy Fork is available in English.
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.
// ==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
};
})();