// Normalize percent-encoded characters in the URL bar that are safe to show decoded. // Runs immediately so the address bar is clean before any other JS runs. (function () { try { const p = window.location.pathname; // Decode colon and space; leave %2F (/), %3F (?), %23 (#), %26 (&) encoded. const clean = p.replace(/%3A/gi, ':').replace(/%20/gi, ' '); if (clean !== p) { history.replaceState(null, '', clean + window.location.search + window.location.hash); } } catch (_) {} })(); if (typeof window.f0ckDebug !== 'function') { window.f0ckDebug = (...args) => { if (window.f0ckSession?.development) console.log(...args); }; } window.getCsrfToken = () => { return (window.f0ckSession && window.f0ckSession.csrf_token) || document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || document.querySelector('input[name="csrf_token"]')?.value || ''; }; window.requestAnimFrame = (function () { return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function (callback) { window.setTimeout(callback, 1000 / 60); }; })(); window.cancelAnimFrame = (function () { return window.cancelAnimationFrame || window.webkitCancelAnimationFrame || window.mozCancelAnimationFrame || function (id) { window.clearTimeout(id); }; })(); (() => { var i18n = window.f0ckI18n || {}; window.escHTML = (str) => { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }; window.getCurrentItemId = () => { // 1. If Onara modal is open or mounted, strictly search within #onara-item-mount const isOnaraOpen = document.body.classList.contains('onara-modal-open'); if (isOnaraOpen) { const mount = document.getElementById('onara-item-mount'); if (mount) { const idEl = mount.querySelector('.item-layout-container[data-item-id], a.id-link[data-item-id], #comments-container[data-item-id], .rating-tag[data-item-id], #tags[data-item-id]'); if (idEl?.dataset?.itemId) { const parsed = parseInt(idEl.dataset.itemId, 10); if (!isNaN(parsed) && parsed > 0) return String(parsed); } const idLink = mount.querySelector('a.id-link'); if (idLink && idLink.innerText) { const parsed = parseInt(idLink.innerText.trim(), 10); if (!isNaN(parsed) && parsed > 0) return String(parsed); } } const activeThumb = document.querySelector('.posts > a.thumb.onara-active[data-item-id]'); if (activeThumb?.dataset?.itemId) { const parsed = parseInt(activeThumb.dataset.itemId, 10); if (!isNaN(parsed) && parsed > 0) return String(parsed); } } // 2. If viewing an item in main content (regular full-page view or PJAX) const itemContainer = document.querySelector('.item-layout-container, #main.item-view, #main > .container.item-view'); if (itemContainer) { const idEl = itemContainer.querySelector('.item-layout-container[data-item-id], a.id-link[data-item-id], #comments-container[data-item-id], .rating-tag[data-item-id], #tags[data-item-id]'); if (idEl?.dataset?.itemId) { const parsed = parseInt(idEl.dataset.itemId, 10); if (!isNaN(parsed) && parsed > 0) return String(parsed); } if (itemContainer.dataset?.itemId) { const parsed = parseInt(itemContainer.dataset.itemId, 10); if (!isNaN(parsed) && parsed > 0) return String(parsed); } const idLink = itemContainer.querySelector('a.id-link'); if (idLink && idLink.innerText) { const parsed = parseInt(idLink.innerText.trim(), 10); if (!isNaN(parsed) && parsed > 0) return String(parsed); } } // 3. Fallback to URL pathname if on an item page const path = window.location.pathname; if (path.includes('/admin/') || path.includes('/mod/') || path.includes('/settings') || path.includes('/user/')) return null; const match = path.match(/\/(\d+)\/?$/); if (match) return match[1]; // NOTE: NEVER use document.querySelector('[data-item-id]') globally here, // as it erroneously matches feed/grid thumbnails! return null; }; // - disabled for clean guest mode const f0ckGuestFavs = { get: () => [], has: () => false, toggle: () => false, clear: () => { try { localStorage.removeItem('f0ck_guest_favs'); localStorage.removeItem('guest_favs'); } catch (e) {} }, count: () => 0 }; window.f0ckGuestFavs = f0ckGuestFavs; const syncGuestFavoIcon = () => {}; window.syncGuestFavoIcon = syncGuestFavoIcon; document.addEventListener('click', e => { const target = e.target.nodeType === 3 ? e.target.parentElement : e.target; const favoBtn = target.closest('#a_favo'); if (!favoBtn) return; if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (typeof window.flashMessage === 'function') { window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error'); } return; } if (window.f0ckSession && window.f0ckSession.user) return; e.preventDefault(); e.stopPropagation(); if (typeof window.flashMessage === 'function') { window.flashMessage('Login to favorite posts', 3000, 'warning'); } }); const checkGuestFavsImportBanner = () => { if (!window.f0ckSession || !window.f0ckSession.user) return; const isUserFavs = window.location.pathname.match(/\/user\/([^/]+)\/favs/); if (!isUserFavs) return; const currentUser = window.f0ckSession.user.toLowerCase(); if (decodeURIComponent(isUserFavs[1]).toLowerCase() !== currentUser) return; const count = window.f0ckGuestFavs ? window.f0ckGuestFavs.count() : 0; if (count <= 0) { const existing = document.getElementById('guest-favs-import-banner'); if (existing) existing.remove(); return; } if (document.getElementById('guest-favs-import-banner')) return; const postsContainer = document.querySelector('.posts'); if (!postsContainer || !postsContainer.parentElement) return; const banner = document.createElement('div'); banner.id = 'guest-favs-import-banner'; banner.className = 'guest-favs-banner'; banner.style.cssText = 'background: rgba(255, 107, 157, 0.12); border: 1px solid rgba(255, 107, 157, 0.35); border-radius: 8px; padding: 12px 18px; margin: 15px auto; max-width: 900px; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 0.95em; color: var(--text-color, #fff);'; const textSpan = document.createElement('span'); const msg = (window.f0ckI18n && window.f0ckI18n.guest_favs_saved) || 'You have {count} guest favorites saved on this device.'; textSpan.innerHTML = ` ${msg.replace('{count}', `${count}`)}`; const actionsDiv = document.createElement('div'); actionsDiv.style.cssText = 'display: flex; gap: 8px; align-items: center; flex-shrink: 0;'; const importBtn = document.createElement('button'); importBtn.id = 'btn-import-guest-favs'; importBtn.className = 'btn btn-sm'; importBtn.style.cssText = 'background: #ff6b9d; border: none; border-radius: 4px; padding: 6px 14px; color: white; cursor: pointer; font-weight: 600; font-size: 0.85em; transition: background 0.15s;'; importBtn.textContent = (window.f0ckI18n && window.f0ckI18n.sync_guest_favs) || 'Import to Account'; const dismissBtn = document.createElement('button'); dismissBtn.id = 'btn-dismiss-guest-favs'; dismissBtn.className = 'btn btn-sm'; dismissBtn.style.cssText = 'background: transparent; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 6px 10px; color: #ccc; cursor: pointer; font-size: 0.85em;'; dismissBtn.textContent = (window.f0ckI18n && window.f0ckI18n.dismiss) || 'Dismiss'; importBtn.onclick = async () => { importBtn.disabled = true; importBtn.textContent = '...'; try { const ids = window.f0ckGuestFavs.get(); const res = await fetch('/api/v2/favorites/import', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token || '' }, body: JSON.stringify({ ids }) }).then(r => r.json()); if (res.success) { window.f0ckGuestFavs.clear(); banner.remove(); if (window.flashMessage) { const succMsg = (window.f0ckI18n && window.f0ckI18n.guest_favs_imported) || 'Imported favorites to your account!'; window.flashMessage(succMsg); } if (typeof window.loadPageAjax === 'function') { window.loadPageAjax(window.location.pathname, true, { bypassCache: true }); } else { window.location.reload(); } } else { importBtn.disabled = false; importBtn.textContent = (window.f0ckI18n && window.f0ckI18n.sync_guest_favs) || 'Import to Account'; } } catch (err) { importBtn.disabled = false; importBtn.textContent = (window.f0ckI18n && window.f0ckI18n.sync_guest_favs) || 'Import to Account'; } }; dismissBtn.onclick = () => { window.f0ckGuestFavs.clear(); banner.remove(); }; actionsDiv.appendChild(importBtn); actionsDiv.appendChild(dismissBtn); banner.appendChild(textSpan); banner.appendChild(actionsDiv); postsContainer.parentElement.insertBefore(banner, postsContainer); }; window.checkGuestFavsImportBanner = checkGuestFavsImportBanner; // // OS and Browser detection for CSS targeting const ua = navigator.userAgent; const htmlEl = document.documentElement; if (ua.includes('Linux')) htmlEl.classList.add('is-linux'); if (ua.includes('Windows')) htmlEl.classList.add('is-windows'); if (ua.includes('Firefox')) htmlEl.classList.add('is-firefox'); if (ua.includes('Chrome')) htmlEl.classList.add('is-chrome'); if (ua.includes('Safari') && !ua.includes('Chrome')) htmlEl.classList.add('is-safari'); // DPR / zoom-level detection — works in Firefox (which ignores zoom in CSS dppx queries) const _updateDprTier = () => { const dpr = window.devicePixelRatio || 1; // Tiers mirror the CSS resolution media-query breakpoints let tier = 'dpr-1x'; // 1.0 – 1.04 if (dpr >= 1.55) tier = 'dpr-high'; // ≥ 155% zoom else if (dpr >= 1.39) tier = 'dpr-150'; // ~140–154% zoom else if (dpr >= 1.26) tier = 'dpr-130'; // ~126–138% else if (dpr >= 1.20) tier = 'dpr-120'; // ~120–125% else if (dpr >= 1.05) tier = 'dpr-110'; // ~105–115% htmlEl.setAttribute('data-dpr', tier); }; _updateDprTier(); // Re-evaluate when the user changes zoom (fires on every DPR change) if (window.matchMedia) { let _dprMql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); const _onDprChange = () => { _updateDprTier(); // Re-bind to the NEW dpr value so we catch the next change too _dprMql.removeEventListener('change', _onDprChange); _dprMql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); _dprMql.addEventListener('change', _onDprChange); }; _dprMql.addEventListener('change', _onDprChange); } if (localStorage.getItem('blurNsfw') === 'true') htmlEl.classList.add('blur-nsfw-active'); if (localStorage.getItem('blurNsfl') === 'true') htmlEl.classList.add('blur-nsfl-active'); if (localStorage.getItem('blurSfw') === 'true') htmlEl.classList.add('blur-sfw-active'); if (localStorage.getItem('blurUntagged') === 'true') htmlEl.classList.add('blur-untagged-active'); if (localStorage.getItem('blurDetail') !== 'false') htmlEl.classList.add('blur-detail-active'); if (localStorage.getItem('imageExpandOnClick') !== 'false') htmlEl.classList.add('image-expand-active'); if (localStorage.getItem('hideItemRatings') === 'true') htmlEl.classList.add('hide-item-ratings-active'); window.updateVisitIndicators = () => { try { // View indicators and counters have been permanently removed as requested. // This function is now a no-op to prevent injection into items. } catch (e) { console.error('Visit tracking error:', e); } }; window.trackVisit = (id) => { try { const visits = JSON.parse(localStorage.getItem('visited_videos') || '{}'); visits[id] = (visits[id] || 0) + 1; localStorage.setItem('visited_videos', JSON.stringify(visits)); // Delay update slightly to ensure DOM is ready? No, update immediately is fine. updateVisitIndicators(); } catch(e) { console.error('Visit tracking error:', e); } }; const ensureLayoutClass = () => { const isLegacy = window.f0ckSession ? !window.f0ckSession.use_new_layout : (window.f0ckDefaultLayout === 'legacy'); document.body.classList.remove('legacy-view', 'layout-modern', 'layout-legacy'); if (isLegacy) { document.body.classList.add('layout-legacy'); } else { document.body.classList.add('layout-modern'); } }; window.ensureLayoutClass = ensureLayoutClass; ensureLayoutClass(); window.applyThumbCacheBust = (bgUrlStr) => { if (!bgUrlStr) return bgUrlStr; try { const bustedStr = localStorage.getItem('bustedThumbs'); if (!bustedStr) return bgUrlStr; const busted = JSON.parse(bustedStr); const match = bgUrlStr.match(/\/t\/(\d+)(?:_blur)?\.webp/); if (match) { const id = match[1]; if (busted[id]) { const url = new URL(bgUrlStr, window.location.origin); url.searchParams.set('t', busted[id]); return url.pathname + url.search; } } } catch(e) {} return bgUrlStr; }; /** * Forcefully refreshes all thumbnail occurrences for a specific item in the DOM. * Handles grid items (data-bg), images (src), and the background canvas. */ window.refreshItemThumbnails = (itemId, timestamp = Date.now(), hasCoverart = undefined) => { if (!itemId) return; const idStr = String(itemId); // Update localStorage so future navigations use the new timestamp try { const bustedStr = localStorage.getItem('bustedThumbs'); const busted = bustedStr ? JSON.parse(bustedStr) : {}; busted[idStr] = timestamp; const keys = Object.keys(busted); if (keys.length > 50) delete busted[keys[0]]; localStorage.setItem('bustedThumbs', JSON.stringify(busted)); } catch(e) {} // Clear grid cache to force fresh render on next navigation if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear(); document.querySelectorAll(`[data-bg*="/t/${idStr}.webp"], [data-bg*="/t/${idStr}_blur.webp"]`).forEach(el => { if (el.dataset.bg) { el.dataset.bg = window.applyThumbCacheBust(el.dataset.bg); } // Update the --thumb-bg CSS custom property used by the ::after pseudo-element const currentBg = el.style.getPropertyValue('--thumb-bg'); if (currentBg && (currentBg.includes(`/t/${idStr}.webp`) || currentBg.includes(`/t/${idStr}_blur.webp`))) { const newUrl = currentBg.replace(/url\(['"](.*?)['"]\)/, (_, p1) => `url('${window.applyThumbCacheBust(p1)}')`); el.style.setProperty('--thumb-bg', newUrl); } }); // Update actual img tags document.querySelectorAll(`img[src*="/t/${idStr}.webp"], img[src*="/t/${idStr}_blur.webp"]`).forEach(el => { try { const url = new URL(el.src, window.location.origin); url.searchParams.set('t', timestamp); el.src = url.pathname + url.search; } catch(e) {} }); const currentId = typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null; // Update audio cover in player if viewing this audio item const audioCover = document.getElementById('f0ck-audio-cover'); if (audioCover && currentId === idStr) { const parent = audioCover.parentElement; const audioEl = document.querySelector('audio#my-video'); let ph = parent ? parent.querySelector(':scope > .sidebar-media-placeholder.audio') : null; if (parent && !ph) { ph = document.createElement('div'); ph.className = 'sidebar-media-placeholder audio'; ph.innerHTML = ''; parent.prepend(ph); } let coverCircle = ph ? ph.querySelector('.audio-cover-circle') : null; if (hasCoverart === true || (hasCoverart === undefined && audioCover.src && audioCover.src.includes('/ca/'))) { const coverUrl = `/ca/${idStr}.webp?t=${timestamp}`; audioCover.src = coverUrl; if (parent) { parent.style.background = 'none'; if (ph) { if (!coverCircle) { coverCircle = document.createElement('div'); coverCircle.className = 'audio-cover-circle'; coverCircle.innerHTML = ''; ph.insertBefore(coverCircle, ph.firstChild); } else { if (!coverCircle.querySelector('i')) { const existingI = ph.querySelector(':scope > i'); if (existingI) coverCircle.appendChild(existingI); else coverCircle.insertAdjacentHTML('beforeend', ''); } } coverCircle.style.backgroundImage = `url('${coverUrl}')`; ph.classList.add('has-cover'); ph.style.display = ''; } } if (audioEl) { audioEl.setAttribute('poster', coverUrl); } } else if (hasCoverart === false) { audioCover.removeAttribute('src'); if (parent) { parent.style.background = '#000000'; if (ph) { if (!coverCircle) { coverCircle = document.createElement('div'); coverCircle.className = 'audio-cover-circle'; coverCircle.innerHTML = ''; ph.insertBefore(coverCircle, ph.firstChild); } else { if (!coverCircle.querySelector('i')) { const existingI = ph.querySelector(':scope > i'); if (existingI) coverCircle.appendChild(existingI); else coverCircle.insertAdjacentHTML('beforeend', ''); } coverCircle.style.backgroundImage = 'none'; coverCircle.style.backgroundColor = '#000000'; } ph.classList.remove('has-cover'); ph.style.backgroundColor = '#000000'; ph.style.display = ''; } } if (audioEl) { audioEl.removeAttribute('poster'); } } } // Refresh background canvas if it matches the current item if (currentId === idStr && window.initBackground) { window.initBackground(); } }; window.loadedThumbs = window.loadedThumbs || new Set(); let lazyObserver; window.initLazyLoading = () => { const getFinalBg = (thumb) => { let bg = thumb.dataset.bg; if (!bg) return null; const mode = thumb.getAttribute('data-mode'); const blurNsfw = localStorage.getItem('blurNsfw') === 'true'; const blurNsfl = localStorage.getItem('blurNsfl') === 'true'; const blurSfw = localStorage.getItem('blurSfw') === 'true'; const blurUntagged = localStorage.getItem('blurUntagged') === 'true'; let shouldBlurThis = false; if (mode === 'nsfw') shouldBlurThis = blurNsfw; else if (mode === 'nsfl') shouldBlurThis = blurNsfl; else if (mode === 'sfw') shouldBlurThis = blurSfw; else if (mode === 'null' || !mode) shouldBlurThis = blurUntagged; if (shouldBlurThis && !thumb.classList.contains('revealed')) { bg = bg.replace('.webp', '_blur.webp'); } return window.applyThumbCacheBust(bg); }; // Helper: apply thumb image via CSS custom property — picked up by the loaded CSS rule const applyThumb = (thumb, finalBg) => { thumb.style.setProperty('--thumb-bg', `url('${finalBg}')`); thumb.classList.add('loaded'); thumb.classList.remove('lazy-thumb'); window.loadedThumbs.add(finalBg); }; // Synchronously resolve any already-loaded thumbnails (cached from previous navigation) document.querySelectorAll('.lazy-thumb').forEach(thumb => { const finalBg = getFinalBg(thumb); if (finalBg && window.loadedThumbs.has(finalBg)) { applyThumb(thumb, finalBg); } }); if (!('IntersectionObserver' in window)) { document.querySelectorAll('.lazy-thumb').forEach(thumb => { const finalBg = getFinalBg(thumb); if (finalBg) applyThumb(thumb, finalBg); }); return; } // ── Synchronous first pass: load all thumbs currently visible in the viewport ── // IntersectionObserver fires asynchronously — visible items would show skeleton // for a brief frame. This eliminates that by loading them in the same JS tick. // // IMPORTANT: batch all getBoundingClientRect() reads before any writes. // Chromium invalidates its layout cache on every DOM write, so interleaving // reads (getBoundingClientRect) and writes (classList.add, style.setProperty) // inside one loop causes one full layout recalculation per thumbnail — O(n) thrash. // Separating into a read pass then a write pass means a single layout flush. const vw = window.innerWidth; const vh = window.innerHeight; const unloadedThumbs = Array.from(document.querySelectorAll('.lazy-thumb')) .filter(t => !t.classList.contains('loaded')); // ── Read phase: one layout flush for all rects ── const thumbRects = unloadedThumbs.map(t => t.getBoundingClientRect()); // ── Write phase: no layout reads ── unloadedThumbs.forEach((thumb, i) => { const r = thumbRects[i]; if (r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw) { const finalBg = getFinalBg(thumb); if (finalBg) { const img = new Image(); img.src = finalBg; if (img.complete && img.naturalWidth > 0) { applyThumb(thumb, finalBg); } else { img.onload = () => applyThumb(thumb, finalBg); img.onerror = () => { const mime = thumb.dataset.mime || ''; if (mime.startsWith('audio/')) { if (!thumb.querySelector('.sidebar-media-placeholder.audio')) { const ph = document.createElement('div'); ph.className = 'sidebar-media-placeholder audio'; ph.innerHTML = ''; thumb.prepend(ph); } thumb.classList.add('thumb-fallback'); } thumb.classList.remove('lazy-thumb'); }; } thumb.dataset.lazyObserved = 'true'; } } }); if (!lazyObserver) { lazyObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const thumb = entry.target; if (thumb.dataset.bg && !thumb.classList.contains('loaded')) { const finalBg = getFinalBg(thumb); if (finalBg) { const img = new Image(); img.src = finalBg; if (img.complete && img.naturalWidth > 0) { applyThumb(thumb, finalBg); } else { img.onload = () => applyThumb(thumb, finalBg); img.onerror = () => { const retries = parseInt(thumb.dataset.retries || '0'); if (retries < 3) { thumb.dataset.retries = retries + 1; setTimeout(() => { img.src = finalBg + '?r=' + Date.now(); }, 1000); } else { const mime = thumb.dataset.mime || ''; if (mime.startsWith('audio/')) { if (!thumb.querySelector('.sidebar-media-placeholder.audio')) { const ph = document.createElement('div'); ph.className = 'sidebar-media-placeholder audio'; ph.innerHTML = ''; thumb.prepend(ph); } thumb.classList.add('thumb-fallback'); } thumb.classList.remove('lazy-thumb'); } }; } } } lazyObserver.unobserve(thumb); } }); }, { rootMargin: '200px 0px', threshold: 0.01 }); } // Nudge lazy loading on tab switch to prevent stuck skeletons in inactive tabs if (!window._lazyVisibilityBound) { window._lazyVisibilityBound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden && typeof window.initLazyLoading === 'function') { document.querySelectorAll('.lazy-thumb:not(.loaded)').forEach(t => { delete t.dataset.lazyObserved; }); window.initLazyLoading(); } }); } // Observe only off-screen thumbs — visible ones were already handled above document.querySelectorAll('.lazy-thumb').forEach(thumb => { if (!thumb.dataset.lazyObserved) { thumb.dataset.lazyObserved = 'true'; lazyObserver.observe(thumb); } }); }; window.showMediaOverlay = (show = true) => { const overlay = document.querySelector('.v0ck_overlay'); if (overlay) overlay.classList[show ? 'remove' : 'add']('v0ck_hidden'); }; window.flashMessage = (text, duration = 2000, type = 'info') => { // Ensure the stacking container exists const fsEl = document.fullscreenElement; const targetParent = fsEl || document.body; let container = document.getElementById('f0ck-flash-container'); if (!container) { container = document.createElement('div'); container.id = 'f0ck-flash-container'; container.style.cssText = 'position:fixed;bottom:20px;left:20px;z-index:100200;display:flex;flex-direction:column-reverse;gap:8px;pointer-events:none;'; targetParent.appendChild(container); } else { container.style.zIndex = '100200'; if (container.parentElement !== targetParent) { targetParent.appendChild(container); } } const flash = document.createElement('div'); if (typeof text === 'string' && text.includes(' { flash.style.opacity = '1'; flash.style.transform = 'translateY(0)'; }); setTimeout(() => { flash.style.opacity = '0'; flash.style.transform = 'translateY(6px)'; setTimeout(() => flash.remove(), 300); }, duration); }; let video; let isNavigating = false; const main = document.getElementById('main'); let posts = document.querySelector('.posts'); const navbar = document.querySelector("nav.navbar"); const gridCacheMap = new Map(); // Cache for detached grid nodes (URL -> {node, scroll}) const itemCacheMap = new Map(); // Stale-while-revalidate cache for item AJAX HTML (cacheKey -> {html, ts}) const ITEM_CACHE_MAX = 30; // Max entries; evict oldest on overflow window.activeMode = 0; // Default let audioCtx = null; let visualizerRafId = null; let audioSource = null; const isAnonUser = () => !(window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon); window.isAnonUser = isAnonUser; const isGuestUser = () => { if (!window.f0ckSession) return true; if (window.f0ckSession.user && !window.f0ckSession.is_anon) return false; if (window.f0ckSession.is_anon && (window.f0ckSession.logged_in || window.f0ckSession.user)) return false; if (window.f0ckAnonSSH && (window.f0ckAnonSSH.isSessionReady || window.f0ckAnonSSH.pubkey)) return false; if (typeof localStorage !== 'undefined' && localStorage.getItem('f0ck_anon_ssh_pub')) return false; return !window.f0ckSession.logged_in; }; window.isGuestUser = isGuestUser; const getRatingsCookie = () => { const raw = document.cookie.split('; ').find(r => r.startsWith('ratings=')); if (!raw) return []; const val = raw.split('=').slice(1).join('='); const decoded = decodeURIComponent(val); const parts = decoded.includes('|') ? decoded.split('|') : decoded.split(','); return parts.filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)); }; window.getRatingsCookie = getRatingsCookie; const setRatingsCookie = (ratings) => { const val = ratings.join('|'); document.cookie = `ratings=${val}; Path=/; Max-Age=31536000; SameSite=Lax`; }; window.setRatingsCookie = setRatingsCookie; const clearRatingsCookie = () => { document.cookie = 'ratings=; Path=/; Max-Age=0; SameSite=Lax'; }; window.clearRatingsCookie = clearRatingsCookie; const reconcileAnonFilterPermissions = () => { if (isGuestUser()) { let changed = false; if (window.activeMode !== 0) { window.activeMode = 0; document.cookie = 'mode=0; Path=/; Max-Age=31536000; SameSite=Lax'; changed = true; } const curRatings = getRatingsCookie(); if (curRatings.length !== 1 || curRatings[0] !== 'sfw') { setRatingsCookie(['sfw']); changed = true; } return changed; } if (!isAnonUser()) return false; const perms = window.f0ckSession?.anon_permissions; if (!perms) return false; let changed = false; const canFilter = perms.filter !== false; const allowedModes = Array.isArray(perms.allowed_modes) ? perms.allowed_modes.map(m => String(m).toLowerCase()) : ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; const allowedMimes = Array.isArray(perms.allowed_mimes) ? perms.allowed_mimes.map(m => String(m).toLowerCase()) : ['image', 'video', 'audio', 'flash', 'pdf']; const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; // 1. Check locked rating from permissions (single allowed mode only) const isSingleAllowedMode = allowedModes.length === 1 && !allowedModes.includes('all'); const lockedRating = isSingleAllowedMode ? allowedModes[0] : null; if (lockedRating) { const targetMode = modeNames.indexOf(lockedRating); if (targetMode >= 0 && window.activeMode !== targetMode) { window.activeMode = targetMode; document.cookie = `mode=${targetMode}; Path=/; Max-Age=31536000; SameSite=Lax`; changed = true; } const raw = getRatingsCookie(); if (raw.length !== 1 || raw[0] !== lockedRating) { setRatingsCookie([lockedRating]); changed = true; } } else { // Validate activeMode const curModeName = modeNames[window.activeMode] || 'sfw'; if (!canFilter || !allowedModes.includes(curModeName)) { const fallbackName = allowedModes.find(m => modeNames.includes(m)) || 'sfw'; const fallbackIdx = modeNames.indexOf(fallbackName); window.activeMode = fallbackIdx >= 0 ? fallbackIdx : 0; document.cookie = `mode=${window.activeMode}; Path=/; Max-Age=31536000; SameSite=Lax`; changed = true; } // Validate ratings cookie const curRatings = getRatingsCookie(); if (curRatings.length > 0) { if (!canFilter) { clearRatingsCookie(); changed = true; } else { const filtered = curRatings.filter(r => allowedModes.includes(r)); if (filtered.length !== curRatings.length) { changed = true; if (filtered.length > 0) { setRatingsCookie(filtered); } else { if (allowedModes.length === 1 && !allowedModes.includes('all')) { setRatingsCookie([allowedModes[0]]); } else { clearRatingsCookie(); } } } } } } // 2. Validate MIME cookie const cookieMimeRaw = document.cookie.split('; ').find(row => row.startsWith('mime=')); const isSingleMime = Array.isArray(allowedMimes) && allowedMimes.length === 1; const singleMime = isSingleMime ? allowedMimes[0] : null; if (isSingleMime) { const curVal = cookieMimeRaw ? cookieMimeRaw.split('=')[1] : null; if (curVal !== singleMime) { document.cookie = `mime=${singleMime}; path=/; max-age=31536000; SameSite=Lax`; changed = true; } } else if (cookieMimeRaw) { const curVal = cookieMimeRaw.split('=')[1] || ''; const curParts = curVal ? curVal.split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)) : []; if (!canFilter) { if (curVal !== '') { document.cookie = 'mime=; path=/; max-age=0; SameSite=Lax'; changed = true; } } else { const filteredParts = curParts.filter(m => allowedMimes.includes(m)); if (filteredParts.length !== curParts.length) { changed = true; if (filteredParts.length > 0) { document.cookie = `mime=${filteredParts.join(',')}; path=/; max-age=31536000; SameSite=Lax`; } else { document.cookie = 'mime=; path=/; max-age=0; SameSite=Lax'; } } } } return changed; }; window.reconcileAnonFilterPermissions = reconcileAnonFilterPermissions; const updateMimeLabel = () => { reconcileAnonFilterPermissions(); const isAnon = isAnonUser(); const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes; const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1; const singleMime = isSingleMime ? allowedMimes[0] : null; let selected = []; if (isSingleMime) { selected = [singleMime]; const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); const val = cookieMime ? cookieMime.split('=')[1] : null; if (val !== singleMime) { document.cookie = `mime=${singleMime}; path=/; max-age=31536000; SameSite=Lax`; } } else { let mimeStr = null; const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); if (cookieMime) { mimeStr = cookieMime.split('=')[1]; } selected = mimeStr ? mimeStr.split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m)) : []; if (isAnon && Array.isArray(allowedMimes) && allowedMimes.length > 0) { const filtered = selected.filter(m => allowedMimes.includes(m)); if (filtered.length !== selected.length) { selected = filtered; if (selected.length > 0) { document.cookie = `mime=${selected.join(',')}; path=/; max-age=31536000; SameSite=Lax`; } else { document.cookie = `mime=; path=/; max-age=0; SameSite=Lax`; } } } } document.querySelectorAll('.nav-mime-btn').forEach(btn => { let label = 'ALL'; if (selected.length > 0) { label = selected.map(s => s.charAt(0).toUpperCase()).sort().join(','); } btn.innerHTML = `${label} ▾`; if (isSingleMime) { btn.classList.add('locked'); } }); document.querySelectorAll('.nav-mime-menu').forEach(menu => { menu.querySelectorAll('input[type="checkbox"]').forEach(cb => { if (isSingleMime) { cb.checked = (cb.value === singleMime); cb.disabled = true; const label = cb.closest('label') || cb.parentElement; if (label) { label.classList.add('locked'); label.style.cursor = 'not-allowed'; label.title = 'Locked by site permissions'; } } else { cb.checked = selected.includes(cb.value); } }); }); if (window.updateFilterBadge) window.updateFilterBadge(); }; window.updateMimeLabel = updateMimeLabel; const updateFilterBadge = () => { reconcileAnonFilterPermissions(); const badge = document.getElementById('nav-filter-badge'); if (!badge) return; let activeMode = 0; if (window.activeMode !== undefined) { activeMode = window.activeMode; } else { const cookieMode = document.cookie.split('; ').find(row => row.startsWith('mode=')); if (cookieMode) { activeMode = +cookieMode.split('=')[1]; } else if (window.f0ckSession && window.f0ckSession.mode !== undefined) { activeMode = window.f0ckSession.mode; } } // Check multi-rating cookie const ratingsRaw = document.cookie.split('; ').find(row => row.startsWith('ratings=')); const activeRatings = window.getRatingsCookie ? window.getRatingsCookie() : (() => { if (!ratingsRaw) return []; const val = ratingsRaw.split('=').slice(1).join('='); const decoded = decodeURIComponent(val); const parts = decoded.includes('|') ? decoded.split('|') : decoded.split(','); return parts.filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)); })(); let hasMimeFilter = false; let mimeStr = ''; const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); if (cookieMime) { mimeStr = cookieMime.split('=')[1] || ''; } const selectedMimes = mimeStr ? mimeStr.split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m)) : []; if (selectedMimes.length > 0) { hasMimeFilter = true; } let badgeText = ''; let badgeClass = 'filter-badge'; if (activeRatings.length > 0) { // If every available rating is selected, treat as ALL const nsflEnabled = !!(window.f0ckSession?.enable_nsfl ?? true); // default true if unknown const allRatings = nsflEnabled ? ['sfw', 'nsfw', 'nsfl', 'untagged'] : ['sfw', 'nsfw', 'untagged']; const isAll = allRatings.every(r => activeRatings.includes(r)); if (isAll) { badgeText = 'ALL'; badgeClass += ' filter-badge-all'; } else if (activeRatings.length === 1) { // Single rating — keep existing single-color badge style const single = activeRatings[0]; const abbr = { sfw: 'SFW', nsfw: 'NSFW', nsfl: 'NSFL', untagged: 'UNT' }; badgeText = abbr[single] || single.toUpperCase(); if (single === 'nsfw') badgeClass += ' filter-badge-nsfw'; else if (single === 'nsfl') badgeClass += ' filter-badge-nsfl'; else if (single === 'sfw') badgeClass += ' filter-badge-sfw'; else badgeClass += ' filter-badge-unt'; } else { // Multi-rating: render individual colored letter chips, neutral container const letterMap = { sfw: 'S', nsfw: 'N', nsfl: 'N', untagged: 'U' }; const colorMap = { sfw: 'filter-letter-sfw', nsfw: 'filter-letter-nsfw', nsfl: 'filter-letter-nsfl', untagged: 'filter-letter-unt' }; badgeText = activeRatings.map(r => `${letterMap[r] || r[0].toUpperCase()}` ).join(''); badgeClass += ' filter-badge-multi'; } } else { switch (activeMode) { case 0: badgeText = 'SFW'; badgeClass += ' filter-badge-sfw'; break; case 1: badgeText = 'NSFW'; badgeClass += ' filter-badge-nsfw'; break; case 4: badgeText = 'NSFL'; badgeClass += ' filter-badge-nsfl'; break; case 2: badgeText = 'UNT'; badgeClass += ' filter-badge-unt'; break; case 3: badgeText = 'ALL'; badgeClass += ' filter-badge-all'; break; default: badgeText = 'SFW'; badgeClass += ' filter-badge-sfw'; } } const isRandom = document.cookie.includes('random_mode=1'); let zomgHtml = ''; if (isRandom) { zomgHtml = ' Z'; } badge.className = badgeClass; // Always wrap rating text in a row container so mime icons can sit on the next row cleanly badge.innerHTML = `${badgeText}${zomgHtml}`; if (hasMimeFilter) { const iconsContainer = document.createElement('span'); iconsContainer.className = 'filter-mime-icons'; selectedMimes.forEach(mime => { const icon = document.createElement('i'); let iconClass = 'mime-icon '; if (mime === 'audio') iconClass += 'fa-solid fa-music mime-icon-audio'; else if (mime === 'image') iconClass += 'fa-solid fa-image mime-icon-image'; else if (mime === 'video') iconClass += 'fa-solid fa-film mime-icon-video'; else if (mime === 'flash') iconClass += 'fa-solid fa-bolt mime-icon-flash'; icon.className = iconClass; icon.title = mime.charAt(0).toUpperCase() + mime.slice(1); iconsContainer.appendChild(icon); }); badge.appendChild(iconsContainer); } badge.style.display = 'inline-flex'; }; window.updateFilterBadge = updateFilterBadge; document.addEventListener('f0ck:modeChanged', () => { updateFilterBadge(); }); window.randomizeLogo = () => { const logoArr = window.f0ckBrandImages; if (!logoArr || !logoArr.length) return; const img = document.getElementById('navbar-logo'); if (!img) return; // Avoid picking the same image if there's more than one let randomImg; do { randomImg = logoArr[Math.floor(Math.random() * logoArr.length)]; } while (logoArr.length > 1 && randomImg === img.getAttribute('src')); img.src = randomImg; }; // Initialize active mode. // For logged-in users, the server's f0ckSession.mode (sourced from user_options DB) is the // authoritative cross-device truth. A stale local `mode` cookie from a previous session on // this device could disagree (e.g. PC had ALL, mobile set SFW — PC cookie still says 3). // Fix: prefer f0ckSession.mode when logged in and reconcile the local cookie to match. // For guests: fall back to cookie (no session = cookie is the only persistence). if (window.f0ckSession && window.f0ckSession.logged_in && !window.f0ckSession.is_anon && window.f0ckSession.mode !== undefined) { window.activeMode = +window.f0ckSession.mode; // Reconcile the local mode cookie so future reads are consistent on this device document.cookie = `mode=${window.activeMode}; Path=/; Max-Age=31536000`; // If the authoritative mode is ALL (3), also clear any stale ratings cookie. // Otherwise a ratings=sfw cookie left from a previous device's filter would make // the badge and button UI show the wrong state (even though the server ignores // the ratings cookie when mode=3). if (window.activeMode === 3) { document.cookie = 'ratings=; Path=/; Max-Age=0'; } } else { const _modeCookieRaw = document.cookie.split('; ').find(r => r.startsWith('mode=')); if (_modeCookieRaw) { window.activeMode = +_modeCookieRaw.split('=')[1]; } else if (window.f0ckSession && window.f0ckSession.mode !== undefined) { window.activeMode = +window.f0ckSession.mode; } else { // Legacy fallback: read from old .mode-btn.active if present const activeModeBtn = document.querySelector('.mode-btn.active'); if (activeModeBtn && activeModeBtn.href) { const modeMatch = activeModeBtn.href.match(/\/mode\/(\d)/); if (modeMatch) window.activeMode = +modeMatch[1]; } } const filterReconciled = reconcileAnonFilterPermissions(); if (filterReconciled) { const postsEl = document.querySelector('.posts, .tags-grid'); if (postsEl && postsEl.children.length === 0 && window.loadPageAjax) { window.loadPageAjax(window.location.href, { skipPush: true, skipCache: true }); } } } // Track active mode on for notification thumbnail blur. // data-notif-filter is set to the current mode string and also // drives src-swapping to pre-blurred thumbnails (/t/{id}_blur.webp). const _notifBlurShouldBlur = (itemMode, activeMode) => { if (activeMode === 3) return false; // ALL mode: never blur if (activeMode === 0) return itemMode === 'nsfw' || itemMode === 'nsfl'; // SFW filter if (activeMode === 1) return itemMode === 'nsfl'; // NSFW filter if (activeMode === 4) return itemMode === 'sfw' || itemMode === 'nsfw'; // NSFL filter return false; }; window.applyNotifThumbBlur = (container) => { const root = container || document; const mode = window.activeMode ?? 3; root.querySelectorAll('.notif-thumb[data-mode]').forEach(thumb => { const img = thumb.querySelector('img'); if (!img) return; const itemMode = thumb.dataset.mode; if (!itemMode || thumb.classList.contains('revealed')) return; if (_notifBlurShouldBlur(itemMode, mode)) { if (!thumb.classList.contains('notif-thumb-blurred')) { const htmlOrigSrc = img.getAttribute('data-orig-src'); const idMatch = img.src.match(/\/t\/(\d+)(?:_blur)?\.webp/); if (idMatch) { // Ensure JS dataset has origSrc (server may have set it via HTML attribute) if (!img.dataset.origSrc) { img.dataset.origSrc = htmlOrigSrc || `/t/${idMatch[1]}.webp`; } // Only swap src if not already the blur version if (!img.src.includes('_blur.webp')) { img.src = `/t/${idMatch[1]}_blur.webp`; } thumb.classList.add('notif-thumb-blurred'); } } } else { // Restore original thumbnail const origSrc = img.dataset.origSrc || img.getAttribute('data-orig-src'); if (origSrc) { img.src = origSrc; delete img.dataset.origSrc; thumb.classList.remove('notif-thumb-blurred'); } } }); }; const _updateNotifFilterClass = (mode) => { const modeNames = { 0: 'sfw', 1: 'nsfw', 2: 'untagged', 3: 'all', 4: 'nsfl' }; htmlEl.setAttribute('data-notif-filter', modeNames[mode] || 'all'); if (mode === 3) htmlEl.classList.add('mode-all'); else htmlEl.classList.remove('mode-all'); // Re-apply blur src swaps whenever mode changes window.applyNotifThumbBlur(); }; _updateNotifFilterClass(window.activeMode ?? 3); document.addEventListener('f0ck:modeChanged', (e) => { _updateNotifFilterClass(e.detail?.mode ?? 3); }); // Apply on hard load (F5) and on every AJAX navigation (f0ck:contentLoaded) const _applyNotifBlurOnNav = () => window.applyNotifThumbBlur(); document.addEventListener('DOMContentLoaded', _applyNotifBlurOnNav); document.addEventListener('f0ck:contentLoaded', _applyNotifBlurOnNav); // ---- Multi-select Rating Toggles ---- const syncRatingButtonUI = () => { reconcileAnonFilterPermissions(); let activeRatings = getRatingsCookie(); if (activeRatings.length === 0) { if (window.activeMode === 0) activeRatings = ['sfw']; else if (window.activeMode === 1) activeRatings = ['nsfw']; else if (window.activeMode === 4) activeRatings = ['nsfl']; else if (window.activeMode === 2) activeRatings = ['untagged']; } const selector = document.getElementById('rating-selector'); if (!selector) return; const isGuest = isGuestUser(); if (isGuest) { selector.classList.add('locked'); selector.dataset.guestLocked = 'true'; selector.dataset.lockedRating = 'sfw'; selector.querySelectorAll('.rating-toggle-btn').forEach(btn => { if (btn.dataset.rating === 'sfw') { btn.classList.add('active', 'locked'); btn.disabled = true; btn.style.cursor = 'default'; btn.style.opacity = '1'; btn.style.display = ''; if (!btn.querySelector('.fa-lock')) { const lockIcon = document.createElement('i'); lockIcon.className = 'fa-solid fa-lock'; lockIcon.style.marginLeft = '5px'; lockIcon.style.fontSize = '0.75em'; lockIcon.style.opacity = '0.7'; btn.appendChild(lockIcon); } } else { btn.classList.remove('active'); btn.classList.add('locked'); btn.disabled = true; btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.45'; btn.style.display = ''; btn.title = (window.f0ckI18n && window.f0ckI18n.members_only) || 'Only available for members'; if (!btn.querySelector('.fa-lock')) { const lockIcon = document.createElement('i'); lockIcon.className = 'fa-solid fa-lock'; lockIcon.style.marginLeft = '5px'; lockIcon.style.fontSize = '0.75em'; lockIcon.style.opacity = '0.7'; btn.appendChild(lockIcon); } } }); return; } // User is NOT a guest (anonymous user or registered member) delete selector.dataset.guestLocked; selector.removeAttribute('data-guest-locked'); // Check single allowed mode restrictions const anonPerms = window.f0ckSession?.anon_permissions; const allowedModes = Array.isArray(anonPerms?.allowed_modes) ? anonPerms.allowed_modes.map(m => String(m).toLowerCase()) : ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; const isSingleAllowedMode = isAnonUser() && allowedModes.length === 1 && !allowedModes.includes('all'); const singleLockedMode = isSingleAllowedMode ? allowedModes[0] : null; if (singleLockedMode) { selector.classList.add('locked'); selector.dataset.lockedRating = singleLockedMode; selector.querySelectorAll('.rating-toggle-btn').forEach(btn => { if (btn.dataset.rating === singleLockedMode) { btn.classList.add('active', 'locked'); btn.disabled = true; btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.85'; if (!btn.querySelector('.fa-lock')) { const lockIcon = document.createElement('i'); lockIcon.className = 'fa-solid fa-lock'; lockIcon.style.marginLeft = '5px'; lockIcon.style.fontSize = '0.75em'; lockIcon.style.opacity = '0.7'; btn.appendChild(lockIcon); } } else { btn.classList.remove('active'); btn.disabled = true; btn.style.display = 'none'; } }); return; } // User is NOT locked into a single mode: unlock container and buttons selector.classList.remove('locked'); delete selector.dataset.lockedRating; selector.removeAttribute('data-locked-rating'); selector.querySelectorAll('.rating-toggle-btn').forEach(btn => { const r = btn.dataset.rating; btn.classList.remove('locked'); btn.disabled = false; btn.style.cursor = ''; btn.style.opacity = ''; btn.style.display = ''; btn.querySelectorAll('.fa-lock').forEach(icon => icon.remove()); if (r) { btn.classList.toggle('active', activeRatings.includes(r)); if (btn.title === 'Only available for members' || (window.f0ckI18n && btn.title === window.f0ckI18n.members_only)) { btn.title = r.toUpperCase(); } } }); // ALL button: active when ratings cookie is empty/absent (server mode is the authority) const allBtn = document.getElementById('rating-btn-all'); if (allBtn) { allBtn.classList.remove('locked'); allBtn.disabled = false; allBtn.style.cursor = ''; allBtn.style.opacity = ''; allBtn.querySelectorAll('.fa-lock').forEach(icon => icon.remove()); allBtn.classList.toggle('active', (getRatingsCookie().length === 0 && window.activeMode === 3) || activeRatings.length === 0); if (allBtn.title === 'Only available for members' || (window.f0ckI18n && allBtn.title === window.f0ckI18n.members_only)) { allBtn.title = 'Show All Ratings'; } } }; // Wire up rating toggle buttons document.addEventListener('click', (e) => { const btn = e.target.closest('.rating-toggle-btn'); if (!btn) return; if (isGuestUser() || btn.disabled || btn.classList.contains('locked') || btn.closest('#rating-selector[data-locked-rating]') || btn.closest('#rating-selector[data-guest-locked]') || btn.closest('#rating-selector.locked')) { e.preventDefault(); e.stopPropagation(); return; } e.preventDefault(); e.stopPropagation(); const isAllBtn = btn.classList.contains('rating-toggle-all'); const fromFilterModal = !!btn.closest('#excluded-tags-overlay'); if (isAllBtn) { // ALL: clear ratings cookie, set mode=3 on server // Must set activeMode BEFORE syncRatingButtonUI so the ALL button // active-state check (activeRatings.length === 0 && window.activeMode === 3) passes. clearRatingsCookie(); window.activeMode = 3; document.cookie = `mode=3; Path=/; Max-Age=31536000`; syncRatingButtonUI(); if (fromFilterModal) window._keepFilterModal = true; document.dispatchEvent(new CustomEvent('f0ck:modeChanged', { detail: { mode: 3 } })); fetch('/mode/3', { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'include' }) .then(r => r.json()) .then(data => { if (data.success) { window.flashMessage('ALL MODE ACTIVATED'); gridCacheMap.clear(); const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isGridView = document.querySelector('.posts, .tags-grid'); const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname); let reloadPromise = null; if (isOnaraOpen) { const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); const urlObj = new URL(bgUrl, window.location.origin); urlObj.searchParams.delete('mode'); reloadPromise = window.loadPageAjax ? window.loadPageAjax(urlObj.toString(), true, { skipCache: true, skipPush: true }) : null; } else if (isGridView) { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('mode'); reloadPromise = window.loadPageAjax ? window.loadPageAjax(currentUrl.toString(), true, { skipCache: true }) : null; } else if (isItemView) { reloadPromise = window.loadItemAjax ? window.loadItemAjax(window.location.href, true, { skipCache: true }) : null; } if (fromFilterModal) Promise.resolve(reloadPromise).finally(() => { window._keepFilterModal = false; }); } }) .catch(() => { if (fromFilterModal) window._keepFilterModal = false; }); return; } const rating = btn.dataset.rating; if (!rating) return; // Toggle rating in cookie const activeRatings = getRatingsCookie(); const idx = activeRatings.indexOf(rating); if (idx === -1) { activeRatings.push(rating); } else { activeRatings.splice(idx, 1); } if (activeRatings.length === 0) { // Nothing selected: treat as ALL (no filter = show everything) clearRatingsCookie(); window.activeMode = 3; document.cookie = `mode=3; Path=/; Max-Age=31536000`; } else { setRatingsCookie(activeRatings); // Use mode=3 (ALL) on server when multi-select; single-select maps to native mode const singleModeMap = { sfw: 0, nsfw: 1, nsfl: 4, untagged: 2 }; const serverMode = activeRatings.length === 1 ? (singleModeMap[activeRatings[0]] ?? 3) : 3; window.activeMode = serverMode; document.cookie = `mode=${serverMode}; Path=/; Max-Age=31536000`; } syncRatingButtonUI(); document.dispatchEvent(new CustomEvent('f0ck:modeChanged', { detail: { mode: window.activeMode } })); if (fromFilterModal) window._keepFilterModal = true; // Sync server mode and reload content fetch(`/mode/${window.activeMode}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'include' }) .then(r => r.json()) .then(data => { if (data.success) { const label = activeRatings.length > 0 ? activeRatings.map(r => r.toUpperCase()).join('+') + ' ACTIVE' : 'ALL MODE ACTIVATED'; window.flashMessage(label); gridCacheMap.clear(); const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isGridView = document.querySelector('.posts, .tags-grid'); const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname); let reloadPromise = null; if (isOnaraOpen) { const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl(); const urlObj = new URL(bgUrl, window.location.origin); urlObj.searchParams.delete('mode'); reloadPromise = window.loadPageAjax ? window.loadPageAjax(urlObj.toString(), true, { skipCache: true, skipPush: true }) : null; } else if (isGridView) { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('mode'); reloadPromise = window.loadPageAjax ? window.loadPageAjax(currentUrl.toString(), true, { skipCache: true }) : null; } else if (isItemView) { updateNavForMode(window.activeMode); reloadPromise = window.loadItemAjax ? window.loadItemAjax(window.location.href, true, { skipCache: true }) : null; } if (fromFilterModal) Promise.resolve(reloadPromise).finally(() => { window._keepFilterModal = false; }); } else { if (fromFilterModal) window._keepFilterModal = false; } }) .catch(() => { if (fromFilterModal) window._keepFilterModal = false; }); }); // Initialize rating toggle UI on page load window.syncRatingButtonUI = syncRatingButtonUI; // Migrate old URL-encoded ratings cookie to new pipe-separated format (function migrateRatingsCookie() { const raw = document.cookie.split('; ').find(r => r.startsWith('ratings=')); if (!raw) return; const val = raw.split('=').slice(1).join('='); if (val.includes('%')) { // Cookie is URL-encoded — rewrite it with new format const decoded = decodeURIComponent(val); const cleaned = decoded.replace(/,/g, '|'); document.cookie = `ratings=${cleaned}; Path=/; Max-Age=31536000; SameSite=Lax`; } })(); syncRatingButtonUI(); window.addEventListener('f0ck:anon_session_ready', (e) => { if (window.f0ckSession) { window.f0ckSession.user = 'anonymous'; window.f0ckSession.is_anon = true; window.f0ckSession.logged_in = true; if (e.detail?.userId) { window.f0ckSession.id = e.detail.userId; window.f0ckSession.user_id = e.detail.userId; } } syncRatingButtonUI(); }); // Cleanup strict param from URL bar on initial load if present (legacy or external link) if (window.location.search.includes('strict=1')) { const cleanUrl = window.location.pathname + window.location.search.replace(/[?&]strict=1/, '').replace(/[?&]$/, '') + window.location.hash; history.replaceState({}, '', cleanUrl); } // User & Visitor dropdown toggle const userToggle = document.getElementById('nav-user-toggle'); const userMenu = document.getElementById('nav-user-menu'); const visitorToggle = document.getElementById('nav-visitor-toggle'); const visitorMenu = document.getElementById('nav-visitor-menu'); const hallsToggle = document.getElementById('nav-halls-toggle'); const hallsMenu = document.getElementById('nav-halls-menu'); const vHallsToggle = document.getElementById('nav-visitor-halls-toggle'); const vHallsMenu = document.getElementById('nav-visitor-halls-menu'); if (userToggle && userMenu) { userToggle.addEventListener('click', (e) => { e.stopPropagation(); if (e.target.closest('.nav-avatar-img, .nav-avatar-icon')) { const username = window.f0ckSession?.is_anon ? (window.f0ckSession?.login || window.f0ckSession?.user) : window.f0ckSession?.user; if (username) { const url = `/user/${username.toLowerCase()}`; if (typeof window.loadPageAjax === 'function') { window.loadPageAjax(url, true); } else { window.location.href = url; } return; } } const opening = !userMenu.classList.contains('show'); userMenu.classList.toggle('show'); userToggle.classList.toggle('is-active', opening); }); userMenu.querySelectorAll('a').forEach(link => { link.addEventListener('click', () => { userMenu.classList.remove('show'); userMenu.classList.remove('show-mobile'); userToggle.classList.remove('is-active'); }); }); } if (visitorToggle && visitorMenu) { visitorToggle.addEventListener('click', (e) => { e.stopPropagation(); visitorMenu.classList.toggle('show'); }); visitorMenu.querySelectorAll('a').forEach(link => { link.addEventListener('click', () => { visitorMenu.classList.remove('show'); visitorMenu.classList.remove('show-mobile'); }); }); } const setupHallsToggle = (toggle, menu) => { if (toggle && menu) { toggle.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); menu.classList.toggle('show'); }); menu.querySelectorAll('a').forEach(link => { link.addEventListener('click', () => menu.classList.remove('show')); }); } }; setupHallsToggle(hallsToggle, hallsMenu); setupHallsToggle(vHallsToggle, vHallsMenu); document.addEventListener('click', (e) => { if (userMenu && !userMenu.contains(e.target) && userToggle && !userToggle.contains(e.target)) { userMenu.classList.remove('show'); userToggle.classList.remove('is-active'); } if (visitorMenu && !visitorMenu.contains(e.target) && visitorToggle && !visitorToggle.contains(e.target)) { visitorMenu.classList.remove('show'); } if (hallsMenu && !hallsMenu.contains(e.target) && hallsToggle && !hallsToggle.contains(e.target)) { hallsMenu.classList.remove('show'); } if (vHallsMenu && !vHallsMenu.contains(e.target) && vHallsToggle && !vHallsToggle.contains(e.target)) { vHallsMenu.classList.remove('show'); } }); // Randomize logo on click document.addEventListener('click', (e) => { if (e.target.closest('.navbar-brand') || e.target.id === 'navbar-logo') { window.randomizeLogo(); } }); const fallbackCopy = (text, msg) => { const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; textarea.style.top = "0"; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { document.execCommand("copy"); if (typeof window.flashMessage === 'function') { window.flashMessage(msg); } } catch (err) { console.error("Fallback copy failed:", err); } document.body.removeChild(textarea); }; const copyCurrentUrl = () => { const url = window.location.href; const msg = (window.f0ckI18n && window.f0ckI18n.copied) || 'URL copied to clipboard'; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(() => { if (typeof window.flashMessage === 'function') { window.flashMessage(msg); } }).catch(() => { fallbackCopy(url, msg); }); } else { fallbackCopy(url, msg); } }; window.copyCurrentUrl = copyCurrentUrl; // Left-click on timestamp copies the current URL to clipboard and shows toast (same as pressing "y") document.addEventListener('click', (e) => { const tsTarget = e.target.closest('.timestamp-link, time.timeago'); if (tsTarget) { if (e.button === 0) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); copyCurrentUrl(); } } }, true); // Modal Logic (Login, Forgot, Reset, Register) const loginBtn = document.getElementById('nav-login-btn'); const loginModal = document.getElementById('login-modal'); const loginClose = document.getElementById('login-modal-close'); const registerBtn = document.getElementById('nav-register-btn'); const registerModal = document.getElementById('register-modal'); const registerClose = document.getElementById('register-modal-close'); const switchModalView = (view) => { if (!loginModal) return; const views = ['login', 'forgot', 'reset']; views.forEach(v => { const el = document.getElementById(`modal-${v}-view`); if (el) el.style.display = (v === view) ? 'block' : 'none'; }); }; const openModal = (modal, view = 'login') => { if (!modal) return; if (modal === loginModal) switchModalView(view); modal.style.display = 'flex'; document.body.classList.add('modal-open'); if (visitorMenu) visitorMenu.classList.remove('show'); }; const closeModal = (modal) => { if (modal) { modal.style.display = 'none'; document.body.classList.remove('modal-open'); } }; /** * Surgical cleanup of scroll-lock state and modal visibility. * Used during AJAX navigation to ensure the UI remains interactive. */ window.resetGlobalScrollState = () => { document.body.classList.remove('modal-open'); document.documentElement.classList.remove('modal-open'); document.body.style.overflow = ''; document.body.style.height = ''; document.documentElement.style.overflow = ''; document.documentElement.style.height = ''; const pw = document.querySelector('.pagewrapper'); if (pw) { pw.style.overflow = ''; pw.style.height = ''; } }; window.hideAllModals = () => { const modalIds = [ 'login-modal', 'register-modal', 'forgot-modal', 'reset-modal', 'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal', 'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay', 'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal', 'visibility-modal' ]; modalIds.forEach(id => { // Don't close the filter modal during a background mime-filter reload if (id === 'excluded-tags-overlay' && window._keepFilterModal) return; const el = document.getElementById(id); if (el) { el.classList.remove('show', 'visible'); // If the modal uses CSS classes for visibility, we must clear the inline display // to allow those classes to work later. For others, we force display: none. if (['upload-drag-modal', 'image-modal', 'gchat-img-modal', 'excluded-tags-overlay'].includes(id)) { el.style.display = ''; } else { el.style.display = 'none'; } } }); // Also handle class-based modals if any document.querySelectorAll('.modal-overlay, .modal-backdrop').forEach(el => { el.classList.remove('show', 'visible'); // Do NOT set display: none here as it might override CSS-based visibility // for modals that use the classes we just removed. }); }; // ── Onara Alternative Flavor: Fullscreen Modal Wrapper ───────────────────── const isOnaraActive = () => { const stored = (() => { try { return localStorage.getItem('f0ck_onara_enabled'); } catch { return null; } })(); if (stored !== null) return stored === 'true'; return !!(window.f0ckSession?.onara || window.onara); }; const getOrCreateOnaraModal = () => { let modal = document.getElementById('onara-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'onara-modal'; modal.className = 'onara-modal-wrapper'; modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); modal.innerHTML = `
`; document.body.appendChild(modal); } return modal; }; const openOnaraModal = () => { const modal = getOrCreateOnaraModal(); if (modal._closeTimeout) { clearTimeout(modal._closeTimeout); modal._closeTimeout = null; } modal._isClosing = false; document.body.classList.add('onara-modal-open'); if (document.querySelector('.index-container') && window.scrollY !== 0) { window.scrollTo(0, 0); } modal.style.display = 'flex'; modal.setAttribute('aria-hidden', 'false'); modal.classList.remove('onara-fade-out'); void modal.offsetWidth; // Force reflow so transition starts reliably modal.classList.add('onara-fade-in'); const canvas = document.getElementById('bg'); if (canvas) { canvas._bgFadingOut = false; canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); canvas.style.transition = 'none'; canvas.style.opacity = 'var(--bg-canvas-opacity, 1.0)'; } if (typeof window.paintImmediateBgThumb === 'function') { window.paintImmediateBgThumb(); } return modal; }; window.openOnaraModal = openOnaraModal; const scrollOnaraThumbIntoView = (thumb, forceCenter = false) => { if (!thumb) return; const container = thumb.closest('.index-container'); if (container) { const cRect = container.getBoundingClientRect(); const tRect = thumb.getBoundingClientRect(); const navbarH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--navbar-h')) || 50; const vh = window.innerHeight || document.documentElement.clientHeight; const isVisible = tRect.bottom > navbarH && tRect.top < vh; if (!isVisible || forceCenter) { const targetScrollTop = container.scrollTop + (tRect.top - cRect.top) - (container.clientHeight / 2) + (tRect.height / 2); container.scrollTop = Math.max(0, targetScrollTop); } } else { thumb.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); } if (document.querySelector('.index-container') && window.scrollY !== 0) { window.scrollTo(0, 0); } }; const updateOnaraActiveItem = (itemid, url, forceScroll = false, slug = null) => { if (!isOnaraActive()) return null; // Clear onara-active from any and all elements to guarantee only 1 item is selected document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); let targetThumb = null; if (url) { try { const parsedPath = new URL(url, window.location.origin).pathname; targetThumb = document.querySelector(`.posts > a.thumb[href="${parsedPath}"], .posts > a.thumb[href="${url}"]`); } catch {} } if (!targetThumb && slug) { targetThumb = document.querySelector(`.posts > a.thumb[href$="/${slug}"], .posts > a.thumb[href*="/${slug}#"]`); } if (!targetThumb && itemid) { targetThumb = document.querySelector(`.posts > a.thumb[data-item-id="${itemid}"], .posts > a.thumb[href$="/${itemid}"], .posts > a.thumb[href*="/${itemid}#"], .posts > a.thumb[data-bg*="/${itemid}."]`); } // Blur any other active element so browser native focus cannot highlight a second thumbnail if (document.activeElement && document.activeElement !== targetThumb && typeof document.activeElement.blur === 'function') { document.activeElement.blur(); } if (targetThumb) { targetThumb.classList.add('onara-active'); scrollOnaraThumbIntoView(targetThumb, forceScroll); } return targetThumb; }; window.updateOnaraActiveItem = updateOnaraActiveItem; let _onaraSyncSeq = 0; const syncOnaraBackgroundGrid = async (itemid, url, knownPage = null, slug = null) => { if (!isOnaraActive()) return; // Fast path: if target item is already in current background DOM, just highlight and ensure visibility const existingThumb = updateOnaraActiveItem(itemid, url, false, slug); if (existingThumb) { return; } const currentSeq = ++_onaraSyncSeq; const urlObj = new URL(url || window.location.href, window.location.origin); const feedBasePath = urlObj.pathname.replace(/\/+$/, '').replace(/\/(?:\d+|[a-zA-Z0-9_-]{11})$/, '') || '/'; let targetPage = (typeof knownPage === 'number' && knownPage >= 1) ? knownPage : null; if (!targetPage) { try { const pageParams = new URLSearchParams(); pageParams.set('id', itemid); if (feedBasePath.startsWith('/tag/')) { pageParams.set('tag', decodeURIComponent(feedBasePath.replace(/^\/tag\//, ''))); } else if (feedBasePath.startsWith('/h/')) { pageParams.set('hall', decodeURIComponent(feedBasePath.replace(/^\/h\//, ''))); } else if (feedBasePath.match(/^\/user\/([^/]+)\/hall\/([^/]+)/)) { const m = feedBasePath.match(/^\/user\/([^/]+)\/hall\/([^/]+)/); pageParams.set('userHallOwner', decodeURIComponent(m[1])); pageParams.set('userHall', decodeURIComponent(m[2])); } else if (feedBasePath.match(/^\/user\/([^/]+)\/favs/)) { const m = feedBasePath.match(/^\/user\/([^/]+)\/favs/); pageParams.set('user', decodeURIComponent(m[1])); pageParams.set('fav', 'true'); } else if (feedBasePath.match(/^\/user\/([^/]+)/)) { const m = feedBasePath.match(/^\/user\/([^/]+)/); pageParams.set('user', decodeURIComponent(m[1])); } else if (feedBasePath === '/favs') { pageParams.set('fav', 'true'); } if (typeof window.activeMode !== 'undefined') { pageParams.set('mode', window.activeMode); } const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true') || urlObj.searchParams.get('strict') === '1'; if (isStrict) { pageParams.set('strict', '1'); } const resp = await fetch(`/api/v2/item-page?${pageParams.toString()}`); if (resp.ok) { const pageData = await resp.json(); if (pageData && pageData.success && pageData.page) { targetPage = pageData.page; } } } catch (e) { console.warn('[ONARA] Failed to fetch item-page:', e); } } if (currentSeq !== _onaraSyncSeq) return; if (!targetPage || targetPage < 1) targetPage = 1; const pagePath = targetPage === 1 ? feedBasePath : (feedBasePath === '/' ? `/p/${targetPage}` : `${feedBasePath}/p/${targetPage}`); const searchParams = new URLSearchParams(urlObj.search); const searchStr = searchParams.toString() ? `?${searchParams.toString()}` : ''; const pageUrl = pagePath + searchStr; try { const pageRes = await fetch(pageUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (currentSeq !== _onaraSyncSeq) return; if (!pageRes.ok) return; const html = await pageRes.text(); if (currentSeq !== _onaraSyncSeq) return; const doc = new DOMParser().parseFromString(html, 'text/html'); const newPosts = doc.querySelector('.posts'); const oldPosts = document.querySelector('.posts'); if (newPosts && oldPosts) { oldPosts.innerHTML = newPosts.innerHTML; oldPosts.dataset.currentPage = targetPage; if (newPosts.dataset.hasMore) { oldPosts.dataset.hasMore = newPosts.dataset.hasMore; } if (typeof window.initLazyLoading === 'function') { window.initLazyLoading(); } } const newPag = doc.querySelector('.pagination-wrapper'); const oldPag = document.querySelector('.pagination-wrapper'); if (newPag && oldPag) { oldPag.innerHTML = newPag.innerHTML; } const newTitle = doc.querySelector('.page-title-wrap, .page-title, .user-profile-header, .hall-header'); const oldTitle = document.querySelector('.page-title-wrap, .page-title, .user-profile-header, .hall-header'); if (newTitle && oldTitle) { oldTitle.replaceWith(newTitle); } window._onaraCurrentGridUrl = pageUrl; window._onaraReturnUrl = pageUrl; if (doc.title) { window._onaraReturnTitle = doc.title; } const thumb = updateOnaraActiveItem(itemid, url, true, slug); if (!thumb && oldPosts) { const synthThumb = document.createElement('a'); synthThumb.href = url; synthThumb.className = 'thumb lazy-thumb onara-active'; synthThumb.dataset.bg = `/t/${itemid}.webp`; synthThumb.dataset.size = '1'; synthThumb.innerHTML = '

'; oldPosts.prepend(synthThumb); if (typeof window.initLazyLoading === 'function') window.initLazyLoading(); scrollOnaraThumbIntoView(synthThumb, true); } } catch (err) { console.error('[ONARA] Failed to sync background grid:', err); } }; window.syncOnaraBackgroundGrid = syncOnaraBackgroundGrid; const isItemPath = (pathOrUrl) => { try { const p = new URL(pathOrUrl, window.location.origin).pathname; const parts = p.split('/').filter(Boolean); return !p.match(/\/p\//) && ( /^\/\d+/.test(p) || /^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/.test(p) || (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ); } catch { return false; } }; window.isItemPath = isItemPath; const getOnaraBaseUrl = (itemUrl = window.location.href) => { try { const urlObj = new URL(itemUrl, window.location.origin); let path = urlObj.pathname.replace(/\/+$/, ''); path = path.replace(/\/(?:\d+|[a-zA-Z0-9_-]{11})$/, ''); // Check current grid URL if available and matching base path if (window._onaraCurrentGridUrl) { try { const gridObj = new URL(window._onaraCurrentGridUrl, window.location.origin); const gridBasePath = gridObj.pathname.replace(/\/p\/\d+/, '').replace(/\/+$/, ''); if (gridBasePath === path) { const safeGridHash = (gridObj.hash && !gridObj.hash.match(/^#[a-zA-Z0-9_-]{1,60}$/)) ? gridObj.hash : ''; return gridObj.pathname + (gridObj.search || '') + safeGridHash; } } catch {} } const postsEl = document.querySelector('.posts'); const activePagSpan = document.querySelector('.pagination span.btn.disabled, .pagination span.disabled'); const pagPage = activePagSpan ? parseInt(activePagSpan.textContent.trim(), 10) : 0; const currentPage = (postsEl && postsEl.dataset.currentPage) ? parseInt(postsEl.dataset.currentPage, 10) : (pagPage || postsEl?._infiniteState?.currentPage || postsEl?._infiniteState?.visiblePage || 1); if (currentPage > 1 && !path.includes('/p/')) { path = (path ? path : '') + '/p/' + currentPage; } if (!path) path = '/'; const safeHash = (urlObj.hash && !urlObj.hash.match(/^#[a-zA-Z0-9_-]{1,60}$/)) ? urlObj.hash : ''; return path + (urlObj.search || '') + safeHash; } catch { return '/'; } }; window.getOnaraBaseUrl = getOnaraBaseUrl; const promoteOnaraModals = () => { const mount = document.getElementById('onara-item-mount'); if (!mount) return; const modals = mount.querySelectorAll('#info-modal, #visibility-modal, .modal-overlay'); modals.forEach(m => { const existing = document.body.querySelector(`:scope > #${m.id}`); if (existing && existing !== m) { existing.remove(); } document.body.appendChild(m); }); }; window.promoteOnaraModals = promoteOnaraModals; const closeOnaraModal = (options = {}) => { const modal = document.getElementById('onara-modal'); if (!modal || modal.style.display === 'none' || modal._isClosing) return; modal._isClosing = true; // Close any auxiliary modals that may be open const infoModal = document.getElementById('info-modal'); if (infoModal) infoModal.style.display = 'none'; const visModal = document.getElementById('visibility-modal'); if (visModal) visModal.style.display = 'none'; const reportModal = document.getElementById('report-modal'); if (reportModal) reportModal.style.display = 'none'; const hallsModal = document.getElementById('halls-modal'); if (hallsModal) hallsModal.style.display = 'none'; if (typeof window.closeImageModal === 'function') window.closeImageModal(); document.body.classList.remove('modal-open'); // Pause any playing media immediately modal.querySelectorAll('video, audio').forEach(el => { try { el.pause(); } catch {} }); // Smooth fade out modal.classList.remove('onara-fade-in'); modal.classList.add('onara-fade-out'); document.body.classList.remove('onara-modal-open'); // Destroy background instance when Onara modal closes so it doesn't linger on the index grid if (window.destroyBackgroundInstance) { window.destroyBackgroundInstance(); } // Restore pagination container visibility on background grid document.querySelectorAll('.pagination-container-fluid').forEach(el => { const pagWrapper = el.querySelector('.pagination-wrapper'); if (!pagWrapper || pagWrapper.innerHTML.trim().length > 0) { el.style.display = ''; } }); if (!options.skipHistory) { const returnUrl = window._onaraReturnUrl || getOnaraBaseUrl(); const returnTitle = window._onaraReturnTitle || window.f0ckDomain || ''; const resolvedReturn = new URL(returnUrl, window.location.origin).href; if (window.location.href !== resolvedReturn) { history.pushState({ onaraGrid: true }, '', returnUrl); } if (returnTitle) { document.title = returnTitle; } } window._onaraReturnUrl = null; window._onaraReturnTitle = null; // Maintain focus on active item so user TAB navigation seamlessly resumes from current progress const activeThumb = document.querySelector('.posts > a.thumb.onara-active'); document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); if (activeThumb) { try { activeThumb.focus({ preventScroll: true }); } catch {} } if (modal._closeTimeout) clearTimeout(modal._closeTimeout); if (options.immediate) { stopMedia(); const mount = document.getElementById('onara-item-mount'); if (mount) mount.innerHTML = ''; modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); modal.classList.remove('onara-fade-out', 'onara-fade-in'); modal._isClosing = false; modal._closeTimeout = null; return; } modal._closeTimeout = setTimeout(() => { if (!modal._isClosing) return; stopMedia(); const mount = document.getElementById('onara-item-mount'); if (mount) mount.innerHTML = ''; modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); modal.classList.remove('onara-fade-out'); modal._isClosing = false; modal._closeTimeout = null; }, 240); }; window.closeOnaraModal = closeOnaraModal; window.isOnaraActive = isOnaraActive; // Content selector for protected elements inside onara modal const _onaraContentSelector = [ '.media-object', '.previous-post', '.next-post', '.arrow-prev', '.arrow-next', '#prev', '#next', '.nav-prev', '.nav-next', '#random', '.steuerung', '.location', '.item_title', '.blahlol', '#comments-container', '.tag-controls', '.sidebar-tags-container', '#tags', '.rating-tag', '.rating-label', '.item-sidebar-left', '.xd-score-wrapper', '.user-infobox-block', 'a', 'button', 'input', 'textarea', 'select', 'label', 'form', 'video', 'audio', 'img', 'canvas', 'iframe', '.iconset', '.badge', '.btn', 'time', '[role="button"]', 'i[class*="fa-"]' ].join(', '); let _onaraMouseDownInside = false; document.addEventListener('mousedown', (e) => { if (!document.body.classList.contains('onara-modal-open')) return; if (e.target.closest('.modal-overlay:not(#onara-modal), .modal-content, .swal2-container, .tippy-box, #comments-container .emoji-picker, .dropdown-menu, .custom-confirm-overlay') || e.target.closest('.global-sidebar-right, nav.navbar, #sidebar-drag-zone') || e.target.closest(_onaraContentSelector)) { _onaraMouseDownInside = true; } else { _onaraMouseDownInside = false; } }, { passive: true }); // Onara modal close click delegation: clicking on empty/free space closes the modal document.addEventListener('click', (e) => { if (!document.body.classList.contains('onara-modal-open')) return; // If mouse press originated on a protected element or if text is currently selected, do not close if (_onaraMouseDownInside) { _onaraMouseDownInside = false; return; } const sel = window.getSelection(); if (sel && sel.toString().trim().length > 0) { return; } // Do not close if clicking auxiliary modals, dropdowns, tooltips, or emoji pickers if (e.target.closest('.modal-overlay:not(#onara-modal), .modal-content, .swal2-container, .tippy-box, #comments-container .emoji-picker, .dropdown-menu, .custom-confirm-overlay')) return; // Do not close if clicking the global sidebar right or navbar if (e.target.closest('.global-sidebar-right, nav.navbar, #sidebar-drag-zone')) return; // Do not close if clicking interactive content elements or actual item components if (e.target.closest(_onaraContentSelector)) return; // Any click on free space (backdrop, modal wrapper, container background, empty space in item-main-content) closes if (e.target.closest('#onara-modal') || e.target.id === 'onara-backdrop' || e.target.id === 'bg') { e.preventDefault(); closeOnaraModal(); } }); if (loginModal) { if (loginBtn) { loginBtn.addEventListener('click', (e) => { e.preventDefault(); openModal(loginModal, 'login'); }); } if (loginClose) loginClose.addEventListener('click', () => closeModal(loginModal)); loginModal.addEventListener('click', (e) => { if (e.target === loginModal) closeModal(loginModal); }); // Forgot Password link const modalForgotBtn = document.getElementById('modal-forgot-btn'); if (modalForgotBtn) { modalForgotBtn.addEventListener('click', (e) => { e.preventDefault(); switchModalView('forgot'); }); } const forgotToLogin = document.getElementById('forgot-to-login'); if (forgotToLogin) { forgotToLogin.addEventListener('click', (e) => { e.preventDefault(); switchModalView('login'); }); } // Check for reset token or login flag in URL const urlParams = new URLSearchParams(window.location.search); const resetToken = urlParams.get('token'); if (resetToken) { const tokenInput = document.getElementById('reset-token'); if (tokenInput) { tokenInput.value = resetToken; openModal(loginModal, 'reset'); // Clean URL const cleanUrl = window.location.pathname + window.location.search.replace(/[?&]token=[^&]+/, '').replace(/[?&]$/, '') + window.location.hash; window.history.replaceState({}, '', cleanUrl); } } else if (urlParams.get('login') === '1') { openModal(loginModal, 'login'); // Clean URL const cleanUrl = window.location.pathname + window.location.search.replace(/[?&]login=1/, '').replace(/[?&]$/, '') + window.location.hash; window.history.replaceState({}, '', cleanUrl); } else if (urlParams.get('already_logged_in') === '1') { // Clean URL first, then show flash (deferred so window.showFlash is defined) const cleanUrl = window.location.pathname + window.location.search.replace(/[?&]already_logged_in=1/, '').replace(/[?&]$/, '') + window.location.hash; window.history.replaceState({}, '', cleanUrl); setTimeout(() => { window.showFlash(i18n.already_logged_in || 'Already logged in lol', 'error'); }, 0); } const loginForm = loginModal.querySelector('.login-form'); if (loginForm && loginForm.id !== 'forgot-password-form' && loginForm.id !== 'reset-password-form') { loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(loginForm); const params = new URLSearchParams(formData); if (!formData.get('password')) { let errDiv = loginForm.querySelector('.flash-error'); if (!errDiv) { errDiv = document.createElement('div'); errDiv.className = 'flash-error'; loginForm.insertBefore(errDiv, loginForm.firstChild); } errDiv.textContent = 'Invalid username or password.'; return; } try { const res = await fetch('/login', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }); if (res.redirected) { window.location.href = res.url; return; } const json = await res.json(); if (json && json.banned) { if (loginModal) closeModal(loginModal); window.location.href = json.redirect || '/banned'; return; } if (json && json.success === false) { let errDiv = loginForm.querySelector('.flash-error'); if (!errDiv) { errDiv = document.createElement('div'); errDiv.className = 'flash-error'; loginForm.insertBefore(errDiv, loginForm.firstChild); } errDiv.textContent = json.msg; } } catch (err) { console.error('Login error:', err); } }); } // Forgot Password Submit const forgotForm = document.getElementById('forgot-password-form'); if (forgotForm) { forgotForm.addEventListener('submit', async (e) => { e.preventDefault(); const email = document.getElementById('forgot-email').value; const status = document.getElementById('forgot-status'); const btn = forgotForm.querySelector('button'); btn.disabled = true; btn.textContent = i18n.sending || 'Sending...'; status.textContent = ''; status.className = ''; try { const res = await fetch('/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: new URLSearchParams({ email }) }); const data = await res.json(); if (data.success) { status.textContent = data.msg || 'Success! Check your email.'; status.className = 'flash-success'; forgotForm.reset(); } else { status.textContent = data.msg || 'Error sending link.'; status.className = 'flash-error'; } } catch (err) { status.textContent = 'Network error.'; status.className = 'flash-error'; } finally { btn.disabled = false; btn.textContent = 'Send Reset Link'; } }); } // Reset Password Submit const resetForm = document.getElementById('reset-password-form'); if (resetForm) { const resetToLogin = document.getElementById('reset-to-login'); resetForm.addEventListener('submit', async (e) => { e.preventDefault(); const token = document.getElementById('reset-token').value; const password = document.getElementById('reset-password').value; const password_confirm = document.getElementById('reset-password-confirm').value; const status = document.getElementById('reset-status'); const btn = resetForm.querySelector('button'); if (password !== password_confirm) { status.className = 'flash-error'; return; } if (password.length < 20) { status.textContent = 'Password is too short (minimum 20 characters).'; status.className = 'flash-error'; return; } btn.disabled = true; btn.textContent = i18n.updating || 'Updating...'; status.textContent = ''; status.className = ''; try { const res = await fetch('/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: new URLSearchParams({ token, password, password_confirm }) }); const data = await res.json(); if (data.success) { status.textContent = data.msg || 'Password updated successfully!'; status.className = 'flash-success'; resetForm.reset(); btn.style.display = 'none'; if (resetToLogin) resetToLogin.style.display = 'inline-block'; } else { status.textContent = data.msg || 'Error resetting password.'; status.className = 'flash-error'; } } catch (err) { status.textContent = 'Network error.'; status.className = 'flash-error'; } finally { btn.disabled = false; btn.textContent = 'Update Password'; } }); if (resetToLogin) { resetToLogin.addEventListener('click', (e) => { e.preventDefault(); switchModalView('login'); resetToLogin.style.display = 'none'; resetForm.querySelector('button').style.display = 'inline-block'; }); } } } if (registerBtn && registerModal) { registerBtn.addEventListener('click', (e) => { e.preventDefault(); openModal(registerModal); }); if (registerClose) registerClose.addEventListener('click', () => closeModal(registerModal)); registerModal.addEventListener('click', (e) => { if (e.target === registerModal) closeModal(registerModal); }); // Register Form AJAX const registerForm = document.getElementById('modal-register-form'); if (registerForm) { registerForm.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(registerForm); const params = new URLSearchParams(formData); const status = document.getElementById('register-status'); const btn = registerForm.querySelector('button'); const password = formData.get('password'); const password_confirm = formData.get('password_confirm'); if (password && password.length < 20) { if (status) { status.textContent = 'Password is too short (minimum 20 characters).'; status.className = 'flash-error'; } return; } if (password !== password_confirm) { if (status) { status.textContent = 'Passwords do not match.'; status.className = 'flash-error'; } return; } btn.disabled = true; btn.textContent = i18n.registering || 'Registering...'; if (status) { status.textContent = ''; status.className = ''; } try { const res = await fetch('/register', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }); const json = await res.json(); if (json.success) { if (status) { status.textContent = json.msg || 'Registration successful! You can now login.'; status.className = 'flash-success'; } registerForm.reset(); // Optional: switch to login view after a delay setTimeout(() => { const loginToRegister = document.getElementById('login-to-register'); if (loginToRegister) { // If we are in register modal, we might want to close it and open login? // But registration modal is separate in HTML. closeModal(registerModal); openModal(loginModal, 'login'); } }, 3000); } else { if (status) { status.textContent = json.msg || 'Registration failed.'; status.className = 'flash-error'; } } } catch (err) { console.error('Registration error:', err); if (status) { status.textContent = 'Network error.'; status.className = 'flash-error'; } } finally { btn.disabled = false; btn.textContent = 'Create Account'; } }); } // Switch to register from login // Switch to register from login const loginToRegister = document.getElementById('login-to-register'); if (loginToRegister) { loginToRegister.addEventListener('click', (e) => { e.preventDefault(); closeModal(loginModal); openModal(registerModal); }); } // Switch to login from register const registerToLogin = document.getElementById('register-to-login'); if (registerToLogin) { registerToLogin.addEventListener('click', (e) => { e.preventDefault(); closeModal(registerModal); openModal(loginModal, 'login'); }); } } // Shortcuts Modal Logic const shortcutsModal = document.getElementById('shortcuts-modal'); const shortcutsClose = document.getElementById('shortcuts-modal-close'); if (shortcutsModal) { if (shortcutsClose) { shortcutsClose.addEventListener('click', () => closeModal(shortcutsModal)); } shortcutsModal.addEventListener('click', (e) => { if (e.target === shortcutsModal) closeModal(shortcutsModal); }); // Delegate help button click (since it's in a partial) document.addEventListener('click', (e) => { if (e.target.id === 'help-button') { openModal(shortcutsModal); } }); } // Handle ESC key to close any modal document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeModal(loginModal); closeModal(registerModal); closeModal(shortcutsModal); const imageModal = document.getElementById('image-modal'); if (imageModal && (imageModal.classList.contains('visible') || imageModal.style.display === 'flex' || imageModal.style.display === 'block')) { if (typeof window.closeImageModal === 'function') window.closeImageModal(); else { imageModal.classList.remove('visible'); imageModal.style.display = 'none'; document.body.classList.remove('modal-open'); } return; } const infoModal = document.getElementById('info-modal'); if (infoModal && infoModal.style.display !== 'none') { infoModal.style.display = 'none'; document.body.classList.remove('modal-open'); return; } const visModal = document.getElementById('visibility-modal'); if (visModal && visModal.style.display !== 'none') { visModal.style.display = 'none'; document.body.classList.remove('modal-open'); return; } const reportModal = document.getElementById('report-modal'); if (reportModal && reportModal.style.display !== 'none') { reportModal.style.display = 'none'; document.body.classList.remove('modal-open'); return; } const hallsModal = document.getElementById('halls-modal'); if (hallsModal && hallsModal.style.display !== 'none') { hallsModal.style.display = 'none'; document.body.classList.remove('modal-open'); return; } const anyOtherModal = document.querySelector('.modal-overlay:not(#onara-modal):not([style*="display: none"]), .modal.show, .swal2-container'); if (anyOtherModal && window.getComputedStyle(anyOtherModal).display !== 'none') { anyOtherModal.style.display = 'none'; document.body.classList.remove('modal-open'); return; } const tagAcWrapper = document.querySelector('.tag-ac-wrapper'); if (tagAcWrapper || (window.TagAutocomplete && typeof window.TagAutocomplete.isOpen === 'function' && window.TagAutocomplete.isOpen())) { if (window.TagAutocomplete && typeof window.TagAutocomplete.destroy === 'function') { window.TagAutocomplete.destroy(); } else if (tagAcWrapper) { tagAcWrapper.remove(); } return; } const searchOverlay = document.getElementById('search-overlay'); if (searchOverlay && searchOverlay.classList.contains('visible')) { const closeBtn = document.getElementById('search-close'); if (closeBtn) closeBtn.click(); return; } const excludedTagsOverlay = document.getElementById('excluded-tags-overlay'); if (excludedTagsOverlay && excludedTagsOverlay.classList.contains('visible')) { const closeBtn = document.getElementById('excluded-tags-close'); if (closeBtn) closeBtn.click(); return; } if (document.body.classList.contains('onara-modal-open')) { closeOnaraModal(); } } }); var background = (window.f0ckSession && window.f0ckSession.show_background !== undefined) ? window.f0ckSession.show_background : (localStorage.getItem('background') !== 'false'); window.background = background; window.toggleBackground = async () => { background = !background; window.background = background; localStorage.setItem('background', background ? 'true' : 'false'); window.initBackground(); // Update videoplayer toggle buttons if they exist document.querySelectorAll("#togglebg").forEach(el => { el.classList.toggle('active', background); }); // Update session preference and persist if logged in if (window.f0ckSession) { window.f0ckSession.show_background = background; try { await fetch('/api/v2/settings/background', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession.csrf_token }, body: JSON.stringify({ show_background: background }) }); } catch (err) { console.error('Failed to sync background preference:', err); } } }; // Initialize autoplay preference if (localStorage.getItem('autoplay') == undefined) { localStorage.setItem('autoplay', 'false'); } var autoplay = localStorage.getItem('autoplay') === 'true'; window.toggleAutoplay = () => { autoplay = !autoplay; localStorage.setItem('autoplay', autoplay.toString()); // Update videoplayer toggle buttons if they exist document.querySelectorAll("#toggleautoplay").forEach(el => { el.classList.toggle('active', autoplay); }); }; let bgRafId = null; let lastBgElem = null; // Apply initial visual state var initialCanvas = document.getElementById('bg'); if (initialCanvas) { // No background on SWF pages if (document.getElementById('ruffle-container')) { initialCanvas.classList.add('fader-out'); initialCanvas.classList.remove('fader-in'); } else if (background) { initialCanvas.classList.add('fader-in'); initialCanvas.classList.remove('fader-out'); } else { initialCanvas.classList.add('fader-out'); initialCanvas.classList.remove('fader-in'); } } const _preloadedMediaUrls = new Set(); let _prefetchAbortController = null; let _prefetchDebounceTimer = null; const cancelPrefetch = () => { if (_prefetchDebounceTimer) { clearTimeout(_prefetchDebounceTimer); _prefetchDebounceTimer = null; } if (_prefetchAbortController) { try { _prefetchAbortController.abort(); } catch (_) {} _prefetchAbortController = null; } }; const preloadMediaFromHtml = (html) => { if (!html) return; try { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // 1. Preload Image only (standard image pipeline, non-blocking) const img = doc.querySelector('#f0ck-image, .embed-responsive-image img, .media-object img'); if (img) { const src = img.getAttribute('src'); if (src && !_preloadedMediaUrls.has(src)) { _preloadedMediaUrls.add(src); const preloadImg = new Image(); preloadImg.decoding = 'async'; preloadImg.src = src; window.f0ckDebug('[prefetch] Preloaded image asset:', src); } } // 2. Preload Audio Cover Art const cover = doc.querySelector('#f0ck-audio-cover'); if (cover) { const coverSrc = cover.getAttribute('src'); if (coverSrc && coverSrc !== '/s/img/200.gif' && !_preloadedMediaUrls.has(coverSrc)) { _preloadedMediaUrls.add(coverSrc); const preloadCover = new Image(); preloadCover.src = coverSrc; } } // Note: Video & Audio binary streams are deliberately NOT fetched via fetch() in JS. // HTML5 media elements stream natively upon mounting. Fetching multi-megabyte chunks // in the background exhausts the browser's 6-connection HTTP pool, stalling navigation. if (_preloadedMediaUrls.size > 150) { _preloadedMediaUrls.clear(); } } catch (e) { console.error('[prefetch] Error preloading media from html:', e); } }; const prefetchNextPrevItems = () => { // Abort prior in-flight prefetch requests if (_prefetchAbortController) { try { _prefetchAbortController.abort(); } catch (_) {} } _prefetchAbortController = new AbortController(); const signal = _prefetchAbortController.signal; // Prioritize next item (most frequent user journey), then prev const nextBtn = document.getElementById('next'); const prevBtn = document.getElementById('prev'); [nextBtn, prevBtn].forEach(btn => { if (!btn || !btn.href || btn.href.endsWith('#')) return; if (signal.aborted) return; const pathSegments = new URL(btn.href, window.location.origin).pathname.split('/'); const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s)); if (keySegments.length === 0) return; const itemId = keySegments.pop(); let ajaxUrl = `/ajax/item/${itemId}`; const params = new URLSearchParams(); params.append('mode', window.activeMode); const tagMatch = btn.href.match(/\/tag\/([^/?]+)/); if (tagMatch) params.append('tag', decodeURIComponent(tagMatch[1])); const userHallMatch = btn.href.match(/\/user\/([^/]+)\/hall\/([^/]+)/); if (userHallMatch) { params.append('userHall', decodeURIComponent(userHallMatch[2])); params.append('userHallOwner', decodeURIComponent(userHallMatch[1])); } else { const userMatch = btn.href.match(/\/user\/([^/]+)/); if (userMatch) { params.append('user', decodeURIComponent(userMatch[1])); if (btn.href.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) params.append('fav', 'true'); } } const hallMatch = btn.href.match(/\/h\/([^/?]+)/); if (hallMatch) params.append('hall', decodeURIComponent(hallMatch[1])); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); if (isStrict) params.append('strict', '1'); if (params.toString() !== '') { ajaxUrl += (ajaxUrl.includes('?') ? '&' : '?') + params.toString(); } if (itemCacheMap.has(ajaxUrl)) { const cached = itemCacheMap.get(ajaxUrl); if (cached && cached.html) preloadMediaFromHtml(cached.html); return; } fetch(ajaxUrl, { credentials: 'include', priority: 'low', signal }) .then(r => r.ok ? r.text() : null) .then(rawText => { if (!rawText || signal.aborted) return; let html = rawText; try { const data = JSON.parse(rawText); if (data && typeof data.html === 'string') html = data.html; } catch (_) {} if (html && !signal.aborted) { itemCacheMap.set(ajaxUrl, { html, ts: Date.now() }); if (itemCacheMap.size > ITEM_CACHE_MAX) { itemCacheMap.delete(itemCacheMap.keys().next().value); } window.f0ckDebug('[prefetch] Cached AJAX template for item', itemId); preloadMediaFromHtml(html); } }) .catch(err => { if (err.name !== 'AbortError') { window.f0ckDebug('[prefetch] Prefetch error or cancelled:', err.message); } }); }); }; const schedulePrefetch = (delay = 600) => { cancelPrefetch(); _prefetchDebounceTimer = setTimeout(() => { _prefetchDebounceTimer = null; if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => prefetchNextPrevItems(), { timeout: 1500 }); } else { prefetchNextPrevItems(); } }, delay); }; const initAlbumGallery = () => { const container = document.querySelector('.album-gallery-container'); if (!container) { window._currentActiveAlbumGallery = null; return; } const getHashSubf0ckId = () => { return (window.location.hash || '').replace(/^#/, '').trim(); }; if (container._f0ckAlbumInit) { const initialHash = getHashSubf0ckId() || container.getAttribute('data-requested-subf0ck') || ''; if (initialHash && window._currentActiveAlbumGallery && typeof window._currentActiveAlbumGallery.showBySlug === 'function') { window._currentActiveAlbumGallery.showBySlug(initialHash); } return; } container._f0ckAlbumInit = true; let albumData = null; const jsonScript = container.querySelector('.album-data-json, #album-data-json'); if (jsonScript && jsonScript.textContent) { try { albumData = JSON.parse(jsonScript.textContent); } catch (e) { console.warn('[ALBUM] Could not parse album script tag:', e); } } if (!albumData) { try { let raw = container.getAttribute('data-album'); if (raw) { if (raw.includes('"') || raw.includes('{') || raw.includes('&')) { const txt = document.createElement('textarea'); txt.innerHTML = raw; raw = txt.value; if (raw.includes('"') || raw.includes('{')) { txt.innerHTML = raw; raw = txt.value; } } albumData = JSON.parse(raw); } } catch (e) { // Fallback to DOM elements below } } // Fallback: build albumData directly from rendered thumbnail buttons if (!Array.isArray(albumData) || albumData.length === 0) { const thumbs = container.querySelectorAll('.album-thumb-item'); if (thumbs.length > 1) { albumData = Array.from(thumbs).map((thumb, i) => { const idx = parseInt(thumb.getAttribute('data-index'), 10); const src = thumb.getAttribute('data-src') || thumb.querySelector('img')?.src || ''; const subSlug = thumb.getAttribute('data-subf0ck-slug') || thumb.getAttribute('data-subf0ck-id') || ''; const mime = (thumb.getAttribute('data-mime') || '').toLowerCase(); const isAudio = mime.startsWith('audio/') || !!thumb.querySelector('.sidebar-media-placeholder.audio'); return { order_index: isNaN(idx) ? i : idx, display_index: (isNaN(idx) ? i : idx) + 1, src: src, dest: src, slug: subSlug, subf0ck_id: subSlug, id: subSlug, mime: mime || (isAudio ? 'audio/mpeg' : ''), is_audio: isAudio, is_video: mime.startsWith('video/') }; }); } } if (!Array.isArray(albumData) || albumData.length <= 1) return; let currentIndex = 0; const initialHash = getHashSubf0ckId() || container.getAttribute('data-requested-subf0ck') || ''; if (initialHash) { if (!getHashSubf0ckId()) { history.replaceState(null, '', window.location.pathname + window.location.search + '#' + initialHash); } const locEl = document.querySelector('.location'); if (locEl && !locEl.textContent.includes('#')) { locEl.textContent = locEl.textContent.replace(/#.*$/, '') + '#' + initialHash; } const foundIdx = albumData.findIndex(item => String(item.slug || '') === initialHash || String(item.subf0ck_id || '') === initialHash || String(item.id) === initialHash || String(item.order_index + 1) === initialHash ); if (foundIdx !== -1) { currentIndex = foundIdx; } } const imgEl = container.querySelector('#f0ck-image'); const videoEl = container.querySelector('#f0ck-album-video'); const videoWrapper = container.querySelector('#f0ck-album-video-wrapper'); const audioWrapper = container.querySelector('#f0ck-album-audio-wrapper') || container.querySelector('#f0ck-album-audio-container'); const audioEl = container.querySelector('#f0ck-album-audio'); const linkEl = container.querySelector('#elfe, .album-stage-link'); const prevBtn = container.querySelector('.album-btn-prev'); const nextBtn = container.querySelector('.album-btn-next'); const currentIdxEl = container.querySelector('.album-current-idx'); const totalCountEl = container.querySelector('.album-total-count'); const thumbItems = container.querySelectorAll('.album-thumb-item'); const stripEl = container.querySelector('.album-thumbnails-strip'); let stripTimeout = null; function showStripPeek(duration = 3000) { if (stripTimeout) clearTimeout(stripTimeout); container.classList.add('strip-peek'); if (stripEl) stripEl.classList.add('strip-peek'); stripTimeout = setTimeout(() => { container.classList.remove('strip-peek'); if (stripEl) stripEl.classList.remove('strip-peek'); }, duration); } if (totalCountEl) totalCountEl.textContent = albumData.length; let albumVideoV0ck = null; const initAlbumVideoV0ck = () => { if (albumVideoV0ck) return albumVideoV0ck; if (videoEl && typeof v0ck === 'function') { try { albumVideoV0ck = new v0ck(videoEl); } catch (e) { console.warn('[ALBUM] Could not init v0ck for album video:', e); } } return albumVideoV0ck; }; let albumAudioV0ck = null; const initAlbumAudioV0ck = () => { if (albumAudioV0ck) return albumAudioV0ck; if (audioEl && typeof v0ck === 'function') { try { albumAudioV0ck = new v0ck(audioEl); } catch (e) { console.warn('[ALBUM] Could not init v0ck for album audio:', e); } } return albumAudioV0ck; }; const preloadedImages = new Set(); const preload = (idx) => { if (idx < 0 || idx >= albumData.length) return; const sub = albumData[idx]; if (!sub) return; const src = sub.src || sub.dest; const mime = (sub.mime || '').toLowerCase(); if (src && !mime.startsWith('video/') && !mime.startsWith('audio/') && !preloadedImages.has(src)) { const i = new Image(); i.src = src; preloadedImages.add(src); } }; preload(1); if (albumData.length > 2) preload(albumData.length - 1); const isBlurred = () => { const mediaObj = container.closest('.media-object') || document.querySelector('.media-object'); if (mediaObj && localStorage.getItem('blurDetail') !== 'false') { const mode = mediaObj.getAttribute('data-mode'); const blurNsfw = localStorage.getItem('blurNsfw') === 'true'; const blurNsfl = localStorage.getItem('blurNsfl') === 'true'; const blurSfw = localStorage.getItem('blurSfw') === 'true'; const blurUntagged = localStorage.getItem('blurUntagged') === 'true'; let shouldBlurThis = false; if (mode === 'nsfw') shouldBlurThis = blurNsfw; else if (mode === 'nsfl') shouldBlurThis = blurNsfl; else if (mode === 'sfw') shouldBlurThis = blurSfw; else if (mode === 'untagged') shouldBlurThis = blurUntagged; if (shouldBlurThis && !mediaObj.classList.contains('revealed')) { return true; } } return false; }; const isAutoplayAllowed = () => { return !isBlurred() && window.f0ckSession?.disable_autoplay !== true; }; const showImage = (index, direction = 'none', updateHash = true) => { if (index < 0) index = albumData.length - 1; if (index >= albumData.length) index = 0; currentIndex = index; const item = albumData[currentIndex]; if (!item) return; const newSrc = item.src || item.dest; const mime = (item.mime || '').toLowerCase(); // Update URL hash with subf0ck slug (or id fallback) so links are shareable directly to this subf0ck const subKey = item.slug || item.subf0ck_id || item.id; if (updateHash && subKey) { history.replaceState(null, '', window.location.pathname + window.location.search + '#' + subKey); } const locEl = document.querySelector('.location'); if (locEl && subKey) { locEl.textContent = locEl.textContent.replace(/#.*$/, '') + '#' + subKey; } // Pause any playing audio or video if (videoEl && !videoEl.paused) { try { videoEl.pause(); } catch {} } if (audioEl && !audioEl.paused) { try { audioEl.pause(); } catch {} } if (mime.startsWith('video/')) { container.classList.add('is-video-active'); container.classList.remove('is-audio-active'); const badge = container.querySelector('.audio-track-info-badge'); if (badge) badge.classList.remove('is-visible'); if (imgEl) imgEl.style.display = 'none'; if (linkEl) linkEl.style.display = 'none'; if (audioWrapper) audioWrapper.style.display = 'none'; if (videoWrapper) videoWrapper.style.display = 'block'; if (videoEl) { videoEl.style.display = 'block'; if (videoEl.src !== newSrc && !videoEl.src.endsWith(newSrc)) { videoEl.src = newSrc; videoEl.load(); } initAlbumVideoV0ck(); video = videoEl; if (window.initBackground) window.initBackground(); if (item.size && videoWrapper) { const dlBtn = videoWrapper.querySelector('#v0ck_download'); if (dlBtn) dlBtn.textContent = `Download (${item.size})`; } const playerWrap = videoWrapper.querySelector('.v0ck') || videoWrapper; if (isAutoplayAllowed()) { const playPromise = videoEl.play(); if (playPromise !== undefined) { playPromise.catch(() => { playerWrap.classList.add('v0ck_initial'); }); } } else { try { videoEl.pause(); } catch {} playerWrap.classList.add('v0ck_initial'); } } } else if (mime.startsWith('audio/')) { container.classList.remove('is-video-active'); container.classList.add('is-audio-active'); if (videoEl) { try { videoEl.pause(); } catch {} } if (videoWrapper) videoWrapper.style.display = 'none'; if (imgEl) imgEl.style.display = 'none'; if (linkEl) linkEl.style.display = 'none'; if (audioWrapper) { audioWrapper.style.display = 'block'; const hasCover = item.has_coverart && item.coverart && !item.coverart.includes('audio.webp'); const coverUrl = hasCover ? (item.coverart || item.thumb) : null; let ph = audioWrapper.querySelector(':scope > .sidebar-media-placeholder.audio'); if (!ph) { ph = document.createElement('div'); ph.className = 'sidebar-media-placeholder audio'; ph.innerHTML = '
'; audioWrapper.prepend(ph); } let coverCircle = ph.querySelector('.audio-cover-circle'); if (!coverCircle) { coverCircle = document.createElement('div'); coverCircle.className = 'audio-cover-circle'; coverCircle.innerHTML = ''; ph.insertBefore(coverCircle, ph.firstChild); } else { if (!coverCircle.querySelector('i')) { const existingI = ph.querySelector(':scope > i'); if (existingI) coverCircle.appendChild(existingI); else coverCircle.insertAdjacentHTML('beforeend', ''); } } audioWrapper.style.backgroundImage = 'none'; audioWrapper.style.backgroundColor = '#000000'; if (coverUrl) { coverCircle.style.backgroundImage = `url('${coverUrl}')`; ph.classList.add('has-cover'); } else { coverCircle.style.backgroundImage = 'none'; coverCircle.style.backgroundColor = '#000000'; ph.classList.remove('has-cover'); ph.style.backgroundColor = '#000000'; } ph.style.display = ''; } if (audioEl) { audioEl.style.display = 'block'; audioEl.crossOrigin = 'anonymous'; const hasCover = item.has_coverart && item.coverart && !item.coverart.includes('audio.webp'); const coverUrl = hasCover ? (item.coverart || item.thumb) : null; if (coverUrl) { audioEl.setAttribute('poster', coverUrl); } else { audioEl.removeAttribute('poster'); } if (audioEl.src !== newSrc && !audioEl.src.endsWith(newSrc)) { audioEl.src = newSrc; audioEl.setAttribute('src', newSrc); audioEl.load(); } initAlbumAudioV0ck(); video = audioEl; if (window.initVisualizer) window.initVisualizer(audioEl); if (window.initBackground) window.initBackground(); if (window.updateAudioTrackBadge) window.updateAudioTrackBadge(audioEl, item.id || item.slug, newSrc); audioEl.addEventListener('play', () => audioWrapper?.classList.add('is-playing')); audioEl.addEventListener('pause', () => audioWrapper?.classList.remove('is-playing')); audioEl.addEventListener('ended', () => audioWrapper?.classList.remove('is-playing')); if (item.size && audioWrapper) { const dlBtn = audioWrapper.querySelector('#v0ck_download'); if (dlBtn) dlBtn.textContent = `Download (${item.size})`; } const playerWrap = audioWrapper.querySelector('.v0ck') || audioWrapper; if (isAutoplayAllowed()) { const playPromise = audioEl.play(); if (playPromise !== undefined) { playPromise.catch(() => { playerWrap.classList.add('v0ck_initial'); }); } } else { try { audioEl.pause(); } catch {} playerWrap.classList.add('v0ck_initial'); } } } else { // Image container.classList.remove('is-video-active', 'is-audio-active'); const badge = container.querySelector('.audio-track-info-badge'); if (badge) badge.classList.remove('is-visible'); if (videoEl) { try { videoEl.pause(); } catch {} } if (audioEl) { try { audioEl.pause(); } catch {} } if (videoWrapper) videoWrapper.style.display = 'none'; if (audioWrapper) audioWrapper.style.display = 'none'; if (linkEl) { linkEl.style.display = ''; linkEl.href = newSrc; } if (imgEl) { imgEl.style.display = 'block'; imgEl.classList.remove('album-img-fade'); void imgEl.offsetWidth; // trigger reflow imgEl.src = newSrc; imgEl.classList.add('album-img-fade'); } video = null; } if (linkEl && !mime.startsWith('video/')) { linkEl.href = newSrc; } if (currentIdxEl) { currentIdxEl.textContent = currentIndex + 1; } thumbItems.forEach((btn, idx) => { const isActive = idx === currentIndex; btn.classList.toggle('active', isActive); if (isActive && stripEl && stripEl.clientWidth > 0) { const stripRect = stripEl.getBoundingClientRect(); const btnRect = btn.getBoundingClientRect(); const offsetWithinStrip = (btnRect.left - stripRect.left) + stripEl.scrollLeft; const targetLeft = offsetWithinStrip - (stripEl.clientWidth - btn.clientWidth) / 2; stripEl.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' }); } }); if (window.scrollX > 0) { window.scrollTo(0, window.scrollY); } showStripPeek(3000); updateInfoModal(); updateAlbumTags(); preload(currentIndex + 1); preload(currentIndex - 1); }; // Helper that returns the parent album's rating as a minimal tag object array. // The authoritative value is stored in #tags[data-parent-rating] and initialised // from the server-rendered DOM on first load, then kept in sync by handleTagsUpdate. const getParentRatingTags = () => { const tagsContainer = document.querySelector('#tags'); if (!tagsContainer) return []; const r = tagsContainer.dataset.parentRating || ''; if (!['sfw', 'nsfw', 'nsfl'].includes(r)) return []; const tagMap = { sfw: { id: 1, tag: 'sfw', normalized: 'sfw', badge: 'badge badge-success' }, nsfw: { id: 2, tag: 'nsfw', normalized: 'nsfw', badge: 'badge badge-danger' }, nsfl: { id: window.f0ckSession?.nsfl_tag_id || 3, tag: 'nsfl', normalized: 'nsfl', badge: 'badge badge-nsfl' } }; return tagMap[r] ? [tagMap[r]] : []; }; // Seed data-parent-rating from the server-rendered rating badge (() => { const tagsContainer = document.querySelector('#tags'); if (!tagsContainer) return; const inner = tagsContainer.querySelector('.tags-inner') || tagsContainer; const ratingEl = inner.querySelector('.rating-tag[data-rating]'); if (!ratingEl) return; const r = ratingEl.dataset.rating; if (['sfw', 'nsfw', 'nsfl'].includes(r)) { tagsContainer.dataset.parentRating = r; } })(); const updateAlbumTags = () => { const sub = albumData[currentIndex]; if (!sub) return; const tagsContainer = document.querySelector('#tags'); if (!tagsContainer) return; const subSlug = sub.slug || sub.subf0ck_id || sub.id; tagsContainer.dataset.subf0ckId = String(sub.id); tagsContainer.dataset.subf0ckSlug = String(subSlug); tagsContainer.dataset.subf0ckIndex = String(currentIndex); let tagScopeEl = document.querySelector('#subf0ck-tag-scope'); if (!tagScopeEl) { const sidebarCont = document.querySelector('.sidebar-tags-container'); if (sidebarCont) { tagScopeEl = document.createElement('div'); tagScopeEl.id = 'subf0ck-tag-scope'; tagScopeEl.className = 'subf0ck-tag-scope'; sidebarCont.insertBefore(tagScopeEl, tagsContainer); } } if (tagScopeEl) { tagScopeEl.innerHTML = ` Subf0ck ${subSlug} (${currentIndex + 1}/${albumData.length}):`; } let subTags = Array.isArray(sub.tags) ? sub.tags : []; // If this sub-item has no rating tag, inherit the parent album's rating const RATING_NORMS = ['sfw', 'nsfw', 'nsfl']; const subHasRating = subTags.some(t => RATING_NORMS.includes(t.normalized)); if (!subHasRating) { const parentRating = getParentRatingTags(); if (parentRating.length > 0) subTags = [...parentRating, ...subTags]; } if (typeof window.renderTags === 'function') { window.renderTags(subTags); } }; const updateInfoModal = () => { const modal = document.getElementById('info-modal'); if (!modal) return; const sub = albumData[currentIndex]; if (!sub) return; const subHeading = modal.querySelector('#info-modal-subheading'); if (subHeading) { const parentId = subHeading.getAttribute('data-item-id') || container.getAttribute('data-album-id') || ''; const parentSlug = subHeading.getAttribute('data-item-slug') || ''; const subSlug = sub.slug || sub.subf0ck_id || sub.id; const total = albumData.length; const idx = currentIndex + 1; subHeading.innerHTML = `Post ID: ${parentId}${parentSlug ? ` (${parentSlug})` : ''} • Subf0ck: ${subSlug} (${idx}/${total})`; } const specsTitle = modal.querySelector('#info-specs-header-title'); if (specsTitle) { specsTitle.textContent = `Technical Specifications (Subf0ck ${currentIndex + 1}/${albumData.length})`; } const fileSizeEl = modal.querySelector('#info-file-size'); if (fileSizeEl) { let displaySize = sub.size || ''; if (displaySize === 'NaN B' || displaySize === 'NaN') displaySize = ''; if (displaySize && !isNaN(displaySize)) { const num = Number(displaySize); if (num > 0) { const i = Math.min(4, Math.max(0, ~~(Math.log(num) / Math.log(1024)))); displaySize = (num / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; } } fileSizeEl.textContent = displaySize; } const dimsCard = modal.querySelector('#info-card-dimensions'); const dimsEl = modal.querySelector('#info-file-dimensions'); if (dimsCard && dimsEl) { if (sub.width && sub.height) { dimsEl.textContent = `${sub.width} × ${sub.height} px`; dimsCard.style.display = ''; } else { dimsCard.style.display = 'none'; } } const mimeEl = modal.querySelector('#info-file-mime'); if (mimeEl) { mimeEl.textContent = sub.mime || ''; } const directLink = modal.querySelector('#info-file-direct-link'); if (directLink) { directLink.href = sub.dest || sub.src || '#'; } const sourceCard = modal.querySelector('#info-card-source'); if (sourceCard) { sourceCard.style.display = 'none'; } const hashCard = modal.querySelector('#info-card-hash'); const hashEl = modal.querySelector('#info-file-hash'); const copyHashBtn = modal.querySelector('#info-copy-hash-btn'); if (hashCard) { const cleanHash = sub.checksum ? String(sub.checksum).split('_bypass_')[0] : ''; if (cleanHash) { if (hashEl) hashEl.textContent = cleanHash; if (copyHashBtn) copyHashBtn.setAttribute('data-hash', cleanHash); hashCard.style.display = ''; } else { hashCard.style.display = 'none'; } } }; if (imgEl) { imgEl.addEventListener('load', () => { const sub = albumData[currentIndex]; if (sub && (!sub.width || !sub.height) && imgEl.naturalWidth) { sub.width = imgEl.naturalWidth; sub.height = imgEl.naturalHeight; updateInfoModal(); } }); } if (videoEl) { videoEl.addEventListener('loadedmetadata', () => { const sub = albumData[currentIndex]; if (sub && (!sub.width || !sub.height) && videoEl.videoWidth) { sub.width = videoEl.videoWidth; sub.height = videoEl.videoHeight; updateInfoModal(); } }); } // Ensure proper initial media display (especially if initial item is video or subf0ck hash was requested) showImage(currentIndex, 'none', !!initialHash); if (prevBtn) { prevBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); showImage(currentIndex - 1, 'prev'); }); } if (nextBtn) { nextBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); showImage(currentIndex + 1, 'next'); }); } let hasDraggedStrip = false; if (stripEl && !stripEl._dragInitialized) { stripEl._dragInitialized = true; let isDown = false; let startX = 0; let startScrollLeft = 0; let momentumID = null; let velX = 0; let lastX = 0; let lastTime = 0; stripEl.addEventListener('mousedown', (e) => { if (e.button !== 0) return; isDown = true; hasDraggedStrip = false; startX = e.pageX; startScrollLeft = stripEl.scrollLeft; lastX = e.pageX; lastTime = performance.now(); velX = 0; cancelAnimationFrame(momentumID); }); window.addEventListener('mousemove', (e) => { if (!isDown) return; const now = performance.now(); const walk = e.pageX - startX; if (Math.abs(walk) > 6) { hasDraggedStrip = true; stripEl.classList.add('is-dragging'); e.preventDefault(); stripEl.scrollLeft = startScrollLeft - walk; const dt = Math.max(1, now - lastTime); velX = (e.pageX - lastX) / dt; lastX = e.pageX; lastTime = now; } }); window.addEventListener('mouseup', () => { if (!isDown) return; isDown = false; stripEl.classList.remove('is-dragging'); showStripPeek(6000); if (hasDraggedStrip && Math.abs(velX) > 0.15) { let currentVel = velX * 18; const glide = () => { if (Math.abs(currentVel) > 0.5) { stripEl.scrollLeft -= currentVel; currentVel *= 0.93; momentumID = requestAnimationFrame(glide); } }; momentumID = requestAnimationFrame(glide); } setTimeout(() => { hasDraggedStrip = false; }, 100); }); // Touch drag / swipe support for mobile let touchStartX = 0; let touchStartScrollLeft = 0; let isTouchingStrip = false; stripEl.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { e.stopPropagation(); if (stripTimeout) clearTimeout(stripTimeout); container.classList.add('strip-peek'); stripEl.classList.add('strip-peek'); isTouchingStrip = true; hasDraggedStrip = false; touchStartX = e.touches[0].clientX; touchStartScrollLeft = stripEl.scrollLeft; lastX = e.touches[0].clientX; lastTime = performance.now(); velX = 0; cancelAnimationFrame(momentumID); } }, { passive: true }); stripEl.addEventListener('touchmove', (e) => { if (!isTouchingStrip || e.touches.length !== 1) return; e.stopPropagation(); const currentX = e.touches[0].clientX; const walk = currentX - touchStartX; if (Math.abs(walk) > 3) { hasDraggedStrip = true; stripEl.classList.add('is-dragging'); stripEl.scrollLeft = touchStartScrollLeft - walk; const now = performance.now(); const dt = Math.max(1, now - lastTime); velX = (currentX - lastX) / dt; lastX = currentX; lastTime = now; } }, { passive: true }); stripEl.addEventListener('touchend', (e) => { if (!isTouchingStrip) return; e.stopPropagation(); isTouchingStrip = false; stripEl.classList.remove('is-dragging'); showStripPeek(6000); if (hasDraggedStrip) { if (Math.abs(velX) > 0.05) { let currentVel = velX * 24; const glide = () => { if (Math.abs(currentVel) > 0.5) { stripEl.scrollLeft -= currentVel; currentVel *= 0.93; momentumID = requestAnimationFrame(glide); } }; momentumID = requestAnimationFrame(glide); } setTimeout(() => { hasDraggedStrip = false; }, 150); } }, { passive: true }); stripEl.addEventListener('touchcancel', (e) => { e.stopPropagation(); isTouchingStrip = false; stripEl.classList.remove('is-dragging'); showStripPeek(6000); setTimeout(() => { hasDraggedStrip = false; }, 100); }, { passive: true }); stripEl.addEventListener('wheel', (e) => { if (e.deltaY !== 0) { e.preventDefault(); stripEl.scrollLeft += e.deltaY; } }, { passive: false }); } thumbItems.forEach((btn) => { btn.addEventListener('click', (e) => { if (hasDraggedStrip) { e.preventDefault(); e.stopPropagation(); return; } e.preventDefault(); e.stopPropagation(); const targetIdx = parseInt(btn.getAttribute('data-index'), 10); if (!isNaN(targetIdx)) { showImage(targetIdx); } }); }); // Touch tap on container reveals thumbnail strip briefly on touch devices container.addEventListener('click', (e) => { if (!e.target.closest('.v0ck_settings_menu, .v0ck_hud, .album-btn')) { showStripPeek(3000); } }); // Handle touch swipe for mobile gallery navigation let touchStartX = null; let touchStartY = null; let touchIgnored = false; container.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { touchIgnored = !!e.target.closest('.album-thumbnails-strip, .v0ck_player_controls, .v0ck_settings_menu, .v0ck_hud, .album-btn'); touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; } }, { passive: true }); container.addEventListener('touchend', (e) => { if (touchStartX === null || touchStartY === null || touchIgnored) { touchStartX = null; touchStartY = null; touchIgnored = false; return; } const touchEndX = e.changedTouches[0].clientX; const touchEndY = e.changedTouches[0].clientY; const diffX = touchEndX - touchStartX; const diffY = touchEndY - touchStartY; // Minimum swipe distance of 50px and predominantly horizontal if (Math.abs(diffX) > 50 && Math.abs(diffX) > Math.abs(diffY) * 1.5) { if (diffX < 0) { showImage(currentIndex + 1, 'next'); } else { showImage(currentIndex - 1, 'prev'); } } touchStartX = null; touchStartY = null; touchIgnored = false; }, { passive: true }); // Handle hash change if user navigates back/forward to another subf0ck window.addEventListener('hashchange', () => { const newHash = getHashSubf0ckId(); if (!newHash) return; const foundIdx = albumData.findIndex((item) => String(item.slug || '') === newHash || String(item.subf0ck_id || '') === newHash || String(item.id) === newHash || String(item.order_index + 1) === newHash ); if (foundIdx !== -1 && foundIdx !== currentIndex) { showImage(foundIdx, 'none', false); } }); window._currentActiveAlbumGallery = { prev: () => showImage(currentIndex - 1, 'prev'), next: () => showImage(currentIndex + 1, 'next'), showBySlug: (subKey) => { if (!subKey || !Array.isArray(albumData)) return false; const foundIdx = albumData.findIndex((item) => String(item.slug || '') === String(subKey) || String(item.subf0ck_id || '') === String(subKey) || String(item.id) === String(subKey) || String(item.order_index + 1) === String(subKey) ); if (foundIdx !== -1) { showImage(foundIdx, 'none', true); return true; } return false; }, updateInfoModal: updateInfoModal, updateAlbumTags: updateAlbumTags, getCurrentSubf0ck: () => albumData[currentIndex], isHovered: false }; window.albumGallery = window._currentActiveAlbumGallery; container.addEventListener('mouseenter', () => { if (window._currentActiveAlbumGallery) window._currentActiveAlbumGallery.isHovered = true; }); container.addEventListener('mouseleave', () => { if (window._currentActiveAlbumGallery) window._currentActiveAlbumGallery.isHovered = false; }); }; const syncLocationSubf0ck = () => { const layout = document.querySelector('.item-layout-container'); const reqSub = layout ? (layout.getAttribute('data-requested-subf0ck') || '') : ''; const hash = (window.location.hash || '').replace(/^#/, '').trim(); const targetSub = hash || reqSub; if (targetSub) { if (!hash && reqSub) { history.replaceState(null, '', window.location.pathname + window.location.search + '#' + reqSub); } const locEl = document.querySelector('.location'); if (locEl && !locEl.textContent.includes('#')) { locEl.textContent = locEl.textContent.replace(/#.*$/, '') + '#' + targetSub; } } }; const setupMedia = () => { window._currentActiveAlbumGallery = null; window.albumGallery = null; const elem = document.querySelector("#my-video") || document.querySelector("audio#my-video"); if (elem) { video = new v0ck(elem); } else { video = null; } initAlbumGallery(); syncLocationSubf0ck(); }; document.addEventListener('f0ck:contentLoaded', () => { initAlbumGallery(); syncLocationSubf0ck(); }); const initOnaraInitialState = () => { if (!isOnaraActive()) return; promoteOnaraModals(); if (document.body.classList.contains('onara-modal-open')) { const modal = openOnaraModal(); if (!window._onaraReturnUrl) { window._onaraReturnUrl = getOnaraBaseUrl(); window._onaraReturnTitle = window.f0ckDomain || 'f0ck'; } window._onaraCurrentGridUrl = window._onaraReturnUrl; const pathSegments = window.location.pathname.split('/'); const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s)); const activeKey = keySegments.length ? keySegments[keySegments.length - 1] : null; if (activeKey) { updateOnaraActiveItem(activeKey, window.location.href); if (typeof window.trackVisit === 'function') { window.trackVisit(activeKey); } } } else { window._onaraCurrentGridUrl = window.location.pathname + window.location.search; } }; // Initial Load document.addEventListener('DOMContentLoaded', () => { setupMedia(); initOnaraInitialState(); schedulePrefetch(800); }); initOnaraInitialState(); // Dynamic YouTube ambient color timeline manager let ytAmbientState = { rafId: null, videoId: null, colors: null, duration: 0, currentTime: 0, isPlaying: false, lastTimeUpdate: 0, currentColor: [0, 0, 0], targetColor: [0, 0, 0], cleanup: null }; const stopYoutubeAmbient = () => { if (ytAmbientState.rafId) { window.cancelAnimFrame(ytAmbientState.rafId); ytAmbientState.rafId = null; } if (ytAmbientState.cleanup) { ytAmbientState.cleanup(); ytAmbientState.cleanup = null; } ytAmbientState.videoId = null; ytAmbientState.colors = null; ytAmbientState.isPlaying = false; }; const initYoutubeAmbient = (ytEmbed, canvas, backgroundEnabled) => { stopYoutubeAmbient(); if (!ytEmbed || !canvas || !backgroundEnabled) return; const videoId = ytEmbed.dataset.ytId || (ytEmbed.src && (ytEmbed.src.match(/embed\/([a-zA-Z0-9_-]+)/) || [])[1]); if (!videoId) return; ytAmbientState.videoId = videoId; const context = canvas.getContext('2d'); const _SCALE = 0.5; const updateCanvasSize = () => { const cw = Math.max(1, (canvas.clientWidth * _SCALE) | 0); const ch = Math.max(1, (canvas.clientHeight * _SCALE) | 0); if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; } return { cw: canvas.width, ch: canvas.height }; }; const { cw, ch } = updateCanvasSize(); const blurPx = Math.round(80 * _SCALE) || 1; // 1. Draw static thumbnail immediately for instant background (with robust fallback) const itemId = window.getCurrentItemId(); const tryDrawThumb = (url, fallbackUrl) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { try { context.filter = `blur(${blurPx}px) brightness(1.2)`; const sw = img.naturalWidth || img.width; const sh = img.naturalHeight || img.height; if (sw > 0 && sh > 0) { const scale = Math.max(canvas.width / sw, canvas.height / sh); const dw = sw * scale; const dh = sh * scale; context.drawImage(img, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh); } else { context.drawImage(img, 0, 0, canvas.width, canvas.height); } context.filter = 'none'; } catch (e) {} canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); }; img.onerror = () => { if (fallbackUrl && url !== fallbackUrl) { tryDrawThumb(fallbackUrl, null); } else { canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); } }; img.src = url; }; const localThumb = itemId ? `/t/${itemId}.webp` : null; const remoteThumb = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; tryDrawThumb(localThumb || remoteThumb, remoteThumb); // 2. Fetch storyboard ambient colors fetch(`/api/v2/ambient/yt/${videoId}`) .then(res => res.ok ? res.json() : null) .then(data => { if (!data || !Array.isArray(data.colors) || !data.colors.length || ytAmbientState.videoId !== videoId) return; ytAmbientState.colors = data.colors; ytAmbientState.duration = Number(data.duration) || 0; if (ytAmbientState.colors.length > 0) { ytAmbientState.currentColor = [...ytAmbientState.colors[0]]; ytAmbientState.targetColor = [...ytAmbientState.colors[0]]; // Immediately paint the first frame color as ambient glow! renderAmbientFrame(); canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); } if (ytAmbientState.isPlaying) { startAmbientLoop(); } }) .catch(() => {}); // 3. Render function for ambient lighting const renderAmbientFrame = () => { if (!ytAmbientState.colors || !ytAmbientState.colors.length || !ytAmbientState.duration) return; const { cw: curW, ch: curH } = updateCanvasSize(); let t = ytAmbientState.currentTime; if (ytAmbientState.isPlaying && ytAmbientState.lastTimeUpdate > 0) { const dt = (performance.now() - ytAmbientState.lastTimeUpdate) / 1000; t = Math.min(ytAmbientState.duration, t + dt); } const norm = Math.max(0, Math.min(1, t / ytAmbientState.duration)); const pos = norm * (ytAmbientState.colors.length - 1); const idx = Math.floor(pos); const next = Math.min(ytAmbientState.colors.length - 1, idx + 1); const frac = pos - idx; const c1 = ytAmbientState.colors[idx]; const c2 = ytAmbientState.colors[next]; ytAmbientState.targetColor = [ c1[0] + (c2[0] - c1[0]) * frac, c1[1] + (c2[1] - c1[1]) * frac, c1[2] + (c2[2] - c1[2]) * frac ]; // Smooth lerp ytAmbientState.currentColor[0] += (ytAmbientState.targetColor[0] - ytAmbientState.currentColor[0]) * 0.15; ytAmbientState.currentColor[1] += (ytAmbientState.targetColor[1] - ytAmbientState.currentColor[1]) * 0.15; ytAmbientState.currentColor[2] += (ytAmbientState.targetColor[2] - ytAmbientState.currentColor[2]) * 0.15; const r = Math.min(255, Math.max(0, Math.round(ytAmbientState.currentColor[0]))); const g = Math.min(255, Math.max(0, Math.round(ytAmbientState.currentColor[1]))); const b = Math.min(255, Math.max(0, Math.round(ytAmbientState.currentColor[2]))); const ctx = canvas.getContext('2d'); if (!ctx) return; // Render radial ambient wash over canvas ctx.save(); const cx = curW / 2; const cy = curH / 2; const maxR = Math.max(curW, curH) * 0.85; const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR); grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.95)`); grad.addColorStop(0.4, `rgba(${Math.round(r * 0.75)}, ${Math.round(g * 0.75)}, ${Math.round(b * 0.75)}, 0.7)`); grad.addColorStop(0.8, `rgba(${Math.round(r * 0.3)}, ${Math.round(g * 0.3)}, ${Math.round(b * 0.3)}, 0.4)`); grad.addColorStop(1, 'rgba(5, 5, 10, 0.95)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, curW, curH); ctx.restore(); }; const startAmbientLoop = () => { if (ytAmbientState.rafId) return; const loop = () => { if (!ytAmbientState.isPlaying || ytAmbientState.videoId !== videoId) { ytAmbientState.rafId = null; return; } renderAmbientFrame(); ytAmbientState.rafId = window.requestAnimFrame(loop); }; ytAmbientState.rafId = window.requestAnimFrame(loop); }; // 4. Connect to YouTube player via official API and postMessage const sendPostMsg = (msg) => { if (!ytEmbed.contentWindow) return; try { ytEmbed.contentWindow.postMessage(typeof msg === 'string' ? msg : JSON.stringify(msg), '*'); } catch (e) {} }; const registerListeners = () => { // YouTube postMessage handshake sendPostMsg({ event: 'listening', id: ytEmbed.id || 1 }); sendPostMsg({ event: 'command', func: 'addEventListener', args: ['onStateChange'] }); }; ytEmbed.addEventListener('load', registerListeners); registerListeners(); setTimeout(registerListeners, 500); setTimeout(registerListeners, 1500); // Also attach YouTube IFrame API if available const tryAttachYTPlayer = () => { if (window.YT && window.YT.Player) { try { new window.YT.Player(ytEmbed, { events: { onStateChange: (event) => { if (event.data === 1) { // PLAYING ytAmbientState.isPlaying = true; startAmbientLoop(); } else if (event.data === 2 || event.data === 0) { ytAmbientState.isPlaying = false; } } } }); } catch (e) {} } }; if (window.YT && window.YT.Player) { tryAttachYTPlayer(); } else if (!document.querySelector('script[src*="youtube.com/iframe_api"]')) { const tag = document.createElement('script'); tag.src = "https://www.youtube.com/iframe_api"; tag.onload = () => setTimeout(tryAttachYTPlayer, 200); document.head.appendChild(tag); } else { setTimeout(tryAttachYTPlayer, 1000); } const onMessage = (event) => { if (typeof event.origin !== 'string' || !event.origin.includes('youtube.com')) return; let data; try { data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; } catch (e) { return; } if (!data) return; let state = undefined; if (data.event === 'onStateChange') { state = data.info; } else if (data.event === 'infoDelivery' && data.info && data.info.playerState !== undefined) { state = data.info.playerState; } if (state === 1) { // PLAYING ytAmbientState.isPlaying = true; startAmbientLoop(); } else if (state === 2 || state === 0) { // PAUSED or ENDED ytAmbientState.isPlaying = false; } if (data.event === 'infoDelivery' && data.info && typeof data.info.currentTime === 'number') { ytAmbientState.currentTime = data.info.currentTime; ytAmbientState.lastTimeUpdate = performance.now(); if (!ytAmbientState.isPlaying) { renderAmbientFrame(); } } }; window.addEventListener('message', onMessage); ytAmbientState.cleanup = () => { window.removeEventListener('message', onMessage); ytEmbed.removeEventListener('load', registerListeners); }; }; // Destroy / stop background canvas instance and animation loops window.destroyBackgroundInstance = () => { if (bgRafId) { window.cancelAnimFrame(bgRafId); bgRafId = null; } if (visualizerRafId) { window.cancelAnimFrame(visualizerRafId); visualizerRafId = null; } stopYoutubeAmbient(); const canvas = document.getElementById('bg'); if (canvas) { canvas._bgFadingOut = false; canvas.classList.remove('fader-in'); canvas.classList.add('fader-out'); const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); } } }; // Paint background canvas immediately from thumbnail without network or animation delay window.paintImmediateBgThumb = (thumbOrElem) => { if (typeof background !== 'undefined' && !background) return; const canvas = document.getElementById('bg'); if (!canvas) return; let img = null; if (thumbOrElem instanceof HTMLImageElement) { img = thumbOrElem; } else if (thumbOrElem && typeof thumbOrElem.querySelector === 'function') { img = thumbOrElem.querySelector('img'); } if (!img) { const activeEl = document.querySelector('.posts > a.thumb.onara-active img, .posts > a.thumb:focus img'); if (activeEl) img = activeEl; } if (!img || !img.complete || (img.naturalWidth === 0 && img.width === 0)) return; const SCALE = 0.5; const cw = Math.max(1, (canvas.clientWidth * SCALE) | 0); const ch = Math.max(1, (canvas.clientHeight * SCALE) | 0); if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; } canvas._bgFadingOut = false; canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); canvas.style.transition = 'none'; canvas.style.opacity = 'var(--bg-canvas-opacity, 1.0)'; const ctx = canvas.getContext('2d'); if (!ctx) return; const cfg = (typeof window.audioVisualizerTuning !== 'undefined' && window.audioVisualizerTuning) || (typeof DEFAULT_AUDIO_TUNING !== 'undefined' ? DEFAULT_AUDIO_TUNING : {}); const method = cfg.bgBlurMethod !== undefined ? Number(cfg.bgBlurMethod) : 0; const bSetting = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 30; const bPx = method === 1 ? 0 : Math.round(bSetting * SCALE); const bVal = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 0.45; const sat = cfg.bgCanvasSaturate !== undefined ? Number(cfg.bgCanvasSaturate) : 1.8; const con = cfg.bgCanvasContrast !== undefined ? Number(cfg.bgCanvasContrast) : 1.1; const parts = []; if (bPx > 0) parts.push(`blur(${bPx}px)`); if (bVal !== 1) parts.push(`brightness(${bVal})`); if (sat !== 1) parts.push(`saturate(${sat})`); if (con !== 1) parts.push(`contrast(${con})`); const filterStr = parts.length > 0 ? parts.join(' ') : 'none'; ctx.clearRect(0, 0, cw, ch); ctx.save(); ctx.filter = filterStr; let sw = img.naturalWidth || img.width; let sh = img.naturalHeight || img.height; if (sw > 0 && sh > 0) { const scale = Math.max(cw / sw, ch / sh); const dw = sw * scale; const dh = sh * scale; const dx = (cw - dw) / 2; const dy = (ch - dh) / 2; ctx.drawImage(img, dx, dy, dw, dh); } else { ctx.drawImage(img, 0, 0, cw, ch); } ctx.restore(); // Color overlay tint if configured const colOp = cfg.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.bgCanvasColorOpacity))) : 0; if (colOp > 0.001) { const col = cfg.bgCanvasColor || '#000000'; ctx.save(); ctx.fillStyle = colOp >= 0.999 ? col : `color-mix(in srgb, ${col} ${Math.round(colOp * 100)}%, transparent)`; ctx.fillRect(0, 0, cw, ch); ctx.restore(); } }; // Export init function for dynamic calls window.initBackground = () => { // Media selection priority let elem = null; if (document.body.classList.contains('onara-modal-open')) { const mount = document.getElementById('onara-item-mount'); if (mount) { // For albums: prioritize visible video/audio over the always-present const albumVidWrap = mount.querySelector('#f0ck-album-video-wrapper'); const albumAudWrap = mount.querySelector('#f0ck-album-audio-wrapper, #f0ck-album-audio-container'); if (albumVidWrap && albumVidWrap.style.display !== 'none') { elem = mount.querySelector('#f0ck-album-video'); } else if (albumAudWrap && albumAudWrap.style.display !== 'none') { elem = mount.querySelector('#f0ck-album-audio'); } if (!elem) { elem = mount.querySelector("#my-video, video, audio, img, ruffle-player"); } } } if (!elem) { elem = document.querySelector("#my-video"); } if (!elem) { // Album items use different IDs for video/audio const albumVideo = document.getElementById('f0ck-album-video'); const albumVideoWrapper = document.getElementById('f0ck-album-video-wrapper'); if (albumVideo && albumVideo.src && albumVideoWrapper && albumVideoWrapper.style.display !== 'none') { elem = albumVideo; } if (!elem) { const albumAudio = document.getElementById('f0ck-album-audio'); if (albumAudio && albumAudio.src) { elem = albumAudio; } } } if (!elem) { const rp = document.querySelector('ruffle-player'); if (rp) { elem = rp.shadowRoot ? rp.shadowRoot.querySelector('canvas') : null; if (!elem) { // If we have a player but no canvas yet, it's likely still initializing. // Re-init background in a moment. setTimeout(window.initBackground, 200); return; } } } // Audio items: don't swap to cover art — the visualizer handles #bg directly if (!elem) { elem = document.querySelector("#f0ck-image"); } const canvas = document.getElementById('bg'); if (elem) { if (canvas) { // Restore visual state on re-init if (background && elem.tagName !== 'AUDIO') { canvas._bgFadingOut = false; canvas.classList.add('fader-in'); canvas.classList.remove('fader-out', 'fast-fade'); } else { // Don't clear the canvas here — let the existing content fade out. canvas._bgFadingOut = true; canvas.classList.add('fader-out'); canvas.classList.remove('fader-in', 'fast-fade'); const stopOnFadeEnd = (ev) => { if (ev.propertyName === 'opacity') { canvas._bgFadingOut = false; canvas.removeEventListener('transitionend', stopOnFadeEnd); } }; canvas.addEventListener('transitionend', stopOnFadeEnd); return; // nothing more to do — let CSS do the fade } const SCALE = 0.5; const cw = Math.max(1, (canvas.clientWidth * SCALE) | 0); const ch = Math.max(1, (canvas.clientHeight * SCALE) | 0); // Capture previous canvas snapshot for buttery-smooth crossfade transition let prevSnapshot = null; if (canvas.width > 0 && canvas.height > 0) { try { prevSnapshot = document.createElement('canvas'); prevSnapshot.width = canvas.width; prevSnapshot.height = canvas.height; const pCtx = prevSnapshot.getContext('2d'); pCtx.drawImage(canvas, 0, 0); } catch (e) { prevSnapshot = null; } } if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; } const context = canvas.getContext('2d'); const cfgCurrent = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; const blurSetting = cfgCurrent.bgCanvasBlur !== undefined ? Number(cfgCurrent.bgCanvasBlur) : 30; const blurPx = Math.round(blurSetting * SCALE); const brightnessVal = cfgCurrent.bgCanvasBrightness !== undefined ? Number(cfgCurrent.bgCanvasBrightness) : 0.45; const getCanvasFilter = () => { const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; const method = cfg.bgBlurMethod !== undefined ? Number(cfg.bgBlurMethod) : 0; const bSetting = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 30; // Method 1 = GPU CSS filter only (no canvas blur, handled by CSS --bg-canvas-filter) const bPx = method === 1 ? 0 : Math.round(bSetting * SCALE); const bVal = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 0.45; const sat = cfg.bgCanvasSaturate !== undefined ? Number(cfg.bgCanvasSaturate) : 1.8; const con = cfg.bgCanvasContrast !== undefined ? Number(cfg.bgCanvasContrast) : 1.1; const parts = []; if (bPx > 0) parts.push(`blur(${bPx}px)`); if (bVal !== 1) parts.push(`brightness(${bVal})`); if (sat !== 1) parts.push(`saturate(${sat})`); if (con !== 1) parts.push(`contrast(${con})`); return parts.length > 0 ? parts.join(' ') : 'none'; }; const drawCoverImage = (ctx, source) => { if (!source) return; let sw = 0, sh = 0; if (source.videoWidth && source.videoHeight) { sw = source.videoWidth; sh = source.videoHeight; } else if (source.naturalWidth && source.naturalHeight) { sw = source.naturalWidth; sh = source.naturalHeight; } else if (source.width && source.height) { sw = source.width; sh = source.height; } else if (source.clientWidth && source.clientHeight) { sw = source.clientWidth; sh = source.clientHeight; } if (sw > 0 && sh > 0) { const scale = Math.max(cw / sw, ch / sh); const dw = sw * scale; const dh = sh * scale; const dx = (cw - dw) / 2; const dy = (ch - dh) / 2; ctx.drawImage(source, dx, dy, dw, dh); } else { ctx.drawImage(source, 0, 0, cw, ch); } }; const applyBgColorOverlay = (ctx, w, h) => { const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; const colOp = cfg.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.bgCanvasColorOpacity))) : 0; if (colOp > 0.001) { const col = cfg.bgCanvasColor || '#000000'; ctx.save(); ctx.fillStyle = colOp >= 0.999 ? col : `color-mix(in srgb, ${col} ${Math.round(colOp * 100)}%, transparent)`; ctx.fillRect(0, 0, w, h); ctx.restore(); } }; let crossfadeRafId = null; const crossfadeToSource = (sourceImg, onComplete) => { if (crossfadeRafId) window.cancelAnimFrame(crossfadeRafId); if (!prevSnapshot) { context.clearRect(0, 0, cw, ch); context.save(); context.filter = getCanvasFilter(); drawCoverImage(context, sourceImg); context.restore(); applyBgColorOverlay(context, cw, ch); if (onComplete) onComplete(); return; } const startTime = performance.now(); const duration = 350; // ms const step = (now) => { const elapsed = now - startTime; const progress = Math.min(1.0, elapsed / duration); const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress; context.clearRect(0, 0, cw, ch); // 1. Fade out previous background if (ease < 1.0 && prevSnapshot) { context.save(); context.globalAlpha = 1.0 - ease; context.drawImage(prevSnapshot, 0, 0, cw, ch); context.restore(); } // 2. Fade in new background with blur and brightness context.save(); context.globalAlpha = ease; context.filter = getCanvasFilter(); try { drawCoverImage(context, sourceImg); } catch (e) {} context.restore(); // 3. Color overlay tint if configured applyBgColorOverlay(context, cw, ch); if (progress < 1.0) { crossfadeRafId = window.requestAnimFrame(step); } else { crossfadeRafId = null; prevSnapshot = null; if (onComplete) onComplete(); } }; crossfadeRafId = window.requestAnimFrame(step); }; const drawOnce = () => { if (!background || !context) return; const itemId = window.getCurrentItemId(); const isDrawable = elem && elem.tagName === 'IMG'; if (itemId) { const thumb = new Image(); thumb.onload = () => { crossfadeToSource(thumb, () => { if (isDrawable) { if (elem.complete && elem.naturalWidth > 0) { crossfadeToSource(elem); } else { elem.onload = () => crossfadeToSource(elem); } } }); }; thumb.onerror = () => { if (isDrawable) { if (elem.complete && elem.naturalWidth > 0) { crossfadeToSource(elem); } else { elem.onload = () => crossfadeToSource(elem); } } }; let newSrc = `/t/${itemId}.webp`; if (window.applyThumbCacheBust) newSrc = window.applyThumbCacheBust(newSrc); thumb.src = newSrc; } else if (isDrawable) { if (elem.complete && elem.naturalWidth > 0) { crossfadeToSource(elem); } else { elem.onload = () => crossfadeToSource(elem); } } }; window.redrawBgCanvas = drawOnce; let videoFadeStartTime = prevSnapshot ? performance.now() : 0; const animationLoop = () => { if (!elem || elem.tagName === 'AUDIO' || elem.paused || elem.ended || (!background && !canvas._bgFadingOut)) { bgRafId = null; return; } try { context.clearRect(0, 0, cw, ch); if (prevSnapshot) { const elapsed = performance.now() - videoFadeStartTime; const progress = Math.min(1.0, elapsed / 350); const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress; if (ease < 1.0) { context.save(); context.globalAlpha = 1.0 - ease; context.drawImage(prevSnapshot, 0, 0, cw, ch); context.restore(); } context.save(); context.globalAlpha = ease; context.filter = getCanvasFilter(); try { drawCoverImage(context, elem); } catch (e) {} context.restore(); if (progress >= 1.0) { prevSnapshot = null; } } else { // Video already loaded or resumed from pause: render directly at 100% opacity without fading from black context.save(); context.filter = getCanvasFilter(); try { drawCoverImage(context, elem); } catch (e) {} context.restore(); } // Color overlay tint if configured applyBgColorOverlay(context, cw, ch); } catch (e) { bgRafId = null; return; } bgRafId = window.requestAnimFrame(animationLoop); }; // Singleton: Ensure only one listener and one loop per element const isAlbumVideo = elem.id === 'f0ck-album-video'; if (lastBgElem !== elem || isAlbumVideo) { if (bgRafId) window.cancelAnimFrame(bgRafId); lastBgElem = elem; if (prevSnapshot) videoFadeStartTime = performance.now(); if (elem.tagName === 'VIDEO') { // For album videos (reused element), remove old listeners first const startVideoLoop = () => { if (bgRafId) window.cancelAnimFrame(bgRafId); // Only reset fade start time if a previous snapshot is actively waiting to be crossfaded if (prevSnapshot) videoFadeStartTime = performance.now(); if (background) animationLoop(); }; if (isAlbumVideo) { // Clean up previous listeners via stored reference if (elem._bgPlayHandler) elem.removeEventListener('play', elem._bgPlayHandler); if (elem._bgCanplayHandler) elem.removeEventListener('canplay', elem._bgCanplayHandler); elem._bgPlayHandler = startVideoLoop; elem._bgCanplayHandler = startVideoLoop; elem.addEventListener('play', startVideoLoop); elem.addEventListener('canplay', startVideoLoop); } else { elem.addEventListener('play', startVideoLoop); } } else if (elem.tagName === 'CANVAS') { // Ruffle canvas: start loop immediately if (bgRafId) window.cancelAnimFrame(bgRafId); if (prevSnapshot) videoFadeStartTime = performance.now(); if (background) animationLoop(); } else if (elem.tagName === 'AUDIO') { elem.addEventListener('play', () => { if (background) { canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); } if (window.initVisualizer) window.initVisualizer(elem); }); elem.addEventListener('playing', () => { if (background) { canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); } if (window.initVisualizer) window.initVisualizer(elem); }); } } if (elem.tagName === 'VIDEO') { if (background) { if (!elem.paused) { if (bgRafId) window.cancelAnimFrame(bgRafId); animationLoop(); } // For album videos that haven't started yet, drawOnce with the thumbnail else if (isAlbumVideo) { drawOnce(); } } } else if (elem.tagName === 'CANVAS') { if (background) { if (bgRafId) window.cancelAnimFrame(bgRafId); animationLoop(); } } else if (elem.tagName === 'IMG') { // 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) { canvas.classList.add('fader-out'); canvas.classList.remove('fader-in', 'fast-fade'); const clearOnFade = (ev) => { if (ev.propertyName === 'opacity') { const ctx = canvas.getContext('2d'); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); canvas.removeEventListener('transitionend', clearOnFade); } }; canvas.addEventListener('transitionend', clearOnFade); } if (window.initVisualizer) { window.initVisualizer(elem); } } } } else if (canvas) { const mount = document.getElementById('onara-item-mount'); const ytEmbed = (mount && mount.querySelector('#yt-embed')) || document.getElementById('yt-embed'); if (ytEmbed) { if (background) { canvas._bgFadingOut = false; initYoutubeAmbient(ytEmbed, canvas, true); } else { stopYoutubeAmbient(); canvas._bgFadingOut = true; canvas.classList.add('fader-out'); canvas.classList.remove('fader-in', 'fast-fade'); } } else { stopYoutubeAmbient(); // No drawable element (e.g. generic file) — still handle canvas fade toggle if (background) { canvas._bgFadingOut = false; // Draw the item thumbnail if we have an item ID in the URL const itemId = window.getCurrentItemId(); if (itemId) { const context = canvas.getContext('2d'); const _SCALE = 0.25; const cw = canvas.width = Math.max(1, (canvas.clientWidth * _SCALE) | 0); const ch = canvas.height = Math.max(1, (canvas.clientHeight * _SCALE) | 0); const blurPx = Math.round(100 * _SCALE) || 1; const thumb = new Image(); thumb.onload = () => { try { context.filter = `blur(${blurPx}px)`; const sw = thumb.naturalWidth || thumb.width; const sh = thumb.naturalHeight || thumb.height; if (sw > 0 && sh > 0) { const scale = Math.max(cw / sw, ch / sh); const dw = sw * scale; const dh = sh * scale; context.drawImage(thumb, (cw - dw) / 2, (ch - dh) / 2, dw, dh); } else { context.drawImage(thumb, 0, 0, cw, ch); } context.filter = 'none'; } catch (e) {} canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); }; thumb.src = `/t/${itemId}.webp`; } else { canvas.classList.remove('fader-out', 'fast-fade'); canvas.classList.add('fader-in'); } } else { canvas._bgFadingOut = true; canvas.classList.add('fader-out'); canvas.classList.remove('fader-in', 'fast-fade'); const stopOnFadeEnd = (ev) => { if (ev.propertyName === 'opacity') { canvas._bgFadingOut = false; canvas.removeEventListener('transitionend', stopOnFadeEnd); } }; canvas.addEventListener('transitionend', stopOnFadeEnd); } } } }; // Audio Visualizer Reactivity Tuner const DEFAULT_AUDIO_TUNING = { useCustomColor: 0, visualizerColor: "#241f31", enableBeatHue: 0, beatHueThreshold: 0.3, beatHueStep: 45, beatHueSmooth: 0.65, beatHueIdleDrift: 0.2, beatHueCooldown: 150, coverSize: 110, glowIntensity: 500, coverGlowBase: 128, glowBrightness: 2.05, glowSensitivity: 1, glowDynamism: 0.85, glowSmoothness: 0.95, solidCover: 1, coverColor: "#000000", coverOpacity: 0, bassGain: 0.5, bassPower: 0.3, scaleBounce: 0.85, bounceBoost: 1, attackSpeed: 1, releaseSpeed: 0.6, enableBars: 0, enableInnerBars: 1, useCustomInnerColor: 0, innerBarColor: "#ff0000", innerBarsMode: 4, innerRadius: 152, innerPupilRadius: 42, innerPupilRingOpacity: 0.99, innerPupilColor: "#000000", enableVoidTunnel: 0, voidTunnelRings: 10, voidTunnelSpeed: 0.6, voidTunnelReactivity: 3.0, voidTunnelFadeWidth: 0.35, voidTunnelUseAccent: 1, voidTunnelColor: "#ffffff", voidTunnelZone: 0, voidTunnelRingWidth: 2.5, voidTunnelEdgeFade: 0.35, voidTunnelOpacity: 1.0, voidTunnelDepth: 0.65, voidTunnelZoomSpeed: 0.5, // Square Corridor Tunnel enableSquareTunnel: 0, squareTunnelSpeed: 0.4, squareTunnelReactivity: 1.5, squareTunnelLevels: 6, squareTunnelOpacity: 1.0, squareTunnelGridWidth: 1.5, squareTunnelWallFillAlpha: 1, squareTunnelFog: 0.75, squareTunnelGlow: 12, squareTunnelWarp: 2.0, squareTunnelUseAccent: 1, squareTunnelGridColor: '#ffffff', squareTunnelRoomScale: 1.5, squareTunnelFOV: 0.55, squareTunnelHideHUD: 0, squareTunnelWallMode: 0, squareTunnelWallColor: '#1a1a2e', squareTunnelWallColor2: '#000000', squareTunnelBackdropColor: '#000000', squareTunnelBackdropBrightness: 0.55, squareTunnelPatternScale: 40, squareTunnelPatTileAlpha: 1, squareTunnelMaze: 0, squareTunnelTurnStrength: 0.6, squareTunnelTurnSpeed: 0.04, squareTunnelStraightBias: 0.6, squareTunnelDriftFreq: 1.0, squareTunnelReactDrift: 0.0, squareTunnelReactScale: 0.18, squareTunnelReactBright: 0.4, innerBarCount: 21, innerBarWidth: 12, innerBarHeight: 0.85, innerBarOpacity: 0.36, outerRingOpacity: 0, eyeDisableGlow: 0, eyeDisableShadow: 0, eyeFOV: 600, eyeZPos: 0, innerHighBoost: 0.34, innerRadialRotation: 90, innerBarGlow: 0, innerHideNote: 1, barHeight: 0.8, barWidth: 6, barGap: 3, barRadius: 3, barOpacity: 0.85, barGlow: 8, smoothing: 0.86, followMouse: 1, followSpeed: 0.02, followRadius: 10, tiltEffect: 1, glowAttack: 1, glowDecay: 0.5, enableBgGradient: 1, bgGradientStyle: 1, bgGradientOpacity: 0.4, bgGradientReactivity: 1.2, bgGradientSpread: 75, useCustomBgColor: 0, bgGradientColor: "#3a1c71", enableBlink: 0, blinkInterval: 12, beatHueSpeed: 0.1, onaraBgOpacity: 0.7, onaraBackdropBlur: 5, bgCanvasColor: '#000000', bgCanvasColorOpacity: 0, bgCanvasOpacity: 1, bgBlurMethod: 0, bgCanvasBlur: 30, bgCanvasBrightness: 0.45, bgCanvasSaturate: 1.8, bgCanvasContrast: 1.1, bgCanvasZoom: 1.12, bgCanvasScale: 3, onaraGridDim: 0.38, feedAudioToBg: 0 }; const TUNING_CONFIG_VERSION = '2026-09-16_tuner_v7'; const getCustomDefaultTuning = () => { try { const stored = localStorage.getItem('f0ck_audio_default_custom'); if (stored) { const parsed = JSON.parse(stored); if (parsed && typeof parsed === 'object') return parsed; } } catch (e) {} if (window.f0ckServerAudioTuner && typeof window.f0ckServerAudioTuner === 'object') { return window.f0ckServerAudioTuner; } return null; }; const getEffectiveDefaultTuning = () => { const custom = getCustomDefaultTuning(); return Object.assign({}, DEFAULT_AUDIO_TUNING, custom || {}); }; window.DEFAULT_AUDIO_TUNING = DEFAULT_AUDIO_TUNING; window.getEffectiveDefaultTuning = getEffectiveDefaultTuning; let savedTuning = null; try { const appliedVer = localStorage.getItem('f0ck_audio_tuning_ver'); if (appliedVer !== TUNING_CONFIG_VERSION) { const eff = getEffectiveDefaultTuning(); localStorage.setItem('f0ck_audio_tuning', JSON.stringify(eff)); localStorage.setItem('f0ck_audio_tuning_ver', TUNING_CONFIG_VERSION); savedTuning = Object.assign({}, eff); } else { const raw = localStorage.getItem('f0ck_audio_tuning'); if (raw) savedTuning = JSON.parse(raw); } } catch (e) {} window.audioVisualizerTuning = Object.assign({}, getEffectiveDefaultTuning(), savedTuning || {}); const applyBackgroundOpacitySettings = (cfg) => { const c = cfg || window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; const onaraOp = c.onaraBgOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.onaraBgOpacity))) : 0.70; const bgOp = c.bgCanvasOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.bgCanvasOpacity))) : 1.00; const gridThumbOp = c.onaraGridDim !== undefined ? Math.min(1, Math.max(0, Number(c.onaraGridDim))) : 0.38; const onaraBlur = c.onaraBackdropBlur !== undefined ? Math.max(0, Number(c.onaraBackdropBlur)) : 5; const bgBlur = c.bgCanvasBlur !== undefined ? Math.max(0, Number(c.bgCanvasBlur)) : 30; const method = c.bgBlurMethod !== undefined ? Number(c.bgBlurMethod) : 0; const zoom = c.bgCanvasZoom !== undefined ? Math.max(1.0, Number(c.bgCanvasZoom)) : 1.12; const bgColor = c.bgCanvasColor || '#000000'; const bgColOp = c.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.bgCanvasColorOpacity))) : 0.0; 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)); document.documentElement.style.setProperty('--bg-canvas-opacity', bgOp.toFixed(3)); document.documentElement.style.setProperty('--onara-grid-dim-opacity', gridThumbOp.toFixed(3)); document.documentElement.style.setProperty('--onara-backdrop-blur', `${onaraBlur}px`); document.documentElement.style.setProperty('--bg-canvas-blur', `${bgBlur}px`); document.documentElement.style.setProperty('--bg-canvas-zoom', zoom.toFixed(3)); document.documentElement.style.setProperty('--bg-canvas-color', computedBgCol); document.documentElement.style.setProperty('--bg-canvas-color-opacity', bgColOp.toFixed(3)); // Method 1 = GPU CSS filter (the classic look); Method 2 = Hybrid (CSS blur + canvas sat/contrast) if (method === 1 || method === 2) { document.documentElement.style.setProperty('--bg-canvas-filter', `blur(${bgBlur}px)`); } else { document.documentElement.style.setProperty('--bg-canvas-filter', 'none'); } const bgEl = document.getElementById('bg'); if (bgEl) { bgEl.style.removeProperty('opacity'); } if (typeof window.redrawBgCanvas === 'function') { window.redrawBgCanvas(); } }; window.applyBackgroundOpacitySettings = applyBackgroundOpacitySettings; applyBackgroundOpacitySettings(window.audioVisualizerTuning); // Eye Cursor Follower & 3D Tracking const mousePos = { x: null, y: null, active: false }; let isEyeActive = false; let eyeInactivityTimer = null; const isCursorNearV0ck = (e) => { const margin = 10; const players = document.querySelectorAll('.v0ck, .sidebar-media-placeholder.audio'); for (const p of players) { const r = p.getBoundingClientRect(); if (r.width > 0 && r.height > 0 && e.clientX >= r.left - margin && e.clientX <= r.right + margin && e.clientY >= r.top - margin && e.clientY <= r.bottom + margin) { return true; } } return false; }; const handlePointerMove = (e) => { mousePos.x = e.clientX; mousePos.y = e.clientY; const nearPlayer = isCursorNearV0ck(e); if (nearPlayer) { isEyeActive = true; mousePos.active = true; clearTimeout(eyeInactivityTimer); eyeInactivityTimer = setTimeout(() => { isEyeActive = false; mousePos.active = false; }, 2500); } else { // More than 10px outside of player: stop following and return to center isEyeActive = false; mousePos.active = false; clearTimeout(eyeInactivityTimer); } }; window.addEventListener('pointermove', handlePointerMove, { passive: true }); window.addEventListener('pointerdown', (e) => { if (isCursorNearV0ck(e)) handlePointerMove(e); }, { passive: true }); document.addEventListener('mouseleave', () => { isEyeActive = false; mousePos.active = false; clearTimeout(eyeInactivityTimer); }); window.addEventListener('pointerup', () => { if (window.matchMedia && window.matchMedia('(hover: none)').matches) { isEyeActive = false; mousePos.active = false; clearTimeout(eyeInactivityTimer); } }, { passive: true }); const updateEyeTracking = () => { const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; const isEnabled = cfg.followMouse !== undefined ? cfg.followMouse === 1 : true; const speed = cfg.followSpeed !== undefined ? cfg.followSpeed : 0.04; const allowTilt = cfg.tiltEffect !== undefined ? cfg.tiltEffect === 1 : true; const circles = document.querySelectorAll('.sidebar-media-placeholder.audio .audio-cover-circle'); circles.forEach(circle => { const ph = circle.closest('.sidebar-media-placeholder.audio'); if (!ph) return; const rect = ph.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const v0ckPlayer = ph.closest('.v0ck') || ph; const pRect = v0ckPlayer.getBoundingClientRect(); const margin = 10; const isNearThisPlayer = ( mousePos.x !== null && mousePos.y !== null && mousePos.x >= pRect.left - margin && mousePos.x <= pRect.right + margin && mousePos.y >= pRect.top - margin && mousePos.y <= pRect.bottom + margin ); let isCursorHiddenOnPlayer = false; if (v0ckPlayer && !v0ckPlayer.classList.contains('v0ck_hover')) { if (mousePos.active && mousePos.x >= pRect.left && mousePos.x <= pRect.right && mousePos.y >= pRect.top && mousePos.y <= pRect.bottom) { isCursorHiddenOnPlayer = true; } } let targetX = 0; let targetY = 0; let tiltX = 0; let tiltY = 0; if (isEnabled && isEyeActive && mousePos.active && isNearThisPlayer && !isCursorHiddenOnPlayer) { const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const dx = mousePos.x - centerX; const dy = mousePos.y - centerY; const maxRadius = cfg.followRadius !== undefined ? cfg.followRadius : 20; const dist = Math.hypot(dx, dy); if (dist > 0) { const clampedDist = Math.min(dist, maxRadius); targetX = (dx / dist) * clampedDist; targetY = (dy / dist) * clampedDist; } if (allowTilt) { tiltX = -(targetY / (maxRadius || 1)) * 20; tiltY = (targetX / (maxRadius || 1)) * 20; } } circle._eyeX = circle._eyeX || 0; circle._eyeY = circle._eyeY || 0; circle._eyeTiltX = circle._eyeTiltX || 0; circle._eyeTiltY = circle._eyeTiltY || 0; circle._eyeX += (targetX - circle._eyeX) * speed; circle._eyeY += (targetY - circle._eyeY) * speed; circle._eyeTiltX += (tiltX - circle._eyeTiltX) * speed; circle._eyeTiltY += (tiltY - circle._eyeTiltY) * speed; // If audio visualizer is NOT actively driving the transform this frame, update it here if (!circle._visualizerDriving) { const curX = circle._eyeX.toFixed(1); const curY = circle._eyeY.toFixed(1); const curTiltX = circle._eyeTiltX.toFixed(1); const curTiltY = circle._eyeTiltY.toFixed(1); circle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(600px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(1)`; } }); requestAnimationFrame(updateEyeTracking); }; requestAnimationFrame(updateEyeTracking); const initAudioTunerUI = () => { if (document.getElementById('f0ck-audio-tuner-panel')) return; const sidebarContainer = document.getElementById('sidebar-tuner-container'); const panel = document.createElement('div'); panel.id = 'f0ck-audio-tuner-panel'; panel.className = sidebarContainer ? 'f0ck-audio-tuner-panel in-sidebar' : 'f0ck-audio-tuner-panel hidden'; const audioSliders = [ // Visualizer Color Section { section: 'Visualizer Color & Theme', key: 'useCustomColor', label: 'Use Custom Color (1=Custom, 0=Site Accent)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Visualizer Color & Theme', key: 'visualizerColor', label: 'Custom Visualizer Color', type: 'color' }, { section: 'Visualizer Color & Theme', key: 'enableBeatHue', label: 'Beat-Reactive Hue Color (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Visualizer Color & Theme', key: 'beatHueThreshold', label: 'Beat Trigger Threshold', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Visualizer Color & Theme', key: 'beatHueStep', label: 'Color Shift Angle per Beat', min: 1, max: 180, step: 1, unit: '°' }, { section: 'Visualizer Color & Theme', key: 'beatHueSmooth', label: 'Color Morph Smoothness (0=Snap, 1=Glide)', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Visualizer Color & Theme', key: 'beatHueIdleDrift', label: 'Idle Ambient Hue Drift', min: 0.00, max: 2.00, step: 0.01, unit: 'x' }, { section: 'Visualizer Color & Theme', key: 'beatHueCooldown', label: 'Min Time Between Jumps', min: 50, max: 800, step: 25, unit: 'ms' }, // Cover Art & Glow Section { section: 'Cover Art & Glow Reactivity', key: 'coverSize', label: 'Cover Art Size (Diameter)', min: 40, max: 600, step: 5, unit: 'px' }, { section: 'Cover Art & Glow Reactivity', key: 'solidCover', label: 'Solid Color Cover (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'coverColor', label: 'Solid Cover Color', type: 'color' }, { section: 'Cover Art & Glow Reactivity', key: 'coverOpacity', label: 'Cover / Disc Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'glowIntensity', label: 'Cover Art Glow Size (Dynamic)', min: 0, max: 500, step: 5, unit: 'px' }, { section: 'Cover Art & Glow Reactivity', key: 'coverGlowBase', label: 'Cover Art Base Glow (Ambient)', min: 0, max: 150, step: 2, unit: 'px' }, { section: 'Cover Art & Glow Reactivity', key: 'glowBrightness', label: 'Cover Art Glow Brightness', min: 0.0, max: 3.0, step: 0.05, unit: 'x' }, { section: 'Cover Art & Glow Reactivity', key: 'glowSensitivity', label: 'Glow Beat Reactivity / Sensitivity', min: 0.10, max: 5.00, step: 0.05, unit: 'x' }, { section: 'Cover Art & Glow Reactivity', key: 'glowDynamism', label: 'Glow Dynamism (Dynamic Punch)', min: 0.00, max: 3.00, step: 0.05, unit: 'x' }, { section: 'Cover Art & Glow Reactivity', key: 'glowSmoothness', label: 'Glow Smoothness (Liquid Glow)', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'scaleBounce', label: 'Disc Bounce (Scale)', min: 0.00, max: 5.00, step: 0.05, unit: 'x' }, { section: 'Cover Art & Glow Reactivity', key: 'bounceBoost', label: 'Bounce Reactivity Boost', min: 1.0, max: 100.0, step: 0.5, unit: 'x' }, { section: 'Cover Art & Glow Reactivity', key: 'bassGain', label: 'Bass Gain (Multiplier)', min: 0.5, max: 6.0, step: 0.1, unit: 'x' }, { section: 'Cover Art & Glow Reactivity', key: 'bassPower', label: 'Sensitivity Curve (Power)', min: 0.10, max: 2.00, step: 0.01, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'attackSpeed', label: 'Attack Speed (Snap)', min: 0.05, max: 1.00, step: 0.01, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'releaseSpeed', label: 'Release Speed (Decay)', min: 0.01, max: 1.00, step: 0.01, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'glowAttack', label: 'Glow Attack Speed (Swell)', min: 0.01, max: 1.00, step: 0.01, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'glowDecay', label: 'Glow Decay Speed (Dissipate)', min: 0.01, max: 1.00, step: 0.01, unit: '' }, // Reactive Background Gradient Section { section: 'Reactive Background Gradient', key: 'enableBgGradient', label: 'Background Gradient (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Reactive Background Gradient', key: 'bgGradientStyle', label: 'Gradient Style (1=Radial Aura, 2=Dual Fog, 3=Conic Vortex, 4=Horizon Flare)', min: 1, max: 4, step: 1, unit: '' }, { section: 'Reactive Background Gradient', key: 'bgGradientOpacity', label: 'Gradient Base Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Reactive Background Gradient', key: 'bgGradientReactivity', label: 'Beat Reactivity (Pulse Strength)', min: 0.00, max: 3.00, step: 0.05, unit: 'x' }, { section: 'Reactive Background Gradient', key: 'bgGradientSpread', label: 'Gradient Radius / Spread', min: 10, max: 200, step: 2, unit: '%' }, { section: 'Reactive Background Gradient', key: 'useCustomBgColor', label: 'Custom Gradient Color (1=On, 0=Accent)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Reactive Background Gradient', key: 'bgGradientColor', label: 'Custom Gradient Color', type: 'color' }, // Inner Eye Visualizer Section { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'enableInnerBars', label: 'Inner Visualizer (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarsMode', label: 'Inner Style (1=Center, 2=Outward Iris, 3=Arc, 4=Inverted Iris, 5=Inverted Rev, 6=Dual Stargate, 7=360° Radar)', min: 1, max: 7, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerRadius', label: 'Inner Visualizer Size (Outer Radius)', min: 30, max: 800, step: 2, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerPupilRadius', label: 'Center Pupil Gap (Inner Radius)', min: 0, max: 600, step: 2, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'useCustomInnerColor', label: 'Solid Custom Inner Color (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarColor', label: 'Solid Inner Bar Color', type: 'color' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarCount', label: 'Spectrum Density (Note Count)', min: 2, max: 512, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerHighBoost', label: 'Melody & Treble Sensitivity', min: 0.000, max: 2.000, step: 0.001, unit: 'x' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarHeight', label: 'Inner Bar Height / Scale', min: 0.05, max: 100.00, step: 0.05, unit: 'x' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarWidth', label: 'Inner Bar Width', min: 1, max: 250, step: 1, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarOpacity', label: 'Inner Bar Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'outerRingOpacity', label: 'Outer Ring Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeDisableGlow', label: 'Disable Accent Glow (1=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeDisableShadow', label: 'Disable Drop Shadow (1=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeFOV', label: 'Eye Perspective / FOV (lower=wider 3D tilt)', min: 100, max: 2000, step: 10, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeZPos', label: 'Eye Camera Distance — Z (positive=closer)', min: -200, max: 500, step: 5, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerPupilRingOpacity', label: 'Center Pupil Fill Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerPupilColor', label: 'Center Pupil Fill Color', type: 'color' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'enableVoidTunnel', label: 'Infinite Void Tunnel (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelRings', label: 'Void Tunnel Ring Count', min: 2, max: 30, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelSpeed', label: 'Void Tunnel Base Speed', min: 0.0, max: 5.0, step: 0.05, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelReactivity', label: 'Void Tunnel Audio Reactivity', min: 0.0, max: 10.0, step: 0.1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelFadeWidth', label: 'Void Tunnel Inner Fade Zone', min: 0.0, max: 1.0, step: 0.05, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelUseAccent', label: 'Void Tunnel Use Accent Color (1=Yes, 0=Custom)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelColor', label: 'Void Tunnel Custom Color', type: 'color' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelZone', label: 'Void Tunnel Zone (0=Inner Pupil, 1=Outer Iris, 2=Outer Shadow)', min: 0, max: 2, step: 1, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelRingWidth', label: 'Void Tunnel Ring Width', min: 0.1, max: 12.0, step: 0.1, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelEdgeFade', label: 'Void Tunnel Edge Fade', min: 0.0, max: 0.9, step: 0.05, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelOpacity', label: 'Void Tunnel Max Opacity', min: 0.0, max: 1.0, step: 0.01, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelDepth', label: 'Void Tunnel Depth (glow + fog + warp)', min: 0.0, max: 1.0, step: 0.05, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelZoomSpeed', label: 'Void Tunnel Zoom Speed (Zone 2)', min: 0.0, max: 5.0, step: 0.05, unit: '' }, // Square Corridor Tunnel { section: 'Square Corridor Tunnel', key: 'enableSquareTunnel', label: 'Enable Square Tunnel (1=On)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelSpeed', label: 'Fly Speed (rooms/sec)', min: 0.0, max: 12.0, step: 0.1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelReactivity', label: 'Beat Speed Boost', min: 0.0, max: 20.0, step: 0.5, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelLevels', label: 'Rooms Visible Ahead', min: 2, max: 30, step: 1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelRoomScale', label: 'Room Width (world units)', min: 0.3, max: 4.0, step: 0.05, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelFOV', label: 'Focal Length (FOV)', min: 0.05, max: 3.0, step: 0.05, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelOpacity', label: 'Opacity', min: 0.0, max: 1.0, step: 0.01, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelGridWidth', label: 'Grid Line Width', min: 0.0, max: 20.0, step: 0.5, unit: 'px' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelGlow', label: 'Grid Glow', min: 0, max: 120, step: 2, unit: 'px' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelUseAccent', label: 'Use Accent Color for Grid (1=On)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelGridColor', label: 'Grid Color (when Accent off)', type: 'color' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelFog', label: 'Depth Fog', min: 0.0, max: 1.0, step: 0.05, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelWallFillAlpha', label: 'Inner Corridor Darkness', min: 0, max: 1, step: 0.01, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelHideHUD', label: 'Hide Eye / Cover Circle HUD (1=On)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelWallMode', label: 'Wall Mode (0=Art, 1=Solid, 2=Checker, 3=V-Stripes, 4=Depth-Stripes)', min: 0, max: 4, step: 1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelWallColor', label: 'Wall Color (Primary)', type: 'color' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelWallColor2', label: 'Wall Color (Secondary / Pattern)', type: 'color' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelBackdropColor', label: 'Backdrop / Inner Color', type: 'color' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelBackdropBrightness', label: 'Backdrop Art Brightness', min: 0.0, max: 1.5, step: 0.05, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelPatternScale', label: 'Pattern Tile Size', min: 8, max: 120, step: 4, unit: 'px' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelPatTileAlpha', label: 'Pattern Tile Opacity', min: 0, max: 1, step: 0.01, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelReactScale', label: 'Beat Room Scale Boost', min: 0.0, max: 2.0, step: 0.02, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelReactBright', label: 'Beat Brightness Flash', min: 0.0, max: 3.0, step: 0.05, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelMaze', label: 'Enable Spirit Drift (1=On)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelTurnStrength', label: 'Drift Intensity', min: 0.0, max: 1.2, step: 0.05, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelTurnSpeed', label: 'Drift Tracking Speed', min: 0.01, max: 0.3, step: 0.01, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelDriftFreq', label: 'Drift Wobble Frequency', min: 0.1, max: 5.0, step: 0.1, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelReactDrift', label: 'Beat Drift Kick', min: 0.0, max: 2.0, step: 0.05, unit: '' }, { section: 'Square Corridor Tunnel', key: 'squareTunnelStraightBias', label: 'Straight-Ahead Probability', min: 0.0, max: 0.9, step: 0.05, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerRadialRotation', label: 'Radial Iris Rotation Angle', min: 0, max: 360, step: 5, unit: '°' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarGlow', label: 'Inner Bar Glow', min: 0, max: 30, step: 1, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerHideNote', label: 'Hide Music Note Icon (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, // Visualizer Bars Section { section: 'Bottom Visualizer Bars', key: 'enableBars', label: 'Show Visualizer Bars (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Bottom Visualizer Bars', key: 'barHeight', label: 'Visualizer Bar Height', min: 0.05, max: 2.50, step: 0.05, unit: 'x' }, { section: 'Bottom Visualizer Bars', key: 'barWidth', label: 'Bar Width', min: 1, max: 35, step: 1, unit: 'px' }, { section: 'Bottom Visualizer Bars', key: 'barGap', label: 'Bar Spacing (Gap)', min: 0, max: 15, step: 1, unit: 'px' }, { section: 'Bottom Visualizer Bars', key: 'barRadius', label: 'Bar Top Rounding', min: 0, max: 12, step: 1, unit: 'px' }, { section: 'Bottom Visualizer Bars', key: 'barOpacity', label: 'Bar Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Bottom Visualizer Bars', key: 'barGlow', label: 'Bar Glow Aura', min: 0, max: 30, step: 1, unit: 'px' }, { section: 'Bottom Visualizer Bars', key: 'smoothing', label: 'Visualizer Bar Smoothing', min: 0.00, max: 1.00, step: 0.01, unit: '' }, // Mouse Follow & Tilt Simulation Section { section: 'Eye Simulation & Mouse Follow', key: 'followMouse', label: 'Follow Mouse Cursor (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Eye Simulation & Mouse Follow', key: 'followSpeed', label: 'Eye Follow Speed', min: 0.01, max: 0.40, step: 0.01, unit: '' }, { section: 'Eye Simulation & Mouse Follow', key: 'followRadius', label: 'Eye Follow Range (Radius)', min: 5, max: 100, step: 1, unit: 'px' }, { section: 'Eye Simulation & Mouse Follow', key: 'tiltEffect', label: '3D Eye Tilt (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' } ]; const backgroundSliders = [ // Video & Ambient Background Canvas Section { section: 'Video & Ambient Background Canvas', key: 'bgCanvasColor', label: 'Background Color', type: 'color' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasColorOpacity', label: 'Background Color Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasOpacity', label: 'Background Canvas Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Video & Ambient Background Canvas', key: 'bgBlurMethod', label: 'Blur Engine (0=Canvas 2D, 1=GPU CSS, 2=Hybrid)', min: 0, max: 2, step: 1, unit: '' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasBlur', label: 'Blur Radius', min: 0, max: 300, step: 1, unit: 'px' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasBrightness', label: 'Brightness', min: 0.10, max: 3.00, step: 0.05, unit: 'x' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasSaturate', label: 'Color Saturation', min: 0.50, max: 4.00, step: 0.05, unit: 'x' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasContrast', label: 'Contrast Depth', min: 0.50, max: 3.00, step: 0.05, unit: 'x' }, { 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: '' }, // 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: '' }, { section: 'Onara Mode Background', key: 'onaraBackdropBlur', label: 'Onara Modal Backdrop Blur', min: 0, max: 50, step: 1, unit: 'px' }, { section: 'Onara Mode Background', key: 'onaraGridDim', label: 'Onara Background Grid Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' } ]; const danmakuSliders = [ // Typography & Appearance { section: 'Typography & Appearance', key: 'fontSize', label: 'Font Size', min: 14, max: 72, step: 1, unit: 'px' }, { section: 'Typography & Appearance', key: 'opacity', label: 'Danmaku Opacity', min: 0.10, max: 1.00, step: 0.05, unit: '' }, { section: 'Typography & Appearance', key: 'fontWeight', label: 'Font Weight', min: 400, max: 900, step: 100, unit: '' }, { section: 'Typography & Appearance', key: 'outlineStyle', label: 'Outline / Glow Style (0=None, 1=Subtle, 2=Outline, 3=Neon)', min: 0, max: 3, step: 1, unit: '' }, { section: 'Typography & Appearance', key: 'pillBackground', label: 'Pill Background (0=None, 1=Dark Glass, 2=Capsule)', min: 0, max: 2, step: 1, unit: '' }, { section: 'Typography & Appearance', key: 'useCustomColor', label: 'Custom Text Color (1=On, 0=White)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Typography & Appearance', key: 'customColor', label: 'Custom Text Color', type: 'color' }, // Flight Dynamics & Lanes { section: 'Flight Dynamics & Lanes', key: 'speedMultiplier', label: 'Flight Speed Multiplier', min: 0.25, max: 3.00, step: 0.05, unit: 'x' }, { section: 'Flight Dynamics & Lanes', key: 'laneCount', label: 'Vertical Lane Slots', min: 3, max: 25, step: 1, unit: '' }, { section: 'Flight Dynamics & Lanes', key: 'laneCoverage', label: 'Screen Coverage Height', min: 20, max: 100, step: 5, unit: '%' }, { section: 'Flight Dynamics & Lanes', key: 'densityLimit', label: 'Max On-Screen Comments', min: 5, max: 80, step: 5, unit: '' }, // Content & Media Controls { section: 'Content & Media Controls', key: 'allowMediaEmbeds', label: 'Allow Inline Media Attachments (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Content & Media Controls', key: 'mediaMaxHeight', label: 'Inline Media Max Height', min: 30, max: 160, step: 5, unit: 'px' }, { section: 'Content & Media Controls', key: 'showGreentext', label: 'Greentext Quotes (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, { section: 'Content & Media Controls', key: 'flashInterval', label: 'Flash/Ruffle Spawn Interval', min: 0.5, max: 8.0, step: 0.5, unit: 's' } ]; const updateCoverArtSolidMode = () => { const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; const isSolid = Number(cfg.solidCover) === 1; const color = cfg.coverColor || '#000000'; const coverOp = cfg.coverOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.coverOpacity))) : 1.0; const bgCol = coverOp <= 0.001 ? 'transparent' : (coverOp >= 0.999 ? color : `color-mix(in srgb, ${color} ${Math.round(coverOp * 100)}%, transparent)`); document.querySelectorAll('.audio-cover-circle').forEach(c => { if (isSolid) { if (c.style.backgroundImage && c.style.backgroundImage !== 'none' && !c._origBgImage) { c._origBgImage = c.style.backgroundImage; } c.style.backgroundImage = 'none'; c.style.backgroundColor = bgCol; } else { if (c._origBgImage) { c.style.backgroundImage = c._origBgImage; c.style.backgroundColor = bgCol; } else { c.style.backgroundImage = 'none'; c.style.backgroundColor = bgCol; } } }); }; let audioRowsHtml = ''; let currentAudioSection = ''; audioSliders.forEach(s => { if (s.section && s.section !== currentAudioSection) { currentAudioSection = s.section; audioRowsHtml += `
${currentAudioSection}
`; } const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; if (s.type === 'color') { audioRowsHtml += `
${s.label} ${val}
`; } else { audioRowsHtml += `
${s.label} ${val}${s.unit || ''}
`; } }); let bgRowsHtml = ''; let currentBgSection = ''; backgroundSliders.forEach(s => { if (s.section && s.section !== currentBgSection) { currentBgSection = s.section; bgRowsHtml += `
${currentBgSection}
`; } const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; if (s.type === 'color') { bgRowsHtml += `
${s.label} ${val}
`; } else { bgRowsHtml += `
${s.label} ${val}${s.unit || ''}
`; } }); const danmakuCfg = window.danmakuTuning || (window.DEFAULT_DANMAKU_TUNING || {}); let danmakuRowsHtml = ''; let currentDanmakuSection = ''; danmakuSliders.forEach(s => { if (s.section && s.section !== currentDanmakuSection) { currentDanmakuSection = s.section; danmakuRowsHtml += `
${currentDanmakuSection}
`; } const val = danmakuCfg[s.key] !== undefined ? danmakuCfg[s.key] : (window.DEFAULT_DANMAKU_TUNING ? window.DEFAULT_DANMAKU_TUNING[s.key] : 0); if (s.type === 'color') { danmakuRowsHtml += `
${s.label} ${val}
`; } else { danmakuRowsHtml += `
${s.label} ${val}${s.unit || ''}
`; } }); panel.innerHTML = `
${audioRowsHtml}
`; // ── Sub-Tab Switching Logic ───────────────────────────────────────────── const subtabButtons = panel.querySelectorAll('.f0ck-tuner-subtab-btn'); const switchSubtab = (targetTab) => { subtabButtons.forEach(btn => { btn.classList.toggle('active', btn.dataset.subtab === targetTab); }); panel.querySelectorAll('.f0ck-tuner-subtab-pane').forEach(p => { const isActive = p.id === `f0ck-tuner-pane-${targetTab}`; p.classList.toggle('active', isActive); p.style.display = isActive ? 'block' : 'none'; }); try { localStorage.setItem('f0ck_tuner_active_subtab', targetTab); } catch (e) {} }; subtabButtons.forEach(btn => { btn.addEventListener('click', () => switchSubtab(btn.dataset.subtab)); }); const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab'); if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background')) { switchSubtab(savedSubtab); } // ── Audio Presets & Events ────────────────────────────────────────────── const BUILTIN_PRESETS = { 'Default': getEffectiveDefaultTuning(), 'Onara': Object.assign({}, DEFAULT_AUDIO_TUNING), 'Reactor': { useCustomColor: 0, visualizerColor: "#241f31", enableBeatHue: 1, beatHueThreshold: 1, beatHueStep: 1, beatHueSmooth: 1, beatHueIdleDrift: 0.21, beatHueCooldown: 800, coverSize: 180, glowIntensity: 500, coverGlowBase: 150, glowBrightness: 1.05, glowSensitivity: 1.8, glowDynamism: 0.85, glowSmoothness: 0.95, solidCover: 1, coverColor: "#000000", coverOpacity: 1, bassGain: 0.5, bassPower: 2, scaleBounce: 0.85, bounceBoost: 3.5, attackSpeed: 1, releaseSpeed: 1, enableBars: 0, enableInnerBars: 1, useCustomInnerColor: 0, innerBarColor: "#ff0000", innerBarsMode: 4, innerRadius: 156, innerPupilRadius: 0, innerPupilRingOpacity: 0, innerBarCount: 512, innerBarWidth: 15, innerBarHeight: 5.4, innerBarOpacity: 0.02, outerRingOpacity: 0, innerHighBoost: 0.066, innerRadialRotation: 270, innerBarGlow: 0, innerHideNote: 1, barHeight: 0.9, barWidth: 34, barGap: 9, barRadius: 12, barOpacity: 0.1, barGlow: 0, smoothing: 0.86, followMouse: 1, followSpeed: 0.1, followRadius: 15, tiltEffect: 1, glowAttack: 1, glowDecay: 0.73, enableBlink: 0, blinkInterval: 12, beatHueSpeed: 0.1 }, 'Butterfly': { useCustomColor: 0, visualizerColor: "#241f31", enableBeatHue: 1, beatHueThreshold: 1, beatHueStep: 1, beatHueSmooth: 1, beatHueIdleDrift: 0.21, beatHueCooldown: 800, coverSize: 205, glowIntensity: 500, coverGlowBase: 150, glowBrightness: 0, glowSensitivity: 0.1, glowDynamism: 0, glowSmoothness: 1, solidCover: 1, coverColor: "#000000", coverOpacity: 0, bassGain: 0.7, bassPower: 1.51, scaleBounce: 0.4, bounceBoost: 3, attackSpeed: 1, releaseSpeed: 1, enableBars: 0, enableInnerBars: 1, useCustomInnerColor: 0, innerBarColor: "#ff0000", innerBarsMode: 6, innerRadius: 158, innerPupilRadius: 0, innerPupilRingOpacity: 0, innerBarCount: 512, innerBarWidth: 2, innerBarHeight: 5.4, innerBarOpacity: 0.94, outerRingOpacity: 0, innerHighBoost: 0.066, innerRadialRotation: 270, innerBarGlow: 0, innerHideNote: 1, barHeight: 0.9, barWidth: 34, barGap: 9, barRadius: 12, barOpacity: 0.1, barGlow: 0, smoothing: 0.86, followMouse: 1, followSpeed: 0.1, followRadius: 15, tiltEffect: 1, glowAttack: 1, glowDecay: 0.73, enableBgGradient: 1, bgGradientStyle: 1, bgGradientOpacity: 1, bgGradientReactivity: 0, bgGradientSpread: 158, useCustomBgColor: 0, bgGradientColor: "#3a1c71", enableBlink: 0, blinkInterval: 12, beatHueSpeed: 0.1 }, 'Hyper Laser 512': { useCustomColor: 0, visualizerColor: '#241f31', enableBeatHue: 0, coverSize: 105, glowIntensity: 500, coverGlowBase: 35, glowBrightness: 2.2, glowSensitivity: 1.2, glowDynamism: 2.2, glowSmoothness: 0.65, solidCover: 1, coverColor: '#000000', coverOpacity: 1, bassGain: 1.4, bassPower: 1.1, scaleBounce: 0, bounceBoost: 24.5, attackSpeed: 1, releaseSpeed: 0.57, enableBars: 0, enableInnerBars: 1, useCustomInnerColor: 0, innerBarColor: '#000000', innerBarsMode: 5, innerRadius: 318, innerPupilRadius: 292, innerPupilRingOpacity: 0, innerBarCount: 512, innerBarWidth: 1, innerBarHeight: 5, innerBarOpacity: 0.85, outerRingOpacity: 0, innerHighBoost: 0.365, innerRadialRotation: 90, innerBarGlow: 0, innerHideNote: 1, smoothing: 0.34, followMouse: 1, followSpeed: 0.02, followRadius: 20, tiltEffect: 1, glowAttack: 0.85, glowDecay: 0.35, enableBlink: 0, blinkInterval: 12 }, 'Deep Bass Pulse': { useCustomColor: 0, coverSize: 110, glowIntensity: 450, coverGlowBase: 30, glowBrightness: 2.5, glowSensitivity: 1.5, glowDynamism: 2.4, glowSmoothness: 0.75, solidCover: 1, coverColor: '#000000', coverOpacity: 1, bassGain: 1.6, bassPower: 1.2, scaleBounce: 0.85, bounceBoost: 2.0, attackSpeed: 1, releaseSpeed: 0.25, enableBars: 1, enableInnerBars: 1, innerBarsMode: 4, innerRadius: 180, innerPupilRadius: 0, innerPupilRingOpacity: 0, innerBarCount: 180, innerBarWidth: 2, innerBarHeight: 2.2, innerBarOpacity: 0.8, outerRingOpacity: 0.3, innerHighBoost: 0.2, innerRadialRotation: 90, innerBarGlow: 8, innerHideNote: 1, barHeight: 0.8, barWidth: 24, barGap: 8, barRadius: 8, barOpacity: 0.7, barGlow: 15, smoothing: 0.80 }, 'Stargate Strobe': { useCustomColor: 0, coverSize: 115, glowIntensity: 400, coverGlowBase: 40, glowBrightness: 2.0, glowSensitivity: 1.3, glowDynamism: 2.0, glowSmoothness: 0.50, solidCover: 1, coverColor: '#000000', coverOpacity: 1, bassGain: 1.3, bassPower: 1.0, scaleBounce: 0.40, bounceBoost: 1.5, enableBars: 0, enableInnerBars: 1, innerBarsMode: 6, innerRadius: 240, innerPupilRadius: 70, innerPupilRingOpacity: 0.5, innerBarCount: 256, innerBarWidth: 2, innerBarHeight: 2.8, innerBarOpacity: 0.9, outerRingOpacity: 0.5, innerHighBoost: 0.25, innerRadialRotation: 90, innerBarGlow: 12, innerHideNote: 1, smoothing: 0.70 }, 'Minimal Bars': { useCustomColor: 0, coverSize: 100, glowIntensity: 200, coverGlowBase: 25, glowBrightness: 1.2, glowSensitivity: 1.0, glowDynamism: 1.5, solidCover: 0, coverOpacity: 1, scaleBounce: 0.50, bounceBoost: 1.2, enableBars: 1, enableInnerBars: 0, barHeight: 0.65, barWidth: 28, barGap: 8, barRadius: 10, barOpacity: 0.85, barGlow: 10, smoothing: 0.80 } }; const loadUserPresets = () => { try { const raw = localStorage.getItem('f0ck_audio_presets'); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } }; const saveUserPresets = (p) => { try { localStorage.setItem('f0ck_audio_presets', JSON.stringify(p)); } catch (e) {} }; const presetSelect = panel.querySelector('#f0ck-tuner-preset-select'); const deleteBtn = panel.querySelector('#f0ck-tuner-preset-delete'); const saveBtn = panel.querySelector('#f0ck-tuner-preset-save'); const saveDefaultBtn = panel.querySelector('#f0ck-tuner-preset-save-default'); const factoryResetBtn = panel.querySelector('#f0ck-tuner-factory-reset'); const importBtn = panel.querySelector('#f0ck-tuner-preset-import'); const nameInput = panel.querySelector('#f0ck-tuner-preset-name'); const renderPresetsDropdown = (selectedVal = '') => { const userPresets = loadUserPresets(); const hasCustomDefault = !!getCustomDefaultTuning(); let html = ``; html += ``; Object.keys(BUILTIN_PRESETS).forEach(name => { const optVal = 'builtin:' + name; const displayName = (name === 'Default' && hasCustomDefault) ? 'Default (Customized ★)' : name; html += ``; }); html += ``; const userKeys = Object.keys(userPresets); if (userKeys.length > 0) { html += ``; userKeys.forEach(name => { const optVal = 'user:' + name; html += ``; }); html += ``; } presetSelect.innerHTML = html; deleteBtn.disabled = !selectedVal || !selectedVal.startsWith('user:'); if (factoryResetBtn) { factoryResetBtn.style.display = hasCustomDefault ? 'inline-flex' : 'none'; } }; const applyTuningConfig = (newCfg, presetName) => { Object.assign(window.audioVisualizerTuning, newCfg); try { localStorage.setItem('f0ck_audio_tuning', JSON.stringify(window.audioVisualizerTuning)); } catch (e) {} audioSliders.forEach(s => { const input = panel.querySelector(`#input-${s.key}`); const valEl = panel.querySelector(`#val-${s.key}`); if (!input || !valEl) return; const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; input.value = val; if (s.type === 'color') { valEl.textContent = val; } else { const num = Number(val); const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); valEl.textContent = `${displayVal}${s.unit || ''}`; } }); backgroundSliders.forEach(s => { const input = panel.querySelector(`#input-bg-${s.key}`); const valEl = panel.querySelector(`#val-bg-${s.key}`); if (!input || !valEl) return; const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; input.value = val; if (s.type === 'color') { valEl.textContent = val; } else { const num = Number(val); const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); valEl.textContent = `${displayVal}${s.unit || ''}`; } }); const cfg = window.audioVisualizerTuning; const isCustom = Number(cfg.useCustomColor) === 1; const activeColor = isCustom ? (cfg.visualizerColor || '#99ff00') : ''; document.querySelectorAll('.audio-cover-circle i').forEach(note => { note.style.color = activeColor; note.style.textShadow = activeColor ? `0 0 16px color-mix(in srgb, ${activeColor} 75%, transparent), 0 0 35px color-mix(in srgb, ${activeColor} 40%, transparent)` : ''; note.style.display = Number(cfg.innerHideNote) === 1 ? 'none' : ''; }); document.querySelectorAll('.audio-cover-circle').forEach(c => { if (cfg.coverSize) { c.style.width = cfg.coverSize + 'px'; c.style.height = cfg.coverSize + 'px'; } const note = c.querySelector('i'); if (note && cfg.coverSize) note.style.fontSize = Math.round(cfg.coverSize * 0.35) + 'px'; }); updateCoverArtSolidMode(); applyBackgroundOpacitySettings(window.audioVisualizerTuning); if (presetName && typeof window.flashMessage === 'function') { window.flashMessage(`Loaded preset: "${presetName}"`, 2200, 'info'); } }; renderPresetsDropdown(); presetSelect.addEventListener('change', () => { const val = presetSelect.value; if (!val) return; if (val.startsWith('builtin:')) { const name = val.replace('builtin:', ''); if (BUILTIN_PRESETS[name]) { applyTuningConfig(BUILTIN_PRESETS[name], name); } deleteBtn.disabled = true; } else if (val.startsWith('user:')) { const name = val.replace('user:', ''); const userPresets = loadUserPresets(); if (userPresets[name]) { applyTuningConfig(userPresets[name], name); } deleteBtn.disabled = false; } }); const handleSaveDefault = () => { const current = Object.assign({}, window.audioVisualizerTuning); try { localStorage.setItem('f0ck_audio_default_custom', JSON.stringify(current)); } catch (e) {} BUILTIN_PRESETS['Default'] = getEffectiveDefaultTuning(); renderPresetsDropdown('builtin:Default'); if (typeof window.flashMessage === 'function') { window.flashMessage('Saved current visualizer look as Default preset!', 3000, 'success'); } }; if (saveDefaultBtn) { saveDefaultBtn.addEventListener('click', handleSaveDefault); } if (factoryResetBtn) { factoryResetBtn.addEventListener('click', () => { if (!confirm('Revert the Default preset back to the pristine factory built-in settings?')) return; try { localStorage.removeItem('f0ck_audio_default_custom'); } catch (e) {} BUILTIN_PRESETS['Default'] = getEffectiveDefaultTuning(); applyTuningConfig(DEFAULT_AUDIO_TUNING, 'Default (Factory)'); renderPresetsDropdown('builtin:Default'); if (typeof window.flashMessage === 'function') { window.flashMessage('Restored factory default visualizer preset', 2500, 'info'); } }); } const handleSavePreset = () => { let name = (nameInput.value || '').trim(); if (!name && presetSelect.value && presetSelect.value.startsWith('user:')) { name = presetSelect.value.replace('user:', ''); } if (!name && presetSelect.value === 'builtin:Default') { handleSaveDefault(); return; } if (!name) { name = prompt('Enter a name for this preset:'); if (!name) return; name = name.trim(); } if (!name) return; const userPresets = loadUserPresets(); userPresets[name] = Object.assign({}, window.audioVisualizerTuning); saveUserPresets(userPresets); nameInput.value = ''; renderPresetsDropdown('user:' + name); if (typeof window.flashMessage === 'function') { window.flashMessage(`Preset "${name}" saved!`, 2500, 'success'); } }; saveBtn.addEventListener('click', handleSavePreset); nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); handleSavePreset(); } }); deleteBtn.addEventListener('click', () => { const val = presetSelect.value; if (!val || !val.startsWith('user:')) return; const name = val.replace('user:', ''); if (!confirm(`Delete preset "${name}"?`)) return; const userPresets = loadUserPresets(); delete userPresets[name]; saveUserPresets(userPresets); renderPresetsDropdown(''); if (typeof window.flashMessage === 'function') { window.flashMessage(`Preset "${name}" deleted`, 2200, 'info'); } }); importBtn.addEventListener('click', () => { const raw = prompt('Paste your visualizer preset JSON:'); if (!raw) return; try { const parsed = JSON.parse(raw.trim()); if (typeof parsed !== 'object' || parsed === null) { throw new Error('Not an object'); } const defaultName = 'Preset ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); let name = prompt('Save preset as:', defaultName); name = name ? name.trim() : defaultName; const userPresets = loadUserPresets(); userPresets[name] = Object.assign({}, DEFAULT_AUDIO_TUNING, parsed); saveUserPresets(userPresets); applyTuningConfig(userPresets[name], name); renderPresetsDropdown('user:' + name); if (typeof window.flashMessage === 'function') { window.flashMessage(`Imported and loaded "${name}"!`, 3000, 'success'); } } catch (err) { alert('Invalid JSON! Please paste valid preset settings.'); } }); audioSliders.forEach(s => { const input = panel.querySelector(`#input-${s.key}`); const valEl = panel.querySelector(`#val-${s.key}`); if (!input || !valEl) return; input.addEventListener('input', () => { if (s.type === 'color') { window.audioVisualizerTuning[s.key] = input.value; valEl.textContent = input.value; } else { const parsed = parseFloat(input.value); window.audioVisualizerTuning[s.key] = parsed; const displayVal = Number.isInteger(parsed) ? parsed : parseFloat(parsed.toFixed(3)); valEl.textContent = `${displayVal}${s.unit || ''}`; } if (s.key === 'useCustomColor' || s.key === 'visualizerColor') { const cfg = window.audioVisualizerTuning; const isCustom = Number(cfg.useCustomColor) === 1; const activeColor = isCustom ? (cfg.visualizerColor || '#99ff00') : ''; document.querySelectorAll('.audio-cover-circle i').forEach(note => { note.style.color = activeColor; note.style.textShadow = activeColor ? `0 0 16px color-mix(in srgb, ${activeColor} 75%, transparent), 0 0 35px color-mix(in srgb, ${activeColor} 40%, transparent)` : ''; }); } else if (s.key === 'solidCover' || s.key === 'coverColor' || s.key === 'coverOpacity') { updateCoverArtSolidMode(); } else if (s.key === 'enableBars') { if (Number(input.value) === 0) { document.querySelectorAll('.v0ck-audio-visualizer-canvas, .audio-visualizer-canvas').forEach(c => { const cCtx = c.getContext('2d'); if (cCtx) cCtx.clearRect(0, 0, c.width, c.height); }); } } else if (s.key === 'enableInnerBars') { if (Number(input.value) === 0) { document.querySelectorAll('.audio-eye-inner-canvas').forEach(c => { const inCtx = c.getContext('2d'); if (inCtx) inCtx.clearRect(0, 0, c.width, c.height); }); } } else if (s.key === 'innerHideNote') { const hide = Number(input.value) === 1; document.querySelectorAll('.audio-cover-circle i').forEach(note => { note.style.display = hide ? 'none' : ''; }); } else if (s.key === 'coverSize') { const parsed = parseFloat(input.value); document.querySelectorAll('.audio-cover-circle').forEach(c => { c.style.width = parsed + 'px'; c.style.height = parsed + 'px'; const note = c.querySelector('i'); if (note) note.style.fontSize = Math.round(parsed * 0.35) + 'px'; }); } else if (s.key === 'coverGlowBase' || s.key === 'glowBrightness') { const cfg = window.audioVisualizerTuning; const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; document.querySelectorAll('.audio-cover-circle').forEach(c => { if (!c._visualizerDriving) { if (glowMult <= 0) { c.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88)`; } else { c.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88), 0 0 ${baseGlow}px color-mix(in srgb, var(--accent, #99ff00) ${Math.round(28 * Math.min(1.5, glowMult))}%, transparent)`; } } }); } try { localStorage.setItem('f0ck_audio_tuning', JSON.stringify(window.audioVisualizerTuning)); } catch (e) {} }); }); // ── Background Sliders Event Listeners ───────────────────────────────── 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', () => { if (s.type === 'color') { window.audioVisualizerTuning[s.key] = input.value; valEl.textContent = input.value; } else { const parsed = parseFloat(input.value); window.audioVisualizerTuning[s.key] = parsed; const displayVal = Number.isInteger(parsed) ? parsed : parseFloat(parsed.toFixed(3)); valEl.textContent = `${displayVal}${s.unit || ''}`; } applyBackgroundOpacitySettings(window.audioVisualizerTuning); try { localStorage.setItem('f0ck_audio_tuning', JSON.stringify(window.audioVisualizerTuning)); } catch (e) {} }); }); panel.querySelector('#bg-tuner-reset')?.addEventListener('click', () => { const defaults = DEFAULT_AUDIO_TUNING; backgroundSliders.forEach(s => { window.audioVisualizerTuning[s.key] = defaults[s.key]; const input = panel.querySelector(`#input-bg-${s.key}`); const valEl = panel.querySelector(`#val-bg-${s.key}`); if (input) input.value = defaults[s.key]; if (valEl) { if (s.type === 'color') { valEl.textContent = defaults[s.key]; } else { const num = Number(defaults[s.key]); const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); valEl.textContent = `${displayVal}${s.unit || ''}`; } } }); applyBackgroundOpacitySettings(window.audioVisualizerTuning); try { localStorage.setItem('f0ck_audio_tuning', JSON.stringify(window.audioVisualizerTuning)); } catch (e) {} if (typeof window.flashMessage === 'function') { window.flashMessage('Background settings reset to default', 2000, 'info'); } }); panel.querySelector('#bg-tuner-copy')?.addEventListener('click', () => { const bgCfg = {}; backgroundSliders.forEach(s => { bgCfg[s.key] = window.audioVisualizerTuning[s.key]; }); navigator.clipboard.writeText(JSON.stringify(bgCfg, null, 2)).then(() => { if (typeof window.flashMessage === 'function') { window.flashMessage('Background settings copied to clipboard!', 2500, 'success'); } }); }); panel.querySelector('#f0ck-tuner-copy')?.addEventListener('click', () => { const copyBtn = panel.querySelector('#f0ck-tuner-copy'); const text = JSON.stringify(window.audioVisualizerTuning, null, 2); navigator.clipboard.writeText(text).then(() => { copyBtn.innerHTML = ' Copied!'; if (typeof window.flashMessage === 'function') { window.flashMessage('Audio visualizer settings copied to clipboard!', 3500, 'success'); } setTimeout(() => { copyBtn.innerHTML = ' Copy JSON'; }, 2000); }); }); panel.querySelector('#f0ck-tuner-copy-code')?.addEventListener('click', () => { const codeBtn = panel.querySelector('#f0ck-tuner-copy-code'); const text = `const DEFAULT_AUDIO_TUNING = ${JSON.stringify(window.audioVisualizerTuning, null, 2)};`; navigator.clipboard.writeText(text).then(() => { codeBtn.innerHTML = ' Copied!'; if (typeof window.flashMessage === 'function') { window.flashMessage('Copied DEFAULT_AUDIO_TUNING JS code to clipboard!', 3500, 'success'); } setTimeout(() => { codeBtn.innerHTML = ' Copy Code'; }, 2000); }); }); panel.querySelector('#f0ck-tuner-reset')?.addEventListener('click', () => { applyTuningConfig(getEffectiveDefaultTuning(), 'Default'); renderPresetsDropdown('builtin:Default'); if (typeof window.flashMessage === 'function') { window.flashMessage('Audio tuner reset to default preset', 2000, 'info'); } }); // ── Danmaku Presets, Debug & Events ───────────────────────────────────── const BUILTIN_DANMAKU_PRESETS = { 'Default Nico': { fontSize: 35, opacity: 1.0, speedMultiplier: 1.0, laneCount: 10, laneCoverage: 100, fontWeight: 700, outlineStyle: 2, useCustomColor: 0, customColor: '#ffffff', pillBackground: 0, allowMediaEmbeds: 1, mediaMaxHeight: 80, showGreentext: 1, densityLimit: 35, flashInterval: 2.5 }, 'Dense & Fast': { fontSize: 28, opacity: 0.90, speedMultiplier: 1.6, laneCount: 16, laneCoverage: 100, fontWeight: 700, outlineStyle: 2, useCustomColor: 0, customColor: '#ffffff', pillBackground: 0, allowMediaEmbeds: 1, mediaMaxHeight: 60, showGreentext: 1, densityLimit: 60, flashInterval: 1.2 }, 'Subtitles Clear (Top 50%)': { fontSize: 32, opacity: 0.95, speedMultiplier: 1.0, laneCount: 6, laneCoverage: 50, fontWeight: 700, outlineStyle: 2, useCustomColor: 0, customColor: '#ffffff', pillBackground: 0, allowMediaEmbeds: 1, mediaMaxHeight: 70, showGreentext: 1, densityLimit: 25, flashInterval: 2.5 }, 'Neon Cyber Glow': { fontSize: 36, opacity: 1.0, speedMultiplier: 1.15, laneCount: 10, laneCoverage: 100, fontWeight: 900, outlineStyle: 3, useCustomColor: 1, customColor: '#00ffcc', pillBackground: 1, allowMediaEmbeds: 1, mediaMaxHeight: 90, showGreentext: 1, densityLimit: 40, flashInterval: 2.0 }, 'Clean Capsule HUD': { fontSize: 24, opacity: 0.90, speedMultiplier: 0.85, laneCount: 12, laneCoverage: 80, fontWeight: 600, outlineStyle: 1, useCustomColor: 0, customColor: '#ffffff', pillBackground: 2, allowMediaEmbeds: 1, mediaMaxHeight: 50, showGreentext: 1, densityLimit: 30, flashInterval: 3.0 } }; const loadUserDanmakuPresets = () => { try { const raw = localStorage.getItem('f0ck_danmaku_presets'); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } }; const saveUserDanmakuPresets = (p) => { try { localStorage.setItem('f0ck_danmaku_presets', JSON.stringify(p)); } catch (e) {} }; const danmakuPresetSelect = panel.querySelector('#danmaku-preset-select'); const danmakuDeleteBtn = panel.querySelector('#danmaku-preset-delete'); const danmakuSaveBtn = panel.querySelector('#danmaku-preset-save'); const danmakuImportBtn = panel.querySelector('#danmaku-preset-import'); const danmakuNameInput = panel.querySelector('#danmaku-preset-name'); const renderDanmakuPresetsDropdown = (selectedVal = '') => { const userPresets = loadUserDanmakuPresets(); let html = ``; html += ``; Object.keys(BUILTIN_DANMAKU_PRESETS).forEach(name => { const optVal = 'builtin:' + name; html += ``; }); html += ``; const userKeys = Object.keys(userPresets); if (userKeys.length > 0) { html += ``; userKeys.forEach(name => { const optVal = 'user:' + name; html += ``; }); html += ``; } danmakuPresetSelect.innerHTML = html; danmakuDeleteBtn.disabled = !selectedVal || !selectedVal.startsWith('user:'); }; const applyDanmakuTuningConfig = (newCfg, presetName) => { if (!window.danmakuTuning) window.danmakuTuning = {}; Object.assign(window.danmakuTuning, newCfg); try { localStorage.setItem('f0ck_danmaku_tuning', JSON.stringify(window.danmakuTuning)); } catch (e) {} danmakuSliders.forEach(s => { const input = panel.querySelector(`#input-danmaku-${s.key}`); const valEl = panel.querySelector(`#val-danmaku-${s.key}`); if (!input || !valEl) return; const val = window.danmakuTuning[s.key] !== undefined ? window.danmakuTuning[s.key] : (window.DEFAULT_DANMAKU_TUNING ? window.DEFAULT_DANMAKU_TUNING[s.key] : 0); input.value = val; if (s.type === 'color') { valEl.textContent = val; } else { const num = Number(val); const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(2)); valEl.textContent = `${displayVal}${s.unit || ''}`; } }); if (typeof Danmaku !== 'undefined' && typeof Danmaku.applyGlobalTuning === 'function') { Danmaku.applyGlobalTuning(window.danmakuTuning); } else if (window.danmakuInstance && typeof window.danmakuInstance._applyTuning === 'function') { window.danmakuInstance._applyTuning(); } if (presetName && typeof window.flashMessage === 'function') { window.flashMessage(`Loaded danmaku preset: "${presetName}"`, 2200, 'info'); } }; renderDanmakuPresetsDropdown(); danmakuPresetSelect.addEventListener('change', () => { const val = danmakuPresetSelect.value; if (!val) return; if (val.startsWith('builtin:')) { const name = val.replace('builtin:', ''); if (BUILTIN_DANMAKU_PRESETS[name]) { applyDanmakuTuningConfig(BUILTIN_DANMAKU_PRESETS[name], name); } danmakuDeleteBtn.disabled = true; } else if (val.startsWith('user:')) { const name = val.replace('user:', ''); const userPresets = loadUserDanmakuPresets(); if (userPresets[name]) { applyDanmakuTuningConfig(userPresets[name], name); } danmakuDeleteBtn.disabled = false; } }); const handleSaveDanmakuPreset = () => { let name = (danmakuNameInput.value || '').trim(); if (!name) { name = prompt('Enter a name for this danmaku preset:'); if (!name) return; name = name.trim(); } if (!name) return; const userPresets = loadUserDanmakuPresets(); userPresets[name] = Object.assign({}, window.danmakuTuning || {}); saveUserDanmakuPresets(userPresets); danmakuNameInput.value = ''; renderDanmakuPresetsDropdown('user:' + name); if (typeof window.flashMessage === 'function') { window.flashMessage(`Danmaku preset "${name}" saved!`, 2500, 'success'); } }; danmakuSaveBtn.addEventListener('click', handleSaveDanmakuPreset); danmakuNameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveDanmakuPreset(); } }); danmakuDeleteBtn.addEventListener('click', () => { const val = danmakuPresetSelect.value; if (!val || !val.startsWith('user:')) return; const name = val.replace('user:', ''); if (!confirm(`Delete danmaku preset "${name}"?`)) return; const userPresets = loadUserDanmakuPresets(); delete userPresets[name]; saveUserDanmakuPresets(userPresets); renderDanmakuPresetsDropdown(''); if (typeof window.flashMessage === 'function') { window.flashMessage(`Danmaku preset "${name}" deleted`, 2200, 'info'); } }); danmakuImportBtn.addEventListener('click', () => { const raw = prompt('Paste your danmaku preset JSON:'); if (!raw) return; try { const parsed = JSON.parse(raw.trim()); if (typeof parsed !== 'object' || parsed === null) { throw new Error('Not an object'); } const defaultName = 'Danmaku Preset ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); let name = prompt('Save danmaku preset as:', defaultName); name = name ? name.trim() : defaultName; const userPresets = loadUserDanmakuPresets(); userPresets[name] = Object.assign({}, window.DEFAULT_DANMAKU_TUNING || {}, parsed); saveUserDanmakuPresets(userPresets); applyDanmakuTuningConfig(userPresets[name], name); renderDanmakuPresetsDropdown('user:' + name); if (typeof window.flashMessage === 'function') { window.flashMessage(`Imported and loaded "${name}"!`, 3000, 'success'); } } catch (err) { alert('Invalid JSON! Please paste valid danmaku preset settings.'); } }); danmakuSliders.forEach(s => { const input = panel.querySelector(`#input-danmaku-${s.key}`); const valEl = panel.querySelector(`#val-danmaku-${s.key}`); if (!input || !valEl) return; input.addEventListener('input', () => { if (!window.danmakuTuning) window.danmakuTuning = {}; if (s.type === 'color') { window.danmakuTuning[s.key] = input.value; valEl.textContent = input.value; } else { const parsed = parseFloat(input.value); window.danmakuTuning[s.key] = parsed; const displayVal = Number.isInteger(parsed) ? parsed : parseFloat(parsed.toFixed(2)); valEl.textContent = `${displayVal}${s.unit || ''}`; } try { localStorage.setItem('f0ck_danmaku_tuning', JSON.stringify(window.danmakuTuning)); } catch (e) {} if (typeof Danmaku !== 'undefined' && typeof Danmaku.applyGlobalTuning === 'function') { Danmaku.applyGlobalTuning(window.danmakuTuning); } else if (window.danmakuInstance && typeof window.danmakuInstance._applyTuning === 'function') { window.danmakuInstance._applyTuning(); } }); }); // Visual Overlays toggles (Lane Guides & Debug HUD) ['showLaneGuides', 'showDebugHUD'].forEach(k => { const input = panel.querySelector(`#input-danmaku-${k}`); const valEl = panel.querySelector(`#val-danmaku-${k}`); if (!input || !valEl) return; input.addEventListener('input', () => { if (!window.danmakuTuning) window.danmakuTuning = {}; const enabled = Number(input.value) === 1; window.danmakuTuning[k] = enabled ? 1 : 0; valEl.textContent = enabled ? '1' : '0'; try { localStorage.setItem('f0ck_danmaku_tuning', JSON.stringify(window.danmakuTuning)); } catch (e) {} if (typeof Danmaku !== 'undefined' && typeof Danmaku.applyGlobalTuning === 'function') { Danmaku.applyGlobalTuning(window.danmakuTuning); } else if (window.danmakuInstance && typeof window.danmakuInstance._applyTuning === 'function') { window.danmakuInstance._applyTuning(); } }); }); // Interactive Comment Spawner & Debug Tools Action Handlers const debugTextInput = panel.querySelector('#danmaku-debug-text'); const debugUserInput = panel.querySelector('#danmaku-debug-user'); const debugColorInput = panel.querySelector('#danmaku-debug-color'); panel.querySelector('#danmaku-btn-fire')?.addEventListener('click', () => { const text = (debugTextInput.value || '').trim() || 'Danmaku Test! :dance_fart:'; const user = (debugUserInput.value || '').trim() || 'Tester'; const col = debugColorInput.value || '#00ffcc'; if (window.danmakuInstance) { if (typeof window.danmakuInstance.isLooping === 'function' && window.danmakuInstance.isLooping()) { window.danmakuInstance.fireBurst(1, { text, username: user, color: col }); if (typeof window.flashMessage === 'function') { window.flashMessage('Looping custom comment continuously (Loop is ON)!', 2200, 'success'); } } else { window.danmakuInstance.fire(text, user, col); if (typeof window.flashMessage === 'function') { window.flashMessage('Fired comment to active player!', 1500, 'info'); } } } else { if (typeof window.flashMessage === 'function') { window.flashMessage('No active video/audio/ruffle player on page!', 2500, 'warning'); } } }); const loopBtn = panel.querySelector('#danmaku-btn-loop'); const loopStatusSpan = panel.querySelector('#danmaku-loop-status'); loopBtn?.addEventListener('click', () => { if (window.danmakuInstance && typeof window.danmakuInstance.toggleLoop === 'function') { const isLooping = window.danmakuInstance.toggleLoop(); loopBtn.classList.toggle('btn-primary', isLooping); if (loopStatusSpan) loopStatusSpan.textContent = isLooping ? 'ON' : 'OFF'; if (typeof window.flashMessage === 'function') { window.flashMessage(`Danmaku continuous loop: ${isLooping ? 'ON (Endless)' : 'OFF'}`, 1800, isLooping ? 'success' : 'info'); } } else { if (typeof window.flashMessage === 'function') { window.flashMessage('No active video/audio player found to loop!', 2500, 'warning'); } } }); panel.querySelector('#danmaku-btn-burst')?.addEventListener('click', () => { if (window.danmakuInstance && typeof window.danmakuInstance.fireBurst === 'function') { const isLooping = typeof window.danmakuInstance.isLooping === 'function' && window.danmakuInstance.isLooping(); window.danmakuInstance.fireBurst(10); if (typeof window.flashMessage === 'function') { window.flashMessage(isLooping ? 'Continuous Burst running indefinitely (Loop is ON)!' : 'Fired 10 burst comments!', 2000, isLooping ? 'success' : 'info'); } } else { if (typeof window.flashMessage === 'function') { window.flashMessage('No active video/audio player found!', 2500, 'warning'); } } }); panel.querySelector('#danmaku-btn-flood')?.addEventListener('click', () => { if (window.danmakuInstance && typeof window.danmakuInstance.fireBurst === 'function') { const isLooping = typeof window.danmakuInstance.isLooping === 'function' && window.danmakuInstance.isLooping(); window.danmakuInstance.fireBurst(30); if (typeof window.flashMessage === 'function') { window.flashMessage(isLooping ? 'Continuous Flood running indefinitely (Loop is ON)!' : 'Stress testing with 30 comments (Turn on Loop for endless flood)!', 2200, isLooping ? 'success' : 'info'); } } else { if (typeof window.flashMessage === 'function') { window.flashMessage('No active video/audio player found!', 2500, 'warning'); } } }); panel.querySelector('#danmaku-btn-greentext')?.addEventListener('click', () => { if (window.danmakuInstance) { const isLooping = typeof window.danmakuInstance.isLooping === 'function' && window.danmakuInstance.isLooping(); if (isLooping) { window.danmakuInstance.fireBurst(1, { text: '>be me\n>browsing f0ck\n>feels good man', username: 'Anon', color: '#78b87a' }); } else { window.danmakuInstance.fire('>be me\n>browsing f0ck\n>feels good man', 'Anon', '#78b87a'); } } else { if (typeof window.flashMessage === 'function') { window.flashMessage('No active video/audio player found!', 2500, 'warning'); } } }); panel.querySelector('#danmaku-btn-spoilers')?.addEventListener('click', () => { if (window.danmakuInstance) { const isLooping = typeof window.danmakuInstance.isLooping === 'function' && window.danmakuInstance.isLooping(); if (isLooping) { window.danmakuInstance.fireBurst(1, { text: '[spoiler]This is a spoiler[/spoiler] & [blur]Blurry text[/blur]', username: 'SecretAgent', color: '#ff5577' }); } else { window.danmakuInstance.fire('[spoiler]This is a spoiler[/spoiler] & [blur]Blurry text[/blur]', 'SecretAgent', '#ff5577'); } } else { if (typeof window.flashMessage === 'function') { window.flashMessage('No active video/audio player found!', 2500, 'warning'); } } }); panel.querySelector('#danmaku-btn-clear')?.addEventListener('click', () => { if (window.danmakuInstance && typeof window.danmakuInstance.clearActivePills === 'function') { window.danmakuInstance.clearActivePills(); if (typeof window.flashMessage === 'function') { window.flashMessage('Cleared all active danmaku pills', 1800, 'info'); } } else { document.querySelectorAll('.danmaku-pill').forEach(p => p.remove()); } }); panel.querySelector('#danmaku-btn-reset-timeline')?.addEventListener('click', () => { if (window.danmakuInstance && typeof window.danmakuInstance.resetTimeline === 'function') { window.danmakuInstance.resetTimeline(); if (typeof window.flashMessage === 'function') { window.flashMessage('Timeline fired state reset', 1800, 'info'); } } }); panel.querySelector('#danmaku-tuner-copy')?.addEventListener('click', () => { const copyBtn = panel.querySelector('#danmaku-tuner-copy'); const text = JSON.stringify(window.danmakuTuning || {}, null, 2); navigator.clipboard.writeText(text).then(() => { copyBtn.innerHTML = ' Copied!'; if (typeof window.flashMessage === 'function') { window.flashMessage('Danmaku settings copied to clipboard!', 3500, 'success'); } setTimeout(() => { copyBtn.innerHTML = ' Copy Settings'; }, 2000); }); }); panel.querySelector('#danmaku-tuner-reset')?.addEventListener('click', () => { applyDanmakuTuningConfig(window.DEFAULT_DANMAKU_TUNING || BUILTIN_DANMAKU_PRESETS['Default Nico'], 'Default Nico'); renderDanmakuPresetsDropdown('builtin:Default Nico'); if (typeof window.flashMessage === 'function') { window.flashMessage('Danmaku tuner reset to defaults', 2000, 'info'); } }); if (sidebarContainer) { sidebarContainer.appendChild(panel); } else { const toggleBtn = document.createElement('button'); toggleBtn.id = 'f0ck-tuner-toggle'; toggleBtn.type = 'button'; toggleBtn.className = 'f0ck-tuner-toggle'; toggleBtn.innerHTML = ' Tuner'; toggleBtn.title = 'Open Live Tuner'; document.body.appendChild(panel); document.body.appendChild(toggleBtn); toggleBtn.addEventListener('click', () => { panel.classList.toggle('hidden'); }); } const closeBtn = panel.querySelector('#f0ck-tuner-close'); if (closeBtn) { closeBtn.addEventListener('click', () => { if (sidebarContainer && typeof window.switchSidebarTab === 'function') { window.switchSidebarTab('comments'); } else { panel.classList.add('hidden'); } }); } updateCoverArtSolidMode(); }; document.addEventListener('click', (e) => { const tunerTab = e.target.closest('#sidebar-tab-tuner, [data-tab="tuner"]'); if (tunerTab) { initAudioTunerUI(); } }); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initAudioTunerUI); } else { setTimeout(initAudioTunerUI, 50); } const audioMetaMemoryCache = new Map(); // Fast client-side binary tag parser (FLAC Vorbis Comment & ID3v2) const parseAudioTagsFromBuffer = (buffer) => { const bytes = new Uint8Array(buffer); const result = { artist: '', title: '', album: '' }; // 1. Check FLAC: "fLaC" (0x66, 0x4C, 0x61, 0x63) if (bytes.length > 4 && bytes[0] === 0x66 && bytes[1] === 0x4C && bytes[2] === 0x61 && bytes[3] === 0x63) { let offset = 4; const view = new DataView(buffer); while (offset + 4 < bytes.length) { const header = bytes[offset]; const isLast = (header & 0x80) !== 0; const type = header & 0x7F; const length = (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; offset += 4; if (type === 4 && offset + length <= bytes.length) { // VORBIS_COMMENT let p = offset; const vendorLen = view.getUint32(p, true); p += 4 + vendorLen; if (p + 4 <= offset + length) { const commentCount = view.getUint32(p, true); p += 4; const decoder = new TextDecoder('utf-8'); for (let i = 0; i < commentCount && p + 4 <= offset + length; i++) { const cLen = view.getUint32(p, true); p += 4; if (p + cLen <= offset + length) { const commentStr = decoder.decode(bytes.subarray(p, p + cLen)); p += cLen; const eqIdx = commentStr.indexOf('='); if (eqIdx > 0) { const key = commentStr.slice(0, eqIdx).toUpperCase().replace(/[-_\s]/g, ''); const val = commentStr.slice(eqIdx + 1).trim(); if (key === 'TITLE' && !result.title) result.title = val; else if ((key === 'ARTIST' || key === 'ALBUMARTIST' || key === 'PERFORMER' || key === 'AUTHOR') && !result.artist) result.artist = val; else if (key === 'ALBUM' && !result.album) result.album = val; } } } } break; } offset += length; if (isLast) break; } } // 2. Check ID3v2: "ID3" (73, 68, 51) if (bytes.length > 10 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) { const version = bytes[3]; const tagSize = ((bytes[6] & 0x7F) << 21) | ((bytes[7] & 0x7F) << 14) | ((bytes[8] & 0x7F) << 7) | (bytes[9] & 0x7F); let offset = 10; const end = Math.min(bytes.length, 10 + tagSize); while (offset + 10 <= end) { let frameId = ''; let frameSize = 0; if (version >= 3) { frameId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); if (version === 4) { frameSize = ((bytes[offset + 4] & 0x7F) << 21) | ((bytes[offset + 5] & 0x7F) << 14) | ((bytes[offset + 6] & 0x7F) << 7) | (bytes[offset + 7] & 0x7F); } else { frameSize = (bytes[offset + 4] << 24) | (bytes[offset + 5] << 16) | (bytes[offset + 6] << 8) | bytes[offset + 7]; } offset += 10; } else if (version === 2) { frameId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2]); frameSize = (bytes[offset + 3] << 16) | (bytes[offset + 4] << 8) | bytes[offset + 5]; offset += 6; } if (!frameId || frameId.charCodeAt(0) === 0 || frameSize <= 0 || offset + frameSize > end) break; if (frameId === 'TIT2' || frameId === 'TT2' || frameId === 'TPE1' || frameId === 'TP1' || frameId === 'TPE2' || frameId === 'TALB') { const encoding = bytes[offset]; const frameData = bytes.subarray(offset + 1, offset + frameSize); let text = ''; try { if (encoding === 1 || encoding === 2) { text = new TextDecoder('utf-16').decode(frameData); } else { text = new TextDecoder('utf-8').decode(frameData); } text = text.replace(/\0+$/, '').trim(); } catch (_) {} if ((frameId === 'TIT2' || frameId === 'TT2') && !result.title) result.title = text; else if ((frameId === 'TPE1' || frameId === 'TP1') && !result.artist) result.artist = text; else if (frameId === 'TPE2' && !result.artist) result.artist = text; else if (frameId === 'TALB' && !result.album) result.album = text; } offset += frameSize; } } return result; }; let badgeUpdateSeq = 0; window.updateAudioTrackBadge = async (audioElement, itemId, explicitSrc) => { if (!audioElement) return; const playerWrap = audioElement.closest('.album-gallery-container, .album-audio-wrapper, .v0ck, .embed-responsive') || audioElement.parentElement; if (!playerWrap) return; let badge = playerWrap.querySelector('.album-audio-wrapper .audio-track-info-badge, .sidebar-media-placeholder.audio > .audio-track-info-badge, .audio-track-info-badge'); if (!badge) { const ph = playerWrap.querySelector('.album-audio-wrapper .sidebar-media-placeholder.audio, .sidebar-media-placeholder.audio') || playerWrap; ph.insertAdjacentHTML('afterbegin', `
`); badge = playerWrap.querySelector('.audio-track-info-badge'); } if (!badge) return; badge.classList.remove('is-playing'); if (audioElement._badgePlayStateListener) { audioElement.removeEventListener('play', audioElement._badgePlayStateListener); audioElement.removeEventListener('pause', audioElement._badgePlayStateListener); audioElement.removeEventListener('ended', audioElement._badgePlayStateListener); audioElement._badgePlayStateListener = null; } const artistEl = badge.querySelector('.audio-track-artist'); const titleEl = badge.querySelector('.audio-track-title'); const src = explicitSrc || (audioElement.getAttribute ? audioElement.getAttribute('src') : null) || audioElement.src || audioElement.currentSrc || ''; if (!src || src === window.location.href || src.endsWith('#')) { badge.classList.remove('is-visible'); return; } const cleanFilename = src.split('?')[0].split('/').pop(); const cacheKey = cleanFilename || `item_${itemId || ''}`; const thisReqId = ++badgeUpdateSeq; badge._lastReqId = thisReqId; let meta = audioMetaMemoryCache.get(cacheKey); const renderMeta = (m) => { if (badge._lastReqId !== thisReqId) return; if (!m || (!m.artist && !m.title)) { badge.classList.remove('is-visible'); return; } if (artistEl) { artistEl.textContent = m.artist || ''; artistEl.style.display = m.artist ? '' : 'none'; } if (titleEl) { titleEl.textContent = m.title || ''; titleEl.style.display = m.title ? '' : 'none'; } const fullTooltip = `${m.artist ? m.artist + ' – ' : ''}${m.title || ''}${m.album ? ' (' + m.album + ')' : ''}`; badge.setAttribute('title', fullTooltip); badge.classList.add('is-visible'); if ('mediaSession' in navigator && window.MediaMetadata) { try { navigator.mediaSession.metadata = new MediaMetadata({ title: m.title || '', artist: m.artist || '', album: m.album || '' }); } catch (_) {} } }; if (meta) { renderMeta(meta); return; } // Immediately clear previous track text while fetching new track metadata if (artistEl) artistEl.textContent = ''; if (titleEl) titleEl.textContent = ''; badge.classList.remove('is-visible'); // 1. Fetch from server endpoint /api/v2/audio-metadata (ffprobe format tags + DB fallback) try { const q = new URLSearchParams(); if (cleanFilename) q.set('src', cleanFilename); if (itemId) q.set('id', itemId); const apiResp = await fetch(`/api/v2/audio-metadata?${q.toString()}`); if (badge._lastReqId !== thisReqId) return; if (apiResp.ok) { const data = await apiResp.json(); if (badge._lastReqId !== thisReqId) return; if (data.success && (data.artist || data.title)) { meta = { artist: data.artist, title: data.title, album: data.album }; audioMetaMemoryCache.set(cacheKey, meta); renderMeta(meta); return; } } } catch (_) {} // 2. Client-side range fetch fallback (first 64KB) try { if (badge._lastReqId !== thisReqId) return; const resp = await fetch(src, { headers: { Range: 'bytes=0-65535' } }); if (badge._lastReqId !== thisReqId) return; if (resp.ok || resp.status === 206) { const buf = await resp.arrayBuffer(); if (badge._lastReqId !== thisReqId) return; const parsed = parseAudioTagsFromBuffer(buf); if (parsed.artist || parsed.title) { meta = parsed; audioMetaMemoryCache.set(cacheKey, meta); renderMeta(meta); return; } } } catch (_) {} // 3. Fallback: parse from filename if "Artist - Title" pattern if (badge._lastReqId !== thisReqId) return; const rawName = decodeURIComponent(cleanFilename.replace(/\.[^/.]+$/, '')); const match = rawName.match(/^(.+?)\s*[-–—_]\s*(.+)$/); if (match && !rawName.startsWith('subf0ck')) { meta = { artist: match[1].replace(/^[0-9]+[\s._-]+/, '').trim(), title: match[2].trim(), album: '' }; audioMetaMemoryCache.set(cacheKey, meta); renderMeta(meta); } else { badge.classList.remove('is-visible'); } }; window.initVisualizer = (targetAudio) => { let audioElement = targetAudio; if (!audioElement || !audioElement.isConnected) { if (document.body.classList.contains('onara-modal-open')) { const onaraMount = document.getElementById('onara-item-mount'); if (onaraMount) { audioElement = onaraMount.querySelector('audio'); } } if (!audioElement || !audioElement.isConnected) { const albumAudio = document.querySelector(".album-gallery-container.is-audio-active audio, #f0ck-album-audio-wrapper audio, audio#f0ck-album-audio"); const standaloneAudio = document.querySelector("audio#my-video"); const container = document.querySelector(".album-gallery-container"); if (container && container.classList.contains('is-audio-active') && albumAudio) { audioElement = albumAudio; } else if (standaloneAudio && standaloneAudio.isConnected) { audioElement = standaloneAudio; } else { audioElement = albumAudio || standaloneAudio || document.querySelector("audio"); } } } if (audioElement) { if (!audioElement.crossOrigin) { audioElement.crossOrigin = 'anonymous'; } // Ensure Tuner UI is mounted initAudioTunerUI(); if (window.updateAudioTrackBadge) { window.updateAudioTrackBadge(audioElement, null, audioElement.getAttribute('src') || audioElement.src); } // Cleanup existing visualizer if (visualizerRafId) window.cancelAnimFrame(visualizerRafId); document.querySelectorAll("canvas.audio-visualizer").forEach(c => c.remove()); const canvas = document.createElement("canvas"); canvas.className = "audio-visualizer"; const ctx = canvas.getContext("2d"); canvas.width = 1920; canvas.height = 1080; const attachCanvas = () => { const v0ckContainer = audioElement.closest('.v0ck, .album-audio-wrapper, .embed-responsive') || audioElement.parentElement; if (v0ckContainer && !v0ckContainer.contains(canvas)) { const controls = v0ckContainer.querySelector('.v0ck_player_controls'); if (controls) { v0ckContainer.insertBefore(canvas, controls); } else { v0ckContainer.appendChild(canvas); } } }; attachCanvas(); setTimeout(attachCanvas, 50); setTimeout(attachCanvas, 250); setTimeout(attachCanvas, 600); if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } let source = audioElement._mediaElementSource; let analyser = audioElement._audioAnalyser; const setupAudioSource = () => { const currentSrc = audioElement.getAttribute('src') || audioElement.src || audioElement.currentSrc; if (!currentSrc || currentSrc === window.location.href || currentSrc.endsWith('#')) { return false; } if (!source) { try { source = audioCtx.createMediaElementSource(audioElement); audioElement._mediaElementSource = source; } catch (e) { console.warn("Visualizer Source creation failed:", e); } } if (source && analyser) { try { if (source._connectedAnalyser !== analyser) { source.connect(analyser); source._connectedAnalyser = analyser; } if (!source._connectedToDest) { source.connect(audioCtx.destination); source._connectedToDest = true; } } catch (e) { console.warn("Visualizer connect failed:", e); } } return !!source; }; const cfgInit = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; if (!analyser) { analyser = audioCtx.createAnalyser(); analyser.fftSize = 2048; analyser.smoothingTimeConstant = Math.min(0.99, Math.max(0, cfgInit.smoothing !== undefined ? Number(cfgInit.smoothing) : 0.80)); audioElement._audioAnalyser = analyser; } else { analyser.fftSize = 2048; analyser.smoothingTimeConstant = Math.min(0.99, Math.max(0, cfgInit.smoothing !== undefined ? Number(cfgInit.smoothing) : 0.80)); } setupAudioSource(); let audioCoverImg = null; const setupCoverImage = () => { const coverEl = document.querySelector("#f0ck-audio-cover"); if (coverEl && coverEl.src && !coverEl.src.endsWith('#') && !coverEl.src.includes('audio.webp') && !coverEl.src.includes('200.gif')) { audioCoverImg = new Image(); audioCoverImg.crossOrigin = 'anonymous'; audioCoverImg.src = coverEl.src; } else { audioCoverImg = null; } }; setupCoverImage(); let data = new Uint8Array(analyser.frequencyBinCount); let noteIcon = null; let smoothScale = 1; let smoothGlow = 0; let currentBeatHue = 0; let targetBeatHue = 0; let lastBeatJumpTime = 0; const draw = (data) => { ctx.clearRect(0, 0, canvas.width, canvas.height); const count = analyser.frequencyBinCount; const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; if (!audioElement.paused && Number(cfg.enableBeatHue) === 1) { const now = performance.now(); const threshold = cfg.beatHueThreshold !== undefined ? Number(cfg.beatHueThreshold) : 0.30; const stepAngle = cfg.beatHueStep !== undefined ? Math.max(1, Number(cfg.beatHueStep)) : 45; const idleDrift = cfg.beatHueIdleDrift !== undefined ? Number(cfg.beatHueIdleDrift) : 0.20; const cooldown = cfg.beatHueCooldown !== undefined ? Number(cfg.beatHueCooldown) : 150; const morphSmooth = cfg.beatHueSmooth !== undefined ? Math.min(0.98, Math.max(0, Number(cfg.beatHueSmooth))) : 0.65; let bMax = 0, bSum = 0; const kBins = Math.min(10, count); for (let b = 1; b <= kBins; b++) { const v = data[b] || 0; if (v > bMax) bMax = v; bSum += v; } const bPeak = bMax / 255; const bAvg = bSum / (kBins * 255); const kEnergy = Math.min(1, (bPeak * 0.75 + bAvg * 0.25) * (cfg.bassGain || 0.5)); const pwr = Math.pow(kEnergy, (cfg.bassPower !== undefined ? cfg.bassPower : 0.3)); // Continuous subtle ambient drift targetBeatHue = (targetBeatHue + idleDrift * 0.4) % 360; // Beat threshold & cooldown check if (pwr >= threshold && (now - lastBeatJumpTime) >= cooldown) { const excess = (pwr - threshold) / Math.max(0.1, 1 - threshold); const jump = stepAngle * (1 + excess * 0.35); targetBeatHue = (targetBeatHue + jump) % 360; lastBeatJumpTime = now; } // Interpolate currentBeatHue towards targetBeatHue if (morphSmooth <= 0.02) { currentBeatHue = targetBeatHue; } else { let diff = (targetBeatHue - currentBeatHue) % 360; if (diff < -180) diff += 360; if (diff > 180) diff -= 360; currentBeatHue = (currentBeatHue + diff * (1 - morphSmooth)) % 360; if (currentBeatHue < 0) currentBeatHue += 360; } } let accent = getComputedStyle(document.body).getPropertyValue("--accent")?.trim() || "#99ff00"; if (Number(cfg.enableBeatHue) === 1) { accent = `hsl(${Math.round(currentBeatHue)}, 100%, 55%)`; } else if (Number(cfg.useCustomColor) === 1 && cfg.visualizerColor) { accent = cfg.visualizerColor; } const barMult = cfg.barHeight !== undefined ? cfg.barHeight : 0.40; const bWidth = Math.max(1, cfg.barWidth !== undefined ? cfg.barWidth : 4); const bGap = Math.max(0, cfg.barGap !== undefined ? cfg.barGap : 2); const bRadius = Math.max(0, cfg.barRadius !== undefined ? cfg.barRadius : 2); const bOpacity = Math.min(1, Math.max(0, cfg.barOpacity !== undefined ? Number(cfg.barOpacity) : 0.85)); const bGlow = Math.max(0, cfg.barGlow !== undefined ? cfg.barGlow : 8); const feedToBg = Number(cfg.feedAudioToBg !== undefined ? cfg.feedAudioToBg : 0) === 1; const showBarsOnPlayer = cfg.enableBars !== undefined ? Number(cfg.enableBars) === 1 : true; const showBars = showBarsOnPlayer || feedToBg; canvas.style.opacity = showBarsOnPlayer ? '1' : '0'; if (showBars) { const totalBarStep = bWidth + bGap; const numBars = Math.max(8, Math.floor((canvas.width + bGap) / totalBarStep)); const totalUsedWidth = numBars * totalBarStep - bGap; const startX = Math.max(0, (canvas.width - totalUsedWidth) / 2); if (bGlow > 0) { ctx.shadowBlur = bGlow; ctx.shadowColor = accent; } else { ctx.shadowBlur = 0; } ctx.fillStyle = `color-mix(in srgb, ${accent} ${Math.round(bOpacity * 100)}%, transparent)`; for (let i = 0; i < numBars; i++) { const normIdx = i / numBars; const nextNormIdx = (i + 1) / numBars; const startBin = Math.floor(Math.pow(normIdx, 1.7) * (count * 0.78)); const endBin = Math.min(count - 1, Math.max(startBin, Math.floor(Math.pow(nextNormIdx, 1.7) * (count * 0.78)))); let valMax = 0; let valSum = 0; let valCount = 0; for (let b = startBin; b <= endBin; b++) { const v = data[b] || 0; if (v > valMax) valMax = v; valSum += v; valCount++; } const val = valCount > 0 ? (valMax * 0.7 + (valSum / valCount) * 0.3) : (data[startBin] || 0); if (val > 0) { const height = Math.max(3, canvas.height * (val / 255) * barMult); const x = startX + i * totalBarStep; const y = canvas.height - height; if (bRadius > 0 && typeof ctx.roundRect === 'function') { ctx.beginPath(); ctx.roundRect(x, y, bWidth, height, [bRadius, bRadius, 0, 0]); ctx.fill(); } else { ctx.fillRect(x, y, bWidth, height); } } } ctx.shadowBlur = 0; } if (cfg.smoothing !== undefined && analyser.smoothingTimeConstant !== cfg.smoothing) { analyser.smoothingTimeConstant = cfg.smoothing; } // Animate the music note and/or cover art circle to react dynamically to the music const v0ckContainer = audioElement.closest('.v0ck, .album-audio-wrapper, .embed-responsive') || audioElement.parentElement; const ph = v0ckContainer ? v0ckContainer.querySelector('.sidebar-media-placeholder.audio') : document.querySelector('.album-gallery-container.is-audio-active .sidebar-media-placeholder.audio, .sidebar-media-placeholder.audio'); if (!noteIcon || !noteIcon.isConnected) { noteIcon = ph ? ph.querySelector('i') : null; } const coverCircle = ph ? ph.querySelector('.audio-cover-circle') : null; let bgEl = ph ? ph.querySelector('.audio-reactive-bg') : null; if (ph && !bgEl) { bgEl = document.createElement('div'); bgEl.className = 'audio-reactive-bg'; ph.insertBefore(bgEl, ph.firstChild); } if (coverCircle) { if (cfg.coverSize) { coverCircle.style.width = cfg.coverSize + 'px'; coverCircle.style.height = cfg.coverSize + 'px'; if (noteIcon && coverCircle.contains(noteIcon)) { noteIcon.style.fontSize = Math.round(cfg.coverSize * 0.35) + 'px'; } } const coverOp = cfg.coverOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.coverOpacity))) : 1.0; const solidCol = cfg.coverColor || '#000000'; const bgCol = coverOp <= 0.001 ? 'transparent' : (coverOp >= 0.999 ? solidCol : `color-mix(in srgb, ${solidCol} ${Math.round(coverOp * 100)}%, transparent)`); if (Number(cfg.solidCover) === 1) { if (coverCircle.style.backgroundImage && coverCircle.style.backgroundImage !== 'none' && !coverCircle._origBgImage) { coverCircle._origBgImage = coverCircle.style.backgroundImage; } if (coverCircle.style.backgroundImage !== 'none') { coverCircle.style.backgroundImage = 'none'; } coverCircle.style.backgroundColor = bgCol; } else if (coverCircle._origBgImage && coverCircle.style.backgroundImage === 'none') { coverCircle.style.backgroundImage = coverCircle._origBgImage; coverCircle.style.backgroundColor = bgCol; } else if (!coverCircle._origBgImage && (!coverCircle.style.backgroundImage || coverCircle.style.backgroundImage === 'none')) { coverCircle.style.backgroundImage = 'none'; coverCircle.style.backgroundColor = bgCol; } let innerCanvas = coverCircle.querySelector('.audio-eye-inner-canvas'); if (!innerCanvas) { innerCanvas = document.createElement('canvas'); innerCanvas.className = 'audio-eye-inner-canvas'; innerCanvas.width = 300; innerCanvas.height = 300; coverCircle.insertBefore(innerCanvas, coverCircle.firstChild); } // Outer shadow canvas — inside coverCircle (overflow:visible) so it extends beyond // the circle boundary without being clipped by ph's overflow:hidden if (!coverCircle.querySelector('.audio-void-outer-canvas')) { const outerVoidCanvas = document.createElement('canvas'); outerVoidCanvas.className = 'audio-void-outer-canvas'; outerVoidCanvas.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;z-index:3;border-radius:0;display:none;'; coverCircle.appendChild(outerVoidCanvas); } if (noteIcon) { noteIcon.style.display = Number(cfg.innerHideNote) === 1 ? 'none' : ''; } } if (noteIcon || coverCircle) { if (!audioElement.paused) { if (audioCtx && audioCtx.state !== 'running') { audioCtx.resume().catch(() => {}); } // Bass energy from kick/sub-bass bins (1 to 10): peak transient + body let bassMax = 0; let bassSum = 0; const bassBins = Math.min(10, count); for (let b = 1; b <= bassBins; b++) { const val = data[b]; if (val > bassMax) bassMax = val; bassSum += val; } const bassPeak = bassMax / 255; const bassAvg = bassSum / (bassBins * 255); // Kick-driven punch: transient peak dominant for instant beat response const kickEnergy = Math.min(1, (bassPeak * 0.75 + bassAvg * 0.25) * (cfg.bassGain || 3.0)); // Midrange melodic energy let midMax = 0; let midSum = 0; const midStart = 11; const midEnd = Math.min(32, count); for (let m = midStart; m < midEnd; m++) { const val = data[m]; if (val > midMax) midMax = val; midSum += val; } const midEnergy = Math.min(1, ((midMax / 255) * 0.65 + (midSum / ((midEnd - midStart) * 255)) * 0.35) * 2.2); // High-frequency snap (snares, hi-hats, percs) let highMax = 0; const highStart = 33; const highEnd = Math.min(70, count); for (let h = highStart; h < highEnd; h++) { if (data[h] > highMax) highMax = data[h]; } const highEnergy = Math.min(1, (highMax / 255) * 2.0); // Dynamic punch with power curve const punch = Math.pow(kickEnergy, (cfg.bassPower !== undefined ? cfg.bassPower : 1.5)); const bounceMultiplier = cfg.bounceBoost !== undefined ? cfg.bounceBoost : 1.5; // Target scale jumps based on slider and bounce boost const targetScale = 1 + (punch * (cfg.scaleBounce !== undefined ? cfg.scaleBounce : 0.50) * bounceMultiplier) + (midEnergy * 0.05); // Normalized composite glow energy: // Combines strong kick/bass transient impact (70%) with melodic bloom (20%) and high sparkle (10%) const dynamism = cfg.glowDynamism !== undefined ? cfg.glowDynamism : 1.50; const glowSens = cfg.glowSensitivity !== undefined ? Number(cfg.glowSensitivity) : 1.0; const rawGlowEnergy = Math.min(1.0, (punch * 0.70 + midEnergy * 0.20 + highEnergy * 0.10) * glowSens); const targetGlowNorm = Math.pow(rawGlowEnergy, Math.max(0.4, dynamism)); // Scale smoothing (instant snap & decay for speaker thump) const cfgAttack = cfg.attackSpeed !== undefined ? cfg.attackSpeed : 0.85; const cfgRelease = cfg.releaseSpeed !== undefined ? cfg.releaseSpeed : 0.16; const attackSpeed = targetScale > smoothScale ? cfgAttack : cfgRelease; smoothScale += (targetScale - smoothScale) * attackSpeed; // Dedicated glow smoothing (butter-smooth swell & organic dissipation) const smoothFactor = cfg.glowSmoothness !== undefined ? Number(cfg.glowSmoothness) : 0.70; const speedScale = Math.max(0.08, 1.0 - smoothFactor * 0.70); const glowAttack = (cfg.glowAttack !== undefined ? Number(cfg.glowAttack) : 0.85) * speedScale; const glowRelease = (cfg.glowDecay !== undefined ? Number(cfg.glowDecay) : (cfg.glowRelease !== undefined ? Number(cfg.glowRelease) : 0.35)) * speedScale; const glowSpeed = targetGlowNorm > smoothGlow ? glowAttack : glowRelease; smoothGlow += (targetGlowNorm - smoothGlow) * Math.min(1.0, Math.max(0.01, glowSpeed)); // Rhythmic tilt based on balance const tilt = (midEnergy - kickEnergy * 0.6) * 18; if (noteIcon) { const notePct = Math.min(100, Math.round((0.55 + (smoothScale - 1) * 1.5) * 100)); if (Number(cfg.enableBeatHue) === 1) { noteIcon.style.color = accent; } if (coverCircle && coverCircle.contains(noteIcon)) { // Note is inside the eye circle; inherits scale from circle, add tilt & glow noteIcon.style.transform = `rotate(${tilt.toFixed(2)}deg)`; noteIcon.style.filter = `drop-shadow(0 0 ${Math.round(12 + smoothGlow * 20)}px color-mix(in srgb, ${accent} ${notePct}%, transparent))`; } else { noteIcon.style.transform = `scale(${smoothScale.toFixed(3)}) rotate(${tilt.toFixed(2)}deg)`; noteIcon.style.filter = `drop-shadow(0 0 ${Math.round(14 + smoothGlow * 25)}px color-mix(in srgb, ${accent} ${notePct}%, transparent))`; } } if (coverCircle) { // HUD hide: when square tunnel is active and squareTunnelHideHUD=1, fade out cover circle const sqHideHUD = Number(cfg.enableSquareTunnel) === 1 && Number(cfg.squareTunnelHideHUD) === 1; if (sqHideHUD) { coverCircle.style.opacity = '0'; coverCircle.style.pointerEvents = 'none'; coverCircle.style.boxShadow = 'none'; // suppress accent glow bleedthrough if (noteIcon) { noteIcon.style.opacity = '0'; noteIcon.style.pointerEvents = 'none'; } // Hide any lingering trail ghosts if (coverCircle._ghosts) coverCircle._ghosts.forEach(g => { if (g) g.style.opacity = '0'; }); } else { if (coverCircle.style.opacity === '0' && coverCircle._sqHiddenByTunnel) { coverCircle.style.opacity = ''; coverCircle.style.pointerEvents = ''; coverCircle.style.boxShadow = ''; // restore dynamic glow } if (noteIcon && noteIcon.style.opacity === '0' && noteIcon._sqHiddenByTunnel) { noteIcon.style.opacity = ''; noteIcon.style.pointerEvents = ''; } } coverCircle._sqHiddenByTunnel = sqHideHUD; if (noteIcon) noteIcon._sqHiddenByTunnel = sqHideHUD; // ─── Square Corridor Tunnel (Infinity Zoom with Cover Art) ────────── const enableSqTunnel = Number(cfg.enableSquareTunnel !== undefined ? cfg.enableSquareTunnel : 0) === 1; let sqCanvas = ph ? ph.querySelector('.audio-sq-tunnel') : null; if (!enableSqTunnel) { if (sqCanvas) { sqCanvas.getContext('2d').clearRect(0, 0, sqCanvas.width || 1, sqCanvas.height || 1); sqCanvas.style.display = 'none'; } if (ph) ph.style.background = ''; // restore CSS default } else { if (ph && !sqCanvas) { sqCanvas = document.createElement('canvas'); sqCanvas.className = 'audio-sq-tunnel'; sqCanvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;display:block;'; ph.insertBefore(sqCanvas, ph.firstChild); } if (sqCanvas) { sqCanvas.style.display = ''; const sqW = ph.offsetWidth; const sqH = ph.offsetHeight; if (sqCanvas.width !== sqW) sqCanvas.width = sqW; if (sqCanvas.height !== sqH) sqCanvas.height = sqH; const sc = sqCanvas.getContext('2d'); sc.clearRect(0, 0, sqW, sqH); // Config const sqSpeedBase = cfg.squareTunnelSpeed !== undefined ? Number(cfg.squareTunnelSpeed) : 0.4; const sqReact = cfg.squareTunnelReactivity !== undefined ? Number(cfg.squareTunnelReactivity) : 1.5; const sqN = Math.max(3, Math.min(20, cfg.squareTunnelLevels !== undefined ? Number(cfg.squareTunnelLevels) : 10)); const sqOpacity = cfg.squareTunnelOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.squareTunnelOpacity))) : 1.0; const sqGridW = cfg.squareTunnelGridWidth !== undefined ? Number(cfg.squareTunnelGridWidth) : 1.5; const sqFillAlpha = cfg.squareTunnelWallFillAlpha !== undefined ? Math.max(0, Math.min(1, Number(cfg.squareTunnelWallFillAlpha))) : 1; const sqFog = cfg.squareTunnelFog !== undefined ? Math.max(0, Math.min(1, Number(cfg.squareTunnelFog))) : 0.75; const sqGlow = cfg.squareTunnelGlow !== undefined ? Number(cfg.squareTunnelGlow) : 12; const sqWarpExp = cfg.squareTunnelWarp !== undefined ? Number(cfg.squareTunnelWarp) : 2.0; const sqUseAcc = cfg.squareTunnelUseAccent !== undefined ? Number(cfg.squareTunnelUseAccent) === 1 : true; const sqGridColor = sqUseAcc ? accent : (cfg.squareTunnelGridColor || '#ffffff'); // Phase & timing const sqNow = performance.now(); if (!sqCanvas._t) sqCanvas._t = sqNow; const sqDt = Math.min(0.1, (sqNow - sqCanvas._t) / 1000); sqCanvas._t = sqNow; // Camera advances along world Z; each integer unit = one cube room sqCanvas._cameraZ = (sqCanvas._cameraZ || 0) + sqDt * (sqSpeedBase + kickEnergy * sqReact); const sqCamZ = sqCanvas._cameraZ; // Room geometry const sqRoomScale = cfg.squareTunnelRoomScale !== undefined ? Number(cfg.squareTunnelRoomScale) : 1.5; const sqFocal = Math.min(sqW, sqH) * (cfg.squareTunnelFOV !== undefined ? Number(cfg.squareTunnelFOV) : 0.55); // Music reactivity const sqReactScale = cfg.squareTunnelReactScale !== undefined ? Number(cfg.squareTunnelReactScale) : 0.18; const sqReactBright = cfg.squareTunnelReactBright !== undefined ? Number(cfg.squareTunnelReactBright) : 0.4; const sqReactDrift = cfg.squareTunnelReactDrift !== undefined ? Number(cfg.squareTunnelReactDrift) : 0.0; const sqEffScale = sqRoomScale + kickEnergy * sqReactScale; const sqBrightMult = 1.0 + kickEnergy * sqReactBright; // Maze turn config const sqMaze = Number(cfg.squareTunnelMaze !== undefined ? cfg.squareTunnelMaze : 0) === 1; const sqTurnStrength = cfg.squareTunnelTurnStrength !== undefined ? Number(cfg.squareTunnelTurnStrength) : 0.5; const sqTurnSmooth = cfg.squareTunnelTurnSpeed !== undefined ? Number(cfg.squareTunnelTurnSpeed) : 0.12; const sqDriftFreq = cfg.squareTunnelDriftFreq !== undefined ? Math.max(0.1, Number(cfg.squareTunnelDriftFreq)) : 1.0; const sqBackdropBrightness = cfg.squareTunnelBackdropBrightness !== undefined ? Number(cfg.squareTunnelBackdropBrightness) : 0.55; const sqStraightBias = cfg.squareTunnelStraightBias !== undefined ? Number(cfg.squareTunnelStraightBias) : 0.35; // Wall appearance const sqWallMode = cfg.squareTunnelWallMode !== undefined ? Math.round(Number(cfg.squareTunnelWallMode)) : 0; const sqWallC1 = cfg.squareTunnelWallColor || '#1a1a2e'; const sqWallC2 = cfg.squareTunnelWallColor2 || '#000000'; const sqBackdropColor = cfg.squareTunnelBackdropColor || '#000000'; const sqPatScale = Math.max(8, Math.round(Number(cfg.squareTunnelPatternScale !== undefined ? cfg.squareTunnelPatternScale : 40))); const sqPatTileAlpha = cfg.squareTunnelPatTileAlpha !== undefined ? Math.max(0, Math.min(1, Number(cfg.squareTunnelPatTileAlpha))) : 1; // Build / cache wall pattern (modes 2-4). Invalidate on any param change. const patKey = `${sqWallMode}|${sqWallC1}|${sqWallC2}|${sqPatScale}`; if (sqWallMode >= 2 && sqCanvas._wallPatKey !== patKey) { sqCanvas._wallPatKey = patKey; const ps = sqPatScale; const pc = document.createElement('canvas'); pc.width = ps * 2; pc.height = ps * 2; const px = pc.getContext('2d'); px.fillStyle = sqWallC1; px.fillRect(0, 0, ps * 2, ps * 2); px.fillStyle = sqWallC2; if (sqWallMode === 2) { // Checkerboard px.fillRect(0, 0, ps, ps); px.fillRect(ps, ps, ps, ps); } else if (sqWallMode === 3) { // Vertical stripes px.fillRect(0, 0, ps, ps * 2); } else if (sqWallMode === 4) { // 45° diagonal stripes px.beginPath(); px.moveTo(0, 0); px.lineTo(ps, 0); px.lineTo(0, ps); px.closePath(); px.fill(); px.beginPath(); px.moveTo(ps, ps * 2); px.lineTo(ps * 2, ps); px.lineTo(ps * 2, ps * 2); px.closePath(); px.fill(); } sqCanvas._wallPattern = sc.createPattern(pc, 'repeat'); } else if (sqWallMode < 2) { sqCanvas._wallPattern = null; } const sqWallPattern = sqCanvas._wallPattern || null; // Resolve the wall fill for a given context (returns style string or pattern) const sqWallFill = () => { if (sqWallMode === 0) return null; // cover art handled separately if (sqWallMode === 1) return sqWallC1; return sqWallPattern || sqWallC1; }; // Cover art image: try coverCircle background-image, then ph img tags let sqImg = sqCanvas._sqImg; let sqImgSrc = ''; if (coverCircle) { const bi = coverCircle.style.backgroundImage || ''; const bm = bi.match(/url\(["']?([^"')]+)["']?\)/); if (bm) sqImgSrc = bm[1]; } if (!sqImgSrc && ph) { const imgEl = ph.querySelector('img:not([src$=".svg"])'); if (imgEl && imgEl.src) sqImgSrc = imgEl.src; } if (sqImgSrc && sqCanvas._sqImgSrc !== sqImgSrc) { const ni = new Image(); ni.crossOrigin = 'anonymous'; ni.src = sqImgSrc; sqCanvas._sqImg = ni; sqCanvas._sqImgSrc = sqImgSrc; sqImg = ni; } const imgOk = sqImg && sqImg.complete && sqImg.naturalWidth > 0; const cx = sqW / 2, cy = sqH / 2; const projH = (depth) => sqFocal * sqEffScale / Math.max(0.001, depth); const sqCurRoom = Math.floor(sqCamZ); const sqFrac = sqCamZ - sqCurRoom; const sqMaxHalf = Math.max(sqW, sqH) * 2.5; // ── Spirit Drift: continuous noise-driven vanishing-point wander ────────── // Instead of per-room binary turn/straight decisions the VP glides along // two independent sine waves at irrational frequency ratios (so the pattern // never repeats) layered over a slow random-walk bias. Result: organic, // unpredictable motion that feels like a spirit drifting through space. if (!sqCanvas._drift) { sqCanvas._drift = { t: Math.random() * 1000, // global time (seconds) bx: 0, by: 0, // slow random-walk bias bvx: 0, bvy: 0, // bias velocity lastBeat: 0, // last beat kick time }; } const drift = sqCanvas._drift; const dt = 1 / 60; // assume ~60fps; visual-only so exact value unimportant drift.t += dt; if (sqMaze) { // --- Slow random-walk bias (drifts gradually in a random direction) --- // Occasionally nudge the bias velocity if (Math.random() < 0.004) { // ~once every 4 seconds at 60fps drift.bvx += (Math.random() - 0.5) * 0.6; drift.bvy += (Math.random() - 0.5) * 0.6; } // Drag the bias velocity toward zero so it doesn't diverge drift.bvx *= 0.985; drift.bvy *= 0.985; // Integrate bias position, clamped so it doesn't dominate drift.bx = Math.max(-1, Math.min(1, drift.bx + drift.bvx * dt)); drift.by = Math.max(-1, Math.min(1, drift.by + drift.bvy * dt)); // --- Two-layer sine noise (incommensurable periods → never repeats) --- // Layer A: slow wide sweep — base period ~18s/23s scaled by sqDriftFreq const wA = sqDriftFreq / 18, hA = sqDriftFreq / 23; const sinAxRaw = Math.sin(drift.t * wA * Math.PI * 2); const sinAyRaw = Math.sin(drift.t * hA * Math.PI * 2); // Layer B: faster subtle wobble (period ~7s x, ~11s y) const wB = sqDriftFreq / 7, hB = sqDriftFreq / 11; const sinBxRaw = Math.sin(drift.t * wB * Math.PI * 2) * 0.35; const sinByRaw = Math.sin(drift.t * hB * Math.PI * 2) * 0.35; // Combine layers + bias, keep in [-1, 1] const noiseX = Math.max(-1, Math.min(1, sinAxRaw + sinBxRaw + drift.bx * 0.4)); const noiseY = Math.max(-1, Math.min(1, sinAyRaw + sinByRaw + drift.by * 0.4)); // Beat impulse: edge-detect kick spikes and inject a directional lurch into // the random-walk so each bass hit pushes the drift in its current direction. const prevKick = drift.prevKick || 0; const kickRise = kickEnergy - prevKick; if (kickRise > 0.28 && kickEnergy > 0.45) { // Push bias in the current noise direction — gives a sharp spatial "lurch" drift.bvx += noiseX * kickRise * sqReactDrift * 2.2; drift.bvy += noiseY * kickRise * sqReactDrift * 2.2; } drift.prevKick = kickEnergy; // VP target: drift noise (beat impulse above already baked into bx/by) const sqTurnPx = sqTurnStrength * Math.min(sqW, sqH) * 0.38; const tVpX = noiseX * sqTurnPx; const tVpY = noiseY * sqTurnPx; // Store previous VP for velocity/banking calculation sqCanvas._prevVpX = sqCanvas._vpX || 0; sqCanvas._prevVpY = sqCanvas._vpY || 0; // Smooth lerp toward target (sqTurnSmooth controls how fast we track) sqCanvas._vpX = ((sqCanvas._vpX || 0)) * (1 - sqTurnSmooth) + tVpX * sqTurnSmooth; sqCanvas._vpY = ((sqCanvas._vpY || 0)) * (1 - sqTurnSmooth) + tVpY * sqTurnSmooth; } else { // Maze off: drift VP slowly back to centre sqCanvas._vpX = ((sqCanvas._vpX || 0)) * 0.95; sqCanvas._vpY = ((sqCanvas._vpY || 0)) * 0.95; sqCanvas._drift = null; // reset so next enable gets a fresh seed } const sqVpX = sqCanvas._vpX || 0; const sqVpY = sqCanvas._vpY || 0; const turnDepth = Math.max(1, sqN * 0.55); // Backdrop fill: opacity tracks sqOpacity so the whole tunnel (backdrop + // walls + grid) fades as one when the Opacity slider is used. // sqFillAlpha (Inner Corridor Darkness) controls room wall faces only. sc.globalAlpha = sqOpacity; sc.fillStyle = sqBackdropColor; sc.fillRect(0, 0, sqW, sqH); sc.globalAlpha = 1; // Draw rooms far→near (painter's algorithm) for (let ri = sqN; ri >= 0; ri--) { const roomIdx = sqCurRoom + ri; const dBack = (roomIdx + 1) - sqCamZ; const dFront = roomIdx - sqCamZ; if (dBack <= 0.001) continue; const hBack = Math.min(projH(dBack), sqMaxHalf); const hFront = dFront > 0.001 ? Math.min(projH(dFront), sqMaxHalf) : sqMaxHalf; // Depth weights: 0 = camera position (no offset), 1 = full VP offset const wBack = Math.min(1, dBack / turnDepth); const wFront = dFront > 0.001 ? Math.min(1, dFront / turnDepth) : 0; // Per-room screen centers for back wall and front opening const bvx = cx + sqVpX * wBack; const bvy = cy + sqVpY * wBack; const fvx = cx + sqVpX * wFront; const fvy = cy + sqVpY * wFront; const fogMult = 1.0 - sqFog * Math.min(1.0, dBack / (sqN + 1)); const wallA = Math.min(1, sqOpacity * fogMult * sqBrightMult); // Back wall: solid dark for pattern modes (pattern lives on corridor walls, not end-cap) if (hBack > 0.5) { sc.save(); if (sqWallMode >= 2) { sc.globalAlpha = wallA * 0.9 * sqFillAlpha; sc.fillStyle = sqWallC2; sc.fillRect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); } else if (sqWallMode === 1) { sc.globalAlpha = wallA * 0.9 * sqFillAlpha; sc.fillStyle = sqWallC1; sc.fillRect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); } else if (imgOk) { sc.globalAlpha = wallA * 0.9 * sqFillAlpha; sc.drawImage(sqImg, bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); } else { sc.globalAlpha = wallA * 0.9 * sqFillAlpha; sc.fillStyle = '#0a0a0a'; sc.fillRect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); } sc.restore(); } // Wall ring: draw the annular region between the front opening and the back wall // using the evenodd fill rule. Outer path (CW) = front square, // inner path (CCW) = back square → punches a hole, leaving only the wall area. // This eliminates black-triangle artifacts that occur when shifted centers // cause trapezoid quads to self-intersect. if (hBack > 0.5 && hFront > hBack) { const s0 = hBack, s1 = hFront; if (sqWallMode >= 2) { // PERSPECTIVE-CORRECT wall pattern rendering. // Each depth band covers equal PHYSICAL depth (physTile world-units). // Screen positions are computed via the projection formula so bands // compress correctly toward the vanishing point (true 1/z foreshortening). // The x tile boundaries also scale with depth, converging to the VP. const focalEff = sqFocal * sqEffScale; const physTile = sqPatScale / focalEff; // physical tile size const d0 = Math.max(dFront, 0.001); const d1 = dBack; // Number of tile columns across the full corridor width const numAcross = Math.min(28, Math.max(2, Math.round(2 * sqEffScale * focalEff / sqPatScale))); const firstID = Math.floor(d0 / physTile); const lastID = Math.ceil(d1 / physTile); // 4 faces: [axis (0=h, 1=v), sign (-1=top/left, +1=bottom/right)] for (const [axis, sign] of [[0,-1],[0,1],[1,-1],[1,1]]) { sc.save(); // Clip to face trapezoid sc.beginPath(); if (axis === 0) { // top / bottom sc.moveTo(fvx - s1, fvy + sign * s1); sc.lineTo(fvx + s1, fvy + sign * s1); sc.lineTo(bvx + s0, bvy + sign * s0); sc.lineTo(bvx - s0, bvy + sign * s0); } else { // left / right sc.moveTo(fvx + sign * s1, fvy - s1); sc.lineTo(fvx + sign * s1, fvy + s1); sc.lineTo(bvx + sign * s0, bvy + s0); sc.lineTo(bvx + sign * s0, bvy - s0); } sc.closePath(); sc.clip(); // Base fill with C1 at wall opacity × fill alpha sc.globalAlpha = wallA * sqFillAlpha; sc.fillStyle = sqWallC1; sc.fillRect(0, 0, sqW, sqH); // C2 tiles drawn at wallA × sqPatTileAlpha sc.globalAlpha = wallA * sqPatTileAlpha; sc.fillStyle = sqWallC2; // Draw depth strips for (let id = firstID; id <= lastID; id++) { const da = Math.max(id * physTile, d0); const db = Math.min((id + 1) * physTile, d1); if (da >= db) continue; const ha = Math.min(focalEff / da, s1 * 2); const hb = Math.min(focalEff / db, s1 * 2); const ta = (da - d0) / (d1 - d0); const tb = (db - d0) / (d1 - d0); // VP position at each depth (linear interp between front and back VP) const vpxa = fvx + (bvx - fvx) * ta; const vpya = fvy + (bvy - fvy) * ta; const vpxb = fvx + (bvx - fvx) * tb; const vpyb = fvy + (bvy - fvy) * tb; for (let ia = 0; ia < numAcross; ia++) { // Decide whether this cell is C2 const drawC2 = sqWallMode === 2 ? (id + ia) % 2 === 1 // checkerboard : sqWallMode === 3 ? ia % 2 === 1 // width stripes : id % 2 === 1; // depth stripes if (!drawC2) continue; const fa = ia / numAcross, fb = (ia + 1) / numAcross; let x0a, y0a, x1a, y1a, x0b, y0b, x1b, y1b; if (axis === 0) { // Horizontal face: face plane at y = vpy + sign*h y0a = y1a = vpya + sign * ha; y0b = y1b = vpyb + sign * hb; x0a = vpxa + (2 * fa - 1) * ha; x1a = vpxa + (2 * fb - 1) * ha; x0b = vpxb + (2 * fa - 1) * hb; x1b = vpxb + (2 * fb - 1) * hb; } else { // Vertical face: face plane at x = vpx + sign*h x0a = x1a = vpxa + sign * ha; x0b = x1b = vpxb + sign * hb; y0a = vpya + (2 * fa - 1) * ha; y1a = vpya + (2 * fb - 1) * ha; y0b = vpyb + (2 * fa - 1) * hb; y1b = vpyb + (2 * fb - 1) * hb; } sc.beginPath(); sc.moveTo(x0a, y0a); sc.lineTo(x1a, y1a); sc.lineTo(x1b, y1b); sc.lineTo(x0b, y0b); sc.closePath(); sc.fill(); } } sc.restore(); } } else { // Solid / cover art: fast evenodd ring (no per-face work needed) sc.save(); sc.globalAlpha = wallA * sqFillAlpha; sc.beginPath(); sc.moveTo(fvx - s1, fvy - s1); sc.lineTo(fvx + s1, fvy - s1); sc.lineTo(fvx + s1, fvy + s1); sc.lineTo(fvx - s1, fvy + s1); sc.closePath(); sc.moveTo(bvx - s0, bvy - s0); sc.lineTo(bvx - s0, bvy + s0); sc.lineTo(bvx + s0, bvy + s0); sc.lineTo(bvx + s0, bvy - s0); sc.closePath(); sc.clip('evenodd'); if (sqWallMode === 1) { sc.fillStyle = sqWallC1; sc.fillRect(0, 0, sqW, sqH); } else if (imgOk) { sc.drawImage(sqImg, fvx - s1, fvy - s1, 2 * s1, 2 * s1); } else { sc.fillStyle = '#111'; sc.fillRect(0, 0, sqW, sqH); } sc.restore(); } } // Grid frame on back wall if (sqGridW > 0 && hBack > 0.5) { sc.save(); sc.strokeStyle = sqGridColor; sc.lineWidth = sqGridW; if (sqGlow > 0) { sc.shadowBlur = sqGlow; sc.shadowColor = sqGridColor; } sc.globalAlpha = Math.min(1, wallA); sc.beginPath(); sc.rect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); sc.stroke(); sc.shadowBlur = 0; sc.restore(); } } sc.globalAlpha = 1.0; } } if (!sqHideHUD) { coverCircle._visualizerDriving = true; const circleScale = smoothScale; const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; const glowReach = cfg.glowIntensity !== undefined ? cfg.glowIntensity : 250; const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; // Dynamic glow spread: smoothGlow expands outward from resting base to dynamic reach const dynSpread = smoothGlow * glowReach; const glow1 = Math.round(baseGlow * 0.20 + dynSpread * 0.18); const glow2 = Math.round(baseGlow * 0.50 + dynSpread * 0.50); const glow3 = Math.round(baseGlow * 1.00 + dynSpread * 0.95); const glow4 = Math.round(baseGlow * 1.60 + dynSpread * 1.55); // Continuous breathing opacities with high dynamic contrast const bMult = Math.min(1.4, Math.max(0.2, glowMult * 0.35)); const pct1 = Math.min(100, Math.max(0, Math.round((0.36 + smoothGlow * 0.64) * bMult * 100))); const pct2 = Math.min(100, Math.max(0, Math.round((0.22 + smoothGlow * 0.72) * bMult * 100))); const pct3 = Math.min(100, Math.max(0, Math.round((0.10 + smoothGlow * 0.68) * bMult * 100))); const pct4 = Math.min(100, Math.max(0, Math.round((0.03 + smoothGlow * 0.55) * bMult * 100))); const ringOp = cfg.outerRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.outerRingOpacity))) : 1.0; const borderPct = Math.min(100, Math.max(0, Math.round((0.35 + smoothGlow * 0.65) * 100 * ringOp))); const curEyeX = coverCircle._eyeX || 0; const curEyeY = coverCircle._eyeY || 0; // Spirit drift: shift eye with the tunnel camera VP so they move as one const driftOffX = (sqCanvas && sqCanvas._vpX) ? sqCanvas._vpX * 0.5 : 0; const driftOffY = (sqCanvas && sqCanvas._vpY) ? sqCanvas._vpY * 0.5 : 0; // ── Flying feel ────────────────────────────────────────────────────────── // 1. Bank: tilt into horizontal turns using VP velocity (rate of change per frame) const vpVelX = (sqCanvas ? ((sqCanvas._vpX || 0) - (sqCanvas._prevVpX || 0)) : 0); const bankDeg = Math.max(-18, Math.min(18, vpVelX * 0.55)); // 2. Bob: slow sinusoidal vertical drift independent of the main wander const drift2 = sqCanvas && sqCanvas._drift; const bobY = drift2 ? Math.sin(drift2.t * 0.97 * Math.PI * 2) * 4.5 : 0; // 3. Kick lunge: quick scale burst that decays each frame (stored on coverCircle) if (!coverCircle._kickLunge) coverCircle._kickLunge = 0; coverCircle._kickLunge += kickEnergy * 0.18; // spike on beat coverCircle._kickLunge *= 0.78; // fast decay const lungeBurst = Math.min(0.12, coverCircle._kickLunge); const curX = (curEyeX + driftOffX).toFixed(1); const curY = (curEyeY + driftOffY + bobY).toFixed(1); const curTiltX = (coverCircle._eyeTiltX || 0).toFixed(1); const curTiltY = (coverCircle._eyeTiltY || 0).toFixed(1); const eyeFov = Math.round(cfg.eyeFOV !== undefined ? cfg.eyeFOV : 600); const eyeZPos = cfg.eyeZPos !== undefined ? Number(cfg.eyeZPos) : 0; const eyeTransform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(${eyeFov}px) translateZ(${eyeZPos}px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) rotateZ(${bankDeg.toFixed(2)}deg) scale(${(circleScale + lungeBurst).toFixed(3)})`; coverCircle.style.transform = eyeTransform; // ── Motion trail ghosts ─────────────────────────────────────────────────── // Ring buffer of past transforms; ghosts replay them with opacity + blur const TRAIL_N = 5; if (!coverCircle._trailBuf) coverCircle._trailBuf = []; if (!coverCircle._ghosts) coverCircle._ghosts = []; // Push current state onto the front coverCircle._trailBuf.unshift(eyeTransform); if (coverCircle._trailBuf.length > TRAIL_N + 1) coverCircle._trailBuf.pop(); // Speed factor: how intense is the motion this frame (0..1) const speedFactor = Math.min(1, Math.abs(bankDeg) / 10 + lungeBurst / 0.06 + Math.abs(vpVelX) * 0.04); const coverBg = coverCircle.style.backgroundImage || ''; // Per-ghost opacity and blur ramps const ghostOpacity = [0.22, 0.14, 0.08, 0.04, 0.02]; const ghostBlur = [3, 5, 8, 11, 15 ]; for (let gi = 0; gi < TRAIL_N; gi++) { // Create ghost element on first encounter if (!coverCircle._ghosts[gi]) { const g = document.createElement('div'); g.className = 'eye-trail-ghost'; ph.insertBefore(g, coverCircle); coverCircle._ghosts[gi] = g; } const ghost = coverCircle._ghosts[gi]; const pastTf = coverCircle._trailBuf[gi + 1]; if (pastTf && speedFactor > 0.02) { ghost.style.transform = pastTf; ghost.style.backgroundImage = coverBg; ghost.style.opacity = (speedFactor * ghostOpacity[gi]).toFixed(3); ghost.style.filter = `blur(${ghostBlur[gi]}px)`; } else { ghost.style.opacity = '0'; } } const eyeNoGlow = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1; const eyeNoShadow = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1; if (!coverCircle._sqHiddenByTunnel) { if (eyeNoShadow) { coverCircle.style.boxShadow = 'none'; } else if (glowMult <= 0 || eyeNoGlow) { coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; } else { coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent), 0 0 ${glow4}px color-mix(in srgb, ${accent} ${pct4}%, transparent)`; } } if (ringOp <= 0.001) { coverCircle.style.borderColor = 'transparent'; } else { coverCircle.style.borderColor = `color-mix(in srgb, ${accent} ${borderPct}%, transparent)`; } // Dynamic Background Gradient Layer Rendering if (bgEl) { const enableBg = Number(cfg.enableBgGradient !== undefined ? cfg.enableBgGradient : 1) === 1; if (!enableBg || audioElement.paused) { bgEl.style.opacity = '0'; } else { const baseOp = cfg.bgGradientOpacity !== undefined ? Number(cfg.bgGradientOpacity) : 0.40; const reactMult = cfg.bgGradientReactivity !== undefined ? Number(cfg.bgGradientReactivity) : 1.20; const spreadPct = cfg.bgGradientSpread !== undefined ? Number(cfg.bgGradientSpread) : 75; const styleMode = cfg.bgGradientStyle !== undefined ? Number(cfg.bgGradientStyle) : 1; const bgCol = (Number(cfg.useCustomBgColor) === 1 && cfg.bgGradientColor) ? cfg.bgGradientColor : accent; const pulse = Math.min(1.0, (smoothGlow * 0.70 + kickEnergy * 0.30) * reactMult); const dynamicOp = Math.min(1.0, Math.max(0.0, baseOp + pulse * (1.0 - baseOp * 0.4))); bgEl.style.opacity = dynamicOp.toFixed(3); const cX = coverCircle && coverCircle._eyeX ? Number(coverCircle._eyeX) : 0; const cY = coverCircle && coverCircle._eyeY ? Number(coverCircle._eyeY) : 0; // Spirit drift: shift gradient focal point with the tunnel VP const bgDriftX = (sqCanvas && sqCanvas._vpX) ? sqCanvas._vpX * 0.5 : 0; const bgDriftY = (sqCanvas && sqCanvas._vpY) ? sqCanvas._vpY * 0.5 : 0; const focalX = `calc(50% + ${(cX * 0.5 + bgDriftX).toFixed(1)}px)`; const focalY = `calc(50% + ${(cY * 0.5 + bgDriftY).toFixed(1)}px)`; const dynamicSpread = Math.round(spreadPct * (1.0 + pulse * 0.45)); if (styleMode === 1) { // Style 1: Radial Beat Aura (breathing spherical bloom from behind disc) const corePct = Math.min(100, Math.round(25 + pulse * 45)); const midPct = Math.min(100, Math.round(10 + pulse * 30)); bgEl.style.background = `radial-gradient(circle at ${focalX} ${focalY}, color-mix(in srgb, ${bgCol} ${corePct}%, transparent) 0%, color-mix(in srgb, ${bgCol} ${midPct}%, transparent) ${Math.round(dynamicSpread * 0.5)}%, transparent ${dynamicSpread}%)`; } else if (styleMode === 2) { // Style 2: Dual Atmospheric Fog (cosmic corners with reactive dark center) const edgePct = Math.min(100, Math.round(15 + pulse * 50)); bgEl.style.background = `radial-gradient(ellipse at 50% 120%, color-mix(in srgb, ${bgCol} ${edgePct}%, transparent) 0%, transparent 65%), radial-gradient(ellipse at 50% -20%, color-mix(in srgb, ${bgCol} ${Math.round(edgePct * 0.7)}%, transparent) 0%, transparent 60%)`; } else if (styleMode === 3) { // Style 3: Conic Sweep / Stargate Vortex const rotAngle = Math.round(((performance.now() * 0.04) % 360)); const corePct = Math.min(100, Math.round(18 + pulse * 42)); bgEl.style.background = `conic-gradient(from ${rotAngle}deg at ${focalX} ${focalY}, transparent 0deg, color-mix(in srgb, ${bgCol} ${corePct}%, transparent) 90deg, transparent 180deg, color-mix(in srgb, ${bgCol} ${corePct}%, transparent) 270deg, transparent 360deg)`; } else if (styleMode === 4) { // Style 4: Horizontal Horizon Flare (cinematic anamorphic flare beam) const beamHeight = Math.max(8, Math.round(18 + pulse * 35)); const beamPct = Math.min(100, Math.round(25 + pulse * 55)); bgEl.style.background = `radial-gradient(ellipse 90% ${beamHeight}% at ${focalX} ${focalY}, color-mix(in srgb, ${bgCol} ${beamPct}%, transparent) 0%, color-mix(in srgb, ${bgCol} ${Math.round(beamPct * 0.35)}%, transparent) 55%, transparent 85%)`; } } } // Inner Eye Visualizer Rendering const innerCanvas = coverCircle.querySelector('.audio-eye-inner-canvas'); if (innerCanvas) { const rMax = Math.max(30, Math.min(800, cfg.innerRadius !== undefined ? Number(cfg.innerRadius) : (cfg.innerSize !== undefined ? Number(cfg.innerSize) : 150))); const rMin = Math.max(0, Math.min(rMax - 6, cfg.innerPupilRadius !== undefined ? Number(cfg.innerPupilRadius) : 0)); const targetDim = Math.max(300, Math.round(rMax * 2 + 20)); if (innerCanvas.width !== targetDim || innerCanvas.height !== targetDim) { innerCanvas.width = targetDim; innerCanvas.height = targetDim; } const currentCoverSize = Math.max(40, cfg.coverSize || 110); const canvasDisplaySize = Math.round(currentCoverSize * (rMax / 150)); const displayPx = canvasDisplaySize + 'px'; if (innerCanvas.style.width !== displayPx) { innerCanvas.style.width = displayPx; innerCanvas.style.height = displayPx; } const inCtx = innerCanvas.getContext('2d'); inCtx.clearRect(0, 0, innerCanvas.width, innerCanvas.height); const showInner = cfg.enableInnerBars !== undefined ? Number(cfg.enableInnerBars) === 1 : true; if (showInner && !audioElement.paused) { const mode = cfg.innerBarsMode !== undefined ? Number(cfg.innerBarsMode) : 1; const iGlow = cfg.innerBarGlow !== undefined ? cfg.innerBarGlow : 12; const iHMult = cfg.innerBarHeight !== undefined ? cfg.innerBarHeight : 1.0; const iBW = Math.max(1, cfg.innerBarWidth !== undefined ? cfg.innerBarWidth : 6); const cw = innerCanvas.width; const ch = innerCanvas.height; const cx = cw / 2; const cy = ch / 2; const innerAccent = (Number(cfg.useCustomInnerColor) === 1 && cfg.innerBarColor) ? cfg.innerBarColor : accent; if (iGlow > 0) { inCtx.shadowBlur = iGlow; inCtx.shadowColor = innerAccent; } else { inCtx.shadowBlur = 0; } inCtx.fillStyle = innerAccent; inCtx.strokeStyle = innerAccent; const inOpacity = cfg.innerBarOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.innerBarOpacity))) : 1.0; inCtx.globalAlpha = inOpacity; const pupilRingOp = cfg.innerPupilRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.innerPupilRingOpacity))) : (cfg.outerRingOpacity !== undefined ? Number(cfg.outerRingOpacity) : 0); const pupilFillColor = cfg.innerPupilColor || '#000000'; const numInnerBars = Math.max(2, Math.min(512, cfg.innerBarCount !== undefined ? Number(cfg.innerBarCount) : 32)); const hBoost = cfg.innerHighBoost !== undefined ? Number(cfg.innerHighBoost) : 0.1; const sRate = (audioCtx && audioCtx.sampleRate) || 44100; const binHz = sRate / 2048; // ~21.5 Hz per FFT bin const fMin = 28; // Deep Sub-Bass const fMax = 15000; // Crisp Air & Sizzle const maxLen = (rMax - rMin) * iHMult; const rotDeg = cfg.innerRadialRotation !== undefined ? Number(cfg.innerRadialRotation) : 90; const rotRad = (rotDeg * Math.PI) / 180; if (mode === 1) { // Mode 1: Mirrored Full-Spectrum Horizon Equalizer across eye disc const spanW = Math.max(40, Math.round(rMax * 1.74)); const barGap = Math.max(2, Math.floor((spanW - numInnerBars * iBW) / Math.max(1, numInnerBars - 1))); const totalW = numInnerBars * iBW + (numInnerBars - 1) * barGap; const startX = (cw - totalW) / 2; for (let i = 0; i < numInnerBars; i++) { const normIdx = i / Math.max(1, numInnerBars - 1); // Logarithmic musical semitone / 1/3-octave band sampling const f1 = fMin * Math.pow(fMax / fMin, i / numInnerBars); const f2 = fMin * Math.pow(fMax / fMin, (i + 1) / numInnerBars); const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); let bMax = 0; let bSum = 0; let bCnt = 0; for (let b = sBin; b <= eBin; b++) { const val = data[b] || 0; if (val > bMax) bMax = val; bSum += val; bCnt++; } const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); // Equal-energy pink noise compensation (allows melody, keys, vocals, and hi-hats to dance) const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(normIdx, 0.72) * (hBoost * 3.5)); const bVal = Math.min(255, rawVal * tilt); const bPct = bVal / 255; const x = startX + i * (iBW + barGap); const barCenterX = x + iBW / 2; const distX = Math.abs(barCenterX - cx); const maxInsideCircle = distX < (rMax - 2) ? Math.sqrt(Math.max(0, (rMax - 2) * (rMax - 2) - distX * distX)) * 2 - 12 : 24; const maxH = Math.min(maxInsideCircle, (rMax - 5) * iHMult); const barH = Math.max(4, bPct * maxH); const y = cy - barH / 2; const radius = Math.min(iBW / 2, barH / 2); inCtx.beginPath(); if (typeof inCtx.roundRect === 'function') { inCtx.roundRect(x, y, iBW, barH, [radius, radius, radius, radius]); } else { inCtx.rect(x, y, iBW, barH); } inCtx.fill(); } } else if (mode === 2) { // Mode 2: 360° Full-Spectrum Outward Radial Iris const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); for (let i = 0; i < numSpokes; i++) { const angle = (i / numSpokes) * Math.PI * 2 + rotRad; // Symmetrical musical octave distribution around iris circle const sym = 1 - Math.abs((i / numSpokes) * 2 - 1); const f1 = fMin * Math.pow(fMax / fMin, sym); const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); let bMax = 0; let bSum = 0; let bCnt = 0; for (let b = sBin; b <= eBin; b++) { const val = data[b] || 0; if (val > bMax) bMax = val; bSum += val; bCnt++; } const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); const bVal = Math.min(255, rawVal * tilt); const len = Math.max(3, (bVal / 255) * maxLen); const x1 = cx + Math.cos(angle) * rMin; const y1 = cy + Math.sin(angle) * rMin; const x2 = cx + Math.cos(angle) * (rMin + len); const y2 = cy + Math.sin(angle) * (rMin + len); inCtx.beginPath(); inCtx.lineWidth = Math.max(1, iBW); inCtx.lineCap = 'round'; inCtx.moveTo(x1, y1); inCtx.lineTo(x2, y2); inCtx.stroke(); } if (pupilRingOp > 0.001 && rMin > 3) { inCtx.save(); inCtx.globalAlpha = inOpacity * pupilRingOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); inCtx.fillStyle = pupilFillColor; inCtx.fill(); inCtx.strokeStyle = inCtx.strokeStyle; // preserve existing stroke color inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } } else if (mode === 3) { // Mode 3: Bottom Arc Full-Spectrum Equalizer const spanW = Math.max(40, Math.round(rMax * 1.74)); const barGap = Math.max(2, Math.floor((spanW - numInnerBars * iBW) / Math.max(1, numInnerBars - 1))); const totalW = numInnerBars * iBW + (numInnerBars - 1) * barGap; const startX = (cw - totalW) / 2; const baseY = cy + rMax * 0.75; for (let i = 0; i < numInnerBars; i++) { const normIdx = i / Math.max(1, numInnerBars - 1); const f1 = fMin * Math.pow(fMax / fMin, i / numInnerBars); const f2 = fMin * Math.pow(fMax / fMin, (i + 1) / numInnerBars); const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); let bMax = 0; let bSum = 0; let bCnt = 0; for (let b = sBin; b <= eBin; b++) { const val = data[b] || 0; if (val > bMax) bMax = val; bSum += val; bCnt++; } const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(normIdx, 0.72) * (hBoost * 3.5)); const bVal = Math.min(255, rawVal * tilt); const bPct = bVal / 255; const x = startX + i * (iBW + barGap); const barCenterX = x + iBW / 2; const distX = Math.abs(barCenterX - cx); const maxInside = distX < (rMax - 5) ? Math.sqrt(Math.max(0, (rMax - 5) * (rMax - 5) - distX * distX)) : 20; const maxH = Math.min(maxInside + 25, rMax * iHMult); const barH = Math.max(4, bPct * maxH); const y = baseY - barH; const radius = Math.min(iBW / 2, barH / 2); inCtx.beginPath(); if (typeof inCtx.roundRect === 'function') { inCtx.roundRect(x, y, iBW, barH, [radius, radius, 0, 0]); } else { inCtx.rect(x, y, iBW, barH); } inCtx.fill(); } } else if (mode === 4) { // Mode 4: 360° Inverted Inward Radial Iris (spikes fire inward from outer rim) const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); for (let i = 0; i < numSpokes; i++) { const angle = (i / numSpokes) * Math.PI * 2 + rotRad; const sym = 1 - Math.abs((i / numSpokes) * 2 - 1); const f1 = fMin * Math.pow(fMax / fMin, sym); const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); let bMax = 0, bSum = 0, bCnt = 0; for (let b = sBin; b <= eBin; b++) { const val = data[b] || 0; if (val > bMax) bMax = val; bSum += val; bCnt++; } const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); const bVal = Math.min(255, rawVal * tilt); const len = Math.max(3, (bVal / 255) * maxLen); const x1 = cx + Math.cos(angle) * rMax; const y1 = cy + Math.sin(angle) * rMax; const x2 = cx + Math.cos(angle) * (rMax - len); const y2 = cy + Math.sin(angle) * (rMax - len); inCtx.beginPath(); inCtx.lineWidth = Math.max(1, iBW); inCtx.lineCap = 'round'; inCtx.moveTo(x1, y1); inCtx.lineTo(x2, y2); inCtx.stroke(); } // Outer glowing rim ring if (ringOp > 0.001) { inCtx.save(); inCtx.globalAlpha = inOpacity * ringOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMax, 0, Math.PI * 2); inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } if (pupilRingOp > 0.001 && rMin > 3) { inCtx.save(); inCtx.globalAlpha = inOpacity * pupilRingOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); inCtx.fillStyle = pupilFillColor; inCtx.fill(); inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } } else if (mode === 5) { // Mode 5: Reversed Inverted Radial Iris (inward spikes + reversed frequency order) const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); for (let i = 0; i < numSpokes; i++) { const angle = (i / numSpokes) * Math.PI * 2 + rotRad; // Inverted / reversed frequency symmetry const sym = Math.abs((i / numSpokes) * 2 - 1); const f1 = fMin * Math.pow(fMax / fMin, sym); const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); let bMax = 0, bSum = 0, bCnt = 0; for (let b = sBin; b <= eBin; b++) { const val = data[b] || 0; if (val > bMax) bMax = val; bSum += val; bCnt++; } const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); const bVal = Math.min(255, rawVal * tilt); const len = Math.max(3, (bVal / 255) * maxLen); const x1 = cx + Math.cos(angle) * rMax; const y1 = cy + Math.sin(angle) * rMax; const x2 = cx + Math.cos(angle) * (rMax - len); const y2 = cy + Math.sin(angle) * (rMax - len); inCtx.beginPath(); inCtx.lineWidth = Math.max(1, iBW); inCtx.lineCap = 'round'; inCtx.moveTo(x1, y1); inCtx.lineTo(x2, y2); inCtx.stroke(); } // Outer glowing rim ring & inner pupil ring if (ringOp > 0.001) { inCtx.save(); inCtx.globalAlpha = inOpacity * ringOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMax, 0, Math.PI * 2); inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } if (pupilRingOp > 0.001 && rMin > 3) { inCtx.save(); inCtx.globalAlpha = inOpacity * pupilRingOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); inCtx.fillStyle = pupilFillColor; inCtx.fill(); inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } } else if (mode === 6) { // Mode 6: Dual Bi-Directional Stargate Iris (spikes pulse inward from rim AND outward from pupil!) const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); const halfLen = maxLen * 0.52; for (let i = 0; i < numSpokes; i++) { const angle = (i / numSpokes) * Math.PI * 2 + rotRad; const sym = 1 - Math.abs((i / numSpokes) * 2 - 1); const f1 = fMin * Math.pow(fMax / fMin, sym); const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); let bMax = 0, bSum = 0, bCnt = 0; for (let b = sBin; b <= eBin; b++) { const val = data[b] || 0; if (val > bMax) bMax = val; bSum += val; bCnt++; } const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); const bVal = Math.min(255, rawVal * tilt); const len = Math.max(3, (bVal / 255) * halfLen); inCtx.beginPath(); inCtx.lineWidth = Math.max(1, iBW); inCtx.lineCap = 'round'; // Outward spike from pupil inCtx.moveTo(cx + Math.cos(angle) * rMin, cy + Math.sin(angle) * rMin); inCtx.lineTo(cx + Math.cos(angle) * (rMin + len), cy + Math.sin(angle) * (rMin + len)); // Inward spike from outer rim inCtx.moveTo(cx + Math.cos(angle) * rMax, cy + Math.sin(angle) * rMax); inCtx.lineTo(cx + Math.cos(angle) * (rMax - len), cy + Math.sin(angle) * (rMax - len)); inCtx.stroke(); } if (pupilRingOp > 0.001 && rMin > 3) { inCtx.save(); inCtx.globalAlpha = inOpacity * pupilRingOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); inCtx.fillStyle = pupilFillColor; inCtx.fill(); inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } if (ringOp > 0.001) { inCtx.save(); inCtx.globalAlpha = inOpacity * ringOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMax, 0, Math.PI * 2); inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } } else if (mode === 7) { // Mode 7: 360° Continuous Radar Iris (seamless full-circle chromatic spectrum) const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); for (let i = 0; i < numSpokes; i++) { const angle = (i / numSpokes) * Math.PI * 2 + rotRad; const sym = i / numSpokes; const f1 = fMin * Math.pow(fMax / fMin, sym); const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); let bMax = 0, bSum = 0, bCnt = 0; for (let b = sBin; b <= eBin; b++) { const val = data[b] || 0; if (val > bMax) bMax = val; bSum += val; bCnt++; } const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); const bVal = Math.min(255, rawVal * tilt); const len = Math.max(3, (bVal / 255) * maxLen); const x1 = cx + Math.cos(angle) * rMin; const y1 = cy + Math.sin(angle) * rMin; const x2 = cx + Math.cos(angle) * (rMin + len); const y2 = cy + Math.sin(angle) * (rMin + len); inCtx.beginPath(); inCtx.lineWidth = Math.max(1, iBW); inCtx.lineCap = 'round'; inCtx.moveTo(x1, y1); inCtx.lineTo(x2, y2); inCtx.stroke(); } if (pupilRingOp > 0.001 && rMin > 3) { inCtx.save(); inCtx.globalAlpha = inOpacity * pupilRingOp; inCtx.beginPath(); inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); inCtx.fillStyle = pupilFillColor; inCtx.fill(); inCtx.lineWidth = 1.5; inCtx.stroke(); inCtx.restore(); } } // ─── Infinite Void Tunnel ─────────────────────────────────── const enableVoid = Number(cfg.enableVoidTunnel !== undefined ? cfg.enableVoidTunnel : 0) === 1; const vZone = cfg.voidTunnelZone !== undefined ? Number(cfg.voidTunnelZone) : 0; // Manage outer shadow canvas visibility const outerVoidCanvas = ph ? ph.querySelector('.audio-void-outer-canvas') : null; if (outerVoidCanvas) { if (!enableVoid || vZone !== 2) { // Clear and hide when not in use if (outerVoidCanvas.width > 0) { const oc = outerVoidCanvas.getContext('2d'); oc.clearRect(0, 0, outerVoidCanvas.width, outerVoidCanvas.height); } outerVoidCanvas.style.display = 'none'; } } if (enableVoid) { const vN = Math.max(2, Math.min(30, cfg.voidTunnelRings !== undefined ? Number(cfg.voidTunnelRings) : 10)); const vBaseSpeed = cfg.voidTunnelSpeed !== undefined ? Number(cfg.voidTunnelSpeed) : 0.6; const vReact = cfg.voidTunnelReactivity !== undefined ? Number(cfg.voidTunnelReactivity) : 3.0; const vFade = Math.max(0, Math.min(0.99, cfg.voidTunnelFadeWidth !== undefined ? Number(cfg.voidTunnelFadeWidth) : 0.35)); const vUseAccent = cfg.voidTunnelUseAccent !== undefined ? Number(cfg.voidTunnelUseAccent) === 1 : true; const vColor = vUseAccent ? innerAccent : (cfg.voidTunnelColor || '#ffffff'); const vRingW = cfg.voidTunnelRingWidth !== undefined ? Number(cfg.voidTunnelRingWidth) : 2.5; const vEdgeFade = cfg.voidTunnelEdgeFade !== undefined ? Math.max(0, Math.min(0.99, Number(cfg.voidTunnelEdgeFade))) : 0.35; const vOpacity = cfg.voidTunnelOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.voidTunnelOpacity))) : 1.0; const vDepth = cfg.voidTunnelDepth !== undefined ? Math.max(0, Math.min(1, Number(cfg.voidTunnelDepth))) : 0.65; // Higher warp exponent = stronger perspective compression near vanishing point const vWarpExp = 1.8 + vDepth * 1.0; if (vZone === 2 && outerVoidCanvas) { // ── Zone 2: Full Soak ────────────────────────────────────── // Strategy: unified t→radius, clip-based partitioning. // Inner canvas: full disc clip ≤ rMax → covers pupil + iris // Outer canvas: annular clip rMaxDisp→edge → covers shadow/halo // Both draw ALL rings; clipping decides which part is visible. // Shared geometry const phW = ph ? ph.offsetWidth : (coverCircle.offsetWidth * 3); const phH = ph ? ph.offsetHeight : phW; const outerHalfPx = Math.sqrt(phW * phW + phH * phH) / 2; const coverDispPx = coverCircle.offsetWidth || 125; const innerPxPerDisp = (innerCanvas.width || 300) / coverDispPx; const rMaxDisp = rMax / innerPxPerDisp; // rMax in display-px // Phase & timing const vNow2 = performance.now(); if (!outerVoidCanvas._voidLastTime) outerVoidCanvas._voidLastTime = vNow2; const vDt2 = Math.min(0.1, (vNow2 - outerVoidCanvas._voidLastTime) / 1000); outerVoidCanvas._voidLastTime = vNow2; const vSpeed2 = vBaseSpeed + kickEnergy * vReact; outerVoidCanvas._voidPhase = ((outerVoidCanvas._voidPhase || 0) + vDt2 * vSpeed2) % 1.0; const vPhase2 = outerVoidCanvas._voidPhase; // Infinite zoom scale: K cycles 1→2 (at K=2 each ring matches the next → seamless) const vZoomSpeed = cfg.voidTunnelZoomSpeed !== undefined ? Number(cfg.voidTunnelZoomSpeed) : 0.5; if (!outerVoidCanvas._zoomK) outerVoidCanvas._zoomK = 1.0; outerVoidCanvas._zoomK *= Math.exp((vZoomSpeed + kickEnergy * vReact * 0.1) * vDt2); if (outerVoidCanvas._zoomK >= 2.0) outerVoidCanvas._zoomK /= 2.0; const vZoomK = outerVoidCanvas._zoomK; // ── Part A: inner canvas — glow pass (no clip, shadows bleed freely) ── if (vDepth > 0.01) { inCtx.save(); inCtx.translate(cx, cy); inCtx.scale(vZoomK, vZoomK); inCtx.translate(-cx, -cy); inCtx.strokeStyle = vColor; for (let vi = 0; vi < vN; vi++) { const t = ((vi / vN) + vPhase2) % 1.0; const tWarped = Math.pow(t, vWarpExp); const ringR = tWarped * (rMax - 1); if (ringR < 0.5) continue; const fadeIn = t < vFade ? (t / vFade) : 1.0; const edgeStart = 1.0 - vEdgeFade; const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; const lineW = Math.max(0.4, tWarped * vRingW); const glowA = ringAlpha * vDepth * 0.55; if (glowA > 0.01) { inCtx.globalAlpha = Math.min(1, glowA); inCtx.lineWidth = lineW * 3.5; inCtx.shadowBlur = vDepth * 28 * tWarped; inCtx.shadowColor = vColor; inCtx.beginPath(); inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); inCtx.stroke(); } } inCtx.shadowBlur = 0; inCtx.restore(); } // ── Part A: inner canvas — core pass (clipped to rMax) ──── inCtx.save(); inCtx.beginPath(); inCtx.arc(cx, cy, rMax - 1, 0, Math.PI * 2, false); inCtx.clip(); inCtx.translate(cx, cy); inCtx.scale(vZoomK, vZoomK); inCtx.translate(-cx, -cy); inCtx.strokeStyle = vColor; inCtx.shadowBlur = 0; for (let vi = 0; vi < vN; vi++) { const t = ((vi / vN) + vPhase2) % 1.0; const tWarped = Math.pow(t, vWarpExp); const ringR = tWarped * (rMax - 1); if (ringR < 0.5) continue; const fadeIn = t < vFade ? (t / vFade) : 1.0; const edgeStart = 1.0 - vEdgeFade; const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; const lineW = Math.max(0.4, tWarped * vRingW); inCtx.globalAlpha = Math.min(1, ringAlpha); inCtx.lineWidth = lineW; inCtx.beginPath(); inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); inCtx.stroke(); } inCtx.restore(); // ── Part B: outer canvas — annular clip rMaxDisp→edge ───── const rMaxOuter = rMaxDisp; // display-px == outer-canvas-px (1:1 mapping) outerVoidCanvas.style.transform = `translate(-50%,-50%) scale(${vZoomK})`; const outerSize = Math.round(Math.sqrt(phW * phW + phH * phH)); if (outerVoidCanvas.width !== outerSize || outerVoidCanvas.height !== outerSize) { outerVoidCanvas.width = outerSize; outerVoidCanvas.height = outerSize; outerVoidCanvas.style.width = outerSize + 'px'; outerVoidCanvas.style.height = outerSize + 'px'; } outerVoidCanvas.style.display = ''; const oc = outerVoidCanvas.getContext('2d'); const ocx = outerSize / 2; const ocy = outerSize / 2; oc.clearRect(0, 0, outerSize, outerSize); // ── Part B: outer canvas — glow pass (no clip) ─────────── if (vDepth > 0.01) { oc.save(); oc.strokeStyle = vColor; for (let vi = 0; vi < vN; vi++) { const t = ((vi / vN) + vPhase2) % 1.0; const tWarped = Math.pow(t, vWarpExp); const ringR = tWarped * outerHalfPx; if (ringR < rMaxOuter - 1) continue; // skip rings inside the inner canvas region if (ringR < 0.5) continue; const fadeIn = t < vFade ? (t / vFade) : 1.0; const edgeStart = 1.0 - vEdgeFade; const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; const lineW = Math.max(0.4, tWarped * vRingW); const glowA = ringAlpha * vDepth * 0.55; if (glowA > 0.01) { oc.globalAlpha = Math.min(1, glowA); oc.lineWidth = lineW * 3.5; oc.shadowBlur = vDepth * 28 * tWarped; oc.shadowColor = vColor; oc.beginPath(); oc.arc(ocx, ocy, ringR, 0, Math.PI * 2); oc.stroke(); } } oc.shadowBlur = 0; oc.restore(); } // ── Part B: outer canvas — core pass (annular clip) ────── oc.save(); oc.beginPath(); oc.arc(ocx, ocy, outerSize, 0, Math.PI * 2, false); oc.arc(ocx, ocy, rMaxOuter, 0, Math.PI * 2, true); oc.clip('evenodd'); oc.strokeStyle = vColor; oc.shadowBlur = 0; for (let vi = 0; vi < vN; vi++) { const t = ((vi / vN) + vPhase2) % 1.0; const tWarped = Math.pow(t, vWarpExp); const ringR = tWarped * outerHalfPx; if (ringR < 0.5) continue; const fadeIn = t < vFade ? (t / vFade) : 1.0; const edgeStart = 1.0 - vEdgeFade; const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; const lineW = Math.max(0.4, tWarped * vRingW); oc.globalAlpha = Math.min(1, ringAlpha); oc.lineWidth = lineW; oc.beginPath(); oc.arc(ocx, ocy, ringR, 0, Math.PI * 2); oc.stroke(); } oc.restore(); oc.globalAlpha = 1.0; } else { // ── Zone 0 / 1: draw on inner canvas (inCtx) ── const vClipOuterR = vZone === 0 ? rMin - 2 : rMax - 1; const vSpan = vClipOuterR; if (vSpan > 4) { const vNow = performance.now(); if (!innerCanvas._voidLastTime) innerCanvas._voidLastTime = vNow; const vDt = Math.min(0.1, (vNow - innerCanvas._voidLastTime) / 1000); innerCanvas._voidLastTime = vNow; const vSpeed = vBaseSpeed + kickEnergy * vReact; innerCanvas._voidPhase = ((innerCanvas._voidPhase || 0) + vDt * vSpeed) % 1.0; const vPhase = innerCanvas._voidPhase; // Glow pass — separate unclipped save block so shadowBlur isn't truncated if (vDepth > 0.01) { inCtx.save(); inCtx.strokeStyle = vColor; for (let vi = 0; vi < vN; vi++) { const t = ((vi / vN) + vPhase) % 1.0; const tWarped = Math.pow(t, vWarpExp); const ringR = tWarped * vSpan; if (ringR < 0.5) continue; const fadeIn = t < vFade ? (t / vFade) : 1.0; const edgeStart = 1.0 - vEdgeFade; const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; const lineW = Math.max(0.4, tWarped * vRingW); const glowA = ringAlpha * vDepth * 0.55; if (glowA > 0.01) { inCtx.globalAlpha = Math.min(1, glowA); inCtx.lineWidth = lineW * 3.5; inCtx.shadowBlur = vDepth * 28 * tWarped; inCtx.shadowColor = vColor; inCtx.beginPath(); inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); inCtx.stroke(); } } inCtx.shadowBlur = 0; inCtx.restore(); } // Core pass — clipped inCtx.save(); inCtx.shadowBlur = 0; inCtx.beginPath(); inCtx.arc(cx, cy, vClipOuterR, 0, Math.PI * 2, false); inCtx.clip(); inCtx.strokeStyle = vColor; for (let vi = 0; vi < vN; vi++) { const t = ((vi / vN) + vPhase) % 1.0; const tWarped = Math.pow(t, vWarpExp); const ringR = tWarped * vSpan; if (ringR < 0.5) continue; const fadeIn = t < vFade ? (t / vFade) : 1.0; const edgeStart = 1.0 - vEdgeFade; const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; const lineW = Math.max(0.4, tWarped * vRingW); inCtx.globalAlpha = Math.min(1, ringAlpha); inCtx.lineWidth = lineW; inCtx.beginPath(); inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); inCtx.stroke(); } inCtx.restore(); } } } // ──────────────────────────────────────────────────────────── inCtx.shadowBlur = 0; inCtx.globalAlpha = 1.0; } } // ──────────────────────────────────────────────────────────────────── } // end if (!sqHideHUD) } } else { // Decay smoothly back to neutral state when paused or idle if (bgEl) { bgEl.style.opacity = '0'; } if (coverCircle) { // Restore HUD if it was hidden by the tunnel if (coverCircle._sqHiddenByTunnel) { coverCircle.style.opacity = ''; coverCircle.style.pointerEvents = ''; coverCircle._sqHiddenByTunnel = false; } const inC = coverCircle.querySelector('.audio-eye-inner-canvas'); if (inC) { const inCtx = inC.getContext('2d'); if (inCtx) inCtx.clearRect(0, 0, inC.width, inC.height); } if (noteIcon && noteIcon._sqHiddenByTunnel) { noteIcon.style.opacity = ''; noteIcon.style.pointerEvents = ''; noteIcon._sqHiddenByTunnel = false; } } if (smoothScale > 1.002 || smoothGlow > 0.005) { smoothScale += (1 - smoothScale) * 0.16; smoothGlow += (0 - smoothGlow) * 0.09; if (noteIcon) { if (coverCircle && coverCircle.contains(noteIcon)) { noteIcon.style.transform = ''; noteIcon.style.filter = ''; } else { noteIcon.style.transform = `scale(${smoothScale.toFixed(3)})`; noteIcon.style.filter = ''; } } if (coverCircle) { const curEyeX2 = coverCircle._eyeX || 0; const curEyeY2 = coverCircle._eyeY || 0; // Spirit drift: shift eye with the tunnel camera VP so they move as one const _sqCv2 = ph && ph.querySelector('.audio-sq-tunnel'); const driftOffX2 = (_sqCv2 && _sqCv2._vpX) ? _sqCv2._vpX * 0.5 : 0; const driftOffY2 = (_sqCv2 && _sqCv2._vpY) ? _sqCv2._vpY * 0.5 : 0; const curX = (curEyeX2 + driftOffX2).toFixed(1); const curY = (curEyeY2 + driftOffY2).toFixed(1); const curTiltX = (coverCircle._eyeTiltX || 0).toFixed(1); const curTiltY = (coverCircle._eyeTiltY || 0).toFixed(1); const eyeFov2 = Math.round(cfg.eyeFOV !== undefined ? cfg.eyeFOV : 600); const eyeZPos2 = cfg.eyeZPos !== undefined ? Number(cfg.eyeZPos) : 0; coverCircle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(${eyeFov2}px) translateZ(${eyeZPos2}px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(${smoothScale.toFixed(3)})`; const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; const glowReach = cfg.glowIntensity !== undefined ? cfg.glowIntensity : 250; const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; const dynSpread = smoothGlow * glowReach; const glow1 = Math.round(baseGlow * 0.20 + dynSpread * 0.18); const glow2 = Math.round(baseGlow * 0.50 + dynSpread * 0.50); const glow3 = Math.round(baseGlow * 1.00 + dynSpread * 0.95); const bMult = Math.min(1.4, Math.max(0.2, glowMult * 0.35)); const pct1 = Math.min(100, Math.max(0, Math.round((0.36 + smoothGlow * 0.64) * bMult * 100))); const pct2 = Math.min(100, Math.max(0, Math.round((0.22 + smoothGlow * 0.72) * bMult * 100))); const pct3 = Math.min(100, Math.max(0, Math.round((0.10 + smoothGlow * 0.68) * bMult * 100))); const eyeNoGlowB = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1; const eyeNoShadowB = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1; if (!coverCircle._sqHiddenByTunnel) { if (eyeNoShadowB) { coverCircle.style.boxShadow = 'none'; } else if (glowMult <= 0 || eyeNoGlowB) { coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; } else { coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent)`; } } } } else if (smoothScale !== 1 || smoothGlow !== 0) { smoothScale = 1; smoothGlow = 0; if (noteIcon) { noteIcon.style.transform = ''; noteIcon.style.filter = ''; } if (coverCircle) { coverCircle._visualizerDriving = false; const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; const eyeNoGlowC = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1; const eyeNoShadowC = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1; if (!coverCircle._sqHiddenByTunnel) { if (eyeNoShadowC) { coverCircle.style.boxShadow = 'none'; } else if (glowMult <= 0 || eyeNoGlowC) { coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88)`; } else { coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88), 0 0 ${baseGlow}px color-mix(in srgb, ${accent} ${Math.round(28 * Math.min(1.5, glowMult))}%, transparent)`; } } const ringOp = cfg.outerRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.outerRingOpacity))) : 1.0; if (ringOp <= 0.001) { coverCircle.style.borderColor = 'transparent'; } else { coverCircle.style.borderColor = `color-mix(in srgb, ${accent} ${Math.round(40 * ringOp)}%, transparent)`; } } } else if (coverCircle) { coverCircle._visualizerDriving = false; } } } // Feed the inner eye canvas (.audio-eye-inner-canvas) as background (#bg) const bgCanvas = document.getElementById('bg'); const innerEyeCanvas = document.querySelector('.audio-eye-inner-canvas'); const bgEnabled = window.background; if (bgCanvas) { if (!bgEnabled) { // Global background toggled off — fade out if (!bgCanvas.classList.contains('fader-out')) { bgCanvas.classList.remove('fader-in'); bgCanvas.classList.add('fader-out'); } } else if (feedToBg && innerEyeCanvas && innerEyeCanvas.width > 0 && innerEyeCanvas.height > 0) { const bgCtx = bgCanvas.getContext('2d'); if (bgCtx) { const SCALE = 0.5; const targetW = Math.max(1, (bgCanvas.clientWidth || window.innerWidth || 1920) * SCALE | 0); const targetH = Math.max(1, (bgCanvas.clientHeight || window.innerHeight || 1080) * SCALE | 0); if (bgCanvas.width !== targetW || bgCanvas.height !== targetH) { bgCanvas.width = targetW; bgCanvas.height = targetH; } const bw = bgCanvas.width; const bh = bgCanvas.height; if (bw > 0 && bh > 0) { // Ensure the bg canvas is visible if (bgCanvas.classList.contains('fader-out')) { bgCanvas.classList.remove('fader-out', 'fast-fade'); bgCanvas.classList.add('fader-in'); } 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) : 3; // Measure the sidebar so we center within the content area, not the full viewport const sidebar = document.querySelector('.global-sidebar-right'); const sidebarW = (sidebar && !document.body.classList.contains('sidebar-right-hidden')) ? (sidebar.offsetWidth || 0) * SCALE : 0; const contentW = bw - sidebarW; 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; // Apply blur & brightness via CSS filter (smooth at display resolution) const blurPx = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 30; const brightVal = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 0.45; 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(' ') : ''; bgCtx.drawImage(innerEyeCanvas, dx, dy, dw, dh); } catch (e) {} } } } } }; const loopingFunction = () => { visualizerRafId = requestAnimationFrame(loopingFunction); if (audioCtx && audioCtx.state === 'suspended' && !audioElement.paused) { audioCtx.resume().catch(() => {}); } if (!source || !source._connectedAnalyser) { setupAudioSource(); } analyser.getByteFrequencyData(data); draw(data); }; visualizerRafId = requestAnimationFrame(loopingFunction); const resumeAudio = () => { setupAudioSource(); if (audioCtx && audioCtx.state !== 'running') { audioCtx.resume().catch(() => {}); } }; audioElement.addEventListener('play', resumeAudio); audioElement.addEventListener('playing', resumeAudio); audioElement.addEventListener('timeupdate', resumeAudio); const playerWrap = audioElement.closest('.v0ck, .album-audio-wrapper, .embed-responsive') || audioElement.parentElement; if (playerWrap) { playerWrap.addEventListener('click', resumeAudio, { passive: true }); playerWrap.addEventListener('pointerdown', resumeAudio, { passive: true }); } ['click', 'pointerdown', 'keydown', 'touchstart'].forEach(evt => { window.addEventListener(evt, resumeAudio, { passive: true }); }); } }; // Content Warning Logic const cwModal = document.getElementById('content-warning-modal'); if (cwModal) { if (!localStorage.getItem('content_warning_accepted')) { cwModal.style.display = 'flex'; document.body.classList.add('modal-open'); } const acceptBtn = document.getElementById('cw-accept'); const declineBtn = document.getElementById('cw-decline'); if (acceptBtn) { acceptBtn.addEventListener('click', () => { localStorage.setItem('content_warning_accepted', 'true'); cwModal.style.display = 'none'; document.body.classList.remove('modal-open'); }); } if (declineBtn) { declineBtn.addEventListener('click', () => { window.location.href = 'https://duckduckgo.com'; }); } } // Initial call window.initBackground(); window.initVisualizer(); // Ruffle / SWF support — only register when the site has SWF enabled. // The static ruffle.js