feat(ui): live VU + audio-driven spectrum, editorial banner, subtler dynamic bg

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.
This commit is contained in:
2026-04-25 02:03:15 +03:00
parent d157388a94
commit d937c1590c
4 changed files with 230 additions and 72 deletions
+79
View File
@@ -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');