player bar: a complete waveform arrives as one motion instead of popping

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.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:46:37 -05:00
1 parent 4c9a6e312a
commit 1765de65cf
3 files changed
+119 -11

No files matched your search

+33
View File
@@ -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 <i> 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;
+79 -11
View File
@@ -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;
+7
View File
@@ -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;