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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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