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:
1 parent
4c9a6e312a
commit
1765de65cf
3 files changed
+119
-11
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user