diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9ee2252 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/media_server/services/audio_analyzer.py b/media_server/services/audio_analyzer.py index 1ce6df7..f96f019 100644 --- a/media_server/services/audio_analyzer.py +++ b/media_server/services/audio_analyzer.py @@ -71,6 +71,11 @@ class AudioAnalyzer: self._lifecycle_lock = threading.Lock() self._data: dict | 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 self._bin_edges = self._compute_bin_edges() @@ -110,6 +115,10 @@ class AudioAnalyzer: if not self.available: 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._thread = threading.Thread(target=self._capture_loop, daemon=True) self._thread.start() @@ -295,10 +304,17 @@ class AudioAnalyzer: else: level = 0.0 - # Normalize bins to 0-1 for spectrum display - max_val = bins.max() - if max_val > 0: - bins *= (1.0 / max_val) + # Slow auto-gain: envelope follower with fast attack, + # slow release. Quiet music yields small bars; loud + # passages reach the top; the reference adapts over + # 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 = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0 diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 189484e..f3ac199 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -829,97 +829,10 @@ h1 { filter: blur(50px) saturate(1.8); } -/* Vinyl Record Mode */ -.album-art-container.vinyl #album-art { - border-radius: 50%; - width: 210px; - 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)); } -} +/* Legacy "vinyl mode" toggle (.album-art-container.vinyl + JS-driven + spinning class) was removed alongside the sleeve+disc redesign — the + disc now spins structurally via .vinyl-stage .vinyl when playstate + is "playing" (see SLEEVE FRAME section below). */ /* Audio Spectrogram Visualization */ .spectrogram-canvas { @@ -946,12 +859,6 @@ h1 { 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 { text-align: center; margin-bottom: 2rem; @@ -2854,27 +2761,6 @@ button.primary svg { 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 { font-size: 1.5rem; } @@ -4656,12 +4542,17 @@ header .brand-sub { } /* ─── 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 { position: absolute; - top: -6%; - right: -4%; - width: 56%; - height: 56%; + top: 26%; + right: -6%; + width: 36%; + height: 36%; pointer-events: none; transform-origin: 88% 12%; transform: rotate(-22deg); @@ -4694,6 +4585,162 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas { 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, .track-masthead { @@ -6511,196 +6558,16 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; } .now-playing { grid-template-columns: 1fr; gap: 40px; } } -/* ─── Vinyl + tonearm ─────────────────────────────────────── */ -.now-playing .vinyl-stage { - position: relative; - aspect-ratio: 1; - width: 100%; - 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); } } +/* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under + .vinyl-stage selectors above. The earlier .now-playing duplicates + were stale clones from the pre-sleeve mockup snap and overrode the + new geometry by source order — removed to let .vinyl-stage rules + take effect. */ /* Spectrogram canvas: hidden — the editorial .spectrum row in the - track-masthead already shows the audio spectrum. The canvas - element stays in the DOM so the visualizer JS keeps rendering - (drives the album-art bass-pulse + dynamic background). */ + track-masthead is the visible spectrum. The canvas stays in the + DOM so the visualizer render loop keeps emitting frequencyData + for the dynamic background to consume. */ .now-playing .spectrogram-canvas { display: none !important; } @@ -7998,7 +7865,7 @@ select option { letter-spacing: -0.005em; line-height: 1.2; display: inline-flex; - align-items: baseline; + align-items: center; gap: 10px; /* Allow text to wrap so we don't ellipsis-truncate the model name */ overflow: hidden; @@ -8013,13 +7880,20 @@ select option { color: var(--ink-mute); } .display-container .display-primary-badge { - font-family: var(--mono); - font-size: 8px; - letter-spacing: 0.18em; - text-transform: uppercase; + display: inline-flex; + align-items: center; + justify-content: center; color: var(--copper); - border: 1px solid var(--copper); - padding: 2px 6px; + background: none; + 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) */ @@ -8486,19 +8360,19 @@ select option { width: 78%; margin: 0 auto !important; } - .vinyl-stage .vinyl { width: 92%; } - .vinyl-stage .vinyl-label { + /* Lighter sleeve grain on phones so the printed art reads + cleanly at small size. */ + .vinyl-stage .sleeve-grain { opacity: 0.55; } + .vinyl-stage .vinyl-wrap .vinyl-label { box-shadow: - inset 0 0 24px rgba(0, 0, 0, 0.4), - 0 0 0 3px var(--bg-deep), - 0 0 0 4px var(--copper-lo); - } - .vinyl-stage .tonearm { - top: -8% !important; - right: -6% !important; - width: 60% !important; - height: 60% !important; + inset 0 0 16px rgba(0, 0, 0, 0.5), + 0 0 0 2px var(--bg-deep), + 0 0 0 3px var(--copper-lo); } + .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-align: center; } @@ -8767,14 +8641,9 @@ select option { 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%; } - .vinyl-stage .tonearm { - top: -10% !important; - right: -8% !important; - width: 64% !important; - height: 64% !important; - } .now-playing #track-title, .player-layout #track-title { diff --git a/media_server/static/index.html b/media_server/static/index.html index 4453337..a4eb082 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -6,6 +6,7 @@