Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 261a14c575 | |||
| e7372b0ccb | |||
| ec5178142e | |||
| 46af2bb8cc | |||
| 25a492d5dd | |||
| f4be2bdb89 | |||
| 51ec1503f4 | |||
| 08c3c80df4 |
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -53,3 +53,5 @@ Thumbs.db
|
||||
# Node.js / esbuild
|
||||
node_modules/
|
||||
media_server/static/dist/
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,3 +196,42 @@ pytest --tb=short -q
|
||||
|
||||
- **ALWAYS ask for user approval before committing and pushing changes.**
|
||||
- When pushing, always push to all remotes: `git push origin master && git push github master`
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
+14
-53
@@ -1,36 +1,19 @@
|
||||
## v0.2.0 (2026-04-25)
|
||||
## v0.2.2 (2026-05-01)
|
||||
|
||||
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.
|
||||
### UI / Player
|
||||
|
||||
### Features
|
||||
- **Studio Reference redesign** — editorial hi-fi aesthetic across the entire UI ([8110c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8110c15))
|
||||
- **Player view rebuilt** to match the Studio Reference mockup ([14e9f22](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/14e9f22))
|
||||
- **Live VU meter + audio-driven spectrum**, editorial banner, subtler dynamic background ([d937c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d937c15))
|
||||
- **Fullscreen "Listening Room" mode** for the player ([59840a1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/59840a1))
|
||||
- **Pocket Edition** mobile layout + tablet tab range fix ([f85ce77](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f85ce77))
|
||||
- Replace sticky footer with a dedicated **About** dialog opened from a new header button — frees up bottom space and removes the always-visible colophon strip ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781))
|
||||
- Reclaim dead space on the player view: drop ~64 px of bottom container padding now that the footer is gone ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781))
|
||||
- Loosen the vinyl stage aspect ratio (`1:1` → `1:0.85`) and switch the tonearm from `height: 36%` to `aspect-ratio: 1` so the disc no longer leaves a tall empty band below the sleeve ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781))
|
||||
- Add `about.*` and `dialog.close` i18n keys for **EN** and **RU** ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781))
|
||||
|
||||
### 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
|
||||
- VU needle now driven from RMS-dB loudness instead of peak-of-bins ([b09569f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/b09569f))
|
||||
- VU: drop conic-gradient mask, draw lines explicitly in 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))
|
||||
- Centred toolbar icons; full-width spectrum; needle at rest; drop dynamic bg ([d7f488a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7f488a))
|
||||
- Real audio level on VU; full-width spectrum; hide canvas under vinyl ([968eb15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/968eb15))
|
||||
- Visualizer: full-width spectrum + device pick auto-starts capture ([a0f74df](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0f74df))
|
||||
- Visualizer: auto-enable actually starts capture; persist audio device ([6066b4a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6066b4a))
|
||||
- Full-width spectrum + log-mapped bars; deeper sepia + soft art fade ([336d596](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/336d596))
|
||||
- Editorial toolbar + sepia album art ([d157388](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d157388))
|
||||
- Close more gaps with mockup (tabs, mini player, volume control) ([e9e4165](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e9e4165))
|
||||
- Snap player view directly from Studio Reference mockup ([77b39e5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/77b39e5))
|
||||
- Drop redundant Elapsed/Length cells; restore timeline ([d9d4672](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d9d4672))
|
||||
- Close gaps with Studio Reference mockup ([265b001](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/265b001))
|
||||
- Player redesign cleanup pass — sleeve, tonearm, AGC, dead code ([2a474ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2a474ea))
|
||||
### Development / Internal
|
||||
|
||||
#### Chores
|
||||
|
||||
- Wire up the **code-review-graph** MCP server: add `.mcp.json` (uvx, stdio), document the graph tools in `CLAUDE.md` so structural exploration prefers graph queries over Grep/Read, and ignore the `.code-review-graph/` index directory ([e7372b0](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e7372b0))
|
||||
|
||||
---
|
||||
|
||||
@@ -39,29 +22,7 @@ A major UI overhaul — the **Studio Reference** editorial hi-fi redesign — pl
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [4c93bfb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4c93bfb) | ui(player): soften vinyl-stage halo, transparent-bg album placeholder, crossfade artwork swaps | alexei.dolgolyov |
|
||||
| [59840a1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/59840a1) | feat(player): fullscreen "Listening Room" mode | alexei.dolgolyov |
|
||||
| [2a474ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2a474ea) | fix(player): redesign cleanup pass — sleeve, tonearm, AGC, dead code | alexei.dolgolyov |
|
||||
| [f85ce77](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f85ce77) | ui(mobile): Pocket Edition layout + tablet tab range fix | alexei.dolgolyov |
|
||||
| [b09569f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/b09569f) | fix(vu): drive needle from RMS-dB loudness instead of peak-of-bins | alexei.dolgolyov |
|
||||
| [f2c8216](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f2c8216) | ui(vu): narrower 44deg swing, peak-based level, faster response; mini progress bar fix | alexei.dolgolyov |
|
||||
| [588a303](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/588a303) | ui: fix search icon overlap, Display cards, compact view, dark dropdowns | alexei.dolgolyov |
|
||||
| [2049850](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2049850) | ui: editorial styling for Library/Quick Access/Settings/Display + tab fix | alexei.dolgolyov |
|
||||
| [9b84fdd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9b84fdd) | fix(vu): drop conic-gradient mask, draw lines explicitly in 0-90 range | alexei.dolgolyov |
|
||||
| [3de2b44](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3de2b44) | fix(vu): clip grid arc to match needle swing range so rest = proper zero | alexei.dolgolyov |
|
||||
| [d7f488a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7f488a) | fix(ui): centred toolbar icons; full-width spectrum; needle at rest; drop dynamic bg | alexei.dolgolyov |
|
||||
| [968eb15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/968eb15) | fix(player): real audio level on VU; full-width spectrum; hide canvas under vinyl | alexei.dolgolyov |
|
||||
| [a0f74df](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0f74df) | fix(visualizer): full-width spectrum + device pick auto-starts capture | alexei.dolgolyov |
|
||||
| [6066b4a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6066b4a) | fix(visualizer): auto-enable actually starts capture; persist audio device | alexei.dolgolyov |
|
||||
| [153424e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/153424e) | ui(player): widen spectrum to fill column; swap volume control to left of VU cluster | alexei.dolgolyov |
|
||||
| [336d596](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/336d596) | fix(ui): full-width spectrum + log-mapped bars; deeper sepia + soft art fade | alexei.dolgolyov |
|
||||
| [d937c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d937c15) | feat(ui): live VU + audio-driven spectrum, editorial banner, subtler dynamic bg | alexei.dolgolyov |
|
||||
| [d157388](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d157388) | fix(ui): editorial toolbar + sepia album art | alexei.dolgolyov |
|
||||
| [e9e4165](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e9e4165) | fix(ui): close more gaps with mockup (tabs, mini player, volume control) | alexei.dolgolyov |
|
||||
| [77b39e5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/77b39e5) | fix(ui): snap player view directly from Studio Reference mockup | alexei.dolgolyov |
|
||||
| [d9d4672](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d9d4672) | fix(ui): drop redundant Elapsed/Length cells; restore timeline | alexei.dolgolyov |
|
||||
| [265b001](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/265b001) | fix(ui): close gaps with Studio Reference mockup | alexei.dolgolyov |
|
||||
| [14e9f22](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/14e9f22) | feat(ui): rebuild player view to match Studio Reference mockup | alexei.dolgolyov |
|
||||
| [8110c15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8110c15) | feat(ui): Studio Reference redesign — editorial hi-fi aesthetic | alexei.dolgolyov |
|
||||
| [ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781) | ui(player): replace footer with About dialog + reclaim dead space | alexei.dolgolyov |
|
||||
| [e7372b0](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e7372b0) | chore: wire up code-review-graph MCP server | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Audio spectrum analyzer service using system loopback capture."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
@@ -71,6 +72,14 @@ class AudioAnalyzer:
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# Generation counter — bumped each time _data is refreshed.
|
||||
# Lets the broadcast loop dedupe without comparing dict identity
|
||||
# (which is fragile because we always allocate a new dict).
|
||||
self._data_seq = 0
|
||||
# Threading.Event signaled when new frame data is available.
|
||||
# The broadcast loop awaits this instead of polling on a timer,
|
||||
# so it wakes up exactly once per produced frame.
|
||||
self._data_event = threading.Event()
|
||||
# Slow AGC envelope so the spectrum reflects real dynamics
|
||||
# instead of being renormalized to peak=1.0 every frame.
|
||||
# A loud transient (e.g. notification beep) lifts the reference
|
||||
@@ -128,17 +137,30 @@ class AudioAnalyzer:
|
||||
"""Stop audio capture and cleanup."""
|
||||
with self._lifecycle_lock:
|
||||
self._running = False
|
||||
# Wake any waiter so it can observe _running and exit cleanly.
|
||||
self._data_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
with self._lock:
|
||||
self._data = None
|
||||
self._data_event.clear()
|
||||
|
||||
def get_frequency_data(self) -> dict | None:
|
||||
"""Return latest frequency data (thread-safe). None if not running."""
|
||||
with self._lock:
|
||||
return self._data
|
||||
|
||||
def get_frequency_data_versioned(self) -> tuple[dict | None, int]:
|
||||
"""Return (data, seq) so callers can dedupe without identity tricks."""
|
||||
with self._lock:
|
||||
return self._data, self._data_seq
|
||||
|
||||
@property
|
||||
def data_event(self) -> threading.Event:
|
||||
"""Event signaled when a fresh frame is ready. Caller must clear()."""
|
||||
return self._data_event
|
||||
|
||||
@staticmethod
|
||||
def list_loopback_devices() -> list[dict[str, str]]:
|
||||
"""List all available loopback audio devices."""
|
||||
@@ -250,12 +272,24 @@ class AudioAnalyzer:
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
window = np.hanning(self.chunk_size)
|
||||
# Float32 window — matches soundcard's typical buffer dtype and
|
||||
# halves FFT memory traffic vs. the default float64.
|
||||
window = np.hanning(self.chunk_size).astype(np.float32)
|
||||
|
||||
# Pre-compute bin edge pairs for vectorized grouping
|
||||
edges = self._bin_edges
|
||||
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
|
||||
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
|
||||
# Counts are constant — compute once.
|
||||
bin_counts = (bin_ends - bin_starts).astype(np.float32)
|
||||
|
||||
# Pre-allocate working buffers so the per-frame allocator churn
|
||||
# on the capture thread (which runs at target_fps Hz, hours on
|
||||
# end) drops to zero copies for these arrays.
|
||||
fft_size = self.chunk_size // 2 + 1
|
||||
windowed = np.empty(self.chunk_size, dtype=np.float32)
|
||||
cumsum = np.empty(fft_size + 1, dtype=np.float32)
|
||||
cumsum[0] = 0.0
|
||||
|
||||
try:
|
||||
with device.recorder(
|
||||
@@ -284,21 +318,23 @@ class AudioAnalyzer:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Apply window and compute FFT
|
||||
windowed = mono[:self.chunk_size] * window
|
||||
# Apply window in-place into the pre-allocated buffer.
|
||||
np.multiply(mono[:self.chunk_size], window, out=windowed)
|
||||
fft_mag = np.abs(np.fft.rfft(windowed))
|
||||
|
||||
# Group into logarithmic bins (vectorized via cumsum)
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
|
||||
counts = bin_ends - bin_starts
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
||||
# Group into logarithmic bins (vectorized via cumsum).
|
||||
# Write into the pre-allocated [1:] slice so cumsum[0]
|
||||
# stays 0.0 and we never allocate a new array.
|
||||
np.cumsum(fft_mag, out=cumsum[1:])
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts
|
||||
|
||||
# True loudness from time-domain RMS, mapped via dB
|
||||
# so the VU needle reflects actual program level — not
|
||||
# the per-frame-normalized spectrum.
|
||||
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
|
||||
if rms > 1e-6:
|
||||
db = 20.0 * np.log10(rms)
|
||||
# True loudness from time-domain RMS via single BLAS
|
||||
# dot — avoids astype() and ** allocations.
|
||||
mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
|
||||
energy = float(np.dot(mono32, mono32))
|
||||
if energy > 1e-12:
|
||||
rms = (energy / mono32.size) ** 0.5
|
||||
db = 20.0 * math.log10(rms)
|
||||
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
||||
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
||||
else:
|
||||
@@ -314,18 +350,33 @@ class AudioAnalyzer:
|
||||
else:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
|
||||
ref = max(self._spectrum_ref, 1e-4)
|
||||
bins = np.clip(bins / ref, 0.0, 1.5)
|
||||
np.divide(bins, ref, out=bins)
|
||||
np.clip(bins, 0.0, 1.5, out=bins)
|
||||
|
||||
# Bass energy: average of first 4 bins (~20-200Hz)
|
||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||
|
||||
# Round for compact JSON
|
||||
frequencies = np.round(bins, 3).tolist()
|
||||
bass = round(bass, 3)
|
||||
level = round(level, 3)
|
||||
# Quantize to 0..1000 ints — same wire fidelity as
|
||||
# 3-decimal floats but smaller GC churn on both ends
|
||||
# (frontend smooths anyway, so quantization is
|
||||
# invisible). JSON encodes ints faster than floats.
|
||||
frequencies = (bins * 1000.0).astype(np.int16).tolist()
|
||||
bass_i = int(bass * 1000.0)
|
||||
level_i = int(level * 1000.0)
|
||||
|
||||
new_data = {
|
||||
"frequencies": frequencies,
|
||||
"bass": bass_i,
|
||||
"level": level_i,
|
||||
# Wire-format flag: clients that see this know
|
||||
# values are 0..1000 ints, not 0..1 floats.
|
||||
"scale": 1000,
|
||||
}
|
||||
with self._lock:
|
||||
self._data = {"frequencies": frequencies, "bass": bass, "level": level}
|
||||
self._data = new_data
|
||||
self._data_seq += 1
|
||||
# Wake any broadcast loop waiting on fresh data.
|
||||
self._data_event.set()
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -161,26 +161,48 @@ class ConnectionManager:
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
||||
from ..config import settings
|
||||
interval = 1.0 / settings.visualizer_fps
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
|
||||
|
||||
_last_data = None
|
||||
Event-driven: blocks on the analyzer's data_event so it wakes up
|
||||
exactly once per produced frame, instead of polling on a timer.
|
||||
Backstop sleep applies when capture is idle / has no subscribers.
|
||||
"""
|
||||
from ..config import settings
|
||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||
wake_timeout = max(0.05, idle_interval)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
last_seq = -1
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self._lock:
|
||||
subscribers = list(self._visualizer_subscribers)
|
||||
|
||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
||||
await asyncio.sleep(interval)
|
||||
analyzer = self._audio_analyzer
|
||||
if not subscribers or not analyzer or not analyzer.running:
|
||||
await asyncio.sleep(idle_interval)
|
||||
continue
|
||||
|
||||
data = self._audio_analyzer.get_frequency_data()
|
||||
if data is None or data is _last_data:
|
||||
await asyncio.sleep(interval)
|
||||
# Wait off-loop for a fresh frame. The capture thread sets
|
||||
# data_event after each FFT update; we clear it before the
|
||||
# next wait so we never burn a wake on stale data.
|
||||
ev = analyzer.data_event
|
||||
|
||||
def _wait() -> bool:
|
||||
return ev.wait(wake_timeout)
|
||||
|
||||
got = await loop.run_in_executor(None, _wait)
|
||||
if not got:
|
||||
# Timeout — loop around to re-check subscriber state.
|
||||
continue
|
||||
_last_data = data
|
||||
ev.clear()
|
||||
|
||||
data, seq = analyzer.get_frequency_data_versioned()
|
||||
if data is None or seq == last_seq:
|
||||
continue
|
||||
last_seq = seq
|
||||
|
||||
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||
@@ -198,13 +220,11 @@ class ConnectionManager:
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
await asyncio.sleep(idle_interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
|
||||
+191
-123
@@ -113,7 +113,8 @@
|
||||
--copper: #E08038;
|
||||
--copper-hi: #F4A064;
|
||||
--copper-lo: #B0561F;
|
||||
--copper-glow: rgba(224, 128, 56, 0.35);
|
||||
--copper-rgb: 224, 128, 56;
|
||||
--copper-glow: rgba(var(--copper-rgb), 0.35);
|
||||
|
||||
--amber: #F5C26B;
|
||||
--jade: #7AB294;
|
||||
@@ -167,7 +168,8 @@
|
||||
--copper: #1F4E3D; /* hunter emerald in light mode */
|
||||
--copper-hi: #2D6A53;
|
||||
--copper-lo: #143E2F;
|
||||
--copper-glow: rgba(31, 78, 61, 0.18);
|
||||
--copper-rgb: 31, 78, 61;
|
||||
--copper-glow: rgba(var(--copper-rgb), 0.18);
|
||||
|
||||
--amber: #C29D31;
|
||||
--jade: #4D8C6F;
|
||||
@@ -2766,36 +2768,6 @@ button.primary svg {
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
transition: padding-bottom 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
body.mini-player-visible footer {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
footer .separator {
|
||||
margin: 0 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Media Browser Styles
|
||||
======================================== */
|
||||
@@ -3924,14 +3896,6 @@ html {
|
||||
padding-right: max(1rem, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
footer {
|
||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
body.mini-player-visible footer {
|
||||
padding-bottom: calc(70px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.connection-banner {
|
||||
padding-top: max(10px, env(safe-area-inset-top));
|
||||
}
|
||||
@@ -3989,13 +3953,17 @@ body.mini-player-visible footer {
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── Container & header ────────────────────────────────────── */
|
||||
/* The footer was removed in favour of an About dialog, so the page
|
||||
bottom is now whatever the active tab content ends with. A 64px
|
||||
bottom pad left a visible dead band under the player view; 24px
|
||||
keeps a breath of breathing room without painting an empty page. */
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
padding: 56px 48px 140px;
|
||||
padding: 56px 48px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.container { padding: 48px 18px 140px; }
|
||||
.container { padding: 48px 18px 24px; }
|
||||
}
|
||||
|
||||
/* ─── Folio marks (page corners, all tabs) ────────────────── */
|
||||
@@ -4416,12 +4384,47 @@ header .brand-sub {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 36px;
|
||||
}
|
||||
/* Single-column hero sizing — prevents the vinyl stage from
|
||||
growing to full content width (which becomes 800px+ on a
|
||||
small-desktop or tablet-landscape window). */
|
||||
.album-art-container.vinyl-stage {
|
||||
max-width: 520px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
/* Cap the right-column copy so a short artist line and meta grid
|
||||
don't sprawl across 1000px of measure. The masthead stays
|
||||
flush-left under the vinyl, just narrower. */
|
||||
.now-playing .track-masthead {
|
||||
max-width: 720px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 721px) and (max-width: 1240px) {
|
||||
/* In the 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 ──────────────────────────────────────────── */
|
||||
/* Aspect-ratio is intentionally wider than tall: the sleeve+disc
|
||||
composition only fills the top ~82% of a square; a strict 1:1 stage
|
||||
left an ~18% empty band below the disc and forced the grid row
|
||||
taller than the masthead column, painting a large dead gap at the
|
||||
bottom of the page. 1:0.85 trims that band while keeping the disc
|
||||
(bottom anchor at top:19.4% + 63% = 82.4% of height) safely inside. */
|
||||
.album-art-container.vinyl-stage {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
aspect-ratio: 1 / 0.85;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
@@ -4558,7 +4561,7 @@ header .brand-sub {
|
||||
top: 26%;
|
||||
right: -6%;
|
||||
width: 36%;
|
||||
height: 36%;
|
||||
aspect-ratio: 1;
|
||||
pointer-events: none;
|
||||
transform-origin: 88% 12%;
|
||||
transform: rotate(-22deg);
|
||||
@@ -4599,23 +4602,31 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
metadata lives in the masthead beside the stage.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Glow: soft ambient halo behind the sleeve */
|
||||
/* Glow: soft ambient halo behind the sleeve.
|
||||
Performance trick: render the image at 25% × 25% of the stage and
|
||||
stretch it via transform: scale(4). Filter runs on the smaller
|
||||
element (16× less area), and scale just upsamples the already-blurred
|
||||
result. Visually identical to a blur(34px) over the full-size image
|
||||
but ~10-16× cheaper on track-switch repaints. */
|
||||
.vinyl-stage > #album-art-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 25%;
|
||||
height: 25%;
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
filter: blur(34px) saturate(1.6);
|
||||
filter: blur(9px) saturate(1.6);
|
||||
opacity: 0.45;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(1.05);
|
||||
transform: translate(-50%, -50%) scale(4.2);
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
:root[data-theme="light"] .vinyl-stage > #album-art-glow {
|
||||
opacity: 0.26;
|
||||
filter: blur(40px) saturate(1.8);
|
||||
filter: blur(10px) saturate(1.8);
|
||||
}
|
||||
|
||||
/* Honour reduced-motion: kill breathing pulse */
|
||||
@@ -4683,7 +4694,8 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
filter: contrast(0.96) saturate(0.92);
|
||||
transition: filter 0.6s ease;
|
||||
/* No transition on filter: the values are static and a 600ms ease
|
||||
on a track-switch swap would force a long compositor pass. */
|
||||
}
|
||||
|
||||
/* Crossfade on artwork swap. Class toggled by player.js right before
|
||||
@@ -4742,7 +4754,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
justify-content: center;
|
||||
}
|
||||
:root[data-theme="light"] .vinyl-stage .vinyl-wrap .vinyl-label {
|
||||
background: linear-gradient(135deg, #1F4E3D 0%, #143E2F 100%);
|
||||
background: linear-gradient(135deg, var(--copper) 0%, var(--copper-lo) 100%);
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
@@ -4937,7 +4949,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px 4px 0 0;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(224,128,56,0.08);
|
||||
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(var(--copper-rgb), 0.08);
|
||||
}
|
||||
.vu-meter::before {
|
||||
content: "";
|
||||
@@ -5119,7 +5131,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
height: 10px;
|
||||
background: var(--copper);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(224, 128, 56, 0.12);
|
||||
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(var(--copper-rgb), 0.12);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -5160,7 +5172,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
transition: all 220ms var(--ease);
|
||||
}
|
||||
.controls button:hover {
|
||||
background: rgba(224, 128, 56, 0.06);
|
||||
background: rgba(var(--copper-rgb), 0.06);
|
||||
border-color: var(--copper);
|
||||
color: var(--copper);
|
||||
}
|
||||
@@ -5204,7 +5216,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
.mute-btn:hover {
|
||||
border-color: var(--copper);
|
||||
color: var(--copper);
|
||||
background: rgba(224, 128, 56, 0.06);
|
||||
background: rgba(var(--copper-rgb), 0.06);
|
||||
}
|
||||
|
||||
#volume-slider {
|
||||
@@ -5223,7 +5235,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
height: 14px;
|
||||
background: var(--copper);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 12px var(--copper-glow), 0 0 0 4px rgba(224, 128, 56, 0.12);
|
||||
box-shadow: 0 0 12px var(--copper-glow), 0 0 0 4px rgba(var(--copper-rgb), 0.12);
|
||||
border: 0;
|
||||
cursor: grab;
|
||||
}
|
||||
@@ -5288,7 +5300,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
.vinyl-toggle-btn.active {
|
||||
border-color: var(--copper);
|
||||
color: var(--copper);
|
||||
background: rgba(224, 128, 56, 0.06);
|
||||
background: rgba(var(--copper-rgb), 0.06);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
@@ -5572,7 +5584,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
.view-toggle-btn:last-child { border-right: 0; }
|
||||
.view-toggle-btn:hover {
|
||||
color: var(--copper);
|
||||
background: rgba(224, 128, 56, 0.04);
|
||||
background: rgba(var(--copper-rgb), 0.04);
|
||||
}
|
||||
.view-toggle-btn.active {
|
||||
background: var(--ink);
|
||||
@@ -5978,7 +5990,7 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
||||
border-color: var(--copper);
|
||||
border-style: solid;
|
||||
color: var(--copper);
|
||||
background: rgba(224, 128, 56, 0.04);
|
||||
background: rgba(var(--copper-rgb), 0.04);
|
||||
}
|
||||
.add-card-icon {
|
||||
font-family: var(--serif);
|
||||
@@ -6347,7 +6359,7 @@ dialog::backdrop {
|
||||
font-size: 10px;
|
||||
}
|
||||
.icon-select-cell:hover {
|
||||
background: rgba(224, 128, 56, 0.06);
|
||||
background: rgba(var(--copper-rgb), 0.06);
|
||||
color: var(--copper);
|
||||
}
|
||||
|
||||
@@ -6466,38 +6478,60 @@ dialog::backdrop {
|
||||
.toast.info { border-color: var(--rule-strong); box-shadow: 0 14px 40px rgba(0,0,0,0.5); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
FOOTER (colophon)
|
||||
ABOUT DIALOG (colophon)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
footer {
|
||||
margin-top: 80px;
|
||||
padding: 28px 0 0;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
background: transparent;
|
||||
.about-dialog {
|
||||
max-width: 520px;
|
||||
}
|
||||
.about-credit {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-soft);
|
||||
margin: 0 0 22px;
|
||||
font-variation-settings: 'opsz' 30;
|
||||
}
|
||||
.about-credit strong {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 22px;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.01em;
|
||||
font-variation-settings: 'opsz' 60;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.about-links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.about-links li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.about-links-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
text-align: center;
|
||||
}
|
||||
footer a {
|
||||
.about-links a {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--copper);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 200ms var(--ease);
|
||||
word-break: break-all;
|
||||
}
|
||||
footer a:hover { border-bottom-color: var(--copper); }
|
||||
footer strong {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
color: var(--ink-soft);
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: none;
|
||||
font-variation-settings: 'opsz' 30;
|
||||
}
|
||||
footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
.about-links a:hover { border-bottom-color: var(--copper); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
DISPLAY container (monitors tab)
|
||||
@@ -6557,7 +6591,6 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
.auth-modal h2 { font-size: 28px; }
|
||||
.settings-section { padding: 20px; }
|
||||
.settings-section summary { font-size: 22px; }
|
||||
footer { font-size: 9px; }
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
@@ -6576,6 +6609,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.now-playing { grid-template-columns: 1fr; gap: 40px; }
|
||||
.now-playing .track-masthead { padding-right: 0; }
|
||||
}
|
||||
|
||||
/* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under
|
||||
@@ -6598,6 +6632,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-top: 0;
|
||||
padding-right: clamp(12px, 1.5vw, 24px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@@ -6702,7 +6737,9 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
/* Match the .kicker (NOW PLAYING) color so the metadata grid reads
|
||||
as part of the same typographic system. */
|
||||
color: var(--copper);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.now-playing .meta-cell .value {
|
||||
@@ -6753,21 +6790,24 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
||||
opacity: 0.92;
|
||||
transform-origin: bottom;
|
||||
border-radius: 99px 99px 0 0;
|
||||
height: var(--bar-h, 40%);
|
||||
/* Bars are full-height boxes; the visible height is driven by
|
||||
transform: scaleY. Transforms are GPU-composited, so per-frame
|
||||
updates skip layout/paint entirely. */
|
||||
height: 100%;
|
||||
transform: scaleY(var(--bar-h-scale, 0.4));
|
||||
animation: sr-snap-bar 1.1s ease-in-out infinite;
|
||||
animation-delay: var(--bar-delay, 0s);
|
||||
animation-play-state: paused;
|
||||
transition: height 60ms linear;
|
||||
will-change: height;
|
||||
transition: transform 50ms linear;
|
||||
will-change: transform;
|
||||
}
|
||||
:root[data-playstate="playing"] .now-playing .spectrum span {
|
||||
animation-play-state: running;
|
||||
}
|
||||
/* When real audio data is driving heights, freeze the CSS animation
|
||||
so JS-set heights aren't overridden by the keyframe. */
|
||||
/* When real audio data is driving the bars, freeze the synthetic CSS
|
||||
animation so JS-set transforms aren't overridden by the keyframe. */
|
||||
body.audio-spectrum-live .now-playing .spectrum span {
|
||||
animation: none !important;
|
||||
transition: height 50ms linear;
|
||||
}
|
||||
@keyframes sr-snap-bar {
|
||||
0%, 100% { transform: scaleY(0.4); }
|
||||
@@ -6832,7 +6872,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
width: 10px; height: 10px;
|
||||
background: var(--copper);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(224, 128, 56, 0.12);
|
||||
box-shadow: 0 0 14px var(--copper-glow), 0 0 0 4px rgba(var(--copper-rgb), 0.12);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -6852,6 +6892,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
border: 1px solid var(--rule-strong);
|
||||
color: var(--ink-soft);
|
||||
width: 48px; height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -6863,7 +6904,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
.now-playing .btn-trans:hover {
|
||||
border-color: var(--copper);
|
||||
color: var(--copper);
|
||||
background: rgba(224, 128, 56, 0.06);
|
||||
background: rgba(var(--copper-rgb), 0.06);
|
||||
}
|
||||
.now-playing .btn-trans:disabled {
|
||||
opacity: 0.35;
|
||||
@@ -6939,7 +6980,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
.now-playing .vu-volume .mute-btn:hover {
|
||||
border-color: var(--copper);
|
||||
color: var(--copper);
|
||||
background: rgba(224, 128, 56, 0.06);
|
||||
background: rgba(var(--copper-rgb), 0.06);
|
||||
}
|
||||
.now-playing .vu-volume .mute-btn svg {
|
||||
width: 14px;
|
||||
@@ -6949,7 +6990,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
.now-playing .vu-volume #volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px;
|
||||
width: 64px;
|
||||
height: 2px;
|
||||
background: var(--rule-strong);
|
||||
border-radius: 0;
|
||||
@@ -6985,13 +7026,13 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
|
||||
.now-playing .vu-meter {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
width: 120px;
|
||||
height: 60px;
|
||||
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px 4px 0 0;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(224,128,56,0.08);
|
||||
box-shadow: inset 0 2px 6px rgba(0,0,0,0.5), inset 0 0 30px rgba(var(--copper-rgb), 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.now-playing .vu-meter::before {
|
||||
@@ -7044,6 +7085,28 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Light theme — paper-faced VU meter (vintage cream gauge instead of black) */
|
||||
:root[data-theme="light"] .now-playing .vu-meter {
|
||||
background: linear-gradient(180deg, #FAF6EE 0%, #E8E0CE 100%);
|
||||
border-color: var(--rule-strong);
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(26, 23, 21, 0.08),
|
||||
inset 0 0 24px rgba(var(--copper-rgb), 0.05);
|
||||
}
|
||||
:root[data-theme="light"] .now-playing .vu-meter::before {
|
||||
background: repeating-conic-gradient(from 195deg at 50% 100%,
|
||||
transparent 0deg 4deg,
|
||||
rgba(26, 23, 21, 0.18) 4deg 5deg,
|
||||
transparent 5deg 9deg);
|
||||
}
|
||||
:root[data-theme="light"] .now-playing .vu-meter::after {
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
:root[data-theme="light"] .now-playing .vu-needle {
|
||||
background: linear-gradient(to top, var(--copper) 0%, var(--copper-lo) 70%, var(--ink) 100%);
|
||||
box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* Mobile VU cluster: stack below controls */
|
||||
@media (max-width: 720px) {
|
||||
.now-playing .controls { flex-wrap: wrap; }
|
||||
@@ -7195,7 +7258,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
|
||||
.browser-container .browser-refresh-btn:hover,
|
||||
.browser-container .browser-play-all-btn:hover {
|
||||
color: var(--copper) !important;
|
||||
background: rgba(224, 128, 56, 0.06) !important;
|
||||
background: rgba(var(--copper-rgb), 0.06) !important;
|
||||
}
|
||||
.browser-container .view-toggle-btn.active {
|
||||
background: var(--ink) !important;
|
||||
@@ -7714,7 +7777,7 @@ select option {
|
||||
border-color: var(--copper) !important;
|
||||
border-style: solid !important;
|
||||
color: var(--copper) !important;
|
||||
background: rgba(224, 128, 56, 0.04) !important;
|
||||
background: rgba(var(--copper-rgb), 0.04) !important;
|
||||
}
|
||||
.settings-container .add-card-icon {
|
||||
font-family: var(--serif);
|
||||
@@ -7949,7 +8012,7 @@ select option {
|
||||
.display-container .display-power-btn:hover {
|
||||
color: var(--copper) !important;
|
||||
border-color: var(--copper) !important;
|
||||
background: rgba(224, 128, 56, 0.06) !important;
|
||||
background: rgba(var(--copper-rgb), 0.06) !important;
|
||||
}
|
||||
|
||||
/* Brightness control row */
|
||||
@@ -8100,7 +8163,7 @@ select option {
|
||||
font-size: 10px;
|
||||
}
|
||||
.icon-select-cell:hover {
|
||||
background: rgba(224, 128, 56, 0.06) !important;
|
||||
background: rgba(var(--copper-rgb), 0.06) !important;
|
||||
color: var(--copper) !important;
|
||||
}
|
||||
|
||||
@@ -8358,7 +8421,7 @@ select option {
|
||||
}
|
||||
.mini-control-btn:hover {
|
||||
border-color: var(--copper) !important;
|
||||
background: rgba(224, 128, 56, 0.08) !important;
|
||||
background: rgba(var(--copper-rgb), 0.08) !important;
|
||||
color: var(--copper) !important;
|
||||
}
|
||||
.mini-control-btn svg {
|
||||
@@ -8374,10 +8437,10 @@ select option {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
/* Vinyl stage: centered, capped at sensible touch size */
|
||||
/* Vinyl stage: centered, generous on tablet/large-phone widths */
|
||||
.album-art-container.vinyl-stage {
|
||||
max-width: 320px;
|
||||
width: 78%;
|
||||
max-width: 460px;
|
||||
width: 92%;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
/* Lighter sleeve grain on phones so the printed art reads
|
||||
@@ -8482,7 +8545,8 @@ select option {
|
||||
.player-layout .controls {
|
||||
justify-content: center !important;
|
||||
gap: 28px !important;
|
||||
flex-wrap: nowrap !important;
|
||||
flex-wrap: wrap !important;
|
||||
row-gap: 0 !important;
|
||||
}
|
||||
.now-playing .controls .btn-trans,
|
||||
.player-layout .controls .btn-trans {
|
||||
@@ -8498,7 +8562,9 @@ select option {
|
||||
.now-playing .controls .btn-trans.primary svg { width: 26px !important; height: 26px !important; }
|
||||
|
||||
/* VU meter is decorative on a phone — hide it. Volume becomes
|
||||
its own full-width hairline row beneath the controls. */
|
||||
its own full-width hairline row beneath the controls.
|
||||
flex-basis: 100% forces the cluster onto its own line in the
|
||||
wrapping .controls flex container. */
|
||||
.now-playing .vu-cluster,
|
||||
.player-layout .vu-cluster {
|
||||
display: flex !important;
|
||||
@@ -8506,6 +8572,7 @@ select option {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px !important;
|
||||
flex-basis: 100% !important;
|
||||
width: 100% !important;
|
||||
margin: 18px 0 0 0 !important;
|
||||
padding-top: 16px !important;
|
||||
@@ -8535,7 +8602,7 @@ select option {
|
||||
.player-layout .vu-volume #volume-slider {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100% !important;
|
||||
max-width: 200px;
|
||||
max-width: none !important;
|
||||
height: 3px !important;
|
||||
}
|
||||
.now-playing .vu-volume #volume-slider::-webkit-slider-thumb,
|
||||
@@ -8633,14 +8700,6 @@ select option {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* ─── Footer: ultra-compact ───────────────────────────── */
|
||||
footer {
|
||||
font-size: 10px !important;
|
||||
padding: 12px 12px !important;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
footer .separator { margin: 0 4px !important; }
|
||||
|
||||
/* Auth modal: full-bleed feel on phones */
|
||||
.auth-modal {
|
||||
width: 92% !important;
|
||||
@@ -8730,7 +8789,7 @@ body.is-fullscreen-player .player-container {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 35%, rgba(224, 128, 56, 0.05) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at 30% 35%, rgba(var(--copper-rgb), 0.05) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at center, var(--bg-paper) 0%, var(--bg-deep) 75%);
|
||||
display: grid;
|
||||
place-items: stretch;
|
||||
@@ -8771,18 +8830,27 @@ body.is-fullscreen-player .fs-bloom {
|
||||
to { opacity: 0.22; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Performance trick (matches the vinyl-stage glow): render the bloom
|
||||
image at 20% of the viewport and stretch it via scale(~6). Blur runs
|
||||
over a 25× smaller area, so a track-switch repaint of the bloom
|
||||
collapses from O(viewport-pixels × 110² ) to O(viewport/25 × 18²). */
|
||||
body.is-fullscreen-player .fs-bloom #fs-bloom-art {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20%;
|
||||
height: 20%;
|
||||
object-fit: cover;
|
||||
filter: blur(110px) saturate(1.6);
|
||||
transform: scale(1.18);
|
||||
filter: blur(18px) saturate(1.6);
|
||||
transform: translate(-50%, -50%) scale(5.9);
|
||||
transform-origin: center;
|
||||
animation: fs-bloom-drift 28s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes fs-bloom-drift {
|
||||
from { transform: scale(1.18) translate3d(-1.5%, -1%, 0); }
|
||||
to { transform: scale(1.22) translate3d(2%, 1.5%, 0); }
|
||||
from { transform: translate(-50%, -50%) scale(5.9) translate3d(-1.5%, -1%, 0); }
|
||||
to { transform: translate(-50%, -50%) scale(6.1) translate3d(2%, 1.5%, 0); }
|
||||
}
|
||||
|
||||
/* Subtle paper-grain veil over the bloom — keeps it from looking flat. */
|
||||
@@ -9022,7 +9090,7 @@ body.is-fullscreen-player .now-playing .meta-cell .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.32em;
|
||||
color: var(--ink-faint);
|
||||
color: var(--copper);
|
||||
}
|
||||
body.is-fullscreen-player .now-playing .meta-cell .value {
|
||||
font-family: var(--mono);
|
||||
|
||||
@@ -91,6 +91,9 @@
|
||||
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
||||
</a>
|
||||
<button class="header-btn" onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
|
||||
</button>
|
||||
<div class="accent-picker">
|
||||
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<span class="accent-dot" id="accentDot"></span>
|
||||
@@ -788,16 +791,31 @@
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div>
|
||||
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
|
||||
<span class="separator">•</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
|
||||
<!-- About Dialog -->
|
||||
<dialog id="aboutDialog" class="about-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="about.title">About</h3>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="dialog-body">
|
||||
<p class="about-credit">
|
||||
<span data-i18n="about.created_by">Created by</span>
|
||||
<strong>Alexei Dolgolyov</strong>
|
||||
</p>
|
||||
<ul class="about-links">
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.email">Email</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.repository">Repository</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="about.source_code">Source Code</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/dist/app.bundle.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
|
||||
changeLocale, t,
|
||||
setAuthRequired,
|
||||
showAboutDialog, closeAboutDialog,
|
||||
} from './core.js';
|
||||
|
||||
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
|
||||
@@ -129,6 +130,8 @@ Object.assign(window, {
|
||||
toggleDisplayPower,
|
||||
// Audio device
|
||||
onAudioDeviceChanged,
|
||||
// About
|
||||
showAboutDialog, closeAboutDialog,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -180,8 +183,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 0; i < SPECTRUM_BARS; i++) {
|
||||
const s = document.createElement('span');
|
||||
// Pseudo-random heights for the synthetic CSS animation phase
|
||||
s.style.setProperty('--bar-h', (25 + Math.abs(Math.sin(i * 0.7)) * 70).toFixed(0) + '%');
|
||||
// Pseudo-random initial scaleY for the synthetic CSS-only
|
||||
// animation (used while no real audio is flowing).
|
||||
const scale = (0.25 + Math.abs(Math.sin(i * 0.7)) * 0.70).toFixed(2);
|
||||
s.style.setProperty('--bar-h-scale', scale);
|
||||
s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's');
|
||||
frag.appendChild(s);
|
||||
}
|
||||
@@ -397,6 +402,16 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// About dialog backdrop click to close
|
||||
const aboutDialog = document.getElementById('aboutDialog');
|
||||
if (aboutDialog) {
|
||||
aboutDialog.addEventListener('click', (e) => {
|
||||
if (e.target === aboutDialog) {
|
||||
closeAboutDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delegated click handlers for link table actions (XSS-safe)
|
||||
document.getElementById('linksTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
|
||||
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
|
||||
|
||||
// ---- Render loop ----
|
||||
|
||||
// Cached step into the bins array; recomputed only when bins.length
|
||||
// changes (which happens at most once after the first audio frame
|
||||
// arrives or when num_bins is reconfigured).
|
||||
let bgBinsLength = -1;
|
||||
let bgBinsStep = 1;
|
||||
// Last applied resolution — drawing with stale viewport is harmless,
|
||||
// but we still need to refresh the uniform after the resize listener
|
||||
// has updated the canvas.
|
||||
let bgLastResW = -1;
|
||||
let bgLastResH = -1;
|
||||
|
||||
function renderBackgroundFrame() {
|
||||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||||
|
||||
const gl = bgGL;
|
||||
if (!gl || !bgUniforms) return;
|
||||
|
||||
resizeBackgroundCanvas();
|
||||
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
|
||||
// Resize listener already keeps canvas dimensions in sync — only
|
||||
// touch the viewport when the canvas actually changed size, so the
|
||||
// per-frame path doesn't read window.innerWidth (a layout-flushing
|
||||
// property).
|
||||
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
|
||||
bgLastResW = bgCanvas.width;
|
||||
bgLastResH = bgCanvas.height;
|
||||
gl.viewport(0, 0, bgLastResW, bgLastResH);
|
||||
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
|
||||
}
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer)
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer).
|
||||
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
const scale = frequencyData.scale && frequencyData.scale > 0
|
||||
? 1.0 / frequencyData.scale : 1.0;
|
||||
if (bins.length !== bgBinsLength) {
|
||||
bgBinsLength = bins.length;
|
||||
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
|
||||
}
|
||||
const step = bgBinsStep;
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
const idx = Math.min(i * step, bins.length - 1);
|
||||
const target = bins[idx] || 0;
|
||||
let idx = i * step;
|
||||
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
|
||||
const target = (bins[idx] || 0) * scale;
|
||||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
||||
}
|
||||
const targetBass = frequencyData.bass || 0;
|
||||
const targetBass = (frequencyData.bass || 0) * scale;
|
||||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||
} else {
|
||||
// Gentle decay when no audio
|
||||
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
|
||||
}
|
||||
|
||||
// Set uniforms (locations cached at init, colors cached on change)
|
||||
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
|
||||
gl.uniform1f(bgUniforms.time, time);
|
||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||
|
||||
@@ -140,7 +140,10 @@ export function cacheDom() {
|
||||
|
||||
// Timing constants
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
export const POSITION_INTERPOLATION_MS = 100;
|
||||
// 250ms is plenty for sub-second progress; the inline updateProgress
|
||||
// also short-circuits when the rounded second hasn't moved, so there's
|
||||
// no visible difference for the user.
|
||||
export const POSITION_INTERPOLATION_MS = 250;
|
||||
export const SEARCH_DEBOUNCE_MS = 200;
|
||||
export const TOAST_DURATION_MS = 3000;
|
||||
export const WS_BACKOFF_BASE_MS = 3000;
|
||||
@@ -394,6 +397,16 @@ export function closeDialog(dialog) {
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
export function showAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) dialog.showModal();
|
||||
}
|
||||
|
||||
export function closeAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) closeDialog(dialog);
|
||||
}
|
||||
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
|
||||
+347
-141
@@ -145,6 +145,22 @@ export function lightenColor(hex, percent) {
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function darkenColor(hex, percent) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.max(0, (num >> 16) - Math.round(255 * percent / 100));
|
||||
const g = Math.max(0, ((num >> 8) & 0xff) - Math.round(255 * percent / 100));
|
||||
const b = Math.max(0, (num & 0xff) - Math.round(255 * percent / 100));
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function hexToRgbTriple(hex) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = (num >> 16) & 0xff;
|
||||
const g = (num >> 8) & 0xff;
|
||||
const b = num & 0xff;
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
export function initAccentColor() {
|
||||
const saved = localStorage.getItem('accentColor');
|
||||
if (saved) {
|
||||
@@ -159,12 +175,25 @@ export function initAccentColor() {
|
||||
}
|
||||
|
||||
export function applyAccentColor(color, hover) {
|
||||
document.documentElement.style.setProperty('--accent', color);
|
||||
document.documentElement.style.setProperty('--accent-hover', hover);
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty('--accent', color);
|
||||
root.setProperty('--accent-hover', hover);
|
||||
// Editorial palette tokens — the redesign reads these directly,
|
||||
// so the picker must drive them too (the --accent alias alone has
|
||||
// no effect once components moved off it).
|
||||
root.setProperty('--copper', color);
|
||||
root.setProperty('--copper-hi', hover);
|
||||
root.setProperty('--copper-lo', darkenColor(color, 12));
|
||||
root.setProperty('--copper-rgb', hexToRgbTriple(color));
|
||||
// --copper-glow inherits the rgba(var(--copper-rgb), 0.35) formula
|
||||
// declared in styles.css, so it picks up the new RGB automatically.
|
||||
localStorage.setItem('accentColor', color);
|
||||
const dot = document.getElementById('accentDot');
|
||||
if (dot) dot.style.background = color;
|
||||
updateBackgroundColors();
|
||||
// Refresh the cached accent in the visualizer so the gradient
|
||||
// rebuilds on its next frame instead of querying CSS every frame.
|
||||
refreshVisualizerAccent();
|
||||
}
|
||||
|
||||
export function renderAccentSwatches() {
|
||||
@@ -215,12 +244,49 @@ export function setVisualizerEnabled(value) {
|
||||
visualizerEnabled = !!value;
|
||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||
}
|
||||
let visualizerCanvas = null; // Cached canvas DOM ref
|
||||
let visualizerCtx = null;
|
||||
let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize)
|
||||
let visualizerAnimFrame = null;
|
||||
export let frequencyData = null;
|
||||
export function setFrequencyData(value) { frequencyData = value; }
|
||||
export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled)
|
||||
let frequencyDataVersion = 0; // Bumped on every setFrequencyData
|
||||
let lastRenderedVersion = -1; // Last version rendered in renderVisualizerFrame
|
||||
let frequenciesScale = 1.0; // Backend scale factor (1000 → ints, 1 → floats)
|
||||
export function setFrequencyData(value) {
|
||||
frequencyData = value;
|
||||
frequencyDataVersion++;
|
||||
// Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale).
|
||||
if (value && typeof value.scale === 'number' && value.scale > 0) {
|
||||
frequenciesScale = 1.0 / value.scale;
|
||||
} else {
|
||||
frequenciesScale = 1.0;
|
||||
}
|
||||
}
|
||||
let smoothedFrequencies = null;
|
||||
const VISUALIZER_SMOOTHING = 0.15;
|
||||
// Cached accent — refreshed by applyAccentColor() rather than on every frame.
|
||||
let cachedAccentHex = '#1db954';
|
||||
let cachedAccentRGB = '29,185,84';
|
||||
function parseAccentHex(hex) {
|
||||
const h = (hex || '').trim().replace('#', '');
|
||||
if (h.length < 6) return null;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
return `${r},${g},${b}`;
|
||||
}
|
||||
export function refreshVisualizerAccent() {
|
||||
const accentHex = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
if (accentHex) {
|
||||
cachedAccentHex = accentHex;
|
||||
const rgb = parseAccentHex(accentHex);
|
||||
if (rgb) cachedAccentRGB = rgb;
|
||||
}
|
||||
// Force gradient rebuild on next frame.
|
||||
visualizerGradient = null;
|
||||
}
|
||||
|
||||
export async function checkVisualizerAvailability() {
|
||||
try {
|
||||
@@ -274,15 +340,28 @@ export function applyVisualizerMode() {
|
||||
}
|
||||
|
||||
function initVisualizerCanvas() {
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!canvas) return;
|
||||
visualizerCtx = canvas.getContext('2d');
|
||||
canvas.width = 300;
|
||||
canvas.height = 64;
|
||||
visualizerCanvas = document.getElementById('spectrogram-canvas');
|
||||
if (!visualizerCanvas) return;
|
||||
visualizerCtx = visualizerCanvas.getContext('2d');
|
||||
visualizerCanvas.width = 300;
|
||||
visualizerCanvas.height = 64;
|
||||
visualizerGradient = null; // Force rebuild
|
||||
refreshVisualizerAccent();
|
||||
}
|
||||
|
||||
function buildVisualizerGradient() {
|
||||
if (!visualizerCtx || !visualizerCanvas) return null;
|
||||
const h = visualizerCanvas.height;
|
||||
const grad = visualizerCtx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`);
|
||||
grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`);
|
||||
return grad;
|
||||
}
|
||||
|
||||
function startVisualizerRender() {
|
||||
if (visualizerAnimFrame) return;
|
||||
// Cache editorial spectrum bar refs once per start.
|
||||
cacheEditorialSpectrumBars();
|
||||
renderVisualizerFrame();
|
||||
}
|
||||
|
||||
@@ -291,62 +370,70 @@ export function stopVisualizerRender() {
|
||||
cancelAnimationFrame(visualizerAnimFrame);
|
||||
visualizerAnimFrame = null;
|
||||
}
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (visualizerCtx && canvas) {
|
||||
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (visualizerCtx && visualizerCanvas) {
|
||||
visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
|
||||
}
|
||||
frequencyData = null;
|
||||
frequencyDataVersion++; // Force next render to redraw cleared state
|
||||
lastRenderedVersion = -1;
|
||||
smoothedFrequencies = null;
|
||||
document.body.classList.remove('audio-spectrum-live');
|
||||
// Reset spectrum bar heights so the synthetic CSS animation takes back over
|
||||
document.querySelectorAll('.now-playing .spectrum > span').forEach(s => {
|
||||
s.style.height = '';
|
||||
});
|
||||
// Reset spectrum bar transforms so the synthetic CSS animation takes back over.
|
||||
if (editorialSpectrumBars) {
|
||||
for (let i = 0; i < editorialSpectrumBars.length; i++) {
|
||||
editorialSpectrumBars[i].style.transform = '';
|
||||
}
|
||||
}
|
||||
// Drop cached bars so next start re-queries.
|
||||
editorialSpectrumBars = null;
|
||||
editorialSpectrumLastScale = null;
|
||||
}
|
||||
|
||||
function renderVisualizerFrame() {
|
||||
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
|
||||
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!frequencyData || !visualizerCtx || !canvas) return;
|
||||
// VU needle + position progress always tick — they read live state
|
||||
// not bound to spectrum payloads. Keeping them in this single rAF
|
||||
// is cheaper than running a second rAF loop just for the needle.
|
||||
tickVuNeedle();
|
||||
|
||||
if (!frequencyData || !visualizerCtx || !visualizerCanvas) return;
|
||||
|
||||
// FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes
|
||||
// at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so
|
||||
// bail when no new payload has arrived since the last draw.
|
||||
if (frequencyDataVersion === lastRenderedVersion) return;
|
||||
lastRenderedVersion = frequencyDataVersion;
|
||||
|
||||
const bins = frequencyData.frequencies;
|
||||
const numBins = bins.length;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const w = visualizerCanvas.width;
|
||||
const h = visualizerCanvas.height;
|
||||
const gap = 2;
|
||||
const barWidth = (w / numBins) - gap;
|
||||
const accent = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
const scale = frequenciesScale;
|
||||
|
||||
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
|
||||
smoothedFrequencies = new Array(numBins).fill(0);
|
||||
smoothedFrequencies = new Float32Array(numBins);
|
||||
}
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const v = bins[i] * scale;
|
||||
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
|
||||
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
|
||||
+ v * (1 - VISUALIZER_SMOOTHING);
|
||||
}
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
if (!visualizerGradient) visualizerGradient = buildVisualizerGradient();
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
visualizerCtx.fillStyle = visualizerGradient;
|
||||
visualizerCtx.beginPath();
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
|
||||
const x = i * (barWidth + gap) + gap / 2;
|
||||
const y = h - barHeight;
|
||||
|
||||
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
|
||||
grad.addColorStop(0, accent);
|
||||
grad.addColorStop(1, accent + '30');
|
||||
|
||||
visualizerCtx.fillStyle = grad;
|
||||
visualizerCtx.beginPath();
|
||||
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
|
||||
visualizerCtx.fill();
|
||||
}
|
||||
|
||||
// Bass-driven album-art scale + glow pulse removed — the
|
||||
// "burst" looked unnatural on the sleeve. Spectrum bars +
|
||||
// VU needle remain the audio-reactive elements.
|
||||
visualizerCtx.fill();
|
||||
|
||||
// Drive the editorial .spectrum bars from the same frequency data.
|
||||
updateEditorialSpectrum(smoothedFrequencies, numBins);
|
||||
@@ -357,36 +444,79 @@ function renderVisualizerFrame() {
|
||||
// dominate); a linear mapping leaves the right half of the spectrum
|
||||
// looking dead. Use a logarithmic frequency-to-bar mapping plus a
|
||||
// per-bar high-end gain so all bars carry visible motion.
|
||||
function updateEditorialSpectrum(bins, numBins) {
|
||||
const root = document.querySelector('.now-playing .spectrum');
|
||||
if (!root) return;
|
||||
const bars = root.children;
|
||||
const barCount = bars.length;
|
||||
if (!barCount) return;
|
||||
document.body.classList.add('audio-spectrum-live');
|
||||
let editorialSpectrumBars = null; // Live HTMLCollection cached at start
|
||||
let editorialSpectrumBarCount = 0;
|
||||
let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded)
|
||||
let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar
|
||||
let editorialBarGains = null; // Pre-computed per-bar gain
|
||||
let editorialBarRangesForBins = -1; // numBins last used to compute ranges
|
||||
|
||||
// Skip the very lowest bin (DC + sub-rumble) which often dominates.
|
||||
function cacheEditorialSpectrumBars() {
|
||||
const root = document.querySelector('.now-playing .spectrum');
|
||||
if (!root) {
|
||||
editorialSpectrumBars = null;
|
||||
editorialSpectrumBarCount = 0;
|
||||
return;
|
||||
}
|
||||
editorialSpectrumBars = root.children;
|
||||
editorialSpectrumBarCount = editorialSpectrumBars.length;
|
||||
editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount);
|
||||
editorialSpectrumLastScale.fill(-1);
|
||||
// Pre-compute per-bar gain (constant for the lifetime of the bar list).
|
||||
editorialBarGains = new Float32Array(editorialSpectrumBarCount);
|
||||
for (let i = 0; i < editorialSpectrumBarCount; i++) {
|
||||
editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8;
|
||||
}
|
||||
editorialBarRangesForBins = -1; // Force range recompute on next call
|
||||
}
|
||||
|
||||
function recomputeEditorialBarRanges(numBins) {
|
||||
const barCount = editorialSpectrumBarCount;
|
||||
editorialBarRanges = new Int16Array(barCount * 2);
|
||||
const lowBin = 1;
|
||||
const highBin = numBins - 1;
|
||||
const span = highBin - lowBin;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
// Logarithmic mapping: equal-area slices of the audible spectrum
|
||||
// map to equal numbers of bars. Each bar covers a wider bin range
|
||||
// toward the highs so they get amplified naturally.
|
||||
const t0 = i / barCount;
|
||||
const t1 = (i + 1) / barCount;
|
||||
const startIdx = Math.max(lowBin, Math.floor(lowBin + Math.pow(t0, 2.0) * (highBin - lowBin)));
|
||||
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + Math.pow(t1, 2.0) * (highBin - lowBin)));
|
||||
const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span));
|
||||
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span));
|
||||
editorialBarRanges[i * 2] = startIdx;
|
||||
editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins);
|
||||
}
|
||||
editorialBarRangesForBins = numBins;
|
||||
}
|
||||
|
||||
function updateEditorialSpectrum(bins, numBins) {
|
||||
if (!editorialSpectrumBars) cacheEditorialSpectrumBars();
|
||||
const barCount = editorialSpectrumBarCount;
|
||||
if (!barCount) return;
|
||||
if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins);
|
||||
document.body.classList.add('audio-spectrum-live');
|
||||
|
||||
const ranges = editorialBarRanges;
|
||||
const gains = editorialBarGains;
|
||||
const lastScale = editorialSpectrumLastScale;
|
||||
const bars = editorialSpectrumBars;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const startIdx = ranges[i * 2];
|
||||
const endIdx = ranges[i * 2 + 1];
|
||||
let peak = 0;
|
||||
for (let j = startIdx; j < endIdx && j < numBins; j++) {
|
||||
if (bins[j] > peak) peak = bins[j];
|
||||
for (let j = startIdx; j < endIdx; j++) {
|
||||
const v = bins[j];
|
||||
if (v > peak) peak = v;
|
||||
}
|
||||
// Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest.
|
||||
// Backend now ships AGC-normalized bins (peak ~1, transients up to 1.5)
|
||||
// so the master multiplier stays modest to avoid perma-clipping.
|
||||
const gain = 1 + (i / barCount) * 0.8;
|
||||
// Floor at 12% so silent bars are still visually present.
|
||||
const pct = Math.max(12, Math.min(100, peak * 65 * gain));
|
||||
bars[i].style.height = pct + '%';
|
||||
// Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5).
|
||||
// Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible.
|
||||
const raw = peak * 0.65 * gains[i];
|
||||
const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw);
|
||||
// Quantize to 1/1000 — anything finer is invisible. Skip the DOM
|
||||
// write when the bar hasn't moved.
|
||||
const q = (scaleY * 1000) | 0;
|
||||
if (q === lastScale[i]) continue;
|
||||
lastScale[i] = q;
|
||||
// transform: scaleY runs on the compositor — no layout/paint.
|
||||
bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,18 +746,46 @@ export function setupProgressDrag(bar, fill) {
|
||||
// Replace the album-art src and replay the .is-swapping CSS animation
|
||||
// so the new artwork crossfades in instead of popping. Re-toggling the
|
||||
// class across rAF restarts the keyframes even if it was already on.
|
||||
//
|
||||
// `forceAnim=false` skips the keyframe-restart reflow when the element
|
||||
// has never run the swap animation before — saves a synchronous layout
|
||||
// flush on first paint. The reflow IS still required when the class
|
||||
// is currently applied; otherwise the browser coalesces add+remove and
|
||||
// the keyframes don't replay.
|
||||
function swapArtworkSrc(imgEl, newSrc) {
|
||||
if (!imgEl) return;
|
||||
if (imgEl.src === newSrc) return;
|
||||
imgEl.classList.remove('is-swapping');
|
||||
void imgEl.offsetWidth;
|
||||
const wasSwapping = imgEl.classList.contains('is-swapping');
|
||||
if (wasSwapping) {
|
||||
imgEl.classList.remove('is-swapping');
|
||||
// Forced reflow restarts the keyframes — only needed when we have
|
||||
// to interrupt an in-flight animation.
|
||||
void imgEl.offsetWidth;
|
||||
}
|
||||
imgEl.src = newSrc;
|
||||
imgEl.classList.add('is-swapping');
|
||||
}
|
||||
|
||||
// Hash of the last fully-rendered status payload — lets us skip
|
||||
// updateUI altogether when the backend re-broadcasts the same state.
|
||||
let lastStatusFingerprint = null;
|
||||
function statusFingerprint(s) {
|
||||
return [
|
||||
s.state, s.title, s.artist, s.album, s.volume, s.muted,
|
||||
s.duration, s.source, s.album_art_url, s.position
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function updateUI(status) {
|
||||
setLastStatus(status);
|
||||
|
||||
// Idempotence: if nothing meaningful changed, skip the entire DOM
|
||||
// pass. Track switches arrive as 1-3 status_update broadcasts in
|
||||
// quick succession; this gates the redundant ones.
|
||||
const fingerprint = statusFingerprint(status);
|
||||
if (fingerprint === lastStatusFingerprint) return;
|
||||
lastStatusFingerprint = fingerprint;
|
||||
|
||||
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||
dom.artist.textContent = status.artist || '';
|
||||
@@ -654,7 +812,10 @@ export function updateUI(status) {
|
||||
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
|
||||
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
|
||||
if (artworkSource) {
|
||||
fetch(`/api/media/artwork?_=${Date.now()}`, {
|
||||
// No cache-buster: when album_art_url is unchanged the
|
||||
// browser can reuse the decoded bitmap. The artworkKey gate
|
||||
// already skips fetches when the user hasn't switched tracks.
|
||||
fetch('/api/media/artwork', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(r => r.ok ? r.blob() : null)
|
||||
@@ -664,8 +825,11 @@ export function updateUI(status) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
currentArtworkBlobUrl = url;
|
||||
swapArtworkSrc(dom.albumArt, url);
|
||||
dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
|
||||
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
|
||||
// Mirror to fullscreen bloom directly — drops the
|
||||
// MutationObserver fan-out path.
|
||||
syncFullscreenBloomArt(url);
|
||||
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||
})
|
||||
.catch(err => console.error('Artwork fetch failed:', err));
|
||||
@@ -675,17 +839,22 @@ export function updateUI(status) {
|
||||
currentArtworkBlobUrl = null;
|
||||
}
|
||||
swapArtworkSrc(dom.albumArt, placeholderArt);
|
||||
dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
syncFullscreenBloomArt(placeholderGlow);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.duration && status.position !== null) {
|
||||
// Only redo the progress DOM when position actually changed.
|
||||
const positionChanged =
|
||||
status.duration !== currentDuration ||
|
||||
Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05;
|
||||
setCurrentDuration(status.duration);
|
||||
setCurrentPosition(status.position);
|
||||
lastPositionUpdate = Date.now();
|
||||
lastPositionValue = status.position;
|
||||
updateProgress(status.position, status.duration);
|
||||
if (positionChanged) updateProgress(status.position, status.duration);
|
||||
}
|
||||
|
||||
if (!isUserAdjustingVolume) {
|
||||
@@ -730,17 +899,24 @@ export function updateUI(status) {
|
||||
// FFT data the visualizer feeds in). When audio capture isn't
|
||||
// running, fall back to a synthetic wobble bounded by the volume
|
||||
// slider position so the needle still looks alive.
|
||||
let vuWobbleHandle = null;
|
||||
//
|
||||
// One unified rAF drives both the spectrum and the VU needle (see
|
||||
// renderVisualizerFrame → tickVuNeedle). If the visualizer isn't
|
||||
// rendering, a separate rAF takes over solely for the needle.
|
||||
let vuStandaloneHandle = null;
|
||||
let vuWobbleStart = 0;
|
||||
let vuLevelSmoothed = 0;
|
||||
let vuNeedleEl = null; // Cached needle element
|
||||
let vuVolumeSliderEl = null; // Cached slider element
|
||||
let vuLastAppliedDeg = -999; // Skip DOM writes when angle unchanged
|
||||
const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits
|
||||
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
|
||||
|
||||
function readAudioLevel() {
|
||||
if (!frequencyData) return null;
|
||||
// Backend sends a true loudness signal (RMS-derived dB, 0..1).
|
||||
// The bins are renormalized per frame so peak-of-bins is useless for level.
|
||||
if (typeof frequencyData.level === 'number') return frequencyData.level;
|
||||
// Backend sends a true loudness signal (RMS-derived dB, 0..1) —
|
||||
// either as float (legacy) or scaled int (new format).
|
||||
if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale;
|
||||
if (!frequencyData.frequencies) return null;
|
||||
const bins = frequencyData.frequencies;
|
||||
if (!bins.length) return null;
|
||||
@@ -748,52 +924,62 @@ function readAudioLevel() {
|
||||
for (let i = 1; i < bins.length; i++) {
|
||||
if (bins[i] > peak) peak = bins[i];
|
||||
}
|
||||
return Math.min(1, peak * 1.4);
|
||||
return Math.min(1, peak * frequenciesScale * 1.4);
|
||||
}
|
||||
|
||||
function tickVuNeedle() {
|
||||
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
|
||||
if (!vuNeedleEl) return;
|
||||
const audioLevel = readAudioLevel();
|
||||
let target;
|
||||
if (audioLevel != null) {
|
||||
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
|
||||
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
|
||||
target = -22 + vuLevelSmoothed * 44;
|
||||
} else {
|
||||
if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider');
|
||||
const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0;
|
||||
const base = -22 + (vol / 100) * 44;
|
||||
const mag = Math.max(2, Math.min(14, vol * 0.16));
|
||||
const t = (performance.now() - vuWobbleStart) / 1000;
|
||||
target = base
|
||||
+ Math.sin(t * 6.3) * mag * 0.55
|
||||
+ Math.sin(t * 11.7 + 1.3) * mag * 0.30
|
||||
+ (Math.random() - 0.5) * mag * 0.30;
|
||||
}
|
||||
// Quantize to 0.1° — finer is invisible. Skip when unchanged.
|
||||
const q = Math.round(target * 10) / 10;
|
||||
if (q === vuLastAppliedDeg) return;
|
||||
vuLastAppliedDeg = q;
|
||||
vuNeedleEl.style.transform = `rotate(${q}deg)`;
|
||||
}
|
||||
|
||||
function startVuWobble() {
|
||||
if (vuWobbleHandle) return;
|
||||
vuWobbleStart = performance.now();
|
||||
const tick = () => {
|
||||
const needle = document.getElementById('vuNeedle');
|
||||
if (needle) {
|
||||
// Loopback capture is post-volume on Windows/macOS, so the
|
||||
// measured level already reflects the output knob — no extra
|
||||
// (vol/100) attenuation needed.
|
||||
const audioLevel = readAudioLevel();
|
||||
let target;
|
||||
if (audioLevel != null) {
|
||||
// Real audio: apply attack/release smoothing for
|
||||
// analog-feeling ballistics.
|
||||
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
|
||||
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
|
||||
target = -22 + vuLevelSmoothed * 44;
|
||||
} else {
|
||||
const slider = document.getElementById('volume-slider');
|
||||
const vol = slider ? Number(slider.value) || 0 : 0;
|
||||
const base = -22 + (vol / 100) * 44;
|
||||
const mag = Math.max(2, Math.min(14, vol * 0.16));
|
||||
const t = (performance.now() - vuWobbleStart) / 1000;
|
||||
target = base
|
||||
+ Math.sin(t * 6.3) * mag * 0.55
|
||||
+ Math.sin(t * 11.7 + 1.3) * mag * 0.30
|
||||
+ (Math.random() - 0.5) * mag * 0.30;
|
||||
}
|
||||
needle.style.transform = `rotate(${target}deg)`;
|
||||
// If the visualizer rAF is already running, it ticks the needle for us.
|
||||
if (visualizerAnimFrame) return;
|
||||
if (vuStandaloneHandle) return;
|
||||
const standalone = () => {
|
||||
tickVuNeedle();
|
||||
// Stop ourselves once the unified visualizer loop is up.
|
||||
if (visualizerAnimFrame) {
|
||||
vuStandaloneHandle = null;
|
||||
return;
|
||||
}
|
||||
vuWobbleHandle = requestAnimationFrame(tick);
|
||||
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||
};
|
||||
vuWobbleHandle = requestAnimationFrame(tick);
|
||||
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||
}
|
||||
|
||||
function stopVuWobble() {
|
||||
if (vuWobbleHandle) {
|
||||
cancelAnimationFrame(vuWobbleHandle);
|
||||
vuWobbleHandle = null;
|
||||
if (vuStandaloneHandle) {
|
||||
cancelAnimationFrame(vuStandaloneHandle);
|
||||
vuStandaloneHandle = null;
|
||||
}
|
||||
vuLevelSmoothed = 0;
|
||||
const needle = document.getElementById('vuNeedle');
|
||||
if (needle) needle.style.transform = 'rotate(-22deg)';
|
||||
vuLastAppliedDeg = -999;
|
||||
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
|
||||
if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)';
|
||||
}
|
||||
|
||||
export function updatePlaybackState(state) {
|
||||
@@ -830,30 +1016,58 @@ export function updatePlaybackState(state) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache last applied progress values so we can skip DOM writes when the
|
||||
// rounded second hasn't moved. Width is quantized to 0.1% — finer is
|
||||
// invisible but would still trigger compositor work.
|
||||
let lastProgressTenths = -1; // 0..1000 (0.1% increments)
|
||||
let lastProgressSec = -1;
|
||||
let lastDurationSec = -1;
|
||||
let cachedMiniBar = null;
|
||||
|
||||
function updateProgress(position, duration) {
|
||||
const percent = (position / duration) * 100;
|
||||
const widthStr = `${percent}%`;
|
||||
const currentStr = formatTime(position);
|
||||
const totalStr = formatTime(duration);
|
||||
const tenths = Math.round(percent * 10); // 0..1000
|
||||
const posRound = Math.round(position);
|
||||
const durRound = Math.round(duration);
|
||||
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.currentTime.textContent = currentStr;
|
||||
dom.totalTime.textContent = totalStr;
|
||||
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
|
||||
if (dom.metaLength) dom.metaLength.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
dom.progressBar.setAttribute('aria-valuenow', posRound);
|
||||
dom.progressBar.setAttribute('aria-valuemax', durRound);
|
||||
const widthChanged = tenths !== lastProgressTenths;
|
||||
const posChanged = posRound !== lastProgressSec;
|
||||
const durChanged = durRound !== lastDurationSec;
|
||||
|
||||
dom.miniProgressFill.style.width = widthStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||
const miniBar = document.getElementById('mini-progress-bar');
|
||||
miniBar.setAttribute('aria-valuenow', posRound);
|
||||
miniBar.setAttribute('aria-valuemax', durRound);
|
||||
if (widthChanged) {
|
||||
lastProgressTenths = tenths;
|
||||
const widthStr = (tenths / 10) + '%';
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.miniProgressFill.style.width = widthStr;
|
||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||
}
|
||||
|
||||
if (posChanged) {
|
||||
lastProgressSec = posRound;
|
||||
const currentStr = formatTime(position);
|
||||
dom.currentTime.textContent = currentStr;
|
||||
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.progressBar.setAttribute('aria-valuenow', posRound);
|
||||
}
|
||||
|
||||
if (durChanged) {
|
||||
lastDurationSec = durRound;
|
||||
const totalStr = formatTime(duration);
|
||||
dom.totalTime.textContent = totalStr;
|
||||
if (dom.metaLength) dom.metaLength.textContent = totalStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
dom.progressBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
if (posChanged || durChanged) {
|
||||
if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar');
|
||||
if (cachedMiniBar) {
|
||||
if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound);
|
||||
if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startPositionInterpolation() {
|
||||
@@ -901,13 +1115,15 @@ function updateMuteIcon(muted) {
|
||||
let fsChromeIdleTimer = null;
|
||||
const FS_CHROME_IDLE_MS = 2500;
|
||||
let fsLastFocusedElement = null;
|
||||
let fsBloomSyncObserver = null;
|
||||
|
||||
function syncFullscreenBloomArt() {
|
||||
const src = document.getElementById('album-art');
|
||||
// Mirror the album-art onto #fs-bloom-art (the fullscreen ambient
|
||||
// bloom). Called directly from the artwork-swap path — no
|
||||
// MutationObserver, so we never repaint the 110px-radius blur twice.
|
||||
function syncFullscreenBloomArt(url) {
|
||||
const bloom = document.getElementById('fs-bloom-art');
|
||||
if (!src || !bloom) return;
|
||||
if (src.src && src.src !== bloom.src) bloom.src = src.src;
|
||||
if (!bloom) return;
|
||||
const target = url || (dom && dom.albumArt && dom.albumArt.src) || '';
|
||||
if (target && bloom.src !== target) bloom.src = target;
|
||||
}
|
||||
|
||||
function showFsChrome() {
|
||||
@@ -978,16 +1194,10 @@ export function enterPlayerFullscreen() {
|
||||
document.body.classList.add('is-fullscreen-player');
|
||||
setMiniPlayerVisible(false);
|
||||
updateFullscreenButtonIcons(true);
|
||||
// Initial mirror — subsequent swaps are pushed by updateUI directly,
|
||||
// so there is no MutationObserver in the hot path.
|
||||
syncFullscreenBloomArt();
|
||||
|
||||
// Watch for album-art swaps so the bloom keeps up.
|
||||
const src = document.getElementById('album-art');
|
||||
if (src && 'MutationObserver' in window) {
|
||||
if (fsBloomSyncObserver) fsBloomSyncObserver.disconnect();
|
||||
fsBloomSyncObserver = new MutationObserver(syncFullscreenBloomArt);
|
||||
fsBloomSyncObserver.observe(src, { attributes: true, attributeFilter: ['src'] });
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onFsMouseMove, { passive: true });
|
||||
document.addEventListener('keydown', onFsKeyDown);
|
||||
showFsChrome();
|
||||
@@ -1017,10 +1227,6 @@ export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
|
||||
clearTimeout(fsChromeIdleTimer);
|
||||
fsChromeIdleTimer = null;
|
||||
}
|
||||
if (fsBloomSyncObserver) {
|
||||
fsBloomSyncObserver.disconnect();
|
||||
fsBloomSyncObserver = null;
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onFsMouseMove);
|
||||
document.removeEventListener('keydown', onFsKeyDown);
|
||||
|
||||
@@ -259,8 +259,13 @@
|
||||
"links.msg.load_failed": "Failed to load link details",
|
||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
||||
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"footer.created_by": "Created by",
|
||||
"footer.source_code": "Source Code",
|
||||
"about.button_title": "About",
|
||||
"about.title": "About",
|
||||
"about.created_by": "Created by",
|
||||
"about.email": "Email",
|
||||
"about.repository": "Repository",
|
||||
"about.source_code": "Source Code",
|
||||
"dialog.close": "Close",
|
||||
"update.available": "Update available: v{version}",
|
||||
"update.view_release": "View Release"
|
||||
}
|
||||
|
||||
@@ -259,8 +259,13 @@
|
||||
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
|
||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код",
|
||||
"about.button_title": "О программе",
|
||||
"about.title": "О программе",
|
||||
"about.created_by": "Создано",
|
||||
"about.email": "Эл. почта",
|
||||
"about.repository": "Репозиторий",
|
||||
"about.source_code": "Исходный код",
|
||||
"dialog.close": "Закрыть",
|
||||
"update.available": "Доступно обновление: v{version}",
|
||||
"update.view_release": "Перейти к релизу"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vinyl Variants · Studio Reference</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
||||
|
||||
<style>
|
||||
/* ───────── Local fonts (re-using main app's woff2 files) ───── */
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: italic;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-italic-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Geist-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/GeistMono-latin.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ───────── Tokens (Studio Reference, dark) ───── */
|
||||
:root {
|
||||
--bg-deep: #0E0D0B;
|
||||
--bg-paper: #18150F;
|
||||
--bg-card: #211E18;
|
||||
--bg-card-2: #26211A;
|
||||
--bg-rule: #2E2820;
|
||||
--ink: #F2EBDC;
|
||||
--ink-soft: #D6CDB9;
|
||||
--ink-mute: #9C937F;
|
||||
--ink-faint: #5C5447;
|
||||
--ink-ghost: #3A3528;
|
||||
--copper: #E08038;
|
||||
--copper-hi: #F4A064;
|
||||
--copper-lo: #B0561F;
|
||||
--copper-glow: rgba(224, 128, 56, 0.35);
|
||||
--rule: rgba(242, 235, 220, 0.08);
|
||||
--rule-strong: rgba(242, 235, 220, 0.18);
|
||||
--serif: 'Fraunces', Georgia, serif;
|
||||
--sans: 'Geist', system-ui, sans-serif;
|
||||
--mono: 'Geist Mono', ui-monospace, monospace;
|
||||
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||
--ease-out: cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { background: var(--bg-deep); }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-deep);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
padding: 56px 36px 80px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Film grain */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.05;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.95 0 0 0 0 0.92 0 0 0 0 0.86 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ───────── Page header (editorial) ───── */
|
||||
header.page-head {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto 48px;
|
||||
text-align: center;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--copper);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.kicker::before, .kicker::after {
|
||||
content: "";
|
||||
height: 1px;
|
||||
width: 40px;
|
||||
background: var(--copper);
|
||||
opacity: 0.6;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: clamp(36px, 5vw, 56px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 14px;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.return-link {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--ink-faint);
|
||||
padding-bottom: 2px;
|
||||
transition: all 200ms var(--ease);
|
||||
}
|
||||
.return-link:hover { color: var(--copper); border-color: var(--copper); }
|
||||
|
||||
/* ───────── Variant grid ───── */
|
||||
.grid {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 56px 40px;
|
||||
}
|
||||
|
||||
article.variant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, var(--bg-card-2) 0%, var(--bg-deep) 80%);
|
||||
border: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.label-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--copper);
|
||||
}
|
||||
.label-name {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
font-variation-settings: 'opsz' 60;
|
||||
flex: 1;
|
||||
}
|
||||
.label-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--rule-strong);
|
||||
}
|
||||
.tag-css { color: var(--jade, #7AB294); border-color: rgba(122, 178, 148, 0.3); }
|
||||
.tag-needs-js { color: var(--copper); border-color: var(--copper-lo); }
|
||||
|
||||
p.descr {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
p.descr strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ───────── Shared vinyl base ───── */
|
||||
.vinyl {
|
||||
position: relative;
|
||||
width: 86%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%,
|
||||
#0a0907 0%, #0a0907 18%,
|
||||
#1a1611 18.3%, #0a0907 18.6%,
|
||||
#14110c 22%, #0a0907 22.3%,
|
||||
#14110c 26%, #0a0907 26.3%,
|
||||
#14110c 30%, #0a0907 30.3%,
|
||||
#14110c 34%, #0a0907 34.3%,
|
||||
#14110c 38%, #0a0907 38.3%,
|
||||
#14110c 42%, #0a0907 42.3%,
|
||||
#14110c 46%, #0a0907 46.3%,
|
||||
#1c1812 47%, #0a0907 100%);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(0, 0, 0, 0.7),
|
||||
0 30px 80px rgba(0, 0, 0, 0.6),
|
||||
0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.vinyl::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 12%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
conic-gradient(from 0deg,
|
||||
rgba(255,255,255,0.04) 0deg,
|
||||
transparent 30deg,
|
||||
rgba(255,255,255,0.06) 90deg,
|
||||
transparent 150deg,
|
||||
rgba(255,255,255,0.03) 210deg,
|
||||
transparent 270deg,
|
||||
rgba(255,255,255,0.05) 330deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vinyl-label {
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 24px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 4px var(--bg-deep),
|
||||
0 0 0 5px var(--copper-lo);
|
||||
background: var(--bg-card);
|
||||
z-index: 1;
|
||||
}
|
||||
.vinyl-label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8%; height: 8%;
|
||||
top: 46%; left: 46%;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-deep);
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
|
||||
z-index: 3;
|
||||
}
|
||||
.vinyl-label img,
|
||||
.vinyl-label svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Album art (shared SVG used by every variant) */
|
||||
.album-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Tonearm (decorative, on every stage so they read as "now playing") */
|
||||
.tonearm {
|
||||
position: absolute;
|
||||
top: -4%;
|
||||
right: -2%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
pointer-events: none;
|
||||
transform-origin: 88% 12%;
|
||||
transform: rotate(0deg);
|
||||
z-index: 5;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
ORIGINAL — current shipping look (control)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v0 .stage { /* nothing extra */ }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 1 — Sleeve frame
|
||||
Vinyl peeks out of a square cardstock sleeve.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v1 .stage {
|
||||
background:
|
||||
radial-gradient(ellipse at center, #1a1611 0%, var(--bg-deep) 80%);
|
||||
}
|
||||
.v1 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v1 .sleeve {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
inset 4px 4px 24px rgba(0,0,0,0.35),
|
||||
-2px 8px 24px rgba(0,0,0,0.5),
|
||||
-4px 16px 40px rgba(0,0,0,0.35);
|
||||
z-index: 3;
|
||||
/* Casually-placed tilt — like a sleeve set down on a console */
|
||||
transform: rotate(-3.2deg);
|
||||
transform-origin: 60% 60%;
|
||||
/* worn-edge cardstock effect */
|
||||
filter: contrast(1.05) brightness(0.97);
|
||||
}
|
||||
.v1 .sleeve::before {
|
||||
/* Cardstock paper grain */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.v1 .sleeve::after {
|
||||
/* Ring-wear: faint circle from the LP rubbing the cardstock */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
box-shadow:
|
||||
inset 0 0 12px rgba(0,0,0,0.18),
|
||||
inset 0 0 0 1px rgba(255,255,255,0.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v1 .sleeve-art {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
z-index: 1;
|
||||
filter: contrast(0.88) saturate(0.6) brightness(0.88);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.v1 .sleeve-art svg { width: 100%; height: 100%; }
|
||||
/* Worn corner notch */
|
||||
.v1 .sleeve-corner {
|
||||
position: absolute;
|
||||
width: 14%;
|
||||
height: 14%;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
background: var(--bg-deep);
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||
opacity: 0.7;
|
||||
z-index: 4;
|
||||
}
|
||||
.v1 .vinyl-wrap {
|
||||
position: absolute;
|
||||
right: -2%;
|
||||
top: 16%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .vinyl-wrap .vinyl {
|
||||
width: 100%;
|
||||
}
|
||||
.v1 .vinyl-label {
|
||||
/* Smaller label since the disc here is showing; album art lives on sleeve */
|
||||
inset: 32%;
|
||||
background: #2E2820;
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(0,0,0,0.4),
|
||||
0 0 0 3px var(--bg-deep),
|
||||
0 0 0 4px var(--copper-lo);
|
||||
}
|
||||
.v1 .vinyl-label::before {
|
||||
/* Plain-color label with faux pressing imprint */
|
||||
content: "REF · 24";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--copper);
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .tonearm {
|
||||
right: -8%;
|
||||
top: 8%;
|
||||
width: 44%;
|
||||
height: 44%;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 2 — Sheen + paper grain + dead-wax + off-center
|
||||
The high-impact variant.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v2 .vinyl-label {
|
||||
/* Slightly off-center spindle for "pressed off-axis" feel */
|
||||
inset: 27% 27% 29% 29%;
|
||||
}
|
||||
.v2 .vinyl-label::after {
|
||||
/* Spindle hole offset 1.5% from true center */
|
||||
top: 47%;
|
||||
left: 47.5%;
|
||||
}
|
||||
/* Paper grain on the label, multiplied so it sits inside the print */
|
||||
.v2 .vinyl-label .label-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.05 0 0 0 0 0.04 0 0 0 0 0.03 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
/* Dead-wax: micro-text engraved between the label and the run-out groove */
|
||||
.v2 .dead-wax {
|
||||
position: absolute;
|
||||
inset: 21%;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
/* Animation OFF the disc — engraving is part of the press, so it does spin with the vinyl */
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
.v2 .dead-wax svg { width: 100%; height: 100%; }
|
||||
/* Reflection sweep — fixed in viewer space, not rotating with the disc */
|
||||
.v2 .sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background:
|
||||
conic-gradient(from 110deg,
|
||||
transparent 0deg,
|
||||
rgba(255, 245, 220, 0) 30deg,
|
||||
rgba(255, 245, 220, 0.07) 60deg,
|
||||
rgba(255, 245, 220, 0.14) 80deg,
|
||||
rgba(255, 245, 220, 0.07) 100deg,
|
||||
transparent 140deg,
|
||||
transparent 280deg,
|
||||
rgba(255, 245, 220, 0.04) 305deg,
|
||||
rgba(255, 245, 220, 0.08) 320deg,
|
||||
rgba(255, 245, 220, 0.04) 335deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 3 — Tone-graded album art (duotone)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v3 .vinyl-label .album-art {
|
||||
filter:
|
||||
saturate(0.35)
|
||||
sepia(0.45)
|
||||
hue-rotate(345deg)
|
||||
brightness(0.85)
|
||||
contrast(1.18);
|
||||
}
|
||||
.v3 .vinyl-label::before {
|
||||
/* Subtle copper duotone overlay tints the highlights */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
rgba(224, 128, 56, 0.18) 0%,
|
||||
rgba(31, 78, 61, 0.10) 50%,
|
||||
rgba(0,0,0,0.18) 100%);
|
||||
mix-blend-mode: overlay;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3 .vinyl-label::after {
|
||||
z-index: 4;
|
||||
}
|
||||
.v3 .vinyl-label .vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 45%,
|
||||
transparent 35%,
|
||||
rgba(0,0,0,0.45) 100%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 4 — Sleeve-to-disc reveal animation
|
||||
(Hover the card to see the disc slide out)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v4 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v4 .sleeve {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
-2px 6px 18px rgba(0,0,0,0.5);
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v4 .sleeve::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.v4 .sleeve-art {
|
||||
width: 100%; height: 100%;
|
||||
filter: contrast(0.92) saturate(0.7) brightness(0.92);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.v4 .vinyl-slot {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 3;
|
||||
transition: transform 1.2s var(--ease-out);
|
||||
}
|
||||
.v4 .vinyl-slot .vinyl {
|
||||
width: 100%;
|
||||
animation-play-state: paused;
|
||||
transition: animation-play-state 0.4s;
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot {
|
||||
transform: translateX(46%);
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot .vinyl {
|
||||
animation-play-state: running;
|
||||
}
|
||||
.v4 .hover-hint {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.v4 .stage:hover .hover-hint { opacity: 0.4; }
|
||||
|
||||
/* Note row at top of every variant */
|
||||
.note {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ───────── Mobile ───── */
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 36px 16px 60px; }
|
||||
.grid { gap: 36px 20px; grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="page-head">
|
||||
<div class="kicker">Studio Reference · Album Art Variants</div>
|
||||
<h1>Vinyl Cover Treatments</h1>
|
||||
<p class="subtitle">Five renderings of the same disc · Hover variant 04 for the sleeve reveal</p>
|
||||
<a class="return-link" href="/">← Return to player</a>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- ═════════ ORIGINAL ═════════ -->
|
||||
<article class="variant v0">
|
||||
<div class="stage">
|
||||
<span class="note">As shipping</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="vigA" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="55%" stop-color="rgba(0,0,0,0)"/>
|
||||
<stop offset="100%" stop-color="rgba(0,0,0,0.55)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgA)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
<rect width="400" height="400" fill="url(#vigA)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad0" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#3a3528"/>
|
||||
<stop offset="0.5" stop-color="#9C937F"/>
|
||||
<stop offset="1" stop-color="#5C5447"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad0)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">00</span>
|
||||
<span class="label-name">Original</span>
|
||||
<span class="label-tag tag-css">control</span>
|
||||
</div>
|
||||
<p class="descr">Current shipping vinyl: pressed grooves, copper-bordered label rim, full album art on the label. Reference baseline for everything below.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 1 — SLEEVE FRAME ═════════ -->
|
||||
<article class="variant v1">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgB" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgB)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sleeve-corner"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<use href="#armGrad0"/>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="80" y2="120" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="72" y="112" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 83 121)"/>
|
||||
<circle cx="78" cy="122" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">01</span>
|
||||
<span class="label-name">Sleeve Frame</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Vinyl peeks out of a square cardstock <strong>sleeve</strong> — paper grain, ring-wear circle, worn-corner notch. The album art lives on the sleeve; the disc gets a plain typographic label. Reads instantly as "record on a turntable", not "spinning disc."</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 2 — SHEEN + GRAIN + DEAD-WAX ═════════ -->
|
||||
<article class="variant v2">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only · highest ROI</span>
|
||||
<div class="vinyl">
|
||||
<div class="dead-wax">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<path id="dwPath" d="M 50,50 m -36,0 a 36,36 0 1,1 72,0 a 36,36 0 1,1 -72,0"/>
|
||||
</defs>
|
||||
<text font-family="monospace" font-size="2.4" fill="#3a3528" letter-spacing="0.45" opacity="0.85">
|
||||
<textPath href="#dwPath">· STUDIO REFERENCE PRESSING · A-SIDE · MASTER LACQUER 24-S · DOLG.AD MASTERED · ½ SPEED</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgC)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="label-grain"></div>
|
||||
</div>
|
||||
<div class="sheen"></div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">02</span>
|
||||
<span class="label-name">Sheen, Grain & Dead-Wax</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Three layers added to the existing vinyl: a <strong>fixed reflection sweep</strong> (doesn't rotate with the disc — the studio-light look), <strong>paper grain</strong> on the label so the print sits in cardstock, and a <strong>dead-wax engraving</strong> of the master‑lacquer code spinning with the disc. Off-center spindle by 1.5%. Highest visual ROI for the smallest amount of new code.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 3 — TONE-GRADED ═════════ -->
|
||||
<article class="variant v3">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgD)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">03</span>
|
||||
<span class="label-name">Tone-Graded Cover</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Same disc, but the album art on the label is <strong>color-graded</strong> — duotone copper/emerald, deeper saturation drop, vignette around the label rim. Effect: every album cover ends up looking like it came from the same pressing plant, matching the Studio Reference chrome.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 4 — SLEEVE-TO-DISC REVEAL ═════════ -->
|
||||
<article class="variant v4">
|
||||
<div class="stage">
|
||||
<span class="note">CSS hover · JS in production</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgE" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgE)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vinyl-slot">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hover-hint">Hover to play</span>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">04</span>
|
||||
<span class="label-name">Sleeve-to-Disc Reveal</span>
|
||||
<span class="label-tag tag-needs-js">needs JS</span>
|
||||
</div>
|
||||
<p class="descr"><strong>Hover this card</strong> — the disc slides out of the sleeve and starts spinning. In production, this would be wired to the play/pause state: paused = tucked-in sleeve view, playing = disc revealed and spinning. Most evocative, also the most code (animation choreography + state coupling).</p>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"description": "Frontend build tooling for media server WebUI",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
Reference in New Issue
Block a user