From 5786269592a30e5f1e96d6fc4014fe2becd7ab13 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:42:54 -0500 Subject: [PATCH] player bar: it grows upward, the cursor is marked by the wave itself, and the shape you leave it in survives a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanding (mock E1+T1, cubic-bezier(.22,1,.36,1) over 350ms to 90px): `.app.player-expanded` sets --player-height beside the sidebar's own rule and grid-template-rows joins the transition, so the bar grows into the content and its bottom edge is pinned without a rule saying so — the grid's height is the window, the bar's track takes 28px and 1fr gives up exactly that. Inside the bar four blocks are absolutely placed so the two layouts can interpolate; flex cannot, because one row to a centred two-row stack means changing flex-direction, which does not animate. Name and extras sit at top 50% with a -50% translate and are centred at every height in both states. --btn-w and --extras-w are measured once rather than assumed: extras is 180px, not the 170 the layout was drafted with, and it differs between a stream and a library track. The toggle takes the download button's seat (the sidebar's .gicon, verbatim) and Save to library moves into the ... menu under the same isStream gate. Measured: name and extras vertical error 0 in both states, transport and lane horizontal error 0 expanded, lane exactly 10px under the transport. The 1px playhead tick is gone from all three painters. The bar under the cursor lifts while its neighbours dip (A4): growing alone reads as nothing, because the median bar is 21.3px of a 24px lane and 87% of bars have no headroom left to grow into — the dip is what carries the silhouette. The wash is enter/leave only, one shared presence ramp with hard span membership, so the edge cannot trail the pointer: 0-bar lag across five jumps in both directions, and moving inside the lane schedules no frames at all. The settled painter keeps its cached-Path2D grouped fills whenever no pointer is on the lane. The clipped-name edge fade from 2a83615 had never once rendered. Its classes are applied by classList.toggle inside an action, which the compiler cannot see, so Svelte deleted the rules as unused — that warning is a deletion notice, not advice. :global() on the mask class only, with the anchor left scoped, and all four states now verify against a narrowed box. Panel shapes persist in one key, soulstream.panels.v1, holding nowPlaying, sidebarCollapsed and playerExpanded together so the three cannot end up half-migrated against each other; it is read per-key so an older blob contributes what it has. showNowPlaying() exists because openQueue assigned ui.nowPlaying directly and would have skipped the write. --- src/App.svelte | 18 +- src/components/PlayerBar.svelte | 491 +++++++++++++++++++++++++------- src/lib/state.svelte.js | 63 +++- 3 files changed, 470 insertions(+), 102 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 3426022..c33b83b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -72,6 +72,7 @@ class="app" class:np-open={ui.nowPlaying} class:sidebar-collapsed={ui.sidebarCollapsed} + class:player-expanded={ui.playerExpanded} class:np-dragging={ui.nowPlayingDragging} class:preview-open={previewUi.open} style="--np-open-width: {ui.nowPlayingWidth}px" @@ -137,7 +138,13 @@ padding: 0 8px 8px; background: var(--bg-sidebar); overflow: hidden; - transition: grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1); + /* The player row grows UPWARD because the grid's total height is the + window: the bar's own track gets taller and `1fr` — the content — + gives up exactly that much. Nothing below the bar can move, so its + bottom edge is pinned without a rule saying so. */ + transition: + grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1), + grid-template-rows 350ms cubic-bezier(0.22, 1, 0.36, 1); /* Indirection so the dragged width can be an INLINE style without outranking the collapse rule below: an inline --nowplaying-width would beat `.app:not(.np-open)` and the panel could never collapse. @@ -162,6 +169,15 @@ --sidebar-width: 64px; } + /* Expanded player: the same one-line gesture the sidebar makes, on the row + instead of the column. 90px was chosen on + research/playbar-redesign/expand-collapse.html — enough for the 34px + transport, a 10px gap and the 24px lane stacked and centred (68px of + content, 11px of air top and bottom), and no more. */ + .app.player-expanded { + --player-height: 90px; + } + /* Now Playing collapses to its RAIL, not to nothing — the same gesture the left nav makes. The column animates between the user's dragged width and the rail on the shared 0.28s curve above; the panel keeps a presence at diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index b3b5008..dee0069 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -14,6 +14,9 @@ getCatalogArt, playerArtSource, resumePlayback, + togglePlayerExpanded, + showNowPlaying, + saveStream, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; import { @@ -31,7 +34,6 @@ PENDING_ALPHA, } from "../lib/waveshape.js"; import Icon from "./Icon.svelte"; - import SaveMenu from "./SaveMenu.svelte"; const pct = $derived( player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0, @@ -230,9 +232,40 @@ }); function openQueue() { menuOpen = false; - ui.nowPlaying = true; // the queue lives in the panel; always reveal it + showNowPlaying(); // the queue lives in the panel; always reveal it } + // ---- Save to library, from the ⋯ menu --------------------------------- + // Only a stream can be saved; a single queued stream has one destination + // and needs no scope choice, which is the rule the old download button + // followed. `saveStream` is the same call it made. + const canSave = $derived(!!currentItem?.stream); + const savingAlbum = $derived(player.queue.filter((it) => it.stream).length > 1); + async function saveFromMenu(scope) { + menuOpen = false; + await saveStream(scope); + } + + // The transport cluster and the extras are content-sized, and neither + // changes width while the bar expands — so ONE measurement each is enough + // and this observer fires at mount and on a real content change (the Save + // items appearing, the volume glyph swapping), never per animation frame. + let barEl = $state(null); + let btnEl = $state(null); + let extrasEl = $state(null); + $effect(() => { + if (!barEl || !btnEl || !extrasEl) return; + const apply = () => { + barEl.style.setProperty("--btn-w", `${Math.round(btnEl.offsetWidth)}px`); + barEl.style.setProperty("--extras-w", `${Math.round(extrasEl.offsetWidth)}px`); + }; + apply(); + const ro = new ResizeObserver(apply); + ro.observe(btnEl); + ro.observe(extrasEl); + return () => ro.disconnect(); + }); + let artUrl = $state(null); let volTrack = $state(null); let draggingVolume = $state(false); @@ -276,7 +309,6 @@ * lanes fade to the same grey. A canvas fill cannot use the CSS var; * if the token ever changes, change this with it. */ const UNPLAYED = "#7a7a7a"; - const PREVIEW = "rgba(255,255,255,0.66)"; const BAR_GAP = BAR_PITCH - BAR_W; // The rail's `color-mix(in srgb, accent × fill%, --text-muted)` for the one @@ -290,23 +322,150 @@ // one dark notch stranded between the accent and the wash. So under a wash // it fades accent → the wash's own resolved colour instead. const MUTED_RGB = [122, 122, 122]; - /** PREVIEW (66% white) composited over an UNPLAYED bar, precomputed: - * 0.66·255 + 0.34·122 — what a washed bar actually renders as. */ + /** 66% white composited over an UNPLAYED bar, precomputed: + * 0.66·255 + 0.34·122 — what a fully washed bar renders as. Bars now + * cross-fade between MUTED_RGB and this rather than being overpainted + * with a translucent fill, so the pair has to stay in sync by hand. */ const WASHED_RGB = [210, 210, 210]; + const WHITE_RGB = [255, 255, 255]; + const easeOut3 = (t) => 1 - Math.pow(1 - t, 3); + function mixRgb(a, b, t) { + return [ + Math.round(a[0] + (b[0] - a[0]) * t), + Math.round(a[1] + (b[1] - a[1]) * t), + Math.round(a[2] + (b[2] - a[2]) * t), + ]; + } let blendParsedFor = ""; let blendRgb = MUTED_RGB; - function blendAccent(accent, fill, towardWash) { + /** `wash` is 0..1 — how washed this bar's NEIGHBOURS currently are, not a + * boolean any more: with the per-bar crossfade the bars to its right pass + * continuously through every value between grey and wash, and a boolean + * target snapped this one bar a step ahead of them. */ + function blendAccentRgb(accent, fill, wash) { if (accent !== blendParsedFor) { const n = parseInt(accent.slice(1), 16); blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; blendParsedFor = accent; } - const toward = towardWash ? WASHED_RGB : MUTED_RGB; - const t = 1 - fill; - const r = Math.round(blendRgb[0] + (toward[0] - blendRgb[0]) * t); - const g = Math.round(blendRgb[1] + (toward[1] - blendRgb[1]) * t); - const b = Math.round(blendRgb[2] + (toward[2] - blendRgb[2]) * t); - return `rgb(${r},${g},${b})`; + return mixRgb(blendRgb, mixRgb(MUTED_RGB, WASHED_RGB, wash), 1 - fill); + } + function blendAccent(accent, fill, wash) { + const c = blendAccentRgb(accent, fill, wash); + return `rgb(${c[0]},${c[1]},${c[2]})`; + } + /** theme.accent as [r,g,b]; shares blendAccentRgb's one-shot parse. */ + function accentRgb() { + blendAccentRgb(theme.accent, 1, 0); + return blendRgb; + } + + // ---- the hover marker and wash ---------------------------------------- + // Chosen with Kyle on research/playbar-redesign/hover-playhead.html + // (2026-08-04), variants A4 + B1 at 400ms, spread 9 bars. + // + // The pink 1px tick is GONE. The cursor is marked by the bar underneath it: + // that bar lifts while its neighbours dip away (A4 "stands proud"), so the + // signal is a silhouette and not only a colour. Growing the bar alone does + // not work — on a normal master the median bar already fills ~89% of the + // 24px lane, so ~87% of bars have no headroom to grow into and simply clip + // flat; the dip is what actually carries it at every loudness. + // + // The wash fades ONLY on enter and on leave (Kyle, 2026-08-04, watching the + // first build). B1's per-bar clocks were literally per bar, so moving the + // pointer left the wash edge easing along behind it — a white fill visibly + // trailing the cursor. Span membership is therefore instant: which bars are + // washed follows the pointer exactly, and the single thing that animates is + // how present the whole highlight is. One gesture, one ramp. + const HOVER_MS = 400; + /** Bars either side of the cursor that the marker touches — 9 across. */ + const MARKER_SPREAD = 4; + const MARKER_LIFT = 0.3; // cursor bar, as a fraction of its own height + const MARKER_DIP = 0.34; // neighbours, scaled by the falloff + /** Gaussian σ, so the cluster has no hard edge at ±MARKER_SPREAD. */ + const MARKER_SIGMA = MARKER_SPREAD / 1.7; + let hoverAlive = 0; // 0..1 presence of the whole highlight, the ONE ramp + let hoverLastX = -1; // remembered so the marker fades out where it was + /** Last bar of the wash span. Frozen on leave so the wash fades out over + * the span it had, instead of vanishing the instant the pointer goes. */ + let hoverSpanEnd = -1; + let hoverTickAt = 0; + let hoverActive = false; // any wash or marker on screen — see tickHover + + function resetHover() { + hoverAlive = 0; + hoverLastX = -1; + hoverSpanEnd = -1; + hoverTickAt = 0; + } + + /** Advance the presence ramp one frame. Returns true only while that ramp + * is running — moving the pointer inside the lane changes the span, not + * the ramp, so a plain mouse move costs no animation frames at all. + * Position events arrive ~5/s and cannot carry a 400ms fade on their own. */ + function tickHover(now, washEndBar, hovering) { + // Real elapsed time, capped: a backgrounded window or a long GC pause + // would otherwise hand us one enormous step and snap the fade. + const dt = hoverTickAt ? Math.min(64, now - hoverTickAt) : 16.7; + hoverTickAt = now; + const step = dt / HOVER_MS; + let moving = false; + + const want = hovering ? 1 : 0; + if (hoverAlive !== want) { + hoverAlive = Math.max(0, Math.min(1, hoverAlive + (hovering ? step : -step))); + if (Math.abs(hoverAlive - want) < 0.002) hoverAlive = want; + moving = true; + } + // Tracks the pointer exactly while hovering; held through the fade-out. + if (hovering) hoverSpanEnd = washEndBar; + // Whether the lane still needs the per-bar painter at all. Once this is + // false the settled lane is three flat regions again and goes back to + // the cached Path2D clip-fills. + hoverActive = hovering || hoverAlive > 0.001; + if (!hoverActive) hoverSpanEnd = -1; + return moving; + } + + /** How washed bar `i` is, 0..1. Membership is a hard test against the live + * span; the only continuous term is the shared presence ramp. */ + function washAt(i, full) { + return i > full && i <= hoverSpanEnd ? easeOut3(hoverAlive) : 0; + } + + /** Which bar the marker sits on, or -1. Held through the fade-out so the + * marker dies where it was rather than jumping to the lane's edge. */ + let hoverMarkerBar = -1; + + /** One lane bar, with the wash crossfade and the cursor marker applied. + * Every painter goes through here so the rise, the arrival morph and the + * settled lane cannot disagree about what a hovered bar looks like. */ + function paintLaneBar(ctx, i, mid, h, full, playedBars, laneH) { + let col; + if (i < full) col = accentRgb(); + else if (i === full && playedBars - full > 0) { + // The boundary bar fades toward whatever its RIGHT neighbour is + // currently showing, which during the ramp is a moving value. + col = blendAccentRgb(theme.accent, playedBars - full, washAt(i + 1, full)); + } else col = mixRgb(MUTED_RGB, WASHED_RGB, washAt(i, full)); + + let scale = 1; + if (hoverMarkerBar >= 0 && hoverAlive > 0.001) { + const d = Math.abs(i - hoverMarkerBar); + if (d <= MARKER_SPREAD) { + const alive = easeOut3(hoverAlive); + const f = Math.exp(-(d * d) / (2 * MARKER_SIGMA * MARKER_SIGMA)); + const k = f * alive; + if (k > 0.004) { + col = mixRgb(col, WHITE_RGB, k); + scale = d === 0 ? 1 + MARKER_LIFT * alive : 1 - MARKER_DIP * f * alive; + } + } + } + // Clamped to the lane: an unclamped lift does not grow a loud bar, it + // clips it flat against the canvas edge, which reads as damage. + paintBar(ctx, i * BAR_PITCH, mid, Math.min(laneH, h * scale), `rgb(${col[0]},${col[1]},${col[2]})`); + return Math.min(laneH, h * scale); } // ---- loading / streaming motion --------------------------------------- @@ -401,25 +560,13 @@ return out; } - /** The fade, one bar at a time — shared by the rise and the arrival - * morph, whose heights differ every frame and cannot use the grouped - * clip-fills of the settled painter. */ - function barColor(i, full, playedBars, washing, washEndBar) { - if (i < full) return theme.accent; - if (i === full && playedBars - full > 0) { - return blendAccent(theme.accent, playedBars - full, washing); - } - if (washing && i > full && i <= washEndBar) return `rgb(${WASHED_RGB.join(",")})`; - return UNPLAYED; - } - /** The streaming rise. Released bars ease from the floor to their height; * queued and beyond-frontier bars are pending dots. The colour pass is * the full painter's fade — accent, boundary blend, wash, muted — done * per bar because the heights differ every frame while bars grow. The * partial waveform describes only the audio that has ARRIVED, so it is * folded across the covered fraction of the lane, not the whole width. */ - function drawRise(ctx, W, H, mid, count, now, frontier, playFrac, playedBars) { + function drawRise(ctx, W, H, mid, count, now, frontier, playedBars) { const coveredBars = Math.max(1, frontier); if (riseLevelsPeaks !== player.waveform || riseLevelsCount !== coveredBars) { riseLevels = waveLevels(player.waveform, coveredBars); @@ -427,9 +574,6 @@ riseLevelsCount = coveredBars; } ctx.clearRect(0, 0, W, H); - const playX = playFrac * W; - const washing = hoverX > playX; - const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1; const full = Math.floor(playedBars); const heights = new Array(count); for (let i = 0; i < count; i++) { @@ -443,15 +587,9 @@ const eased = 1 - Math.pow(1 - grow, 3); const target = Math.max(2, (riseLevels[i] ?? 0.12) * H); const h = 2 + (target - 2) * eased; - heights[i] = h; - paintBar(ctx, x, mid, h, barColor(i, full, playedBars, washing, washEndBar)); + heights[i] = paintLaneBar(ctx, i, mid, h, full, playedBars, H); } lastDrawnHeights = heights; - if (hoverX >= 0) { - const LINE_H = 18; - ctx.fillStyle = theme.accentHover; - ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H); - } } function fmt(secs) { @@ -643,6 +781,9 @@ if (!peaks || peaks.length === 0) { riseKey = null; morphFrom = null; + // Nothing to wash or mark. Cleared rather than left to decay so a + // waveform arriving later does not inherit a stale half-fade. + resetHover(); if (hadPeaks) { holdUntil = now + ARRIVE_HOLD_MS; hadPeaks = false; @@ -669,6 +810,22 @@ } hadPeaks = true; + // ---- the hover crossfade, ticked exactly once per frame ------------ + // Ahead of every painter and before the early-out below, because all + // three paint the marker and the fade has to advance on frames the + // playhead alone would have skipped. + const hovering = hoverX >= 0; + if (hovering) hoverLastX = hoverX; + const hoverFrac = player.duration + ? Math.max(0, Math.min(1, player.position / player.duration)) + : 0; + const hoverEnd = hovering && hoverX > hoverFrac * W ? Math.floor(hoverX / BAR_PITCH) : -1; + const hoverMoving = tickHover(now, hoverEnd, hovering); + hoverMarkerBar = + hoverLastX >= 0 && (hovering || hoverAlive > 0.001) + ? Math.floor(hoverLastX / BAR_PITCH) + : -1; + // A stream whose waveform does not yet cover the whole track (or one // still standing its last bars up): the rise owns the lane. The // frontier is the WAVEFORM'S OWN coverage — the audio that has @@ -718,13 +875,13 @@ const growing = riseReleased > 0 && now - newest < RISE_MS; if (waveCov < 1 || riseReleased < count || growing) { morphFrom = null; // the rise owns the lane's motion - drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow); + drawRise(ctx, W, H, mid, count, now, frontier, playedBarsNow); lastPlayedBars = playedBarsNow; lastHoverDrawn = hoverX; lastAccent = theme.accent; lastAccentHover = theme.accentHover; lastDuration = player.duration; - scheduleAnim(growing || riseReleased < frontier); + scheduleAnim(growing || riseReleased < frontier || hoverMoving); return; } // Fully covered, fully grown: hand the lane back to the cached @@ -774,7 +931,6 @@ const playFrac = player.duration ? Math.max(0, Math.min(1, player.position / player.duration)) : 0; - const playX = playFrac * W; const playedBars = playFrac * waveCount; // Mid-arrival: every bar eases from where it was to where the new @@ -786,22 +942,14 @@ } else { const k = 1 - Math.pow(1 - t, 3); const full = Math.floor(playedBars); - const washing = hoverX > playX; - const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1; ctx.clearRect(0, 0, W, H); const heights = new Array(waveCount); for (let i = 0; i < waveCount; i++) { const from = morphFrom[i] ?? 2; const h = from + (waveHeights[i] - from) * k; - heights[i] = h; - paintBar(ctx, i * BAR_PITCH, mid, h, barColor(i, full, playedBars, washing, washEndBar)); + heights[i] = paintLaneBar(ctx, i, mid, h, full, playedBars, H); } lastDrawnHeights = heights; - if (hoverX >= 0) { - const LINE_H = 18; - ctx.fillStyle = theme.accentHover; - ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H); - } lastPlayedBars = playedBars; lastHoverDrawn = hoverX; lastAccent = theme.accent; @@ -814,6 +962,7 @@ const visualsChanged = geometryChanged || + hoverMoving || hoverX !== lastHoverDrawn || theme.accent !== lastAccent || theme.accentHover !== lastAccentHover || @@ -849,40 +998,44 @@ // matches the primary Play button and the sidebar's completed bars; // `accentHover` remains reserved for hover previews and controls. const full = Math.floor(playedBars); - const washing = hoverX > playX; - fillClip(0, W, UNPLAYED); - if (washing) { - // Bar-quantized for the same reason: the wash recolours whole bars - // between the playhead's bar and the cursor, never a sliver. - const washEnd = (Math.floor(hoverX / BAR_PITCH) + 1) * BAR_PITCH; - fillClip((full + 1) * BAR_PITCH, washEnd, PREVIEW); - } - fillClip(0, full * BAR_PITCH, theme.accent); - const frac = playedBars - full; - if (frac > 0 && full < waveCount) { - const h = waveHeights[full]; - ctx.fillStyle = blendAccent(theme.accent, frac, washing); - ctx.beginPath(); - ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1); - ctx.fill(); + + // Under the pointer every bar carries its own wash value and the marker + // cluster its own heights, so the grouped clip-fills cannot express the + // lane — paint it a bar at a time, exactly as the rise and the morph do. + // This is the only state that pays for it: with no pointer on the lane + // the picture is three flat regions and the cached Path2D wins. + if (hoverActive) { + const heights = new Array(waveCount); + for (let i = 0; i < waveCount; i++) { + heights[i] = paintLaneBar(ctx, i, mid, waveHeights[i], full, playedBars, H); + } + lastDrawnHeights = heights; + } else { + fillClip(0, W, UNPLAYED); + fillClip(0, full * BAR_PITCH, theme.accent); + const frac = playedBars - full; + if (frac > 0 && full < waveCount) { + const h = waveHeights[full]; + ctx.fillStyle = blendAccent(theme.accent, frac, 0); + ctx.beginPath(); + ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1); + ctx.fill(); + } + lastDrawnHeights = waveHeights; } - // No standing playhead rule any more: at this pitch the accent's own - // leading edge marks the position, and a white line through the bars - // read as a seam. The hover tick stays — it is the only thing that - // says where a click would land. - if (hoverX >= 0) { - const LINE_H = 18; - ctx.fillStyle = theme.accentHover; - ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H); - } - - lastDrawnHeights = waveHeights; + // No standing playhead rule, and no hover tick either: the 1px + // accent-hover line that used to say "a click lands here" is replaced by + // the marker inside paintLaneBar — the bar itself lifts and whitens. lastPlayedBars = playedBars; lastHoverDrawn = hoverX; lastAccent = theme.accent; lastAccentHover = theme.accentHover; lastDuration = player.duration; + // The settled lane is otherwise repainted only by position events + // (~5/s), which cannot carry a 400ms fade — keep frames coming until + // the crossfade and the marker have both landed. + scheduleAnim(hoverMoving); } // Redraw whenever the waveform, position, hover, or accent changes @@ -906,7 +1059,7 @@ }); -