From d937c1590c0636c8fb17b0002fdaf7f2200573ad Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 02:03:15 +0300 Subject: [PATCH] feat(ui): live VU + audio-driven spectrum, editorial banner, subtler dynamic bg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VU needle (animated) - Synthetic wobble bounded by current volume runs only while state==='playing'. Two combined sines + jitter make it look like a real analog needle reacting to peaks. - Settles back to the static volume-mapped position when paused. Spectrum (real audio) - Now driven by the same frequencyData the visualizer canvas uses. Each visual bar averages a chunk of frequency bins. - Spans are now JS-injected (60 bars) instead of hardcoded so the bar count is no longer baked in. - Spectrum spans full width of the masthead column, height bumped to 56px for presence. - CSS animation pauses (sets via `body.audio-spectrum-live`) when JS is driving heights so the keyframes don't fight. - Synthetic CSS animation remains as the fallback when audio capture isn't available. Visualizer auto-enable - On first install with loopback support, visualizer is enabled so the spectrum is alive out of the box. Dynamic background - Lower max opacity (1 → 0.45 dark, 0.35 light) - sepia + saturate filter + hue-rotate keep it palette-aligned with the copper editorial tones instead of fighting them - mix-blend-mode screen (dark) / multiply (light) blends into the page background instead of overlaying Update + connection banners - Fully restyled: glassy card with copper hairline accent, mono uppercase text, copper hairline-border CTA buttons, minimal close button. Matches the rest of the editorial palette instead of the old solid-green-bar look. --- media_server/static/css/styles.css | 183 +++++++++++++++++++---------- media_server/static/index.html | 12 +- media_server/static/js/app.js | 28 ++++- media_server/static/js/player.js | 79 +++++++++++++ 4 files changed, 230 insertions(+), 72 deletions(-) diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index aeaf8f9..3322eca 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -216,7 +216,7 @@ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08); } -/* Dynamic Background Canvas */ +/* Dynamic Background Canvas — editorial-toned (warm, subtle) */ .bg-shader-canvas { position: fixed; top: 0; @@ -227,10 +227,23 @@ pointer-events: none; opacity: 0; transition: opacity 0.6s ease; + /* Sepia + slight desaturation keeps the shader colors palette-aligned */ + filter: sepia(0.4) saturate(0.75) contrast(0.95) brightness(0.9) hue-rotate(-8deg); + /* Multiply blends the bright shader into the warm dark page background */ + mix-blend-mode: screen; } .bg-shader-canvas.visible { - opacity: 1; + /* Lower max opacity so it reads as atmosphere, not foreground */ + opacity: 0.45; +} + +:root[data-theme="light"] .bg-shader-canvas { + filter: sepia(0.35) saturate(0.7) contrast(1.05) brightness(1.05) hue-rotate(-12deg); + mix-blend-mode: multiply; +} +:root[data-theme="light"] .bg-shader-canvas.visible { + opacity: 0.35; } body.dynamic-bg-active { @@ -4368,31 +4381,101 @@ header .brand-sub { box-shadow: 0 0 12px var(--copper-glow); } -/* ─── Update + connection banners ───────────────────────────── */ +/* ─── Update + connection banners (editorial) ────────────────── */ .update-banner, .connection-banner { - background: var(--bg-card); - border: 1px solid var(--copper); - border-radius: 0; - color: var(--ink-soft); - box-shadow: 0 0 24px var(--copper-glow); - font-family: var(--sans); - font-size: 13px; + /* Override legacy fixed-top + accent background */ + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + z-index: 1001; + background: rgba(33, 30, 24, 0.94) !important; + color: var(--ink-soft) !important; + border: 0 !important; + border-bottom: 1px solid var(--copper) !important; + border-radius: 0 !important; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4) !important; + padding: 12px 32px !important; + font-family: var(--mono) !important; + font-size: 11px !important; + letter-spacing: 0.12em !important; + text-transform: uppercase !important; + font-weight: 400 !important; + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + display: flex; + align-items: center; + justify-content: center; + gap: 18px; } -.update-banner a, -.connection-banner-btn { +/* Tiny copper hairline accent on the bottom edge */ +.update-banner::before, +.connection-banner::before { + content: ""; + position: absolute; + bottom: -1px; + left: 32px; + right: 32px; + height: 1px; + background: linear-gradient(90deg, transparent, var(--copper), transparent); + opacity: 0.7; +} +/* Brand prefix */ +.update-banner > span:first-child::before, +.connection-banner > span:first-child::before { + content: "● "; color: var(--copper); - font-family: var(--mono); - font-size: 11px; - letter-spacing: 0.12em; - text-transform: uppercase; - border-color: var(--copper); - border-radius: 0; - background: transparent; + margin-right: 6px; +} +.update-banner a { + color: var(--copper) !important; + text-decoration: none !important; + font-family: var(--mono) !important; + font-size: 11px !important; + letter-spacing: 0.18em !important; + text-transform: uppercase !important; + font-weight: 500 !important; + border: 1px solid var(--copper); + padding: 6px 14px; + transition: all 180ms var(--ease); +} +.update-banner a:hover { + background: var(--copper); + color: var(--bg-deep) !important; + opacity: 1 !important; +} +.update-banner-close { + background: transparent !important; + color: var(--ink-mute) !important; + border: 0 !important; + font-size: 18px !important; + width: 24px; + height: 24px; + padding: 0 !important; + margin-left: 8px; + cursor: pointer; + transition: color 180ms var(--ease); + opacity: 1 !important; +} +.update-banner-close:hover { + color: var(--copper) !important; +} +.connection-banner-btn { + color: var(--copper) !important; + font-family: var(--mono) !important; + font-size: 11px !important; + letter-spacing: 0.18em !important; + text-transform: uppercase !important; + border: 1px solid var(--copper) !important; + border-radius: 0 !important; + background: transparent !important; + padding: 6px 14px !important; + transition: all 180ms var(--ease); } .connection-banner-btn:hover { - background: var(--copper); - color: var(--bg-deep); + background: var(--copper) !important; + color: var(--bg-deep) !important; } /* ═══════════════════════════════════════════════════════════════ @@ -6753,61 +6836,39 @@ body.visualizer-active .now-playing .spectrogram-canvas { fill: currentColor; } -/* ─── Spectrum bars ───────────────────────────────────────── */ +/* ─── Spectrum bars (JS-injected; real audio when available) ─── */ .now-playing .spectrum { display: flex; align-items: flex-end; - justify-content: center; - gap: 3px; - height: 38px; - margin: 36px auto 24px; - max-width: 480px; + justify-content: stretch; + gap: 2px; + height: 56px; + margin: 36px 0 24px; + width: 100%; } .now-playing .spectrum span { display: block; - width: 3px; - flex: 0 0 3px; + flex: 1 1 0; + min-width: 2px; background: linear-gradient(to top, var(--copper-lo), var(--copper-hi)); - opacity: 0.85; + opacity: 0.9; transform-origin: bottom; border-radius: 99px 99px 0 0; + height: var(--bar-h, 40%); animation: sr-snap-bar 1.1s ease-in-out infinite; + animation-delay: var(--bar-delay, 0s); animation-play-state: paused; + transition: height 80ms linear; } :root[data-playstate="playing"] .now-playing .spectrum span { animation-play-state: running; } -.now-playing .spectrum span:nth-child(1) { animation-delay: -0.10s; height: 30%; } -.now-playing .spectrum span:nth-child(2) { animation-delay: -0.45s; height: 60%; } -.now-playing .spectrum span:nth-child(3) { animation-delay: -0.20s; height: 80%; } -.now-playing .spectrum span:nth-child(4) { animation-delay: -0.55s; height: 50%; } -.now-playing .spectrum span:nth-child(5) { animation-delay: -0.30s; height: 95%; } -.now-playing .spectrum span:nth-child(6) { animation-delay: -0.05s; height: 70%; } -.now-playing .spectrum span:nth-child(7) { animation-delay: -0.65s; height: 40%; } -.now-playing .spectrum span:nth-child(8) { animation-delay: -0.25s; height: 85%; } -.now-playing .spectrum span:nth-child(9) { animation-delay: -0.40s; height: 55%; } -.now-playing .spectrum span:nth-child(10) { animation-delay: -0.10s; height: 75%; } -.now-playing .spectrum span:nth-child(11) { animation-delay: -0.50s; height: 35%; } -.now-playing .spectrum span:nth-child(12) { animation-delay: -0.15s; height: 90%; } -.now-playing .spectrum span:nth-child(13) { animation-delay: -0.60s; height: 45%; } -.now-playing .spectrum span:nth-child(14) { animation-delay: -0.30s; height: 65%; } -.now-playing .spectrum span:nth-child(15) { animation-delay: -0.45s; height: 85%; } -.now-playing .spectrum span:nth-child(16) { animation-delay: -0.20s; height: 55%; } -.now-playing .spectrum span:nth-child(17) { animation-delay: -0.55s; height: 70%; } -.now-playing .spectrum span:nth-child(18) { animation-delay: -0.10s; height: 30%; } -.now-playing .spectrum span:nth-child(19) { animation-delay: -0.40s; height: 80%; } -.now-playing .spectrum span:nth-child(20) { animation-delay: -0.25s; height: 60%; } -.now-playing .spectrum span:nth-child(21) { animation-delay: -0.50s; height: 90%; } -.now-playing .spectrum span:nth-child(22) { animation-delay: -0.15s; height: 50%; } -.now-playing .spectrum span:nth-child(23) { animation-delay: -0.60s; height: 70%; } -.now-playing .spectrum span:nth-child(24) { animation-delay: -0.30s; height: 40%; } -.now-playing .spectrum span:nth-child(25) { animation-delay: -0.45s; height: 85%; } -.now-playing .spectrum span:nth-child(26) { animation-delay: -0.20s; height: 55%; } -.now-playing .spectrum span:nth-child(27) { animation-delay: -0.55s; height: 75%; } -.now-playing .spectrum span:nth-child(28) { animation-delay: -0.10s; height: 35%; } -.now-playing .spectrum span:nth-child(29) { animation-delay: -0.40s; height: 65%; } -.now-playing .spectrum span:nth-child(30) { animation-delay: -0.25s; height: 95%; } - +/* When real audio data is driving heights, freeze the CSS animation + so JS-set heights aren't overridden by the keyframe. */ +body.audio-spectrum-live .now-playing .spectrum span { + animation: none !important; + transition: height 50ms linear; +} @keyframes sr-snap-bar { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } diff --git a/media_server/static/index.html b/media_server/static/index.html index 42953ed..4453337 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -216,15 +216,9 @@ - - + +
diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index 410e5ed..318f18e 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -166,9 +166,33 @@ window.addEventListener('DOMContentLoaded', async () => { // Vinyl is now structural / always-on via CSS — no init call needed. // applyVinylMode(); - // Initialize audio visualizer + // Build the editorial spectrum bars (60 spans). JS-managed so we can + // drive heights from real audio data when available. + const spectrumRoot = document.getElementById('player-spectrum'); + if (spectrumRoot && !spectrumRoot.children.length) { + const SPECTRUM_BARS = 60; + const frag = document.createDocumentFragment(); + for (let i = 0; i < SPECTRUM_BARS; i++) { + const s = document.createElement('span'); + // Pseudo-random heights for the synthetic CSS animation phase + s.style.setProperty('--bar-h', (25 + Math.abs(Math.sin(i * 0.7)) * 70).toFixed(0) + '%'); + s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's'); + frag.appendChild(s); + } + spectrumRoot.appendChild(frag); + } + + // Initialize audio visualizer — auto-enable when supported so the + // spectrum shows real audio out of the box. checkVisualizerAvailability().then(() => { - if (visualizerEnabled && visualizerAvailable) { + if (visualizerAvailable && !visualizerEnabled) { + // Auto-enable on first install if loopback capture works. + if (localStorage.getItem('visualizerEnabled') === null) { + localStorage.setItem('visualizerEnabled', 'true'); + } + } + if ((visualizerEnabled || localStorage.getItem('visualizerEnabled') === 'true') + && visualizerAvailable) { applyVisualizerMode(); } }); diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index 261b0bb..68f00df 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -366,6 +366,11 @@ export function stopVisualizerRender() { if (glow) glow.style.opacity = ''; frequencyData = null; smoothedFrequencies = null; + document.body.classList.remove('audio-spectrum-live'); + // Reset spectrum bar heights so the synthetic CSS animation takes back over + document.querySelectorAll('.now-playing .spectrum > span').forEach(s => { + s.style.height = ''; + }); } function renderVisualizerFrame() { @@ -422,6 +427,30 @@ function renderVisualizerFrame() { if (glow) { glow.style.opacity = (0.4 + bass * 0.4).toFixed(2); } + + // Drive the editorial .spectrum bars from the same frequency data. + updateEditorialSpectrum(smoothedFrequencies, numBins); +} + +// ─── Editorial spectrum (.spectrum bars) driven by audio ────── +function updateEditorialSpectrum(bins, numBins) { + const root = document.querySelector('.now-playing .spectrum'); + if (!root) return; + const bars = root.children; + const barCount = bars.length; + if (!barCount) return; + document.body.classList.add('audio-spectrum-live'); + for (let i = 0; i < barCount; i++) { + // Map each visual bar to a chunk of frequency bins (averaged). + const startIdx = Math.floor((i / barCount) * numBins); + const endIdx = Math.max(startIdx + 1, Math.floor(((i + 1) / barCount) * numBins)); + let sum = 0; + for (let j = startIdx; j < endIdx && j < numBins; j++) sum += bins[j]; + const avg = sum / (endIdx - startIdx); + // Boost mids/highs and floor to 6% so quiet bars are still visible. + const pct = Math.max(6, Math.min(100, avg * 110)); + bars[i].style.height = pct + '%'; + } } // Audio device selection @@ -711,10 +740,60 @@ export function updateUI(status) { } } +// ─── VU needle synthetic wobble ────────────────────────────── +// Real audio-level analysis is only available when the visualizer +// is enabled. For the common case (visualizer off), drive the needle +// with a low-frequency pseudo-random walk that's bounded by current +// volume, so it looks alive without being noisy. +let vuWobbleHandle = null; +let vuWobbleStart = 0; + +function startVuWobble() { + if (vuWobbleHandle) return; + vuWobbleStart = performance.now(); + const tick = () => { + const needle = document.getElementById('vuNeedle'); + if (needle) { + const slider = document.getElementById('volume-slider'); + const vol = slider ? Number(slider.value) || 0 : 0; + const base = -45 + (vol / 100) * 90; + // Wobble magnitude scales with volume, capped at ~12deg either way. + const mag = Math.max(2, Math.min(14, vol * 0.16)); + const t = (performance.now() - vuWobbleStart) / 1000; + // Two combined sines + a tiny random component for organic motion. + const wobble = + Math.sin(t * 6.3) * mag * 0.55 + + Math.sin(t * 11.7 + 1.3) * mag * 0.30 + + (Math.random() - 0.5) * mag * 0.30; + needle.style.transform = `rotate(${base + wobble}deg)`; + } + vuWobbleHandle = requestAnimationFrame(tick); + }; + vuWobbleHandle = requestAnimationFrame(tick); +} + +function stopVuWobble() { + if (vuWobbleHandle) { + cancelAnimationFrame(vuWobbleHandle); + vuWobbleHandle = null; + } + // Settle needle back to the static volume-mapped position. + const needle = document.getElementById('vuNeedle'); + const slider = document.getElementById('volume-slider'); + if (needle && slider) { + const vol = Number(slider.value) || 0; + const base = -45 + (vol / 100) * 90; + needle.style.transform = `rotate(${base}deg)`; + } +} + export function updatePlaybackState(state) { setCurrentPlayState(state); // Expose state to CSS so tonearm / vinyl spin can react. document.documentElement.dataset.playstate = state; + // Drive the VU needle wobble — running only while playing. + if (state === 'playing') startVuWobble(); + else stopVuWobble(); switch(state) { case 'playing': dom.playbackState.textContent = t('state.playing');