Compare commits

...

5 Commits

Author SHA1 Message Date
alexei.dolgolyov 46af2bb8cc chore: release v0.2.1
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 49s
2026-04-25 20:23:01 +03:00
alexei.dolgolyov 25a492d5dd ui(player): meaningful caps for tablet/small-desktop range + tighter footer
Lint & Test / test (push) Successful in 17s
The 720–1240 px viewport range was a "strange zone": below 1240 the layout
is single-column, but above 720 none of the Pocket Edition rules fire, so
the vinyl stage stretched to full content width (~1100 px) and the masthead
ran to a 1000 px+ measure on a small-desktop window.

Caps now degrade in three steps:
- ≤ 720 px: vinyl 460 px / 92% width (mobile hero unchanged)
- 721–1240 px: vinyl 480 px, masthead 640 px, both centered
- ≥ 1241 px: two-column layout (no caps needed; grid does it)

Also reduce the bottom dead space:
- footer margin-top 80 → 36, inner top padding 28 → 20
- .container bottom padding 140 → 64 (desktop) / 56 (mobile)

And a small mobile-volume fix in the same range:
- .controls flex-wrap nowrap → wrap so the vu-cluster can take its own row
- vu-cluster gets flex-basis 100 % (forces own row in the wrapping flexbox)
- volume-slider drops the max-width: 200 cap so it fills the row width
- vinyl-stage on mobile bumped 320 → 460 px / 78% → 92% width
2026-04-25 20:19:37 +03:00
alexei.dolgolyov f4be2bdb89 fix(player): wire accent picker to editorial copper palette + visual polish
Lint & Test / test (push) Successful in 9s
The accent picker only mutated --accent / --accent-hover, but the redesign
reads everything off --copper, --copper-hi, --copper-lo, --copper-glow.
--accent was a one-way alias of --copper, so picking a color did nothing.

Frontend (player.js):
- applyAccentColor now drives --copper, --copper-hi, --copper-lo, and a
  new --copper-rgb triplet (used by every soft tint / glow on both themes)
- darkenColor / hexToRgbTriple helpers added beside lightenColor

CSS (styles.css):
- introduce --copper-rgb tokens for both themes; --copper-glow now derives
  from rgba(var(--copper-rgb), 0.35) so it follows the picker
- replace 21 hardcoded rgba(224,128,56,...) / rgba(31,78,61,...) literals
  across hover bgs, focus halos, glows, vinyl-label gradients with
  rgba(var(--copper-rgb), ...)
- replace the light-theme vinyl-label gradient hexes with
  var(--copper) / var(--copper-lo)

Other player polish in this changeset:
- track-masthead: padding-right clamp(12px, 1.5vw, 24px) so VU meter,
  spectrum tail, end timecode and controls sit inset from the panel edge
  (zeroed on the single-column mobile breakpoint to keep symmetry)
- VU meter 140→120 px, volume slider 80→64 px to free up row width so
  the cluster stays inline with prev/play/next instead of wrapping
- light-theme VU meter override: cream gauge face, dark-ink scale ticks,
  hunter-emerald needle (replaces the hardcoded black gauge)
- fullscreen meta-cell labels: var(--ink-faint) → var(--copper) so STATE
  and SOURCE read as part of the same editorial system as the kicker
