player bar: the name sets the width, and the waveform stops flattering the clamp

The source chips are gone — the rail already tells that story — and with
them the measured slot, its effects, and the bar's only container query.
The now-playing cluster hugs the song name with no cap, giving ground only
once the scrubber hits its 260px floor; the artist's 55% ceiling applies
only when an album sits beside it, so a lone loading artist no longer
renders as two letters in a twelve-pixel box.

The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms
per bucket — at 400, every display bar was ~2s of audio averaged twice),
and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp
rule, which flat-topped 53.8% of the bars on a real decode and was propped
up by per-bar noise in the rail — deleted; the lane shows the record, not a
hash of the bar index. Bars fold with a mean+peak blend and stretch across
the track's own p05–p97, so a loud master stops pinning to the ceiling and
a quiet one stops vanishing.

The playbar copies the rail's fade verbatim: whole bars blend accent→grey
with cuts landing in the 4px gap, and the one bar under the playhead
crossfades toward whatever its neighbours are — the wash while hovering,
the muted grey otherwise. The redraw gate watches that blend instead of a
0.75px playhead move, so both lanes now step on every position event.

Loading is drawn, not implied: the solid fallback bar is gone and the lane
is always the bars, always seekable. Empty, it rests as the rail's dots; a
swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a
streaming track's bars stand up left→right on a 40ms cadence, each rising
over 2s. The rise frontier is the waveform's OWN coverage, reported by
stream_waveform beside the peaks — the old contract zero-padded partial
peaks to full length and let the UI fold them against a fresher byte
count, which stretched the whole shape between refreshes. Where the bars
end is how much of the track has actually loaded; cached and local tracks
arrive at coverage 1 and pop in complete. The first partial is also
requested eagerly (fresh refresh clock per track, 700ms retries until
peaks exist), closing most of the audio-before-lane gap.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:08:26 -05:00
1 parent 3aaec30dc6
commit b33d403634
9 files changed
+740 -323

No files matched your search

