Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46af2bb8cc | |||
| 25a492d5dd | |||
| f4be2bdb89 | |||
| 51ec1503f4 | |||
| 08c3c80df4 |
@@ -8,6 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
+22
-54
@@ -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.
|
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
|
||||||
### Features
|
small-desktop widths.
|
||||||
- **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))
|
|
||||||
|
|
||||||
### Bug Fixes
|
### 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))
|
- **Accent picker** now wired to the editorial copper palette + visual polish ([f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd))
|
||||||
- VU: drop conic-gradient mask, draw lines explicitly in 0–90° 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))
|
### Performance
|
||||||
- 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))
|
- **Visualizer** — significant CPU cuts on spectrum rendering and track switches ([51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150))
|
||||||
- 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))
|
### UI Improvements
|
||||||
- Visualizer: auto-enable actually starts capture; persist audio device ([6066b4a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6066b4a))
|
- Meaningful caps for tablet / small-desktop range + tighter footer ([25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d))
|
||||||
- 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))
|
### Development / Internal
|
||||||
- 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))
|
#### CI/Build
|
||||||
- Player redesign cleanup pass — sleeve, tonearm, AGC, dead code ([2a474ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2a474ea))
|
- 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 |
|
| 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 |
|
| [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 |
|
||||||
| [59840a1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/59840a1) | feat(player): fullscreen "Listening Room" mode | 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 |
|
||||||
| [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 |
|
| [51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150) | perf(visualizer): cut spectrum + track-switch CPU significantly | 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 |
|
| [08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80) | ci: skip test workflow on release commits | 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 |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Audio spectrum analyzer service using system loopback capture."""
|
"""Audio spectrum analyzer service using system loopback capture."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import platform
|
import platform
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -71,6 +72,14 @@ class AudioAnalyzer:
|
|||||||
self._lifecycle_lock = threading.Lock()
|
self._lifecycle_lock = threading.Lock()
|
||||||
self._data: dict | None = None
|
self._data: dict | None = None
|
||||||
self._current_device_name: str | 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
|
# Slow AGC envelope so the spectrum reflects real dynamics
|
||||||
# instead of being renormalized to peak=1.0 every frame.
|
# instead of being renormalized to peak=1.0 every frame.
|
||||||
# A loud transient (e.g. notification beep) lifts the reference
|
# A loud transient (e.g. notification beep) lifts the reference
|
||||||
@@ -128,17 +137,30 @@ class AudioAnalyzer:
|
|||||||
"""Stop audio capture and cleanup."""
|
"""Stop audio capture and cleanup."""
|
||||||
with self._lifecycle_lock:
|
with self._lifecycle_lock:
|
||||||
self._running = False
|
self._running = False
|
||||||
|
# Wake any waiter so it can observe _running and exit cleanly.
|
||||||
|
self._data_event.set()
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=3.0)
|
self._thread.join(timeout=3.0)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._data = None
|
self._data = None
|
||||||
|
self._data_event.clear()
|
||||||
|
|
||||||
def get_frequency_data(self) -> dict | None:
|
def get_frequency_data(self) -> dict | None:
|
||||||
"""Return latest frequency data (thread-safe). None if not running."""
|
"""Return latest frequency data (thread-safe). None if not running."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._data
|
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
|
@staticmethod
|
||||||
def list_loopback_devices() -> list[dict[str, str]]:
|
def list_loopback_devices() -> list[dict[str, str]]:
|
||||||
"""List all available loopback audio devices."""
|
"""List all available loopback audio devices."""
|
||||||
@@ -250,12 +272,24 @@ class AudioAnalyzer:
|
|||||||
return
|
return
|
||||||
|
|
||||||
interval = 1.0 / self.target_fps
|
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
|
# Pre-compute bin edge pairs for vectorized grouping
|
||||||
edges = self._bin_edges
|
edges = self._bin_edges
|
||||||
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
|
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)
|
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:
|
try:
|
||||||
with device.recorder(
|
with device.recorder(
|
||||||
@@ -284,21 +318,23 @@ class AudioAnalyzer:
|
|||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Apply window and compute FFT
|
# Apply window in-place into the pre-allocated buffer.
|
||||||
windowed = mono[:self.chunk_size] * window
|
np.multiply(mono[:self.chunk_size], window, out=windowed)
|
||||||
fft_mag = np.abs(np.fft.rfft(windowed))
|
fft_mag = np.abs(np.fft.rfft(windowed))
|
||||||
|
|
||||||
# Group into logarithmic bins (vectorized via cumsum)
|
# Group into logarithmic bins (vectorized via cumsum).
|
||||||
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
|
# Write into the pre-allocated [1:] slice so cumsum[0]
|
||||||
counts = bin_ends - bin_starts
|
# stays 0.0 and we never allocate a new array.
|
||||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
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
|
# True loudness from time-domain RMS via single BLAS
|
||||||
# so the VU needle reflects actual program level — not
|
# dot — avoids astype() and ** allocations.
|
||||||
# the per-frame-normalized spectrum.
|
mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
|
||||||
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
|
energy = float(np.dot(mono32, mono32))
|
||||||
if rms > 1e-6:
|
if energy > 1e-12:
|
||||||
db = 20.0 * np.log10(rms)
|
rms = (energy / mono32.size) ** 0.5
|
||||||
|
db = 20.0 * math.log10(rms)
|
||||||
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
||||||
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
||||||
else:
|
else:
|
||||||
@@ -314,18 +350,33 @@ class AudioAnalyzer:
|
|||||||
else:
|
else:
|
||||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
|
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
|
||||||
ref = max(self._spectrum_ref, 1e-4)
|
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 energy: average of first 4 bins (~20-200Hz)
|
||||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||||
|
|
||||||
# Round for compact JSON
|
# Quantize to 0..1000 ints — same wire fidelity as
|
||||||
frequencies = np.round(bins, 3).tolist()
|
# 3-decimal floats but smaller GC churn on both ends
|
||||||
bass = round(bass, 3)
|
# (frontend smooths anyway, so quantization is
|
||||||
level = round(level, 3)
|
# 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:
|
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
|
# Throttle to target FPS
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
|
|||||||
@@ -161,26 +161,48 @@ class ConnectionManager:
|
|||||||
self._audio_task = None
|
self._audio_task = None
|
||||||
|
|
||||||
async def _audio_broadcast_loop(self) -> None:
|
async def _audio_broadcast_loop(self) -> None:
|
||||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
|
||||||
from ..config import settings
|
|
||||||
interval = 1.0 / settings.visualizer_fps
|
|
||||||
|
|
||||||
_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:
|
while True:
|
||||||
try:
|
try:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
subscribers = list(self._visualizer_subscribers)
|
subscribers = list(self._visualizer_subscribers)
|
||||||
|
|
||||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
analyzer = self._audio_analyzer
|
||||||
await asyncio.sleep(interval)
|
if not subscribers or not analyzer or not analyzer.running:
|
||||||
|
await asyncio.sleep(idle_interval)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = self._audio_analyzer.get_frequency_data()
|
# Wait off-loop for a fresh frame. The capture thread sets
|
||||||
if data is None or data is _last_data:
|
# data_event after each FFT update; we clear it before the
|
||||||
await asyncio.sleep(interval)
|
# 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
|
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)
|
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||||
@@ -198,13 +220,11 @@ class ConnectionManager:
|
|||||||
for ws in failed:
|
for ws in failed:
|
||||||
await self.disconnect(ws)
|
await self.disconnect(ws)
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error in audio broadcast: %s", e)
|
logger.error("Error in audio broadcast: %s", e)
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(idle_interval)
|
||||||
|
|
||||||
def status_changed(
|
def status_changed(
|
||||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||||
|
|||||||
@@ -113,7 +113,8 @@
|
|||||||
--copper: #E08038;
|
--copper: #E08038;
|
||||||
--copper-hi: #F4A064;
|
--copper-hi: #F4A064;
|
||||||
--copper-lo: #B0561F;
|
--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;
|
--amber: #F5C26B;
|
||||||
--jade: #7AB294;
|
--jade: #7AB294;
|
||||||
@@ -167,7 +168,8 @@
|
|||||||
--copper: #1F4E3D; /* hunter emerald in light mode */
|
--copper: #1F4E3D; /* hunter emerald in light mode */
|
||||||
--copper-hi: #2D6A53;
|
--copper-hi: #2D6A53;
|
||||||
--copper-lo: #143E2F;
|
--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;
|
--amber: #C29D31;
|
||||||
--jade: #4D8C6F;
|
--jade: #4D8C6F;
|
||||||
@@ -3991,11 +3993,11 @@ body.mini-player-visible footer {
|
|||||||
/* ─── Container & header ────────────────────────────────────── */
|
/* ─── Container & header ────────────────────────────────────── */
|
||||||
.container {
|
.container {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
padding: 56px 48px 140px;
|
padding: 56px 48px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.container { padding: 48px 18px 140px; }
|
.container { padding: 48px 18px 56px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Folio marks (page corners, all tabs) ────────────────── */
|
/* ─── Folio marks (page corners, all tabs) ────────────────── */
|
||||||
@@ -4416,6 +4418,35 @@ header .brand-sub {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 36px;
|
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 721–1240 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 ──────────────────────────────────────────── */
|
/* ─── Vinyl stage ──────────────────────────────────────────── */
|
||||||
@@ -4599,23 +4630,31 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
metadata lives in the masthead beside the stage.
|
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 {
|
.vinyl-stage > #album-art-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 50%;
|
||||||
width: 100%;
|
left: 50%;
|
||||||
height: 100%;
|
width: 25%;
|
||||||
|
height: 25%;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
filter: blur(34px) saturate(1.6);
|
filter: blur(9px) saturate(1.6);
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
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 {
|
:root[data-theme="light"] .vinyl-stage > #album-art-glow {
|
||||||
opacity: 0.26;
|
opacity: 0.26;
|
||||||
filter: blur(40px) saturate(1.8);
|
filter: blur(10px) saturate(1.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Honour reduced-motion: kill breathing pulse */
|
/* Honour reduced-motion: kill breathing pulse */
|
||||||
@@ -4683,7 +4722,8 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
filter: contrast(0.96) saturate(0.92);
|
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
|
/* 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;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] .vinyl-stage .vinyl-wrap .vinyl-label {
|
: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:
|
box-shadow:
|
||||||
inset 0 0 18px rgba(0, 0, 0, 0.4),
|
inset 0 0 18px rgba(0, 0, 0, 0.4),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
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: 1px solid var(--rule-strong);
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
overflow: hidden;
|
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 {
|
.vu-meter::before {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -5119,7 +5159,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
height: 10px;
|
height: 10px;
|
||||||
background: var(--copper);
|
background: var(--copper);
|
||||||
border-radius: 50%;
|
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;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5160,7 +5200,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
transition: all 220ms var(--ease);
|
transition: all 220ms var(--ease);
|
||||||
}
|
}
|
||||||
.controls button:hover {
|
.controls button:hover {
|
||||||
background: rgba(224, 128, 56, 0.06);
|
background: rgba(var(--copper-rgb), 0.06);
|
||||||
border-color: var(--copper);
|
border-color: var(--copper);
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
}
|
}
|
||||||
@@ -5204,7 +5244,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
.mute-btn:hover {
|
.mute-btn:hover {
|
||||||
border-color: var(--copper);
|
border-color: var(--copper);
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
background: rgba(224, 128, 56, 0.06);
|
background: rgba(var(--copper-rgb), 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
#volume-slider {
|
#volume-slider {
|
||||||
@@ -5223,7 +5263,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
background: var(--copper);
|
background: var(--copper);
|
||||||
border-radius: 50%;
|
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;
|
border: 0;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
@@ -5288,7 +5328,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
.vinyl-toggle-btn.active {
|
.vinyl-toggle-btn.active {
|
||||||
border-color: var(--copper);
|
border-color: var(--copper);
|
||||||
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:last-child { border-right: 0; }
|
||||||
.view-toggle-btn:hover {
|
.view-toggle-btn:hover {
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
background: rgba(224, 128, 56, 0.04);
|
background: rgba(var(--copper-rgb), 0.04);
|
||||||
}
|
}
|
||||||
.view-toggle-btn.active {
|
.view-toggle-btn.active {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
@@ -5978,7 +6018,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
border-color: var(--copper);
|
border-color: var(--copper);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
background: rgba(224, 128, 56, 0.04);
|
background: rgba(var(--copper-rgb), 0.04);
|
||||||
}
|
}
|
||||||
.add-card-icon {
|
.add-card-icon {
|
||||||
font-family: var(--serif);
|
font-family: var(--serif);
|
||||||
@@ -6347,7 +6387,7 @@ dialog::backdrop {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.icon-select-cell:hover {
|
.icon-select-cell:hover {
|
||||||
background: rgba(224, 128, 56, 0.06);
|
background: rgba(var(--copper-rgb), 0.06);
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6469,8 +6509,8 @@ dialog::backdrop {
|
|||||||
FOOTER (colophon)
|
FOOTER (colophon)
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
footer {
|
footer {
|
||||||
margin-top: 80px;
|
margin-top: 36px;
|
||||||
padding: 28px 0 0;
|
padding: 20px 0 0;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
@@ -6576,6 +6616,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
|||||||
}
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.now-playing { grid-template-columns: 1fr; gap: 40px; }
|
.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
|
/* 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;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
|
padding-right: clamp(12px, 1.5vw, 24px);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6702,7 +6744,9 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.22em;
|
letter-spacing: 0.22em;
|
||||||
text-transform: uppercase;
|
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;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.now-playing .meta-cell .value {
|
.now-playing .meta-cell .value {
|
||||||
@@ -6753,21 +6797,24 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
|||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
border-radius: 99px 99px 0 0;
|
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: sr-snap-bar 1.1s ease-in-out infinite;
|
||||||
animation-delay: var(--bar-delay, 0s);
|
animation-delay: var(--bar-delay, 0s);
|
||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
transition: height 60ms linear;
|
transition: transform 50ms linear;
|
||||||
will-change: height;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
:root[data-playstate="playing"] .now-playing .spectrum span {
|
:root[data-playstate="playing"] .now-playing .spectrum span {
|
||||||
animation-play-state: running;
|
animation-play-state: running;
|
||||||
}
|
}
|
||||||
/* When real audio data is driving heights, freeze the CSS animation
|
/* When real audio data is driving the bars, freeze the synthetic CSS
|
||||||
so JS-set heights aren't overridden by the keyframe. */
|
animation so JS-set transforms aren't overridden by the keyframe. */
|
||||||
body.audio-spectrum-live .now-playing .spectrum span {
|
body.audio-spectrum-live .now-playing .spectrum span {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
transition: height 50ms linear;
|
|
||||||
}
|
}
|
||||||
@keyframes sr-snap-bar {
|
@keyframes sr-snap-bar {
|
||||||
0%, 100% { transform: scaleY(0.4); }
|
0%, 100% { transform: scaleY(0.4); }
|
||||||
@@ -6832,7 +6879,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
|||||||
width: 10px; height: 10px;
|
width: 10px; height: 10px;
|
||||||
background: var(--copper);
|
background: var(--copper);
|
||||||
border-radius: 50%;
|
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;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6852,6 +6899,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
|||||||
border: 1px solid var(--rule-strong);
|
border: 1px solid var(--rule-strong);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
width: 48px; height: 48px;
|
width: 48px; height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -6863,7 +6911,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
|||||||
.now-playing .btn-trans:hover {
|
.now-playing .btn-trans:hover {
|
||||||
border-color: var(--copper);
|
border-color: var(--copper);
|
||||||
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 {
|
.now-playing .btn-trans:disabled {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
@@ -6939,7 +6987,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
|||||||
.now-playing .vu-volume .mute-btn:hover {
|
.now-playing .vu-volume .mute-btn:hover {
|
||||||
border-color: var(--copper);
|
border-color: var(--copper);
|
||||||
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 {
|
.now-playing .vu-volume .mute-btn svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -6949,7 +6997,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
|||||||
.now-playing .vu-volume #volume-slider {
|
.now-playing .vu-volume #volume-slider {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 80px;
|
width: 64px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--rule-strong);
|
background: var(--rule-strong);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -6985,13 +7033,13 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
|||||||
|
|
||||||
.now-playing .vu-meter {
|
.now-playing .vu-meter {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 140px;
|
width: 120px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
|
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
|
||||||
border: 1px solid var(--rule-strong);
|
border: 1px solid var(--rule-strong);
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
overflow: hidden;
|
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;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.now-playing .vu-meter::before {
|
.now-playing .vu-meter::before {
|
||||||
@@ -7044,6 +7092,28 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
|||||||
font-weight: 400;
|
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 */
|
/* Mobile VU cluster: stack below controls */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.now-playing .controls { flex-wrap: wrap; }
|
.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-refresh-btn:hover,
|
||||||
.browser-container .browser-play-all-btn:hover {
|
.browser-container .browser-play-all-btn:hover {
|
||||||
color: var(--copper) !important;
|
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 {
|
.browser-container .view-toggle-btn.active {
|
||||||
background: var(--ink) !important;
|
background: var(--ink) !important;
|
||||||
@@ -7714,7 +7784,7 @@ select option {
|
|||||||
border-color: var(--copper) !important;
|
border-color: var(--copper) !important;
|
||||||
border-style: solid !important;
|
border-style: solid !important;
|
||||||
color: var(--copper) !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 {
|
.settings-container .add-card-icon {
|
||||||
font-family: var(--serif);
|
font-family: var(--serif);
|
||||||
@@ -7949,7 +8019,7 @@ select option {
|
|||||||
.display-container .display-power-btn:hover {
|
.display-container .display-power-btn:hover {
|
||||||
color: var(--copper) !important;
|
color: var(--copper) !important;
|
||||||
border-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 */
|
/* Brightness control row */
|
||||||
@@ -8100,7 +8170,7 @@ select option {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.icon-select-cell:hover {
|
.icon-select-cell:hover {
|
||||||
background: rgba(224, 128, 56, 0.06) !important;
|
background: rgba(var(--copper-rgb), 0.06) !important;
|
||||||
color: var(--copper) !important;
|
color: var(--copper) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8358,7 +8428,7 @@ select option {
|
|||||||
}
|
}
|
||||||
.mini-control-btn:hover {
|
.mini-control-btn:hover {
|
||||||
border-color: var(--copper) !important;
|
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;
|
color: var(--copper) !important;
|
||||||
}
|
}
|
||||||
.mini-control-btn svg {
|
.mini-control-btn svg {
|
||||||
@@ -8374,10 +8444,10 @@ select option {
|
|||||||
margin-top: 4px !important;
|
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 {
|
.album-art-container.vinyl-stage {
|
||||||
max-width: 320px;
|
max-width: 460px;
|
||||||
width: 78%;
|
width: 92%;
|
||||||
margin: 0 auto !important;
|
margin: 0 auto !important;
|
||||||
}
|
}
|
||||||
/* Lighter sleeve grain on phones so the printed art reads
|
/* Lighter sleeve grain on phones so the printed art reads
|
||||||
@@ -8482,7 +8552,8 @@ select option {
|
|||||||
.player-layout .controls {
|
.player-layout .controls {
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
gap: 28px !important;
|
gap: 28px !important;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: wrap !important;
|
||||||
|
row-gap: 0 !important;
|
||||||
}
|
}
|
||||||
.now-playing .controls .btn-trans,
|
.now-playing .controls .btn-trans,
|
||||||
.player-layout .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; }
|
.now-playing .controls .btn-trans.primary svg { width: 26px !important; height: 26px !important; }
|
||||||
|
|
||||||
/* VU meter is decorative on a phone — hide it. Volume becomes
|
/* 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,
|
.now-playing .vu-cluster,
|
||||||
.player-layout .vu-cluster {
|
.player-layout .vu-cluster {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
@@ -8506,6 +8579,7 @@ select option {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 14px !important;
|
gap: 14px !important;
|
||||||
|
flex-basis: 100% !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
margin: 18px 0 0 0 !important;
|
margin: 18px 0 0 0 !important;
|
||||||
padding-top: 16px !important;
|
padding-top: 16px !important;
|
||||||
@@ -8535,7 +8609,7 @@ select option {
|
|||||||
.player-layout .vu-volume #volume-slider {
|
.player-layout .vu-volume #volume-slider {
|
||||||
flex: 1 1 auto !important;
|
flex: 1 1 auto !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 200px;
|
max-width: none !important;
|
||||||
height: 3px !important;
|
height: 3px !important;
|
||||||
}
|
}
|
||||||
.now-playing .vu-volume #volume-slider::-webkit-slider-thumb,
|
.now-playing .vu-volume #volume-slider::-webkit-slider-thumb,
|
||||||
@@ -8730,7 +8804,7 @@ body.is-fullscreen-player .player-container {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background:
|
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%);
|
radial-gradient(ellipse at center, var(--bg-paper) 0%, var(--bg-deep) 75%);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: stretch;
|
place-items: stretch;
|
||||||
@@ -8771,18 +8845,27 @@ body.is-fullscreen-player .fs-bloom {
|
|||||||
to { opacity: 0.22; transform: scale(1); }
|
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 {
|
body.is-fullscreen-player .fs-bloom #fs-bloom-art {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 20%;
|
||||||
|
height: 20%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
filter: blur(110px) saturate(1.6);
|
filter: blur(18px) saturate(1.6);
|
||||||
transform: scale(1.18);
|
transform: translate(-50%, -50%) scale(5.9);
|
||||||
|
transform-origin: center;
|
||||||
animation: fs-bloom-drift 28s ease-in-out infinite alternate;
|
animation: fs-bloom-drift 28s ease-in-out infinite alternate;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fs-bloom-drift {
|
@keyframes fs-bloom-drift {
|
||||||
from { transform: scale(1.18) translate3d(-1.5%, -1%, 0); }
|
from { transform: translate(-50%, -50%) scale(5.9) translate3d(-1.5%, -1%, 0); }
|
||||||
to { transform: scale(1.22) translate3d(2%, 1.5%, 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. */
|
/* 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-family: var(--mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.32em;
|
letter-spacing: 0.32em;
|
||||||
color: var(--ink-faint);
|
color: var(--copper);
|
||||||
}
|
}
|
||||||
body.is-fullscreen-player .now-playing .meta-cell .value {
|
body.is-fullscreen-player .now-playing .meta-cell .value {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|||||||
@@ -180,8 +180,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
for (let i = 0; i < SPECTRUM_BARS; i++) {
|
for (let i = 0; i < SPECTRUM_BARS; i++) {
|
||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
// Pseudo-random heights for the synthetic CSS animation phase
|
// Pseudo-random initial scaleY for the synthetic CSS-only
|
||||||
s.style.setProperty('--bar-h', (25 + Math.abs(Math.sin(i * 0.7)) * 70).toFixed(0) + '%');
|
// 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');
|
s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's');
|
||||||
frag.appendChild(s);
|
frag.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
|
|||||||
|
|
||||||
// ---- Render loop ----
|
// ---- 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() {
|
function renderBackgroundFrame() {
|
||||||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||||||
|
|
||||||
const gl = bgGL;
|
const gl = bgGL;
|
||||||
if (!gl || !bgUniforms) return;
|
if (!gl || !bgUniforms) return;
|
||||||
|
|
||||||
resizeBackgroundCanvas();
|
// Resize listener already keeps canvas dimensions in sync — only
|
||||||
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
|
// 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;
|
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) {
|
if (frequencyData && frequencyData.frequencies) {
|
||||||
const bins = 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++) {
|
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||||
const idx = Math.min(i * step, bins.length - 1);
|
let idx = i * step;
|
||||||
const target = bins[idx] || 0;
|
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
|
||||||
|
const target = (bins[idx] || 0) * scale;
|
||||||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
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);
|
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||||
} else {
|
} else {
|
||||||
// Gentle decay when no audio
|
// Gentle decay when no audio
|
||||||
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set uniforms (locations cached at init, colors cached on change)
|
// 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.time, time);
|
||||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ export function cacheDom() {
|
|||||||
|
|
||||||
// Timing constants
|
// Timing constants
|
||||||
export const VOLUME_THROTTLE_MS = 16;
|
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 SEARCH_DEBOUNCE_MS = 200;
|
||||||
export const TOAST_DURATION_MS = 3000;
|
export const TOAST_DURATION_MS = 3000;
|
||||||
export const WS_BACKOFF_BASE_MS = 3000;
|
export const WS_BACKOFF_BASE_MS = 3000;
|
||||||
|
|||||||
+347
-141
@@ -145,6 +145,22 @@ export function lightenColor(hex, percent) {
|
|||||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
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() {
|
export function initAccentColor() {
|
||||||
const saved = localStorage.getItem('accentColor');
|
const saved = localStorage.getItem('accentColor');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -159,12 +175,25 @@ export function initAccentColor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applyAccentColor(color, hover) {
|
export function applyAccentColor(color, hover) {
|
||||||
document.documentElement.style.setProperty('--accent', color);
|
const root = document.documentElement.style;
|
||||||
document.documentElement.style.setProperty('--accent-hover', hover);
|
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);
|
localStorage.setItem('accentColor', color);
|
||||||
const dot = document.getElementById('accentDot');
|
const dot = document.getElementById('accentDot');
|
||||||
if (dot) dot.style.background = color;
|
if (dot) dot.style.background = color;
|
||||||
updateBackgroundColors();
|
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() {
|
export function renderAccentSwatches() {
|
||||||
@@ -215,12 +244,49 @@ export function setVisualizerEnabled(value) {
|
|||||||
visualizerEnabled = !!value;
|
visualizerEnabled = !!value;
|
||||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||||
}
|
}
|
||||||
|
let visualizerCanvas = null; // Cached canvas DOM ref
|
||||||
let visualizerCtx = null;
|
let visualizerCtx = null;
|
||||||
|
let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize)
|
||||||
let visualizerAnimFrame = null;
|
let visualizerAnimFrame = null;
|
||||||
export let frequencyData = null;
|
export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled)
|
||||||
export function setFrequencyData(value) { frequencyData = value; }
|
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;
|
let smoothedFrequencies = null;
|
||||||
const VISUALIZER_SMOOTHING = 0.15;
|
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() {
|
export async function checkVisualizerAvailability() {
|
||||||
try {
|
try {
|
||||||
@@ -274,15 +340,28 @@ export function applyVisualizerMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initVisualizerCanvas() {
|
function initVisualizerCanvas() {
|
||||||
const canvas = document.getElementById('spectrogram-canvas');
|
visualizerCanvas = document.getElementById('spectrogram-canvas');
|
||||||
if (!canvas) return;
|
if (!visualizerCanvas) return;
|
||||||
visualizerCtx = canvas.getContext('2d');
|
visualizerCtx = visualizerCanvas.getContext('2d');
|
||||||
canvas.width = 300;
|
visualizerCanvas.width = 300;
|
||||||
canvas.height = 64;
|
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() {
|
function startVisualizerRender() {
|
||||||
if (visualizerAnimFrame) return;
|
if (visualizerAnimFrame) return;
|
||||||
|
// Cache editorial spectrum bar refs once per start.
|
||||||
|
cacheEditorialSpectrumBars();
|
||||||
renderVisualizerFrame();
|
renderVisualizerFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,62 +370,70 @@ export function stopVisualizerRender() {
|
|||||||
cancelAnimationFrame(visualizerAnimFrame);
|
cancelAnimationFrame(visualizerAnimFrame);
|
||||||
visualizerAnimFrame = null;
|
visualizerAnimFrame = null;
|
||||||
}
|
}
|
||||||
const canvas = document.getElementById('spectrogram-canvas');
|
if (visualizerCtx && visualizerCanvas) {
|
||||||
if (visualizerCtx && canvas) {
|
visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
|
||||||
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
}
|
}
|
||||||
frequencyData = null;
|
frequencyData = null;
|
||||||
|
frequencyDataVersion++; // Force next render to redraw cleared state
|
||||||
|
lastRenderedVersion = -1;
|
||||||
smoothedFrequencies = null;
|
smoothedFrequencies = null;
|
||||||
document.body.classList.remove('audio-spectrum-live');
|
document.body.classList.remove('audio-spectrum-live');
|
||||||
// Reset spectrum bar heights so the synthetic CSS animation takes back over
|
// Reset spectrum bar transforms so the synthetic CSS animation takes back over.
|
||||||
document.querySelectorAll('.now-playing .spectrum > span').forEach(s => {
|
if (editorialSpectrumBars) {
|
||||||
s.style.height = '';
|
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() {
|
function renderVisualizerFrame() {
|
||||||
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
|
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
|
||||||
|
|
||||||
const canvas = document.getElementById('spectrogram-canvas');
|
// VU needle + position progress always tick — they read live state
|
||||||
if (!frequencyData || !visualizerCtx || !canvas) return;
|
// 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 bins = frequencyData.frequencies;
|
||||||
const numBins = bins.length;
|
const numBins = bins.length;
|
||||||
const w = canvas.width;
|
const w = visualizerCanvas.width;
|
||||||
const h = canvas.height;
|
const h = visualizerCanvas.height;
|
||||||
const gap = 2;
|
const gap = 2;
|
||||||
const barWidth = (w / numBins) - gap;
|
const barWidth = (w / numBins) - gap;
|
||||||
const accent = getComputedStyle(document.documentElement)
|
const scale = frequenciesScale;
|
||||||
.getPropertyValue('--accent').trim();
|
|
||||||
|
|
||||||
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
|
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
|
||||||
smoothedFrequencies = new Array(numBins).fill(0);
|
smoothedFrequencies = new Float32Array(numBins);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < numBins; i++) {
|
for (let i = 0; i < numBins; i++) {
|
||||||
|
const v = bins[i] * scale;
|
||||||
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
|
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++) {
|
for (let i = 0; i < numBins; i++) {
|
||||||
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
|
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
|
||||||
const x = i * (barWidth + gap) + gap / 2;
|
const x = i * (barWidth + gap) + gap / 2;
|
||||||
const y = h - barHeight;
|
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.roundRect(x, y, barWidth, barHeight, 1.5);
|
||||||
visualizerCtx.fill();
|
|
||||||
}
|
}
|
||||||
|
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.
|
|
||||||
|
|
||||||
// Drive the editorial .spectrum bars from the same frequency data.
|
// Drive the editorial .spectrum bars from the same frequency data.
|
||||||
updateEditorialSpectrum(smoothedFrequencies, numBins);
|
updateEditorialSpectrum(smoothedFrequencies, numBins);
|
||||||
@@ -357,36 +444,79 @@ function renderVisualizerFrame() {
|
|||||||
// dominate); a linear mapping leaves the right half of the spectrum
|
// dominate); a linear mapping leaves the right half of the spectrum
|
||||||
// looking dead. Use a logarithmic frequency-to-bar mapping plus a
|
// looking dead. Use a logarithmic frequency-to-bar mapping plus a
|
||||||
// per-bar high-end gain so all bars carry visible motion.
|
// per-bar high-end gain so all bars carry visible motion.
|
||||||
function updateEditorialSpectrum(bins, numBins) {
|
let editorialSpectrumBars = null; // Live HTMLCollection cached at start
|
||||||
const root = document.querySelector('.now-playing .spectrum');
|
let editorialSpectrumBarCount = 0;
|
||||||
if (!root) return;
|
let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded)
|
||||||
const bars = root.children;
|
let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar
|
||||||
const barCount = bars.length;
|
let editorialBarGains = null; // Pre-computed per-bar gain
|
||||||
if (!barCount) return;
|
let editorialBarRangesForBins = -1; // numBins last used to compute ranges
|
||||||
document.body.classList.add('audio-spectrum-live');
|
|
||||||
|
|
||||||
// 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 lowBin = 1;
|
||||||
const highBin = numBins - 1;
|
const highBin = numBins - 1;
|
||||||
|
const span = highBin - lowBin;
|
||||||
for (let i = 0; i < barCount; i++) {
|
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 t0 = i / barCount;
|
||||||
const t1 = (i + 1) / barCount;
|
const t1 = (i + 1) / barCount;
|
||||||
const startIdx = Math.max(lowBin, Math.floor(lowBin + Math.pow(t0, 2.0) * (highBin - lowBin)));
|
const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span));
|
||||||
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + Math.pow(t1, 2.0) * (highBin - lowBin)));
|
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;
|
let peak = 0;
|
||||||
for (let j = startIdx; j < endIdx && j < numBins; j++) {
|
for (let j = startIdx; j < endIdx; j++) {
|
||||||
if (bins[j] > peak) peak = bins[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 ships AGC-normalized bins (peak ~1, transients up to ~1.5).
|
||||||
// Backend now 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.
|
||||||
// so the master multiplier stays modest to avoid perma-clipping.
|
const raw = peak * 0.65 * gains[i];
|
||||||
const gain = 1 + (i / barCount) * 0.8;
|
const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw);
|
||||||
// Floor at 12% so silent bars are still visually present.
|
// Quantize to 1/1000 — anything finer is invisible. Skip the DOM
|
||||||
const pct = Math.max(12, Math.min(100, peak * 65 * gain));
|
// write when the bar hasn't moved.
|
||||||
bars[i].style.height = pct + '%';
|
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
|
// Replace the album-art src and replay the .is-swapping CSS animation
|
||||||
// so the new artwork crossfades in instead of popping. Re-toggling the
|
// so the new artwork crossfades in instead of popping. Re-toggling the
|
||||||
// class across rAF restarts the keyframes even if it was already on.
|
// 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) {
|
function swapArtworkSrc(imgEl, newSrc) {
|
||||||
if (!imgEl) return;
|
if (!imgEl) return;
|
||||||
if (imgEl.src === newSrc) return;
|
if (imgEl.src === newSrc) return;
|
||||||
imgEl.classList.remove('is-swapping');
|
const wasSwapping = imgEl.classList.contains('is-swapping');
|
||||||
void imgEl.offsetWidth;
|
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.src = newSrc;
|
||||||
imgEl.classList.add('is-swapping');
|
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) {
|
export function updateUI(status) {
|
||||||
setLastStatus(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');
|
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||||
dom.artist.textContent = status.artist || '';
|
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 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";
|
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) {
|
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()
|
headers: getAuthHeaders()
|
||||||
})
|
})
|
||||||
.then(r => r.ok ? r.blob() : null)
|
.then(r => r.ok ? r.blob() : null)
|
||||||
@@ -664,8 +825,11 @@ export function updateUI(status) {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
currentArtworkBlobUrl = url;
|
currentArtworkBlobUrl = url;
|
||||||
swapArtworkSrc(dom.albumArt, url);
|
swapArtworkSrc(dom.albumArt, url);
|
||||||
dom.miniAlbumArt.src = url;
|
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
|
||||||
if (dom.albumArtGlow) dom.albumArtGlow.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);
|
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Artwork fetch failed:', err));
|
.catch(err => console.error('Artwork fetch failed:', err));
|
||||||
@@ -675,17 +839,22 @@ export function updateUI(status) {
|
|||||||
currentArtworkBlobUrl = null;
|
currentArtworkBlobUrl = null;
|
||||||
}
|
}
|
||||||
swapArtworkSrc(dom.albumArt, placeholderArt);
|
swapArtworkSrc(dom.albumArt, placeholderArt);
|
||||||
dom.miniAlbumArt.src = placeholderArt;
|
if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt;
|
||||||
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
|
if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||||
|
syncFullscreenBloomArt(placeholderGlow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.duration && status.position !== null) {
|
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);
|
setCurrentDuration(status.duration);
|
||||||
setCurrentPosition(status.position);
|
setCurrentPosition(status.position);
|
||||||
lastPositionUpdate = Date.now();
|
lastPositionUpdate = Date.now();
|
||||||
lastPositionValue = status.position;
|
lastPositionValue = status.position;
|
||||||
updateProgress(status.position, status.duration);
|
if (positionChanged) updateProgress(status.position, status.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUserAdjustingVolume) {
|
if (!isUserAdjustingVolume) {
|
||||||
@@ -730,17 +899,24 @@ export function updateUI(status) {
|
|||||||
// FFT data the visualizer feeds in). When audio capture isn't
|
// FFT data the visualizer feeds in). When audio capture isn't
|
||||||
// running, fall back to a synthetic wobble bounded by the volume
|
// running, fall back to a synthetic wobble bounded by the volume
|
||||||
// slider position so the needle still looks alive.
|
// 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 vuWobbleStart = 0;
|
||||||
let vuLevelSmoothed = 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_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
|
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
|
||||||
|
|
||||||
function readAudioLevel() {
|
function readAudioLevel() {
|
||||||
if (!frequencyData) return null;
|
if (!frequencyData) return null;
|
||||||
// Backend sends a true loudness signal (RMS-derived dB, 0..1).
|
// 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.
|
// either as float (legacy) or scaled int (new format).
|
||||||
if (typeof frequencyData.level === 'number') return frequencyData.level;
|
if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale;
|
||||||
if (!frequencyData.frequencies) return null;
|
if (!frequencyData.frequencies) return null;
|
||||||
const bins = frequencyData.frequencies;
|
const bins = frequencyData.frequencies;
|
||||||
if (!bins.length) return null;
|
if (!bins.length) return null;
|
||||||
@@ -748,52 +924,62 @@ function readAudioLevel() {
|
|||||||
for (let i = 1; i < bins.length; i++) {
|
for (let i = 1; i < bins.length; i++) {
|
||||||
if (bins[i] > peak) peak = bins[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() {
|
function startVuWobble() {
|
||||||
if (vuWobbleHandle) return;
|
|
||||||
vuWobbleStart = performance.now();
|
vuWobbleStart = performance.now();
|
||||||
const tick = () => {
|
// If the visualizer rAF is already running, it ticks the needle for us.
|
||||||
const needle = document.getElementById('vuNeedle');
|
if (visualizerAnimFrame) return;
|
||||||
if (needle) {
|
if (vuStandaloneHandle) return;
|
||||||
// Loopback capture is post-volume on Windows/macOS, so the
|
const standalone = () => {
|
||||||
// measured level already reflects the output knob — no extra
|
tickVuNeedle();
|
||||||
// (vol/100) attenuation needed.
|
// Stop ourselves once the unified visualizer loop is up.
|
||||||
const audioLevel = readAudioLevel();
|
if (visualizerAnimFrame) {
|
||||||
let target;
|
vuStandaloneHandle = null;
|
||||||
if (audioLevel != null) {
|
return;
|
||||||
// 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)`;
|
|
||||||
}
|
}
|
||||||
vuWobbleHandle = requestAnimationFrame(tick);
|
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||||
};
|
};
|
||||||
vuWobbleHandle = requestAnimationFrame(tick);
|
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopVuWobble() {
|
function stopVuWobble() {
|
||||||
if (vuWobbleHandle) {
|
if (vuStandaloneHandle) {
|
||||||
cancelAnimationFrame(vuWobbleHandle);
|
cancelAnimationFrame(vuStandaloneHandle);
|
||||||
vuWobbleHandle = null;
|
vuStandaloneHandle = null;
|
||||||
}
|
}
|
||||||
vuLevelSmoothed = 0;
|
vuLevelSmoothed = 0;
|
||||||
const needle = document.getElementById('vuNeedle');
|
vuLastAppliedDeg = -999;
|
||||||
if (needle) needle.style.transform = 'rotate(-22deg)';
|
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
|
||||||
|
if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePlaybackState(state) {
|
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) {
|
function updateProgress(position, duration) {
|
||||||
const percent = (position / duration) * 100;
|
const percent = (position / duration) * 100;
|
||||||
const widthStr = `${percent}%`;
|
const tenths = Math.round(percent * 10); // 0..1000
|
||||||
const currentStr = formatTime(position);
|
|
||||||
const totalStr = formatTime(duration);
|
|
||||||
const posRound = Math.round(position);
|
const posRound = Math.round(position);
|
||||||
const durRound = Math.round(duration);
|
const durRound = Math.round(duration);
|
||||||
|
|
||||||
dom.progressFill.style.width = widthStr;
|
const widthChanged = tenths !== lastProgressTenths;
|
||||||
dom.currentTime.textContent = currentStr;
|
const posChanged = posRound !== lastProgressSec;
|
||||||
dom.totalTime.textContent = totalStr;
|
const durChanged = durRound !== lastDurationSec;
|
||||||
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);
|
|
||||||
|
|
||||||
dom.miniProgressFill.style.width = widthStr;
|
if (widthChanged) {
|
||||||
dom.miniCurrentTime.textContent = currentStr;
|
lastProgressTenths = tenths;
|
||||||
dom.miniTotalTime.textContent = totalStr;
|
const widthStr = (tenths / 10) + '%';
|
||||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
dom.progressFill.style.width = widthStr;
|
||||||
const miniBar = document.getElementById('mini-progress-bar');
|
dom.miniProgressFill.style.width = widthStr;
|
||||||
miniBar.setAttribute('aria-valuenow', posRound);
|
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||||
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() {
|
export function startPositionInterpolation() {
|
||||||
@@ -901,13 +1115,15 @@ function updateMuteIcon(muted) {
|
|||||||
let fsChromeIdleTimer = null;
|
let fsChromeIdleTimer = null;
|
||||||
const FS_CHROME_IDLE_MS = 2500;
|
const FS_CHROME_IDLE_MS = 2500;
|
||||||
let fsLastFocusedElement = null;
|
let fsLastFocusedElement = null;
|
||||||
let fsBloomSyncObserver = null;
|
|
||||||
|
|
||||||
function syncFullscreenBloomArt() {
|
// Mirror the album-art onto #fs-bloom-art (the fullscreen ambient
|
||||||
const src = document.getElementById('album-art');
|
// 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');
|
const bloom = document.getElementById('fs-bloom-art');
|
||||||
if (!src || !bloom) return;
|
if (!bloom) return;
|
||||||
if (src.src && src.src !== bloom.src) bloom.src = src.src;
|
const target = url || (dom && dom.albumArt && dom.albumArt.src) || '';
|
||||||
|
if (target && bloom.src !== target) bloom.src = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFsChrome() {
|
function showFsChrome() {
|
||||||
@@ -978,16 +1194,10 @@ export function enterPlayerFullscreen() {
|
|||||||
document.body.classList.add('is-fullscreen-player');
|
document.body.classList.add('is-fullscreen-player');
|
||||||
setMiniPlayerVisible(false);
|
setMiniPlayerVisible(false);
|
||||||
updateFullscreenButtonIcons(true);
|
updateFullscreenButtonIcons(true);
|
||||||
|
// Initial mirror — subsequent swaps are pushed by updateUI directly,
|
||||||
|
// so there is no MutationObserver in the hot path.
|
||||||
syncFullscreenBloomArt();
|
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('mousemove', onFsMouseMove, { passive: true });
|
||||||
document.addEventListener('keydown', onFsKeyDown);
|
document.addEventListener('keydown', onFsKeyDown);
|
||||||
showFsChrome();
|
showFsChrome();
|
||||||
@@ -1017,10 +1227,6 @@ export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
|
|||||||
clearTimeout(fsChromeIdleTimer);
|
clearTimeout(fsChromeIdleTimer);
|
||||||
fsChromeIdleTimer = null;
|
fsChromeIdleTimer = null;
|
||||||
}
|
}
|
||||||
if (fsBloomSyncObserver) {
|
|
||||||
fsBloomSyncObserver.disconnect();
|
|
||||||
fsBloomSyncObserver = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.removeEventListener('mousemove', onFsMouseMove);
|
document.removeEventListener('mousemove', onFsMouseMove);
|
||||||
document.removeEventListener('keydown', onFsKeyDown);
|
document.removeEventListener('keydown', onFsKeyDown);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "media-server"
|
name = "media-server"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
description = "REST API server for controlling system-wide media playback"
|
description = "REST API server for controlling system-wide media playback"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
Reference in New Issue
Block a user