Compare commits

..

8 Commits

Author SHA1 Message Date
alexei.dolgolyov 261a14c575 chore: release v0.2.2
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m22s
2026-05-01 17:15:24 +03:00
alexei.dolgolyov e7372b0ccb chore: wire up code-review-graph MCP server
Lint & Test / test (push) Successful in 11s
- Add .mcp.json registering code-review-graph (uvx, stdio)
- Document the MCP tools in CLAUDE.md so the assistant prefers
  graph queries over Grep/Glob/Read for structural exploration
- Ignore .code-review-graph/ index directory
2026-05-01 11:28:22 +03:00
alexei.dolgolyov ec5178142e ui(player): replace footer with About dialog + reclaim dead space
- Move colophon (credit/email/source link) from sticky footer into
  a dedicated About dialog, opened from a new header button
- Drop ~64px of bottom container padding now that the footer is gone
- Loosen vinyl-stage aspect-ratio (1:1 -> 1:0.85) so the disc no
  longer leaves a tall empty band below the sleeve
- Switch tonearm height: 36% to aspect-ratio: 1 to keep proportions
  consistent across the new stage ratio
- Add about.* / dialog.close i18n keys for EN and RU
- Add vinyl-variants-mockup.html as next design reference target
2026-05-01 11:28:10 +03:00
alexei.dolgolyov 46af2bb8cc chore: release v0.2.1
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 49s
2026-04-25 20:23:01 +03:00
alexei.dolgolyov 25a492d5dd ui(player): meaningful caps for tablet/small-desktop range + tighter footer
Lint & Test / test (push) Successful in 17s
The 720–1240 px viewport range was a "strange zone": below 1240 the layout
is single-column, but above 720 none of the Pocket Edition rules fire, so
the vinyl stage stretched to full content width (~1100 px) and the masthead
ran to a 1000 px+ measure on a small-desktop window.

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

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

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

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

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

Other player polish in this changeset:
- track-masthead: padding-right clamp(12px, 1.5vw, 24px) so VU meter,
  spectrum tail, end timecode and controls sit inset from the panel edge
  (zeroed on the single-column mobile breakpoint to keep symmetry)
- VU meter 140→120 px, volume slider 80→64 px to free up row width so
  the cluster stays inline with prev/play/next instead of wrapping
- light-theme VU meter override: cream gauge face, dark-ink scale ticks,
  hunter-emerald needle (replaces the hardcoded black gauge)
- fullscreen meta-cell labels: var(--ink-faint) → var(--copper) so STATE
  and SOURCE read as part of the same editorial system as the kicker
2026-04-25 18:19:19 +03:00
alexei.dolgolyov 51ec1503f4 perf(visualizer): cut spectrum + track-switch CPU significantly
Lint & Test / test (push) Successful in 10s
Frontend hot path (player.js, background.js):
- visualizer rAF: drop per-frame getComputedStyle('--accent') (cached on
  applyAccentColor), build canvas LinearGradient once per accent change
  instead of 32× per frame, batch all bars into a single beginPath/fill
- FPS-gate canvas redraw via frequencyDataVersion so 60-144 Hz monitors
  stop re-rendering identical frames produced at 30 Hz on the backend
- editorial spectrum bars: replace style.height (layout) with
  transform: scaleY (compositor-only); cache bar refs, pre-compute
  per-bar gain/range, dedup writes at 1/1000 quantization
- coalesce VU needle into the visualizer rAF; cache vuNeedle ref;
  dedup angle writes at 0.1°
- updateUI: status-payload fingerprint short-circuits the redundant
  status_update broadcasts that fire during a track change
- swapArtworkSrc: only force layout reflow when keyframe is in flight;
  drop the ?_=Date.now() cache-buster so identical artwork URLs reuse
  the decoded bitmap; mini/glow imgs only re-set src when changed
- drop the fullscreen MutationObserver — fs-bloom-art is mirrored
  directly from the artwork-swap path, eliminating the second blur paint
- updateProgress: skip text writes when the rounded second hasn't moved;
  POSITION_INTERPOLATION_MS 100 → 250
- background.js: lift resizeBackgroundCanvas out of the rAF body, cache
  step, accept new int-scaled wire format

CSS:
- spectrum bars use transform: scaleY(var(--bar-h-scale)) + transition
  on transform; will-change updated to transform
- album-art-glow and fs-bloom-art switched to small-source-blur trick
  (render at 20-25% size, scale 4-6×, lower blur radius) — visually
  equivalent, ~10-25× cheaper repaint on track change
- drop unused transition: filter on .vinyl-stage #album-art

Backend (audio_analyzer.py, websocket_manager.py):
- pre-allocate windowed and cumsum buffers; replace
  np.concatenate(([0.0], np.cumsum(...))) with cumsum[0]=0 +
  np.cumsum(out=cumsum[1:]); float32 hanning window
- RMS via np.dot(mono, mono) — no astype copy, no ** temp
- int16 wire format (scale=1000) — smaller JSON, no Python float boxing
- versioned data + threading.Event so _audio_broadcast_loop is event-
  driven (ev.wait + monotonic seq dedup) instead of polling on a timer
  with the always-false `data is _last_data` identity check

