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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user