From c8cfe07c70d326b90dc53746ecf239db75651439 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 16 Sep 2026 17:40:52 +0200 Subject: [PATCH] huo --- public/s/css/f0ckm.css | 9 +- public/s/js/f0ckm.js | 769 +++++++++++++++++++++++++------- public/s/js/sidebar-activity.js | 20 +- src/inc/routeinc/f0cklib.mjs | 291 +++++++++--- src/inc/routes/ajax.mjs | 228 ++++++++++ src/inc/routes/apiv2/index.mjs | 68 ++- src/inc/routes/random.mjs | 6 +- views/error-partial.html | 2 +- views/error.html | 2 +- 9 files changed, 1142 insertions(+), 253 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 180807a..4113613 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -3812,7 +3812,8 @@ body.sidebar-right-hidden #sidebar-drag-zone { margin-right: 8px; } -.f0ck-tuner-subtab-btn { +.f0ck-tuner-subtab-btn, +.f0ck-tuner-mode-btn { flex: 1; display: inline-flex; align-items: center; @@ -3830,12 +3831,14 @@ body.sidebar-right-hidden #sidebar-drag-zone { white-space: nowrap; } -.f0ck-tuner-subtab-btn:hover { +.f0ck-tuner-subtab-btn:hover, +.f0ck-tuner-mode-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.06); } -.f0ck-tuner-subtab-btn.active { +.f0ck-tuner-subtab-btn.active, +.f0ck-tuner-mode-btn.active { color: #000; background: var(--accent, #99ff00); font-weight: 700; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index da0c3f9..fa08223 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -104,6 +104,123 @@ window.cancelAnimFrame = (function () { return null; }; + // Get current item's slug from the URL (for pool-based random exclusion) + window.getCurrentItemSlug = () => { + const path = window.location.pathname; + const match = path.match(/\/([^/]+)\/?$/); + if (match && match[1] !== 'random' && match[1] !== 'favs') return match[1]; + return null; + }; + // ── Random Pool Manager (Fisher-Yates) ──────────────────────────── + // Preloads all candidate item IDs for the current context (tag/hall/user). + // Items are shuffled once; a cursor walks through sequentially → zero duplicates + // until every item has been seen, then re-shuffles automatically. + window._randomPool = null; + window._randomPoolContext = null; + window._randomPoolCursor = 0; + + // Fisher-Yates (Durstenfeld) in-place shuffle + const _shuffleArray = (arr) => { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; + }; + + // Pick next item from the shuffled pool, skipping current + window.pickFromPool = (currentSlug, currentId) => { + const pool = window._randomPool; + if (!pool || !pool.items || pool.items.length < 2) return null; + + // Walk forward from cursor, skipping current item + const len = pool.items.length; + for (let attempts = 0; attempts < len; attempts++) { + const pick = pool.items[window._randomPoolCursor]; + window._randomPoolCursor++; + + // Exhausted — re-shuffle and reset + if (window._randomPoolCursor >= len) { + _shuffleArray(pool.items); + window._randomPoolCursor = 0; + } + + if (pick !== currentSlug && pick !== currentId) return pick; + } + // Fallback (pool has only 1 unique item) + return pool.items[0]; + }; + + window.fetchRandomPool = (forceRefresh = false) => { + const params = new URLSearchParams(); + const loc = window.location.href; + + const tagMatch = loc.match(/\/tag\/([^/]+)/); + if (tagMatch) params.append('tag', decodeURIComponent(tagMatch[1])); + + const hallMatch = loc.match(/\/h\/([^/]+)/); + if (hallMatch) params.append('hall', decodeURIComponent(hallMatch[1])); + + const userHallMatch = loc.match(/\/user\/([^/]+)\/hall\/([^/]+)/); + if (userHallMatch) { + params.append('userHall', decodeURIComponent(userHallMatch[2])); + params.append('userHallOwner', decodeURIComponent(userHallMatch[1])); + } else { + const userMatch = loc.match(/\/user\/([^/]+)/); + if (userMatch) { + params.append('user', decodeURIComponent(userMatch[1])); + if (loc.match(/\/favs(\/|$|\?)/)) params.append('fav', 'true'); + } + } + + const urlParams = new URLSearchParams(window.location.search); + const qMime = urlParams.get('mime'); + const cookieMimeMatch = document.cookie.match(/(?:^|;\s*)mime=([^;]*)/); + const cookieMime = cookieMimeMatch ? decodeURIComponent(cookieMimeMatch[1]).trim() : null; + if (qMime) params.append('mime', qMime); + else if (cookieMime) params.append('mime', cookieMime); + else { + const mimeMatch = loc.match(/\/((?:video|audio|image|,)+)(\/|$|\?)/); + if (mimeMatch) params.append('mime', mimeMatch[1]); + } + + const isStrict = window.f0ckSession?.strict_mode || window.location.search.includes('strict=1') || (localStorage.getItem('search_strict') === 'true'); + if (isStrict) params.append('strict', '1'); + + const contextKey = params.toString() + '|mode=' + (window.activeMode ?? 0); + if (!forceRefresh && window._randomPoolContext === contextKey && window._randomPool) return; + + const url = '/api/v2/random-pool' + ([...params].length > 0 ? '?' + params.toString() : ''); + fetch(url, { credentials: 'include' }) + .then(r => r.json()) + .then(data => { + if (data && Array.isArray(data.items)) { + _shuffleArray(data.items); + window._randomPool = { items: data.items, total: data.total, sampled: data.sampled }; + window._randomPoolContext = contextKey; + window._randomPoolCursor = 0; + console.log(`[RANDOM-POOL] Cached ${data.items.length} items (shuffled) for context: ${contextKey}`); + } + }) + .catch(err => console.warn('[RANDOM-POOL] Failed to fetch:', err)); + }; + + // Invalidate pool on mode change + document.addEventListener('f0ck:modeChanged', () => { + window._randomPool = null; + window._randomPoolContext = null; + setTimeout(() => window.fetchRandomPool(true), 200); + }); + + // Fetch pool on AJAX navigation (context may change e.g. /tag/babymetal → /tag/music) + document.addEventListener('f0ck:contentLoaded', () => { + // Slight delay so URL has updated via pushState before we parse it + setTimeout(() => window.fetchRandomPool(), 50); + }); + + // Initial pool fetch on page load (non-blocking) + setTimeout(() => window.fetchRandomPool(), 300); + // - disabled for clean guest mode const f0ckGuestFavs = { get: () => [], @@ -4081,12 +4198,12 @@ window.cancelAnimFrame = (function () { } } - const top = countTop > 0 - ? [Math.round(rTop / countTop), Math.round(gTop / countTop), Math.round(bTop / countTop)] - : [25, 25, 35]; const bot = countBot > 0 ? [Math.round(rBot / countBot), Math.round(gBot / countBot), Math.round(bBot / countBot)] - : [25, 25, 35]; + : (countTop > 0 ? [Math.round(rTop / countTop), Math.round(gTop / countTop), Math.round(bTop / countTop)] : [25, 25, 35]); + const top = countTop > 0 + ? [Math.round(rTop / countTop), Math.round(gTop / countTop), Math.round(bTop / countTop)] + : (countBot > 0 ? [...bot] : [25, 25, 35]); return { top, bottom: bot }; } catch (e) { @@ -4094,6 +4211,39 @@ window.cancelAnimFrame = (function () { } }; + const parseCssColorToRgb = (str) => { + if (!str) return [153, 255, 0]; + if (str.startsWith('#')) { + let hex = str.slice(1); + if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); + const num = parseInt(hex, 16); + return [(num >> 16) & 255, (num >> 8) & 255, num & 255]; + } + const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (m) return [Number(m[1]), Number(m[2]), Number(m[3])]; + const hm = str.match(/hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?\s*\)/); + if (hm) { + const h = Number(hm[1]) / 360, s = Number(hm[2]) / 100, l = Number(hm[3]) / 100; + let r, g, b; + if (s === 0) { r = g = b = l; } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; + } + return [153, 255, 0]; + }; + const boostColorSaturation = (rgb, boost = 1.35) => { if (!rgb || !Array.isArray(rgb) || rgb.length < 3) return [30, 30, 40]; const [r, g, b] = rgb; @@ -4135,12 +4285,17 @@ window.cancelAnimFrame = (function () { }; }; - const renderAmbientLightingWash = (ctx, targetW, targetH, colors, alpha = 1.0) => { + const renderAmbientLightingWash = (ctx, targetW, targetH, colors, alpha = 1.0, reactiveOpts = null) => { if (!ctx || !colors) return; const cfg = (typeof window.getEffectiveBackgroundTuning === 'function' ? window.getEffectiveBackgroundTuning() : null) || window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; const satBoost = cfg.bgAmbientSaturation !== undefined ? Number(cfg.bgAmbientSaturation) : 1.35; - const intensity = cfg.bgAmbientIntensity !== undefined ? Number(cfg.bgAmbientIntensity) : 1.0; - const spread = cfg.bgAmbientSpread !== undefined ? Number(cfg.bgAmbientSpread) : 0.85; + let intensity = cfg.bgAmbientIntensity !== undefined ? Number(cfg.bgAmbientIntensity) : 1.0; + let spread = cfg.bgAmbientSpread !== undefined ? Number(cfg.bgAmbientSpread) : 0.85; + + if (reactiveOpts) { + if (reactiveOpts.intensityMult !== undefined) intensity *= reactiveOpts.intensityMult; + if (reactiveOpts.spreadMult !== undefined) spread *= reactiveOpts.spreadMult; + } const rawTop = colors.top || colors; const rawBot = colors.bottom || colors.top || colors; @@ -4185,6 +4340,25 @@ window.cancelAnimFrame = (function () { ctx.fillStyle = gradBot; ctx.fillRect(0, 0, targetW, targetH); + // 4. Reactive Center Beat Bloom (Dynamic Audio Pulse) + if (reactiveOpts && reactiveOpts.pulse > 0.03) { + const p = Math.min(1.0, reactiveOpts.pulse); + const coreR = maxR * (0.30 + p * 0.45); + const rc = reactiveOpts.coreColor ? reactiveOpts.coreColor[0] : Math.round((rt + rb) / 2); + const gc = reactiveOpts.coreColor ? reactiveOpts.coreColor[1] : Math.round((gt + gb) / 2); + const bc = reactiveOpts.coreColor ? reactiveOpts.coreColor[2] : Math.round((bt + bb) / 2); + const pAlpha = Math.min(1.0, Math.max(0.0, p * 0.75 * intensity)).toFixed(3); + const pAlphaHalf = (pAlpha * 0.45).toFixed(3); + + const gradCore = ctx.createRadialGradient(cx, targetH * 0.50, 0, cx, targetH * 0.50, coreR); + gradCore.addColorStop(0, `rgba(${rc}, ${gc}, ${bc}, ${pAlpha})`); + gradCore.addColorStop(0.45, `rgba(${rc}, ${gc}, ${bc}, ${pAlphaHalf})`); + gradCore.addColorStop(1.0, 'rgba(6, 6, 12, 0.0)'); + + ctx.fillStyle = gradCore; + ctx.fillRect(0, 0, targetW, targetH); + } + ctx.restore(); }; @@ -4375,7 +4549,10 @@ window.cancelAnimFrame = (function () { if (elem) { if (canvas) { // Restore visual state on re-init - if (background && elem.tagName !== 'AUDIO') { + const bgCfg = (typeof window.getEffectiveBackgroundTuning === 'function' ? window.getEffectiveBackgroundTuning() : null) || window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + const feedAudio = Number(bgCfg.feedAudioToBg !== undefined ? bgCfg.feedAudioToBg : 1) === 1; + + if (background && (elem.tagName !== 'AUDIO' || feedAudio)) { canvas._bgFadingOut = false; canvas.classList.add('fader-in'); canvas.classList.remove('fader-out', 'fast-fade'); @@ -4775,9 +4952,26 @@ window.cancelAnimFrame = (function () { // IMG: draw from thumbnail. drawOnce(); } else if (elem.tagName === 'AUDIO') { - // Audio items: fade out the previous item's background, then clear canvas - // The audio visualizer uses its own separate canvas (.audio-visualizer), not #bg - if (canvas) { + const bgCfg = (typeof window.getEffectiveBackgroundTuning === 'function' ? window.getEffectiveBackgroundTuning() : null) || window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + const feedAudio = Number(bgCfg.feedAudioToBg !== undefined ? bgCfg.feedAudioToBg : 1) === 1; + + if (feedAudio) { + if (background && canvas) { + canvas._bgFadingOut = false; + canvas.classList.remove('fader-out', 'fast-fade'); + canvas.classList.add('fader-in'); + } + const coverEl = document.querySelector("#f0ck-audio-cover"); + if (coverEl && coverEl.src && !coverEl.src.endsWith('#') && !coverEl.src.includes('audio.webp') && !coverEl.src.includes('200.gif')) { + if (coverEl.complete && coverEl.naturalWidth > 0) { + crossfadeToSource(coverEl); + } else { + coverEl.onload = () => crossfadeToSource(coverEl); + } + } else { + drawOnce(); + } + } else if (canvas) { canvas.classList.add('fader-out'); canvas.classList.remove('fader-in', 'fast-fade'); const clearOnFade = (ev) => { @@ -4995,7 +5189,8 @@ window.cancelAnimFrame = (function () { bgAmbientIntensity: 1.9, bgAmbientSpread: 0.5, bgAmbientSaturation: 1.5, - bgAmbientSmoothness: 0.06 + bgAmbientSmoothness: 0.06, + bgAudioReactivity: 1.50 }; const TUNING_CONFIG_VERSION = '2026-09-16_tuner_v12'; @@ -5019,7 +5214,8 @@ window.cancelAnimFrame = (function () { bgAmbientIntensity: 1.0, bgAmbientSpread: 0.85, bgAmbientSaturation: 1.35, - bgAmbientSmoothness: 0.05 + bgAmbientSmoothness: 0.05, + bgAudioReactivity: 1.50 }; const ONARA_TUNING_CONFIG_VERSION = '2026-09-16_onara_v3'; @@ -5039,17 +5235,79 @@ window.cancelAnimFrame = (function () { window.DEFAULT_ONARA_TUNING = DEFAULT_ONARA_TUNING; window.onaraTuning = Object.assign({}, DEFAULT_ONARA_TUNING, savedOnaraTuning || {}); + const DEFAULT_AUDIO_BG_TUNING = { + bgCanvasColor: "#000000", + bgCanvasColorOpacity: 0.15, + bgCanvasOpacity: 0.90, + bgBlurMethod: 0, + bgCanvasBlur: 60, + bgCanvasBrightness: 0.75, + bgCanvasSaturate: 1.9, + bgCanvasContrast: 1.15, + bgCanvasZoom: 1.38, + bgCanvasScale: 2.15, + feedAudioToBg: 1, + bgAmbientLighting: 1, + bgAmbientIntensity: 1.45, + bgAmbientSpread: 0.85, + bgAmbientSaturation: 1.65, + bgAmbientSmoothness: 0.08, + bgAudioReactivity: 1.50, + onaraBgOpacity: 0.4, + onaraBackdropBlur: 5, + onaraGridDim: 0.38 + }; + + const AUDIO_BG_TUNING_CONFIG_VERSION = '2026-09-16_audio_bg_v2'; + let savedAudioBgTuning = null; + try { + const appliedAudioBgVer = localStorage.getItem('f0ck_audio_bg_tuning_ver'); + if (appliedAudioBgVer !== AUDIO_BG_TUNING_CONFIG_VERSION) { + localStorage.setItem('f0ck_audio_bg_tuning', JSON.stringify(DEFAULT_AUDIO_BG_TUNING)); + localStorage.setItem('f0ck_audio_bg_tuning_ver', AUDIO_BG_TUNING_CONFIG_VERSION); + savedAudioBgTuning = Object.assign({}, DEFAULT_AUDIO_BG_TUNING); + } else { + const raw = localStorage.getItem('f0ck_audio_bg_tuning'); + if (raw) savedAudioBgTuning = JSON.parse(raw); + } + } catch (e) {} + + window.DEFAULT_AUDIO_BG_TUNING = DEFAULT_AUDIO_BG_TUNING; + window.audioBgTuning = Object.assign({}, DEFAULT_AUDIO_BG_TUNING, savedAudioBgTuning || {}); + const isCurrentlyInOnara = () => { return document.body.classList.contains('onara-modal-open') || (typeof isOnaraActive === 'function' && isOnaraActive() && !!document.getElementById('onara-item-mount')); }; - const getEffectiveBackgroundTuning = (forceMode) => { - const useOnara = forceMode !== undefined ? (forceMode === 'onara' || forceMode === true) : isCurrentlyInOnara(); - if (useOnara) { - return Object.assign({}, window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING, window.onaraTuning || DEFAULT_ONARA_TUNING); + const isCurrentItemAudio = () => { + const mount = document.getElementById('onara-item-mount'); + if (mount) { + const a = mount.querySelector('#f0ck-album-audio, audio#my-video, audio'); + if (a && a.isConnected) return true; } - return window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + const aud = document.querySelector('#f0ck-album-audio, audio#my-video, audio'); + if (aud && aud.isConnected) { + const albumAudioWrap = document.getElementById('f0ck-album-audio-wrapper'); + if (albumAudioWrap && albumAudioWrap.style.display !== 'none') return true; + if (aud.tagName === 'AUDIO') return true; + } + return false; + }; + window.isCurrentItemAudio = isCurrentItemAudio; + + const getEffectiveBackgroundTuning = (forceMode) => { + const isAudio = forceMode !== undefined ? (forceMode === 'audio') : isCurrentItemAudio(); + const useOnara = forceMode !== undefined ? (forceMode === 'onara' || forceMode === true) : isCurrentlyInOnara(); + + let base = Object.assign({}, DEFAULT_AUDIO_TUNING, window.audioVisualizerTuning || {}); + if (useOnara) { + base = Object.assign(base, DEFAULT_ONARA_TUNING, window.onaraTuning || {}); + } + if (isAudio) { + base = Object.assign(base, DEFAULT_AUDIO_BG_TUNING, window.audioBgTuning || {}); + } + return base; }; window.getEffectiveBackgroundTuning = getEffectiveBackgroundTuning; @@ -5092,17 +5350,18 @@ window.cancelAnimFrame = (function () { window.audioVisualizerTuning = Object.assign({}, getEffectiveDefaultTuning(), savedTuning || {}); const applyBackgroundOpacitySettings = (cfg, forceMode) => { + const isAudio = forceMode !== undefined ? (forceMode === 'audio') : isCurrentItemAudio(); const inOnara = forceMode !== undefined ? (forceMode === 'onara' || forceMode === true) : isCurrentlyInOnara(); const c = cfg || getEffectiveBackgroundTuning(forceMode); const onaraOp = c.onaraBgOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.onaraBgOpacity))) : (inOnara ? 0.0 : 0.40); - const bgOp = c.bgCanvasOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.bgCanvasOpacity))) : (inOnara ? 0.95 : 0.81); + const bgOp = c.bgCanvasOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.bgCanvasOpacity))) : (isAudio ? 0.90 : (inOnara ? 0.95 : 0.81)); const gridThumbOp = c.onaraGridDim !== undefined ? Math.min(1, Math.max(0, Number(c.onaraGridDim))) : (inOnara ? 0.0 : 0.38); const onaraBlur = c.onaraBackdropBlur !== undefined ? Math.max(0, Number(c.onaraBackdropBlur)) : (inOnara ? 10 : 5); - const bgBlur = c.bgCanvasBlur !== undefined ? Math.max(0, Number(c.bgCanvasBlur)) : (inOnara ? 31 : 151); + const bgBlur = c.bgCanvasBlur !== undefined ? Math.max(0, Number(c.bgCanvasBlur)) : (isAudio ? 60 : (inOnara ? 31 : 151)); const method = c.bgBlurMethod !== undefined ? Number(c.bgBlurMethod) : (inOnara ? 2 : 0); const zoom = c.bgCanvasZoom !== undefined ? Math.max(1.0, Number(c.bgCanvasZoom)) : 1.38; const bgColor = c.bgCanvasColor || '#000000'; - const bgColOp = c.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.bgCanvasColorOpacity))) : (inOnara ? 0.8 : 0.09); + const bgColOp = c.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.bgCanvasColorOpacity))) : (isAudio ? 0.15 : (inOnara ? 0.8 : 0.09)); const computedBgCol = bgColOp <= 0.001 ? 'transparent' : (bgColOp >= 0.999 ? bgColor : `color-mix(in srgb, ${bgColor} ${Math.round(bgColOp * 100)}%, transparent)`); document.documentElement.style.setProperty('--onara-bg-opacity', onaraOp.toFixed(3)); @@ -5427,6 +5686,7 @@ window.cancelAnimFrame = (function () { { section: 'Video & Ambient Background Canvas', key: 'bgCanvasZoom', label: 'Edge Overscan Zoom', min: 1.00, max: 1.80, step: 0.01, unit: 'x' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasScale', label: 'Visualizer Bar Scale', min: 0.20, max: 4.00, step: 0.05, unit: 'x' }, { section: 'Video & Ambient Background Canvas', key: 'feedAudioToBg', label: 'Feed Visualizer to Background Canvas (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Video & Ambient Background Canvas', key: 'bgAudioReactivity', label: 'Audio Beat Reactivity (Pulse Strength)', min: 0.00, max: 4.00, step: 0.05, unit: 'x' }, // Onara Mode Background Section { section: 'Onara Mode Background', key: 'onaraBgOpacity', label: 'Onara Modal Backdrop Darkness', min: 0.00, max: 1.00, step: 0.01, unit: '' }, @@ -5699,8 +5959,9 @@ window.cancelAnimFrame = (function () {
- - + + +
${bgRowsHtml} @@ -5708,8 +5969,9 @@ window.cancelAnimFrame = (function () { `; // ── Sub-Tab Switching Logic ───────────────────────────────────────────── - const subtabButtons = panel.querySelectorAll('.f0ck-tuner-subtab-btn'); + const subtabButtons = panel.querySelectorAll('.f0ck-tuner-subtabs-nav .f0ck-tuner-subtab-btn'); const switchSubtab = (targetTab) => { + if (!targetTab) return; subtabButtons.forEach(btn => { btn.classList.toggle('active', btn.dataset.subtab === targetTab); }); @@ -5724,7 +5986,9 @@ window.cancelAnimFrame = (function () { }; subtabButtons.forEach(btn => { - btn.addEventListener('click', () => switchSubtab(btn.dataset.subtab)); + btn.addEventListener('click', () => { + if (btn.dataset.subtab) switchSubtab(btn.dataset.subtab); + }); }); const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab'); @@ -6323,18 +6587,29 @@ window.cancelAnimFrame = (function () { }); }); - // ── Background Sliders Event Listeners (Standard vs Onara mode) ──────── - let activeBgTunerMode = isCurrentlyInOnara() ? 'onara' : 'standard'; + // ── Background Sliders Event Listeners (Standard vs Onara vs Audio mode) ──────── + let activeBgTunerMode = isCurrentItemAudio() ? 'audio' : (isCurrentlyInOnara() ? 'onara' : 'standard'); const syncBgSlidersDisplay = () => { - const isO = activeBgTunerMode === 'onara'; - const sourceObj = isO ? (window.onaraTuning || DEFAULT_ONARA_TUNING) : (window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING); - const defaultObj = isO ? DEFAULT_ONARA_TUNING : DEFAULT_AUDIO_TUNING; + const mode = activeBgTunerMode; + let sourceObj, defaultObj; + if (mode === 'audio') { + sourceObj = window.audioBgTuning || DEFAULT_AUDIO_BG_TUNING; + defaultObj = DEFAULT_AUDIO_BG_TUNING; + } else if (mode === 'onara') { + sourceObj = window.onaraTuning || DEFAULT_ONARA_TUNING; + defaultObj = DEFAULT_ONARA_TUNING; + } else { + sourceObj = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + defaultObj = DEFAULT_AUDIO_TUNING; + } const stdBtn = panel.querySelector('#bg-mode-btn-standard'); const onaraBtn = panel.querySelector('#bg-mode-btn-onara'); - if (stdBtn) stdBtn.classList.toggle('active', !isO); - if (onaraBtn) onaraBtn.classList.toggle('active', isO); + const audioBtn = panel.querySelector('#bg-mode-btn-audio'); + if (stdBtn) stdBtn.classList.toggle('active', mode === 'standard'); + if (onaraBtn) onaraBtn.classList.toggle('active', mode === 'onara'); + if (audioBtn) audioBtn.classList.toggle('active', mode === 'audio'); backgroundSliders.forEach(s => { const input = panel.querySelector(`#input-bg-${s.key}`); @@ -6370,14 +6645,32 @@ window.cancelAnimFrame = (function () { } }); + panel.querySelector('#bg-mode-btn-audio')?.addEventListener('click', () => { + activeBgTunerMode = 'audio'; + syncBgSlidersDisplay(); + applyBackgroundOpacitySettings(window.audioBgTuning, 'audio'); + if (typeof window.flashMessage === 'function') { + window.flashMessage('Tuning Audio Background', 1500, 'info'); + } + }); + backgroundSliders.forEach(s => { const input = panel.querySelector(`#input-bg-${s.key}`); const valEl = panel.querySelector(`#val-bg-${s.key}`); if (!input || !valEl) return; input.addEventListener('input', () => { - const isO = activeBgTunerMode === 'onara'; - const targetObj = isO ? window.onaraTuning : window.audioVisualizerTuning; - const storageKey = isO ? 'f0ck_onara_tuning' : 'f0ck_audio_tuning'; + const mode = activeBgTunerMode; + let targetObj, storageKey; + if (mode === 'audio') { + targetObj = window.audioBgTuning; + storageKey = 'f0ck_audio_bg_tuning'; + } else if (mode === 'onara') { + targetObj = window.onaraTuning; + storageKey = 'f0ck_onara_tuning'; + } else { + targetObj = window.audioVisualizerTuning; + storageKey = 'f0ck_audio_tuning'; + } if (s.type === 'color') { targetObj[s.key] = input.value; @@ -6389,7 +6682,7 @@ window.cancelAnimFrame = (function () { valEl.textContent = `${displayVal}${s.unit || ''}`; } - applyBackgroundOpacitySettings(targetObj, isO); + applyBackgroundOpacitySettings(targetObj, mode === 'audio' ? 'audio' : (mode === 'onara')); try { localStorage.setItem(storageKey, JSON.stringify(targetObj)); @@ -6398,8 +6691,18 @@ window.cancelAnimFrame = (function () { }); panel.querySelector('#bg-tuner-reset')?.addEventListener('click', () => { - const isO = activeBgTunerMode === 'onara'; - if (isO) { + const mode = activeBgTunerMode; + if (mode === 'audio') { + window.audioBgTuning = Object.assign({}, DEFAULT_AUDIO_BG_TUNING); + try { + localStorage.setItem('f0ck_audio_bg_tuning', JSON.stringify(window.audioBgTuning)); + } catch (e) {} + syncBgSlidersDisplay(); + applyBackgroundOpacitySettings(window.audioBgTuning, 'audio'); + if (typeof window.flashMessage === 'function') { + window.flashMessage('Audio background settings reset to defaults', 2000, 'info'); + } + } else if (mode === 'onara') { window.onaraTuning = Object.assign({}, DEFAULT_ONARA_TUNING); try { localStorage.setItem('f0ck_onara_tuning', JSON.stringify(window.onaraTuning)); @@ -6426,15 +6729,15 @@ window.cancelAnimFrame = (function () { }); panel.querySelector('#bg-tuner-copy')?.addEventListener('click', () => { - const isO = activeBgTunerMode === 'onara'; - const targetObj = isO ? window.onaraTuning : window.audioVisualizerTuning; + const mode = activeBgTunerMode; + const targetObj = mode === 'audio' ? window.audioBgTuning : (mode === 'onara' ? window.onaraTuning : window.audioVisualizerTuning); const bgCfg = {}; backgroundSliders.forEach(s => { bgCfg[s.key] = targetObj[s.key]; }); navigator.clipboard.writeText(JSON.stringify(bgCfg, null, 2)).then(() => { if (typeof window.flashMessage === 'function') { - window.flashMessage(`${isO ? 'Onara' : 'Standard'} background settings copied to clipboard!`, 2500, 'success'); + window.flashMessage(`${mode === 'audio' ? 'Audio' : (mode === 'onara' ? 'Onara' : 'Standard')} background settings copied to clipboard!`, 2500, 'success'); } }); }); @@ -7359,7 +7662,7 @@ window.cancelAnimFrame = (function () { const draw = (data) => { ctx.clearRect(0, 0, canvas.width, canvas.height); const count = analyser.frequencyBinCount; - const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + const cfg = (typeof window.getEffectiveBackgroundTuning === 'function' ? window.getEffectiveBackgroundTuning() : null) || window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; if (!audioElement.paused && Number(cfg.enableBeatHue) === 1) { const now = performance.now(); @@ -9034,9 +9337,12 @@ window.cancelAnimFrame = (function () { } } - // Feed the inner eye canvas (.audio-eye-inner-canvas) as background (#bg) + // Feed visualizer canvas (.audio-visualizer) or inner eye canvas as background (#bg) const bgCanvas = document.getElementById('bg'); const innerEyeCanvas = document.querySelector('.audio-eye-inner-canvas'); + const sourceCanvas = (innerEyeCanvas && innerEyeCanvas.width > 0 && innerEyeCanvas.height > 0) + ? innerEyeCanvas + : (canvas && canvas.width > 0 && canvas.height > 0 ? canvas : null); const bgEnabled = window.background; if (bgCanvas) { if (!bgEnabled) { @@ -9045,7 +9351,7 @@ window.cancelAnimFrame = (function () { bgCanvas.classList.remove('fader-in'); bgCanvas.classList.add('fader-out'); } - } else if (feedToBg && innerEyeCanvas && innerEyeCanvas.width > 0 && innerEyeCanvas.height > 0) { + } else if (feedToBg && sourceCanvas && sourceCanvas.width > 0 && sourceCanvas.height > 0) { const bgCtx = bgCanvas.getContext('2d'); if (bgCtx) { const SCALE = 0.5; @@ -9067,31 +9373,132 @@ window.cancelAnimFrame = (function () { bgCtx.clearRect(0, 0, bw, bh); - try { - // Draw inner eye canvas centered behind the item content (respecting sidebar width) - const srcW = innerEyeCanvas.width; - const srcH = innerEyeCanvas.height; - const bgScale = cfg.bgCanvasScale !== undefined ? Number(cfg.bgCanvasScale) : 2.15; + // Compute real-time audio dynamics from frequency spectrum + const reactMult = cfg.bgAudioReactivity !== undefined ? Number(cfg.bgAudioReactivity) : 1.50; + const reactScale = reactMult / 1.50; - // #bg canvas is already positioned to the free space respecting the sidebar - const contentW = bw; + let dynBassMax = 0, dynBassSum = 0; + const dynBassBins = Math.min(10, count); + for (let b = 1; b <= dynBassBins; b++) { + const v = data[b] || 0; + if (v > dynBassMax) dynBassMax = v; + dynBassSum += v; + } + const dynBassPeak = dynBassMax / 255; + const dynBassAvg = dynBassSum / (dynBassBins * 255); + const dynKick = Math.min(1.0, (dynBassPeak * 0.70 + dynBassAvg * 0.30) * 1.85 * reactScale); - const fitScale = Math.min(contentW / srcW, bh / srcH); - const dw = srcW * fitScale * bgScale; - const dh = srcH * fitScale * bgScale; - const dx = (contentW - dw) / 2; - const dy = (bh - dh) / 2; + let dynMidMax = 0, dynMidSum = 0; + const dynMidStart = 11, dynMidEnd = Math.min(36, count); + for (let m = dynMidStart; m < dynMidEnd; m++) { + const v = data[m] || 0; + if (v > dynMidMax) dynMidMax = v; + dynMidSum += v; + } + const dynMid = Math.min(1.0, ((dynMidMax / 255) * 0.6 + (dynMidSum / ((dynMidEnd - dynMidStart) * 255)) * 0.4) * 1.85 * reactScale); - // Apply blur & brightness via CSS filter (smooth at display resolution) - const blurPx = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 31; - const brightVal = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 0.6; - const filterParts = []; - if (blurPx > 0) filterParts.push(`blur(${blurPx}px)`); - if (brightVal !== 1) filterParts.push(`brightness(${brightVal})`); - bgCanvas.style.filter = filterParts.length > 0 ? filterParts.join(' ') : ''; + let dynHighMax = 0; + const dynHighStart = 37, dynHighEnd = Math.min(80, count); + for (let h = dynHighStart; h < dynHighEnd; h++) { + if ((data[h] || 0) > dynHighMax) dynHighMax = data[h]; + } + const dynHigh = Math.min(1.0, (dynHighMax / 255) * 1.6 * reactScale); - bgCtx.drawImage(innerEyeCanvas, dx, dy, dw, dh); - } catch (e) {} + if (!bgCanvas._audioEnvelope) { + bgCanvas._audioEnvelope = { kick: 0, mid: 0, high: 0, pulse: 0 }; + } + const aEnv = bgCanvas._audioEnvelope; + const isPaused = audioElement.paused; + if (isPaused) { + aEnv.kick *= 0.85; + aEnv.mid *= 0.85; + aEnv.high *= 0.85; + aEnv.pulse *= 0.85; + } else { + aEnv.kick += (dynKick - aEnv.kick) * (dynKick > aEnv.kick ? 0.65 : 0.12); + aEnv.mid += (dynMid - aEnv.mid) * (dynMid > aEnv.mid ? 0.55 : 0.10); + aEnv.high += (dynHigh - aEnv.high) * (dynHigh > aEnv.high ? 0.70 : 0.14); + const rawPulse = Math.min(1.0, aEnv.kick * 0.70 + aEnv.mid * 0.30); + aEnv.pulse += (rawPulse - aEnv.pulse) * (rawPulse > aEnv.pulse ? 0.60 : 0.10); + } + + // Check ambient lighting mode + const useAudioAmbient = Number(cfg.bgAmbientLighting) === 1; + + if (useAudioAmbient) { + // Reactive YouTube Glow mode: sample colors + modulate dynamically with frequencies & beats + const pulseVal = Math.min(1.0, aEnv.pulse * reactScale); + const reactiveOpts = { + pulse: isPaused ? 0 : pulseVal, + intensityMult: isPaused ? 0.40 : (0.75 + (aEnv.pulse * 0.85 + aEnv.kick * 0.55) * reactScale), + spreadMult: isPaused ? 0.75 : (0.85 + aEnv.pulse * 0.45 * reactScale), + coreColor: null + }; + + if (!bgCanvas._audioAmbientCount) bgCanvas._audioAmbientCount = 0; + bgCanvas._audioAmbientCount++; + if (bgCanvas._audioAmbientCount % 2 === 0 || !mediaAmbientState.hasSampled) { + let sampled = null; + if (audioCoverImg && audioCoverImg.complete && audioCoverImg.naturalWidth > 0) { + sampled = sampleMediaSpatialColors(audioCoverImg); + } + if (!sampled && sourceCanvas) { + sampled = sampleMediaSpatialColors(sourceCanvas); + } + if (sampled && (sampled.top[0] > 30 || sampled.top[1] > 30 || sampled.top[2] > 45 || sampled.bottom[0] > 30 || sampled.bottom[1] > 30 || sampled.bottom[2] > 45)) { + // Modulate sampled colors dynamically with real-time frequency energy + const modTop = [ + Math.min(255, Math.max(0, Math.round(sampled.top[0] * (1.0 + (aEnv.mid + aEnv.high) * 0.50 * reactScale)))), + Math.min(255, Math.max(0, Math.round(sampled.top[1] * (1.0 + (aEnv.mid + aEnv.high) * 0.50 * reactScale)))), + Math.min(255, Math.max(0, Math.round(sampled.top[2] * (1.0 + (aEnv.mid + aEnv.high) * 0.50 * reactScale)))) + ]; + const modBot = [ + Math.min(255, Math.max(0, Math.round(sampled.bottom[0] * (1.0 + aEnv.kick * 0.65 * reactScale)))), + Math.min(255, Math.max(0, Math.round(sampled.bottom[1] * (1.0 + aEnv.kick * 0.65 * reactScale)))), + Math.min(255, Math.max(0, Math.round(sampled.bottom[2] * (1.0 + aEnv.kick * 0.65 * reactScale)))) + ]; + setAmbientTargetColors({ top: modTop, bottom: modBot }); + mediaAmbientState.hasSampled = true; + } else { + // Synthesize harmonic audio colors from beat hue or accent + const baseHue = (Number(cfg.enableBeatHue) === 1 ? currentBeatHue : (currentBeatHue || 110)) % 360; + const topHue = (baseHue + 45) % 360; + const botHue = baseHue; + const modTop = parseCssColorToRgb(`hsl(${Math.round(topHue)}, 100%, ${Math.round(50 + (aEnv.mid + aEnv.high) * 20 * reactScale)}%)`); + const modBot = parseCssColorToRgb(`hsl(${Math.round(botHue)}, 100%, ${Math.round(45 + aEnv.kick * 25 * reactScale)}%)`); + setAmbientTargetColors({ top: modTop, bottom: modBot }); + mediaAmbientState.hasSampled = true; + } + } + + const current = updateAmbientLerp(0.08); + renderAmbientLightingWash(bgCtx, bw, bh, current, 1.0, reactiveOpts); + bgCanvas.style.filter = ''; + } else { + // Filter mode: copy visualizer canvas to bg with reactive blur/brightness & pulse scale + try { + const srcW = sourceCanvas.width; + const srcH = sourceCanvas.height; + const baseBgScale = cfg.bgCanvasScale !== undefined ? Number(cfg.bgCanvasScale) : 2.15; + const bgScale = baseBgScale * (1.0 + (isPaused ? 0 : aEnv.pulse * 0.15 * reactScale)); + const contentW = bw; + const fitScale = Math.min(contentW / srcW, bh / srcH); + const dw = srcW * fitScale * bgScale; + const dh = srcH * fitScale * bgScale; + const dx = (contentW - dw) / 2; + const dy = (bh - dh) / 2; + + const blurPx = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 31; + const baseBright = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 0.6; + const brightVal = Math.min(2.0, baseBright * (1.0 + (isPaused ? 0 : aEnv.kick * 0.45 * reactScale))); + const filterParts = []; + if (blurPx > 0) filterParts.push(`blur(${blurPx}px)`); + if (brightVal !== 1) filterParts.push(`brightness(${brightVal.toFixed(2)})`); + bgCanvas.style.filter = filterParts.length > 0 ? filterParts.join(' ') : ''; + + bgCtx.drawImage(sourceCanvas, dx, dy, dw, dh); + } catch (e) {} + } } } } @@ -11133,28 +11540,15 @@ window.cancelAnimFrame = (function () { window.f0ckDebug("[itemCache] MISS — fetching:", ajaxUrl); const tStart = performance.now(); - const response = await fetch(ajaxUrl, { credentials: 'include' }); - const tHeaders = performance.now(); - - if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`); - - const rawText = await response.text(); - const tBody = performance.now(); - - window.f0ckDebug(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}: - - TTFB (Headers): ${(tHeaders - tStart).toFixed(2)}ms - - Content Download: ${(tBody - tHeaders).toFixed(2)}ms - - Total Network: ${(tBody - tStart).toFixed(2)}ms - - Content Size: ${(rawText.length / 1024).toFixed(2)} KB`); let html, paginationHtml, responseSlug = null, responsePage = null; - try { - // Optimistically try to parse as JSON first - const data = JSON.parse(rawText); + // ── Pre-fetched data path (merged random + item load) ────────────── + if (options.prefetchedData) { + const data = options.prefetchedData; if (data && data.success === false) { - console.warn("loadItemAjax: Server returned failure:", data.message); - window.location.href = url; // Fallback to full page load + console.warn("loadItemAjax: Pre-fetched data returned failure:", data.message); + window.location.href = url; return; } if (data && typeof data.html === 'string') { @@ -11162,12 +11556,44 @@ window.cancelAnimFrame = (function () { paginationHtml = data.pagination; responseSlug = data.slug || data.item?.slug || null; responsePage = data.page || null; - } else { + } + window.f0ckDebug(`[CLIENT_DEBUG] Using pre-fetched data (skipped network fetch)`); + } else { + // ── Normal fetch path ──────────────────────────────────────────── + const response = await fetch(ajaxUrl, { credentials: 'include' }); + const tHeaders = performance.now(); + + if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`); + + const rawText = await response.text(); + const tBody = performance.now(); + + window.f0ckDebug(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}: + - TTFB (Headers): ${(tHeaders - tStart).toFixed(2)}ms + - Content Download: ${(tBody - tHeaders).toFixed(2)}ms + - Total Network: ${(tBody - tStart).toFixed(2)}ms + - Content Size: ${(rawText.length / 1024).toFixed(2)} KB`); + + try { + // Optimistically try to parse as JSON first + const data = JSON.parse(rawText); + if (data && data.success === false) { + console.warn("loadItemAjax: Server returned failure:", data.message); + window.location.href = url; // Fallback to full page load + return; + } + if (data && typeof data.html === 'string') { + html = data.html; + paginationHtml = data.pagination; + responseSlug = data.slug || data.item?.slug || null; + responsePage = data.page || null; + } else { + html = rawText; + } + } catch (e) { + // If JSON parse fails, assume it's HTML text html = rawText; } - } catch (e) { - // If JSON parse fails, assume it's HTML text - html = rawText; } // ── Store in item cache (stale-while-revalidate) ─────────────────────── @@ -11557,24 +11983,98 @@ window.cancelAnimFrame = (function () { const nav = document.querySelector("nav.navbar"); if (nav) nav.classList.add("pbwork"); - // Extract current context from window location - let randomUrl = '/api/v2/random'; - const params = new URLSearchParams(); - + // ── Client-side random pool: pick locally, load normally ────── const wTagMatch = window.location.href.match(/\/tag\/([^/]+)/); - if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1])); - - // Check for user hall FIRST — if we're in a user hall, don't also send user= - // (the /user/:name part of the URL would otherwise incorrectly trigger user= filter) + const wHallMatch = window.location.href.match(/\/h\/([^/]+)/); let wUserHall = null, wUserHallOwner = null; const wUserHallMatch = window.location.href.match(/\/user\/([^/]+)\/hall\/([^/]+)/); if (wUserHallMatch) { wUserHallOwner = decodeURIComponent(wUserHallMatch[1]); wUserHall = decodeURIComponent(wUserHallMatch[2]); + } + let wFavsUser = null; + if (!wUserHall) { + const wUserM = window.location.href.match(/\/user\/([^/]+)/); + if (wUserM && window.location.href.match(/\/favs(\/|$|\?)/)) { + wFavsUser = decodeURIComponent(wUserM[1]); + } else if (window.location.href.match(/\/favs(\/|$|\?)/)) { + const guestIds = window.f0ckGuestFavs ? window.f0ckGuestFavs.get() : []; + if (guestIds.length > 0) { + const currentId = window.getCurrentItemId(); + const candidates = guestIds.filter(id => id !== parseInt(currentId, 10)); + const chosen = candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : guestIds[0]; + loadItemAjax(`/favs/${chosen}`, true, { transition: 'fade-zoom' }); + return; + } + } + } + + // Try pool-based random (instant pick from cached IDs) + const pool = window._randomPool; + const currentSlug = window.getCurrentItemSlug ? window.getCurrentItemSlug() : null; + const currentId = window.getCurrentItemId ? String(window.getCurrentItemId()) : null; + + // Pool loaded but empty — no items match current filters + if (pool && pool.items && pool.items.length === 0) { + if (nav) nav.classList.remove("pbwork"); + outEls.forEach(el => { + el.classList.remove('fade-out-zoom'); + el.classList.add('fade-in-zoom'); + }); + if (canvas) { + canvas.classList.remove('fader-out', 'fast-fade'); + canvas.classList.add('fader-in'); + } + if (window.flashMessage) window.flashMessage('No items found for this filter'); + return; + } + + if (pool && pool.items && pool.items.length > 1) { + // Pick next from shuffled pool — zero duplicates until all seen + const pick = window.pickFromPool(currentSlug, currentId); + if (!pick) { // shouldn't happen, but safety + if (window.flashMessage) window.flashMessage('No items found for this filter'); + return; + } + // Flag for sidebar: skip tag feed re-centering on random nav + window._isPoolRandomNav = true; + + // Build URL preserving context + let targetUrl; + if (wUserHall && wUserHallOwner) { + targetUrl = `/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${pick}`; + } else if (wFavsUser) { + targetUrl = `/user/${encodeURIComponent(wFavsUser)}/favs/${pick}`; + } else if (wTagMatch) { + const wTag = decodeURIComponent(wTagMatch[1]); + targetUrl = `/tag/${encodeURIComponent(wTag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${pick}`; + } else if (wHallMatch) { + const wHall = decodeURIComponent(wHallMatch[1]); + targetUrl = `/h/${encodeURIComponent(wHall).replace(/%20/g, ' ')}/${pick}`; + } else { + targetUrl = `/${pick}`; + } + + loadItemAjax(targetUrl, true, { transition: 'fade-zoom' }); + + // Background grid sync + if (isOnaraActive()) { + syncOnaraBackgroundGrid(pick, `/${pick}`, 1, pick); + } + return; + } + + // Fallback: pool not ready — use server-side random + let randomUrl = '/ajax/item/random'; + const params = new URLSearchParams(); + params.append('mode', window.activeMode); + + if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1])); + + if (wUserHallMatch) { params.append('userHall', wUserHall); params.append('userHallOwner', wUserHallOwner); } else { - // Only add user= when NOT in a user hall context const wUserMatch = window.location.href.match(/\/user\/([^/]+)/); if (wUserMatch) { params.append('user', decodeURIComponent(wUserMatch[1])); @@ -11584,7 +12084,6 @@ window.cancelAnimFrame = (function () { } } - const wHallMatch = window.location.href.match(/\/h\/([^/]+)/); if (wHallMatch) params.append('hall', decodeURIComponent(wHallMatch[1])); const urlParams = new URLSearchParams(window.location.search); @@ -11610,78 +12109,42 @@ window.cancelAnimFrame = (function () { randomUrl += '?' + params.toString(); } - // Capture favs user context before fetch so it's accessible in .then() - // Without this, random in /user/foo/favs would navigate to /:id (losing context), - // causing mode mismatches between the AJAX pick and any subsequent full-page reload. - let wFavsUser = null; - if (!wUserHall) { - const wUserM = window.location.href.match(/\/user\/([^/]+)/); - if (wUserM && window.location.href.match(/\/favs(\/|$|\?)/)) { - wFavsUser = decodeURIComponent(wUserM[1]); - } else if (window.location.href.match(/\/favs(\/|$|\?)/)) { - const guestIds = window.f0ckGuestFavs ? window.f0ckGuestFavs.get() : []; - if (guestIds.length > 0) { - const currentId = window.getCurrentItemId(); - const candidates = guestIds.filter(id => id !== parseInt(currentId, 10)); - const chosen = candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : guestIds[0]; - loadItemAjax(`/favs/${chosen}`, true, { transition: 'fade-zoom' }); - return; - } - } - } - - fetch(randomUrl) + fetch(randomUrl, { credentials: 'include' }) .then(r => r.json()) .then(data => { - if (data.success && data.items && (data.items.slug || data.items.id)) { - const targetKey = data.items.slug || data.items.id; - const targetId = data.items.id; - const targetPage = data.items.page || 1; - - // Navigate immediately + if (data && typeof data.html === 'string' && !data.error) { + const targetKey = data.slug || data.id; + let targetUrl; if (wUserHall && wUserHallOwner) { - loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true, { transition: 'fade-zoom' }); + targetUrl = `/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`; } else if (wFavsUser) { - loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true, { transition: 'fade-zoom' }); + targetUrl = `/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`; } else if (wTagMatch) { const wTag = decodeURIComponent(wTagMatch[1]); - loadItemAjax(`/tag/${encodeURIComponent(wTag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${targetKey}`, true, { transition: 'fade-zoom' }); + targetUrl = `/tag/${encodeURIComponent(wTag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${targetKey}`; } else if (wHallMatch) { const wHall = decodeURIComponent(wHallMatch[1]); - loadItemAjax(`/h/${encodeURIComponent(wHall).replace(/%20/g, ' ')}/${targetKey}`, true, { transition: 'fade-zoom' }); + targetUrl = `/h/${encodeURIComponent(wHall).replace(/%20/g, ' ')}/${targetKey}`; } else { - loadItemAjax(`/${targetKey}`, true, { transition: 'fade-zoom' }); + targetUrl = `/${targetKey}`; } - - // Background grid sync — page number already in response, no extra fetch needed - if (isOnaraActive() && targetId) { - syncOnaraBackgroundGrid(targetId, `/${targetKey}`, targetPage, targetKey); + loadItemAjax(targetUrl, true, { transition: 'fade-zoom', prefetchedData: data }); + if (isOnaraActive() && data.id) { + syncOnaraBackgroundGrid(data.id, `/${targetKey}`, data.page || 1, targetKey); } - } else if (params.has('tag') || params.has('hall') || params.has('user') || params.has('userHall')) { - // Context had no matching items with the active MIME filter — try global random with the same filter - const fallbackParams = new URLSearchParams(); - const effectiveMime = params.get('mime'); - if (effectiveMime) fallbackParams.append('mime', effectiveMime); - if (params.get('strict')) fallbackParams.append('strict', '1'); - const fallbackUrl = '/api/v2/random' + ([...fallbackParams].length > 0 ? ('?' + fallbackParams.toString()) : ''); - fetch(fallbackUrl) - .then(r => r.json()) - .then(fbData => { - if (fbData.success && fbData.items && (fbData.items.slug || fbData.items.id)) { - const targetKey = fbData.items.slug || fbData.items.id; - loadItemAjax(`/${targetKey}`, true, { transition: 'fade-zoom' }); - } else { - window.location.href = link.href; - } - }) - .catch(() => { window.location.href = link.href; }); } else { - window.location.href = link.href; + // No items found — restore UI, don't redirect + if (nav) nav.classList.remove("pbwork"); + outEls.forEach(el => { el.classList.remove('fade-out-zoom'); el.classList.add('fade-in-zoom'); }); + if (canvas) { canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); } + if (window.flashMessage) window.flashMessage('No items found for this filter'); } }) .catch((err) => { console.error("Random fetch failed:", err); - window.location.href = link.href; + if (nav) nav.classList.remove("pbwork"); + outEls.forEach(el => { el.classList.remove('fade-out-zoom'); el.classList.add('fade-in-zoom'); }); + if (canvas) { canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); } }); return; } diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index fa16b46..f438618 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -1615,8 +1615,13 @@ } } } else if (!matchedCard && identifiers.length > 0 && currentTag && !isReCenteringFeed && !tagFeedLoading) { - // Active item belongs to current tag context but is not in currently rendered slice - // (e.g. after pressing Random in tag). Center feed around this active item! + // Active item belongs to current tag context but is not in currently rendered slice. + // If this was a pool-based random navigation, skip reload — keep existing items visible. + if (window._isPoolRandomNav) { + window._isPoolRandomNav = false; + return; + } + // Non-pool navigation (next/prev): center feed around the active item isReCenteringFeed = true; loadTagFeed(true, identifiers[0]).finally(() => { isReCenteringFeed = false; @@ -2030,6 +2035,17 @@ // Reload recommendations or sync tag feed when user triggers Random (#random, #nav-random, or 'r' key) const handleRandomAction = () => { + // If pool-based random is active, the context hasn't changed — skip reload + if (window._randomPool && window._randomPool.items && window._randomPool.items.length > 1) { + // Context is stable (same tag/hall/user) — just highlight the new item in tag feed + const tag = getCurrentTag(); + if (tag && !userManuallySelectedNonTagTab) { + // Refresh highlight in tag feed without re-fetching + setTimeout(() => highlightActiveTagCard(true), 100); + } + return; + } + // Fallback: server-side random may change context, so reload recommendationsLoaded = false; const tag = getCurrentTag(); if (tag) { diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 619407b..802202d 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -1236,7 +1236,7 @@ const f0cklib = { // 1. Fetch the main item const items = await db` - select distinct on (items.id) + select items.*, items.username as username, uo.username_color as author_color, @@ -1252,9 +1252,7 @@ const f0cklib = { ${user_id ? db`coalesce(uvv.view_count, 0) as my_views` : db`0 as my_views`} from items - left join favorites on favorites.item_id = items.id - left join "user" fav_u on fav_u.id = favorites.user_id - left join "user" author_u on author_u."user" = items.username or author_u.login = items.username + left join "user" author_u on author_u."user" = items.username left join "user_options" uo on uo.user_id = author_u.id ${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``} where @@ -1408,24 +1406,65 @@ const f0cklib = { }; const runTimings = startTime; + + // 2. Neighbor queries — skip useless ones in random mode const [nextItem, prevItem, startItem, endItem, cheatItems] = await Promise.all([ - random ? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`) : optimizedBaseQuery(db`and items.id > ${itemid}`, db`order by items.id asc`), - random ? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`) : optimizedBaseQuery(db`and items.id < ${itemid}`, db`order by items.id desc`), - optimizedBaseQuery(db``, db`order by items.id asc`), - optimizedBaseQuery(db``, db`order by items.id desc`), - // Cheat items - try to get a few neighbors. Simplified: just get some newer ones - optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by abs(items.id - ${itemid}) asc`, 7) + random + ? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`) + : optimizedBaseQuery(db`and items.id > ${itemid}`, db`order by items.id asc`), + random + ? Promise.resolve(null) // reuse nextItem for prev in random mode + : optimizedBaseQuery(db`and items.id < ${itemid}`, db`order by items.id desc`), + random ? Promise.resolve([]) : optimizedBaseQuery(db``, db`order by items.id asc`), + random ? Promise.resolve([]) : optimizedBaseQuery(db``, db`order by items.id desc`), + random ? Promise.resolve([]) : optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by abs(items.id - ${itemid}) asc`, 7) ]); - console.log(`[GETF0CK_OPT] Neighbor queries finished in ${Date.now() - runTimings}ms`); + console.log(`[GETF0CK_OPT] Neighbor queries finished in ${Date.now() - runTimings}ms (random=${!!random})`); + + // For random: prev reuses nextItem result (both are random, no point running 2 ORDER BY random()) + const effectivePrev = random ? nextItem : prevItem; // Cheat array should include current item and neighbors, sorted const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b); - const tags = await lib.getTags(itemid, session); - const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`; - const userHallsForItem = user_id - ? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}` - : []; + // 3. Metadata queries — sequential to avoid pool saturation + const repostBaseChecksum = actitem.checksum + ? (actitem.checksum.includes('_bypass_') ? actitem.checksum.split('_bypass_')[0] : actitem.checksum) + : null; + const hasBypass = actitem.checksum && actitem.checksum.includes('_bypass_'); + + const [tags, itemHalls, userHallsForItem, favorites, repostRows, phashMatches] = await Promise.all([ + lib.getTags(itemid, session), + db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`, + user_id + ? db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}` + : [], + db` + select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint + from "favorites" + left join "user" on "user".id = "favorites".user_id + left join "user_options" on "user_options".user_id = "favorites".user_id + left join "anon_identities" on "anon_identities".user_id = "favorites".user_id + where "favorites".item_id = ${itemid} + `, + repostBaseChecksum + ? db` + SELECT id, slug, username, stamp FROM items + WHERE active = true + AND id != ${itemid} + AND ${hasBypass + ? db`(checksum = ${repostBaseChecksum} OR checksum LIKE ${repostBaseChecksum + '_bypass_%'})` + : db`checksum LIKE ${repostBaseChecksum + '_bypass_%'}` + } + ORDER BY id ASC + ` + : [], + (actitem.phash && actitem.phash !== 'ERROR' && actitem.phash !== 'MISSING') + ? queue.findallrepostphash(actitem.phash, itemid).catch(() => []) + : [] + ]); + console.log(`[GETF0CK_OPT] All queries finished in ${Date.now() - runTimings}ms`); + const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: false }); // Override link for title searches — pagination must use the /tag/title:... prefix if (isTitleSearch && titleQuery) { @@ -1442,14 +1481,6 @@ const f0cklib = { link.path = ''; link.suffix = ''; } - const favorites = await db` - select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint - from "favorites" - left join "user" on "user".id = "favorites".user_id - left join "user_options" on "user_options".user_id = "favorites".user_id - left join "anon_identities" on "anon_identities".user_id = "favorites".user_id - where "favorites".item_id = ${itemid} - `; for (const f of favorites) { if (f.anon_fingerprint || f.user === 'anonymous' || (typeof f.user === 'string' && f.user.startsWith('anon_'))) { @@ -1464,47 +1495,17 @@ const f0cklib = { } } - // Detect reposts: items uploaded with bypass_duplicate_check have checksum = `{hash}_bypass_{ts}` - // Find all items (including this one) that share the same base checksum. - let repostItems = []; - if (actitem.checksum && actitem.checksum.includes('_bypass_')) { - const baseChecksum = actitem.checksum.split('_bypass_')[0]; - const repostRows = await db` - SELECT id, slug, username, stamp FROM items - WHERE active = true - AND id != ${itemid} - AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'}) - ORDER BY id ASC - `; - repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' })); - } else if (actitem.checksum) { - // Even without bypass, check if other bypass-entries exist with this same hash - const baseChecksum = actitem.checksum; - const repostRows = await db` - SELECT id, slug, username, stamp FROM items - WHERE active = true - AND id != ${itemid} - AND checksum LIKE ${baseChecksum + '_bypass_%'} - ORDER BY id ASC - `; - repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' })); - } - - // Also find visually-similar items via phash, merging with checksum results - if (actitem.phash && actitem.phash !== 'ERROR' && actitem.phash !== 'MISSING') { - try { - const phashMatches = await queue.findallrepostphash(actitem.phash, itemid); - const existingIds = new Set(repostItems.map(r => r.id)); - for (const pm of phashMatches) { - if (!existingIds.has(pm.id)) { - repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' }); - existingIds.add(pm.id); - } + // Merge checksum + phash repost results + let repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' })); + if (phashMatches.length > 0) { + const existingIds = new Set(repostItems.map(r => r.id)); + for (const pm of phashMatches) { + if (!existingIds.has(pm.id)) { + repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' }); + existingIds.add(pm.id); } - repostItems.sort((a, b) => a.id - b.id); - } catch (e) { - console.error('[GETF0CK] phash repost lookup failed:', e.message); } + repostItems.sort((a, b) => a.id - b.id); } let hasCoverart = false; @@ -1844,7 +1845,7 @@ const f0cklib = { let anonBlocked = false; if (isNsfw && !allowedModes.includes('nsfw')) anonBlocked = true; else if (isNsfl && !allowedModes.includes('nsfl')) anonBlocked = true; - else if (isUntagged && !allowedModes.includes('untagged')) anonBlocked = true; + else if (isUntagged && !allowedModes.includes('untagged') && Number(mode ?? 0) !== 2) anonBlocked = true; else if (isSfw && !allowedModes.includes('sfw')) anonBlocked = true; if (anonBlocked) { @@ -1867,6 +1868,7 @@ const f0cklib = { // Mode-mismatch visibility check: // Mode 0=sfw, 1=nsfw, 2=untagged, 3=all const userMode = Number(mode ?? 0); + if (userMode === 2) console.log(`[MODE2-DEBUG] item=${itemid} isTagged=${isTagged} isUntagged=${isUntagged} isSfw=${isSfw} isNsfw=${isNsfw} isNsfl=${isNsfl} tags=${effectiveItemTags.map(t => t.id + ':' + t.normalized).join(',')}`); if (!bypass_filter) { let modeBlocked = false; const untaggedDisallowed = (!session && !cfg.websrv.public_untagged) || (isAnonSession(session) && !getAnonAllowedModes().includes('untagged')); @@ -1877,7 +1879,7 @@ const f0cklib = { if (userMode === 0 && (isNsfw || isNsfl || (isUntagged && untaggedDisallowed))) modeBlocked = true; // SFW mode, item is NSFW or NSFL else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL - else if (userMode === 2 && (isTagged || untaggedDisallowed)) modeBlocked = true; // Untagged mode, item has tags + else if (userMode === 2 && !isUntagged) modeBlocked = true; // Unrated mode, item has a rating (sfw/nsfw/nsfl) } if (modeBlocked) { @@ -1993,7 +1995,7 @@ const f0cklib = { end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid), start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid), next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null), - prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null), + prev: (getEnableItemSlugs() && effectivePrev[0]?.slug) ? effectivePrev[0].slug : (effectivePrev[0]?.id || null), page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id, cheat: cheat }, @@ -2114,8 +2116,6 @@ const f0cklib = { select items.id from items - left join tags_assign on tags_assign.item_id = items.id - left join tags on tags.id = tags_assign.tag_id where ${db.unsafe(modequery)} and items.active = true @@ -2126,7 +2126,6 @@ const f0cklib = { ${mimeSQL} ${(!session || isAnonSession(session)) && getGlobalfilter(session) ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter(session))}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} - group by items.id, tags.tag order by random() limit 1 `; @@ -2176,7 +2175,8 @@ const f0cklib = { // If audio is included, we avoid the strict tagId optimization to ensure audio is visible const useTagIdOpt = tagId && !mimeParts.includes('audio'); const nsfpIds = cfg.nsfp || []; - const checkFilter = (!session || isAnonSession(session)) && nsfpIds.length > 0; + // Only apply nsfp filter for SFW/All modes — when user explicitly selected NSFW/NSFL, don't exclude those items + const checkFilter = (!session || isAnonSession(session)) && nsfpIds.length > 0 && (mode === 0 || mode === 3 || mode === undefined || mode === null); // Use a single uniform query with ORDER BY random() // For 30k-100k items, this is performant enough and much more reliable than seeking. @@ -2211,6 +2211,157 @@ const f0cklib = { itemid: item[0].id }; }, + /** + * getRandomPool — returns all candidate IDs/slugs for client-side random selection. + * Mirrors getRandom() filter logic exactly (mode, ratings, strict, exclusions, visibility, mime, etc.) + * Caps at 500 items; uses ORDER BY random() sampling for larger sets. + */ + getRandomPool: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => { + if (fav && rawUser) { + const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin }); + if (isPrivate && !isAllowed) { + return { items: [], total: 0 }; + } + } + const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; + const hall = rawHall || null; + + const _decodedTag = rawTag ? decodeURIComponent(rawTag) : ''; + const isTitleSearch = _decodedTag.startsWith('title:'); + const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null; + const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null); + + const mime = (rawMime ?? ""); + const userHallSlug = rawUserHall || null; + const userHallOwner = rawUserHallOwner || null; + + let userHallId = null; + if (userHallSlug && userHallOwner) { + const uhRows = await db` + SELECT uh.id FROM user_halls uh + JOIN "user" u ON u.id = uh.user_id + WHERE u."user" ILIKE ${userHallOwner} AND uh.slug = ${userHallSlug} + LIMIT 1 + `; + userHallId = uhRows[0]?.id || null; + } + + const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session); + const excludedTags = session && exclude ? (exclude || []) : []; + + const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; + const isStrict = strictParams.length > 0; + + const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null; + const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null; + const modequery = computeBaseMode(mode, ratings, session); + + // Common filter fragments + const globalFilter = (!session || isAnonSession(session)) && getGlobalfilter(session) ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter(session))}))` : db``; + const excludeFilter = excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``; + + let rows; + + if (isTitleSearch && titleQuery) { + rows = await db` + SELECT items.id, items.slug FROM items + WHERE ${db.unsafe(modequery)} + AND items.active = true AND coalesce(items.visibility, 0) = 0 + AND items.title ILIKE ${'%' + titleQuery + '%'} AND items.title IS NOT NULL + ${mimeSQL} ${globalFilter} ${excludeFilter} + ORDER BY items.id + `; + } else if (fav && user) { + rows = await db` + SELECT DISTINCT items.id, items.slug FROM favorites + INNER JOIN items ON favorites.item_id = items.id + INNER JOIN "user" ON "user".id = favorites.user_id + WHERE ${db.unsafe(modequery)} + AND "user".user ILIKE ${user} + AND items.active = true AND coalesce(items.visibility, 0) = 0 + ${mimeSQL} ${globalFilter} + ORDER BY items.id + `; + } else if (user || tag) { + let tagFilter = db``; + if (tag) { + const terms = tag.split(',').map(t => t.trim()).filter(Boolean); + if (terms.length > 0) { + if (isStrict) { + tagFilter = db`and items.id in ( + select ta.item_id + from tags_assign ta + join tags t on t.id = ta.tag_id + where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x)) + group by ta.item_id + having count(distinct t.normalized) = ${terms.length} + )`; + } else { + const conditions = terms.map(term => { + return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`; + }); + tagFilter = db`${conditions}`; + } + } + } + + rows = await db` + SELECT items.id, items.slug FROM items + WHERE ${db.unsafe(modequery)} + AND items.active = true AND coalesce(items.visibility, 0) = 0 + ${tagFilter} + ${user ? db`and items.username ilike ${user}` : db``} + ${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``} + ${mimeSQL} ${globalFilter} ${excludeFilter} + ORDER BY items.id + `; + } else if (hall) { + rows = await db` + SELECT items.id, items.slug FROM items + JOIN halls_assign ha ON ha.item_id = items.id + JOIN halls h ON h.id = ha.hall_id + WHERE ${db.unsafe(modequery)} + AND h.slug = ${hall} + AND items.active = true AND coalesce(items.visibility, 0) = 0 + ${mimeSQL} ${globalFilter} ${excludeFilter} + ORDER BY items.id + `; + } else if (userHallId) { + rows = await db` + SELECT items.id, items.slug FROM items + JOIN user_halls_assign uha ON uha.item_id = items.id + WHERE ${db.unsafe(modequery)} + AND uha.hall_id = ${userHallId} + AND items.active = true AND coalesce(items.visibility, 0) = 0 + ${mimeSQL} ${excludeFilter} + ORDER BY items.id + `; + } else { + // Global pool — use tag optimization where possible + const globalModeQuery = modequery; + const tagId = session && !isAnonSession(session) && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4) + ? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1)) + : null; + const useTagIdOpt = tagId && !mimeParts.includes('audio'); + const nsfpIds = cfg.nsfp || []; + const checkFilter = (!session || isAnonSession(session)) && nsfpIds.length > 0 && (mode === 0 || mode === 3 || mode === undefined || mode === null); + + rows = await db` + SELECT items.id, items.slug FROM items + ${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``} + ${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``} + WHERE items.active = true AND coalesce(items.visibility, 0) = 0 + ${mimeSQL} + ${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``} + ${excludeFilter} + ${!useTagIdOpt ? db`AND ${db.unsafe(globalModeQuery)}` : db``} + ORDER BY items.id LIMIT 10000 + `; + } + + const items = rows.map(r => r.slug || String(r.id)); + return { items, total: items.length, sampled: items.length >= 10000 }; + }, getComments: async (itemId, sort = 'new', process = true) => { const numericId = await resolveNumericItemId(itemId); if (!numericId) return []; diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 437a3c2..b099787 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -6,6 +6,234 @@ import { createI18n } from "../i18n.mjs"; import { isAnonymizeSession } from "../settings.mjs"; export default (router, tpl) => { + // ── Merged random + item load: single request instead of two ──────────── + router.get(/^\/ajax\/item\/random/, async (req, res) => { + const tAjaxStart = Date.now(); + let query = {}; + if (typeof req.url === 'string') { + const parsedUrl = url.parse(req.url, true); + query = parsedUrl.query; + } else { + query = req.url.qs || {}; + } + + const isGuest = !req.session || !req.session.user; + const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode); + const ratingsRaw = req.cookies.ratings; + const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null)); + + const tag = query.tag || null; + const hall = query.hall || null; + const user = query.user || null; + const userHall = query.userHall || null; + const userHallOwner = query.userHallOwner || null; + const isFav = query.fav === 'true'; + const isStrict = query.strict === '1'; + const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null; + const mime = (typeof query.mime !== 'undefined') ? (query.mime || null) : (cookieMime || null); + + // Resolve random item ID + const randomData = await f0cklib.getRandom({ + user, tag, hall, userHall, userHallOwner, mime, + fav: isFav, + mode: reqMode, + ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, + strict: isStrict, + session: req.session, + exclude: req.session?.excluded_tags || [], + user_id: req.session?.id, + is_admin: req.session?.admin + }); + const tRandom = Date.now(); + if (!randomData || !randomData.itemid) { + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ html: '', error: true, success: false, message: 'No items found' }) + }); + } + + const itemid = String(randomData.itemid); + + // Build context URL for the resolved item + let contextUrl = `/${itemid}`; + if (tag) contextUrl = `/tag/${encodeURIComponent(tag)}/${itemid}`; + if (hall) contextUrl = `/h/${encodeURIComponent(hall)}/${itemid}`; + if (userHall && userHallOwner) { + contextUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`; + } else if (user) { + contextUrl = isFav + ? `/user/${encodeURIComponent(user)}/favs/${itemid}` + : `/user/${encodeURIComponent(user)}/${itemid}`; + } else if (isFav) { + contextUrl = `/favs/${itemid}`; + } + if (mime) { + contextUrl = contextUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`); + } + + if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX-RANDOM] Resolved random item ${itemid} in ${Date.now() - tAjaxStart}ms`); + + // Now run the full item load pipeline (same as /ajax/item/:id) + const bypassFilter = !!(query.bypass === '1'); + const data = await f0cklib.getf0ck({ + itemid: itemid, + mode: reqMode, + ratings: ratingsArr, + bypass_filter: bypassFilter, + session: req.session, + url: contextUrl, + user: user, + tag: tag, + hall: hall, + userHall: userHall, + userHallOwner: userHallOwner, + mime: mime, + fav: isFav, + ids: null, + random: true, + strict: isStrict || req.session?.strict_mode, + explicitStrict: isStrict, + exclude: req.session ? (req.session.excluded_tags || []) : [], + user_id: req.session?.id, + subf0ck: query.subf0ck || null + }); + const tAjaxFetch = Date.now(); + + if (!data.success) { + const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); + const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' }; + const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null; + const errorHtml = tpl.render('error-partial', { + message: tErr('error.post_not_visible'), + tmp: null, + session: req.session ? { ...req.session } : false, + item_id: data.item?.id || itemid, + item_slug: data.item?.slug || null, + error_mode_label: errorModeLabel, + error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `${errorModeLabel}` }) : null, + error_filter_hint_link: tErr('error.filter_hint_link'), + error_see_anyways: tErr('error.see_anyways') + }, req); + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ html: errorHtml, pagination: '', error: true }) + }); + } + + // xD Score + comments — parallelize subscription + comments fetch + if (req.session || !cfg.main.hide_comments_from_public) { + if (req.session?.id) { + f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {}); + } + const [sub, commentsForScore] = await Promise.all([ + req.session ? f0cklib.getSubscriptionStatus(req.session.id, itemid) : false, + f0cklib.getComments(itemid, 'old', false) + ]); + data.isSubscribed = sub; + const xdScore = f0cklib.computeXdScore(commentsForScore); + const xdMeta = f0cklib.xdScoreMeta(xdScore); + data.item.xd_score = xdScore; + data.item.xd_tier = xdMeta.tier; + data.item.xd_label = xdMeta.label; + data.commentsJSON = null; + data.comments = []; + } else { + data.isSubscribed = false; + data.commentsJSON = null; + data.comments = []; + data.item.xd_score = 0; + data.item.xd_tier = 0; + data.item.xd_label = ''; + } + const tAjaxAux = Date.now(); + + // Session + template vars + data.session = req.session ? { ...req.session } : false; + data.url = { pathname: contextUrl }; + data.fullscreen = req.cookies.fullscreen || 0; + data.hidePagination = true; + + // Precompute hall display data + if (data.item && data.item.halls && data.item.halls.length) { + const currentHallSlug = data.tmp && data.tmp.hall + ? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall) + : null; + data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0]; + data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug); + } else if (data.item) { + data.item.primaryHall = null; + data.item.otherHalls = []; + } + + // Precomputed template booleans + if (data.item) { + const session = data.session; + const item = data.item; + if (isAnonymizeSession(req.session)) { + if (item.src) item.src = null; + item.username = 'anonymous'; + item.author_banner_file = null; + item.author_banner_position = null; + item.author_banner_size = null; + item.author_avatar = null; + item.author_avatar_file = null; + item.author_color = null; + item.author_description = null; + item.author_display_name = null; + item.author_id = null; + if (data.uploader) { + data.uploader.name = 'anonymous'; + data.uploader.id = null; + data.uploader.color = null; + } + if (Array.isArray(item.favorites)) { + item.favorites = item.favorites.map(f => { + const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id); + if (isSelf) return f; + return { user_id: null, user: 'anonymous', login: 'anonymous', display_name: 'Anonymous', avatar: null, avatar_file: null, username_color: null, hide_fav_badge: f.hide_fav_badge, is_anon: true }; + }); + } + } + const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); + data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); + data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); + data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); + data.user_has_favorited = lib.userHasFavorited(session, item.favorites); + data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; + data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : ''; + data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')); + data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')); + data.item_username_lower = (item.username || '').toLowerCase(); + data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1)); + data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])); + data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : ''); + data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : ''); + data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : ''; + data.item_has_dimensions = !!(item.width && item.height); + } + + // Render + const itemHtml = tpl.render('ajax-item', data, req); + const paginationHtml = tpl.render('snippets/pagination', data, req); + const tAjaxRender = Date.now(); + + // Detailed timing breakdown + console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`); + + res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + html: itemHtml, + pagination: paginationHtml, + title: data.title, + id: itemid, + slug: data.item?.slug || null, + page: null, + is_random: true + }) + }); + }); + router.get(/^\/ajax\/item\/(?[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => { const tAjaxStart = Date.now(); let query = {}; diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index dfe8401..10e6d85 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -800,27 +800,13 @@ export default router => { }); } - // Run item fetch + page lookup in parallel — saves one sequential DB round-trip - const [rows, itemPage] = await Promise.all([ - db` - SELECT * - FROM "items" - WHERE id = ${data.itemid} AND active = true - LIMIT 1 - `, - f0cklib.getItemPage({ - targetItemId: data.itemid, - user, tag, hall, userHall, userHallOwner, mime, - fav: isFav, - mode, - ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, - strict: isStrict, - session: req.session, - exclude: req.session?.excluded_tags || [], - user_id: req.session?.id, - is_admin: req.session?.admin - }).catch(() => 1) - ]); + // Skip getItemPage for random — page number is meaningless and it's expensive + const rows = await db` + SELECT * + FROM "items" + WHERE id = ${data.itemid} AND active = true + LIMIT 1 + `; const item = rows[0]; @@ -852,11 +838,49 @@ export default router => { dest: relativeDest, url: directUrl, direct_url: directUrl, - page: itemPage + page: 1 } }); }); + group.get('/random-pool', async (req, res) => { + const user = req.url.qs?.user || null; + const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null; + const mime = (typeof req.url.qs?.mime !== 'undefined') + ? (req.url.qs.mime || null) + : (cookieMime || null); + const tag = req.url.qs?.tag || null; + const hall = req.url.qs?.hall || null; + const userHall = req.url.qs?.userHall || null; + const userHallOwner = req.url.qs?.userHallOwner || null; + const isFav = req.url.qs?.fav === 'true'; + const isStrict = req.url.qs?.strict === '1'; + const mode = req.mode ?? 0; + const ratingsRaw = req.cookies.ratings; + const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; + + const tStart = Date.now(); + const pool = await f0cklib.getRandomPool({ + user, + tag, + hall, + userHall, + userHallOwner, + mime, + fav: isFav, + mode, + ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, + strict: isStrict, + session: req.session, + exclude: req.session?.excluded_tags || [], + user_id: req.session?.id, + is_admin: req.session?.admin + }); + console.log(`[RANDOM-POOL] ${pool.total} items in ${Date.now() - tStart}ms (mode=${mode} tag=${tag} hall=${hall} user=${user})`); + + return res.json(pool); + }); + group.get(/\/recommendations(?:\/(?videos|all))?$/, async (req, res) => { try { const limit = Math.min(+(req.url.qs?.limit || 20), 50); diff --git a/src/inc/routes/random.mjs b/src/inc/routes/random.mjs index b0a5713..f739d78 100644 --- a/src/inc/routes/random.mjs +++ b/src/inc/routes/random.mjs @@ -67,7 +67,11 @@ export default (router, tpl) => { code: 404, body: tpl.render('error', { message: data.message, - tmp: null + tmp: null, + session: req.session ? { ...req.session } : false, + error_filter_hint: null, + error_filter_hint_link: null, + error_see_anyways: null }, req) }); } diff --git a/views/error-partial.html b/views/error-partial.html index 5bb9795..759a96e 100644 --- a/views/error-partial.html +++ b/views/error-partial.html @@ -11,7 +11,7 @@ {{ t('error.label') }} {{ message }} - @if(error_filter_hint) + @if(typeof error_filter_hint !== 'undefined' && error_filter_hint)
{{ error_filter_hint }}
{{ error_filter_hint_link }} diff --git a/views/error.html b/views/error.html index c5b88df..0471cda 100644 --- a/views/error.html +++ b/views/error.html @@ -14,7 +14,7 @@ {{ t('error.label') }} {{ message }}
- @if(error_filter_hint) + @if(typeof error_filter_hint !== 'undefined' && error_filter_hint)
{{ error_filter_hint }}
{{ error_filter_hint_link }}