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:
1 parent
3aaec30dc6
commit
b33d403634
9 files changed
+740
-323
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user