+102 -35
View File
@@ -32,6 +32,13 @@
import { floatScroll } from "../lib/scrollbar.js";
import { splitDescription } from "../lib/editions.js";
import { accentFromImage } from "../lib/color.js";
import {
BAR_PITCH,
BAR_W,
waveLevels,
RISE_CADENCE_MS,
PENDING_ALPHA,
} from "../lib/waveshape.js";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
@@ -308,37 +315,29 @@
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
// Average dense source peaks into equally sized display bars. Using an
// average (rather than picking one peak) stops isolated transients from
// making random bars hit the ceiling.
//
// The COUNT is derived from the panel's width so the lane reads at one
// density everywhere. It used to be a flat 78 bars on a 2px pitch — a
// 310px block that overflowed the 272px panel and left a third of the
// 560px one empty, which is precisely what looked wrong at both ends.
// One bar per BAR_PITCH px instead: a 2px bar on a 4px gap.
// Pitch 6 chosen off research/nowplaying-fans-waveform/mockup.html (W1).
const BAR_PITCH = 6;
const BAR_W = 2;
// One bar per BAR_PITCH px instead; the fold and shaping live in
// waveshape.js, shared with the player bar so the two lanes cannot drift.
// .np-scroll's horizontal padding, both sides — the lane is what is left.
const LANE_PAD = 28;
const BARS = $derived(
Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)),
);
const bars = $derived.by(() => {
const w = player.waveform;
if (!w || w.length === 0) return [];
const out = [];
for (let i = 0; i < BARS; i++) {
const from = Math.floor((i / BARS) * w.length);
const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length));
let sum = 0;
for (let j = from; j < to; j++) sum += w[j] ?? 0;
out.push(sum / (to - from));
}
return out;
});
const playedBars = $derived((playedPct / 100) * bars.length);
// The rise frontier is the WAVEFORM's own coverage — the audio actually
// decoded, reported by the backend with the peaks — never the live byte
// counter, whose fresher number stretched the drawn shape between
// refreshes. A partial waveform folds across exactly the covered fraction
// of the lane; the rest holds its place as pending dots (nulls below).
const railCoverage = $derived(Math.max(0, Math.min(1, player.waveformCoverage)));
const railCovered = $derived(Math.max(1, Math.floor(BARS * railCoverage)));
const bars = $derived.by(() =>
waveLevels(player.waveform, railCoverage < 1 ? railCovered : BARS),
);
// Bar i always maps to slice i/BARS of the whole track, covered or not.
const playedBars = $derived((playedPct / 100) * BARS);
/** The lane is 38px of the panel's vertical rhythm whether or not peaks
* have arrived yet. `player.waveform` is emptied the instant a track
@@ -351,18 +350,61 @@
* elements rather than replacing them. */
const FLAT_BARS = $derived(Array(BARS).fill(0));
const wavePending = $derived(bars.length === 0);
const waveBars = $derived(wavePending ? FLAT_BARS : bars);
const waveBars = $derived.by(() => {
if (wavePending) return FLAT_BARS;
if (bars.length >= BARS) return bars;
return [...bars, ...Array(BARS - bars.length).fill(null)];
});
function barHeight(value, index) {
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
// Stable per-bar grain keeps a dense loud passage from flattening
// into a ruler of identical maximum-height bars. It is deterministic
// (no visual flicker as the playhead moves) and only matters near the
// cap, where source peaks otherwise converge on the same value.
const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;
const peakFactor = Math.max(0, (capped - 0.58) / 0.3);
const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor));
return 14 + level * 72; // preserve headroom; never peak against the top
// Streaming rise — the playbar's S2, mirrored: bars stand up on the shared
// RISE_CADENCE_MS rhythm behind the download instead of at whatever rate
// bytes arrive; the CSS `rising` transition is the rail's half of RISE_MS.
// Reset when the queue item changes.
let railRevealed = $state(0);
let railRiseKey = null;
$effect(() => {
const key = currentItem ? `${currentItem.path ?? currentItem.title}` : null;
if (key !== railRiseKey) {
railRiseKey = key;
railRevealed = 0;
}
});
// Rising while the waveform is still partial, and until the queue stands
// the last chunk up. A cached replay or a local file arrives with
// coverage 1 on its FIRST decode and never rises — it pops in complete.
const railRising = $derived(
player.track?.source === "stream" &&
(railCoverage < 1 || (railRevealed > 0 && railRevealed < railCovered)),
);
const railRevealDone = $derived(!railRising || railRevealed >= railCovered);
$effect(() => {
if (railRevealDone) return;
const timer = setInterval(() => {
// Untracked reads: the interval IS the clock, not the reactivity.
// Played bars are never hidden behind the queue.
railRevealed = Math.min(
Math.max(railRevealed + 1, Math.ceil(playedBars)),
railCovered,
);
}, RISE_CADENCE_MS);
return () => clearInterval(timer);
});
function railBarHeight(v, i) {
if (wavePending || v == null) return 0;
if (railRising && i >= railRevealed) return 0;
return barHeight(v);
}
function railBarDim(v, i) {
return wavePending || v == null || (railRising && i >= railRevealed);
}
/* `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
only maps that level onto the rail's vertical rhythm: a 14% baseline,
86% ceiling — headroom above the bars is part of the panel's look. */
function barHeight(level) {
return 14 + level * 72;
}
function barFill(index) {
return Math.max(0, Math.min(1, playedBars - index));
@@ -1038,7 +1080,9 @@
class="wave"
class:scrubbing={waveDragging}
class:pending={wavePending}
style="--bar-gap: {BAR_PITCH - BAR_W}px"
class:loading={wavePending && player.wavePending}
class:rising={railRising}
style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}"
role="slider"
tabindex="0"
aria-label="Seek"
@@ -1053,7 +1097,9 @@
>
{#each waveBars as v, i}
<i
style="height:{wavePending ? 0 : barHeight(v, i)}%; --fill:{wavePending ? 0 : barFill(i)}"
style="--i:{i}; height:{railBarHeight(v, i)}%; --fill:{railBarDim(v, i)
? 0
: barFill(i)}; opacity:{railBarDim(v, i) ? PENDING_ALPHA : 1}"
></i>
{/each}
</div>
@@ -1911,6 +1957,27 @@
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
min-height: 2px;
}
/* Loading swell — the playbar's L2, phase-staggered per bar so one crest
travels the lane left→right; the negative delay starts the pattern
mid-flight instead of all-flat. Timing pairs with waveshape.js: 2000ms
sweep + 500ms rest = the 2500ms period, and the 30% crest is the 7px
playbar lift at the rail's 1.6× scale. CSS cannot read those constants —
change them together. */
.wave.loading i {
animation: rail-swell 2500ms linear infinite;
animation-delay: calc(-2500ms + 2000ms * var(--i) / var(--bars, 60));
}
@keyframes rail-swell {
0%, 26%, 100% { height: 2px; opacity: 0.45; }
13% { height: 30%; opacity: 0.85; }
}
/* Streaming rise — the playbar's S2: a released bar stands up over the
same RISE_MS (2000ms), on the house ease. */
.wave.rising i {
transition:
height 2000ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 400ms linear;
}
.times {
display: flex;
justify-content: space-between;