ruff clean, pytest 7 passed / 3 numpy-skipped, esbuild bundle 113.6 kB.
2026-04-25 18:05:57 +03:00
alexei.dolgolyov 08c3c80df4 ci: skip test workflow on release commits
Lint & Test / test (push) Successful in 46s
2026-04-25 15:36:18 +03:00
19 changed files with 1730 additions and 377 deletions
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
test:
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+2
View File
@@ -53,3 +53,5 @@ Thumbs.db
# Node.js / esbuild
node_modules/
media_server/static/dist/
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+39
View File
@@ -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
View File
@@ -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 090° range ([9b84fdd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9b84fdd))
- VU: clip grid arc to match needle swing range so rest = proper zero ([3de2b44](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3de2b44))
- Centred toolbar icons; full-width spectrum; needle at rest; drop dynamic bg ([d7f488a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7f488a))
- Real audio level on VU; full-width spectrum; hide canvas under vinyl ([968eb15](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/968eb15))
- Visualizer: full-width spectrum + device pick auto-starts capture ([a0f74df](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0f74df))
- Visualizer: auto-enable actually starts capture; persist audio device ([6066b4a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6066b4a))
- Full-width spectrum + log-mapped bars; deeper sepia + soft art fade ([336d596](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/336d596))
- Editorial toolbar + sepia album art ([d157388](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d157388))
- Close more gaps with mockup (tabs, mini player, volume control) ([e9e4165](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e9e4165))
- Snap player view directly from Studio Reference mockup ([77b39e5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/77b39e5))
- Drop redundant Elapsed/Length cells; restore timeline ([d9d4672](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d9d4672))
- Close gaps with Studio Reference mockup ([265b001](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/265b001))
- Player redesign cleanup pass — sleeve, tonearm, AGC, dead code ([2a474ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2a474ea))
### 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>
+70 -19
View File
@@ -1,6 +1,7 @@
"""Audio spectrum analyzer service using system loopback capture."""
import logging
import math
import platform
import threading
import time
@@ -71,6 +72,14 @@ class AudioAnalyzer:
self._lifecycle_lock = threading.Lock()
self._data: dict | None = None
self._current_device_name: str | None = None
# Generation counter — bumped each time _data is refreshed.
# Lets the broadcast loop dedupe without comparing dict identity
# (which is fragile because we always allocate a new dict).
self._data_seq = 0
# Threading.Event signaled when new frame data is available.
# The broadcast loop awaits this instead of polling on a timer,
# so it wakes up exactly once per produced frame.
self._data_event = threading.Event()
# Slow AGC envelope so the spectrum reflects real dynamics
# instead of being renormalized to peak=1.0 every frame.
# A loud transient (e.g. notification beep) lifts the reference
@@ -128,17 +137,30 @@ class AudioAnalyzer:
"""Stop audio capture and cleanup."""
with self._lifecycle_lock:
self._running = False
# Wake any waiter so it can observe _running and exit cleanly.
self._data_event.set()
if self._thread:
self._thread.join(timeout=3.0)
self._thread = None
with self._lock:
self._data = None
self._data_event.clear()
def get_frequency_data(self) -> dict | None:
"""Return latest frequency data (thread-safe). None if not running."""
with self._lock:
return self._data
def get_frequency_data_versioned(self) -> tuple[dict | None, int]:
"""Return (data, seq) so callers can dedupe without identity tricks."""
with self._lock:
return self._data, self._data_seq
@property
def data_event(self) -> threading.Event:
"""Event signaled when a fresh frame is ready. Caller must clear()."""
return self._data_event
@staticmethod
def list_loopback_devices() -> list[dict[str, str]]:
"""List all available loopback audio devices."""
@@ -250,12 +272,24 @@ class AudioAnalyzer:
return
interval = 1.0 / self.target_fps
window = np.hanning(self.chunk_size)
# Float32 window — matches soundcard's typical buffer dtype and
# halves FFT memory traffic vs. the default float64.
window = np.hanning(self.chunk_size).astype(np.float32)
# Pre-compute bin edge pairs for vectorized grouping
edges = self._bin_edges
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
# Counts are constant — compute once.
bin_counts = (bin_ends - bin_starts).astype(np.float32)
# Pre-allocate working buffers so the per-frame allocator churn
# on the capture thread (which runs at target_fps Hz, hours on
# end) drops to zero copies for these arrays.
fft_size = self.chunk_size // 2 + 1
windowed = np.empty(self.chunk_size, dtype=np.float32)
cumsum = np.empty(fft_size + 1, dtype=np.float32)
cumsum[0] = 0.0
try:
with device.recorder(
@@ -284,21 +318,23 @@ class AudioAnalyzer:
time.sleep(interval)
continue
# Apply window and compute FFT
windowed = mono[:self.chunk_size] * window
# Apply window in-place into the pre-allocated buffer.
np.multiply(mono[:self.chunk_size], window, out=windowed)
fft_mag = np.abs(np.fft.rfft(windowed))
# Group into logarithmic bins (vectorized via cumsum)
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
counts = bin_ends - bin_starts
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
# Group into logarithmic bins (vectorized via cumsum).
# Write into the pre-allocated [1:] slice so cumsum[0]
# stays 0.0 and we never allocate a new array.
np.cumsum(fft_mag, out=cumsum[1:])
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts
# True loudness from time-domain RMS, mapped via dB
# so the VU needle reflects actual program level — not
# the per-frame-normalized spectrum.
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
if rms > 1e-6:
db = 20.0 * np.log10(rms)
# True loudness from time-domain RMS via single BLAS
# dot — avoids astype() and ** allocations.
mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
energy = float(np.dot(mono32, mono32))
if energy > 1e-12:
rms = (energy / mono32.size) ** 0.5
db = 20.0 * math.log10(rms)
# Map -60 dB..-6 dB to 0..1 (typical music range)
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
else:
@@ -314,18 +350,33 @@ class AudioAnalyzer:
else:
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
ref = max(self._spectrum_ref, 1e-4)
bins = np.clip(bins / ref, 0.0, 1.5)
np.divide(bins, ref, out=bins)
np.clip(bins, 0.0, 1.5, out=bins)
# Bass energy: average of first 4 bins (~20-200Hz)
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
# Round for compact JSON
frequencies = np.round(bins, 3).tolist()
bass = round(bass, 3)
level = round(level, 3)
# Quantize to 0..1000 ints — same wire fidelity as
# 3-decimal floats but smaller GC churn on both ends
# (frontend smooths anyway, so quantization is
# invisible). JSON encodes ints faster than floats.
frequencies = (bins * 1000.0).astype(np.int16).tolist()
bass_i = int(bass * 1000.0)
level_i = int(level * 1000.0)
new_data = {
"frequencies": frequencies,
"bass": bass_i,
"level": level_i,
# Wire-format flag: clients that see this know
# values are 0..1000 ints, not 0..1 floats.
"scale": 1000,
}
with self._lock:
self._data = {"frequencies": frequencies, "bass": bass, "level": level}
self._data = new_data
self._data_seq += 1
# Wake any broadcast loop waiting on fresh data.
self._data_event.set()
# Throttle to target FPS
elapsed = time.monotonic() - t0
+33 -13
View File
@@ -161,26 +161,48 @@ class ConnectionManager:
self._audio_task = None
async def _audio_broadcast_loop(self) -> None:
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
from ..config import settings
interval = 1.0 / settings.visualizer_fps
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
_last_data = None
Event-driven: blocks on the analyzer's data_event so it wakes up
exactly once per produced frame, instead of polling on a timer.
Backstop sleep applies when capture is idle / has no subscribers.
"""
from ..config import settings
idle_interval = 1.0 / max(1, settings.visualizer_fps)
# Bounded wait so we still notice subscribe/unsubscribe transitions.
wake_timeout = max(0.05, idle_interval)
loop = asyncio.get_event_loop()
last_seq = -1
while True:
try:
async with self._lock:
subscribers = list(self._visualizer_subscribers)
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
await asyncio.sleep(interval)
analyzer = self._audio_analyzer
if not subscribers or not analyzer or not analyzer.running:
await asyncio.sleep(idle_interval)
continue
data = self._audio_analyzer.get_frequency_data()
if data is None or data is _last_data:
await asyncio.sleep(interval)
# Wait off-loop for a fresh frame. The capture thread sets
# data_event after each FFT update; we clear it before the
# next wait so we never burn a wake on stale data.
ev = analyzer.data_event
def _wait() -> bool:
return ev.wait(wake_timeout)
got = await loop.run_in_executor(None, _wait)
if not got:
# Timeout — loop around to re-check subscriber state.
continue
_last_data = data
ev.clear()
data, seq = analyzer.get_frequency_data_versioned()
if data is None or seq == last_seq:
continue
last_seq = seq
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
@@ -198,13 +220,11 @@ class ConnectionManager:
for ws in failed:
await self.disconnect(ws)
await asyncio.sleep(interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Error in audio broadcast: %s", e)
await asyncio.sleep(interval)
await asyncio.sleep(idle_interval)
def status_changed(
self, old: dict[str, Any] | None, new: dict[str, Any]
+191 -123
View File
@@ -113,7 +113,8 @@
--copper: #E08038;
--copper-hi: #F4A064;
--copper-lo: #B0561F;
--copper-glow: rgba(224, 128, 56, 0.35);
--copper-rgb: 224, 128, 56;
--copper-glow: rgba(var(--copper-rgb), 0.35);
--amber: #F5C26B;
--jade: #7AB294;
@@ -167,7 +168,8 @@
--copper: #1F4E3D; /* hunter emerald in light mode */
--copper-hi: #2D6A53;
--copper-lo: #143E2F;
--copper-glow: rgba(31, 78, 61, 0.18);
--copper-rgb: 31, 78, 61;
--copper-glow: rgba(var(--copper-rgb), 0.18);
--amber: #C29D31;
--jade: #4D8C6F;
@@ -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 7211240 zone the vinyl can be larger than on phone but
smaller than the full-column 800px+ default. */
.album-art-container.vinyl-stage {
max-width: 480px;
}
.now-playing .track-masthead {
max-width: 640px;
margin-left: auto;
margin-right: auto;
padding-right: 0;
text-align: left;
}
}
/* ─── Vinyl stage ──────────────────────────────────────────── */
/* 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);
+27 -9
View File
@@ -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>
+17 -2
View File
@@ -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]');
+34 -8
View File
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
// ---- Render loop ----
// Cached step into the bins array; recomputed only when bins.length
// changes (which happens at most once after the first audio frame
// arrives or when num_bins is reconfigured).
let bgBinsLength = -1;
let bgBinsStep = 1;
// Last applied resolution — drawing with stale viewport is harmless,
// but we still need to refresh the uniform after the resize listener
// has updated the canvas.
let bgLastResW = -1;
let bgLastResH = -1;
function renderBackgroundFrame() {
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
const gl = bgGL;
if (!gl || !bgUniforms) return;
resizeBackgroundCanvas();
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
// Resize listener already keeps canvas dimensions in sync — only
// touch the viewport when the canvas actually changed size, so the
// per-frame path doesn't read window.innerWidth (a layout-flushing
// property).
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
bgLastResW = bgCanvas.width;
bgLastResH = bgCanvas.height;
gl.viewport(0, 0, bgLastResW, bgLastResH);
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
}
const time = performance.now() / 1000 - bgStartTime;
// Smooth audio data from the imported frequencyData (shared with visualizer)
// Smooth audio data from the imported frequencyData (shared with visualizer).
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
if (frequencyData && frequencyData.frequencies) {
const bins = frequencyData.frequencies;
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
const scale = frequencyData.scale && frequencyData.scale > 0
? 1.0 / frequencyData.scale : 1.0;
if (bins.length !== bgBinsLength) {
bgBinsLength = bins.length;
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
}
const step = bgBinsStep;
for (let i = 0; i < BG_BAND_COUNT; i++) {
const idx = Math.min(i * step, bins.length - 1);
const target = bins[idx] || 0;
let idx = i * step;
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
const target = (bins[idx] || 0) * scale;
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
}
const targetBass = frequencyData.bass || 0;
const targetBass = (frequencyData.bass || 0) * scale;
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
} else {
// Gentle decay when no audio
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
}
// Set uniforms (locations cached at init, colors cached on change)
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
gl.uniform1f(bgUniforms.time, time);
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
+14 -1
View File
@@ -140,7 +140,10 @@ export function cacheDom() {
// Timing constants
export const VOLUME_THROTTLE_MS = 16;
export const POSITION_INTERPOLATION_MS = 100;
// 250ms is plenty for sub-second progress; the inline updateProgress
// also short-circuits when the rounded second hasn't moved, so there's
// no visible difference for the user.
export const POSITION_INTERPOLATION_MS = 250;
export const SEARCH_DEBOUNCE_MS = 200;
export const TOAST_DURATION_MS = 3000;
export const WS_BACKOFF_BASE_MS = 3000;
@@ -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
View File
@@ -145,6 +145,22 @@ export function lightenColor(hex, percent) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function darkenColor(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, (num >> 16) - Math.round(255 * percent / 100));
const g = Math.max(0, ((num >> 8) & 0xff) - Math.round(255 * percent / 100));
const b = Math.max(0, (num & 0xff) - Math.round(255 * percent / 100));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function hexToRgbTriple(hex) {
const num = parseInt(hex.replace('#', ''), 16);
const r = (num >> 16) & 0xff;
const g = (num >> 8) & 0xff;
const b = num & 0xff;
return `${r}, ${g}, ${b}`;
}
export function initAccentColor() {
const saved = localStorage.getItem('accentColor');
if (saved) {
@@ -159,12 +175,25 @@ export function initAccentColor() {
}
export function applyAccentColor(color, hover) {
document.documentElement.style.setProperty('--accent', color);
document.documentElement.style.setProperty('--accent-hover', hover);
const root = document.documentElement.style;
root.setProperty('--accent', color);
root.setProperty('--accent-hover', hover);
// Editorial palette tokens — the redesign reads these directly,
// so the picker must drive them too (the --accent alias alone has
// no effect once components moved off it).
root.setProperty('--copper', color);
root.setProperty('--copper-hi', hover);
root.setProperty('--copper-lo', darkenColor(color, 12));
root.setProperty('--copper-rgb', hexToRgbTriple(color));
// --copper-glow inherits the rgba(var(--copper-rgb), 0.35) formula
// declared in styles.css, so it picks up the new RGB automatically.
localStorage.setItem('accentColor', color);
const dot = document.getElementById('accentDot');
if (dot) dot.style.background = color;
updateBackgroundColors();
// Refresh the cached accent in the visualizer so the gradient
// rebuilds on its next frame instead of querying CSS every frame.
refreshVisualizerAccent();
}
export function renderAccentSwatches() {
@@ -215,12 +244,49 @@ export function setVisualizerEnabled(value) {
visualizerEnabled = !!value;
localStorage.setItem('visualizerEnabled', visualizerEnabled);
}
let visualizerCanvas = null; // Cached canvas DOM ref
let visualizerCtx = null;
let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize)
let visualizerAnimFrame = null;
export let frequencyData = null;
export function setFrequencyData(value) { frequencyData = value; }
export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled)
let frequencyDataVersion = 0; // Bumped on every setFrequencyData
let lastRenderedVersion = -1; // Last version rendered in renderVisualizerFrame
let frequenciesScale = 1.0; // Backend scale factor (1000 → ints, 1 → floats)
export function setFrequencyData(value) {
frequencyData = value;
frequencyDataVersion++;
// Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale).
if (value && typeof value.scale === 'number' && value.scale > 0) {
frequenciesScale = 1.0 / value.scale;
} else {
frequenciesScale = 1.0;
}
}
let smoothedFrequencies = null;
const VISUALIZER_SMOOTHING = 0.15;
// Cached accent — refreshed by applyAccentColor() rather than on every frame.
let cachedAccentHex = '#1db954';
let cachedAccentRGB = '29,185,84';
function parseAccentHex(hex) {
const h = (hex || '').trim().replace('#', '');
if (h.length < 6) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return `${r},${g},${b}`;
}
export function refreshVisualizerAccent() {
const accentHex = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim();
if (accentHex) {
cachedAccentHex = accentHex;
const rgb = parseAccentHex(accentHex);
if (rgb) cachedAccentRGB = rgb;
}
// Force gradient rebuild on next frame.
visualizerGradient = null;
}
export async function checkVisualizerAvailability() {
try {
@@ -274,15 +340,28 @@ export function applyVisualizerMode() {
}
function initVisualizerCanvas() {
const canvas = document.getElementById('spectrogram-canvas');
if (!canvas) return;
visualizerCtx = canvas.getContext('2d');
canvas.width = 300;
canvas.height = 64;
visualizerCanvas = document.getElementById('spectrogram-canvas');
if (!visualizerCanvas) return;
visualizerCtx = visualizerCanvas.getContext('2d');
visualizerCanvas.width = 300;
visualizerCanvas.height = 64;
visualizerGradient = null; // Force rebuild
refreshVisualizerAccent();
}
function buildVisualizerGradient() {
if (!visualizerCtx || !visualizerCanvas) return null;
const h = visualizerCanvas.height;
const grad = visualizerCtx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`);
grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`);
return grad;
}
function startVisualizerRender() {
if (visualizerAnimFrame) return;
// Cache editorial spectrum bar refs once per start.
cacheEditorialSpectrumBars();
renderVisualizerFrame();
}
@@ -291,62 +370,70 @@ export function stopVisualizerRender() {
cancelAnimationFrame(visualizerAnimFrame);
visualizerAnimFrame = null;
}
const canvas = document.getElementById('spectrogram-canvas');
if (visualizerCtx && canvas) {
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
if (visualizerCtx && visualizerCanvas) {
visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
}
frequencyData = null;
frequencyDataVersion++; // Force next render to redraw cleared state
lastRenderedVersion = -1;
smoothedFrequencies = null;
document.body.classList.remove('audio-spectrum-live');
// Reset spectrum bar heights so the synthetic CSS animation takes back over
document.querySelectorAll('.now-playing .spectrum > span').forEach(s => {
s.style.height = '';
});
// Reset spectrum bar transforms so the synthetic CSS animation takes back over.
if (editorialSpectrumBars) {
for (let i = 0; i < editorialSpectrumBars.length; i++) {
editorialSpectrumBars[i].style.transform = '';
}
}
// Drop cached bars so next start re-queries.
editorialSpectrumBars = null;
editorialSpectrumLastScale = null;
}
function renderVisualizerFrame() {
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
const canvas = document.getElementById('spectrogram-canvas');
if (!frequencyData || !visualizerCtx || !canvas) return;
// VU needle + position progress always tick — they read live state
// not bound to spectrum payloads. Keeping them in this single rAF
// is cheaper than running a second rAF loop just for the needle.
tickVuNeedle();
if (!frequencyData || !visualizerCtx || !visualizerCanvas) return;
// FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes
// at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so
// bail when no new payload has arrived since the last draw.
if (frequencyDataVersion === lastRenderedVersion) return;
lastRenderedVersion = frequencyDataVersion;
const bins = frequencyData.frequencies;
const numBins = bins.length;
const w = canvas.width;
const h = canvas.height;
const w = visualizerCanvas.width;
const h = visualizerCanvas.height;
const gap = 2;
const barWidth = (w / numBins) - gap;
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim();
const scale = frequenciesScale;
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
smoothedFrequencies = new Array(numBins).fill(0);
smoothedFrequencies = new Float32Array(numBins);
}
for (let i = 0; i < numBins; i++) {
const v = bins[i] * scale;
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
+ v * (1 - VISUALIZER_SMOOTHING);
}
visualizerCtx.clearRect(0, 0, w, h);
if (!visualizerGradient) visualizerGradient = buildVisualizerGradient();
visualizerCtx.clearRect(0, 0, w, h);
visualizerCtx.fillStyle = visualizerGradient;
visualizerCtx.beginPath();
for (let i = 0; i < numBins; i++) {
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
const x = i * (barWidth + gap) + gap / 2;
const y = h - barHeight;
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
grad.addColorStop(0, accent);
grad.addColorStop(1, accent + '30');
visualizerCtx.fillStyle = grad;
visualizerCtx.beginPath();
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
visualizerCtx.fill();
}
// Bass-driven album-art scale + glow pulse removed — the
// "burst" looked unnatural on the sleeve. Spectrum bars +
// VU needle remain the audio-reactive elements.
visualizerCtx.fill();
// Drive the editorial .spectrum bars from the same frequency data.
updateEditorialSpectrum(smoothedFrequencies, numBins);
@@ -357,36 +444,79 @@ function renderVisualizerFrame() {
// dominate); a linear mapping leaves the right half of the spectrum
// looking dead. Use a logarithmic frequency-to-bar mapping plus a
// per-bar high-end gain so all bars carry visible motion.
function updateEditorialSpectrum(bins, numBins) {
const root = document.querySelector('.now-playing .spectrum');
if (!root) return;
const bars = root.children;
const barCount = bars.length;
if (!barCount) return;
document.body.classList.add('audio-spectrum-live');
let editorialSpectrumBars = null; // Live HTMLCollection cached at start
let editorialSpectrumBarCount = 0;
let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded)
let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar
let editorialBarGains = null; // Pre-computed per-bar gain
let editorialBarRangesForBins = -1; // numBins last used to compute ranges
// Skip the very lowest bin (DC + sub-rumble) which often dominates.
function cacheEditorialSpectrumBars() {
const root = document.querySelector('.now-playing .spectrum');
if (!root) {
editorialSpectrumBars = null;
editorialSpectrumBarCount = 0;
return;
}
editorialSpectrumBars = root.children;
editorialSpectrumBarCount = editorialSpectrumBars.length;
editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount);
editorialSpectrumLastScale.fill(-1);
// Pre-compute per-bar gain (constant for the lifetime of the bar list).
editorialBarGains = new Float32Array(editorialSpectrumBarCount);
for (let i = 0; i < editorialSpectrumBarCount; i++) {
editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8;
}
editorialBarRangesForBins = -1; // Force range recompute on next call
}
function recomputeEditorialBarRanges(numBins) {
const barCount = editorialSpectrumBarCount;
editorialBarRanges = new Int16Array(barCount * 2);
const lowBin = 1;
const highBin = numBins - 1;
const span = highBin - lowBin;
for (let i = 0; i < barCount; i++) {
// Logarithmic mapping: equal-area slices of the audible spectrum
// map to equal numbers of bars. Each bar covers a wider bin range
// toward the highs so they get amplified naturally.
const t0 = i / barCount;
const t1 = (i + 1) / barCount;
const startIdx = Math.max(lowBin, Math.floor(lowBin + Math.pow(t0, 2.0) * (highBin - lowBin)));
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + Math.pow(t1, 2.0) * (highBin - lowBin)));
const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span));
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span));
editorialBarRanges[i * 2] = startIdx;
editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins);
}
editorialBarRangesForBins = numBins;
}
function updateEditorialSpectrum(bins, numBins) {
if (!editorialSpectrumBars) cacheEditorialSpectrumBars();
const barCount = editorialSpectrumBarCount;
if (!barCount) return;
if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins);
document.body.classList.add('audio-spectrum-live');
const ranges = editorialBarRanges;
const gains = editorialBarGains;
const lastScale = editorialSpectrumLastScale;
const bars = editorialSpectrumBars;
for (let i = 0; i < barCount; i++) {
const startIdx = ranges[i * 2];
const endIdx = ranges[i * 2 + 1];
let peak = 0;
for (let j = startIdx; j < endIdx && j < numBins; j++) {
if (bins[j] > peak) peak = bins[j];
for (let j = startIdx; j < endIdx; j++) {
const v = bins[j];
if (v > peak) peak = v;
}
// Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest.
// Backend now ships AGC-normalized bins (peak ~1, transients up to 1.5)
// so the master multiplier stays modest to avoid perma-clipping.
const gain = 1 + (i / barCount) * 0.8;
// Floor at 12% so silent bars are still visually present.
const pct = Math.max(12, Math.min(100, peak * 65 * gain));
bars[i].style.height = pct + '%';
// Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5).
// Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible.
const raw = peak * 0.65 * gains[i];
const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw);
// Quantize to 1/1000 — anything finer is invisible. Skip the DOM
// write when the bar hasn't moved.
const q = (scaleY * 1000) | 0;
if (q === lastScale[i]) continue;
lastScale[i] = q;
// transform: scaleY runs on the compositor — no layout/paint.
bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`;
}
}
@@ -616,18 +746,46 @@ export function setupProgressDrag(bar, fill) {
// Replace the album-art src and replay the .is-swapping CSS animation
// so the new artwork crossfades in instead of popping. Re-toggling the
// class across rAF restarts the keyframes even if it was already on.
//
// `forceAnim=false` skips the keyframe-restart reflow when the element
// has never run the swap animation before — saves a synchronous layout
// flush on first paint. The reflow IS still required when the class
// is currently applied; otherwise the browser coalesces add+remove and
// the keyframes don't replay.
function swapArtworkSrc(imgEl, newSrc) {
if (!imgEl) return;
if (imgEl.src === newSrc) return;
imgEl.classList.remove('is-swapping');
void imgEl.offsetWidth;
const wasSwapping = imgEl.classList.contains('is-swapping');
if (wasSwapping) {
imgEl.classList.remove('is-swapping');
// Forced reflow restarts the keyframes — only needed when we have
// to interrupt an in-flight animation.
void imgEl.offsetWidth;
}
imgEl.src = newSrc;
imgEl.classList.add('is-swapping');
}
// Hash of the last fully-rendered status payload — lets us skip
// updateUI altogether when the backend re-broadcasts the same state.
let lastStatusFingerprint = null;
function statusFingerprint(s) {
return [
s.state, s.title, s.artist, s.album, s.volume, s.muted,
s.duration, s.source, s.album_art_url, s.position
].join('|');
}
export function updateUI(status) {
setLastStatus(status);
// Idempotence: if nothing meaningful changed, skip the entire DOM
// pass. Track switches arrive as 1-3 status_update broadcasts in
// quick succession; this gates the redundant ones.
const fingerprint = statusFingerprint(status);
if (fingerprint === lastStatusFingerprint) return;
lastStatusFingerprint = fingerprint;
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
dom.trackTitle.textContent = status.title || fallbackTitle;
dom.artist.textContent = status.artist || '';
@@ -654,7 +812,10 @@ export function updateUI(status) {
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
if (artworkSource) {
fetch(`/api/media/artwork?_=${Date.now()}`, {
// No cache-buster: when album_art_url is unchanged the
// browser can reuse the decoded bitmap. The artworkKey gate
// already skips fetches when the user hasn't switched tracks.
fetch('/api/media/artwork', {
headers: getAuthHeaders()
})
.then(r => r.ok ? r.blob() : null)
@@ -664,8 +825,11 @@ export function updateUI(status) {
const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url;
swapArtworkSrc(dom.albumArt, url);
dom.miniAlbumArt.src = url;
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
// Mirror to fullscreen bloom directly — drops the
// MutationObserver fan-out path.
syncFullscreenBloomArt(url);
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
})
.catch(err => console.error('Artwork fetch failed:', err));
@@ -675,17 +839,22 @@ export function updateUI(status) {
currentArtworkBlobUrl = null;
}
swapArtworkSrc(dom.albumArt, placeholderArt);
dom.miniAlbumArt.src = placeholderArt;
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt;
if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow;
syncFullscreenBloomArt(placeholderGlow);
}
}
if (status.duration && status.position !== null) {
// Only redo the progress DOM when position actually changed.
const positionChanged =
status.duration !== currentDuration ||
Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05;
setCurrentDuration(status.duration);
setCurrentPosition(status.position);
lastPositionUpdate = Date.now();
lastPositionValue = status.position;
updateProgress(status.position, status.duration);
if (positionChanged) updateProgress(status.position, status.duration);
}
if (!isUserAdjustingVolume) {
@@ -730,17 +899,24 @@ export function updateUI(status) {
// FFT data the visualizer feeds in). When audio capture isn't
// running, fall back to a synthetic wobble bounded by the volume
// slider position so the needle still looks alive.
let vuWobbleHandle = null;
//
// One unified rAF drives both the spectrum and the VU needle (see
// renderVisualizerFrame → tickVuNeedle). If the visualizer isn't
// rendering, a separate rAF takes over solely for the needle.
let vuStandaloneHandle = null;
let vuWobbleStart = 0;
let vuLevelSmoothed = 0;
let vuNeedleEl = null; // Cached needle element
let vuVolumeSliderEl = null; // Cached slider element
let vuLastAppliedDeg = -999; // Skip DOM writes when angle unchanged
const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
function readAudioLevel() {
if (!frequencyData) return null;
// Backend sends a true loudness signal (RMS-derived dB, 0..1).
// The bins are renormalized per frame so peak-of-bins is useless for level.
if (typeof frequencyData.level === 'number') return frequencyData.level;
// Backend sends a true loudness signal (RMS-derived dB, 0..1)
// either as float (legacy) or scaled int (new format).
if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale;
if (!frequencyData.frequencies) return null;
const bins = frequencyData.frequencies;
if (!bins.length) return null;
@@ -748,52 +924,62 @@ function readAudioLevel() {
for (let i = 1; i < bins.length; i++) {
if (bins[i] > peak) peak = bins[i];
}
return Math.min(1, peak * 1.4);
return Math.min(1, peak * frequenciesScale * 1.4);
}
function tickVuNeedle() {
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
if (!vuNeedleEl) return;
const audioLevel = readAudioLevel();
let target;
if (audioLevel != null) {
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
target = -22 + vuLevelSmoothed * 44;
} else {
if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider');
const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0;
const base = -22 + (vol / 100) * 44;
const mag = Math.max(2, Math.min(14, vol * 0.16));
const t = (performance.now() - vuWobbleStart) / 1000;
target = base
+ Math.sin(t * 6.3) * mag * 0.55
+ Math.sin(t * 11.7 + 1.3) * mag * 0.30
+ (Math.random() - 0.5) * mag * 0.30;
}
// Quantize to 0.1° — finer is invisible. Skip when unchanged.
const q = Math.round(target * 10) / 10;
if (q === vuLastAppliedDeg) return;
vuLastAppliedDeg = q;
vuNeedleEl.style.transform = `rotate(${q}deg)`;
}
function startVuWobble() {
if (vuWobbleHandle) return;
vuWobbleStart = performance.now();
const tick = () => {
const needle = document.getElementById('vuNeedle');
if (needle) {
// Loopback capture is post-volume on Windows/macOS, so the
// measured level already reflects the output knob — no extra
// (vol/100) attenuation needed.
const audioLevel = readAudioLevel();
let target;
if (audioLevel != null) {
// Real audio: apply attack/release smoothing for
// analog-feeling ballistics.
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
target = -22 + vuLevelSmoothed * 44;
} else {
const slider = document.getElementById('volume-slider');
const vol = slider ? Number(slider.value) || 0 : 0;
const base = -22 + (vol / 100) * 44;
const mag = Math.max(2, Math.min(14, vol * 0.16));
const t = (performance.now() - vuWobbleStart) / 1000;
target = base
+ Math.sin(t * 6.3) * mag * 0.55
+ Math.sin(t * 11.7 + 1.3) * mag * 0.30
+ (Math.random() - 0.5) * mag * 0.30;
}
needle.style.transform = `rotate(${target}deg)`;
// If the visualizer rAF is already running, it ticks the needle for us.
if (visualizerAnimFrame) return;
if (vuStandaloneHandle) return;
const standalone = () => {
tickVuNeedle();
// Stop ourselves once the unified visualizer loop is up.
if (visualizerAnimFrame) {
vuStandaloneHandle = null;
return;
}
vuWobbleHandle = requestAnimationFrame(tick);
vuStandaloneHandle = requestAnimationFrame(standalone);
};
vuWobbleHandle = requestAnimationFrame(tick);
vuStandaloneHandle = requestAnimationFrame(standalone);
}
function stopVuWobble() {
if (vuWobbleHandle) {
cancelAnimationFrame(vuWobbleHandle);
vuWobbleHandle = null;
if (vuStandaloneHandle) {
cancelAnimationFrame(vuStandaloneHandle);
vuStandaloneHandle = null;
}
vuLevelSmoothed = 0;
const needle = document.getElementById('vuNeedle');
if (needle) needle.style.transform = 'rotate(-22deg)';
vuLastAppliedDeg = -999;
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)';
}
export function updatePlaybackState(state) {
@@ -830,30 +1016,58 @@ export function updatePlaybackState(state) {
}
}
// Cache last applied progress values so we can skip DOM writes when the
// rounded second hasn't moved. Width is quantized to 0.1% — finer is
// invisible but would still trigger compositor work.
let lastProgressTenths = -1; // 0..1000 (0.1% increments)
let lastProgressSec = -1;
let lastDurationSec = -1;
let cachedMiniBar = null;
function updateProgress(position, duration) {
const percent = (position / duration) * 100;
const widthStr = `${percent}%`;
const currentStr = formatTime(position);
const totalStr = formatTime(duration);
const tenths = Math.round(percent * 10); // 0..1000
const posRound = Math.round(position);
const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr;
dom.currentTime.textContent = currentStr;
dom.totalTime.textContent = totalStr;
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
if (dom.metaLength) dom.metaLength.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuenow', posRound);
dom.progressBar.setAttribute('aria-valuemax', durRound);
const widthChanged = tenths !== lastProgressTenths;
const posChanged = posRound !== lastProgressSec;
const durChanged = durRound !== lastDurationSec;
dom.miniProgressFill.style.width = widthStr;
dom.miniCurrentTime.textContent = currentStr;
dom.miniTotalTime.textContent = totalStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
const miniBar = document.getElementById('mini-progress-bar');
miniBar.setAttribute('aria-valuenow', posRound);
miniBar.setAttribute('aria-valuemax', durRound);
if (widthChanged) {
lastProgressTenths = tenths;
const widthStr = (tenths / 10) + '%';
dom.progressFill.style.width = widthStr;
dom.miniProgressFill.style.width = widthStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
}
if (posChanged) {
lastProgressSec = posRound;
const currentStr = formatTime(position);
dom.currentTime.textContent = currentStr;
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
dom.miniCurrentTime.textContent = currentStr;
dom.progressBar.setAttribute('aria-valuenow', posRound);
}
if (durChanged) {
lastDurationSec = durRound;
const totalStr = formatTime(duration);
dom.totalTime.textContent = totalStr;
if (dom.metaLength) dom.metaLength.textContent = totalStr;
dom.miniTotalTime.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuemax', durRound);
}
if (posChanged || durChanged) {
if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar');
if (cachedMiniBar) {
if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound);
if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound);
}
}
}
export function startPositionInterpolation() {
@@ -901,13 +1115,15 @@ function updateMuteIcon(muted) {
let fsChromeIdleTimer = null;
const FS_CHROME_IDLE_MS = 2500;
let fsLastFocusedElement = null;
let fsBloomSyncObserver = null;
function syncFullscreenBloomArt() {
const src = document.getElementById('album-art');
// Mirror the album-art onto #fs-bloom-art (the fullscreen ambient
// bloom). Called directly from the artwork-swap path — no
// MutationObserver, so we never repaint the 110px-radius blur twice.
function syncFullscreenBloomArt(url) {
const bloom = document.getElementById('fs-bloom-art');
if (!src || !bloom) return;
if (src.src && src.src !== bloom.src) bloom.src = src.src;
if (!bloom) return;
const target = url || (dom && dom.albumArt && dom.albumArt.src) || '';
if (target && bloom.src !== target) bloom.src = target;
}
function showFsChrome() {
@@ -978,16 +1194,10 @@ export function enterPlayerFullscreen() {
document.body.classList.add('is-fullscreen-player');
setMiniPlayerVisible(false);
updateFullscreenButtonIcons(true);
// Initial mirror — subsequent swaps are pushed by updateUI directly,
// so there is no MutationObserver in the hot path.
syncFullscreenBloomArt();
// Watch for album-art swaps so the bloom keeps up.
const src = document.getElementById('album-art');
if (src && 'MutationObserver' in window) {
if (fsBloomSyncObserver) fsBloomSyncObserver.disconnect();
fsBloomSyncObserver = new MutationObserver(syncFullscreenBloomArt);
fsBloomSyncObserver.observe(src, { attributes: true, attributeFilter: ['src'] });
}
document.addEventListener('mousemove', onFsMouseMove, { passive: true });
document.addEventListener('keydown', onFsKeyDown);
showFsChrome();
@@ -1017,10 +1227,6 @@ export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
clearTimeout(fsChromeIdleTimer);
fsChromeIdleTimer = null;
}
if (fsBloomSyncObserver) {
fsBloomSyncObserver.disconnect();
fsBloomSyncObserver = null;
}
document.removeEventListener('mousemove', onFsMouseMove);
document.removeEventListener('keydown', onFsKeyDown);
+7 -2
View File
@@ -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"
}
+7 -2
View File
@@ -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 &amp; 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&#8209;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>
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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" }