perf(visualizer): cut spectrum + track-switch CPU significantly
Lint & Test / test (push) Successful in 10s
Lint & Test / test (push) Successful in 10s
Frontend hot path (player.js, background.js):
- visualizer rAF: drop per-frame getComputedStyle('--accent') (cached on
applyAccentColor), build canvas LinearGradient once per accent change
instead of 32× per frame, batch all bars into a single beginPath/fill
- FPS-gate canvas redraw via frequencyDataVersion so 60-144 Hz monitors
stop re-rendering identical frames produced at 30 Hz on the backend
- editorial spectrum bars: replace style.height (layout) with
transform: scaleY (compositor-only); cache bar refs, pre-compute
per-bar gain/range, dedup writes at 1/1000 quantization
- coalesce VU needle into the visualizer rAF; cache vuNeedle ref;
dedup angle writes at 0.1°
- updateUI: status-payload fingerprint short-circuits the redundant
status_update broadcasts that fire during a track change
- swapArtworkSrc: only force layout reflow when keyframe is in flight;
drop the ?_=Date.now() cache-buster so identical artwork URLs reuse
the decoded bitmap; mini/glow imgs only re-set src when changed
- drop the fullscreen MutationObserver — fs-bloom-art is mirrored
directly from the artwork-swap path, eliminating the second blur paint
- updateProgress: skip text writes when the rounded second hasn't moved;
POSITION_INTERPOLATION_MS 100 → 250
- background.js: lift resizeBackgroundCanvas out of the rAF body, cache
step, accept new int-scaled wire format
CSS:
- spectrum bars use transform: scaleY(var(--bar-h-scale)) + transition
on transform; will-change updated to transform
- album-art-glow and fs-bloom-art switched to small-source-blur trick
(render at 20-25% size, scale 4-6×, lower blur radius) — visually
equivalent, ~10-25× cheaper repaint on track change
- drop unused transition: filter on .vinyl-stage #album-art
Backend (audio_analyzer.py, websocket_manager.py):
- pre-allocate windowed and cumsum buffers; replace
np.concatenate(([0.0], np.cumsum(...))) with cumsum[0]=0 +
np.cumsum(out=cumsum[1:]); float32 hanning window
- RMS via np.dot(mono, mono) — no astype copy, no ** temp
- int16 wire format (scale=1000) — smaller JSON, no Python float boxing
- versioned data + threading.Event so _audio_broadcast_loop is event-
driven (ev.wait + monotonic seq dedup) instead of polling on a timer
with the always-false `data is _last_data` identity check
ruff clean, pytest 7 passed / 3 numpy-skipped, esbuild bundle 113.6 kB.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Audio spectrum analyzer service using system loopback capture."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
@@ -71,6 +72,14 @@ class AudioAnalyzer:
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# Generation counter — bumped each time _data is refreshed.
|
||||
# Lets the broadcast loop dedupe without comparing dict identity
|
||||
# (which is fragile because we always allocate a new dict).
|
||||
self._data_seq = 0
|
||||
# Threading.Event signaled when new frame data is available.
|
||||
# The broadcast loop awaits this instead of polling on a timer,
|
||||
# so it wakes up exactly once per produced frame.
|
||||
self._data_event = threading.Event()
|
||||
# Slow AGC envelope so the spectrum reflects real dynamics
|
||||
# instead of being renormalized to peak=1.0 every frame.
|
||||
# A loud transient (e.g. notification beep) lifts the reference
|
||||
@@ -128,17 +137,30 @@ class AudioAnalyzer:
|
||||
"""Stop audio capture and cleanup."""
|
||||
with self._lifecycle_lock:
|
||||
self._running = False
|
||||
# Wake any waiter so it can observe _running and exit cleanly.
|
||||
self._data_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
with self._lock:
|
||||
self._data = None
|
||||
self._data_event.clear()
|
||||
|
||||
def get_frequency_data(self) -> dict | None:
|
||||
"""Return latest frequency data (thread-safe). None if not running."""
|
||||
with self._lock:
|
||||
return self._data
|
||||
|
||||
def get_frequency_data_versioned(self) -> tuple[dict | None, int]:
|
||||
"""Return (data, seq) so callers can dedupe without identity tricks."""
|
||||
with self._lock:
|
||||
return self._data, self._data_seq
|
||||
|
||||
@property
|
||||
def data_event(self) -> threading.Event:
|
||||
"""Event signaled when a fresh frame is ready. Caller must clear()."""
|
||||
return self._data_event
|
||||
|
||||
@staticmethod
|
||||
def list_loopback_devices() -> list[dict[str, str]]:
|
||||
"""List all available loopback audio devices."""
|
||||
@@ -250,12 +272,24 @@ class AudioAnalyzer:
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
window = np.hanning(self.chunk_size)
|
||||
# Float32 window — matches soundcard's typical buffer dtype and
|
||||
# halves FFT memory traffic vs. the default float64.
|
||||
window = np.hanning(self.chunk_size).astype(np.float32)
|
||||
|
||||
# Pre-compute bin edge pairs for vectorized grouping
|
||||
edges = self._bin_edges
|
||||
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
|
||||
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
|
||||
# Counts are constant — compute once.
|
||||
bin_counts = (bin_ends - bin_starts).astype(np.float32)
|
||||
|
||||
# Pre-allocate working buffers so the per-frame allocator churn
|
||||
# on the capture thread (which runs at target_fps Hz, hours on
|
||||
# end) drops to zero copies for these arrays.
|
||||
fft_size = self.chunk_size // 2 + 1
|
||||
windowed = np.empty(self.chunk_size, dtype=np.float32)
|
||||
cumsum = np.empty(fft_size + 1, dtype=np.float32)
|
||||
cumsum[0] = 0.0
|
||||
|
||||
try:
|
||||
with device.recorder(
|
||||
@@ -284,21 +318,23 @@ class AudioAnalyzer:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Apply window and compute FFT
|
||||
windowed = mono[:self.chunk_size] * window
|
||||
# Apply window in-place into the pre-allocated buffer.
|
||||
np.multiply(mono[:self.chunk_size], window, out=windowed)
|
||||
fft_mag = np.abs(np.fft.rfft(windowed))
|
||||
|
||||
# Group into logarithmic bins (vectorized via cumsum)
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
|
||||
counts = bin_ends - bin_starts
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
||||
# Group into logarithmic bins (vectorized via cumsum).
|
||||
# Write into the pre-allocated [1:] slice so cumsum[0]
|
||||
# stays 0.0 and we never allocate a new array.
|
||||
np.cumsum(fft_mag, out=cumsum[1:])
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts
|
||||
|
||||
# True loudness from time-domain RMS, mapped via dB
|
||||
# so the VU needle reflects actual program level — not
|
||||
# the per-frame-normalized spectrum.
|
||||
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
|
||||
if rms > 1e-6:
|
||||
db = 20.0 * np.log10(rms)
|
||||
# True loudness from time-domain RMS via single BLAS
|
||||
# dot — avoids astype() and ** allocations.
|
||||
mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
|
||||
energy = float(np.dot(mono32, mono32))
|
||||
if energy > 1e-12:
|
||||
rms = (energy / mono32.size) ** 0.5
|
||||
db = 20.0 * math.log10(rms)
|
||||
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
||||
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
||||
else:
|
||||
@@ -314,18 +350,33 @@ class AudioAnalyzer:
|
||||
else:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
|
||||
ref = max(self._spectrum_ref, 1e-4)
|
||||
bins = np.clip(bins / ref, 0.0, 1.5)
|
||||
np.divide(bins, ref, out=bins)
|
||||
np.clip(bins, 0.0, 1.5, out=bins)
|
||||
|
||||
# Bass energy: average of first 4 bins (~20-200Hz)
|
||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||
|
||||
# Round for compact JSON
|
||||
frequencies = np.round(bins, 3).tolist()
|
||||
bass = round(bass, 3)
|
||||
level = round(level, 3)
|
||||
# Quantize to 0..1000 ints — same wire fidelity as
|
||||
# 3-decimal floats but smaller GC churn on both ends
|
||||
# (frontend smooths anyway, so quantization is
|
||||
# invisible). JSON encodes ints faster than floats.
|
||||
frequencies = (bins * 1000.0).astype(np.int16).tolist()
|
||||
bass_i = int(bass * 1000.0)
|
||||
level_i = int(level * 1000.0)
|
||||
|
||||
new_data = {
|
||||
"frequencies": frequencies,
|
||||
"bass": bass_i,
|
||||
"level": level_i,
|
||||
# Wire-format flag: clients that see this know
|
||||
# values are 0..1000 ints, not 0..1 floats.
|
||||
"scale": 1000,
|
||||
}
|
||||
with self._lock:
|
||||
self._data = {"frequencies": frequencies, "bass": bass, "level": level}
|
||||
self._data = new_data
|
||||
self._data_seq += 1
|
||||
# Wake any broadcast loop waiting on fresh data.
|
||||
self._data_event.set()
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -161,26 +161,48 @@ class ConnectionManager:
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
||||
from ..config import settings
|
||||
interval = 1.0 / settings.visualizer_fps
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
|
||||
|
||||
_last_data = None
|
||||
Event-driven: blocks on the analyzer's data_event so it wakes up
|
||||
exactly once per produced frame, instead of polling on a timer.
|
||||
Backstop sleep applies when capture is idle / has no subscribers.
|
||||
"""
|
||||
from ..config import settings
|
||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||
wake_timeout = max(0.05, idle_interval)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
last_seq = -1
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self._lock:
|
||||
subscribers = list(self._visualizer_subscribers)
|
||||
|
||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
||||
await asyncio.sleep(interval)
|
||||
analyzer = self._audio_analyzer
|
||||
if not subscribers or not analyzer or not analyzer.running:
|
||||
await asyncio.sleep(idle_interval)
|
||||
continue
|
||||
|
||||
data = self._audio_analyzer.get_frequency_data()
|
||||
if data is None or data is _last_data:
|
||||
await asyncio.sleep(interval)
|
||||
# Wait off-loop for a fresh frame. The capture thread sets
|
||||
# data_event after each FFT update; we clear it before the
|
||||
# next wait so we never burn a wake on stale data.
|
||||
ev = analyzer.data_event
|
||||
|
||||
def _wait() -> bool:
|
||||
return ev.wait(wake_timeout)
|
||||
|
||||
got = await loop.run_in_executor(None, _wait)
|
||||
if not got:
|
||||
# Timeout — loop around to re-check subscriber state.
|
||||
continue
|
||||
_last_data = data
|
||||
ev.clear()
|
||||
|
||||
data, seq = analyzer.get_frequency_data_versioned()
|
||||
if data is None or seq == last_seq:
|
||||
continue
|
||||
last_seq = seq
|
||||
|
||||
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||
@@ -198,13 +220,11 @@ class ConnectionManager:
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
await asyncio.sleep(idle_interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
|
||||
@@ -4599,23 +4599,31 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
metadata lives in the masthead beside the stage.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Glow: soft ambient halo behind the sleeve */
|
||||
/* Glow: soft ambient halo behind the sleeve.
|
||||
Performance trick: render the image at 25% × 25% of the stage and
|
||||
stretch it via transform: scale(4). Filter runs on the smaller
|
||||
element (16× less area), and scale just upsamples the already-blurred
|
||||
result. Visually identical to a blur(34px) over the full-size image
|
||||
but ~10-16× cheaper on track-switch repaints. */
|
||||
.vinyl-stage > #album-art-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 25%;
|
||||
height: 25%;
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
filter: blur(34px) saturate(1.6);
|
||||
filter: blur(9px) saturate(1.6);
|
||||
opacity: 0.45;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(1.05);
|
||||
transform: translate(-50%, -50%) scale(4.2);
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
:root[data-theme="light"] .vinyl-stage > #album-art-glow {
|
||||
opacity: 0.26;
|
||||
filter: blur(40px) saturate(1.8);
|
||||
filter: blur(10px) saturate(1.8);
|
||||
}
|
||||
|
||||
/* Honour reduced-motion: kill breathing pulse */
|
||||
@@ -4683,7 +4691,8 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
filter: contrast(0.96) saturate(0.92);
|
||||
transition: filter 0.6s ease;
|
||||
/* No transition on filter: the values are static and a 600ms ease
|
||||
on a track-switch swap would force a long compositor pass. */
|
||||
}
|
||||
|
||||
/* Crossfade on artwork swap. Class toggled by player.js right before
|
||||
@@ -6576,6 +6585,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.now-playing { grid-template-columns: 1fr; gap: 40px; }
|
||||
.now-playing .track-masthead { padding-right: 0; }
|
||||
}
|
||||
|
||||
/* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under
|
||||
@@ -6598,6 +6608,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-top: 0;
|
||||
padding-right: clamp(12px, 1.5vw, 24px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@@ -6702,7 +6713,9 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
/* Match the .kicker (NOW PLAYING) color so the metadata grid reads
|
||||
as part of the same typographic system. */
|
||||
color: var(--copper);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.now-playing .meta-cell .value {
|
||||
@@ -6753,21 +6766,24 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
opacity: 0.92;
|
||||
transform-origin: bottom;
|
||||
border-radius: 99px 99px 0 0;
|
||||
height: var(--bar-h, 40%);
|
||||
/* Bars are full-height boxes; the visible height is driven by
|
||||
transform: scaleY. Transforms are GPU-composited, so per-frame
|
||||
updates skip layout/paint entirely. */
|
||||
height: 100%;
|
||||
transform: scaleY(var(--bar-h-scale, 0.4));
|
||||
animation: sr-snap-bar 1.1s ease-in-out infinite;
|
||||
animation-delay: var(--bar-delay, 0s);
|
||||
animation-play-state: paused;
|
||||
transition: height 60ms linear;
|
||||
will-change: height;
|
||||
transition: transform 50ms linear;
|
||||
will-change: transform;
|
||||
}
|
||||
:root[data-playstate="playing"] .now-playing .spectrum span {
|
||||
animation-play-state: running;
|
||||
}
|
||||
/* When real audio data is driving heights, freeze the CSS animation
|
||||
so JS-set heights aren't overridden by the keyframe. */
|
||||
/* When real audio data is driving the bars, freeze the synthetic CSS
|
||||
animation so JS-set transforms 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); }
|
||||
@@ -6949,7 +6965,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
.now-playing .vu-volume #volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px;
|
||||
width: 64px;
|
||||
height: 2px;
|
||||
background: var(--rule-strong);
|
||||
border-radius: 0;
|
||||
@@ -6985,7 +7001,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
|
||||
.now-playing .vu-meter {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
width: 120px;
|
||||
height: 60px;
|
||||
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
|
||||
border: 1px solid var(--rule-strong);
|
||||
@@ -7044,6 +7060,28 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Light theme — paper-faced VU meter (vintage cream gauge instead of black) */
|
||||
:root[data-theme="light"] .now-playing .vu-meter {
|
||||
background: linear-gradient(180deg, #FAF6EE 0%, #E8E0CE 100%);
|
||||
border-color: var(--rule-strong);
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(26, 23, 21, 0.08),
|
||||
inset 0 0 24px rgba(31, 78, 61, 0.05);
|
||||
}
|
||||
:root[data-theme="light"] .now-playing .vu-meter::before {
|
||||
background: repeating-conic-gradient(from 195deg at 50% 100%,
|
||||
transparent 0deg 4deg,
|
||||
rgba(26, 23, 21, 0.18) 4deg 5deg,
|
||||
transparent 5deg 9deg);
|
||||
}
|
||||
:root[data-theme="light"] .now-playing .vu-meter::after {
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
:root[data-theme="light"] .now-playing .vu-needle {
|
||||
background: linear-gradient(to top, var(--copper) 0%, var(--copper-lo) 70%, var(--ink) 100%);
|
||||
box-shadow: 0 0 6px rgba(31, 78, 61, 0.25);
|
||||
}
|
||||
|
||||
/* Mobile VU cluster: stack below controls */
|
||||
@media (max-width: 720px) {
|
||||
.now-playing .controls { flex-wrap: wrap; }
|
||||
@@ -8771,18 +8809,27 @@ body.is-fullscreen-player .fs-bloom {
|
||||
to { opacity: 0.22; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Performance trick (matches the vinyl-stage glow): render the bloom
|
||||
image at 20% of the viewport and stretch it via scale(~6). Blur runs
|
||||
over a 25× smaller area, so a track-switch repaint of the bloom
|
||||
collapses from O(viewport-pixels × 110² ) to O(viewport/25 × 18²). */
|
||||
body.is-fullscreen-player .fs-bloom #fs-bloom-art {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20%;
|
||||
height: 20%;
|
||||
object-fit: cover;
|
||||
filter: blur(110px) saturate(1.6);
|
||||
transform: scale(1.18);
|
||||
filter: blur(18px) saturate(1.6);
|
||||
transform: translate(-50%, -50%) scale(5.9);
|
||||
transform-origin: center;
|
||||
animation: fs-bloom-drift 28s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes fs-bloom-drift {
|
||||
from { transform: scale(1.18) translate3d(-1.5%, -1%, 0); }
|
||||
to { transform: scale(1.22) translate3d(2%, 1.5%, 0); }
|
||||
from { transform: translate(-50%, -50%) scale(5.9) translate3d(-1.5%, -1%, 0); }
|
||||
to { transform: translate(-50%, -50%) scale(6.1) translate3d(2%, 1.5%, 0); }
|
||||
}
|
||||
|
||||
/* Subtle paper-grain veil over the bloom — keeps it from looking flat. */
|
||||
|
||||
@@ -180,8 +180,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
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) + '%');
|
||||
// Pseudo-random initial scaleY for the synthetic CSS-only
|
||||
// animation (used while no real audio is flowing).
|
||||
const scale = (0.25 + Math.abs(Math.sin(i * 0.7)) * 0.70).toFixed(2);
|
||||
s.style.setProperty('--bar-h-scale', scale);
|
||||
s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's');
|
||||
frag.appendChild(s);
|
||||
}
|
||||
|
||||
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
|
||||
|
||||
// ---- Render loop ----
|
||||
|
||||
// Cached step into the bins array; recomputed only when bins.length
|
||||
// changes (which happens at most once after the first audio frame
|
||||
// arrives or when num_bins is reconfigured).
|
||||
let bgBinsLength = -1;
|
||||
let bgBinsStep = 1;
|
||||
// Last applied resolution — drawing with stale viewport is harmless,
|
||||
// but we still need to refresh the uniform after the resize listener
|
||||
// has updated the canvas.
|
||||
let bgLastResW = -1;
|
||||
let bgLastResH = -1;
|
||||
|
||||
function renderBackgroundFrame() {
|
||||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||||
|
||||
const gl = bgGL;
|
||||
if (!gl || !bgUniforms) return;
|
||||
|
||||
resizeBackgroundCanvas();
|
||||
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
|
||||
// Resize listener already keeps canvas dimensions in sync — only
|
||||
// touch the viewport when the canvas actually changed size, so the
|
||||
// per-frame path doesn't read window.innerWidth (a layout-flushing
|
||||
// property).
|
||||
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
|
||||
bgLastResW = bgCanvas.width;
|
||||
bgLastResH = bgCanvas.height;
|
||||
gl.viewport(0, 0, bgLastResW, bgLastResH);
|
||||
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
|
||||
}
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer)
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer).
|
||||
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
const scale = frequencyData.scale && frequencyData.scale > 0
|
||||
? 1.0 / frequencyData.scale : 1.0;
|
||||
if (bins.length !== bgBinsLength) {
|
||||
bgBinsLength = bins.length;
|
||||
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
|
||||
}
|
||||
const step = bgBinsStep;
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
const idx = Math.min(i * step, bins.length - 1);
|
||||
const target = bins[idx] || 0;
|
||||
let idx = i * step;
|
||||
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
|
||||
const target = (bins[idx] || 0) * scale;
|
||||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
||||
}
|
||||
const targetBass = frequencyData.bass || 0;
|
||||
const targetBass = (frequencyData.bass || 0) * scale;
|
||||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||
} else {
|
||||
// Gentle decay when no audio
|
||||
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
|
||||
}
|
||||
|
||||
// Set uniforms (locations cached at init, colors cached on change)
|
||||
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
|
||||
gl.uniform1f(bgUniforms.time, time);
|
||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||
|
||||
@@ -140,7 +140,10 @@ export function cacheDom() {
|
||||
|
||||
// Timing constants
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
export const POSITION_INTERPOLATION_MS = 100;
|
||||
// 250ms is plenty for sub-second progress; the inline updateProgress
|
||||
// also short-circuits when the rounded second hasn't moved, so there's
|
||||
// no visible difference for the user.
|
||||
export const POSITION_INTERPOLATION_MS = 250;
|
||||
export const SEARCH_DEBOUNCE_MS = 200;
|
||||
export const TOAST_DURATION_MS = 3000;
|
||||
export const WS_BACKOFF_BASE_MS = 3000;
|
||||
|
||||
+303
-123
@@ -165,6 +165,9 @@ export function applyAccentColor(color, hover) {
|
||||
const dot = document.getElementById('accentDot');
|
||||
if (dot) dot.style.background = color;
|
||||
updateBackgroundColors();
|
||||
// Refresh the cached accent in the visualizer so the gradient
|
||||
// rebuilds on its next frame instead of querying CSS every frame.
|
||||
refreshVisualizerAccent();
|
||||
}
|
||||
|
||||
export function renderAccentSwatches() {
|
||||
@@ -215,12 +218,49 @@ export function setVisualizerEnabled(value) {
|
||||
visualizerEnabled = !!value;
|
||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||
}
|
||||
let visualizerCanvas = null; // Cached canvas DOM ref
|
||||
let visualizerCtx = null;
|
||||
let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize)
|
||||
let visualizerAnimFrame = null;
|
||||
export let frequencyData = null;
|
||||
export function setFrequencyData(value) { frequencyData = value; }
|
||||
export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled)
|
||||
let frequencyDataVersion = 0; // Bumped on every setFrequencyData
|
||||
let lastRenderedVersion = -1; // Last version rendered in renderVisualizerFrame
|
||||
let frequenciesScale = 1.0; // Backend scale factor (1000 → ints, 1 → floats)
|
||||
export function setFrequencyData(value) {
|
||||
frequencyData = value;
|
||||
frequencyDataVersion++;
|
||||
// Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale).
|
||||
if (value && typeof value.scale === 'number' && value.scale > 0) {
|
||||
frequenciesScale = 1.0 / value.scale;
|
||||
} else {
|
||||
frequenciesScale = 1.0;
|
||||
}
|
||||
}
|
||||
let smoothedFrequencies = null;
|
||||
const VISUALIZER_SMOOTHING = 0.15;
|
||||
// Cached accent — refreshed by applyAccentColor() rather than on every frame.
|
||||
let cachedAccentHex = '#1db954';
|
||||
let cachedAccentRGB = '29,185,84';
|
||||
function parseAccentHex(hex) {
|
||||
const h = (hex || '').trim().replace('#', '');
|
||||
if (h.length < 6) return null;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
return `${r},${g},${b}`;
|
||||
}
|
||||
export function refreshVisualizerAccent() {
|
||||
const accentHex = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
if (accentHex) {
|
||||
cachedAccentHex = accentHex;
|
||||
const rgb = parseAccentHex(accentHex);
|
||||
if (rgb) cachedAccentRGB = rgb;
|
||||
}
|
||||
// Force gradient rebuild on next frame.
|
||||
visualizerGradient = null;
|
||||
}
|
||||
|
||||
export async function checkVisualizerAvailability() {
|
||||
try {
|
||||
@@ -274,15 +314,28 @@ export function applyVisualizerMode() {
|
||||
}
|
||||
|
||||
function initVisualizerCanvas() {
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!canvas) return;
|
||||
visualizerCtx = canvas.getContext('2d');
|
||||
canvas.width = 300;
|
||||
canvas.height = 64;
|
||||
visualizerCanvas = document.getElementById('spectrogram-canvas');
|
||||
if (!visualizerCanvas) return;
|
||||
visualizerCtx = visualizerCanvas.getContext('2d');
|
||||
visualizerCanvas.width = 300;
|
||||
visualizerCanvas.height = 64;
|
||||
visualizerGradient = null; // Force rebuild
|
||||
refreshVisualizerAccent();
|
||||
}
|
||||
|
||||
function buildVisualizerGradient() {
|
||||
if (!visualizerCtx || !visualizerCanvas) return null;
|
||||
const h = visualizerCanvas.height;
|
||||
const grad = visualizerCtx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`);
|
||||
grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`);
|
||||
return grad;
|
||||
}
|
||||
|
||||
function startVisualizerRender() {
|
||||
if (visualizerAnimFrame) return;
|
||||
// Cache editorial spectrum bar refs once per start.
|
||||
cacheEditorialSpectrumBars();
|
||||
renderVisualizerFrame();
|
||||
}
|
||||
|
||||
@@ -291,62 +344,70 @@ export function stopVisualizerRender() {
|
||||
cancelAnimationFrame(visualizerAnimFrame);
|
||||
visualizerAnimFrame = null;
|
||||
}
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (visualizerCtx && canvas) {
|
||||
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (visualizerCtx && visualizerCanvas) {
|
||||
visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
|
||||
}
|
||||
frequencyData = null;
|
||||
frequencyDataVersion++; // Force next render to redraw cleared state
|
||||
lastRenderedVersion = -1;
|
||||
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 = '';
|
||||
});
|
||||
// Reset spectrum bar transforms so the synthetic CSS animation takes back over.
|
||||
if (editorialSpectrumBars) {
|
||||
for (let i = 0; i < editorialSpectrumBars.length; i++) {
|
||||
editorialSpectrumBars[i].style.transform = '';
|
||||
}
|
||||
}
|
||||
// Drop cached bars so next start re-queries.
|
||||
editorialSpectrumBars = null;
|
||||
editorialSpectrumLastScale = null;
|
||||
}
|
||||
|
||||
function renderVisualizerFrame() {
|
||||
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
|
||||
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!frequencyData || !visualizerCtx || !canvas) return;
|
||||
// VU needle + position progress always tick — they read live state
|
||||
// not bound to spectrum payloads. Keeping them in this single rAF
|
||||
// is cheaper than running a second rAF loop just for the needle.
|
||||
tickVuNeedle();
|
||||
|
||||
if (!frequencyData || !visualizerCtx || !visualizerCanvas) return;
|
||||
|
||||
// FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes
|
||||
// at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so
|
||||
// bail when no new payload has arrived since the last draw.
|
||||
if (frequencyDataVersion === lastRenderedVersion) return;
|
||||
lastRenderedVersion = frequencyDataVersion;
|
||||
|
||||
const bins = frequencyData.frequencies;
|
||||
const numBins = bins.length;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const w = visualizerCanvas.width;
|
||||
const h = visualizerCanvas.height;
|
||||
const gap = 2;
|
||||
const barWidth = (w / numBins) - gap;
|
||||
const accent = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
const scale = frequenciesScale;
|
||||
|
||||
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
|
||||
smoothedFrequencies = new Array(numBins).fill(0);
|
||||
smoothedFrequencies = new Float32Array(numBins);
|
||||
}
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const v = bins[i] * scale;
|
||||
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
|
||||
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
|
||||
+ v * (1 - VISUALIZER_SMOOTHING);
|
||||
}
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
if (!visualizerGradient) visualizerGradient = buildVisualizerGradient();
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
visualizerCtx.fillStyle = visualizerGradient;
|
||||
visualizerCtx.beginPath();
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
|
||||
const x = i * (barWidth + gap) + gap / 2;
|
||||
const y = h - barHeight;
|
||||
|
||||
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
|
||||
grad.addColorStop(0, accent);
|
||||
grad.addColorStop(1, accent + '30');
|
||||
|
||||
visualizerCtx.fillStyle = grad;
|
||||
visualizerCtx.beginPath();
|
||||
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
|
||||
visualizerCtx.fill();
|
||||
}
|
||||
|
||||
// Bass-driven album-art scale + glow pulse removed — the
|
||||
// "burst" looked unnatural on the sleeve. Spectrum bars +
|
||||
// VU needle remain the audio-reactive elements.
|
||||
visualizerCtx.fill();
|
||||
|
||||
// Drive the editorial .spectrum bars from the same frequency data.
|
||||
updateEditorialSpectrum(smoothedFrequencies, numBins);
|
||||
@@ -357,36 +418,79 @@ function renderVisualizerFrame() {
|
||||
// dominate); a linear mapping leaves the right half of the spectrum
|
||||
// looking dead. Use a logarithmic frequency-to-bar mapping plus a
|
||||
// per-bar high-end gain so all bars carry visible motion.
|
||||
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');
|
||||
let editorialSpectrumBars = null; // Live HTMLCollection cached at start
|
||||
let editorialSpectrumBarCount = 0;
|
||||
let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded)
|
||||
let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar
|
||||
let editorialBarGains = null; // Pre-computed per-bar gain
|
||||
let editorialBarRangesForBins = -1; // numBins last used to compute ranges
|
||||
|
||||
// Skip the very lowest bin (DC + sub-rumble) which often dominates.
|
||||
function cacheEditorialSpectrumBars() {
|
||||
const root = document.querySelector('.now-playing .spectrum');
|
||||
if (!root) {
|
||||
editorialSpectrumBars = null;
|
||||
editorialSpectrumBarCount = 0;
|
||||
return;
|
||||
}
|
||||
editorialSpectrumBars = root.children;
|
||||
editorialSpectrumBarCount = editorialSpectrumBars.length;
|
||||
editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount);
|
||||
editorialSpectrumLastScale.fill(-1);
|
||||
// Pre-compute per-bar gain (constant for the lifetime of the bar list).
|
||||
editorialBarGains = new Float32Array(editorialSpectrumBarCount);
|
||||
for (let i = 0; i < editorialSpectrumBarCount; i++) {
|
||||
editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8;
|
||||
}
|
||||
editorialBarRangesForBins = -1; // Force range recompute on next call
|
||||
}
|
||||
|
||||
function recomputeEditorialBarRanges(numBins) {
|
||||
const barCount = editorialSpectrumBarCount;
|
||||
editorialBarRanges = new Int16Array(barCount * 2);
|
||||
const lowBin = 1;
|
||||
const highBin = numBins - 1;
|
||||
const span = highBin - lowBin;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
// Logarithmic mapping: equal-area slices of the audible spectrum
|
||||
// map to equal numbers of bars. Each bar covers a wider bin range
|
||||
// toward the highs so they get amplified naturally.
|
||||
const t0 = i / barCount;
|
||||
const t1 = (i + 1) / barCount;
|
||||
const startIdx = Math.max(lowBin, Math.floor(lowBin + Math.pow(t0, 2.0) * (highBin - lowBin)));
|
||||
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + Math.pow(t1, 2.0) * (highBin - lowBin)));
|
||||
let peak = 0;
|
||||
for (let j = startIdx; j < endIdx && j < numBins; j++) {
|
||||
if (bins[j] > peak) peak = bins[j];
|
||||
const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span));
|
||||
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span));
|
||||
editorialBarRanges[i * 2] = startIdx;
|
||||
editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins);
|
||||
}
|
||||
// Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest.
|
||||
// Backend now ships AGC-normalized bins (peak ~1, transients up to 1.5)
|
||||
// so the master multiplier stays modest to avoid perma-clipping.
|
||||
const gain = 1 + (i / barCount) * 0.8;
|
||||
// Floor at 12% so silent bars are still visually present.
|
||||
const pct = Math.max(12, Math.min(100, peak * 65 * gain));
|
||||
bars[i].style.height = pct + '%';
|
||||
editorialBarRangesForBins = numBins;
|
||||
}
|
||||
|
||||
function updateEditorialSpectrum(bins, numBins) {
|
||||
if (!editorialSpectrumBars) cacheEditorialSpectrumBars();
|
||||
const barCount = editorialSpectrumBarCount;
|
||||
if (!barCount) return;
|
||||
if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins);
|
||||
document.body.classList.add('audio-spectrum-live');
|
||||
|
||||
const ranges = editorialBarRanges;
|
||||
const gains = editorialBarGains;
|
||||
const lastScale = editorialSpectrumLastScale;
|
||||
const bars = editorialSpectrumBars;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const startIdx = ranges[i * 2];
|
||||
const endIdx = ranges[i * 2 + 1];
|
||||
let peak = 0;
|
||||
for (let j = startIdx; j < endIdx; j++) {
|
||||
const v = bins[j];
|
||||
if (v > peak) peak = v;
|
||||
}
|
||||
// Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5).
|
||||
// Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible.
|
||||
const raw = peak * 0.65 * gains[i];
|
||||
const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw);
|
||||
// Quantize to 1/1000 — anything finer is invisible. Skip the DOM
|
||||
// write when the bar hasn't moved.
|
||||
const q = (scaleY * 1000) | 0;
|
||||
if (q === lastScale[i]) continue;
|
||||
lastScale[i] = q;
|
||||
// transform: scaleY runs on the compositor — no layout/paint.
|
||||
bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,18 +720,46 @@ export function setupProgressDrag(bar, fill) {
|
||||
// Replace the album-art src and replay the .is-swapping CSS animation
|
||||
// so the new artwork crossfades in instead of popping. Re-toggling the
|
||||
// class across rAF restarts the keyframes even if it was already on.
|
||||
//
|
||||
// `forceAnim=false` skips the keyframe-restart reflow when the element
|
||||
// has never run the swap animation before — saves a synchronous layout
|
||||
// flush on first paint. The reflow IS still required when the class
|
||||
// is currently applied; otherwise the browser coalesces add+remove and
|
||||
// the keyframes don't replay.
|
||||
function swapArtworkSrc(imgEl, newSrc) {
|
||||
if (!imgEl) return;
|
||||
if (imgEl.src === newSrc) return;
|
||||
const wasSwapping = imgEl.classList.contains('is-swapping');
|
||||
if (wasSwapping) {
|
||||
imgEl.classList.remove('is-swapping');
|
||||
// Forced reflow restarts the keyframes — only needed when we have
|
||||
// to interrupt an in-flight animation.
|
||||
void imgEl.offsetWidth;
|
||||
}
|
||||
imgEl.src = newSrc;
|
||||
imgEl.classList.add('is-swapping');
|
||||
}
|
||||
|
||||
// Hash of the last fully-rendered status payload — lets us skip
|
||||
// updateUI altogether when the backend re-broadcasts the same state.
|
||||
let lastStatusFingerprint = null;
|
||||
function statusFingerprint(s) {
|
||||
return [
|
||||
s.state, s.title, s.artist, s.album, s.volume, s.muted,
|
||||
s.duration, s.source, s.album_art_url, s.position
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function updateUI(status) {
|
||||
setLastStatus(status);
|
||||
|
||||
// Idempotence: if nothing meaningful changed, skip the entire DOM
|
||||
// pass. Track switches arrive as 1-3 status_update broadcasts in
|
||||
// quick succession; this gates the redundant ones.
|
||||
const fingerprint = statusFingerprint(status);
|
||||
if (fingerprint === lastStatusFingerprint) return;
|
||||
lastStatusFingerprint = fingerprint;
|
||||
|
||||
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||
dom.artist.textContent = status.artist || '';
|
||||
@@ -654,7 +786,10 @@ export function updateUI(status) {
|
||||
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
|
||||
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
|
||||
if (artworkSource) {
|
||||
fetch(`/api/media/artwork?_=${Date.now()}`, {
|
||||
// No cache-buster: when album_art_url is unchanged the
|
||||
// browser can reuse the decoded bitmap. The artworkKey gate
|
||||
// already skips fetches when the user hasn't switched tracks.
|
||||
fetch('/api/media/artwork', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(r => r.ok ? r.blob() : null)
|
||||
@@ -664,8 +799,11 @@ export function updateUI(status) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
currentArtworkBlobUrl = url;
|
||||
swapArtworkSrc(dom.albumArt, url);
|
||||
dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
|
||||
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
|
||||
// Mirror to fullscreen bloom directly — drops the
|
||||
// MutationObserver fan-out path.
|
||||
syncFullscreenBloomArt(url);
|
||||
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||
})
|
||||
.catch(err => console.error('Artwork fetch failed:', err));
|
||||
@@ -675,17 +813,22 @@ export function updateUI(status) {
|
||||
currentArtworkBlobUrl = null;
|
||||
}
|
||||
swapArtworkSrc(dom.albumArt, placeholderArt);
|
||||
dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
syncFullscreenBloomArt(placeholderGlow);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.duration && status.position !== null) {
|
||||
// Only redo the progress DOM when position actually changed.
|
||||
const positionChanged =
|
||||
status.duration !== currentDuration ||
|
||||
Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05;
|
||||
setCurrentDuration(status.duration);
|
||||
setCurrentPosition(status.position);
|
||||
lastPositionUpdate = Date.now();
|
||||
lastPositionValue = status.position;
|
||||
updateProgress(status.position, status.duration);
|
||||
if (positionChanged) updateProgress(status.position, status.duration);
|
||||
}
|
||||
|
||||
if (!isUserAdjustingVolume) {
|
||||
@@ -730,17 +873,24 @@ export function updateUI(status) {
|
||||
// FFT data the visualizer feeds in). When audio capture isn't
|
||||
// running, fall back to a synthetic wobble bounded by the volume
|
||||
// slider position so the needle still looks alive.
|
||||
let vuWobbleHandle = null;
|
||||
//
|
||||
// One unified rAF drives both the spectrum and the VU needle (see
|
||||
// renderVisualizerFrame → tickVuNeedle). If the visualizer isn't
|
||||
// rendering, a separate rAF takes over solely for the needle.
|
||||
let vuStandaloneHandle = null;
|
||||
let vuWobbleStart = 0;
|
||||
let vuLevelSmoothed = 0;
|
||||
let vuNeedleEl = null; // Cached needle element
|
||||
let vuVolumeSliderEl = null; // Cached slider element
|
||||
let vuLastAppliedDeg = -999; // Skip DOM writes when angle unchanged
|
||||
const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits
|
||||
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
|
||||
|
||||
function readAudioLevel() {
|
||||
if (!frequencyData) return null;
|
||||
// Backend sends a true loudness signal (RMS-derived dB, 0..1).
|
||||
// The bins are renormalized per frame so peak-of-bins is useless for level.
|
||||
if (typeof frequencyData.level === 'number') return frequencyData.level;
|
||||
// Backend sends a true loudness signal (RMS-derived dB, 0..1) —
|
||||
// either as float (legacy) or scaled int (new format).
|
||||
if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale;
|
||||
if (!frequencyData.frequencies) return null;
|
||||
const bins = frequencyData.frequencies;
|
||||
if (!bins.length) return null;
|
||||
@@ -748,29 +898,21 @@ function readAudioLevel() {
|
||||
for (let i = 1; i < bins.length; i++) {
|
||||
if (bins[i] > peak) peak = bins[i];
|
||||
}
|
||||
return Math.min(1, peak * 1.4);
|
||||
return Math.min(1, peak * frequenciesScale * 1.4);
|
||||
}
|
||||
|
||||
function startVuWobble() {
|
||||
if (vuWobbleHandle) return;
|
||||
vuWobbleStart = performance.now();
|
||||
const tick = () => {
|
||||
const needle = document.getElementById('vuNeedle');
|
||||
if (needle) {
|
||||
// Loopback capture is post-volume on Windows/macOS, so the
|
||||
// measured level already reflects the output knob — no extra
|
||||
// (vol/100) attenuation needed.
|
||||
function tickVuNeedle() {
|
||||
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
|
||||
if (!vuNeedleEl) return;
|
||||
const audioLevel = readAudioLevel();
|
||||
let target;
|
||||
if (audioLevel != null) {
|
||||
// Real audio: apply attack/release smoothing for
|
||||
// analog-feeling ballistics.
|
||||
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
|
||||
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
|
||||
target = -22 + vuLevelSmoothed * 44;
|
||||
} else {
|
||||
const slider = document.getElementById('volume-slider');
|
||||
const vol = slider ? Number(slider.value) || 0 : 0;
|
||||
if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider');
|
||||
const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0;
|
||||
const base = -22 + (vol / 100) * 44;
|
||||
const mag = Math.max(2, Math.min(14, vol * 0.16));
|
||||
const t = (performance.now() - vuWobbleStart) / 1000;
|
||||
@@ -779,21 +921,39 @@ function startVuWobble() {
|
||||
+ Math.sin(t * 11.7 + 1.3) * mag * 0.30
|
||||
+ (Math.random() - 0.5) * mag * 0.30;
|
||||
}
|
||||
needle.style.transform = `rotate(${target}deg)`;
|
||||
// Quantize to 0.1° — finer is invisible. Skip when unchanged.
|
||||
const q = Math.round(target * 10) / 10;
|
||||
if (q === vuLastAppliedDeg) return;
|
||||
vuLastAppliedDeg = q;
|
||||
vuNeedleEl.style.transform = `rotate(${q}deg)`;
|
||||
}
|
||||
vuWobbleHandle = requestAnimationFrame(tick);
|
||||
|
||||
function startVuWobble() {
|
||||
vuWobbleStart = performance.now();
|
||||
// If the visualizer rAF is already running, it ticks the needle for us.
|
||||
if (visualizerAnimFrame) return;
|
||||
if (vuStandaloneHandle) return;
|
||||
const standalone = () => {
|
||||
tickVuNeedle();
|
||||
// Stop ourselves once the unified visualizer loop is up.
|
||||
if (visualizerAnimFrame) {
|
||||
vuStandaloneHandle = null;
|
||||
return;
|
||||
}
|
||||
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||
};
|
||||
vuWobbleHandle = requestAnimationFrame(tick);
|
||||
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||
}
|
||||
|
||||
function stopVuWobble() {
|
||||
if (vuWobbleHandle) {
|
||||
cancelAnimationFrame(vuWobbleHandle);
|
||||
vuWobbleHandle = null;
|
||||
if (vuStandaloneHandle) {
|
||||
cancelAnimationFrame(vuStandaloneHandle);
|
||||
vuStandaloneHandle = null;
|
||||
}
|
||||
vuLevelSmoothed = 0;
|
||||
const needle = document.getElementById('vuNeedle');
|
||||
if (needle) needle.style.transform = 'rotate(-22deg)';
|
||||
vuLastAppliedDeg = -999;
|
||||
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
|
||||
if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)';
|
||||
}
|
||||
|
||||
export function updatePlaybackState(state) {
|
||||
@@ -830,30 +990,58 @@ export function updatePlaybackState(state) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache last applied progress values so we can skip DOM writes when the
|
||||
// rounded second hasn't moved. Width is quantized to 0.1% — finer is
|
||||
// invisible but would still trigger compositor work.
|
||||
let lastProgressTenths = -1; // 0..1000 (0.1% increments)
|
||||
let lastProgressSec = -1;
|
||||
let lastDurationSec = -1;
|
||||
let cachedMiniBar = null;
|
||||
|
||||
function updateProgress(position, duration) {
|
||||
const percent = (position / duration) * 100;
|
||||
const widthStr = `${percent}%`;
|
||||
const currentStr = formatTime(position);
|
||||
const totalStr = formatTime(duration);
|
||||
const tenths = Math.round(percent * 10); // 0..1000
|
||||
const posRound = Math.round(position);
|
||||
const durRound = Math.round(duration);
|
||||
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.currentTime.textContent = currentStr;
|
||||
dom.totalTime.textContent = totalStr;
|
||||
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
|
||||
if (dom.metaLength) dom.metaLength.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
dom.progressBar.setAttribute('aria-valuenow', posRound);
|
||||
dom.progressBar.setAttribute('aria-valuemax', durRound);
|
||||
const widthChanged = tenths !== lastProgressTenths;
|
||||
const posChanged = posRound !== lastProgressSec;
|
||||
const durChanged = durRound !== lastDurationSec;
|
||||
|
||||
if (widthChanged) {
|
||||
lastProgressTenths = tenths;
|
||||
const widthStr = (tenths / 10) + '%';
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.miniProgressFill.style.width = widthStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||
const miniBar = document.getElementById('mini-progress-bar');
|
||||
miniBar.setAttribute('aria-valuenow', posRound);
|
||||
miniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
if (posChanged) {
|
||||
lastProgressSec = posRound;
|
||||
const currentStr = formatTime(position);
|
||||
dom.currentTime.textContent = currentStr;
|
||||
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.progressBar.setAttribute('aria-valuenow', posRound);
|
||||
}
|
||||
|
||||
if (durChanged) {
|
||||
lastDurationSec = durRound;
|
||||
const totalStr = formatTime(duration);
|
||||
dom.totalTime.textContent = totalStr;
|
||||
if (dom.metaLength) dom.metaLength.textContent = totalStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
dom.progressBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
if (posChanged || durChanged) {
|
||||
if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar');
|
||||
if (cachedMiniBar) {
|
||||
if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound);
|
||||
if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startPositionInterpolation() {
|
||||
@@ -901,13 +1089,15 @@ function updateMuteIcon(muted) {
|
||||
let fsChromeIdleTimer = null;
|
||||
const FS_CHROME_IDLE_MS = 2500;
|
||||
let fsLastFocusedElement = null;
|
||||
let fsBloomSyncObserver = null;
|
||||
|
||||
function syncFullscreenBloomArt() {
|
||||
const src = document.getElementById('album-art');
|
||||
// Mirror the album-art onto #fs-bloom-art (the fullscreen ambient
|
||||
// bloom). Called directly from the artwork-swap path — no
|
||||
// MutationObserver, so we never repaint the 110px-radius blur twice.
|
||||
function syncFullscreenBloomArt(url) {
|
||||
const bloom = document.getElementById('fs-bloom-art');
|
||||
if (!src || !bloom) return;
|
||||
if (src.src && src.src !== bloom.src) bloom.src = src.src;
|
||||
if (!bloom) return;
|
||||
const target = url || (dom && dom.albumArt && dom.albumArt.src) || '';
|
||||
if (target && bloom.src !== target) bloom.src = target;
|
||||
}
|
||||
|
||||
function showFsChrome() {
|
||||
@@ -978,16 +1168,10 @@ export function enterPlayerFullscreen() {
|
||||
document.body.classList.add('is-fullscreen-player');
|
||||
setMiniPlayerVisible(false);
|
||||
updateFullscreenButtonIcons(true);
|
||||
// Initial mirror — subsequent swaps are pushed by updateUI directly,
|
||||
// so there is no MutationObserver in the hot path.
|
||||
syncFullscreenBloomArt();
|
||||
|
||||
// Watch for album-art swaps so the bloom keeps up.
|
||||
const src = document.getElementById('album-art');
|
||||
if (src && 'MutationObserver' in window) {
|
||||
if (fsBloomSyncObserver) fsBloomSyncObserver.disconnect();
|
||||
fsBloomSyncObserver = new MutationObserver(syncFullscreenBloomArt);
|
||||
fsBloomSyncObserver.observe(src, { attributes: true, attributeFilter: ['src'] });
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onFsMouseMove, { passive: true });
|
||||
document.addEventListener('keydown', onFsKeyDown);
|
||||
showFsChrome();
|
||||
@@ -1017,10 +1201,6 @@ export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
|
||||
clearTimeout(fsChromeIdleTimer);
|
||||
fsChromeIdleTimer = null;
|
||||
}
|
||||
if (fsBloomSyncObserver) {
|
||||
fsBloomSyncObserver.disconnect();
|
||||
fsBloomSyncObserver = null;
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onFsMouseMove);
|
||||
document.removeEventListener('keydown', onFsKeyDown);
|
||||
|
||||
Reference in New Issue
Block a user