fix(player): redesign cleanup pass — sleeve, tonearm, AGC, dead code
Production-readiness pass before merging the Studio Reference redesign to master. Audio (backend): - Reset AGC `_spectrum_ref` envelope on `start()` so a long silent gap between sessions doesn't make the first new transients clip at the ceiling. Annotated the trade-off (loud transient lifts reference for a few seconds afterwards — the price of real loudness). - Add `tests/test_audio_analyzer.py` with 10 cases: bin-edge layout, AGC attack/release asymmetry, lifecycle reset. Skips numpy-dependent cases when numpy isn't installed; CI has it. Vinyl mode dead code removed: - The toggle button was dropped during the sleeve refactor but the JS state, 2 s `setInterval`, `beforeunload` handler, and `applyVinylMode` call (commented out in app.js) all stayed. Now properly excised from player.js + app.js + window.* exports. - Stripped the matching `.album-art-container.vinyl*` CSS block and its `vinylSpin` keyframes (~95 LoC). Sleeve + tonearm fixes: - Removed the duplicate `.now-playing .vinyl-stage` / `.vinyl-label` / `.tonearm` block that was overriding the new `.vinyl-stage` rules by source order — the uncommitted tonearm geometry never took effect because the stale clone won the cascade. - Tightened tonearm to 36% × 36% at right:-6%, top:26% so the SVG bounding box stays right of the sleeve (sleeve right edge ~68%). Needle now lands on the visible disc grooves at both rest and playing rotations and never overlaps the cover. - Removed sleeve `transform: rotate(-2.5deg)` + the matching mobile `-1.8deg` override; sleeve now sits flat and squared-off. - Removed the 1px inset hairline on the sleeve and the 1px outline + inset highlight on the album art — cleaner, no semitransparent border noise. - Album art inset 5% to expose a cardstock margin around the print (using explicit width/height — `inset` shorthand triggered the CSS replaced-element rule that uses the image's intrinsic size and blew out the grid track). Mobile + misc: - Removed mobile tonearm overrides at 720px and 420px — they were calibrated for the pre-sleeve geometry and put the needle back over the cover on phones; desktop geometry is proportional and works. - Added `<meta name="mobile-web-app-capable">` alongside the legacy Apple variant to silence the deprecation warning in Chromium. - Replaced the "PRIMARY" badge on display cards with a copper star icon (translation key still drives title + aria-label). - `.gitattributes` with `* text=auto eol=lf` so Windows checkouts stop nagging "LF will be replaced by CRLF". Annotations: - "REF · 24" record-label catalogue mark marked as intentional non-i18n decoration in index.html. CI: ruff clean, pytest 7 passed + 3 numpy-skipped (all 10 run on CI). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# Normalise text files to LF in the repo so Windows checkouts stop
|
||||||
|
# nagging "LF will be replaced by CRLF" on every git status.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary assets — never touch.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.svg text
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.exe binary
|
||||||
|
*.dll binary
|
||||||
|
*.zip binary
|
||||||
@@ -71,6 +71,11 @@ class AudioAnalyzer:
|
|||||||
self._lifecycle_lock = threading.Lock()
|
self._lifecycle_lock = threading.Lock()
|
||||||
self._data: dict | None = None
|
self._data: dict | None = None
|
||||||
self._current_device_name: str | None = None
|
self._current_device_name: str | None = None
|
||||||
|
# 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
|
||||||
|
# for a few seconds afterwards; this is the price of real loudness.
|
||||||
|
self._spectrum_ref = 0.01
|
||||||
|
|
||||||
# Pre-compute logarithmic bin edges
|
# Pre-compute logarithmic bin edges
|
||||||
self._bin_edges = self._compute_bin_edges()
|
self._bin_edges = self._compute_bin_edges()
|
||||||
@@ -110,6 +115,10 @@ class AudioAnalyzer:
|
|||||||
if not self.available:
|
if not self.available:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Reset AGC envelope so a long silent gap between sessions
|
||||||
|
# doesn't make the first new transients clip at the ceiling.
|
||||||
|
self._spectrum_ref = 0.01
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
@@ -295,10 +304,17 @@ class AudioAnalyzer:
|
|||||||
else:
|
else:
|
||||||
level = 0.0
|
level = 0.0
|
||||||
|
|
||||||
# Normalize bins to 0-1 for spectrum display
|
# Slow auto-gain: envelope follower with fast attack,
|
||||||
max_val = bins.max()
|
# slow release. Quiet music yields small bars; loud
|
||||||
if max_val > 0:
|
# passages reach the top; the reference adapts over
|
||||||
bins *= (1.0 / max_val)
|
# seconds instead of resetting every frame.
|
||||||
|
current_peak = float(bins.max())
|
||||||
|
if current_peak > self._spectrum_ref:
|
||||||
|
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.05
|
||||||
|
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)
|
||||||
|
|
||||||
# Bass energy: average of first 4 bins (~20-200Hz)
|
# Bass energy: average of first 4 bins (~20-200Hz)
|
||||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||||
|
|||||||
+204
-335
@@ -829,97 +829,10 @@ h1 {
|
|||||||
filter: blur(50px) saturate(1.8);
|
filter: blur(50px) saturate(1.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vinyl Record Mode */
|
/* Legacy "vinyl mode" toggle (.album-art-container.vinyl + JS-driven
|
||||||
.album-art-container.vinyl #album-art {
|
spinning class) was removed alongside the sleeve+disc redesign — the
|
||||||
border-radius: 50%;
|
disc now spins structurally via .vinyl-stage .vinyl when playstate
|
||||||
width: 210px;
|
is "playing" (see SLEEVE FRAME section below). */
|
||||||
height: 210px;
|
|
||||||
margin: 45px;
|
|
||||||
filter: saturate(0.55) sepia(0.12) brightness(0.92) contrast(1.08);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 3px var(--vinyl-groove),
|
|
||||||
0 0 0 5px var(--vinyl-ring),
|
|
||||||
0 0 0 6px var(--vinyl-highlight),
|
|
||||||
0 0 0 12px var(--vinyl-ring),
|
|
||||||
0 0 0 13px var(--vinyl-highlight-dim),
|
|
||||||
0 0 0 20px var(--vinyl-ring),
|
|
||||||
0 0 0 21px var(--vinyl-highlight),
|
|
||||||
0 0 0 28px var(--vinyl-ring),
|
|
||||||
0 0 0 29px var(--vinyl-highlight-dim),
|
|
||||||
0 0 0 36px var(--vinyl-ring),
|
|
||||||
0 0 0 37px var(--vinyl-highlight),
|
|
||||||
0 0 0 42px var(--vinyl-ring),
|
|
||||||
0 0 0 43px var(--vinyl-groove),
|
|
||||||
0 0 0 45px var(--vinyl-edge),
|
|
||||||
0 4px 15px 45px var(--shadow-elevation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vinyl label vignette overlay */
|
|
||||||
.album-art-container.vinyl::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 210px;
|
|
||||||
height: 210px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(
|
|
||||||
circle,
|
|
||||||
transparent 50%,
|
|
||||||
rgba(0,0,0,0.25) 100%
|
|
||||||
);
|
|
||||||
z-index: 2;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.6s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container.vinyl.spinning::before,
|
|
||||||
.album-art-container.vinyl.paused::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container.vinyl .album-art-glow {
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Center spindle hole */
|
|
||||||
.album-art-container::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--vinyl-spindle);
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
|
|
||||||
z-index: 3;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.4s ease 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container.vinyl::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container.vinyl.spinning #album-art {
|
|
||||||
animation: vinylSpin 12s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-container.vinyl.paused #album-art {
|
|
||||||
animation: vinylSpin 12s linear infinite;
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes vinylSpin {
|
|
||||||
from { transform: rotate(var(--vinyl-offset, 0deg)) scale(var(--vinyl-scale, 1)); }
|
|
||||||
to { transform: rotate(calc(var(--vinyl-offset, 0deg) + 360deg)) scale(var(--vinyl-scale, 1)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Audio Spectrogram Visualization */
|
/* Audio Spectrogram Visualization */
|
||||||
.spectrogram-canvas {
|
.spectrogram-canvas {
|
||||||
@@ -946,12 +859,6 @@ h1 {
|
|||||||
transition: opacity 0.08s ease-out;
|
transition: opacity 0.08s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adapt spectrogram for vinyl mode */
|
|
||||||
.album-art-container.vinyl .spectrogram-canvas {
|
|
||||||
bottom: -10px;
|
|
||||||
border-radius: 0 0 50% 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info {
|
.track-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
@@ -2854,27 +2761,6 @@ button.primary svg {
|
|||||||
height: 250px;
|
height: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-art-container.vinyl #album-art {
|
|
||||||
width: 170px;
|
|
||||||
height: 170px;
|
|
||||||
margin: 40px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 3px #2a2a2a,
|
|
||||||
0 0 0 5px #1a1a1a,
|
|
||||||
0 0 0 6px rgba(255,255,255,0.05),
|
|
||||||
0 0 0 12px #1a1a1a,
|
|
||||||
0 0 0 13px rgba(255,255,255,0.03),
|
|
||||||
0 0 0 20px #1a1a1a,
|
|
||||||
0 0 0 21px rgba(255,255,255,0.05),
|
|
||||||
0 0 0 28px #1a1a1a,
|
|
||||||
0 0 0 29px rgba(255,255,255,0.03),
|
|
||||||
0 0 0 36px #1a1a1a,
|
|
||||||
0 0 0 37px rgba(255,255,255,0.04),
|
|
||||||
0 0 0 38px #2a2a2a,
|
|
||||||
0 0 0 40px #111,
|
|
||||||
0 4px 12px 40px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#track-title {
|
#track-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -4656,12 +4542,17 @@ header .brand-sub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Tonearm SVG ──────────────────────────────────────────── */
|
/* ─── Tonearm SVG ──────────────────────────────────────────── */
|
||||||
|
/* Geometry chosen so the SVG bounding box stays right of the sleeve
|
||||||
|
(sleeve right edge ≈ 68%). Pivot floats just past the stage's right
|
||||||
|
edge; needle lands on the visible disc grooves at playing rotation
|
||||||
|
(0deg) and on the outer rest position at -22deg. Never overlaps
|
||||||
|
the sleeve cover. */
|
||||||
.vinyl-stage .tonearm {
|
.vinyl-stage .tonearm {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6%;
|
top: 26%;
|
||||||
right: -4%;
|
right: -6%;
|
||||||
width: 56%;
|
width: 36%;
|
||||||
height: 56%;
|
height: 36%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform-origin: 88% 12%;
|
transform-origin: 88% 12%;
|
||||||
transform: rotate(-22deg);
|
transform: rotate(-22deg);
|
||||||
@@ -4694,6 +4585,162 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
SLEEVE FRAME — production layout
|
||||||
|
The album cover prints on a cardstock sleeve at left; the disc
|
||||||
|
sits to the right and peeks out, spinning while the tonearm
|
||||||
|
rests on it. The vinyl label is now a typographic plate; track
|
||||||
|
metadata lives in the masthead beside the stage.
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Glow: soft ambient halo behind the sleeve */
|
||||||
|
.vinyl-stage > #album-art-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: blur(34px) saturate(1.6);
|
||||||
|
opacity: 0.45;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .vinyl-stage > #album-art-glow {
|
||||||
|
opacity: 0.26;
|
||||||
|
filter: blur(40px) saturate(1.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage gets a warm ambient gradient (matches mockup) */
|
||||||
|
.album-art-container.vinyl-stage {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, #1a1611 0%, var(--bg-deep) 80%);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .album-art-container.vinyl-stage {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, var(--bg-card-2) 0%, var(--bg-deep) 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sleeve: cardstock card with album cover printed on its face.
|
||||||
|
Geometry mirrors the mockup — sleeve+disc occupy a centered 90%
|
||||||
|
inner square so the arrangement breathes inside the stage. */
|
||||||
|
.vinyl-stage .sleeve {
|
||||||
|
position: absolute;
|
||||||
|
left: 5%;
|
||||||
|
top: 10.4%;
|
||||||
|
width: 63%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
z-index: 3;
|
||||||
|
background: var(--bg-card-2);
|
||||||
|
box-shadow:
|
||||||
|
inset 4px 4px 24px rgba(0, 0, 0, 0.35),
|
||||||
|
-2px 8px 24px rgba(0, 0, 0, 0.5),
|
||||||
|
-4px 18px 44px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .vinyl-stage .sleeve {
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow:
|
||||||
|
inset 4px 4px 18px rgba(0, 0, 0, 0.10),
|
||||||
|
-2px 8px 22px rgba(0, 0, 0, 0.20),
|
||||||
|
-4px 18px 36px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Album art is the printed cover tipped onto the cardstock sleeve.
|
||||||
|
A 5% inset reveals the cardstock as a visible border around the
|
||||||
|
print; outline + inset shadow define the printed-cover edge.
|
||||||
|
Explicit width/height (not `inset` shorthand) — img is a replaced
|
||||||
|
element and would otherwise fall back to its intrinsic pixel
|
||||||
|
size, blowing out the grid track. */
|
||||||
|
.vinyl-stage .sleeve #album-art {
|
||||||
|
position: absolute;
|
||||||
|
top: 5%;
|
||||||
|
left: 5%;
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 18px rgba(0, 0, 0, 0.35),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
filter: contrast(0.96) saturate(0.92);
|
||||||
|
transition: filter 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cardstock paper grain over the print — multiplies into the image */
|
||||||
|
.vinyl-stage .sleeve-grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%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.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: 2;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .vinyl-stage .sleeve-grain { opacity: 0.32; }
|
||||||
|
|
||||||
|
/* Worn corner notch — tiny triangular wear on bottom-right */
|
||||||
|
.vinyl-stage .sleeve-corner {
|
||||||
|
position: absolute;
|
||||||
|
width: 13%;
|
||||||
|
height: 13%;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||||
|
opacity: 0.6;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disc wrap — sits behind the sleeve, peeks out the right edge */
|
||||||
|
.vinyl-stage .vinyl-wrap {
|
||||||
|
position: absolute;
|
||||||
|
right: 3.2%;
|
||||||
|
top: 19.4%;
|
||||||
|
width: 63%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.vinyl-stage .vinyl-wrap .vinyl {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vinyl label = typographic plate (no album art, lives on the sleeve now) */
|
||||||
|
.vinyl-stage .vinyl-wrap .vinyl-label {
|
||||||
|
background: linear-gradient(135deg, #2E2820 0%, #1f1a13 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 18px rgba(0, 0, 0, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||||
|
0 0 0 3px var(--bg-deep),
|
||||||
|
0 0 0 4px var(--copper-lo);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .vinyl-stage .vinyl-wrap .vinyl-label {
|
||||||
|
background: linear-gradient(135deg, #1F4E3D 0%, #143E2F 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 18px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||||
|
0 0 0 3px var(--bg-paper),
|
||||||
|
0 0 0 4px var(--copper-lo);
|
||||||
|
}
|
||||||
|
.vinyl-label-text {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
color: var(--copper);
|
||||||
|
text-transform: uppercase;
|
||||||
|
z-index: 2;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.6);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Player details (right column / masthead) ──────────────── */
|
/* ─── Player details (right column / masthead) ──────────────── */
|
||||||
.player-details,
|
.player-details,
|
||||||
.track-masthead {
|
.track-masthead {
|
||||||
@@ -6511,196 +6558,16 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
|||||||
.now-playing { grid-template-columns: 1fr; gap: 40px; }
|
.now-playing { grid-template-columns: 1fr; gap: 40px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Vinyl + tonearm ─────────────────────────────────────── */
|
/* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under
|
||||||
.now-playing .vinyl-stage {
|
.vinyl-stage selectors above. The earlier .now-playing duplicates
|
||||||
position: relative;
|
were stale clones from the pre-sleeve mockup snap and overrode the
|
||||||
aspect-ratio: 1;
|
new geometry by source order — removed to let .vinyl-stage rules
|
||||||
width: 100%;
|
take effect. */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 0;
|
|
||||||
overflow: visible;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.now-playing .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: sr-snap-spin 14s linear infinite;
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
:root[data-playstate="playing"] .now-playing .vinyl {
|
|
||||||
animation-play-state: running;
|
|
||||||
}
|
|
||||||
.now-playing .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 = circular clip holding the actual album art */
|
|
||||||
.now-playing .vinyl-label {
|
|
||||||
position: absolute;
|
|
||||||
inset: 28%;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-card);
|
|
||||||
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);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.now-playing .vinyl-label::before {
|
|
||||||
/* Spindle hole */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
.now-playing .vinyl-label #album-art-glow {
|
|
||||||
position: absolute;
|
|
||||||
inset: -10%;
|
|
||||||
width: 120%;
|
|
||||||
height: 120%;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(22px) saturate(1.4);
|
|
||||||
opacity: 0.5;
|
|
||||||
z-index: 0;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.now-playing .vinyl-label #album-art {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 2;
|
|
||||||
/* Heavy vinyl-consistent tint: deeper sepia + lower saturation
|
|
||||||
so vibrant covers blend with the copper grooves. */
|
|
||||||
filter:
|
|
||||||
sepia(0.6)
|
|
||||||
saturate(0.7)
|
|
||||||
contrast(1.12)
|
|
||||||
brightness(0.88)
|
|
||||||
hue-rotate(-8deg);
|
|
||||||
transition: filter 480ms var(--ease), -webkit-mask-image 480ms var(--ease);
|
|
||||||
/* Soft radial fade — the outer ~12% of the art fades to black so
|
|
||||||
the album image dissolves into the vinyl surface rather than
|
|
||||||
cutting hard at the circular clip edge. */
|
|
||||||
-webkit-mask-image: radial-gradient(circle at 50% 50%,
|
|
||||||
black 0%,
|
|
||||||
black 78%,
|
|
||||||
rgba(0,0,0,0.85) 88%,
|
|
||||||
rgba(0,0,0,0.4) 96%,
|
|
||||||
transparent 100%);
|
|
||||||
mask-image: radial-gradient(circle at 50% 50%,
|
|
||||||
black 0%,
|
|
||||||
black 78%,
|
|
||||||
rgba(0,0,0,0.85) 88%,
|
|
||||||
rgba(0,0,0,0.4) 96%,
|
|
||||||
transparent 100%);
|
|
||||||
}
|
|
||||||
.now-playing:hover .vinyl-label #album-art {
|
|
||||||
/* On hover, ease back toward natural color and pull the fade
|
|
||||||
inward so more of the real cover is visible. */
|
|
||||||
filter:
|
|
||||||
sepia(0.25)
|
|
||||||
saturate(0.92)
|
|
||||||
contrast(1.05)
|
|
||||||
brightness(0.98)
|
|
||||||
hue-rotate(-4deg);
|
|
||||||
-webkit-mask-image: radial-gradient(circle at 50% 50%,
|
|
||||||
black 0%,
|
|
||||||
black 88%,
|
|
||||||
rgba(0,0,0,0.9) 95%,
|
|
||||||
rgba(0,0,0,0.5) 99%,
|
|
||||||
transparent 100%);
|
|
||||||
mask-image: radial-gradient(circle at 50% 50%,
|
|
||||||
black 0%,
|
|
||||||
black 88%,
|
|
||||||
rgba(0,0,0,0.9) 95%,
|
|
||||||
rgba(0,0,0,0.5) 99%,
|
|
||||||
transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Match the glow tint and soft edge to the album art treatment */
|
|
||||||
.now-playing .vinyl-label #album-art-glow {
|
|
||||||
filter: blur(22px) saturate(1.1) sepia(0.5) hue-rotate(-8deg);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tonearm */
|
|
||||||
.now-playing .tonearm {
|
|
||||||
position: absolute;
|
|
||||||
top: -8%; right: -4%;
|
|
||||||
width: 58%; height: 58%;
|
|
||||||
pointer-events: none;
|
|
||||||
transform-origin: 88% 12%;
|
|
||||||
transform: rotate(-22deg);
|
|
||||||
transition: transform 1s var(--ease);
|
|
||||||
z-index: 3;
|
|
||||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
|
|
||||||
}
|
|
||||||
:root[data-playstate="playing"] .now-playing .tonearm {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sr-snap-spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* Spectrogram canvas: hidden — the editorial .spectrum row in the
|
/* Spectrogram canvas: hidden — the editorial .spectrum row in the
|
||||||
track-masthead already shows the audio spectrum. The canvas
|
track-masthead is the visible spectrum. The canvas stays in the
|
||||||
element stays in the DOM so the visualizer JS keeps rendering
|
DOM so the visualizer render loop keeps emitting frequencyData
|
||||||
(drives the album-art bass-pulse + dynamic background). */
|
for the dynamic background to consume. */
|
||||||
.now-playing .spectrogram-canvas {
|
.now-playing .spectrogram-canvas {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -7998,7 +7865,7 @@ select option {
|
|||||||
letter-spacing: -0.005em;
|
letter-spacing: -0.005em;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
/* Allow text to wrap so we don't ellipsis-truncate the model name */
|
/* Allow text to wrap so we don't ellipsis-truncate the model name */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -8013,13 +7880,20 @@ select option {
|
|||||||
color: var(--ink-mute);
|
color: var(--ink-mute);
|
||||||
}
|
}
|
||||||
.display-container .display-primary-badge {
|
.display-container .display-primary-badge {
|
||||||
font-family: var(--mono);
|
display: inline-flex;
|
||||||
font-size: 8px;
|
align-items: center;
|
||||||
letter-spacing: 0.18em;
|
justify-content: center;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
border: 1px solid var(--copper);
|
background: none;
|
||||||
padding: 2px 6px;
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
filter: drop-shadow(0 0 4px var(--copper-glow));
|
||||||
|
}
|
||||||
|
.display-container .display-primary-badge svg {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Power button — visible & state-coloured (on = jade, off = ink-mute) */
|
/* Power button — visible & state-coloured (on = jade, off = ink-mute) */
|
||||||
@@ -8486,19 +8360,19 @@ select option {
|
|||||||
width: 78%;
|
width: 78%;
|
||||||
margin: 0 auto !important;
|
margin: 0 auto !important;
|
||||||
}
|
}
|
||||||
.vinyl-stage .vinyl { width: 92%; }
|
/* Lighter sleeve grain on phones so the printed art reads
|
||||||
.vinyl-stage .vinyl-label {
|
cleanly at small size. */
|
||||||
|
.vinyl-stage .sleeve-grain { opacity: 0.55; }
|
||||||
|
.vinyl-stage .vinyl-wrap .vinyl-label {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 24px rgba(0, 0, 0, 0.4),
|
inset 0 0 16px rgba(0, 0, 0, 0.5),
|
||||||
0 0 0 3px var(--bg-deep),
|
0 0 0 2px var(--bg-deep),
|
||||||
0 0 0 4px var(--copper-lo);
|
0 0 0 3px var(--copper-lo);
|
||||||
}
|
|
||||||
.vinyl-stage .tonearm {
|
|
||||||
top: -8% !important;
|
|
||||||
right: -6% !important;
|
|
||||||
width: 60% !important;
|
|
||||||
height: 60% !important;
|
|
||||||
}
|
}
|
||||||
|
.vinyl-label-text { font-size: 9px; letter-spacing: 0.24em; }
|
||||||
|
/* Tonearm geometry inherits the desktop .vinyl-stage .tonearm
|
||||||
|
values — sleeve + disc proportions are identical on mobile,
|
||||||
|
so the needle still lands on the visible disc grooves. */
|
||||||
|
|
||||||
/* Track masthead text: centered, condensed cadence */
|
/* Track masthead text: centered, condensed cadence */
|
||||||
.track-masthead { text-align: center; }
|
.track-masthead { text-align: center; }
|
||||||
@@ -8767,14 +8641,9 @@ select option {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tonearm + vinyl tighter still */
|
/* Vinyl stage tighter still on small phones; tonearm inherits
|
||||||
|
desktop geometry which is proportional to the stage. */
|
||||||
.album-art-container.vinyl-stage { width: 84%; }
|
.album-art-container.vinyl-stage { width: 84%; }
|
||||||
.vinyl-stage .tonearm {
|
|
||||||
top: -10% !important;
|
|
||||||
right: -8% !important;
|
|
||||||
width: 64% !important;
|
|
||||||
height: 64% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.now-playing #track-title,
|
.now-playing #track-title,
|
||||||
.player-layout #track-title {
|
.player-layout #track-title {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>Media Server</title>
|
<title>Media Server</title>
|
||||||
<meta name="description" content="Remote media player control and file browser">
|
<meta name="description" content="Remote media player control and file browser">
|
||||||
<meta name="theme-color" content="#121212">
|
<meta name="theme-color" content="#121212">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Media Server">
|
<meta name="apple-mobile-web-app-title" content="Media Server">
|
||||||
@@ -159,12 +160,21 @@
|
|||||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||||
<section class="now-playing player-layout">
|
<section class="now-playing player-layout">
|
||||||
|
|
||||||
<!-- Vinyl stage with album art as label, plus tonearm -->
|
<!-- Vinyl stage: cardstock sleeve + disc peeking out, plus tonearm -->
|
||||||
<div class="vinyl-stage album-art-container">
|
<div class="vinyl-stage album-art-container">
|
||||||
|
<img id="album-art-glow" class="album-art-glow" src="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" alt="" aria-hidden="true">
|
||||||
|
<div class="sleeve">
|
||||||
|
<img id="album-art" src="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%3Cpath fill='%236a6a6a' 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" alt="Album Art">
|
||||||
|
<div class="sleeve-grain" aria-hidden="true"></div>
|
||||||
|
<div class="sleeve-corner" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
<div class="vinyl-wrap">
|
||||||
<div class="vinyl">
|
<div class="vinyl">
|
||||||
<div class="vinyl-label">
|
<div class="vinyl-label">
|
||||||
<img id="album-art-glow" class="album-art-glow" src="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" alt="" aria-hidden="true">
|
<!-- Stylised record-label catalogue mark, not user-facing
|
||||||
<img id="album-art" src="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%3Cpath fill='%236a6a6a' 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" alt="Album Art">
|
copy — intentionally not in the i18n bundle. -->
|
||||||
|
<span class="vinyl-label-text">REF · 24</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
|
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
|
||||||
initTheme, toggleTheme, initAccentColor, applyAccentColor,
|
initTheme, toggleTheme, initAccentColor, applyAccentColor,
|
||||||
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
|
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
|
||||||
toggleVinylMode, applyVinylMode,
|
|
||||||
visualizerEnabled, visualizerAvailable, setVisualizerEnabled,
|
visualizerEnabled, visualizerAvailable, setVisualizerEnabled,
|
||||||
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
||||||
loadAudioDevices, onAudioDeviceChanged,
|
loadAudioDevices, onAudioDeviceChanged,
|
||||||
@@ -96,8 +95,8 @@ Object.assign(window, {
|
|||||||
switchTab,
|
switchTab,
|
||||||
// Theme & accent
|
// Theme & accent
|
||||||
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
|
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
|
||||||
// Vinyl & visualizer
|
// Visualizer (vinyl spin is structural CSS — no toggle)
|
||||||
toggleVinylMode, toggleVisualizer,
|
toggleVisualizer,
|
||||||
// Background
|
// Background
|
||||||
toggleDynamicBackground,
|
toggleDynamicBackground,
|
||||||
// Auth
|
// Auth
|
||||||
@@ -163,9 +162,6 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vinyl is now structural / always-on via CSS — no init call needed.
|
|
||||||
// applyVinylMode();
|
|
||||||
|
|
||||||
// Build the editorial spectrum bars. Fewer, fatter bars read better
|
// Build the editorial spectrum bars. Fewer, fatter bars read better
|
||||||
// than many thin ones at this column width. JS-managed so we can
|
// than many thin ones at this column width. JS-managed so we can
|
||||||
// drive heights from real audio data when available.
|
// drive heights from real audio data when available.
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ export async function loadDisplayMonitors() {
|
|||||||
|
|
||||||
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
||||||
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
||||||
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : '';
|
const primaryBadge = monitor.is_primary
|
||||||
|
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||||
|
</svg>
|
||||||
|
</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="display-monitor-header">
|
<div class="display-monitor-header">
|
||||||
|
|||||||
@@ -208,72 +208,6 @@ document.addEventListener('click', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Vinyl mode
|
|
||||||
let vinylMode = localStorage.getItem('vinylMode') === 'true';
|
|
||||||
|
|
||||||
function getVinylAngle() {
|
|
||||||
const art = document.getElementById('album-art');
|
|
||||||
if (!art) return 0;
|
|
||||||
const st = getComputedStyle(art);
|
|
||||||
const tr = st.transform;
|
|
||||||
if (!tr || tr === 'none') return 0;
|
|
||||||
const m = tr.match(/matrix\((.+)\)/);
|
|
||||||
if (!m) return 0;
|
|
||||||
const vals = m[1].split(',').map(Number);
|
|
||||||
const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI));
|
|
||||||
return ((angle % 360) + 360) % 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveVinylAngle() {
|
|
||||||
if (!vinylMode) return;
|
|
||||||
localStorage.setItem('vinylAngle', getVinylAngle());
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreVinylAngle() {
|
|
||||||
const saved = localStorage.getItem('vinylAngle');
|
|
||||||
if (saved) {
|
|
||||||
const art = document.getElementById('album-art');
|
|
||||||
if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(saveVinylAngle, 2000);
|
|
||||||
window.addEventListener('beforeunload', saveVinylAngle);
|
|
||||||
|
|
||||||
export function toggleVinylMode() {
|
|
||||||
if (vinylMode) saveVinylAngle();
|
|
||||||
vinylMode = !vinylMode;
|
|
||||||
localStorage.setItem('vinylMode', vinylMode);
|
|
||||||
applyVinylMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyVinylMode() {
|
|
||||||
const container = document.querySelector('.album-art-container');
|
|
||||||
const btn = document.getElementById('vinylToggle');
|
|
||||||
if (!container) return;
|
|
||||||
if (vinylMode) {
|
|
||||||
container.classList.add('vinyl');
|
|
||||||
if (btn) btn.classList.add('active');
|
|
||||||
restoreVinylAngle();
|
|
||||||
updateVinylSpin();
|
|
||||||
} else {
|
|
||||||
saveVinylAngle();
|
|
||||||
container.classList.remove('vinyl', 'spinning', 'paused');
|
|
||||||
if (btn) btn.classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateVinylSpin() {
|
|
||||||
const container = document.querySelector('.album-art-container');
|
|
||||||
if (!container || !vinylMode) return;
|
|
||||||
container.classList.remove('spinning', 'paused');
|
|
||||||
if (currentPlayState === 'playing') {
|
|
||||||
container.classList.add('spinning');
|
|
||||||
} else if (currentPlayState === 'paused') {
|
|
||||||
container.classList.add('paused');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio Visualizer
|
// Audio Visualizer
|
||||||
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||||||
export let visualizerAvailable = false;
|
export let visualizerAvailable = false;
|
||||||
@@ -361,13 +295,6 @@ export function stopVisualizerRender() {
|
|||||||
if (visualizerCtx && canvas) {
|
if (visualizerCtx && canvas) {
|
||||||
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
const art = document.getElementById('album-art');
|
|
||||||
if (art) {
|
|
||||||
art.style.transform = '';
|
|
||||||
art.style.removeProperty('--vinyl-scale');
|
|
||||||
}
|
|
||||||
const glow = document.getElementById('album-art-glow');
|
|
||||||
if (glow) glow.style.opacity = '';
|
|
||||||
frequencyData = null;
|
frequencyData = null;
|
||||||
smoothedFrequencies = null;
|
smoothedFrequencies = null;
|
||||||
document.body.classList.remove('audio-spectrum-live');
|
document.body.classList.remove('audio-spectrum-live');
|
||||||
@@ -417,20 +344,9 @@ function renderVisualizerFrame() {
|
|||||||
visualizerCtx.fill();
|
visualizerCtx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
const bass = frequencyData.bass || 0;
|
// Bass-driven album-art scale + glow pulse removed — the
|
||||||
const scale = 1 + bass * 0.04;
|
// "burst" looked unnatural on the sleeve. Spectrum bars +
|
||||||
const art = document.getElementById('album-art');
|
// VU needle remain the audio-reactive elements.
|
||||||
if (art) {
|
|
||||||
if (vinylMode) {
|
|
||||||
art.style.setProperty('--vinyl-scale', scale);
|
|
||||||
} else {
|
|
||||||
art.style.transform = `scale(${scale})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const glow = document.getElementById('album-art-glow');
|
|
||||||
if (glow) {
|
|
||||||
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drive the editorial .spectrum bars from the same frequency data.
|
// Drive the editorial .spectrum bars from the same frequency data.
|
||||||
updateEditorialSpectrum(smoothedFrequencies, numBins);
|
updateEditorialSpectrum(smoothedFrequencies, numBins);
|
||||||
@@ -464,10 +380,12 @@ function updateEditorialSpectrum(bins, numBins) {
|
|||||||
for (let j = startIdx; j < endIdx && j < numBins; j++) {
|
for (let j = startIdx; j < endIdx && j < numBins; j++) {
|
||||||
if (bins[j] > peak) peak = bins[j];
|
if (bins[j] > peak) peak = bins[j];
|
||||||
}
|
}
|
||||||
// Per-bar high-end gain: 1.0 at the lowest bar, ~3.0 at the highest.
|
// Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest.
|
||||||
const gain = 1 + (i / barCount) * 2.0;
|
// 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.
|
// Floor at 12% so silent bars are still visually present.
|
||||||
const pct = Math.max(12, Math.min(100, peak * 110 * gain));
|
const pct = Math.max(12, Math.min(100, peak * 65 * gain));
|
||||||
bars[i].style.height = pct + '%';
|
bars[i].style.height = pct + '%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -898,7 +816,6 @@ export function updatePlaybackState(state) {
|
|||||||
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
||||||
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
||||||
}
|
}
|
||||||
updateVinylSpin();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress(position, duration) {
|
function updateProgress(position, duration) {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Tests for AudioAnalyzer.
|
||||||
|
|
||||||
|
Covers the pure-Python pieces that don't need real audio hardware:
|
||||||
|
- Logarithmic FFT bin edge layout
|
||||||
|
- Slow-AGC envelope follower (attack vs release behaviour)
|
||||||
|
- Lifecycle reset of the AGC reference on start()
|
||||||
|
|
||||||
|
Tests are skipped when numpy isn't installed in the host environment
|
||||||
|
so they don't block CI on a minimal interpreter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from media_server.services.audio_analyzer import AudioAnalyzer, _load_numpy
|
||||||
|
|
||||||
|
np = _load_numpy()
|
||||||
|
needs_numpy = pytest.mark.skipif(np is None, reason="numpy not available")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def analyzer() -> AudioAnalyzer:
|
||||||
|
return AudioAnalyzer(num_bins=16, sample_rate=44100, chunk_size=1024)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _compute_bin_edges ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@needs_numpy
|
||||||
|
def test_bin_edges_count_matches_num_bins_plus_one(analyzer: AudioAnalyzer) -> None:
|
||||||
|
edges = analyzer._compute_bin_edges()
|
||||||
|
assert len(edges) == analyzer.num_bins + 1
|
||||||
|
|
||||||
|
|
||||||
|
@needs_numpy
|
||||||
|
def test_bin_edges_are_monotonic_non_decreasing(analyzer: AudioAnalyzer) -> None:
|
||||||
|
edges = analyzer._compute_bin_edges()
|
||||||
|
assert all(edges[i] <= edges[i + 1] for i in range(len(edges) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
@needs_numpy
|
||||||
|
def test_bin_edges_stay_within_fft_size(analyzer: AudioAnalyzer) -> None:
|
||||||
|
edges = analyzer._compute_bin_edges()
|
||||||
|
fft_size = analyzer.chunk_size // 2 + 1
|
||||||
|
assert max(edges) <= fft_size - 1
|
||||||
|
assert min(edges) >= 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── AGC envelope follower (the new behaviour) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _step_envelope(analyzer: AudioAnalyzer, peak: float) -> float:
|
||||||
|
"""Run one frame of the AGC update with a known peak value.
|
||||||
|
|
||||||
|
Mirrors the math inside _capture_loop without spinning up a real
|
||||||
|
capture thread or requiring numpy: pure Python on a single float.
|
||||||
|
"""
|
||||||
|
if peak > analyzer._spectrum_ref:
|
||||||
|
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.05
|
||||||
|
else:
|
||||||
|
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.005
|
||||||
|
return analyzer._spectrum_ref
|
||||||
|
|
||||||
|
|
||||||
|
def test_agc_initial_reference_is_quiet(analyzer: AudioAnalyzer) -> None:
|
||||||
|
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agc_attacks_quickly_toward_loud_signal(analyzer: AudioAnalyzer) -> None:
|
||||||
|
# Drive 30 frames of a loud signal; reference should climb sharply.
|
||||||
|
for _ in range(30):
|
||||||
|
_step_envelope(analyzer, peak=1.0)
|
||||||
|
# 30 frames of attack=0.05 brings (1 - 0.99^30) ≈ 0.78 of the way to 1.0.
|
||||||
|
assert analyzer._spectrum_ref > 0.5
|
||||||
|
assert analyzer._spectrum_ref < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_agc_releases_slowly_toward_quiet_signal(analyzer: AudioAnalyzer) -> None:
|
||||||
|
analyzer._spectrum_ref = 1.0
|
||||||
|
for _ in range(30):
|
||||||
|
_step_envelope(analyzer, peak=0.0)
|
||||||
|
# Release coefficient is 0.005 — after 30 frames we should have shed
|
||||||
|
# only ~14% of the headroom, not snap back to silent.
|
||||||
|
assert analyzer._spectrum_ref > 0.7
|
||||||
|
assert analyzer._spectrum_ref < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_agc_is_asymmetric_attack_faster_than_release(analyzer: AudioAnalyzer) -> None:
|
||||||
|
a = AudioAnalyzer()
|
||||||
|
b = AudioAnalyzer()
|
||||||
|
a._spectrum_ref = 0.5
|
||||||
|
b._spectrum_ref = 0.5
|
||||||
|
# One attack frame toward 1.0
|
||||||
|
_step_envelope(a, peak=1.0)
|
||||||
|
# One release frame toward 0.0 (same magnitude of error: 0.5)
|
||||||
|
_step_envelope(b, peak=0.0)
|
||||||
|
attack_delta = a._spectrum_ref - 0.5
|
||||||
|
release_delta = 0.5 - b._spectrum_ref
|
||||||
|
# Attack coefficient (0.05) is 10× the release coefficient (0.005).
|
||||||
|
assert attack_delta == pytest.approx(release_delta * 10, rel=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
# ── start() lifecycle reset ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_resets_spectrum_ref_when_unavailable(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""Even when start() returns False (no hardware), the AGC state
|
||||||
|
should remain at the documented quiet baseline."""
|
||||||
|
# Force unavailable so start() short-circuits without spawning a thread.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
AudioAnalyzer, "available", property(lambda self: False)
|
||||||
|
)
|
||||||
|
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||||
|
started = analyzer.start()
|
||||||
|
assert started is False
|
||||||
|
# start() returned early before the reset — by design (no capture
|
||||||
|
# means no need to renormalize). Document the contract.
|
||||||
|
assert analyzer._spectrum_ref == 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_resets_spectrum_ref_when_available(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""When capture actually starts, leftover AGC state from a prior
|
||||||
|
session must be cleared so the first transients don't clip."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
AudioAnalyzer, "available", property(lambda self: True)
|
||||||
|
)
|
||||||
|
# Stub out the thread so we don't actually spin up a capture loop.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"media_server.services.audio_analyzer.threading.Thread",
|
||||||
|
lambda *a, **kw: type("T", (), {"start": lambda self: None})(),
|
||||||
|
)
|
||||||
|
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||||
|
try:
|
||||||
|
started = analyzer.start()
|
||||||
|
assert started is True
|
||||||
|
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||||
|
finally:
|
||||||
|
analyzer._running = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_frequency_data thread-safe contract ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_frequency_data_returns_none_before_capture(
|
||||||
|
analyzer: AudioAnalyzer,
|
||||||
|
) -> None:
|
||||||
|
assert analyzer.get_frequency_data() is None
|
||||||
Reference in New Issue
Block a user