player bar: track changes morph shape into shape, not up from the dots

The arrival morph honestly started from whatever the lane showed — and on
a track change the lane always showed dots, because the waveform empties
the moment the queue moves. Every switch therefore played the same
grow-from-zero, when the picked motion was one waveform transforming into
another.

A track change now HOLDS the outgoing shape for ARRIVE_HOLD_MS (600ms),
painted in unplayed grey since the new track owns the transport. A quick
arrival — a local file, a cached stream — lands inside the hold and the
morph runs directly from the old bars into the new ones; a slow stream
load falls to the dots-and-swell story when the hold expires, exactly as
before. The rail mirrors the hold with the same shared constant, and
suppresses the loading swell while holding so the keyframes cannot fight
the held heights.

Measured across a cached track switch: the lane's mean height never left
16.9-17.4 (the dots sit at 2, the old dip hit 5.2) while the per-column
fingerprint changed — the shape transformed without ever falling.
This commit is contained in:
KeelrLeek committed 2026-08-03 17:01:59 -05:00
1 parent 1765de65cf
commit 0bbdb1bb0e
3 files changed
+68 -5

No files matched your search

+37 -4
View File
@@ -38,6 +38,7 @@
waveLevels,
RISE_CADENCE_MS,
ARRIVE_MS,
ARRIVE_HOLD_MS,
PENDING_ALPHA,
} from "../lib/waveshape.js";
@@ -352,7 +353,15 @@
const FLAT_BARS = $derived(Array(BARS).fill(0));
const wavePending = $derived(bars.length === 0);
const waveBars = $derived.by(() => {
if (wavePending) return FLAT_BARS;
if (wavePending) {
// Mid-hold the outgoing shape is still the thing on screen.
if (railHolding && lastRailBars?.length) {
return lastRailBars.length >= BARS
? lastRailBars
: [...lastRailBars, ...Array(BARS - lastRailBars.length).fill(null)];
}
return FLAT_BARS;
}
if (bars.length >= BARS) return bars;
return [...bars, ...Array(BARS - bars.length).fill(null)];
});
@@ -390,13 +399,37 @@
}, RISE_CADENCE_MS);
return () => clearInterval(timer);
});
// Track-change hold — the playbar's ARRIVE_HOLD_MS, mirrored: the
// outgoing bars stay up briefly so a quick arrival transitions
// shape→shape instead of dropping to the floor first. The hold renders in
// full-opacity muted (the new track owns the transport, nothing played);
// a slow load falls to the pending dots when the timer expires.
let lastRailBars = null;
let railHolding = $state(false);
let railHoldTimer = null;
$effect(() => {
if (bars.length > 0) {
lastRailBars = bars;
railHolding = false;
clearTimeout(railHoldTimer);
} else if (player.wavePending && lastRailBars?.length) {
railHolding = true;
clearTimeout(railHoldTimer);
railHoldTimer = setTimeout(() => (railHolding = false), ARRIVE_HOLD_MS);
}
});
$effect(() => () => clearTimeout(railHoldTimer));
function railBarHeight(v, i) {
if (wavePending || v == null) return 0;
if (v == null) return 0;
if (wavePending) return railHolding ? barHeight(v) : 0;
if (railRising && i >= railRevealed) return 0;
return barHeight(v);
}
function railBarDim(v, i) {
return wavePending || v == null || (railRising && i >= railRevealed);
if (v == null) return true;
if (wavePending) return !railHolding;
return railRising && i >= railRevealed;
}
// The arrival morph — the playbar's M2, in CSS: when a COMPLETE waveform
@@ -1103,7 +1136,7 @@
class="wave"
class:scrubbing={waveDragging}
class:pending={wavePending}
class:loading={wavePending && player.wavePending}
class:loading={wavePending && player.wavePending && !railHolding}
class:rising={railRising}
class:arriving={railArriving}
style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}"
+25 -1
View File
@@ -27,6 +27,7 @@
RISE_MS,
RISE_CADENCE_MS,
ARRIVE_MS,
ARRIVE_HOLD_MS,
PENDING_ALPHA,
} from "../lib/waveshape.js";
import Icon from "./Icon.svelte";
@@ -194,6 +195,10 @@
let lastDrawnHeights = null;
let morphFrom = null;
let morphStart = 0;
// Track-change hold: the outgoing shape stays painted for ARRIVE_HOLD_MS
// so a quick arrival morphs shape→shape instead of dipping through dots.
let hadPeaks = false;
let holdUntil = 0;
let animRaf = null;
function paintBar(ctx, x, mid, h, color) {
@@ -474,15 +479,34 @@
const now = performance.now();
// Nothing to draw yet: resting dots, with the swell crossing them
// while a waveform request is actually working.
// while a waveform request is actually working — but a TRACK CHANGE
// first holds the outgoing shape for ARRIVE_HOLD_MS, so when the next
// waveform lands quickly (local file, cached stream) the arrival
// morph runs one shape into the other instead of up from the dots.
// The hold paints in unplayed grey: the new track owns the transport
// now, and none of it has played.
if (!peaks || peaks.length === 0) {
riseKey = null;
morphFrom = null;
if (hadPeaks) {
holdUntil = now + ARRIVE_HOLD_MS;
hadPeaks = false;
}
if (player.wavePending && lastDrawnHeights?.length === count && now < holdUntil) {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < count; i++) {
paintBar(ctx, i * BAR_PITCH, mid, lastDrawnHeights[i], UNPLAYED);
}
lastPlayedBars = Number.NEGATIVE_INFINITY;
scheduleAnim(true);
return;
}
const moving = drawUnloaded(ctx, W, H, mid, count, now);
lastPlayedBars = Number.NEGATIVE_INFINITY;
scheduleAnim(moving);
return;
}
hadPeaks = true;
// 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
+6
View File
@@ -54,6 +54,12 @@ export const RISE_CADENCE_MS = 40; // gap between one bar starting and the next
* 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;
/** How long the OUTGOING waveform holds after a track change. A quick
* arrival — a local file, a cached stream — lands inside this window and
* morphs shape→shape, one waveform transforming into the other; a slow load
* falls to the dots-and-swell story when the hold expires. Without the hold,
* the cleared lane always morphed up from the dots. */
export const ARRIVE_HOLD_MS = 600;
/** 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;