diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 9901d79..7db3e81 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -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}" diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index 796aec2..608501a 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -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 diff --git a/src/lib/waveshape.js b/src/lib/waveshape.js index 6a1c583..169a494 100644 --- a/src/lib/waveshape.js +++ b/src/lib/waveshape.js @@ -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;