diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index de037ff9..f2b07ac3 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -19,6 +19,21 @@ const _RATE_GAUGE_COLORS = { amazon: '#FF9900', }; +// 2–3 character abbreviations rendered inside each equalizer bar's +// avatar disc. First-letter alone collided too often (3× "A" services, +// 2× "D"); these read as service shorthand at a glance. +const _RATE_GAUGE_GLYPHS = { + spotify: 'SP', itunes: 'AM', deezer: 'DZ', lastfm: 'LF', genius: 'GN', + musicbrainz: 'MB', audiodb: 'ADB', tidal: 'TD', qobuz: 'QB', discogs: 'DC', + amazon: 'AZ', +}; + +// Per-service display state for the equalizer bars. Holds the last +// rendered count so we can animate digit changes via easing, and the +// last fill height so the spike-detect peak flash only fires on a +// real upward step (not on repaint / equal-value socket updates). +const _eqDisplay = {}; + // SVG constants — 240° arc, gap at bottom const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 }; @@ -170,6 +185,7 @@ function _renderEqualizerBars(grid, data) { for (const svc of _RATE_GAUGE_SERVICES) { const accent = _RATE_GAUGE_COLORS[svc] || '#888'; const label = _RATE_GAUGE_LABELS[svc] || svc; + const glyph = _RATE_GAUGE_GLYPHS[svc] || (label[0] || '?').toUpperCase(); const bar = document.createElement('button'); bar.type = 'button'; bar.className = 'rate-eq'; @@ -177,7 +193,13 @@ function _renderEqualizerBars(grid, data) { bar.style.setProperty('--eq-accent', accent); bar.setAttribute('aria-label', `${label} rate detail`); bar.onclick = () => _openRateModal(svc); + // Layout, top-to-bottom: + // - avatar disc (brand glyph) anchored above the track + // - track (with reflection puddle as ::after) holding the + // fill / shimmer / peak tip / live count + // - meta column (state pill + service name) bar.innerHTML = ` +
@@ -195,6 +217,7 @@ function _renderEqualizerBars(grid, data) {
`; grid.appendChild(bar); + _eqDisplay[svc] = { value: 0, pct: 0.04 }; } } @@ -217,10 +240,50 @@ function _renderEqualizerBars(grid, data) { const wStatus = worker.status || 'stopped'; const isRateLimited = d.rate_limited === true; + const prev = _eqDisplay[svc] || { value: 0, pct: 0.04 }; + const fill = bar.querySelector('.rate-eq-fill'); if (fill) fill.style.height = `${pct * 100}%`; + + // The reflection puddle (CSS ::after on the track) fades in + // proportional to the real (unclamped) rate so idle services + // don't pollute the row with a floor of glow. Bound to a + // CSS variable so the puddle and any future glow-aware + // styling can share the same source-of-truth value. + const realPct = max > 0 ? value / max : 0; + bar.style.setProperty('--eq-glow', String(Math.min(1, realPct + 0.04))); + + // Rolling counter — animate the digit change instead of + // snapping. Premium feel; matches the smooth height + // transition the fill already has. const val = bar.querySelector('.rate-eq-value'); - if (val) val.textContent = Math.round(value); + if (val) { + const rounded = Math.round(value); + const prevRounded = Math.round(prev.value); + if (rounded !== prevRounded) { + _animateRollingNumber(val, prevRounded, rounded); + } else { + val.textContent = rounded; + } + } + + // Peak-flash detector: if the rate stepped UPWARD between + // socket updates, briefly trigger the .peak-flash class on + // the bar so the tip emits a quick accent burst. Mimics a + // hardware VU meter's peak-detect LED — sells the "alive" + // feeling and ties bar movement to actual call activity. + // Only fires on real increases above a small noise floor + // (jitter on near-zero rates would otherwise pulse the + // bar constantly). + const PEAK_JITTER_THRESHOLD = 1; + if (value - prev.value > PEAK_JITTER_THRESHOLD) { + bar.classList.remove('peak-flash'); + // Force a reflow so re-adding the class restarts the + // animation even if it was already mid-cycle. + void bar.offsetWidth; // eslint-disable-line no-unused-expressions + bar.classList.add('peak-flash'); + window.setTimeout(() => bar.classList.remove('peak-flash'), 700); + } const state = bar.querySelector('.rate-eq-state'); if (state) { @@ -232,13 +295,44 @@ function _renderEqualizerBars(grid, data) { // Danger threshold uses the REAL (unclamped) ratio so the // 4% visual floor for idle bars doesn't push everything into // a permanent green state — the threshold reads true rate. - const realPct = max > 0 ? value / max : 0; bar.classList.toggle('danger', realPct > 0.8 || isRateLimited); bar.classList.toggle('active', value > 0 || wStatus === 'running'); bar.classList.toggle('rate-limited', isRateLimited); + + _eqDisplay[svc] = { value, pct }; } } +// Animate a single integer counter from `from` to `to` with an +// easeOutCubic curve. Used by the equalizer bars so the live count +// digit-rolls instead of snapping when sockets push a new value. +const _eqRollingHandles = new WeakMap(); + +function _animateRollingNumber(el, from, to, duration = 520) { + // Cancel any animation already running on this element so we + // don't get two RAF loops fighting over its textContent. + const prev = _eqRollingHandles.get(el); + if (prev) cancelAnimationFrame(prev); + + if (from === to) { + el.textContent = String(to); + return; + } + const start = performance.now(); + const span = to - from; + function step(now) { + const t = Math.min(1, (now - start) / duration); + const eased = 1 - Math.pow(1 - t, 3); + el.textContent = String(Math.round(from + span * eased)); + if (t < 1) { + _eqRollingHandles.set(el, requestAnimationFrame(step)); + } else { + _eqRollingHandles.delete(el); + } + } + _eqRollingHandles.set(el, requestAnimationFrame(step)); +} + function _workerStatusLabel(status, worker) { if (status === 'not_configured') return 'Not configured'; if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; diff --git a/webui/static/helper.js b/webui/static/helper.js index d1037100..f63df992 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.3': [ { unreleased: true }, + { title: 'Dashboard: enrichment equalizer bars get next-level polish', desc: 'follow-up to the equalizer redesign. four upgrades that take the bars from "nice" to "vibey": (1) every bar now carries a circular brand-color avatar disc above the track with the service\'s 2-3 letter glyph (SP, AM, DZ, LF, GN, MB, ADB, TD, QB, DC, AZ) — disc has its own radial gradient + inner highlight + slow halo pulse when the worker is running, ties the bar visually to its identity; (2) peak-flash detector — when the worker\'s calls/min actually steps upward between updates, the peak tip briefly flares white-hot and the fill flashes brighter for ~650ms, like a hardware VU meter\'s peak-detect LED catching a beat. only fires on real increases above a noise threshold so jitter doesn\'t pulse the bar constantly; (3) rolling number counter — the live count digit-animates from old→new value with easeOutCubic instead of snapping, the same premium-counter feel you\'d see on a fancy dashboard; (4) glass-surface reflection puddle under each bar — soft accent-colored glow whose opacity tracks the real (unclamped) rate, so the row looks like the bars are standing on a polished pane rather than floating in space. puddle ripples on peak-flash too. all four tied together by the same --eq-accent / --eq-glow CSS variables so future tweaks stay coherent.', page: 'dashboard' }, { title: 'Dashboard: equalizer-bar redesign for the Enrichment Services panel', desc: 'the enrichment rate monitor on the dashboard used a 10-column grid of circular speedometers, which (a) lost symmetry the moment a new service was added — 11 services meant 10 in one row and a lonely orphan in the next, (b) wasted ~80% of pixels on empty gauge arcs showing "0" most of the time. Replaced with a VU-meter / equalizer-bar row: each service is a vertical capsule with the service\'s brand-color fill height tracking calls/min, a continuous shimmer scan + glowing peak tip when active, a slow breathing animation when idle, and a red pulsing variant when rate-limited. Symmetric by design at any service count — the row uses flex with even gaps so new services just slot in. Bar tip + value text adapt color to the service\'s accent. Status pill below each bar (Running / Idle / Paused / Stopped) with its own pulsing dot. Click any bar to open the same detail modal the speedometer used; nothing changed about the underlying data flow.', page: 'dashboard' }, { title: 'Auto-Sync: fix ListenBrainz pipelines stuck on "Refreshing:" for 5+ minutes', desc: 'Auto-Sync pipeline runs against a ListenBrainz playlist (Weekly Jams / Weekly Exploration / Top Discoveries / etc.) would sit on "Refreshing: \'\'" for minutes with zero UI updates before anything else happened. Two real bugs ganging up: (1) the refresh path ran the LB matching engine on every track to produce matched_data — 5+ minutes blocking with no progress emission — and Phase 2 of the pipeline then ran the SAME matching engine on the same tracks via the discovery worker. So LB tracks discovered twice, the first time silently. (2) ListenBrainz manager only exposed an `update_all_playlists` entry-point — refreshing one playlist re-pulled all 12+ cached LB playlists\' details from the API even though we only cared about one. Pipeline now skips the refresh-side discovery (Phase 2 handles it with progress emits every 3s — same pattern that already works for Spotify); refresh uses a new targeted `LBManager.refresh_playlist(mbid)` that hits just the requested playlist. Also killed a silent `except Exception: pass` in the LB adapter that was masking real API failures as stale-cache hits — refresh errors now log with traceback and surface to the run-history error tally. Pinned with 12 new unit tests.', page: 'automations' }, { title: 'Wishlist: harden Spotify backfill so a poisoned track_number can\'t mask a lean album', desc: 'follow-up to the earlier wishlist import-path work. residual per-track wishlist downloads (single tracks from different albums, falling below the album-bundle threshold) were producing folders without a year subfolder whenever the underlying wishlist row had a track_number=1 from an older default — the candidate dispatcher\'s "fall back to Spotify API" branch was gated entirely on track_number being missing, but the same API call was also the only thing that hydrated the lean album_context (release_date / total_tracks / cover art) when the original discovery match came from Deezer\'s search endpoint which doesn\'t carry those fields. so any row whose track_number happened to look "filled" (1 from a default) would short-circuit the API call and the year disappeared from the folder path even though the API knew it. split the two concerns: track_number resolution keeps its track_info → track object → API precedence, but album hydration now runs whenever release_date or total_tracks are missing regardless of where track_number came from. one network round-trip still serves both. lifted into core/downloads/track_metadata_backfill.py with 24 unit tests pinning every branch — including the regression: poisoned default-1 track_number does NOT block album backfill anymore.' }, diff --git a/webui/static/style.css b/webui/static/style.css index 33b99d36..a8af4bbc 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -62271,12 +62271,13 @@ body.reduce-effects .dash-card::after { .rate-eq { --eq-accent: #888; + --eq-glow: 0.04; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: stretch; - gap: 10px; + gap: 8px; padding: 0; background: none; border: 0; @@ -62288,6 +62289,71 @@ body.reduce-effects .dash-card::after { transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); } +/* Avatar disc — circular brand-color chip above the bar, glyph + * centered. Anchors each capsule to its service so the row reads + * as identifiable rather than 11 anonymous bars. */ +.rate-eq-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + color: rgba(255, 255, 255, 0.95); + background: + radial-gradient(circle at 30% 25%, + color-mix(in srgb, var(--eq-accent) 65%, white 35%), + var(--eq-accent) 55%, + color-mix(in srgb, var(--eq-accent) 55%, black 45%) 100%); + border: 1px solid color-mix(in srgb, var(--eq-accent) 55%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 4px 14px color-mix(in srgb, var(--eq-accent) 30%, transparent), + 0 0 0 0 color-mix(in srgb, var(--eq-accent) 35%, transparent); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); + transition: box-shadow 0.3s, transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + flex: 0 0 auto; +} + +.rate-eq.active .rate-eq-avatar { + animation: rate-eq-avatar-halo 2.8s ease-in-out infinite; +} + +@keyframes rate-eq-avatar-halo { + 0%, 100% { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 4px 14px color-mix(in srgb, var(--eq-accent) 30%, transparent), + 0 0 0 0 color-mix(in srgb, var(--eq-accent) 35%, transparent); + } + 50% { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.3), + 0 4px 18px color-mix(in srgb, var(--eq-accent) 55%, transparent), + 0 0 0 5px color-mix(in srgb, var(--eq-accent) 12%, transparent); + } +} + +.rate-eq:hover .rate-eq-avatar { + transform: translateY(-1px) scale(1.05); +} + +.rate-eq.rate-limited .rate-eq-avatar { + background: + radial-gradient(circle at 30% 25%, #ffb4b4, #ef4444 55%, #7a1a1a 100%); + border-color: rgba(239, 68, 68, 0.5); + animation: rate-eq-avatar-warn 1.1s ease-in-out infinite; +} + +@keyframes rate-eq-avatar-warn { + 0%, 100% { filter: brightness(1); } + 50% { filter: brightness(1.35); } +} + .rate-eq:focus-visible { outline: 2px solid var(--eq-accent); outline-offset: 4px; @@ -62317,6 +62383,45 @@ body.reduce-effects .dash-card::after { transition: border-color 0.3s, box-shadow 0.3s; } +/* Reflection puddle — a soft accent-colored glow rendered as a + * pseudo on the host button so it escapes the track's + * ``overflow: hidden`` clipping. Sits between the track and the + * meta column, intensifying with bar height to sell the "glass + * surface" feel: the bar looks like it's standing on a polished + * pane, not floating in space. Opacity is driven by --eq-glow + * which the renderer sets proportional to the real (unclamped) + * rate, so idle bars don't pollute the row with permanent + * ground-light. */ +.rate-eq::after { + content: ''; + position: absolute; + left: 8%; + right: 8%; + /* Anchored just above the meta column. Using a numeric inset + * from the bottom keeps the puddle visually attached to the + * track regardless of meta-block height. */ + bottom: 44px; + height: 18px; + border-radius: 50%; + background: radial-gradient(ellipse at center, + color-mix(in srgb, var(--eq-accent) 75%, transparent) 0%, + color-mix(in srgb, var(--eq-accent) 40%, transparent) 40%, + transparent 70%); + opacity: calc(var(--eq-glow, 0.04) * 1.1); + filter: blur(5px); + pointer-events: none; + transition: opacity 0.6s; + z-index: 0; +} + +.rate-eq.rate-limited::after, +.rate-eq.danger::after { + background: radial-gradient(ellipse at center, + rgba(239, 68, 68, 0.75) 0%, + rgba(239, 68, 68, 0.4) 40%, + transparent 70%); +} + .rate-eq:hover .rate-eq-track { border-color: color-mix(in srgb, var(--eq-accent) 45%, rgba(255, 255, 255, 0.12)); box-shadow: @@ -62416,6 +62521,61 @@ body.reduce-effects .dash-card::after { } } +/* Peak-flash — fires on a real upward step in cpm between socket + * updates. Mimics a hardware VU meter's peak-detect LED: tip + * briefly flares white-hot, the bar fill flashes brighter, and a + * thin ring radiates from the top of the fill. Self-contained + * 650ms one-shot; JS removes the class after the animation ends. */ +.rate-eq.peak-flash .rate-eq-tip { + animation: rate-eq-tip-flash 0.65s ease-out; +} + +.rate-eq.peak-flash .rate-eq-fill { + animation: rate-eq-fill-flash 0.65s ease-out; +} + +@keyframes rate-eq-tip-flash { + 0% { + background: #fff; + height: 2px; + opacity: 1; + box-shadow: + 0 0 18px #fff, + 0 0 36px color-mix(in srgb, var(--eq-accent) 90%, white), + 0 0 60px color-mix(in srgb, var(--eq-accent) 60%, transparent); + transform: translateY(0); + } + 40% { + height: 4px; + opacity: 1; + box-shadow: + 0 0 22px #fff, + 0 0 44px color-mix(in srgb, var(--eq-accent) 80%, white); + transform: translateY(-2px); + } + 100% { + height: 2px; + transform: translateY(0); + } +} + +@keyframes rate-eq-fill-flash { + 0% { filter: brightness(1.55) saturate(1.25); } + 50% { filter: brightness(1.25) saturate(1.15); } + 100% { filter: brightness(1) saturate(1); } +} + +/* Peak-flash also briefly intensifies the reflection puddle, so + * the surface "ripples" with the call burst. */ +.rate-eq.peak-flash::after { + animation: rate-eq-puddle-flash 0.65s ease-out; +} + +@keyframes rate-eq-puddle-flash { + 0% { opacity: calc((var(--eq-glow, 0.04) + 0.45) * 1.2); filter: blur(7px); } + 100% { opacity: calc(var(--eq-glow, 0.04) * 1.2); filter: blur(5px); } +} + /* Idle floor: keep the 4% sliver visibly alive — a slow breathe so the row reads as standby, not dead. */ .rate-eq:not(.active) .rate-eq-fill { @@ -62570,9 +62730,10 @@ body.reduce-effects .dash-card::after { --eq-track-h: 140px; gap: 4px; } - .rate-eq-value { font-size: 16px; } - .rate-eq-name { font-size: 10px; } - .rate-eq-state { font-size: 8px; padding: 1px 6px; } + .rate-eq-value { font-size: 16px; } + .rate-eq-name { font-size: 10px; } + .rate-eq-state { font-size: 8px; padding: 1px 6px; } + .rate-eq-avatar { width: 26px; height: 26px; font-size: 10px; } } @media (max-width: 720px) { @@ -62589,6 +62750,7 @@ body.reduce-effects .dash-card::after { .dash-card[data-card="enrichment"] .rate-monitor-grid--equalizer { --eq-track-h: 108px; } + .rate-eq-avatar { width: 24px; height: 24px; font-size: 9px; } } /* Active downloads container */