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 @@ }); -