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:
@@ -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); }
|
||||
|
||||
@@ -216,15 +216,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative spectrum bars -->
|
||||
<div class="spectrum" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<!-- Spectrum bars — driven by real audio when visualizer is active,
|
||||
CSS-animated synthetic motion otherwise. JS injects the spans. -->
|
||||
<div class="spectrum" id="player-spectrum" aria-hidden="true"></div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div class="transport">
|
||||
|
||||
@@ -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