From 1765de65cfd259e420d4a365f25727bf45a24eb1 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:46:37 -0500 Subject: [PATCH] player bar: a complete waveform arrives as one motion instead of popping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chosen on waveform-arrive.html (M2, direct morph, 1000ms): when a track has its whole waveform at once — a local file, a fully-cached stream, or the final decode replacing a stream's progressive peaks — every bar eases from whatever the lane currently shows straight into the new shape, all together. From the resting dots that reads as a uniform grow; from a mid-sweep swell or the previous track's bars it is literally one waveform transforming into another. The streaming rise keeps its left-to-right story untouched; the morph fires only on a peaks swap at coverage 1, and never on a resize, which re-lays the same shape and stays instant. The canvas keeps a record of what it actually painted last frame — dots, swell, rise, morph or settled — so the morph always starts from the truth on screen, and the fade colours (accent, boundary blend, hover wash) stay live through it. The rail mirrors the motion in CSS: an `arriving` class carries the same 1000ms house-ease height transition, placed after the rise rule so a stream's final decode arrives on this ease too. Measured live on a cached start: bar heights walked 5.2 → 8.2 → 10.6 → 12.3 → 13.6 → 14.6 → 15.0 and settled at 15.1 at the 1000ms mark — the ease-out curve, frame by frame. --- src/components/NowPlaying.svelte | 33 ++++++++++++ src/components/PlayerBar.svelte | 90 ++++++++++++++++++++++++++++---- src/lib/waveshape.js | 7 +++ 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 3c15a81..9901d79 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -37,6 +37,7 @@ BAR_W, waveLevels, RISE_CADENCE_MS, + ARRIVE_MS, PENDING_ALPHA, } from "../lib/waveshape.js"; @@ -398,6 +399,28 @@ return wavePending || v == null || (railRising && i >= railRevealed); } + // The arrival morph — the playbar's M2, in CSS: when a COMPLETE waveform + // lands (local track, cached stream, a stream's final decode), the + // `arriving` class puts an ARRIVE_MS height transition on every bar, so + // the whole lane eases from whatever it showed into the new shape at + // once. Svelte reuses the nodes across the swap, which is what makes + // the transition run from the old inline heights. + let railArriving = $state(false); + let railArrivePeaks = null; + let railArriveTimer = null; + $effect(() => { + const w = player.waveform; + if (w.length > 0 && railCoverage >= 1 && w !== railArrivePeaks) { + railArrivePeaks = w; + railArriving = true; + clearTimeout(railArriveTimer); + railArriveTimer = setTimeout(() => (railArriving = false), ARRIVE_MS + 80); + } else if (w.length === 0) { + railArrivePeaks = null; + } + }); + $effect(() => () => clearTimeout(railArriveTimer)); + /* `bars` is already shaped 0..1 by waveshape.js (which also explains, with measurements, why the old gain-and-clamp rule here rendered half the lane at one height and had to be propped up with per-bar noise). This @@ -1082,6 +1105,7 @@ class:pending={wavePending} class:loading={wavePending && player.wavePending} class:rising={railRising} + class:arriving={railArriving} style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}" role="slider" tabindex="0" @@ -1978,6 +2002,15 @@ height 2000ms cubic-bezier(0.22, 1, 0.36, 1), opacity 400ms linear; } + /* Arrival morph — the playbar's M2: a complete waveform eases in as one + motion, every bar at once. 1000ms is ARRIVE_MS in waveshape.js — change + them together. After `.rising` so a stream's final decode arrives on + this ease even while the rise class lingers. */ + .wave.arriving i { + transition: + height 1000ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 400ms linear; + } .times { display: flex; justify-content: space-between; diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index c287b1f..796aec2 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -26,6 +26,7 @@ SWELL_WIDTH_BARS, RISE_MS, RISE_CADENCE_MS, + ARRIVE_MS, PENDING_ALPHA, } from "../lib/waveshape.js"; import Icon from "./Icon.svelte"; @@ -185,6 +186,14 @@ let riseLevels = []; let riseLevelsPeaks = null; let riseLevelsCount = 0; + // The arrival morph (M2 on waveform-arrive.html): when a COMPLETE + // waveform lands, every bar eases from whatever the lane painted last — + // resting dots, a mid-sweep swell, the previous shape — straight into the + // new one over ARRIVE_MS. `lastDrawnHeights` is kept current by every + // painter so the morph always starts from the truth on screen. + let lastDrawnHeights = null; + let morphFrom = null; + let morphStart = 0; let animRaf = null; function paintBar(ctx, x, mid, h, color) { @@ -217,6 +226,7 @@ const period = SWELL_SWEEP_MS + SWELL_REST_MS; const u = (now % period) / SWELL_SWEEP_MS; // > 1 during the rest beat const centre = u * (count + SWELL_WIDTH_BARS * 2) - SWELL_WIDTH_BARS; + const heights = new Array(count); for (let i = 0; i < count; i++) { let env = 0; if (pending && u <= 1) { @@ -224,11 +234,25 @@ env = Math.exp(-d * d); } const alpha = PENDING_ALPHA + (0.85 - PENDING_ALPHA) * env; - paintBar(ctx, i * BAR_PITCH, mid, 2 + env * SWELL_AMP_PX * 2, `rgba(122,122,122,${alpha})`); + heights[i] = 2 + env * SWELL_AMP_PX * 2; + paintBar(ctx, i * BAR_PITCH, mid, heights[i], `rgba(122,122,122,${alpha})`); } + lastDrawnHeights = heights; return pending; } + /** 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 @@ -247,9 +271,11 @@ 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++) { const x = i * BAR_PITCH; if (i >= riseReleased) { + heights[i] = 2; paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`); continue; } @@ -257,17 +283,10 @@ const eased = 1 - Math.pow(1 - grow, 3); const target = Math.max(2, (riseLevels[i] ?? 0.12) * H); const h = 2 + (target - 2) * eased; - let color; - if (i < full) color = theme.accent; - else if (i === full && playedBars - full > 0) { - color = blendAccent(theme.accent, playedBars - full, washing); - } else if (washing && i > full && i <= washEndBar) { - color = `rgb(${WASHED_RGB.join(",")})`; - } else { - color = UNPLAYED; - } - paintBar(ctx, x, mid, h, color); + heights[i] = h; + paintBar(ctx, x, mid, h, barColor(i, full, playedBars, washing, washEndBar)); } + lastDrawnHeights = heights; if (hoverX >= 0) { const LINE_H = 18; ctx.fillStyle = theme.accentHover; @@ -458,6 +477,7 @@ // while a waveform request is actually working. if (!peaks || peaks.length === 0) { riseKey = null; + morphFrom = null; const moving = drawUnloaded(ctx, W, H, mid, count, now); lastPlayedBars = Number.NEGATIVE_INFINITY; scheduleAnim(moving); @@ -512,6 +532,7 @@ const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0; 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); lastPlayedBars = playedBarsNow; lastHoverDrawn = hoverX; @@ -534,6 +555,7 @@ wavePathWidth !== W || wavePathHeight !== H; if (geometryChanged) { + const peaksSwapped = wavePathPeaks !== peaks; // Same pitch AND same shaping as the Now Playing rail — both come // from waveshape.js — so the two lanes in the window read as one // instrument and neither can drift from the other. Mirrored about @@ -551,6 +573,15 @@ wavePathPeaks = peaks; wavePathWidth = W; wavePathHeight = H; + // A NEW complete waveform arrives on the morph — from the dots, + // from the swell mid-sweep, from the previous track's shape, or + // from the progressive peaks the final stream decode replaces. + // Only on a peaks swap at a matching bar count: a pure resize + // re-lays the same shape and stays instant. + if (peaksSwapped && lastDrawnHeights?.length === count) { + morphFrom = [...lastDrawnHeights]; + morphStart = now; + } } const playFrac = player.duration @@ -558,6 +589,42 @@ : 0; const playX = playFrac * W; const playedBars = playFrac * waveCount; + + // Mid-arrival: every bar eases from where it was to where the new + // waveform puts it, all together, with the full fade colours live. + if (morphFrom) { + const t = (now - morphStart) / ARRIVE_MS; + if (t >= 1) { + morphFrom = null; + } 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)); + } + 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; + lastAccentHover = theme.accentHover; + lastDuration = player.duration; + scheduleAnim(true); + return; + } + } + const visualsChanged = geometryChanged || hoverX !== lastHoverDrawn || @@ -623,6 +690,7 @@ ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H); } + lastDrawnHeights = waveHeights; lastPlayedBars = playedBars; lastHoverDrawn = hoverX; lastAccent = theme.accent; diff --git a/src/lib/waveshape.js b/src/lib/waveshape.js index 5ff3e1f..6a1c583 100644 --- a/src/lib/waveshape.js +++ b/src/lib/waveshape.js @@ -47,6 +47,13 @@ export const SWELL_AMP_PX = 7; // crest lift, playbar px (rail scales ×1.6) export const SWELL_WIDTH_BARS = 54; // gaussian width of the crest export const RISE_MS = 2000; // one bar, floor → full height export const RISE_CADENCE_MS = 40; // gap between one bar starting and the next +/** The ARRIVAL morph — chosen on waveform-arrive.html (M2, direct morph, + * 2026-08-03): when a COMPLETE waveform lands (local track, cached stream, + * the final decode replacing a stream's progressive peaks), every bar eases + * from whatever the lane currently shows — resting dots, a mid-sweep swell, + * the previous shape — straight into the new one, all at once, one motion. + * The streaming rise keeps its left-to-right story; this is only coverage 1. */ +export const ARRIVE_MS = 1000; /** Resting/queued bars render the muted grey at this opacity — the rail's * pending dim, shared so the two lanes' dots match. */ export const PENDING_ALPHA = 0.45;