2026-04-25 18:19:19 +03:00
alexei.dolgolyov 51ec1503f4 perf(visualizer): cut spectrum + track-switch CPU significantly
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.
2026-04-25 18:05:57 +03:00
alexei.dolgolyov 08c3c80df4 ci: skip test workflow on release commits
Lint & Test / test (push) Successful in 46s
2026-04-25 15:36:18 +03:00
10 changed files with 655 additions and 295 deletions
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
test:
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+22 -54
View File
@@ -1,36 +1,24 @@
## v0.2.0 (2026-04-25)
## v0.2.1 (2026-04-25)
A major UI overhaul — the **Studio Reference** editorial hi-fi redesign — plus a fullscreen "Listening Room" mode, a Pocket Edition mobile layout, and a fully audio-driven VU/spectrum cluster.
### Features
- **Studio Reference redesign** — editorial hi-fi aesthetic across the entire UI ([8110c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8110c15))
- **Player view rebuilt** to match the Studio Reference mockup ([14e9f22](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/14e9f22))
- **Live VU meter + audio-driven spectrum**, editorial banner, subtler dynamic background ([d937c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d937c15))
- **Fullscreen "Listening Room" mode** for the player ([59840a1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/59840a1))
- **Pocket Edition** mobile layout + tablet tab range fix ([f85ce77](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f85ce77))
### UI Improvements
- Editorial styling for Library, Quick Access, Settings, Display + tab fix ([2049850](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2049850))
- Search icon overlap fix, Display cards, compact view, dark dropdowns ([588a303](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/588a303))
- Widen spectrum to fill column; volume control moved to left of VU cluster ([153424e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/153424e))
- VU: narrower 44° swing, peak-based level, faster response; mini progress bar fix ([f2c8216](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f2c8216))
- Soften vinyl-stage halo, transparent-bg album placeholder, crossfade artwork swaps ([4c93bfb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4c93bfb))
A small polish release on top of the Studio Reference redesign — accent picker fix,
visualizer performance work, and a couple of layout refinements for tablet and
small-desktop widths.
### Bug Fixes
- VU needle now driven from RMS-dB loudness instead of peak-of-bins ([b09569f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/b09569f))
- VU: drop conic-gradient mask, draw lines explicitly in 090° range ([9b84fdd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9b84fdd))
- VU: clip grid arc to match needle swing range so rest = proper zero ([3de2b44](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3de2b44))
- Centred toolbar icons; full-width spectrum; needle at rest; drop dynamic bg ([d7f488a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7f488a))
- Real audio level on VU; full-width spectrum; hide canvas under vinyl ([968eb15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/968eb15))
- Visualizer: full-width spectrum + device pick auto-starts capture ([a0f74df](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0f74df))
- Visualizer: auto-enable actually starts capture; persist audio device ([6066b4a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6066b4a))
- Full-width spectrum + log-mapped bars; deeper sepia + soft art fade ([336d596](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/336d596))
- Editorial toolbar + sepia album art ([d157388](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d157388))
- Close more gaps with mockup (tabs, mini player, volume control) ([e9e4165](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e9e4165))
- Snap player view directly from Studio Reference mockup ([77b39e5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/77b39e5))
- Drop redundant Elapsed/Length cells; restore timeline ([d9d4672](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d9d4672))
- Close gaps with Studio Reference mockup ([265b001](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/265b001))
- Player redesign cleanup pass — sleeve, tonearm, AGC, dead code ([2a474ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2a474ea))
- **Accent picker** now wired to the editorial copper palette + visual polish ([f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd))
### Performance
- **Visualizer** — significant CPU cuts on spectrum rendering and track switches ([51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150))
### UI Improvements
- Meaningful caps for tablet / small-desktop range + tighter footer ([25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d))
---
### Development / Internal
#### CI/Build
- Skip test workflow on release commits ([08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80))
---
@@ -39,29 +27,9 @@ A major UI overhaul — the **Studio Reference** editorial hi-fi redesign — pl
| Hash | Message | Author |
|------|---------|--------|
| [4c93bfb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4c93bfb) | ui(player): soften vinyl-stage halo, transparent-bg album placeholder, crossfade artwork swaps | alexei.dolgolyov |
| [59840a1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/59840a1) | feat(player): fullscreen "Listening Room" mode | alexei.dolgolyov |
| [2a474ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2a474ea) | fix(player): redesign cleanup pass — sleeve, tonearm, AGC, dead code | alexei.dolgolyov |
| [f85ce77](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f85ce77) | ui(mobile): Pocket Edition layout + tablet tab range fix | alexei.dolgolyov |
| [b09569f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/b09569f) | fix(vu): drive needle from RMS-dB loudness instead of peak-of-bins | alexei.dolgolyov |
| [f2c8216](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f2c8216) | ui(vu): narrower 44deg swing, peak-based level, faster response; mini progress bar fix | alexei.dolgolyov |
| [588a303](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/588a303) | ui: fix search icon overlap, Display cards, compact view, dark dropdowns | alexei.dolgolyov |
| [2049850](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2049850) | ui: editorial styling for Library/Quick Access/Settings/Display + tab fix | alexei.dolgolyov |
| [9b84fdd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9b84fdd) | fix(vu): drop conic-gradient mask, draw lines explicitly in 0-90 range | alexei.dolgolyov |
| [3de2b44](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3de2b44) | fix(vu): clip grid arc to match needle swing range so rest = proper zero | alexei.dolgolyov |
| [d7f488a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7f488a) | fix(ui): centred toolbar icons; full-width spectrum; needle at rest; drop dynamic bg | alexei.dolgolyov |
| [968eb15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/968eb15) | fix(player): real audio level on VU; full-width spectrum; hide canvas under vinyl | alexei.dolgolyov |
| [a0f74df](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0f74df) | fix(visualizer): full-width spectrum + device pick auto-starts capture | alexei.dolgolyov |
| [6066b4a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6066b4a) | fix(visualizer): auto-enable actually starts capture; persist audio device | alexei.dolgolyov |
| [153424e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/153424e) | ui(player): widen spectrum to fill column; swap volume control to left of VU cluster | alexei.dolgolyov |
| [336d596](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/336d596) | fix(ui): full-width spectrum + log-mapped bars; deeper sepia + soft art fade | alexei.dolgolyov |
| [d937c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d937c15) | feat(ui): live VU + audio-driven spectrum, editorial banner, subtler dynamic bg | alexei.dolgolyov |
| [d157388](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d157388) | fix(ui): editorial toolbar + sepia album art | alexei.dolgolyov |
| [e9e4165](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e9e4165) | fix(ui): close more gaps with mockup (tabs, mini player, volume control) | alexei.dolgolyov |
| [77b39e5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/77b39e5) | fix(ui): snap player view directly from Studio Reference mockup | alexei.dolgolyov |
| [d9d4672](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d9d4672) | fix(ui): drop redundant Elapsed/Length cells; restore timeline | alexei.dolgolyov |
| [265b001](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/265b001) | fix(ui): close gaps with Studio Reference mockup | alexei.dolgolyov |
| [14e9f22](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/14e9f22) | feat(ui): rebuild player view to match Studio Reference mockup | alexei.dolgolyov |
| [8110c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8110c15) | feat(ui): Studio Reference redesign — editorial hi-fi aesthetic | alexei.dolgolyov |
| [25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d) | ui(player): meaningful caps for tablet/small-desktop range + tighter footer | alexei.dolgolyov |
| [f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd) | fix(player): wire accent picker to editorial copper palette + visual polish | alexei.dolgolyov |
| [51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150) | perf(visualizer): cut spectrum + track-switch CPU significantly | alexei.dolgolyov |
| [08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80) | ci: skip test workflow on release commits | alexei.dolgolyov |
</details>
+70 -19
View File
@@ -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
+33 -13
View File
@@ -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]
+139 -56
View File
@@ -113,7 +113,8 @@
--copper: #E08038;
--copper-hi: #F4A064;
--copper-lo: #B0561F;
--copper-glow: rgba(224, 128, 56, 0.35);
--copper-rgb: 224, 128, 56;
--copper-glow: rgba(var(--copper-rgb), 0.35);
--amber: #F5C26B;
--jade: #7AB294;
@@ -167,7 +168,8 @@
--copper: #1F4E3D; /* hunter emerald in light mode */
--copper-hi: #2D6A53;
--copper-lo: #143E2F;
--copper-glow: rgba(31, 78, 61, 0.18);
--copper-rgb: 31, 78, 61;
--copper-glow: rgba(var(--copper-rgb), 0.18);
--amber: #C29D31;
--jade: #4D8C6F;
@@ -3991,11 +3993,11 @@ body.mini-player-visible footer {
/* ─── Container & header ────────────────────────────────────── */
.container {
max-width: 1280px;
padding: 56px 48px 140px;
padding: 56px 48px 64px;
}
@media (max-width: 720px) {
.container { padding: 48px 18px 140px; }
.container { padding: 48px 18px 56px; }
}
/* ─── Folio marks (page corners, all tabs) ────────────────── */
@@ -4416,6 +4418,35 @@ header .brand-sub {
grid-template-columns: 1fr;
gap: 36px;
}
/* Single-column hero sizing — prevents the vinyl stage from
growing to full content width (which becomes 800px+ on a
small-desktop or tablet-landscape window). */
.album-art-container.vinyl-stage {
max-width: 520px;
margin-left: auto;
margin-right: auto;
}
/* Cap the right-column copy so a short artist line and meta grid
don't sprawl across 1000px of measure. The masthead stays
flush-left under the vinyl, just narrower. */
.now-playing .track-masthead {
max-width: 720px;
}
}
@media (min-width: 721px) and (max-width: 1240px) {
/* In the 7211240 zone the vinyl can be larger than on phone but
smaller than the full-column 800px+ default. */
.album-art-container.vinyl-stage {
max-width: 480px;
}
.now-playing .track-masthead {
max-width: 640px;
margin-left: auto;
margin-right: auto;
padding-right: 0;
text-align: left;
}
}
/* ─── Vinyl stage ──────────────────────────────────────────── */
@@ -4599,23 +4630,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 +4722,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
@@ -4742,7 +4782,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
justify-content: center;
}
:root[data-theme="light"] .vinyl-stage .vinyl-wrap .vinyl-label {
background: linear-gradient(135deg, #1F4E3D 0%, #143E2F 100%);
background: linear-gradient(135deg, var(--copper) 0%, var(--copper-lo) 100%);
box-shadow:
inset 0 0 18px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.06),
@@ -4937,7 +4977,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
border: 1px solid var(--rule-strong);
border-radius: 4px 4px 0 0;
overflow: hidden;
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(224,128,56,0.08);
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(var(--copper-rgb), 0.08);
}
.vu-meter::before {
content: "";
@@ -5119,7 +5159,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
height: 10px;
background: var(--copper);
border-radius: 50%;
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(224, 128, 56, 0.12);
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(var(--copper-rgb), 0.12);
transition: none;
}
@@ -5160,7 +5200,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
transition: all 220ms var(--ease);
}
.controls button:hover {
background: rgba(224, 128, 56, 0.06);
background: rgba(var(--copper-rgb), 0.06);
border-color: var(--copper);
color: var(--copper);
}
@@ -5204,7 +5244,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
.mute-btn:hover {
border-color: var(--copper);
color: var(--copper);
background: rgba(224, 128, 56, 0.06);
background: rgba(var(--copper-rgb), 0.06);
}
#volume-slider {
@@ -5223,7 +5263,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
height: 14px;
background: var(--copper);
border-radius: 50%;
box-shadow: 0 0 12px var(--copper-glow), 0 0 0 4px rgba(224, 128, 56, 0.12);
box-shadow: 0 0 12px var(--copper-glow), 0 0 0 4px rgba(var(--copper-rgb), 0.12);
border: 0;
cursor: grab;
}
@@ -5288,7 +5328,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
.vinyl-toggle-btn.active {
border-color: var(--copper);
color: var(--copper);
background: rgba(224, 128, 56, 0.06);
background: rgba(var(--copper-rgb), 0.06);
}
/* ═══════════════════════════════════════════════════════════════
@@ -5572,7 +5612,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
.view-toggle-btn:last-child { border-right: 0; }
.view-toggle-btn:hover {
color: var(--copper);
background: rgba(224, 128, 56, 0.04);
background: rgba(var(--copper-rgb), 0.04);
}
.view-toggle-btn.active {
background: var(--ink);
@@ -5978,7 +6018,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
border-color: var(--copper);
border-style: solid;
color: var(--copper);
background: rgba(224, 128, 56, 0.04);
background: rgba(var(--copper-rgb), 0.04);
}
.add-card-icon {
font-family: var(--serif);
@@ -6347,7 +6387,7 @@ dialog::backdrop {
font-size: 10px;
}
.icon-select-cell:hover {
background: rgba(224, 128, 56, 0.06);
background: rgba(var(--copper-rgb), 0.06);
color: var(--copper);
}
@@ -6469,8 +6509,8 @@ dialog::backdrop {
FOOTER (colophon)
═══════════════════════════════════════════════════════════════ */
footer {
margin-top: 80px;
padding: 28px 0 0;
margin-top: 36px;
padding: 20px 0 0;
border-top: 1px solid var(--rule-strong);
background: transparent;
font-family: var(--mono);
@@ -6576,6 +6616,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 +6639,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 +6744,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 +6797,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); }
@@ -6832,7 +6879,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
width: 10px; height: 10px;
background: var(--copper);
border-radius: 50%;
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(224, 128, 56, 0.12);
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(var(--copper-rgb), 0.12);
transition: none;
}
@@ -6852,6 +6899,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
border: 1px solid var(--rule-strong);
color: var(--ink-soft);
width: 48px; height: 48px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -6863,7 +6911,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
.now-playing .btn-trans:hover {
border-color: var(--copper);
color: var(--copper);
background: rgba(224, 128, 56, 0.06);
background: rgba(var(--copper-rgb), 0.06);
}
.now-playing .btn-trans:disabled {
opacity: 0.35;
@@ -6939,7 +6987,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
.now-playing .vu-volume .mute-btn:hover {
border-color: var(--copper);
color: var(--copper);
background: rgba(224, 128, 56, 0.06);
background: rgba(var(--copper-rgb), 0.06);
}
.now-playing .vu-volume .mute-btn svg {
width: 14px;
@@ -6949,7 +6997,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,13 +7033,13 @@ 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);
border-radius: 4px 4px 0 0;
overflow: hidden;
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(224,128,56,0.08);
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(var(--copper-rgb), 0.08);
flex-shrink: 0;
}
.now-playing .vu-meter::before {
@@ -7044,6 +7092,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(var(--copper-rgb), 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(var(--copper-rgb), 0.25);
}
/* Mobile VU cluster: stack below controls */
@media (max-width: 720px) {
.now-playing .controls { flex-wrap: wrap; }
@@ -7195,7 +7265,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
.browser-container .browser-refresh-btn:hover,
.browser-container .browser-play-all-btn:hover {
color: var(--copper) !important;
background: rgba(224, 128, 56, 0.06) !important;
background: rgba(var(--copper-rgb), 0.06) !important;
}
.browser-container .view-toggle-btn.active {
background: var(--ink) !important;
@@ -7714,7 +7784,7 @@ select option {
border-color: var(--copper) !important;
border-style: solid !important;
color: var(--copper) !important;
background: rgba(224, 128, 56, 0.04) !important;
background: rgba(var(--copper-rgb), 0.04) !important;
}
.settings-container .add-card-icon {
font-family: var(--serif);
@@ -7949,7 +8019,7 @@ select option {
.display-container .display-power-btn:hover {
color: var(--copper) !important;
border-color: var(--copper) !important;
background: rgba(224, 128, 56, 0.06) !important;
background: rgba(var(--copper-rgb), 0.06) !important;
}
/* Brightness control row */
@@ -8100,7 +8170,7 @@ select option {
font-size: 10px;
}
.icon-select-cell:hover {
background: rgba(224, 128, 56, 0.06) !important;
background: rgba(var(--copper-rgb), 0.06) !important;
color: var(--copper) !important;
}
@@ -8358,7 +8428,7 @@ select option {
}
.mini-control-btn:hover {
border-color: var(--copper) !important;
background: rgba(224, 128, 56, 0.08) !important;
background: rgba(var(--copper-rgb), 0.08) !important;
color: var(--copper) !important;
}
.mini-control-btn svg {
@@ -8374,10 +8444,10 @@ select option {
margin-top: 4px !important;
}
/* Vinyl stage: centered, capped at sensible touch size */
/* Vinyl stage: centered, generous on tablet/large-phone widths */
.album-art-container.vinyl-stage {
max-width: 320px;
width: 78%;
max-width: 460px;
width: 92%;
margin: 0 auto !important;
}
/* Lighter sleeve grain on phones so the printed art reads
@@ -8482,7 +8552,8 @@ select option {
.player-layout .controls {
justify-content: center !important;
gap: 28px !important;
flex-wrap: nowrap !important;
flex-wrap: wrap !important;
row-gap: 0 !important;
}
.now-playing .controls .btn-trans,
.player-layout .controls .btn-trans {
@@ -8498,7 +8569,9 @@ select option {
.now-playing .controls .btn-trans.primary svg { width: 26px !important; height: 26px !important; }
/* VU meter is decorative on a phone — hide it. Volume becomes
its own full-width hairline row beneath the controls. */
its own full-width hairline row beneath the controls.
flex-basis: 100% forces the cluster onto its own line in the
wrapping .controls flex container. */
.now-playing .vu-cluster,
.player-layout .vu-cluster {
display: flex !important;
@@ -8506,6 +8579,7 @@ select option {
align-items: center;
justify-content: space-between;
gap: 14px !important;
flex-basis: 100% !important;
width: 100% !important;
margin: 18px 0 0 0 !important;
padding-top: 16px !important;
@@ -8535,7 +8609,7 @@ select option {
.player-layout .vu-volume #volume-slider {
flex: 1 1 auto !important;
width: 100% !important;
max-width: 200px;
max-width: none !important;
height: 3px !important;
}
.now-playing .vu-volume #volume-slider::-webkit-slider-thumb,
@@ -8730,7 +8804,7 @@ body.is-fullscreen-player .player-container {
margin: 0;
padding: 0;
background:
radial-gradient(ellipse at 30% 35%, rgba(224, 128, 56, 0.05) 0%, transparent 55%),
radial-gradient(ellipse at 30% 35%, rgba(var(--copper-rgb), 0.05) 0%, transparent 55%),
radial-gradient(ellipse at center, var(--bg-paper) 0%, var(--bg-deep) 75%);
display: grid;
place-items: stretch;
@@ -8771,18 +8845,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. */
@@ -9022,7 +9105,7 @@ body.is-fullscreen-player .now-playing .meta-cell .label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.32em;
color: var(--ink-faint);
color: var(--copper);
}
body.is-fullscreen-player .now-playing .meta-cell .value {
font-family: var(--mono);
+4 -2
View File
@@ -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);
}
+34 -8
View File
@@ -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);
+4 -1
View File
@@ -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;
+347 -141
View File
@@ -145,6 +145,22 @@ export function lightenColor(hex, percent) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function darkenColor(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, (num >> 16) - Math.round(255 * percent / 100));
const g = Math.max(0, ((num >> 8) & 0xff) - Math.round(255 * percent / 100));
const b = Math.max(0, (num & 0xff) - Math.round(255 * percent / 100));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function hexToRgbTriple(hex) {
const num = parseInt(hex.replace('#', ''), 16);
const r = (num >> 16) & 0xff;
const g = (num >> 8) & 0xff;
const b = num & 0xff;
return `${r}, ${g}, ${b}`;
}
export function initAccentColor() {
const saved = localStorage.getItem('accentColor');
if (saved) {
@@ -159,12 +175,25 @@ export function initAccentColor() {
}
export function applyAccentColor(color, hover) {
document.documentElement.style.setProperty('--accent', color);
document.documentElement.style.setProperty('--accent-hover', hover);
const root = document.documentElement.style;
root.setProperty('--accent', color);
root.setProperty('--accent-hover', hover);
// Editorial palette tokens — the redesign reads these directly,
// so the picker must drive them too (the --accent alias alone has
// no effect once components moved off it).
root.setProperty('--copper', color);
root.setProperty('--copper-hi', hover);
root.setProperty('--copper-lo', darkenColor(color, 12));
root.setProperty('--copper-rgb', hexToRgbTriple(color));
// --copper-glow inherits the rgba(var(--copper-rgb), 0.35) formula
// declared in styles.css, so it picks up the new RGB automatically.
localStorage.setItem('accentColor', color);
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 +244,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 +340,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 +370,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 +444,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)));
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);
}
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 < numBins; j++) {
if (bins[j] > peak) peak = bins[j];
for (let j = startIdx; j < endIdx; j++) {
const v = bins[j];
if (v > peak) peak = v;
}
// 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 + '%';
// 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 +746,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;
imgEl.classList.remove('is-swapping');
void imgEl.offsetWidth;
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 +812,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 +825,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 +839,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 +899,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,52 +924,62 @@ 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 tickVuNeedle() {
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
if (!vuNeedleEl) return;
const audioLevel = readAudioLevel();
let target;
if (audioLevel != null) {
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
target = -22 + vuLevelSmoothed * 44;
} else {
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;
target = base
+ 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;
}
// 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)`;
}
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.
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;
const base = -22 + (vol / 100) * 44;
const mag = Math.max(2, Math.min(14, vol * 0.16));
const t = (performance.now() - vuWobbleStart) / 1000;
target = base
+ 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(${target}deg)`;
// 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;
}
vuWobbleHandle = requestAnimationFrame(tick);
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 +1016,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;
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 (widthChanged) {
lastProgressTenths = tenths;
const widthStr = (tenths / 10) + '%';
dom.progressFill.style.width = widthStr;
dom.miniProgressFill.style.width = widthStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
}
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 +1115,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 +1194,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 +1227,6 @@ export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
clearTimeout(fsChromeIdleTimer);
fsChromeIdleTimer = null;
}
if (fsBloomSyncObserver) {
fsBloomSyncObserver.disconnect();
fsBloomSyncObserver = null;
}
document.removeEventListener('mousemove', onFsMouseMove);
document.removeEventListener('keydown', onFsKeyDown);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "0.2.0"
version = "0.2.1"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }