Unified: Smart Search, Parallel CDN, Mixer, Trending, Curated, Background Preloading
// ==UserScript==
// @name Coomer Explore
// @namespace http://tampermonkey.net/
// @version 3.2.1
// @description Unified: Smart Search, Parallel CDN, Mixer, Trending, Curated, Background Preloading
// @author Hky
// @match https://coomer.st/*
// @grant GM_xmlhttpRequest
// @license MIT (Non-commercial use only PLS)
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @connect coomer.st
// @connect bin.lysator.liu.se
// @connect *
// @grant GM_getResourceText
// @run-at document-idle
// ==/UserScript==
// =========================================================================
// DISCOVERY CONFIG & DATA
// =========================================================================
// ATresource creators_v2 creators_v2.json <-- add this at the top
const DISCO_CFG = {
baseUrl: window.location.origin || 'https://coomer.st',
ratio: GM_getValue('disco_ratio', 70),
};
const TAG_CATEGORIES = [
{ id: 'bodyparts', label: 'Body Parts', tags: ['ass', 'asshole', 'bigass', 'fatass', 'bigbutt', 'booty', 'butthole', 'butt', 'doggystyle', 'bigbooty', 'perfectass', 'sexyass', 'bigasss', 'assshaking', 'assbig', 'breasts', 'tits', 'boobs', 'bigtits', 'bigboobs', 'smalltits', 'smallboobs', 'hugetits', 'hugeboobs', 'naturaltits', 'titties', 'boobies', 'nipples', 'bignipples', 'nipple', 'legs', 'longlegs', 'thighs', 'thickthighs', 'feet', 'foot', 'toes', 'soles', 'footfetish', 'feetfetish', 'footfetishnation', 'pussy', 'wetpussy', 'pinkpussy', 'tightpussy', 'hairypussy', 'shavedpussy', 'perfectpussy', 'creamypussy', 'juicypussy', 'pussylips', 'closeuppussy', 'openpussy', 'bigpussy', 'pussywet', 'pussyrubbing', 'pussyplay', 'pussyeating', 'ebonypussy', 'vagina', 'bubblebutt', 'oil', 'milk'] },
{ id: 'bodytypes', label: 'Body Types', tags: ['petite', 'tiny', 'slim', 'skinny', 'thick', 'thicc', 'curvy', 'bbw', 'chubby', 'fit', 'model', 'babe', 'petitegirl', 'cutegirl', 'curvygirl', 'smallgirl', 'whitegirl', 'pawg', 'curvybigass', 'natural', 'naturalbody', 'perfectbody'] },
{ id: 'agerole', label: 'Age / Role', tags: ['teen', 'young', 'barelylegal', 'college', 'student', 'girlnextdoor', 'schoolgirl', 'schoolgirls', 'milf', 'milfs', 'mommy', 'stepmom', 'stepmommy', 'stepsister', 'stepdaughter', 'cougar', 'mature', 'hotwife', 'hotgirl', 'hotmom', 'sexymilf', 'hotmilf', 'hornymilf', 'collegegirl', 'naughtygirl', 'daddysgirl', 'mom', 'wife', 'girl', 'virgin', 'sister', 'sisters'] },
{ id: 'hairethnicity', label: 'Hair / Ethnicity', tags: ['blonde', 'blond', 'brunette', 'redhead', 'ginger', 'redhair', 'latina', 'latinas', 'asian', 'asiangirl', 'ebony', 'japanese', 'indian', 'arab', 'colombiana', 'colombian', 'colombianas', 'colombia', 'blondegirl', 'lesbianas', 'anime', 'hentai', 'vtuber', 'waifu', 'catgirl', 'ahegao'] },
{ id: 'sexualacts', label: 'Sexual Acts', tags: ['oral', 'blowjob', 'bj', 'deepthroat', 'handjob', 'sloppyblowjob', 'sucking', 'anal', 'analplay', 'analsex', 'analfuck', 'analplug', 'buttplug', 'insertions', 'doggystyle', 'doggy', 'solo', 'masturbation', 'masturbating', 'masturbate', 'fingering', 'soloplay', 'solomasturbation', 'cum', 'cumshot', 'cumming', 'cumslut', 'cumsluts', 'creampie', 'squirt', 'squirting', 'squirter', 'squirts', 'squirtingorgasm', 'multisquirt', 'orgasm', 'femaleorgasm', 'sex', 'fuck', 'fucking', 'fuckme', 'fuckpussy', 'cowgirl', 'riding', 'ride', 'threesome', 'doublepenetration', 'roughsex', 'hardsex', 'hardcore', 'striptease', 'dildofucking', 'ridedildo', 'sensual', 'dirtytalk', 'moaning', 'tease', 'exhibitionism', 'publicsex', 'publicmasturbation', 'publicflash', 'publicshow', 'voyeur', 'realsex'] },
{ id: 'sextoys', label: 'Sex Toys & Objects', tags: ['toys', 'dildo', 'toy', 'sextoy', 'vibrator', 'strapon', 'machine', 'fuckmachine', 'fuckingmachine', 'sexmachine', 'lovense'] },
{ id: 'fetish', label: 'Fetish & Roleplay', tags: ['bdsm', 'femdom', 'dominatrix', 'submissive', 'bondage', 'roleplay', 'roleplays', 'fetish', 'fetishes', 'footfetish', 'feetfetish', 'footfetishnation', 'public', 'ddlg', 'findom', 'sph', 'bbc', 'bigcock', 'bigdick', 'cuckold', 'slave', 'whore'] },
{ id: 'style', label: 'Style & Aesthetics', tags: ['alt', 'goth', 'altgirl', 'gothgirl', 'emo', 'tattoo', 'tattoos', 'tattooed', 'inked', 'tatted', 'tattoogirl', 'cosplay', 'cosplayer', 'cosplaygirl', 'lingerie', 'stockings', 'fishnets', 'latex', 'pantyhose', 'thong', 'panties', 'bikini', 'glasses', 'upskirt'] },
{ id: 'platform', label: 'Platform & Creator Type', tags: ['amateur', 'homemade', 'natural', 'allnatural', 'pro', 'model', 'onlyfans', 'fansly', 'fanslymodel', 'tiktok', 'tiktok18', 'tiktokporn', 'tiktokxxx', 'tiktoksexy', 'pornhub', 'xvideos', 'camsoda', 'camsodagirl', 'camsodalive', 'stripchat', 'chaturbate', 'webcam', 'camgirl', 'pornstar', 'fastfap', 'instagram', 'snapchat', 'online', 'homevideo', 'latinascamsoda'] },
{ id: 'general', label: 'General Descriptors', tags: ['sexy', 'hot', 'cute', 'beautiful', 'pretty', 'nude', 'naked', 'topless', 'nakedgirl', 'slut', 'slutty', 'kinky', 'kink', 'naughty', 'lewd', 'erotic', 'public', 'horny', 'hornygirl', 'wet', 'creamy', 'dirty', 'dance', 'twerk', 'twerking', 'oil', 'couple', 'outdoor', 'pov', 'lesbian', 'lesbians', 'trans', 'transgirl', 'porn', 'shower', 'gym'] }
];
const MATCHED_CREATORS_RAW = `
fansly:399542742671175680
fansly:492216456952426496
fansly:582967323984404480
fansly:704552506134372353
fansly:725100158009749504
onlyfans:addison.riley
onlyfans:adeenacherry
onlyfans:akemyni
onlyfans:alexis_de_vip
onlyfans:alexis_deep
onlyfans:alexisdemie
onlyfans:alexisdeveaux
onlyfans:alina_cherry1
onlyfans:alina_cherry7
onlyfans:amalilith
onlyfans:amy_quinn
onlyfans:anaischerryl
onlyfans:andreaocean
onlyfans:angy.cherry
onlyfans:anika_cherry
onlyfans:animebecca
onlyfans:anna_cherry77
onlyfans:annacherry1
onlyfans:annachherry
onlyfans:anny_bunnyy
onlyfans:aria_brooks
onlyfans:ariahix
onlyfans:ariana.gomez
onlyfans:ariasix
onlyfans:ariaxbrooks
onlyfans:aritabrooks
onlyfans:ashleyswfree
onlyfans:ashleyve_free
onlyfans:asia.bby
onlyfans:asialynnbby
onlyfans:asianbarby
onlyfans:asianbbyari
onlyfans:asianbbyboi
onlyfans:asiann.babyy
onlyfans:asiannbabyy
onlyfans:avacherrrry
onlyfans:bella-th
onlyfans:bella_hotx
onlyfans:bella_tx
onlyfans:bellahale
onlyfans:bellahotx
onlyfans:bellashyx
onlyfans:cardi-i
onlyfans:carili
onlyfans:carlamrtz
onlyfans:carmii
onlyfans:carolmarthy
onlyfans:carolts
onlyfans:cassie_babe
onlyfans:cassie_bale
onlyfans:cassiemaee
onlyfans:celinecinnamon
onlyfans:celinecinnamon2
onlyfans:chloe_skin
onlyfans:christalsskin
onlyfans:cristal-kinky
onlyfans:crystal_conners
onlyfans:danner1.0
onlyfans:dannye18
onlyfans:dari_ki
onlyfans:di.foxy
onlyfans:djarii
onlyfans:dunnybunny
onlyfans:dvalentinaxo
onlyfans:ella.cherry
onlyfans:elliehopps
onlyfans:emily.miller
onlyfans:emilyhill
onlyfans:emilywillis18
onlyfans:emilywills
onlyfans:emma_fairy
onlyfans:emmaacd
onlyfans:emmalayne
onlyfans:eva_charming
onlyfans:eva_fay
onlyfans:eva_fiery
onlyfans:eva_rouge
onlyfans:everougex
onlyfans:fitrebecca
onlyfans:funnyy_bunnyy
onlyfans:gunbunnyy
onlyfans:hellokinky2
onlyfans:hina_wayne
onlyfans:hollysecret
onlyfans:hunny_bunny_u
onlyfans:hunnybunny5
onlyfans:hunnybunnyb
onlyfans:i.am.ellie
onlyfans:iam.becca
onlyfans:iamiel
onlyfans:iamthylilith
onlyfans:illnanaa
onlyfans:irinnnaa
onlyfans:islandjade
onlyfans:itsscinnamon
onlyfans:jasi.bae
onlyfans:juicy_katie
onlyfans:karilix
onlyfans:karina_ocean
onlyfans:katescutiies
onlyfans:keittyfree
onlyfans:kitty_dream
onlyfans:kittybread
onlyfans:lanahcherry03
onlyfans:liamei
onlyfans:lianna_na_na
onlyfans:lil_amelia
onlyfans:lil_kittyy
onlyfans:lila.cherry
onlyfans:lilvkitty
onlyfans:lily_lovely_girl
onlyfans:linacherryy
onlyfans:lisedevine
onlyfans:littlekaitt
onlyfans:littlekitti
onlyfans:loivelygirl
onlyfans:lovelyegirl
onlyfans:lovelygirl17
onlyfans:luhasianbby
onlyfans:maddie-grey
onlyfans:maddie.grey
onlyfans:maddiegrey
onlyfans:maddisonspage
onlyfans:madisonginley
onlyfans:madisongray7
onlyfans:madisongrey69
onlyfans:madisongreyts
onlyfans:miachan
onlyfans:mialilith
onlyfans:milky.mei
onlyfans:milky_mei
onlyfans:mimifoxy
onlyfans:misaki_meii
onlyfans:mmarii
onlyfans:moanaocean
onlyfans:moni_kaa
onlyfans:monika_clay
onlyfans:monikaclay
onlyfans:monikastray
onlyfans:moondays
onlyfans:mystic_kitty2.0
onlyfans:mystic_kitty90
onlyfans:nattcherry
onlyfans:nickoletta.n
onlyfans:nicole_kitten
onlyfans:nicolettten
onlyfans:nikole_gy
onlyfans:nikoleg
onlyfans:ninabrooks
onlyfans:raaeluna
onlyfans:raarii
onlyfans:rebeccaa
onlyfans:rythecutiepie
onlyfans:sabrina_nellie
onlyfans:sabrinakellis
onlyfans:sabrinasplits
onlyfans:sallysecret
onlyfans:samyquinvip
onlyfans:sariixo
onlyfans:secretjuliet
onlyfans:shine_lia
onlyfans:shinegal
onlyfans:sofiblossomm
onlyfans:sofisofi
onlyfans:sophia.baily
onlyfans:sophia_rey
onlyfans:sophiebabyx
onlyfans:sophieblaze
onlyfans:sophieily
onlyfans:spunbunny
onlyfans:stassiebabee
onlyfans:stella_charming
onlyfans:sub_bunnyy
onlyfans:sunny.bunny.xx
onlyfans:sunny_anny
onlyfans:sunnybanny1
onlyfans:sunnyy.hunnyy
onlyfans:sweetiejuliet
onlyfans:sweetyjuliaa
onlyfans:synnbunny
onlyfans:takemy9
onlyfans:tianabrook
onlyfans:tiffanyrousso
onlyfans:tina_cherry
onlyfans:tiny_may
onlyfans:va_ness_a
onlyfans:valentiinaxo
onlyfans:valentina-arg
onlyfans:valentina.cho
onlyfans:valentina.jones
onlyfans:valentina.sm
onlyfans:valentina.ts
onlyfans:valentina.xo
onlyfans:valentinaofswe
onlyfans:valentinaomi
onlyfans:valentinaricco
onlyfans:valentinaros06
onlyfans:valentinarosa89
onlyfans:valentinarose18
onlyfans:valentinarose4
onlyfans:valentinarouge
onlyfans:valentinasz
onlyfans:valentinayours
onlyfans:valentinisss
onlyfans:valeria_kross
onlyfans:valfairyx
onlyfans:vanesaonly
onlyfans:vanessa.snow
onlyfans:vanessa_gold
onlyfans:vanessadvl
onlyfans:vanessamorel
onlyfans:vanessascorp
onlyfans:vanessaxo
onlyfans:vanessssas
onlyfans:vera_fairy
onlyfans:victoria_skinny
onlyfans:victoriaprice
onlyfans:victoriashy
onlyfans:victoriasinz
onlyfans:victoriasvip
onlyfans:vnikolee
onlyfans:yourcutekami`;
const MATCHED_CREATORS = MATCHED_CREATORS_RAW.trim().split('\n').map(line => {
const i = line.indexOf(':');
if (i < 0) return null;
return { service: line.slice(0, i).trim(), id: line.slice(i + 1).trim() };
}).filter(Boolean);
// =========================================================================
// RESILIENT API FETCH
// =========================================================================
async function apiFetch(path, opts = {}) {
const origin = window.location.origin || 'https://coomer.st';
if (origin && DISCO_CFG.baseUrl !== origin) DISCO_CFG.baseUrl = origin;
let url = path.startsWith('http') ? path : DISCO_CFG.baseUrl + '/api/v1' + path;
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
return await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: opts.method || 'GET',
url,
headers: Object.assign({ 'Accept': 'text/css' }, opts.headers),
timeout: 10000,
onload: (res) => {
if (res.status >= 200 && res.status < 300) {
try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); }
} else { reject(new Error(`HTTP ${res.status}`)); }
},
onerror: (e) => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout'))
});
});
} catch (e) {
retries++;
if (retries >= maxRetries) throw e;
await new Promise(r => setTimeout(r, 1000 * retries));
}
}
}
(function () {
'use strict';
const COOMER_ORIGIN = window.location.origin || 'https://coomer.st';
const EXPLORE_HASH = '#explore';
const SITE_DOMAIN = window.location.hostname.replace(/^www\./, '') || 'coomer.st';
const CDN_DOMAIN = SITE_DOMAIN.includes('kemono') ? 'kemono.su' : SITE_DOMAIN;
const CDN_NODES = [`n1.${CDN_DOMAIN}`, `n2.${CDN_DOMAIN}`, `n3.${CDN_DOMAIN}`, `n4.${CDN_DOMAIN}`];
let preferredNode = `n2.${CDN_DOMAIN}`;
function cdnUrl(path, node, bustCache = false) {
const base = `https://${node || preferredNode}/data${path}`;
return bustCache ? `${base}?_=${Date.now()}` : base;
}
const CREATORS_URL = 'https://bin.lysator.liu.se/raw/YxScT91MSbm';
const TAGS_URL = 'https://bin.lysator.liu.se/raw/I0WV5pil7ne';
let cachedCreatorsList = null;
let cachedTagsList = null;
const FALLBACK_TAGS = [
{ tag: 'public', count: 8177, category: 'Location' },
{ tag: 'x', count: 27904, category: 'Content' },
];
const MAX_WATCHED_HISTORY = 5000;
let watchedSet = new Set(GM_getValue('watchedVideos', []));
function markWatched(postId) {
if (!postId) return;
watchedSet.add(String(postId));
if (watchedSet.size > MAX_WATCHED_HISTORY) {
const arr = [...watchedSet];
watchedSet = new Set(arr.slice(arr.length - MAX_WATCHED_HISTORY));
}
GM_setValue('watchedVideos', [...watchedSet]);
}
function isWatched(postId) { return watchedSet.has(String(postId)); }
function getPostId(post) { const p = post.post || post; return p.id || p.hash || null; }
let currentSlideIdx = 0;
let currentPlaylistPosts = [];
let userManuallyPlaying = false;
let activeWatchTimers = new Map();
function escapeHtml(str) {
if (!str) return '';
const p = document.createElement('p');
p.textContent = str;
return p.innerHTML;
}
function escapeAttr(str) {
if (!str) return '';
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
function avatarUrl(s, id) { return `https://img.${CDN_DOMAIN}/icons/${s}/${encodeURIComponent(id)}`; }
function bannerUrl(s, id) { return `https://img.${CDN_DOMAIN}/banners/${s}/${encodeURIComponent(id)}`; }
const CFG = {
nodes: ['n1', 'n2', 'n3', 'n4'],
chunkSize: 1024 * 1024 * 5,
maxConcurrency: 8,
chunkTimeout: 30000,
headTimeout: 10000,
maxChunkRetries: 3,
debug: false,
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
mseBufferAhead: 30,
mseBufferBehind: 15,
mseAppendRetryDelay: 100,
mseMaxQueuedAppends: 50,
autoPlayOnData: true,
minBufferToPlay: 0.5,
showOverlay: true,
useParallelCDN: true,
parallelCDNNodes: 3,
nodePerformanceTracking: true,
};
const log = (...a) => CFG.debug && console.log('%c[CDN-Stream]', 'color:#4ade80;font-weight:bold', ...a);
const warn = (...a) => console.warn('%c[CDN-Stream]', 'color:#f59e0b;font-weight:bold', ...a);
const err = (...a) => console.error('%c[CDN-Stream]', 'color:#ef4444;font-weight:bold', ...a);
class FileMetadataCache {
constructor() { this.cache = new Map(); this.maxAge = 5 * 60 * 1000; this.pendingRequests = new Map(); }
extractHash(url) { const match = url.match(/\/([a-f0-9]{64})\.(mp4|webm|mov|m4v)/i); return match ? match[1] : null; }
async get(hash) {
const cached = this.cache.get(hash);
if (cached && Date.now() - cached.timestamp < this.maxAge) return cached.data;
if (this.pendingRequests.has(hash)) return await this.pendingRequests.get(hash);
const fetchPromise = this._fetchFromApi(hash);
this.pendingRequests.set(hash, fetchPromise);
try { const data = await fetchPromise; this.cache.set(hash, { data, timestamp: Date.now() }); return data; }
finally { this.pendingRequests.delete(hash); }
}
async _fetchFromApi(hash) {
const response = await cdnFetch(`${COOMER_ORIGIN}/api/v2/file/${hash}`, { timeout: 5000 });
if (response.status !== 200) throw new Error(`API returned ${response.status}`);
const json = JSON.parse(response.responseText);
if (!json.data || !json.data.file) throw new Error('Invalid API response structure');
return json.data;
}
cleanup() { const now = Date.now(); for (const [hash, entry] of this.cache.entries()) { if (now - entry.timestamp > this.maxAge) this.cache.delete(hash); } }
}
class SmartNodeSelector {
constructor(nodes = CFG.nodes) {
this.nodes = nodes; this.latencyMap = new Map(); this.failureMap = new Map();
this.sessionBestNode = GM_getValue('sessionBestNode', null);
this.lastMeasurementTime = 0; this.measurementCooldown = 60000;
}
async measureLatency(node, testUrl) {
const t0 = performance.now();
try { await cdnFetch(testUrl, { method: 'HEAD', timeout: 3000 }); return performance.now() - t0; }
catch (e) { return Infinity; }
}
async getBestNode(candidateUrls, fileHash = null, fileCache = null) {
if (this.sessionBestNode) {
const failures = this.failureMap.get(this.sessionBestNode) || 0;
if (failures < 3) return this.sessionBestNode;
}
const now = Date.now();
if (now - this.lastMeasurementTime < this.measurementCooldown && this.latencyMap.size > 0) {
const sorted = [...this.latencyMap.entries()].filter(([n]) => (this.failureMap.get(n) || 0) < 3).sort((a, b) => a[1] - b[1]);
if (sorted.length > 0) return sorted[0][0];
}
let availableNodes = this.nodes;
if (fileHash && fileCache) {
try { const metadata = await fileCache.get(fileHash); if (metadata.file_list?.length > 0) availableNodes = metadata.file_list; } catch (e) {}
}
const filteredCandidates = []; const nodeIndices = [];
for (let i = 0; i < candidateUrls.length; i++) {
const node = this.nodes[i];
if (availableNodes.includes(node)) { filteredCandidates.push(candidateUrls[i]); nodeIndices.push(i); }
}
if (filteredCandidates.length === 0) { filteredCandidates.push(...candidateUrls); nodeIndices.push(...Array.from({ length: candidateUrls.length }, (_, i) => i)); }
this.lastMeasurementTime = now;
const measurements = await Promise.all(filteredCandidates.map(async (url, idx) => {
const node = this.nodes[nodeIndices[idx]];
const latency = await this.measureLatency(node, url);
this.latencyMap.set(node, latency);
return { node, latency };
}));
const healthy = measurements.filter(m => m.latency < Infinity && (this.failureMap.get(m.node) || 0) < 3).sort((a, b) => a.latency - b.latency);
if (healthy.length === 0) return this.nodes[0];
const bestNode = healthy[0].node;
this.sessionBestNode = bestNode;
GM_setValue('sessionBestNode', bestNode);
return bestNode;
}
recordFailure(node) { const count = (this.failureMap.get(node) || 0) + 1; this.failureMap.set(node, count); if (count >= 3 && this.sessionBestNode === node) { this.sessionBestNode = null; GM_setValue('sessionBestNode', null); } }
recordSuccess(node) { this.failureMap.set(node, 0); }
}
class URLResolver {
constructor(selector, fileCache = null) { this.selector = selector; this.fileCache = fileCache; }
extractHash(url) {
const match = url.match(/\/([a-f0-9]{2})\/([a-f0-9]{2})\/([a-f0-9]{64})\.(mp4|webm|mov|m4v)/i);
if (match) return { p1: match[1], p2: match[2], hash: match[3], ext: '.' + match[4].toLowerCase() };
return null;
}
buildCandidateUrls(p1, p2, hash, ext) { return this.selector.nodes.map(node => `https://${node}.${CDN_DOMAIN}/data/${p1}/${p2}/${hash}${ext}`); }
async resolve(originalUrl) {
const info = this.extractHash(originalUrl);
if (!info) return originalUrl;
const candidates = this.buildCandidateUrls(info.p1, info.p2, info.hash, info.ext);
const bestNode = await this.selector.getBestNode(candidates, info.hash, this.fileCache);
return `https://${bestNode}.${CDN_DOMAIN}/data/${info.p1}/${info.p2}/${info.hash}${info.ext}`;
}
}
const PAGE_ORIGIN = typeof window !== 'undefined' && window.location ? window.location.origin : 'https://coomer.st';
let fetchQueue = Promise.resolve();
const THROTTLE_MS = 250;
let lastRequestTime = 0;
function gmFetch(url, opts = {}) {
const isApiCall = url.includes('/api/');
const defaultHeaders = {
'Referer': PAGE_ORIGIN + '/',
'Origin': PAGE_ORIGIN,
'User-Agent': CFG.ua,
'Accept': opts.method === 'HEAD' ? '*/*' : (opts.headers?.['Accept']) || (isApiCall ? 'text/css' : '*/*'),
};
const headers = { ...defaultHeaders, ...(opts.headers || {}) };
const requestPromise = fetchQueue.then(async () => {
const now = Date.now();
const timeSinceLast = now - lastRequestTime;
if (timeSinceLast < THROTTLE_MS) await new Promise(r => setTimeout(r, THROTTLE_MS - timeSinceLast));
lastRequestTime = Date.now();
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: opts.method || 'GET', url,
responseType: opts.responseType || 'text',
headers, timeout: opts.timeout || 30000, anonymous: false,
onload(r) {
if (r.status >= 200 && r.status < 400) {
if (!opts.responseType || opts.responseType === 'text') {
try { const text = r.responseText; if (text && (text.trim().startsWith('{') || text.trim().startsWith('['))) r.json = JSON.parse(text); } catch (_) {}
}
resolve(r);
} else reject(new Error(`HTTP ${r.status}: ${url}`));
},
onerror() { reject(new Error(`Network error: ${url}`)); },
ontimeout() { reject(new Error(`Timeout: ${url}`)); },
});
});
});
fetchQueue = requestPromise.catch(() => {});
return requestPromise.then(r => {
if (opts.responseType === 'arraybuffer') return r;
return r.json || r.responseText;
});
}
function cdnFetch(url, opts = {}) {
const isApiCall = url.includes('/api/');
const defaultHeaders = {
'Referer': PAGE_ORIGIN + '/',
'Origin': PAGE_ORIGIN,
'User-Agent': CFG.ua,
'Accept': opts.method === 'HEAD' ? '*/*' : (opts.headers?.['Accept']) || (isApiCall ? 'text/css' : '*/*'),
};
const headers = { ...defaultHeaders, ...(opts.headers || {}) };
const requestPromise = fetchQueue.then(async () => {
const now = Date.now();
const timeSinceLast = now - lastRequestTime;
if (timeSinceLast < THROTTLE_MS) await new Promise(r => setTimeout(r, THROTTLE_MS - timeSinceLast));
lastRequestTime = Date.now();
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: opts.method || 'GET', url,
responseType: opts.responseType || 'text',
headers, timeout: opts.timeout || 30000, anonymous: false,
onload(r) { if (r.status >= 200 && r.status < 400) resolve(r); else reject(new Error(`HTTP ${r.status}: ${url}`)); },
onerror() { reject(new Error(`Network error: ${url}`)); },
ontimeout() { reject(new Error(`Timeout: ${url}`)); },
});
});
});
fetchQueue = requestPromise.catch(() => {});
return requestPromise;
}
class StreamOrchestrator {
constructor(urlResolver) { this.resolver = urlResolver; this.videoMap = new Map(); this.accessOrder = []; this.maxPoolSize = /iPhone|iPad/i.test(navigator.userAgent) ? 2 : 3; }
createVideo() {
const v = document.createElement('video');
v.preload = 'auto'; v.muted = true; v.playsInline = true;
v.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
document.body.appendChild(v);
return v;
}
async getStream(url, mode = 'playback') {
const resolvedUrl = await this.resolver.resolve(url);
if (this.videoMap.has(resolvedUrl)) { this._updateAccessOrder(resolvedUrl); return this.videoMap.get(resolvedUrl); }
const video = this.createVideo();
video.src = resolvedUrl;
if (mode === 'metadata' && video.readyState < 1) await new Promise(r => video.addEventListener('loadedmetadata', r, { once: true }));
this.videoMap.set(resolvedUrl, video);
this.accessOrder.push(resolvedUrl);
this._evictIfNeeded();
return video;
}
async prepareNext(url) { try { const video = await this.getStream(url); const p = video.play(); if (p) await p.then(() => video.pause()).catch(() => {}); } catch (e) {} }
_updateAccessOrder(url) { const idx = this.accessOrder.indexOf(url); if (idx !== -1) this.accessOrder.splice(idx, 1); this.accessOrder.push(url); }
_evictIfNeeded() {
while (this.videoMap.size > this.maxPoolSize) {
const url = this.accessOrder.shift();
const v = this.videoMap.get(url);
if (v) { v.pause(); v.removeAttribute('src'); while (v.firstChild) v.removeChild(v.firstChild); v.load(); v.remove(); _activeConnections = Math.max(0, _activeConnections - 1); }
this.videoMap.delete(url);
}
}
}
let urlResolver = null;
let nodeSelector = null;
let fileCache = null;
let streamOrch = null;
async function streamVideo(videoEl) {
if (videoEl.dataset.cdnStreaming) return;
if (!urlResolver) return;
videoEl.dataset.cdnStreaming = 'true';
let src = videoEl.src;
if (!src) { const source = videoEl.querySelector('source'); if (source) src = source.src; }
if (!src) return;
const resolved = await urlResolver.resolve(src);
if (resolved !== src) {
const ct = videoEl.currentTime; const paused = videoEl.paused;
videoEl.src = resolved;
if (!paused) videoEl.play().catch(() => {});
if (ct > 0) videoEl.currentTime = ct;
}
}
function initCDN() {
fileCache = new FileMetadataCache();
nodeSelector = new SmartNodeSelector(CFG.nodes);
urlResolver = new URLResolver(nodeSelector, fileCache);
streamOrch = new StreamOrchestrator(urlResolver);
window.cdnStream = streamVideo;
window.coomerApi = window.coomerApi || {};
window.coomerApi.urlResolver = urlResolver;
log('CDN Logic Initialized');
const s = document.createElement('style');
s.textContent = '.gallery-page .gallery-creator-banner { display: none !important; }';
document.head.appendChild(s);
}
initCDN();
async function loadExternalCreatorsList() {
if (cachedCreatorsList) return cachedCreatorsList;
try {
const text = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({ method: 'GET', url: CREATORS_URL, headers: { 'User-Agent': navigator.userAgent }, onload: (r) => { if (r.status >= 200 && r.status < 400) resolve(r.responseText); else reject(new Error(`HTTP ${r.status}`)); }, onerror: reject, ontimeout: () => reject(new Error('Timeout')) });
});
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const creators = []; const seen = new Set();
for (const line of lines) {
const [service, id] = line.split(':');
if (service && id) { const key = `${service}:${id}`; if (!seen.has(key)) { seen.add(key); creators.push({ service, id, name: id }); } }
}
cachedCreatorsList = creators;
return creators;
} catch (e) { return []; }
}
async function loadExternalTags() {
if (cachedTagsList) return cachedTagsList;
try {
const text = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({ method: 'GET', url: TAGS_URL, headers: { 'User-Agent': navigator.userAgent }, onload: (r) => { if (r.status >= 200 && r.status < 400) resolve(r.responseText); else reject(new Error(`HTTP ${r.status}`)); }, onerror: reject, ontimeout: () => reject(new Error('Timeout')) });
});
const tags = [];
const arrayMatches = text.matchAll(/\[\s*\{[^\]]+\}\s*\]/g);
for (const match of arrayMatches) { try { const arr = JSON.parse(match[0]); if (Array.isArray(arr)) arr.forEach(item => { if (item.tag && item.post_count) tags.push({ tag: item.tag, count: parseInt(item.post_count, 10), category: item.category || 'General' }); }); } catch (e) {} }
const jsonMatches = text.matchAll(/\{\s*"tag"\s*:\s*"([^"]+)"\s*,\s*"post_count"\s*:\s*(\d+)\s*(?:,\s*"category"\s*:\s*"([^"]*)")?\s*\}/g);
for (const match of jsonMatches) { const tag = match[1]; const count = parseInt(match[2], 10); const category = match[3] || 'General'; if (!tags.find(t => t.tag === tag)) tags.push({ tag, count, category }); }
tags.sort((a, b) => b.count - a.count);
cachedTagsList = tags;
return tags;
} catch (e) { return FALLBACK_TAGS; }
}
async function getCreators() {
const externalCreators = await loadExternalCreatorsList();
if (externalCreators.length > 0) return externalCreators;
return new Promise((resolve) => {
const timer = setTimeout(() => { if (window.coomerApi?.getCreators) window.coomerApi.getCreators().then(resolve).catch(() => resolve([])); else resolve([]); }, 2000);
document.addEventListener('CoomerApiCreatorsResponse', (e) => { clearTimeout(timer); resolve(e.detail.creators || []); }, { once: true });
document.dispatchEvent(new CustomEvent('CoomerApiGetCreators'));
});
}
async function getCreatorPosts(service, id, offset = 0) {
const url = `/api/v1/${service}/user/${id}/posts?o=${offset}`;
const data = await gmFetch(url);
if (data.results && Array.isArray(data.results)) {
const props = data.props || {};
const limit = props.limit || 50;
const posts = data.results;
return { posts, count: props.count || 0, hasMore: posts.length >= limit, artist: props.artist };
}
const posts = Array.isArray(data) ? data : [];
return { posts, count: posts.length, hasMore: posts.length >= 50, artist: null };
}
async function getPostsByTag(tag, offset = 0) {
const data = await gmFetch(`/api/v1/posts?tag=${encodeURIComponent(tag)}&o=${offset}`);
const posts = Array.isArray(data) ? data : (data.results || data.posts || []);
return { posts, count: posts.length, hasMore: posts.length >= 50 };
}
async function getRecommendedCreators(service, id) {
try {
const data = await gmFetch(`/api/v1/${service}/user/${id}/recommended`);
if (Array.isArray(data)) return data.map(c => ({ service: c.service || service, id: c.id || c.user || c.name, name: c.name || c.id || c.user, score: c.score })).filter(c => c.id);
return [];
} catch (e) { return []; }
}
function hasVideoAttachments(post) {
const p = post.post || post;
const file = p.file || {};
const attachments = p.attachments || [];
if (file.path && /\.(mp4|webm|mov|avi|mkv|m4v)$/i.test(file.path)) return true;
return attachments.some(a => a.path && /\.(mp4|webm|mov|avi|mkv|m4v)$/i.test(a.path));
}
function isVideoPost(post) { return hasVideoAttachments(post); }
function getFirstVideoPath(post) {
const p = post.post || post;
const file = p.file || {};
const attachments = p.attachments || [];
if (file.path && /\.(mp4|webm|mov|avi|mkv|m4v)$/i.test(file.path)) return file.path;
for (const att of attachments) { if (att.path && /\.(mp4|webm|mov|avi|mkv|m4v)$/i.test(att.path)) return att.path; }
return null;
}
function getFirstVideoUrl(post) { const path = getFirstVideoPath(post); return path ? cdnUrl(path) : null; }
let currentPlaybackContext = null;
let isLoadingMore = false;
let lastActiveTab = 'creators';
const visitedCreators = new Set();
// =========================================================================
// STYLES
// =========================================================================
GM_addStyle(`
.hidden { display: none !important; }
#explore-root { position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 9999; background: #0a0a0a; overflow: hidden; }
.video-player { width: 100%; height: calc(100vh - 60px); overflow-y: scroll; scroll-snap-type: y mandatory; scroll-behavior: smooth; scrollbar-width: none; }
.video-player::-webkit-scrollbar { display: none; }
.video-slide { width: 100%; height: 100%; scroll-snap-align: start; scroll-snap-stop: always; display: flex; align-items: center; justify-content: center; background: #000; position: relative; }
.explore-header { display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #1a1a1a; border-bottom: 2px solid #4ade80; }
.explore-tabs { display: flex; gap: 12px; }
.explore-tab { padding: 8px 16px; background: #2a2a2a; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; transition: all 0.2s; }
.explore-tab:hover { background: #3a3a3a; }
.explore-tab.active { background: #4ade80; color: #000; }
.explore-close { padding: 8px 16px; background: #f87171; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
.explore-content { position: relative; height: calc(100vh - 60px); overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; }
.explore-content::-webkit-scrollbar { display: none; }
.explore-page { display: none; padding: 20px; }
.explore-page.active { display: block; }
.creator-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; padding: 20px; }
.creator-card { background: #1a1a1a; border-radius: 12px; overflow: hidden; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.creator-card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(74, 222, 128, 0.3); }
.creator-banner { width: 100%; height: 140px; object-fit: cover; background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); }
.creator-info { padding: 12px; }
.creator-name { font-weight: 600; color: #fff; margin-bottom: 4px; }
.creator-service { font-size: 14px; color: #4ade80; }
.tag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; padding: 20px; }
.tag-card { background: #1a1a1a; padding: 16px; border-radius: 8px; cursor: pointer; transition: all 0.2s; border: 2px solid transparent; }
.tag-card:hover { background: #2a2a2a; border-color: #4ade80; }
.tag-name { font-weight: 600; color: #4ade80; font-size: 18px; margin-bottom: 4px; }
.tag-count { color: #9ca3af; font-size: 14px; }
.tag-category { color: #6b7280; font-size: 12px; margin-top: 4px; }
.mixer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; padding: 20px; }
.mixer-card { background: #262626; border-radius: 8px; padding: 10px; display: flex; align-items: center; gap: 10px; cursor: pointer; border: 2px solid transparent; transition: all 0.2s; position: relative; }
.mixer-card:hover { background: #333; transform: translateY(-2px); }
.mixer-card.selected { border-color: #4ade80; background: #1a2e1a; }
.mixer-avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; background: #333; }
.mixer-info { flex: 1; min-width: 0; }
.mixer-name { color: #e5e7eb; font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mixer-stats { color: #9ca3af; font-size: 11px; }
.mixer-checkbox { width: 16px; height: 16px; border: 2px solid #4b5563; border-radius: 4px; margin-left: auto; }
.mixer-card.selected .mixer-checkbox { background: #4ade80; border-color: #4ade80; }
.mixer-controls { padding: 15px 20px; display: flex; align-items: center; gap: 15px; border-bottom: 1px solid #333; background: #1a1a1a; position: sticky; top: 0; z-index: 10; }
.mixer-play-btn { background: #4ade80; color: #000; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; border: none; transition: transform 0.2s; }
.mixer-play-btn:hover { transform: scale(1.05); }
.fav-btn { position: absolute; top: 20px; right: 20px; background: rgba(0,0,0,0.6); color: #fff; border: 2px solid #fff; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; cursor: pointer; z-index: 100; transition: all 0.2s; }
.fav-btn:hover { background: rgba(255,255,255,0.2); transform: scale(1.1); }
.fav-btn.active { background: #ef4444; border-color: #ef4444; }
.trending-controls { display: flex; align-items: center; gap: 10px; padding: 16px 20px; flex-wrap: wrap; }
.trending-controls label { color: #9ca3af; font-size: 14px; font-weight: 600; }
.trending-period-btn { padding: 6px 16px; background: #2a2a2a; color: #fff; border: 2px solid transparent; border-radius: 20px; cursor: pointer; font-weight: 600; font-size: 13px; transition: all 0.2s; }
.trending-period-btn:hover { background: #3a3a3a; }
.trending-period-btn.active { background: #4ade80; color: #000; border-color: #4ade80; }
.trending-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; padding: 0 20px 20px; }
.trending-card { background: #1a1a1a; border-radius: 12px; overflow: hidden; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.trending-card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(74, 222, 128, 0.3); }
.trending-thumb { position: relative; width: 100%; height: 160px; background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); overflow: hidden; }
.trending-thumb img { width: 100%; height: 100%; object-fit: cover; }
.trending-thumb .trending-play-icon { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 36px; opacity: 0.7; pointer-events: none; text-shadow: 0 2px 8px rgba(0,0,0,0.6); }
.trending-card-info { padding: 12px; }
.trending-card-title { font-weight: 600; color: #fff; font-size: 14px; margin-bottom: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trending-card-meta { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #9ca3af; }
.trending-card-creator { color: #4ade80; font-weight: 600; }
.trending-card-service { color: #6b7280; text-transform: capitalize; }
.trending-load-more { display: block; margin: 0 auto 24px; padding: 10px 32px; background: #2a2a2a; color: #4ade80; border: 2px solid #4ade80; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 14px; transition: all 0.2s; }
.trending-load-more:hover { background: #4ade80; color: #000; }
.trending-load-more:disabled { opacity: 0.5; cursor: not-allowed; }
.video-player video { max-width: 100%; max-height: 100%; object-fit: contain; }
.video-slide video { width: 100%; height: 100%; object-fit: contain; }
.video-slide.watched-marker::after { content: '✓ Watched'; position: absolute; top: 40px; right: 15px; background: rgba(74, 222, 128, 0.8); color: #000; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 4px; z-index: 20; pointer-events: none; }
.video-info { position: absolute; bottom: 40px; left: 20px; right: 80px; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.9); z-index: 20; pointer-events: none; }
.video-creator { font-weight: 600; font-size: 18px; }
.video-service { font-size: 14px; opacity: 0.9; }
.video-title { font-size: 13px; opacity: 0.85; margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.video-meta { font-size: 11px; opacity: 0.6; margin-top: 3px; }
.back-btn { position: fixed; top: 75px; left: 15px; z-index: 10000; background: rgba(0, 0, 0, 0.7); color: #fff; border: 1px solid rgba(255, 255, 255, 0.2); padding: 8px 16px; border-radius: 20px; cursor: pointer; backdrop-filter: blur(4px); font-size: 13px; transition: all 0.2s; }
.back-btn:hover { background: rgba(0, 0, 0, 0.8); transform: translateY(1px); }
.explore-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #4ade80; font-size: 18px; gap: 16px; }
.explore-loading-spinner { width: 48px; height: 48px; border: 4px solid rgba(74, 222, 128, 0.2); border-top-color: #4ade80; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.seek-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.75); color: #4ade80; font-size: 28px; font-weight: 700; padding: 16px 28px; border-radius: 12px; pointer-events: none; z-index: 200; animation: seekFade 0.6s ease-out forwards; }
@keyframes seekFade { 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.2); } }
.slide-counter { position: fixed; top: 175px; right: 15px; background: rgba(0, 0, 0, 0.7); color: #fff; font-size: 13px; padding: 6px 12px; border-radius: 20px; z-index: 10000; backdrop-filter: blur(4px); border: 1px solid rgba(255, 255, 255, 0.2); }
.play-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.4); z-index: 150; cursor: pointer; }
.play-overlay-btn { width: 80px; height: 80px; background: rgba(74, 222, 128, 0.85); border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: transform 0.2s; }
.play-overlay-btn:hover { transform: scale(1.1); }
.play-overlay-btn::after { content: ''; display: block; width: 0; height: 0; border-style: solid; border-width: 18px 0 18px 30px; border-color: transparent transparent transparent #000; margin-left: 6px; }
.video-error-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.7); color: #f87171; z-index: 150; cursor: pointer; gap: 12px; }
.video-error-overlay span { font-size: 14px; color: #9ca3af; }
.video-progress-container { position: absolute; top: 0; left: 0; width: 100%; height: 4px; background: rgba(255, 255, 255, 0.1); z-index: 10; pointer-events: none; }
.video-progress-bar { height: 100%; background: #4ade80; width: 0%; transition: width 0.1s linear; }
/* ── Smart Search styles ── */
.smart-search-panel { background: #111; border-radius: 16px; padding: 28px; max-width: 720px; margin: 24px auto; border: 1px solid #2a2a2a; }
.smart-search-title { font-size: 22px; font-weight: 700; color: #4ade80; margin-bottom: 24px; display: flex; align-items: center; gap: 10px; }
.smart-search-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.smart-search-field { display: flex; flex-direction: column; gap: 6px; }
.smart-search-field label { font-size: 12px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
.smart-search-field select, .smart-search-field input { background: #1e1e1e; border: 1px solid #333; color: #fff; border-radius: 8px; padding: 10px 12px; font-size: 14px; outline: none; transition: border-color 0.2s; width: 100%; box-sizing: border-box; }
.smart-search-field select:focus, .smart-search-field input:focus { border-color: #4ade80; }
.smart-search-keyword { margin-bottom: 20px; }
.smart-search-keyword input { width: 100%; }
.smart-search-btn { width: 100%; padding: 14px; background: #4ade80; color: #000; border: none; border-radius: 10px; font-size: 16px; font-weight: 700; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
.smart-search-btn:hover { background: #22c55e; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(74,222,128,0.3); }
.smart-search-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.smart-search-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px; gap: 16px; color: #4ade80; }
.smart-search-loading-text { font-size: 15px; color: #9ca3af; }
/* Search overlay that slides in front of the rest of the UI */
#smart-search-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #0a0a0a; z-index: 10001; overflow-y: auto; display: flex; flex-direction: column; }
#smart-search-overlay.hidden { display: none !important; }
.sso-header { display: flex; align-items: center; gap: 12px; padding: 16px 20px; background: #111; border-bottom: 2px solid #4ade80; position: sticky; top: 0; z-index: 10; }
.sso-back { background: #2a2a2a; color: #fff; border: none; border-radius: 8px; padding: 8px 16px; cursor: pointer; font-weight: 600; font-size: 14px; transition: background 0.2s; }
.sso-back:hover { background: #3a3a3a; }
.sso-title { font-size: 18px; font-weight: 700; color: #4ade80; }
.sso-count { font-size: 13px; color: #9ca3af; margin-left: auto; }
.sso-content { flex: 1; padding: 20px; }
.sso-mode-tabs { display: flex; gap: 10px; margin-bottom: 20px; }
.sso-mode-btn { padding: 7px 18px; background: #1e1e1e; color: #9ca3af; border: 1px solid #333; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; }
.sso-mode-btn.active { background: #4ade80; color: #000; border-color: #4ade80; }
.sso-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.sso-video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.sso-play-all { display: inline-flex; align-items: center; gap: 8px; background: #4ade80; color: #000; border: none; border-radius: 8px; padding: 10px 22px; cursor: pointer; font-weight: 700; font-size: 15px; margin-bottom: 18px; transition: all 0.2s; }
.sso-play-all:hover { background: #22c55e; transform: translateY(-1px); }
.sso-empty { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 16px; }
`);
// =========================================================================
// SEEK OVERLAY
// =========================================================================
function showSeekOverlay(slide, text) {
const existing = slide.querySelector('.seek-overlay');
if (existing) existing.remove();
const el = document.createElement('div');
el.className = 'seek-overlay';
el.textContent = text;
slide.appendChild(el);
setTimeout(() => el.remove(), 600);
}
// =========================================================================
// VIDEO PLAYBACK HELPERS
// =========================================================================
function tryPlayVideo(video) {
if (!video) return;
if (video._playPending) return;
if (!video.src || video.src === '' || video.readyState === 0) { video.addEventListener('loadedmetadata', () => tryPlayVideo(video), { once: true }); return; }
if (video._disposed || !video.isConnected) return;
video.muted = true;
video._playPending = true;
const p = video.play();
if (p !== undefined) {
p.then(() => { video._playPending = false; video.muted = false; video.volume = 1.0; removePlayOverlay(video); })
.catch(e => {
video._playPending = false;
if (e.name === 'AbortError') return;
if (e.name === 'NotSupportedError') { video.addEventListener('loadedmetadata', () => tryPlayVideo(video), { once: true }); return; }
if (e.name === 'NotAllowedError') showPlayOverlay(video);
});
} else { video._playPending = false; }
}
function showPlayOverlay(video) {
const slide = video?.closest('.video-slide');
if (!slide || slide.querySelector('.play-overlay')) return;
const overlay = document.createElement('div');
overlay.className = 'play-overlay';
overlay.innerHTML = '<div class="play-overlay-btn"></div>';
overlay.addEventListener('click', (e) => { e.stopPropagation(); overlay.remove(); video.muted = false; video.volume = 1.0; video.play().catch(() => {}); });
slide.appendChild(overlay);
}
function showErrorOverlay(video, videoPath) {
const slide = video?.closest('.video-slide');
if (!slide || slide.querySelector('.video-error-overlay')) return;
video._failed = true;
clearTimeout(video._stallTimer);
const overlay = document.createElement('div');
overlay.className = 'video-error-overlay';
overlay.innerHTML = '<div>⚠️</div><span>Video failed to load</span><span>Skipping in 2s... (tap to retry)</span>';
overlay.addEventListener('click', (e) => { e.stopPropagation(); overlay.remove(); clearTimeout(slide._autoSkipTimer); video._failed = false; video._cdnAttempts = 0; video._cdnNodeIndex = CDN_NODES.indexOf(preferredNode); video.src = cdnUrl(videoPath, preferredNode, true); video.load(); tryPlayVideo(video); });
slide.appendChild(overlay);
slide._autoSkipTimer = setTimeout(() => {
const player = document.querySelector('.video-player');
if (!player) return;
const allSlides = player.querySelectorAll('.video-slide:not(.loading-placeholder)');
const nextIdx = Math.min(currentSlideIdx + 1, allSlides.length - 1);
if (nextIdx !== currentSlideIdx) allSlides[nextIdx]?.scrollIntoView({ behavior: 'smooth' });
}, 2000);
}
function removePlayOverlay(video) {
const slide = video?.closest('.video-slide');
if (!slide) return;
slide.querySelector('.play-overlay')?.remove();
slide.querySelector('.video-error-overlay')?.remove();
}
function isCurrentSlideVideo(video) {
const slide = video?.closest('.video-slide');
if (!slide) return false;
return parseInt(slide.dataset.index, 10) === currentSlideIdx;
}
function setupVideoErrorFallback(video, videoPath) {
if (!videoPath || video._cdnFallbackAttached) return;
video._cdnFallbackAttached = true;
video._cdnNodeIndex = CDN_NODES.indexOf(preferredNode);
if (video._cdnNodeIndex < 0) video._cdnNodeIndex = 0;
video._cdnAttempts = 0;
video._disposed = false;
video.addEventListener('error', () => {
if (video._disposed || !video.isConnected) return;
if (!isCurrentSlideVideo(video)) return;
video._cdnAttempts++;
if (video._cdnAttempts >= CDN_NODES.length * 2) { showErrorOverlay(video, videoPath); return; }
video._cdnNodeIndex = (video._cdnNodeIndex + 1) % CDN_NODES.length;
const nextNode = CDN_NODES[video._cdnNodeIndex];
const retryDelay = Math.min(300 * video._cdnAttempts, 2000);
setTimeout(() => { if (video._disposed || !video.isConnected) return; if (!isCurrentSlideVideo(video)) return; video.src = cdnUrl(videoPath, nextNode, true); video.load(); }, retryDelay);
});
video.addEventListener('loadstart', () => {
video._loadStart = Date.now();
clearTimeout(video._stallTimer);
video._stallTimer = setTimeout(() => {
if (video._disposed || !video.isConnected) return;
if (!isCurrentSlideVideo(video)) return;
if (video._failed) return;
if (video.readyState < 2 && !video.error) { video._cdnAttempts++; video._cdnNodeIndex = (video._cdnNodeIndex + 1) % CDN_NODES.length; const nextNode = CDN_NODES[video._cdnNodeIndex]; video.src = cdnUrl(videoPath, nextNode, true); video.load(); }
}, 15000);
});
video.addEventListener('canplay', () => { clearTimeout(video._stallTimer); }, { once: true });
}
function startWatchTimer(postId, video) {
if (!postId || isWatched(postId)) return;
stopWatchTimer(postId);
let watchTime = 0;
const interval = setInterval(() => {
if (video && !video.paused && !video.ended) {
watchTime += 0.5;
if (watchTime >= 3) { markWatched(postId); clearInterval(interval); activeWatchTimers.delete(postId); const slide = video.closest('.video-slide'); if (slide) slide.classList.add('watched-marker'); }
}
}, 500);
activeWatchTimers.set(postId, interval);
}
function stopWatchTimer(postId) { if (activeWatchTimers.has(postId)) { clearInterval(activeWatchTimers.get(postId)); activeWatchTimers.delete(postId); } }
// =========================================================================
// CONNECTION BUDGET & TRICKLE PRELOAD
// =========================================================================
const MAX_CDN_CONNECTIONS = 30;
const EVICT_COOLDOWN_MS = 450;
const TRICKLE_INTERVAL_MIN = 50;
const TRICKLE_INTERVAL_MAX = 150;
const TRICKLE_RATIO = 0.20;
let _activeConnections = 0;
let _recentlyEvicted = [];
let _trickleTimer = null;
let _trickleQueue = [];
function mountVideo(slide) {
if (slide.querySelector('video')) return;
const videoPath = slide.dataset.videoPath;
const url = getPreloadedUrl(videoPath) || cdnUrl(videoPath);
const video = document.createElement('video');
video.src = url; video.loop = true; video.playsInline = true; video.preload = 'metadata';
video.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;';
_activeConnections++;
if (!slide.querySelector('.video-progress-container')) {
const progress = document.createElement('div');
progress.className = 'video-progress-container';
progress.innerHTML = `<div class="video-progress-bar"></div><div class="video-time-display" style="position:absolute;top:6px;right:8px;color:#fff;font-size:11px;background:rgba(0,0,0,0.5);padding:2px 6px;border-radius:4px;display:none;"></div>`;
slide.appendChild(progress);
}
const bar = slide.querySelector('.video-progress-bar');
const timeDisplay = slide.querySelector('.video-time-display');
if (bar && timeDisplay) {
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
bar.style.width = `${(video.currentTime / video.duration) * 100}%`;
const left = video.duration - video.currentTime;
const m = Math.floor(left / 60); const s = Math.floor(left % 60).toString().padStart(2, '0');
timeDisplay.textContent = `-${m}:${s}`; timeDisplay.style.display = 'block';
});
}
const info = slide.querySelector('.video-info');
if (info) slide.insertBefore(video, info); else slide.appendChild(video);
setupVideoErrorFallback(video, videoPath);
}
function unmountVideo(slide) {
const video = slide.querySelector('video');
if (!video) return;
video._disposed = true; clearTimeout(video._stallTimer);
stopWatchTimer(slide.dataset.postId);
video.pause(); video.removeAttribute('src');
while (video.firstChild) video.removeChild(video.firstChild);
video.load();
if (video._objectUrl) { try { URL.revokeObjectURL(video._objectUrl); } catch (_) {} video._objectUrl = null; }
video.remove();
const videoPath = slide.dataset.videoPath;
if (videoPath) { _recentlyEvicted.push(videoPath); if (_recentlyEvicted.length > 30) _recentlyEvicted.shift(); }
slide._manualPlay = false;
slide.querySelectorAll('.play-overlay, .video-error-overlay, .seek-overlay').forEach(el => el.remove());
_activeConnections = Math.max(0, _activeConnections - 1);
}
function scheduleTricklePreload() {
if (_trickleTimer) return;
if (_recentlyEvicted.length === 0) return;
_trickleTimer = setTimeout(() => {
_trickleTimer = null;
const evicted = _recentlyEvicted.splice(0);
const count = Math.max(1, Math.ceil(evicted.length * TRICKLE_RATIO));
const toPreload = evicted.slice(0, count);
if (toPreload.length === 0) return;
const budget = MAX_CDN_CONNECTIONS - _activeConnections;
if (budget <= 2) return;
const capped = toPreload.slice(0, Math.min(budget - 1, toPreload.length));
_trickleQueue.push(...capped.map(path => getPreloadedUrl(path) || cdnUrl(path)));
drainTrickleQueue();
}, EVICT_COOLDOWN_MS);
}
function drainTrickleQueue() {
if (_trickleQueue.length === 0) return;
if (_activeConnections >= MAX_CDN_CONNECTIONS) return;
const url = _trickleQueue.shift();
if (!url) return;
_activeConnections++;
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'Range': 'bytes=0-262143', 'Referer': PAGE_ORIGIN + '/', 'User-Agent': CFG.ua },
responseType: 'arraybuffer', timeout: 8000, anonymous: false,
onload() { _activeConnections = Math.max(0, _activeConnections - 1); setTimeout(() => drainTrickleQueue(), TRICKLE_INTERVAL_MIN + Math.random() * (TRICKLE_INTERVAL_MAX - TRICKLE_INTERVAL_MIN)); },
onerror() { _activeConnections = Math.max(0, _activeConnections - 1); setTimeout(() => drainTrickleQueue(), TRICKLE_INTERVAL_MIN + Math.random() * (TRICKLE_INTERVAL_MAX - TRICKLE_INTERVAL_MIN)); },
ontimeout() { _activeConnections = Math.max(0, _activeConnections - 1); drainTrickleQueue(); }
});
}
function manageVideoResources(player) {
if (!player) return;
const slides = player.querySelectorAll('.video-slide:not(.loading-placeholder)');
let evictedCount = 0;
slides.forEach((slide, i) => {
const distance = Math.abs(i - currentSlideIdx);
if (distance <= 2) {
if (!slide.querySelector('video') && _activeConnections >= MAX_CDN_CONNECTIONS) return;
mountVideo(slide);
const video = slide.querySelector('video');
if (video && !video.dataset.cdnStreaming && typeof window.cdnStream === 'function') {
if (distance <= 1) { try { video._optimizationPromise = window.cdnStream(video); } catch (e) {} }
}
if (video) { video.preload = distance === 0 ? 'auto' : distance === 1 ? 'auto' : 'metadata'; }
} else {
if (slide.querySelector('video')) { unmountVideo(slide); evictedCount++; }
}
});
if (evictedCount > 0) scheduleTricklePreload();
backgroundPreloadAhead(slides);
}
let _bgPreloadCache = new Map();
let _bgPreloadInFlight = new Set();
async function backgroundPreloadAhead(slides) {
if (!urlResolver) return;
if (_activeConnections >= MAX_CDN_CONNECTIONS - 3) return;
for (let offset = 3; offset <= 6; offset++) {
const targetIdx = currentSlideIdx + offset;
if (targetIdx >= slides.length) break;
const slide = slides[targetIdx];
if (!slide) continue;
const videoPath = slide.dataset.videoPath;
if (!videoPath || _bgPreloadCache.has(videoPath) || _bgPreloadInFlight.has(videoPath)) continue;
_bgPreloadInFlight.add(videoPath);
(async () => {
try {
const originalUrl = cdnUrl(videoPath);
const resolved = await urlResolver.resolve(originalUrl);
_bgPreloadCache.set(videoPath, resolved);
if (_activeConnections < MAX_CDN_CONNECTIONS - 5) {
_activeConnections++;
GM_xmlhttpRequest({ method: 'HEAD', url: resolved, headers: { 'Referer': PAGE_ORIGIN + '/', 'User-Agent': CFG.ua }, timeout: 5000, anonymous: false, onload() { _activeConnections = Math.max(0, _activeConnections - 1); }, onerror() { _activeConnections = Math.max(0, _activeConnections - 1); }, ontimeout() { _activeConnections = Math.max(0, _activeConnections - 1); } });
}
} catch (e) {}
finally { _bgPreloadInFlight.delete(videoPath); }
})();
}
if (_bgPreloadCache.size > 50) { const entries = [..._bgPreloadCache.entries()]; _bgPreloadCache = new Map(entries.slice(entries.length - 30)); }
}
function getPreloadedUrl(videoPath) { return _bgPreloadCache.get(videoPath) || null; }
// =========================================================================
// RENDERING - CREATORS / TAGS / RECOMMENDED
// =========================================================================
async function renderCreators(creators) {
const page = document.getElementById('explore-page-creators');
if (!page) return;
const shuffled = [...creators].sort(() => Math.random() - 0.5);
page.innerHTML = `<div class="creator-grid">${shuffled.map(c => `<div class="creator-card" data-service="${escapeAttr(c.service)}" data-id="${escapeAttr(c.id)}"><img class="creator-banner" src="${escapeAttr(bannerUrl(c.service, c.id))}" onerror="this.style.display='none'"><div class="creator-info"><div class="creator-name">${escapeHtml(c.name || c.id)}</div><div class="creator-service">${escapeHtml(c.service)}</div></div></div>`).join('')}</div>`;
page.querySelectorAll('.creator-card').forEach(card => { card.addEventListener('click', () => playCreator(card.dataset.service, card.dataset.id)); });
}
async function renderRecommended() {
const page = document.getElementById('explore-page-recommended');
if (!page) return;
page.innerHTML = '<div class="explore-loading"><div class="explore-loading-spinner"></div>Loading recommended creators...</div>';
let recommended = [];
if (currentPlaybackContext?.type === 'creator') {
const apiRecs = await getRecommendedCreators(currentPlaybackContext.service, currentPlaybackContext.id);
if (apiRecs.length > 0) recommended = apiRecs;
}
if (recommended.length === 0) {
const allCreators = await loadExternalCreatorsList();
if (!allCreators?.length) { page.innerHTML = '<div class="explore-loading">No recommended creators found</div>'; return; }
recommended = [...allCreators].sort(() => Math.random() - 0.5).slice(0, 30);
}
page.innerHTML = `<div class="creator-grid">${recommended.map(c => `<div class="creator-card" data-service="${escapeAttr(c.service)}" data-id="${escapeAttr(c.id)}"><img class="creator-banner" src="${escapeAttr(bannerUrl(c.service, c.id))}" onerror="this.style.display='none'"><div class="creator-info"><div class="creator-name">${escapeHtml(c.name || c.id)}</div><div class="creator-service">${escapeHtml(c.service)}</div></div></div>`).join('')}</div>`;
page.querySelectorAll('.creator-card').forEach(card => { card.addEventListener('click', () => playCreator(card.dataset.service, card.dataset.id)); });
}
async function renderTags() {
const page = document.getElementById('explore-page-tags');
if (!page) return;
page.innerHTML = '<div class="explore-loading"><div class="explore-loading-spinner"></div>Loading tags...</div>';
const tags = await loadExternalTags();
const shuffled = [...tags].sort(() => Math.random() - 0.5);
page.innerHTML = `<div class="tag-grid">${shuffled.map(t => `<div class="tag-card" data-tag="${escapeAttr(t.tag)}"><div class="tag-name">#${escapeHtml(t.tag)}</div><div class="tag-count">${t.count.toLocaleString()} posts</div><div class="tag-category">${escapeHtml(t.category)}</div></div>`).join('')}</div>`;
page.querySelectorAll('.tag-card').forEach(card => { card.addEventListener('click', () => playTag(card.dataset.tag)); });
}
// =========================================================================
// SMART SEARCH — Opens a dedicated explorer overlay on top
// =========================================================================
function createSmartSearchTab() {
let container = document.getElementById('smart-search-view');
if (!container) {
container = document.createElement('div');
container.id = 'smart-search-view';
container.innerHTML = `
<div class="smart-search-panel">
<div class="smart-search-title">🔍 Smart Search</div>
<div class="smart-search-row">
<div class="smart-search-field">
<label>Tag 1</label>
<select id="smart-tag1">
<option value="">-- Select Category --</option>
${TAG_CATEGORIES.map(cat => `<optgroup label="${escapeHtml(cat.label)}">${cat.tags.map(t => `<option value="${escapeAttr(t)}">${escapeHtml(t)}</option>`).join('')}</optgroup>`).join('')}
</select>
</div>
<div class="smart-search-field">
<label>Tag 2 (Optional)</label>
<select id="smart-tag2">
<option value="">-- None --</option>
${TAG_CATEGORIES.map(cat => `<optgroup label="${escapeHtml(cat.label)}">${cat.tags.map(t => `<option value="${escapeAttr(t)}">${escapeHtml(t)}</option>`).join('')}</optgroup>`).join('')}
</select>
</div>
</div>
<div class="smart-search-field smart-search-keyword">
<label>Keyword (Optional)</label>
<input type="text" id="smart-query" placeholder="e.g. outdoor, amateur, lingerie..." />
</div>
<button class="smart-search-btn" id="btn-smart-search">▶ Search & Explore</button>
</div>
`;
}
const btn = container.querySelector('#btn-smart-search');
if (btn) {
btn.onclick = async () => {
const t1 = container.querySelector('#smart-tag1').value;
const t2 = container.querySelector('#smart-tag2').value;
const q = container.querySelector('#smart-query').value.trim();
if (!t1 && !t2 && !q) { alert('Select at least one filter'); return; }
const labelParts = [t1, t2, q].filter(Boolean);
const searchLabel = labelParts.join(' + ');
// Show loading state on button
btn.disabled = true;
btn.textContent = '⏳ Searching...';
try {
const params = new URLSearchParams();
if (t1) params.append('tag', t1);
if (t2) params.append('tag', t2);
if (q) params.set('q', q);
const raw = await gmFetch(`/api/v1/posts?${params.toString()}`);
const data = Array.isArray(raw) ? raw : (raw?.results || raw?.posts || []);
if (!data || data.length === 0) {
alert('No results found for this search.');
return;
}
// Open the full explorer overlay with results
openSmartSearchExplorer(data, searchLabel, { t1, t2, q, offset: data.length, hasMore: data.length >= 50 });
} catch (e) {
alert(`Search error: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = '▶ Search & Explore';
}
};
}
return container;
}
// =========================================================================
// SMART SEARCH EXPLORER OVERLAY
// =========================================================================
function openSmartSearchExplorer(posts, label, searchCtx) {
// Remove any existing overlay
document.getElementById('smart-search-overlay')?.remove();
const videoPosts = posts.filter(p => isVideoPost(p));
const allPosts = posts;
const overlay = document.createElement('div');
overlay.id = 'smart-search-overlay';
overlay.innerHTML = `
<div class="sso-header">
<button class="sso-back" id="sso-back-btn">← Back</button>
<div class="sso-title">🔍 ${escapeHtml(label)}</div>
<div class="sso-count" id="sso-count">${allPosts.length} results</div>
</div>
<div class="sso-content">
<div class="sso-mode-tabs">
<button class="sso-mode-btn active" data-mode="videos">▶ Videos (${videoPosts.length})</button>
<button class="sso-mode-btn" data-mode="all">All Posts (${allPosts.length})</button>
</div>
<div id="sso-body"></div>
</div>
`;
document.body.appendChild(overlay);
// Back button
overlay.querySelector('#sso-back-btn').addEventListener('click', () => {
overlay.remove();
});
// Mode tabs
let currentMode = 'videos';
overlay.querySelectorAll('.sso-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
overlay.querySelectorAll('.sso-mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMode = btn.dataset.mode;
renderSSOBody(currentMode);
});
});
// State for lazy-load
let ssoAllPosts = [...allPosts];
let ssoOffset = searchCtx.offset || allPosts.length;
let ssoLoading = false;
let ssoHasMore = searchCtx.hasMore;
function renderSSOBody(mode) {
const body = overlay.querySelector('#sso-body');
if (!body) return;
const displayPosts = mode === 'videos' ? ssoAllPosts.filter(p => isVideoPost(p)) : ssoAllPosts;
if (displayPosts.length === 0) {
body.innerHTML = '<div class="sso-empty">No ' + (mode === 'videos' ? 'video' : '') + ' posts found.</div>';
return;
}
// Play All button (only for videos mode)
const playAllBtn = mode === 'videos' ? `
<button class="sso-play-all" id="sso-play-all">
▶ Play All Videos (${displayPosts.length})
</button>
` : '';
body.innerHTML = `
${playAllBtn}
<div class="sso-grid" id="sso-cards"></div>
${ssoHasMore ? '<button class="trending-load-more" id="sso-load-more">Load More</button>' : ''}
`;
// Play All
body.querySelector('#sso-play-all')?.addEventListener('click', () => {
overlay.remove();
launchSmartSearchPlayer(displayPosts, label, searchCtx);
});
// Load More
body.querySelector('#sso-load-more')?.addEventListener('click', () => loadMoreSSO());
// Cards
renderSSOCards(body.querySelector('#sso-cards'), displayPosts, mode);
}
function renderSSOCards(container, posts, mode) {
if (!container) return;
posts.forEach(post => {
const p = post.post || post;
const service = p.service || post.service || 'unknown';
const user = p.user || post.user || 'unknown';
const postId = p.id || post.id || '';
const title = p.title || p.substring || '';
const hasVideo = isVideoPost(post);
const watched = isWatched(postId);
const file = p.file || {};
const attachments = p.attachments || [];
let thumbUrl = '';
if (file.path && /\.(jpe?g|png|gif|webp)$/i.test(file.path)) thumbUrl = cdnUrl(file.path, preferredNode);
else { for (const att of attachments) { if (att.path && /\.(jpe?g|png|gif|webp)$/i.test(att.path)) { thumbUrl = cdnUrl(att.path, preferredNode); break; } } }
if (!thumbUrl) thumbUrl = avatarUrl(service, user);
const card = document.createElement('div');
card.className = 'trending-card';
card.style.opacity = watched ? '0.5' : '1';
card.innerHTML = `
<div class="trending-thumb">
<img src="${escapeAttr(thumbUrl)}" loading="lazy" onerror="this.style.display='none'">
${hasVideo ? '<div class="trending-play-icon">▶</div>' : ''}
</div>
<div class="trending-card-info">
<div class="trending-card-title">${escapeHtml(title || 'Untitled post')}</div>
<div class="trending-card-meta">
<span class="trending-card-creator">${escapeHtml(user)}</span>
<span class="trending-card-service">${escapeHtml(service)}</span>
</div>
</div>
`;
card.addEventListener('click', () => {
if (hasVideo) {
overlay.remove();
// Play just this video, then chain into creator
playCreatorFromPost(post, service, user, label);
} else {
window.open(`${COOMER_ORIGIN}/${service}/user/${user}/post/${postId}`, '_blank');
}
});
container.appendChild(card);
});
}
async function loadMoreSSO() {
if (ssoLoading || !ssoHasMore) return;
ssoLoading = true;
const loadBtn = overlay.querySelector('#sso-load-more');
if (loadBtn) { loadBtn.disabled = true; loadBtn.textContent = 'Loading...'; }
try {
const params = new URLSearchParams();
if (searchCtx.t1) params.append('tag', searchCtx.t1);
if (searchCtx.t2) params.append('tag', searchCtx.t2);
if (searchCtx.q) params.set('q', searchCtx.q);
params.set('o', ssoOffset);
const raw = await gmFetch(`/api/v1/posts?${params.toString()}`);
const newPosts = Array.isArray(raw) ? raw : (raw?.results || raw?.posts || []);
if (newPosts.length > 0) {
ssoAllPosts.push(...newPosts);
ssoOffset += newPosts.length;
ssoHasMore = newPosts.length >= 50;
// Update count display
const countEl = overlay.querySelector('#sso-count');
if (countEl) countEl.textContent = `${ssoAllPosts.length} results`;
// Append new cards to current grid
const grid = overlay.querySelector('#sso-cards');
if (grid) {
const displayPosts = currentMode === 'videos' ? newPosts.filter(p => isVideoPost(p)) : newPosts;
renderSSOCards(grid, displayPosts, currentMode);
}
} else {
ssoHasMore = false;
}
} catch (e) {
console.error('[SmartSearch] Load more error:', e);
} finally {
ssoLoading = false;
const loadBtn = overlay.querySelector('#sso-load-more');
if (loadBtn) {
if (ssoHasMore) { loadBtn.disabled = false; loadBtn.textContent = 'Load More'; }
else { loadBtn.textContent = 'No more results'; loadBtn.disabled = true; }
}
}
}
// Initial render
renderSSOBody('videos');
}
// =========================================================================
// SMART SEARCH → PLAYER LAUNCH
// =========================================================================
function launchSmartSearchPlayer(posts, label, searchCtx) {
const videoPosts = posts.filter(p => isVideoPost(p) && !isWatched(getPostId(p)));
if (videoPosts.length === 0) { alert('No unwatched videos to play.'); return; }
const page = document.getElementById('explore-page-player');
if (!page) return;
page.classList.add('active');
document.querySelectorAll('.explore-page').forEach(p => { if (p.id !== 'explore-page-player') p.classList.remove('active'); });
// Switch active tab visual
document.querySelectorAll('.explore-tab').forEach(t => t.classList.remove('active'));
currentPlaybackContext = {
type: 'smartsearch',
label,
searchCtx,
offset: searchCtx.offset || posts.length,
hasMore: searchCtx.hasMore ?? false,
_allFetched: [...posts]
};
renderVideoPlayer(videoPosts, `Search: ${label}`);
}
async function playCreatorFromPost(post, service, user, label) {
const page = document.getElementById('explore-page-player');
if (!page) return;
page.classList.add('active');
document.querySelectorAll('.explore-page').forEach(p => { if (p.id !== 'explore-page-player') p.classList.remove('active'); });
currentPlaybackContext = { type: 'creator', service, id: user, offset: 0, hasMore: true };
renderVideoPlayer([post], label || `${user} (${service})`);
try {
const result = await getCreatorPosts(service, user, 0);
const videoPosts = result.posts.filter(p => {
const pid = getPostId(p); const trendingPid = getPostId(post);
return isVideoPost(p) && !isWatched(pid) && pid !== trendingPid;
});
currentPlaybackContext = { type: 'creator', service, id: user, offset: result.posts.length, hasMore: result.hasMore };
if (videoPosts.length > 0) appendVideosToPlayer(videoPosts);
} catch (e) { console.warn('[Explore] Error loading creator after search pick:', e); }
}
// =========================================================================
// MATCHED CREATORS TAB
// =========================================================================
function createMatchedCreatorsTab() {
let container = document.getElementById('matched-creators-view');
if (!container) {
container = document.createElement('div');
container.className = 'trending-container hidden';
container.id = 'matched-creators-view';
container.innerHTML = `<h2 style="color:#4ade80;padding:20px 20px 0;">Curated Creators</h2><div class="trending-grid" id="matched-grid" style="padding-top:16px;"></div>`;
setTimeout(() => {
const grid = container.querySelector('#matched-grid');
if (!grid) return;
MATCHED_CREATORS.forEach(c => {
const card = document.createElement('div'); card.className = 'trending-card'; card.style.cursor = 'pointer';
card.innerHTML = `<div class="trending-card-info"><div class="trending-card-title">${escapeHtml(c.id)}</div><div class="trending-card-meta"><span class="trending-card-service">${escapeHtml(c.service)}</span></div></div>`;
card.onclick = () => window.open(`${COOMER_ORIGIN}/${c.service}/user/${c.id}`, '_blank');
grid.appendChild(card);
});
}, 100);
}
return container;
}
// =========================================================================
// TRENDING PAGE
// =========================================================================
let trendingState = { period: 'day', offset: 0, posts: [], loading: false, date: getTodayDate(), renderVersion: 0 };
function getTodayDate() { return new Date().toISOString().split('T')[0]; }
function abbreviateNumber(value) {
if (value >= 1000) { const suffixes = ["", "k", "m", "b", "t"]; const suffixNum = Math.floor(("" + value).length / 3); let shortValue = parseFloat((suffixNum !== 0 ? value / Math.pow(1000, suffixNum) : value).toPrecision(2)); if (shortValue % 1 !== 0) shortValue = shortValue.toFixed(1); return shortValue + suffixes[suffixNum]; }
return value;
}
async function fetchPopularPosts(period, offset = 0) {
const date = trendingState.date || getTodayDate();
const result = await gmFetch(`/api/v1/posts/popular?date=${date}&period=${period}&o=${offset}`);
if (result && typeof result === 'object' && Array.isArray(result.posts)) return result;
if (Array.isArray(result)) return { posts: result, info: {}, props: {} };
return { posts: [], info: {}, props: {} };
}
function buildTrendingCardHTML(post) {
const p = post.post || post;
const service = p.service || post.service || 'unknown';
const user = p.user || post.user || 'unknown';
const postId = p.id || post.id || '';
const title = p.title || p.substring || '';
const hasVideo = isVideoPost(post);
const favCount = p.fav_count || post.fav_count || 0;
let thumbUrl = '';
const file = p.file || {}; const attachments = p.attachments || [];
if (file.path && /\.(jpe?g|png|gif|webp)$/i.test(file.path)) thumbUrl = cdnUrl(file.path, preferredNode);
else { for (const att of attachments) { if (att.path && /\.(jpe?g|png|gif|webp)$/i.test(att.path)) { thumbUrl = cdnUrl(att.path, preferredNode); break; } } }
if (!thumbUrl) thumbUrl = avatarUrl(service, user);
let dateStr = '';
const published = p.published || p.added || '';
if (published) { try { dateStr = new Date(published).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) {} }
const watchedStyle = isWatched(postId) ? 'style="opacity:0.5"' : '';
return `<div class="trending-card" data-service="${escapeAttr(service)}" data-user="${escapeAttr(user)}" data-post-id="${escapeAttr(postId)}" ${watchedStyle}><div class="trending-thumb">${thumbUrl ? `<img src="${escapeAttr(thumbUrl)}" loading="lazy">` : '<div style="width:100%;height:100%;background:#1a1a1a;"></div>'}${hasVideo ? '<div class="trending-play-icon">▶</div>' : ''}</div><div class="trending-card-info"><div class="trending-card-title">${escapeHtml(title || 'Untitled post')}</div><div class="trending-card-meta"><span class="trending-card-creator">${escapeHtml(user)}</span><span class="trending-card-service">${escapeHtml(service)}</span></div></div></div>`;
}
async function renderTrending(period, forceReload = false) {
const page = document.getElementById('explore-page-trending');
if (!page) return;
if (!period && !forceReload && trendingState.posts.length > 0) return;
if (period) { trendingState.period = period; trendingState.date = getTodayDate(); }
trendingState.offset = 0; trendingState.posts = []; trendingState.loading = false;
trendingState.renderVersion++;
const currentVersion = trendingState.renderVersion;
page.innerHTML = `<div class="trending-header" style="padding:16px 20px 0;"><div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;"><label style="color:#9ca3af;font-weight:600;">Period:</label><button class="trending-period-btn ${trendingState.period==='day'?'active':''}" data-period="day">Today</button><button class="trending-period-btn ${trendingState.period==='week'?'active':''}" data-period="week">This Week</button><button class="trending-period-btn ${trendingState.period==='month'?'active':''}" data-period="month">This Month</button></div></div><div class="trending-grid" style="margin-top:20px;"><div class="explore-loading" style="grid-column:1/-1;padding:60px;"><div class="explore-loading-spinner"></div></div></div>`;
page.querySelectorAll('.trending-period-btn').forEach(btn => { btn.addEventListener('click', () => renderTrending(btn.dataset.period)); });
try {
const result = await fetchPopularPosts(trendingState.period, 0);
if (trendingState.renderVersion !== currentVersion) return;
const posts = result.posts || [];
trendingState.posts = posts; trendingState.offset = posts.length;
const grid = page.querySelector('.trending-grid');
if (!posts.length) { grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:60px;color:#6b7280;">No trending posts found.</div>'; return; }
grid.innerHTML = posts.map(p => buildTrendingCardHTML(p)).join('');
attachTrendingCardListeners(page);
if (posts.length >= 50) { const btn = document.createElement('button'); btn.className = 'trending-load-more'; btn.textContent = 'Load More'; btn.addEventListener('click', () => loadMoreTrending()); page.appendChild(btn); }
} catch (e) { page.querySelector('.trending-grid').innerHTML = `<div style="grid-column:1/-1;text-align:center;padding:60px;color:#f87171;">Error: ${escapeHtml(e.message)}</div>`; }
}
async function loadMoreTrending() {
if (trendingState.loading) return;
trendingState.loading = true;
const currentVersion = trendingState.renderVersion;
const page = document.getElementById('explore-page-trending');
const loadMoreBtn = page?.querySelector('.trending-load-more');
if (loadMoreBtn) { loadMoreBtn.disabled = true; loadMoreBtn.textContent = 'Loading...'; }
try {
const result = await fetchPopularPosts(trendingState.period, trendingState.offset);
if (trendingState.renderVersion !== currentVersion) return;
const posts = result.posts || [];
if (!posts.length) { if (loadMoreBtn) { loadMoreBtn.textContent = 'No more posts'; loadMoreBtn.disabled = true; } trendingState.loading = false; return; }
trendingState.posts.push(...posts); trendingState.offset += posts.length;
const grid = page?.querySelector('.trending-grid');
if (grid) { grid.insertAdjacentHTML('beforeend', posts.map(p => buildTrendingCardHTML(p)).join('')); attachTrendingCardListeners(page); }
if (loadMoreBtn) { loadMoreBtn.disabled = false; loadMoreBtn.textContent = posts.length < 50 ? 'No more posts' : 'Load More'; if (posts.length < 50) loadMoreBtn.disabled = true; }
trendingState.loading = false;
} catch (e) { if (loadMoreBtn) { loadMoreBtn.disabled = false; loadMoreBtn.textContent = 'Retry'; } trendingState.loading = false; }
}
function attachTrendingCardListeners(page) {
page.querySelectorAll('.trending-card').forEach(card => {
if (card._listenerAttached) return; card._listenerAttached = true;
card.addEventListener('click', () => {
const service = card.dataset.service; const user = card.dataset.user; const postId = card.dataset.postId;
const post = trendingState.posts.find(p => { const pp = p.post || p; return String(pp.id || p.id) === postId; });
if (post && isVideoPost(post)) playTrendingVideo(post, service, user);
else playCreator(service, user);
});
});
}
async function playTrendingVideo(post, service, user) {
const page = document.getElementById('explore-page-player');
if (!page) return;
page.innerHTML = `<div class="explore-loading"><div class="explore-loading-spinner"></div>Loading video...</div>`;
page.classList.add('active');
document.querySelectorAll('.explore-page').forEach(p => { if (p.id !== 'explore-page-player') p.classList.remove('active'); });
currentPlaybackContext = { type: 'creator', service, id: user, offset: 0, hasMore: true };
renderVideoPlayer([post], `Trending – ${user} (${service})`);
try {
const result = await getCreatorPosts(service, user, 0);
const videoPosts = result.posts.filter(p => { const pid = getPostId(p); const trendingPid = getPostId(post); return isVideoPost(p) && !isWatched(pid) && pid !== trendingPid; });
currentPlaybackContext = { type: 'creator', service, id: user, offset: result.posts.length, hasMore: result.hasMore };
if (videoPosts.length > 0) appendVideosToPlayer(videoPosts);
} catch (e) { console.warn('[Explore] Error loading creator after trending:', e); }
}
async function playCreator(service, id, offset = 0) {
const page = document.getElementById('explore-page-player');
if (!page) return;
if (offset === 0) {
page.innerHTML = `<div class="explore-loading"><div class="explore-loading-spinner"></div>Loading videos from ${escapeHtml(id)}...</div>`;
page.classList.add('active');
document.querySelectorAll('.explore-page').forEach(p => { if (p.id !== 'explore-page-player') p.classList.remove('active'); });
}
try {
const result = await getCreatorPosts(service, id, offset);
const videoPosts = result.posts.filter(p => isVideoPost(p) && !isWatched(getPostId(p)));
const artistName = result.artist?.name || id;
if (videoPosts.length === 0 && offset === 0) {
if (result.hasMore && result.posts.filter(isVideoPost).length > 0) { if (offset > 1000) { page.innerHTML = `<div class="explore-loading">Too many watched videos skipped.</div>`; return; } return playCreator(service, id, offset + result.posts.length); }
page.innerHTML = `<div style="padding:40px;text-align:center;color:#9ca3af;"><p>No unwatched videos found for ${escapeHtml(artistName)}</p><button class="back-btn" style="position:static;display:inline-block;margin-top:16px;">← Back to Creators</button></div>`;
page.querySelector('.back-btn')?.addEventListener('click', () => { document.querySelector('.explore-tab[data-tab="creators"]')?.click(); });
return;
}
visitedCreators.add(`${service}/${id}`);
currentPlaybackContext = { type: 'creator', service, id, offset: offset + result.posts.length, hasMore: result.hasMore };
if (offset === 0) renderVideoPlayer(videoPosts, `${artistName} (${service})`);
else appendVideosToPlayer(videoPosts);
} catch (e) { if (offset === 0) page.innerHTML = `<div class="explore-loading" style="color:#f87171;">Error: ${escapeHtml(e.message)}</div>`; }
}
function filterDiverseAuthors(posts, maxPerAuthor = 3) {
const authorCounts = {};
return posts.filter(p => { const author = p.user || p.author || 'unknown'; authorCounts[author] = (authorCounts[author] || 0) + 1; return authorCounts[author] <= maxPerAuthor; });
}
async function playTag(tag, offset = 0) {
const page = document.getElementById('explore-page-player');
if (!page) return;
if (offset === 0) {
page.innerHTML = `<div class="explore-loading"><div class="explore-loading-spinner"></div>Loading videos for #${escapeHtml(tag)}...</div>`;
page.classList.add('active');
document.querySelectorAll('.explore-page').forEach(p => { if (p.id !== 'explore-page-player') p.classList.remove('active'); });
}
try {
const result = await getPostsByTag(tag, offset);
let videoPosts = result.posts.filter(p => isVideoPost(p) && !isWatched(getPostId(p)));
videoPosts = filterDiverseAuthors(videoPosts, 2);
if (videoPosts.length === 0 && offset === 0) {
if (result.hasMore && result.posts.filter(isVideoPost).length > 0) { if (offset > 1000) { page.innerHTML = `<div class="explore-loading">Too many watched videos skipped.</div>`; return; } return playTag(tag, offset + result.posts.length); }
page.innerHTML = '<div class="explore-loading">No unwatched videos found</div>'; return;
}
currentPlaybackContext = { type: 'tag', tag, offset: offset + result.posts.length, hasMore: result.hasMore };
if (offset === 0) renderVideoPlayer(videoPosts, `#${tag}`);
else appendVideosToPlayer(videoPosts);
} catch (e) { if (offset === 0) page.innerHTML = `<div class="explore-loading" style="color:#f87171;">Error: ${escapeHtml(e.message)}</div>`; }
}
async function loadMoreVideos() {
if (!currentPlaybackContext || isLoadingMore) return;
// Smart search: load more from API
if (currentPlaybackContext.type === 'smartsearch') {
return loadMoreSmartSearch();
}
if (!currentPlaybackContext.hasMore) {
if (currentPlaybackContext.type === 'creator' && !currentPlaybackContext._recommendedExhausted) return loadRecommendedChain();
return;
}
isLoadingMore = true;
const player = document.querySelector('.video-player');
if (player) { player.insertAdjacentHTML('beforeend', Array(3).fill(`<div class="video-slide loading-placeholder"><div class="explore-loading"><div class="explore-loading-spinner"></div></div></div>`).join('')); }
try {
if (currentPlaybackContext.type === 'creator') await playCreator(currentPlaybackContext.service, currentPlaybackContext.id, currentPlaybackContext.offset);
else if (currentPlaybackContext.type === 'tag') await playTag(currentPlaybackContext.tag, currentPlaybackContext.offset);
else if (currentPlaybackContext.type === 'mixer') await loadNextMixBatch();
} finally { if (player) player.querySelectorAll('.loading-placeholder').forEach(el => el.remove()); isLoadingMore = false; }
}
async function loadMoreSmartSearch() {
if (isLoadingMore) return;
const ctx = currentPlaybackContext;
if (!ctx || !ctx.hasMore || !ctx.searchCtx) return;
isLoadingMore = true;
try {
const params = new URLSearchParams();
if (ctx.searchCtx.t1) params.append('tag', ctx.searchCtx.t1);
if (ctx.searchCtx.t2) params.append('tag', ctx.searchCtx.t2);
if (ctx.searchCtx.q) params.set('q', ctx.searchCtx.q);
params.set('o', ctx.offset);
const raw = await gmFetch(`/api/v1/posts?${params.toString()}`);
const newPosts = Array.isArray(raw) ? raw : (raw?.results || raw?.posts || []);
if (newPosts.length > 0) {
ctx.offset += newPosts.length;
ctx.hasMore = newPosts.length >= 50;
const videoPosts = newPosts.filter(p => isVideoPost(p) && !isWatched(getPostId(p)));
if (videoPosts.length > 0) appendVideosToPlayer(videoPosts);
} else { ctx.hasMore = false; }
} catch (e) { console.error('[SmartSearch] loadMore error:', e); }
finally { isLoadingMore = false; }
}
async function loadRecommendedChain() {
if (!currentPlaybackContext || currentPlaybackContext.type !== 'creator' || isLoadingMore) return;
isLoadingMore = true;
const player = document.querySelector('.video-player');
try {
const ctx = currentPlaybackContext;
if (player && !player.querySelector('.loading-placeholder')) { player.insertAdjacentHTML('beforeend', `<div class="video-slide loading-placeholder"><div class="explore-loading"><div class="explore-loading-spinner"></div></div></div>`); }
while (true) {
if (!ctx._recommendedQueue?.length) {
if (ctx._recommendedExhausted) break;
const recs = await getRecommendedCreators(ctx.service, ctx.id);
if (recs?.length > 0) ctx._recommendedQueue = recs.sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 10);
else { ctx._recommendedExhausted = true; break; }
}
const next = ctx._recommendedQueue.shift();
if (!next) { ctx._recommendedExhausted = true; continue; }
const nextService = next.service || ctx.service; const nextId = next.id || next.name;
const creatorKey = `${nextService}/${nextId}`;
if (visitedCreators.has(creatorKey)) continue;
visitedCreators.add(creatorKey);
const result = await getCreatorPosts(nextService, nextId, 0);
const videoPosts = result.posts.filter(p => isVideoPost(p) && !isWatched(getPostId(p)));
if (videoPosts.length > 0) {
const savedQueue = ctx._recommendedQueue;
currentPlaybackContext = { type: 'creator', service: nextService, id: nextId, offset: result.posts.length, hasMore: result.hasMore, _recommendedQueue: savedQueue, _parentCreator: ctx._parentCreator || { service: ctx.service, id: ctx.id } };
appendVideosToPlayer(videoPosts);
break;
}
}
} catch (e) { console.error('[Explore] Error in recommended chain:', e); }
finally { if (player) player.querySelectorAll('.loading-placeholder').forEach(el => el.remove()); isLoadingMore = false; }
}
// =========================================================================
// SLIDE BUILDING
// =========================================================================
function buildSlideHTML(post, index) {
const videoPath = getFirstVideoPath(post);
if (!videoPath || !/\.(mp4|webm|mov|avi|mkv|m4v)$/i.test(videoPath)) return '';
const p = post.post || post;
const service = p.service || post.service || 'unknown';
const user = p.user || post.user || 'unknown';
const postId = getPostId(post) || '';
const watchedClass = isWatched(postId) ? ' watched-marker' : '';
const title = p.title || p.substring || '';
const published = p.published || '';
let dateStr = '';
if (published) { try { dateStr = new Date(published).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) {} }
return `<div class="video-slide${watchedClass}" data-index="${index}" data-post-id="${escapeAttr(postId)}" data-service="${escapeAttr(service)}" data-user="${escapeAttr(user)}" data-video-path="${escapeAttr(videoPath)}"><div class="video-info"><div class="video-creator">${escapeHtml(user)}</div><div class="video-service">${escapeHtml(service)}</div>${title ? `<div class="video-title">${escapeHtml(title)}</div>` : ''}${dateStr ? `<div class="video-meta">${escapeHtml(dateStr)}</div>` : ''}</div></div>`;
}
// =========================================================================
// SLIDE OBSERVER & PLAYER
// =========================================================================
function createSlideObserver(player) {
return new IntersectionObserver((entries) => {
entries.forEach(entry => {
const slide = entry.target;
const video = slide.querySelector('video');
if (!video) return;
const idx = parseInt(slide.dataset.index, 10);
const postId = slide.dataset.postId;
const videoPath = slide.dataset.videoPath;
if (entry.isIntersecting && entry.intersectionRatio > 0.6) {
currentSlideIdx = idx;
const favBtn = player.closest('#explore-page-player')?.querySelector('.fav-btn');
if (favBtn && currentPlaylistPosts[idx]) { const p = currentPlaylistPosts[idx].post || currentPlaylistPosts[idx]; const key = `${p.service}:${p.user}:${p.id}`; favBtn.classList.toggle('active', !!(GM_getValue('favorites', {})[key])); }
player.querySelectorAll('.video-slide').forEach(otherSlide => {
const otherIdx = parseInt(otherSlide.dataset.index, 10);
if (otherIdx !== idx) {
const otherVideo = otherSlide.querySelector('video');
if (otherVideo && !otherVideo.paused) {
if (userManuallyPlaying && otherSlide._manualPlay) { const rect = otherSlide.getBoundingClientRect(); const vis = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)) / rect.height; if (vis >= 0.3) return; }
otherVideo.pause(); otherVideo.muted = true; otherSlide._manualPlay = false; stopWatchTimer(otherSlide.dataset.postId);
}
}
});
setupVideoErrorFallback(video, videoPath);
tryPlayVideo(video);
startWatchTimer(postId, video);
manageVideoResources(player);
updateSlideCounter(player);
} else if (!entry.isIntersecting || entry.intersectionRatio < 0.3) {
if (userManuallyPlaying && slide._manualPlay) { const rect = slide.getBoundingClientRect(); const vis = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)) / rect.height; if (vis >= 0.3) return; }
video.pause(); video.muted = true; slide._manualPlay = false; stopWatchTimer(postId);
}
});
}, { threshold: [0.3, 0.6] });
}
function appendVideosToPlayer(posts) {
const player = document.querySelector('.video-player');
if (!player) return;
const validPosts = posts.filter(p => getFirstVideoPath(p));
currentPlaylistPosts.push(...validPosts);
const currentSlideCount = player.querySelectorAll('.video-slide:not(.loading-placeholder)').length;
const slides = validPosts.map((post, i) => buildSlideHTML(post, currentSlideCount + i)).join('');
if (!slides?.trim()) return;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = slides;
const observer = player._slideObserver || createSlideObserver(player);
player._slideObserver = observer;
let appendedCount = 0;
while (tempDiv.firstChild) {
const slide = tempDiv.firstChild;
if (slide?.nodeType === 1) { player.appendChild(slide); observer.observe(slide); appendedCount++; }
else tempDiv.removeChild(slide);
}
updateSlideCounter(player);
manageVideoResources(player);
}
function updateSlideCounter(player) {
if (!player) return;
const totalSlides = player.querySelectorAll('.video-slide:not(.loading-placeholder)').length;
const counter = player.closest('#explore-page-player')?.querySelector('.slide-counter');
if (counter) counter.textContent = `${currentSlideIdx + 1} / ${totalSlides}`;
}
// =========================================================================
// MAIN VIDEO PLAYER RENDERER
// =========================================================================
function renderVideoPlayer(posts, title) {
const page = document.getElementById('explore-page-player');
if (!page) return;
if (page._keyHandler) { document.removeEventListener('keydown', page._keyHandler); page._keyHandler = null; }
if (page._favListener) { document.removeEventListener('keydown', page._favListener); page._favListener = null; }
if (page._slideObserver) { page._slideObserver.disconnect(); page._slideObserver = null; }
if (page._predictiveInterval) { clearInterval(page._predictiveInterval); page._predictiveInterval = null; }
currentSlideIdx = 0;
const validPosts = posts.filter(p => getFirstVideoPath(p));
currentPlaylistPosts = [...validPosts];
userManuallyPlaying = false;
activeWatchTimers.forEach(t => clearInterval(t)); activeWatchTimers.clear();
visitedCreators.clear();
const slides = validPosts.map((post, i) => buildSlideHTML(post, i)).join('');
page.innerHTML = `
<button class="back-btn">← Back to ${escapeHtml(title)}</button>
<div class="slide-counter">1 / ${validPosts.length}</div>
<button class="fav-btn">V</button>
<div class="video-player">${slides}</div>
`;
const favBtn = page.querySelector('.fav-btn');
if (validPosts[0]) { const p = validPosts[0].post || validPosts[0]; const k = `${p.service}:${p.user}:${p.id}`; if (GM_getValue('favorites', {})[k]) favBtn.classList.add('active'); }
favBtn.addEventListener('click', () => { if (currentPlaylistPosts[currentSlideIdx]) toggleFavorite(currentPlaylistPosts[currentSlideIdx], favBtn); });
if (!page._favListener) {
page._favListener = (e) => { if (e.key.toLowerCase() === 'v' && page.classList.contains('active') && currentPlaylistPosts[currentSlideIdx]) toggleFavorite(currentPlaylistPosts[currentSlideIdx], page.querySelector('.fav-btn')); };
document.addEventListener('keydown', page._favListener);
}
page.querySelector('.back-btn').addEventListener('click', () => {
if (page._slideObserver) { page._slideObserver.disconnect(); page._slideObserver = null; }
page.querySelectorAll('video').forEach(v => { v._disposed = true; clearTimeout(v._stallTimer); v.pause(); v.removeAttribute('src'); while (v.firstChild) v.removeChild(v.firstChild); v.load(); v.remove(); });
_activeConnections = 0; _trickleQueue = []; clearTimeout(_trickleTimer); _trickleTimer = null;
activeWatchTimers.forEach(t => clearInterval(t)); activeWatchTimers.clear();
const tabToClick = document.querySelector(`.explore-tab[data-tab="${lastActiveTab}"]`) || document.querySelector('.explore-tab[data-tab="creators"]');
tabToClick?.click();
});
const player = page.querySelector('.video-player');
if (page._slideObserver) { page._slideObserver.disconnect(); page._slideObserver = null; }
const observer = createSlideObserver(player);
page._slideObserver = observer;
player.querySelectorAll('.video-slide').forEach(s => observer.observe(s));
player.addEventListener('click', (e) => {
const slide = e.target.closest('.video-slide');
if (!slide) return;
const video = slide.querySelector('video');
if (video) { if (video.paused) { video.muted = false; video.play(); userManuallyPlaying = true; slide._manualPlay = true; } else { video.pause(); userManuallyPlaying = false; slide._manualPlay = false; } }
});
manageVideoResources(player);
page._predictiveInterval = setInterval(() => {
const allSlides = player.querySelectorAll('.video-slide:not(.loading-placeholder)');
if (allSlides.length - currentSlideIdx - 1 <= 5 && allSlides.length < currentSlideIdx + 20) loadMoreVideos();
}, 3000);
const keyHandler = (e) => {
if (!page.classList.contains('active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const allSlides = player.querySelectorAll('.video-slide:not(.loading-placeholder)');
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 's') { e.preventDefault(); userManuallyPlaying = false; allSlides[Math.min(currentSlideIdx + 1, allSlides.length - 1)]?.scrollIntoView({ behavior: 'smooth' }); }
else if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'w') { e.preventDefault(); userManuallyPlaying = false; allSlides[Math.max(currentSlideIdx - 1, 0)]?.scrollIntoView({ behavior: 'smooth' }); }
else if (e.key === ' ') { e.preventDefault(); const slide = allSlides[currentSlideIdx]; const video = slide?.querySelector('video'); if (video) { if (video.paused) { video.muted = false; video.play(); userManuallyPlaying = true; slide._manualPlay = true; } else { video.pause(); userManuallyPlaying = false; slide._manualPlay = false; } } }
else if (e.key === 'm') { const video = allSlides[currentSlideIdx]?.querySelector('video'); if (video) video.muted = !video.muted; }
else if (e.key === 'd' || e.key === 'ArrowRight') { e.preventDefault(); const slide = allSlides[currentSlideIdx]; const video = slide?.querySelector('video'); if (video) { video.currentTime = Math.min(video.currentTime + 5, video.duration); showSeekOverlay(slide, '+5s'); } }
else if (e.key === 'a' || e.key === 'ArrowLeft') { e.preventDefault(); const slide = allSlides[currentSlideIdx]; const video = slide?.querySelector('video'); if (video) { video.currentTime = Math.max(video.currentTime - 5, 0); showSeekOverlay(slide, '-5s'); } }
};
document.addEventListener('keydown', keyHandler);
page._keyHandler = keyHandler;
}
// =========================================================================
// MIXER
// =========================================================================
let topCreatorsCache = null;
async function renderMixer() {
const page = document.getElementById('explore-page-mixer');
if (!page) return;
page.innerHTML = '<div class="explore-loading"><div class="explore-loading-spinner"></div>Loading top creators...</div>';
const creators = await fetchTopCreators();
if (!creators.length) { page.innerHTML = '<div class="explore-loading">Failed to load creators.</div>'; return; }
page.innerHTML = `<div class="mixer-controls"><button class="mixer-play-btn" id="mixer-start-btn">▶ Create Playlist</button><span style="color:#9ca3af;font-size:13px;">Select creators to mix</span></div><div class="mixer-grid">${creators.map(c => `<div class="mixer-card" data-service="${escapeAttr(c.service)}" data-id="${escapeAttr(c.id)}"><img class="mixer-avatar" src="${escapeAttr(avatarUrl(c.service, c.id))}" onerror="this.src=''" alt=""><div class="mixer-info"><div class="mixer-name">${escapeHtml(c.name || c.id)}</div><div class="mixer-stats">❤️ ${abbreviateNumber(c.favorited || 0)}</div></div><div class="mixer-checkbox"></div></div>`).join('')}</div>`;
page.querySelectorAll('.mixer-card').forEach(card => { card.addEventListener('click', () => card.classList.toggle('selected')); });
document.getElementById('mixer-start-btn').addEventListener('click', () => {
const selected = Array.from(page.querySelectorAll('.mixer-card.selected')).map(el => ({ service: el.dataset.service, id: el.dataset.id }));
if (!selected.length) { alert('Please select at least one creator!'); return; }
playMix(selected);
});
}
async function fetchTopCreators() {
if (topCreatorsCache) return topCreatorsCache;
try { const data = await gmFetch('/api/v1/creators'); if (Array.isArray(data)) { topCreatorsCache = data.sort((a, b) => (b.favorited || 0) - (a.favorited || 0)).slice(0, 100); return topCreatorsCache; } return []; }
catch (e) { return []; }
}
async function loadNextMixBatch() {
if (!currentPlaybackContext || currentPlaybackContext.type !== 'mixer') return;
const ctx = currentPlaybackContext;
const batch = ctx.creators.slice(ctx.nextIndex, ctx.nextIndex + 5);
if (!batch.length) { ctx.hasMore = false; return; }
ctx.nextIndex += 5;
if (ctx.nextIndex >= ctx.creators.length) ctx.hasMore = false;
try {
const results = await Promise.all(batch.map(c => getCreatorPosts(c.service, c.id, 0)));
let newPosts = [];
for (const res of results) { if (res.posts?.length) newPosts.push(...res.posts.filter(p => isVideoPost(p) || p.file?.path || p.attachments?.length).slice(0, 5)); }
newPosts.sort(() => Math.random() - 0.5);
if (newPosts.length > 0) appendVideosToPlayer(newPosts);
else if (ctx.hasMore) await loadNextMixBatch();
} catch (e) { console.error(e); }
}
async function playMix(creators) {
const page = document.getElementById('explore-page-player');
if (!page) return;
page.innerHTML = `<div class="explore-loading"><div class="explore-loading-spinner"></div>Starting playlist...</div>`;
page.classList.add('active');
document.querySelectorAll('.explore-page').forEach(p => { if (p.id !== 'explore-page-player') p.classList.remove('active'); });
const shuffled = [...creators].sort(() => Math.random() - 0.5);
try {
const results = await Promise.all(shuffled.slice(0, 5).map(c => getCreatorPosts(c.service, c.id, 0)));
let initialPosts = [];
for (const res of results) { if (res.posts?.length) initialPosts.push(...res.posts.filter(p => isVideoPost(p) || p.file?.path || p.attachments?.length).slice(0, 5)); }
initialPosts.sort(() => Math.random() - 0.5);
if (!initialPosts.length) { page.innerHTML = '<div class="explore-loading">No content found.</div>'; return; }
currentPlaybackContext = { type: 'mixer', creators: shuffled, nextIndex: 5, hasMore: shuffled.length > 5 };
renderVideoPlayer(initialPosts, 'Custom Playlist');
} catch (e) { page.innerHTML = `<div class="explore-loading" style="color:#f87171;">Error: ${escapeHtml(e.message)}</div>`; }
}
function toggleFavorite(post, btn) {
const p = post.post || post;
const favs = GM_getValue('favorites', {});
const key = `${p.service}:${p.user}:${p.id}`;
if (favs[key]) { delete favs[key]; if (btn) btn.classList.remove('active'); }
else { favs[key] = { service: p.service, user: p.user, id: p.id, title: p.title, date: new Date().toISOString() }; if (btn) btn.classList.add('active'); }
GM_setValue('favorites', favs);
}
// =========================================================================
// MOUNT EXPLORE
// =========================================================================
function isExplorePage() { return window.location.hash === EXPLORE_HASH; }
function unmountExplore() {
const root = document.getElementById('explore-root');
if (root) {
const playerPage = document.getElementById('explore-page-player');
if (playerPage) {
if (playerPage._keyHandler) { document.removeEventListener('keydown', playerPage._keyHandler); playerPage._keyHandler = null; }
if (playerPage._favListener) { document.removeEventListener('keydown', playerPage._favListener); playerPage._favListener = null; }
if (playerPage._predictiveInterval) { clearInterval(playerPage._predictiveInterval); playerPage._predictiveInterval = null; }
if (playerPage._slideObserver) { playerPage._slideObserver.disconnect(); playerPage._slideObserver = null; }
}
root.querySelectorAll('video').forEach(v => { v._disposed = true; clearTimeout(v._stallTimer); v.pause(); v.removeAttribute('src'); while (v.firstChild) v.removeChild(v.firstChild); v.load(); v.remove(); });
_activeConnections = 0; _recentlyEvicted = []; _trickleQueue = []; clearTimeout(_trickleTimer); _trickleTimer = null;
activeWatchTimers.forEach(t => clearInterval(t)); activeWatchTimers.clear();
root.remove();
}
document.getElementById('smart-search-overlay')?.remove();
}
async function mountExplore() {
if (document.getElementById('explore-root')) return;
const root = document.createElement('div');
root.id = 'explore-root';
root.innerHTML = `
<div class="explore-header">
<div class="explore-tabs">
<button class="explore-tab active" data-tab="creators">Creators</button>
<button class="explore-tab" data-tab="recommended">Recommended</button>
<button class="explore-tab" data-tab="tags">Tags</button>
<button class="explore-tab" data-tab="trending">Trending</button>
<button class="explore-tab" data-tab="smartsearch">Smart Search</button>
<button class="explore-tab" data-tab="matched">Curated</button>
<button class="explore-tab" data-tab="mixer">Mixer</button>
</div>
<button class="explore-close">✕ Close</button>
</div>
<div class="explore-content">
<div id="explore-page-creators" class="explore-page active"></div>
<div id="explore-page-recommended" class="explore-page"></div>
<div id="explore-page-tags" class="explore-page"></div>
<div id="explore-page-trending" class="explore-page"></div>
<div id="explore-page-smartsearch" class="explore-page"></div>
<div id="explore-page-matched" class="explore-page"></div>
<div id="explore-page-mixer" class="explore-page"></div>
<div id="explore-page-player" class="explore-page"></div>
</div>
`;
document.body.appendChild(root);
root.querySelectorAll('.explore-tab').forEach(tab => {
tab.addEventListener('click', () => {
root.querySelectorAll('video').forEach(v => { v.pause(); v.muted = true; });
activeWatchTimers.forEach(t => clearInterval(t)); activeWatchTimers.clear();
root.querySelectorAll('.explore-tab').forEach(t => t.classList.remove('active'));
root.querySelectorAll('.explore-page').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
if (tab.dataset.tab !== 'player') lastActiveTab = tab.dataset.tab;
const page = document.getElementById(`explore-page-${tab.dataset.tab}`);
if (page) page.classList.add('active');
if (tab.dataset.tab === 'tags') renderTags();
else if (tab.dataset.tab === 'recommended') renderRecommended();
else if (tab.dataset.tab === 'trending') renderTrending();
else if (tab.dataset.tab === 'mixer') renderMixer();
else if (tab.dataset.tab === 'smartsearch') {
const main = document.getElementById('explore-page-smartsearch');
if (main) {
const ssContainer = createSmartSearchTab();
if (!main.contains(ssContainer)) main.appendChild(ssContainer);
}
} else if (tab.dataset.tab === 'matched') {
const main = document.getElementById('explore-page-matched');
if (main && !main.querySelector('#matched-creators-view')) {
const matchedContainer = createMatchedCreatorsTab();
main.appendChild(matchedContainer);
matchedContainer.classList.remove('hidden');
}
}
});
});
root.querySelector('.explore-close').addEventListener('click', () => { window.location.hash = ''; });
const creators = await getCreators();
if (creators.length > 0) renderCreators(creators);
else document.getElementById('explore-page-creators').innerHTML = '<div class="explore-loading">No creators found.</div>';
}
function addExploreLink() {
if (document.querySelector('[data-explore-link]')) return;
const nav = document.querySelector('header nav') || document.querySelector('nav') || document.querySelector('.header');
if (!nav) return;
const link = document.createElement('a');
link.href = '/' + EXPLORE_HASH; link.textContent = 'Explore'; link.setAttribute('data-explore-link', '1');
link.style.cssText = 'margin-left:12px;color:#4ade80;text-decoration:none;font-weight:600;';
link.addEventListener('click', e => { e.preventDefault(); window.location.hash = EXPLORE_HASH; });
nav.firstChild ? nav.insertBefore(link, nav.firstChild) : nav.appendChild(link);
}
if (isExplorePage()) mountExplore();
else { addExploreLink(); setTimeout(addExploreLink, 1500); }
window.addEventListener('hashchange', () => { if (isExplorePage()) { if (!document.getElementById('explore-root')) mountExplore(); } else unmountExplore(); });
})();