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,
|
BAR_W,
|
||||||
waveLevels,
|
waveLevels,
|
||||||
RISE_CADENCE_MS,
|
RISE_CADENCE_MS,
|
||||||
|
ARRIVE_MS,
|
||||||
PENDING_ALPHA,
|
PENDING_ALPHA,
|
||||||
} from "../lib/waveshape.js";
|
} from "../lib/waveshape.js";
|
||||||
|
|
||||||
@@ -398,6 +399,28 @@
|
|||||||
return wavePending || v == null || (railRising && i >= railRevealed);
|
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
|
/* `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
|
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
|
lane at one height and had to be propped up with per-bar noise). This
|
||||||
@@ -1082,6 +1105,7 @@
|
|||||||
class:pending={wavePending}
|
class:pending={wavePending}
|
||||||
class:loading={wavePending && player.wavePending}
|
class:loading={wavePending && player.wavePending}
|
||||||
class:rising={railRising}
|
class:rising={railRising}
|
||||||
|
class:arriving={railArriving}
|
||||||
style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}"
|
style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}"
|
||||||
role="slider"
|
role="slider"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -1978,6 +2002,15 @@
|
|||||||
height 2000ms cubic-bezier(0.22, 1, 0.36, 1),
|
height 2000ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
opacity 400ms linear;
|
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 {
|
.times {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
SWELL_WIDTH_BARS,
|
SWELL_WIDTH_BARS,
|
||||||
RISE_MS,
|
RISE_MS,
|
||||||
RISE_CADENCE_MS,
|
RISE_CADENCE_MS,
|
||||||
|
ARRIVE_MS,
|
||||||
PENDING_ALPHA,
|
PENDING_ALPHA,
|
||||||
} from "../lib/waveshape.js";
|
} from "../lib/waveshape.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
@@ -185,6 +186,14 @@
|
|||||||
let riseLevels = [];
|
let riseLevels = [];
|
||||||
let riseLevelsPeaks = null;
|
let riseLevelsPeaks = null;
|
||||||
let riseLevelsCount = 0;
|
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;
|
let animRaf = null;
|
||||||
|
|
||||||
function paintBar(ctx, x, mid, h, color) {
|
function paintBar(ctx, x, mid, h, color) {
|
||||||
@@ -217,6 +226,7 @@
|
|||||||
const period = SWELL_SWEEP_MS + SWELL_REST_MS;
|
const period = SWELL_SWEEP_MS + SWELL_REST_MS;
|
||||||
const u = (now % period) / SWELL_SWEEP_MS; // > 1 during the rest beat
|
const u = (now % period) / SWELL_SWEEP_MS; // > 1 during the rest beat
|
||||||
const centre = u * (count + SWELL_WIDTH_BARS * 2) - SWELL_WIDTH_BARS;
|
const centre = u * (count + SWELL_WIDTH_BARS * 2) - SWELL_WIDTH_BARS;
|
||||||
|
const heights = new Array(count);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
let env = 0;
|
let env = 0;
|
||||||
if (pending && u <= 1) {
|
if (pending && u <= 1) {
|
||||||
@@ -224,11 +234,25 @@
|
|||||||
env = Math.exp(-d * d);
|
env = Math.exp(-d * d);
|
||||||
}
|
}
|
||||||
const alpha = PENDING_ALPHA + (0.85 - PENDING_ALPHA) * env;
|
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;
|
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;
|
/** The streaming rise. Released bars ease from the floor to their height;
|
||||||
* queued and beyond-frontier bars are pending dots. The colour pass is
|
* queued and beyond-frontier bars are pending dots. The colour pass is
|
||||||
* the full painter's fade — accent, boundary blend, wash, muted — done
|
* the full painter's fade — accent, boundary blend, wash, muted — done
|
||||||
@@ -247,9 +271,11 @@
|
|||||||
const washing = hoverX > playX;
|
const washing = hoverX > playX;
|
||||||
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
|
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
|
||||||
const full = Math.floor(playedBars);
|
const full = Math.floor(playedBars);
|
||||||
|
const heights = new Array(count);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const x = i * BAR_PITCH;
|
const x = i * BAR_PITCH;
|
||||||
if (i >= riseReleased) {
|
if (i >= riseReleased) {
|
||||||
|
heights[i] = 2;
|
||||||
paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`);
|
paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -257,17 +283,10 @@
|
|||||||
const eased = 1 - Math.pow(1 - grow, 3);
|
const eased = 1 - Math.pow(1 - grow, 3);
|
||||||
const target = Math.max(2, (riseLevels[i] ?? 0.12) * H);
|
const target = Math.max(2, (riseLevels[i] ?? 0.12) * H);
|
||||||
const h = 2 + (target - 2) * eased;
|
const h = 2 + (target - 2) * eased;
|
||||||
let color;
|
heights[i] = h;
|
||||||
if (i < full) color = theme.accent;
|
paintBar(ctx, x, mid, h, barColor(i, full, playedBars, washing, washEndBar));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
lastDrawnHeights = heights;
|
||||||
if (hoverX >= 0) {
|
if (hoverX >= 0) {
|
||||||
const LINE_H = 18;
|
const LINE_H = 18;
|
||||||
ctx.fillStyle = theme.accentHover;
|
ctx.fillStyle = theme.accentHover;
|
||||||
@@ -458,6 +477,7 @@
|
|||||||
// while a waveform request is actually working.
|
// while a waveform request is actually working.
|
||||||
if (!peaks || peaks.length === 0) {
|
if (!peaks || peaks.length === 0) {
|
||||||
riseKey = null;
|
riseKey = null;
|
||||||
|
morphFrom = null;
|
||||||
const moving = drawUnloaded(ctx, W, H, mid, count, now);
|
const moving = drawUnloaded(ctx, W, H, mid, count, now);
|
||||||
lastPlayedBars = Number.NEGATIVE_INFINITY;
|
lastPlayedBars = Number.NEGATIVE_INFINITY;
|
||||||
scheduleAnim(moving);
|
scheduleAnim(moving);
|
||||||
@@ -512,6 +532,7 @@
|
|||||||
const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0;
|
const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0;
|
||||||
const growing = riseReleased > 0 && now - newest < RISE_MS;
|
const growing = riseReleased > 0 && now - newest < RISE_MS;
|
||||||
if (waveCov < 1 || riseReleased < count || growing) {
|
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);
|
drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow);
|
||||||
lastPlayedBars = playedBarsNow;
|
lastPlayedBars = playedBarsNow;
|
||||||
lastHoverDrawn = hoverX;
|
lastHoverDrawn = hoverX;
|
||||||
@@ -534,6 +555,7 @@
|
|||||||
wavePathWidth !== W ||
|
wavePathWidth !== W ||
|
||||||
wavePathHeight !== H;
|
wavePathHeight !== H;
|
||||||
if (geometryChanged) {
|
if (geometryChanged) {
|
||||||
|
const peaksSwapped = wavePathPeaks !== peaks;
|
||||||
// Same pitch AND same shaping as the Now Playing rail — both come
|
// 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
|
// from waveshape.js — so the two lanes in the window read as one
|
||||||
// instrument and neither can drift from the other. Mirrored about
|
// instrument and neither can drift from the other. Mirrored about
|
||||||
@@ -551,6 +573,15 @@
|
|||||||
wavePathPeaks = peaks;
|
wavePathPeaks = peaks;
|
||||||
wavePathWidth = W;
|
wavePathWidth = W;
|
||||||
wavePathHeight = H;
|
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
|
const playFrac = player.duration
|
||||||
@@ -558,6 +589,42 @@
|
|||||||
: 0;
|
: 0;
|
||||||
const playX = playFrac * W;
|
const playX = playFrac * W;
|
||||||
const playedBars = playFrac * waveCount;
|
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 =
|
const visualsChanged =
|
||||||
geometryChanged ||
|
geometryChanged ||
|
||||||
hoverX !== lastHoverDrawn ||
|
hoverX !== lastHoverDrawn ||
|
||||||
@@ -623,6 +690,7 @@
|
|||||||
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastDrawnHeights = waveHeights;
|
||||||
lastPlayedBars = playedBars;
|
lastPlayedBars = playedBars;
|
||||||
lastHoverDrawn = hoverX;
|
lastHoverDrawn = hoverX;
|
||||||
lastAccent = theme.accent;
|
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 SWELL_WIDTH_BARS = 54; // gaussian width of the crest
|
||||||
export const RISE_MS = 2000; // one bar, floor → full height
|
export const RISE_MS = 2000; // one bar, floor → full height
|
||||||
export const RISE_CADENCE_MS = 40; // gap between one bar starting and the next
|
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
|
/** Resting/queued bars render the muted grey at this opacity — the rail's
|
||||||
* pending dim, shared so the two lanes' dots match. */
|
* pending dim, shared so the two lanes' dots match. */
|
||||||
export const PENDING_ALPHA = 0.45;
|
export const PENDING_ALPHA = 0.45;
|
||||||
|
|||||||
Reference in new issue
Block a user