From 20470435ead39a001cf4c97f1d889eee44f85d27 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Tue, 15 Sep 2026 20:47:12 +0200 Subject: [PATCH] bvcx --- public/s/css/f0ckm.css | 75 ++- public/s/js/f0ckm.js | 786 ++++++++++++++++++++++++++----- public/s/js/settings.js | 19 +- src/inc/queue.mjs | 5 + src/inc/routes/apiv2/ambient.mjs | 155 ++++++ src/index.mjs | 1 + views/settings.html | 60 ++- views/snippets/footer.html | 2 +- views/snippets/header.html | 2 +- views/snippets/item-media.html | 2 +- 10 files changed, 964 insertions(+), 143 deletions(-) create mode 100644 src/inc/routes/apiv2/ambient.mjs diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index d4017bd..6cdce17 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -41,10 +41,10 @@ --posts-meta-bg: #000000b8; --x2154: #215483; --bg-canvas-opacity: 1.0; - --bg-canvas-blur: 300px; - --onara-bg-opacity: 0.84; + --bg-canvas-blur: 30px; + --onara-bg-opacity: 0.7; --onara-backdrop-blur: 5px; - --onara-grid-dim-opacity: 0.15; + --onara-grid-dim-opacity: 0.38; } html:not([theme]), html[theme=""] { @@ -3716,6 +3716,50 @@ body.sidebar-right-hidden #sidebar-drag-zone { color: #000; } +.f0ck-tuner-btn-save-default { + background: color-mix(in srgb, var(--accent, #99ff00) 18%, rgba(0, 0, 0, 0.4)); + border: 1px solid color-mix(in srgb, var(--accent, #99ff00) 45%, transparent); + color: var(--accent, #99ff00); + border-radius: 6px; + padding: 5px 8px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.f0ck-tuner-btn-save-default:hover { + background: var(--accent, #99ff00); + border-color: var(--accent, #99ff00); + color: #000; + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent, #99ff00) 35%, transparent); +} + +.f0ck-tuner-btn-factory-reset { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #aaa; + border-radius: 6px; + padding: 5px 8px; + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.f0ck-tuner-btn-factory-reset:hover { + background: rgba(255, 100, 100, 0.18); + border-color: rgba(255, 100, 100, 0.4); + color: #ff7777; +} + .f0ck-tuner-btn-import-preset:hover { background: rgba(255, 255, 255, 0.2); color: #fff; @@ -3738,13 +3782,13 @@ body.sidebar-right-hidden #sidebar-drag-zone { display: inline-flex; align-items: center; justify-content: center; - gap: 5px; + gap: 4px; background: transparent; border: none; color: #8e8e93; - font-size: 11px; + font-size: 10.5px; font-weight: 600; - padding: 5px 8px; + padding: 5px 5px; border-radius: 6px; cursor: pointer; transition: all 0.15s ease; @@ -10314,14 +10358,13 @@ div.favs div.posts { right: 0; width: 100%; height: 100%; - /* blur is applied in canvas 2D context at draw time — no CSS filter needed. - CSS filter: blur() on a full-screen fixed element forces Chromium to run - a GPU blur pass on every compositor frame (incl. during the fade transition), - which is extremely expensive. Firefox uses a cheaper path for this. */ - transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0) scale(var(--bg-canvas-zoom, 1.12)); + transform-origin: center center; z-index: -2; transition: opacity 1.5s cubic-bezier(0.4, 0, 0.2, 1); opacity: var(--bg-canvas-opacity, 1.0); + filter: var(--bg-canvas-filter, none); + -webkit-filter: var(--bg-canvas-filter, none); } button#togglebg { @@ -20624,9 +20667,14 @@ a.lazy-thumb.filtered-upload-ghost:hover::after { } .user-infobox-username[contenteditable="true"] { border-bottom: 1px dashed rgba(255, 255, 255, 0.3); - padding: 1px 4px; + padding: 1px 6px; border-radius: 3px; transition: all 0.2s ease; + min-width: 80px; + max-width: 320px; + white-space: nowrap; + overflow: visible; + cursor: text; } .user-infobox-username[contenteditable="true"]:hover { border-bottom-color: var(--accent); @@ -20636,6 +20684,7 @@ a.lazy-thumb.filtered-upload-ghost:hover::after { outline: none; border-bottom: 2px solid var(--accent); background: rgba(0, 0, 0, 0.3); + min-width: 120px; } .color-picker-badge { @@ -20821,7 +20870,7 @@ body.onara-modal-open .admin-bar.open { z-index: 10050 !important; display: none; flex-direction: column !important; - background: rgba(0, 0, 0, var(--onara-bg-opacity, 0.84)) !important; + background: rgba(0, 0, 0, var(--onara-bg-opacity, 0.7)) !important; backdrop-filter: blur(var(--onara-backdrop-blur, 5px)) saturate(130%) !important; -webkit-backdrop-filter: blur(var(--onara-backdrop-blur, 5px)) saturate(130%) !important; overflow-y: auto !important; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index f7dbcd6..a75404f 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -3652,6 +3652,272 @@ window.cancelAnimFrame = (function () { }); 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) { @@ -3662,6 +3928,7 @@ window.cancelAnimFrame = (function () { window.cancelAnimFrame(visualizerRafId); visualizerRafId = null; } + stopYoutubeAmbient(); const canvas = document.getElementById('bg'); if (canvas) { canvas._bgFadingOut = false; @@ -3777,21 +4044,56 @@ window.cancelAnimFrame = (function () { const context = canvas.getContext('2d'); const cfgCurrent = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; - const blurSetting = cfgCurrent.bgCanvasBlur !== undefined ? Number(cfgCurrent.bgCanvasBlur) : 15; + const blurSetting = cfgCurrent.bgCanvasBlur !== undefined ? Number(cfgCurrent.bgCanvasBlur) : 30; const blurPx = Math.round(blurSetting * SCALE); - const brightnessVal = cfgCurrent.bgCanvasBrightness !== undefined ? Number(cfgCurrent.bgCanvasBrightness) : 1.25; + const brightnessVal = cfgCurrent.bgCanvasBrightness !== undefined ? Number(cfgCurrent.bgCanvasBrightness) : 0.45; const getCanvasFilter = () => { const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; - const bSetting = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 15; - const bPx = Math.round(bSetting * SCALE); - const bVal = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 1.25; + 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); + } + }; + let crossfadeRafId = null; const crossfadeToSource = (sourceImg, onComplete) => { if (crossfadeRafId) window.cancelAnimFrame(crossfadeRafId); @@ -3799,7 +4101,7 @@ window.cancelAnimFrame = (function () { context.clearRect(0, 0, cw, ch); context.save(); context.filter = getCanvasFilter(); - context.drawImage(sourceImg, 0, 0, cw, ch); + drawCoverImage(context, sourceImg); context.restore(); if (onComplete) onComplete(); return; @@ -3828,7 +4130,7 @@ window.cancelAnimFrame = (function () { context.globalAlpha = ease; context.filter = getCanvasFilter(); try { - context.drawImage(sourceImg, 0, 0, cw, ch); + drawCoverImage(context, sourceImg); } catch (e) {} context.restore(); @@ -3882,33 +4184,41 @@ window.cancelAnimFrame = (function () { } }; - let videoFadeStartTime = performance.now(); + let videoFadeStartTime = prevSnapshot ? performance.now() : 0; const animationLoop = () => { if (!elem || elem.tagName === 'AUDIO' || elem.paused || elem.ended || (!background && !canvas._bgFadingOut)) { bgRafId = null; return; } try { - 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; - context.clearRect(0, 0, cw, ch); - if (ease < 1.0 && prevSnapshot) { + 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 = 1.0 - ease; - context.drawImage(prevSnapshot, 0, 0, cw, ch); + context.globalAlpha = ease; + context.filter = getCanvasFilter(); + drawCoverImage(context, elem); context.restore(); - } - context.save(); - context.globalAlpha = ease; - context.filter = getCanvasFilter(); - context.drawImage(elem, 0, 0, cw, ch); - context.restore(); - - if (progress >= 1.0) { - prevSnapshot = null; + 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(); + drawCoverImage(context, elem); + context.restore(); } } catch (e) { bgRafId = null; @@ -3922,13 +4232,14 @@ window.cancelAnimFrame = (function () { if (lastBgElem !== elem || isAlbumVideo) { if (bgRafId) window.cancelAnimFrame(bgRafId); lastBgElem = elem; - videoFadeStartTime = performance.now(); + 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); - videoFadeStartTime = performance.now(); + // 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) { @@ -3945,7 +4256,7 @@ window.cancelAnimFrame = (function () { } else if (elem.tagName === 'CANVAS') { // Ruffle canvas: start loop immediately if (bgRafId) window.cancelAnimFrame(bgRafId); - videoFadeStartTime = performance.now(); + if (prevSnapshot) videoFadeStartTime = performance.now(); if (background) animationLoop(); } else if (elem.tagName === 'AUDIO') { elem.addEventListener('play', () => { @@ -4006,43 +4317,67 @@ window.cancelAnimFrame = (function () { } } else if (canvas) { - // No drawable element (e.g. YouTube iframe) — 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)`; - 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`; + 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 { - canvas.classList.remove('fader-out', 'fast-fade'); - canvas.classList.add('fader-in'); + stopYoutubeAmbient(); + canvas._bgFadingOut = true; + canvas.classList.add('fader-out'); + canvas.classList.remove('fader-in', 'fast-fade'); } } 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); + 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'); } - }; - canvas.addEventListener('transitionend', stopOnFadeEnd); + } 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); + } } } }; @@ -4079,15 +4414,16 @@ window.cancelAnimFrame = (function () { useCustomInnerColor: 0, innerBarColor: "#ff0000", innerBarsMode: 4, - innerRadius: 150, - innerPupilRadius: 46, - innerPupilRingOpacity: 0, - innerBarCount: 73, - innerBarWidth: 80, - innerBarHeight: 2.65, - innerBarOpacity: 0.13, + innerRadius: 152, + innerPupilRadius: 42, + innerPupilRingOpacity: 0.99, + innerPupilColor: "#000000", + innerBarCount: 21, + innerBarWidth: 12, + innerBarHeight: 0.85, + innerBarOpacity: 0.36, outerRingOpacity: 0, - innerHighBoost: 0.162, + innerHighBoost: 0.34, innerRadialRotation: 90, innerBarGlow: 0, innerHideNote: 1, @@ -4099,8 +4435,8 @@ window.cancelAnimFrame = (function () { barGlow: 8, smoothing: 0.86, followMouse: 1, - followSpeed: 0.1, - followRadius: 15, + followSpeed: 0.02, + followRadius: 10, tiltEffect: 1, glowAttack: 1, glowDecay: 0.5, @@ -4114,35 +4450,83 @@ window.cancelAnimFrame = (function () { enableBlink: 0, blinkInterval: 12, beatHueSpeed: 0.1, - onaraBgOpacity: 0.35, - onaraBackdropBlur: 0, + onaraBgOpacity: 0.7, + onaraBackdropBlur: 5, bgCanvasOpacity: 1, - bgCanvasBlur: 59, - bgCanvasBrightness: 0.35, - bgCanvasScale: 0.95, - onaraGridDim: 0, + 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-15_tuner_v6'; + + 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 raw = localStorage.getItem('f0ck_audio_tuning'); - if (raw) savedTuning = JSON.parse(raw); + 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({}, DEFAULT_AUDIO_TUNING, savedTuning || {}); + 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.84; + 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.15; + 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; 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)); + + // 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) { @@ -4354,7 +4738,8 @@ window.cancelAnimFrame = (function () { { 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: 'innerPupilRingOpacity', label: 'Center Pupil Gap Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { 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: '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: '' }, @@ -4373,17 +4758,25 @@ window.cancelAnimFrame = (function () { { 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: '' }, + { section: 'Eye Simulation & Mouse Follow', key: 'tiltEffect', label: '3D Eye Tilt (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' } + ]; - // Background Canvas & Onara Mode Section - { section: 'Background Canvas & Onara Mode', key: 'bgCanvasOpacity', label: 'Background Canvas Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, - { section: 'Background Canvas & Onara Mode', key: 'bgCanvasBlur', label: 'Background Canvas Blur Amount', min: 0, max: 300, step: 1, unit: 'px' }, - { section: 'Background Canvas & Onara Mode', key: 'bgCanvasBrightness', label: 'Background Brightness Boost', min: 0.20, max: 3.00, step: 0.05, unit: 'x' }, - { section: 'Background Canvas & Onara Mode', key: 'bgCanvasScale', label: 'Background Visualizer Bar Scale', min: 0.20, max: 4.00, step: 0.05, unit: 'x' }, - { section: 'Background Canvas & Onara Mode', key: 'onaraBgOpacity', label: 'Onara Modal Backdrop Darkness', min: 0.00, max: 1.00, step: 0.01, unit: '' }, - { section: 'Background Canvas & Onara Mode', key: 'onaraBackdropBlur', label: 'Onara Modal Backdrop Blur', min: 0, max: 50, step: 1, unit: 'px' }, - { section: 'Background Canvas & Onara Mode', key: 'onaraGridDim', label: 'Onara Background Grid Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, - { section: 'Background Canvas & Onara Mode', key: 'feedAudioToBg', label: 'Feed Visualizer to Background Canvas (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' } + const backgroundSliders = [ + // Video & Ambient Background Canvas Section + { 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 = [ @@ -4466,6 +4859,25 @@ window.cancelAnimFrame = (function () { } }); + 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]; + bgRowsHtml += ` +
+
+ ${s.label} + ${val}${s.unit || ''} +
+ +
+ `; + }); + const danmakuCfg = window.danmakuTuning || (window.DEFAULT_DANMAKU_TUNING || {}); let danmakuRowsHtml = ''; let currentDanmakuSection = ''; @@ -4507,6 +4919,9 @@ window.cancelAnimFrame = (function () { + @@ -4522,14 +4937,17 @@ window.cancelAnimFrame = (function () {
+
${audioRowsHtml} @@ -4600,6 +5018,22 @@ window.cancelAnimFrame = (function () { + + + `; // ── Sub-Tab Switching Logic ───────────────────────────────────────────── @@ -4623,13 +5057,14 @@ window.cancelAnimFrame = (function () { }); const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab'); - if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku')) { + if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background')) { switchSubtab(savedSubtab); } // ── Audio Presets & Events ────────────────────────────────────────────── const BUILTIN_PRESETS = { - 'Default': DEFAULT_AUDIO_TUNING, + 'Default': getEffectiveDefaultTuning(), + 'Onara': Object.assign({}, DEFAULT_AUDIO_TUNING), 'Reactor': { useCustomColor: 0, visualizerColor: "#241f31", @@ -4918,17 +5353,21 @@ window.cancelAnimFrame = (function () { 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; - html += ``; + const displayName = (name === 'Default' && hasCustomDefault) ? 'Default (Customized ★)' : name; + html += ``; }); html += ``; @@ -4944,6 +5383,9 @@ window.cancelAnimFrame = (function () { presetSelect.innerHTML = html; deleteBtn.disabled = !selectedVal || !selectedVal.startsWith('user:'); + if (factoryResetBtn) { + factoryResetBtn.style.display = hasCustomDefault ? 'inline-flex' : 'none'; + } }; const applyTuningConfig = (newCfg, presetName) => { @@ -4967,6 +5409,17 @@ window.cancelAnimFrame = (function () { } }); + 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; + 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') : ''; @@ -5012,8 +5465,46 @@ window.cancelAnimFrame = (function () { } }); + 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; @@ -5149,8 +5640,6 @@ window.cancelAnimFrame = (function () { } } }); - } else if (s.key === 'onaraBgOpacity' || s.key === 'bgCanvasOpacity' || s.key === 'onaraGridDim' || s.key === 'onaraBackdropBlur') { - applyBackgroundOpacitySettings(window.audioVisualizerTuning); } try { @@ -5159,25 +5648,92 @@ window.cancelAnimFrame = (function () { }); }); - panel.querySelector('#f0ck-tuner-copy').addEventListener('click', () => { + // ── 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', () => { + 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) { + 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 settings copied to clipboard!', 3500, 'success'); + window.flashMessage('Audio visualizer settings copied to clipboard!', 3500, 'success'); } setTimeout(() => { - copyBtn.innerHTML = ' Copy Settings'; + copyBtn.innerHTML = ' Copy JSON'; }, 2000); }); }); - panel.querySelector('#f0ck-tuner-reset').addEventListener('click', () => { - applyTuningConfig(DEFAULT_AUDIO_TUNING, 'Default'); + 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 defaults', 2000, 'info'); + window.flashMessage('Audio tuner reset to default preset', 2000, 'info'); } }); @@ -6120,7 +6676,7 @@ window.cancelAnimFrame = (function () { 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 : 1) === 1; + 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'; @@ -6446,6 +7002,7 @@ window.cancelAnimFrame = (function () { 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; @@ -6551,6 +7108,9 @@ window.cancelAnimFrame = (function () { 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(); @@ -6654,6 +7214,8 @@ window.cancelAnimFrame = (function () { 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(); @@ -6712,6 +7274,8 @@ window.cancelAnimFrame = (function () { 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(); @@ -6758,6 +7322,8 @@ window.cancelAnimFrame = (function () { 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(); @@ -6814,6 +7380,8 @@ window.cancelAnimFrame = (function () { 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(); @@ -6937,7 +7505,7 @@ window.cancelAnimFrame = (function () { // 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.65; + 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'); @@ -6952,8 +7520,8 @@ window.cancelAnimFrame = (function () { const dy = (bh - dh) / 2; // Apply blur & brightness via CSS filter (smooth at display resolution) - const blurPx = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 0; - const brightVal = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 1; + 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})`); diff --git a/public/s/js/settings.js b/public/s/js/settings.js index da42bde..aa4f67d 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -1561,10 +1561,10 @@ const valSpan = document.getElementById(valId); if (!slider) return; - let currentTuning = {}; + let currentTuning = (window.audioVisualizerTuning && Object.keys(window.audioVisualizerTuning).length > 0) ? window.audioVisualizerTuning : {}; try { const raw = localStorage.getItem('f0ck_audio_tuning'); - if (raw) currentTuning = JSON.parse(raw); + if (raw) currentTuning = Object.assign({}, currentTuning, JSON.parse(raw)); } catch (e) {} const initialVal = currentTuning[tuningKey] !== undefined ? Number(currentTuning[tuningKey]) : defaultVal; @@ -1585,12 +1585,19 @@ }); }; - setupAudioTuningSlider('onara_bg_opacity_slider', 'onara_bg_opacity_val', 'onaraBgOpacity', 0.84, v => Math.round(v * 100) + '%'); + setupAudioTuningSlider('onara_bg_opacity_slider', 'onara_bg_opacity_val', 'onaraBgOpacity', 0.70, v => Math.round(v * 100) + '%'); setupAudioTuningSlider('onara_backdrop_blur_slider', 'onara_backdrop_blur_val', 'onaraBackdropBlur', 5, v => Math.round(v) + 'px'); - setupAudioTuningSlider('onara_grid_dim_slider', 'onara_grid_dim_val', 'onaraGridDim', 0.15, v => Math.round(v * 100) + '%'); + setupAudioTuningSlider('onara_grid_dim_slider', 'onara_grid_dim_val', 'onaraGridDim', 0.38, v => Math.round(v * 100) + '%'); setupAudioTuningSlider('bg_canvas_opacity_slider', 'bg_canvas_opacity_val', 'bgCanvasOpacity', 1.00, v => Math.round(v * 100) + '%'); - setupAudioTuningSlider('bg_canvas_blur_slider', 'bg_canvas_blur_val', 'bgCanvasBlur', 300, v => Math.round(v) + 'px'); - setupAudioTuningSlider('bg_canvas_brightness_slider', 'bg_canvas_brightness_val', 'bgCanvasBrightness', 1.00, v => Math.round(v * 100) + '%'); + setupAudioTuningSlider('bg_canvas_blur_slider', 'bg_canvas_blur_val', 'bgCanvasBlur', 30, v => Math.round(v) + 'px'); + setupAudioTuningSlider('bg_canvas_brightness_slider', 'bg_canvas_brightness_val', 'bgCanvasBrightness', 0.45, v => Number(v).toFixed(2) + 'x'); + setupAudioTuningSlider('bg_canvas_saturate_slider', 'bg_canvas_saturate_val', 'bgCanvasSaturate', 1.8, v => Number(v).toFixed(2) + 'x'); + setupAudioTuningSlider('bg_canvas_contrast_slider', 'bg_canvas_contrast_val', 'bgCanvasContrast', 1.1, v => Number(v).toFixed(2) + 'x'); + setupAudioTuningSlider('bg_canvas_zoom_slider', 'bg_canvas_zoom_val', 'bgCanvasZoom', 1.12, v => Number(v).toFixed(2) + 'x'); + + // Blur Engine slider — shows friendly label text instead of raw number + const blurMethodNames = ['Canvas 2D', 'GPU CSS Filter', 'Hybrid']; + setupAudioTuningSlider('bg_blur_method_slider', 'bg_blur_method_val', 'bgBlurMethod', 0, v => blurMethodNames[Math.round(v)] || String(Math.round(v)), v => Math.round(Number(v))); // Quote Emojis Toggle const quoteEmojisToggle = document.getElementById('quote_emojis_toggle'); diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs index 856102b..9663625 100644 --- a/src/inc/queue.mjs +++ b/src/inc/queue.mjs @@ -411,6 +411,11 @@ export default new class queue { } if (!fetched) { console.error(`[QUEUE] YouTube thumbnail extraction failed for ${itemid}: all quality levels failed or returned placeholder`); + } else { + // Pre-warm dynamic ambient timeline in background + import('./routes/apiv2/ambient.mjs') + .then(m => m.getOrExtractYoutubeAmbient(videoId)) + .catch(() => {}); } } } diff --git a/src/inc/routes/apiv2/ambient.mjs b/src/inc/routes/apiv2/ambient.mjs new file mode 100644 index 0000000..8f94f13 --- /dev/null +++ b/src/inc/routes/apiv2/ambient.mjs @@ -0,0 +1,155 @@ +import { promises as fs } from "fs"; +import path from "path"; +import os from "os"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import cfg from "../../config.mjs"; + +const execFileAsync = promisify(execFile); + +// Memory cache for in-flight requests to avoid concurrent yt-dlp calls for the same video +const pendingRequests = new Map(); + +export async function getOrExtractYoutubeAmbient(videoId) { + // Validate videoId: YouTube IDs are 11 chars containing [a-zA-Z0-9_-] + if (!videoId || !/^[a-zA-Z0-9_-]{6,15}$/.test(videoId)) { + throw new Error("Invalid YouTube video ID"); + } + + let tDir = cfg.paths?.t || 'public/t'; + try { tDir = await fs.realpath(tDir); } catch (_) {} + const cacheFile = path.join(tDir, `ambient_${videoId}.json`); + + // 1. Check disk cache + try { + const cached = await fs.readFile(cacheFile, 'utf8'); + return JSON.parse(cached); + } catch (_) { + // Not cached yet + } + + // 2. De-duplicate concurrent requests + if (pendingRequests.has(videoId)) { + return pendingRequests.get(videoId); + } + + const promise = (async () => { + try { + const ytUrl = `https://www.youtube.com/watch?v=${videoId}`; + const proxyArgs = (cfg.main?.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') + ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] + : []; + + // Run yt-dlp to inspect formats + const { stdout } = await execFileAsync('yt-dlp', [ + '-j', + '--skip-download', + '--no-playlist', + ...proxyArgs, + ytUrl + ], { timeout: 15000 }); + + const info = JSON.parse(stdout); + const duration = Number(info.duration) || 0; + + // Find storyboard format (prefer sb3, fallback to sb2/sb1/sb0) + const sbFormats = (info.formats || []).filter(f => f.format_id && f.format_id.startsWith('sb')); + const sb = sbFormats.find(f => f.format_id === 'sb3') || + sbFormats.find(f => f.format_id === 'sb2') || + sbFormats[sbFormats.length - 1]; + + let colors = []; + + if (sb && (sb.url || (sb.fragments && sb.fragments[0]?.url))) { + const imgUrl = (sb.fragments && sb.fragments[0]?.url) + ? sb.fragments[0].url + : sb.url.replace('$M', '0'); + + const tmpImg = path.join(os.tmpdir(), `sb_${videoId}_${Date.now()}.jpg`); + const tmpRgb = path.join(os.tmpdir(), `sb_${videoId}_${Date.now()}.rgb`); + + try { + const curlProxyArgs = (cfg.main?.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') + ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] + : []; + await execFileAsync('curl', ['-s', '-L', imgUrl, '-o', tmpImg, ...curlProxyArgs], { timeout: 10000 }); + await execFileAsync('magick', [tmpImg, '-scale', '10x10!', `rgb:${tmpRgb}`], { timeout: 5000 }); + + const buf = await fs.readFile(tmpRgb); + for (let i = 0; i < buf.length; i += 3) { + colors.push([buf[i], buf[i + 1], buf[i + 2]]); + } + } finally { + await fs.unlink(tmpImg).catch(() => {}); + await fs.unlink(tmpRgb).catch(() => {}); + } + } + + // If storyboard wasn't available or yielded no colors, fallback to thumbnail sampling + if (!colors.length) { + // Fallback: download thumbnail and sample a 3x3 palette + const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; + const tmpImg = path.join(os.tmpdir(), `sb_thumb_${videoId}_${Date.now()}.jpg`); + const tmpRgb = path.join(os.tmpdir(), `sb_thumb_${videoId}_${Date.now()}.rgb`); + try { + await execFileAsync('curl', ['-s', '-L', thumbUrl, '-o', tmpImg], { timeout: 10000 }); + await execFileAsync('magick', [tmpImg, '-scale', '3x3!', `rgb:${tmpRgb}`], { timeout: 5000 }); + const buf = await fs.readFile(tmpRgb); + for (let i = 0; i < buf.length; i += 3) { + colors.push([buf[i], buf[i + 1], buf[i + 2]]); + } + } catch (_) { + // Minimal fallback + colors = [[30, 30, 35]]; + } finally { + await fs.unlink(tmpImg).catch(() => {}); + await fs.unlink(tmpRgb).catch(() => {}); + } + } + + const result = { + videoId, + duration, + colors + }; + + // Cache to disk + try { + await fs.writeFile(cacheFile, JSON.stringify(result), 'utf8'); + } catch (err) { + console.warn(`[AMBIENT] Failed to write cache for ${videoId}:`, err.message); + } + + return result; + } finally { + pendingRequests.delete(videoId); + } + })(); + + pendingRequests.set(videoId, promise); + return promise; +} + +export default (router) => { + router.get(/^\/api\/v2\/ambient\/yt\/([a-zA-Z0-9_-]+)/, async (req, res) => { + const videoId = req.url.pathname.split('/')[5]; + if (!videoId) { + return res.writeHead(400, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ error: 'Missing video ID' })); + } + + try { + const data = await getOrExtractYoutubeAmbient(videoId); + return res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800' + }).end(JSON.stringify(data)); + } catch (err) { + console.error(`[AMBIENT ERROR] Failed for ${videoId}:`, err.message); + return res.writeHead(500, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ error: err.message })); + } + }); + + return router; +}; diff --git a/src/index.mjs b/src/index.mjs index 7a52575..0a95190 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1837,6 +1837,7 @@ process.on('uncaughtException', err => { nsfl_tag_id: cfg.nsfl_tag_id || 3, scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'], themes_json: JSON.stringify(cfg.websrv.themes || []), + audio_tuner_json: JSON.stringify(cfg.audio_tuner || cfg.audio_visualizer || cfg.websrv?.audio_tuner || null), enable_profile_description: !!cfg.websrv.enable_profile_description, expose_repost_links_to_guests: !!(cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links), get private_messages() { return getPrivateMessages(); }, diff --git a/views/settings.html b/views/settings.html index 05f66ad..d11d6c8 100644 --- a/views/settings.html +++ b/views/settings.html @@ -255,9 +255,9 @@
- 84% + 70%
- + Overlay darkness in Onara mode (lower values allow background canvas to shine through).
@@ -273,9 +273,9 @@
- 15% + 38%
- + Control how clearly the background thumbnail grid shines through over the ambient canvas in Onara mode.
@@ -290,20 +290,56 @@
- - 300px + + 30px
- - Adjust the ambient blur of the background canvas (higher values create a soft, dreamy ambient glow). + + Adjust the ambient blur radius. Higher values = softer, dreamier glow. In GPU CSS mode this goes up to 300px at full resolution.
- - 100% + + 0.45x
- - Boost background canvas brightness so visuals pop through clearly. + + Boost brightness so colors pop. Values < 1 darken, > 1 amplify. +
+ +
+
+ + Canvas 2D +
+ + 0 = Canvas 2D (downsampled, fast)  |  1 = GPU CSS Filter (classic look, full-res compositor blur like older versions)  |  2 = Hybrid (CSS blur + canvas color grading) +
+ +
+
+ + 1.80x +
+ + Boost color saturation of the background. 1.0 = original, higher = more vivid / neon. +
+ +
+
+ + 1.10x +
+ + Contrast punch. Values > 1.0 darken blacks and pop highlights. +
+ +
+
+ + 1.12x +
+ + Zoom the background canvas slightly to hide blurry edge bleeding. 1.12 eliminates edge artifacts at typical blur values.
diff --git a/views/snippets/footer.html b/views/snippets/footer.html index b2fae90..0582b56 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -131,7 +131,7 @@ -
diff --git a/views/snippets/header.html b/views/snippets/header.html index 41dd858..4dbf63f 100644 --- a/views/snippets/header.html +++ b/views/snippets/header.html @@ -67,7 +67,7 @@ @endif @endif - + @if(!private_society || session) diff --git a/views/snippets/item-media.html b/views/snippets/item-media.html index 16ab7a3..129718e 100644 --- a/views/snippets/item-media.html +++ b/views/snippets/item-media.html @@ -59,7 +59,7 @@ @elseif(item.mime === 'video/youtube')
- +
@elseif(item.mime.startsWith("video"))