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;
|
||||
|
||||
+337
-269
@@ -16,18 +16,24 @@
|
||||
resumePlayback,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import {
|
||||
BAR_PITCH,
|
||||
BAR_W,
|
||||
waveLevels,
|
||||
SWELL_SWEEP_MS,
|
||||
SWELL_REST_MS,
|
||||
SWELL_AMP_PX,
|
||||
SWELL_WIDTH_BARS,
|
||||
RISE_MS,
|
||||
RISE_CADENCE_MS,
|
||||
PENDING_ALPHA,
|
||||
} from "../lib/waveshape.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
import SaveMenu from "./SaveMenu.svelte";
|
||||
|
||||
const pct = $derived(
|
||||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
||||
);
|
||||
const bufferPct = $derived(
|
||||
player.streamTotal > 0
|
||||
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
||||
: 0,
|
||||
);
|
||||
const hasWave = $derived(player.waveform.length > 0);
|
||||
const idle = $derived(player.status === "idle");
|
||||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||
// The native scratch engine needs a complete local file. A network stream
|
||||
@@ -59,101 +65,12 @@
|
||||
: "volume",
|
||||
);
|
||||
|
||||
// ---- the source line ---------------------------------------------------
|
||||
// Every value below already exists in state — these are the same fields
|
||||
// NowPlaying reads for its status line, so this needed no new command.
|
||||
// The derivations are deliberately identical to that component's, because
|
||||
// two surfaces disagreeing about whether a track is "cached" would be worse
|
||||
// than either of them being wrong.
|
||||
const isStream = $derived(!!currentItem?.stream);
|
||||
const quality = $derived(currentItem?.quality ?? null);
|
||||
/** Same set NowPlaying uses, so the green dot means the same thing here. */
|
||||
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
|
||||
const transferring = $derived(
|
||||
isStream && player.streamTotal > 0 && player.streamReceived < player.streamTotal,
|
||||
);
|
||||
const cachedStream = $derived(
|
||||
isStream && player.streamTotal > 0 && player.streamReceived >= player.streamTotal,
|
||||
);
|
||||
/** What the line is ABOUT, as opposed to what it currently reads. The slot
|
||||
* is re-measured when THIS changes and never when a percentage ticks. */
|
||||
const srcState = $derived(
|
||||
!isStream
|
||||
? "library"
|
||||
: cachedStream
|
||||
? "cached"
|
||||
: transferring
|
||||
? "transferring"
|
||||
: "connecting",
|
||||
);
|
||||
const peerName = $derived(currentItem?.stream?.username ?? null);
|
||||
const rateText = $derived(
|
||||
player.streamSpeed > 0 ? `${(player.streamSpeed / 1048576).toFixed(1)} MB/s` : null,
|
||||
);
|
||||
const etaText = $derived.by(() => {
|
||||
if (!transferring || player.streamSpeed <= 0) return null;
|
||||
const secs = (player.streamTotal - player.streamReceived) / player.streamSpeed;
|
||||
if (!isFinite(secs) || secs < 1) return null;
|
||||
return secs >= 60
|
||||
? `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s left`
|
||||
: `${Math.round(secs)}s left`;
|
||||
});
|
||||
const sizeText = (bytes) => (bytes ? `${(bytes / 1048576).toFixed(1)} MB` : null);
|
||||
const qualityChip = $derived.by(() => {
|
||||
if (!quality?.format) return null;
|
||||
return quality.bitrate
|
||||
? `${quality.format} ${Math.round(quality.bitrate)}k`
|
||||
: quality.format;
|
||||
});
|
||||
/** A library track reports NOTHING: it has no peer and no transfer, and
|
||||
* zeroes would read as a stalled one. Same call NowPlaying makes. */
|
||||
const sourceParts = $derived.by(() => {
|
||||
if (!isStream) return [];
|
||||
if (cachedStream) return ["Cached", sizeText(player.streamTotal)].filter(Boolean);
|
||||
if (transferring) {
|
||||
return [peerName, `${Math.round(bufferPct)}%`, rateText, etaText].filter(Boolean);
|
||||
}
|
||||
return peerName ? [`Streaming from ${peerName}`] : [];
|
||||
});
|
||||
|
||||
// The slot reserves a width rather than hugging its text: while a transfer
|
||||
// runs, the percentage, rate and ETA change every position event, and a
|
||||
// slot that tracked them would slide the transport and the whole scrubber
|
||||
// sideways continuously. It GROWS the instant the text needs more room and
|
||||
// SHRINKS only when the state changes, so nothing ever clips and nothing
|
||||
// twitches. Written straight to the node: routing it through $state would
|
||||
// make the effect that measures it depend on its own output.
|
||||
let srcSlotEl = $state(null);
|
||||
let srcLineEl = $state(null);
|
||||
const SLOT_CAP = 340;
|
||||
const SLOT_PAD_L = 10;
|
||||
const SLOT_PAD_R = 8;
|
||||
function sizeSourceSlot(reset = false) {
|
||||
if (!srcSlotEl) return;
|
||||
const natural = srcLineEl ? Math.ceil(srcLineEl.getBoundingClientRect().width) : 0;
|
||||
const want = natural ? Math.min(natural + SLOT_PAD_L + SLOT_PAD_R, SLOT_CAP) : 0;
|
||||
const current = parseFloat(srcSlotEl.style.width) || 0;
|
||||
if (!reset && want <= current) return;
|
||||
srcSlotEl.style.width = `${want}px`;
|
||||
srcSlotEl.style.paddingLeft = want ? `${SLOT_PAD_L}px` : "0px";
|
||||
srcSlotEl.style.paddingRight = want ? `${SLOT_PAD_R}px` : "0px";
|
||||
}
|
||||
$effect(() => {
|
||||
srcState;
|
||||
qualityChip;
|
||||
sizeSourceSlot(true);
|
||||
});
|
||||
$effect(() => {
|
||||
sourceParts;
|
||||
sizeSourceSlot(false);
|
||||
});
|
||||
// The slot's width is written imperatively, so a window resize — which can
|
||||
// cross the @container threshold that hides it — has to re-measure.
|
||||
$effect(() => {
|
||||
const onResize = () => sizeSourceSlot(true);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
});
|
||||
// The source line — peer, percentage, rate, ETA, size, format — used to
|
||||
// live here in a slot whose width was measured and written by hand. It is
|
||||
// gone: the Now Playing rail already carries every one of those fields, and
|
||||
// reserving room for them here made the transport's position a function of
|
||||
// how much a peer had sent. Nothing reads `player.streamSpeed`,
|
||||
// `streamTotal` or `currentItem.quality` in this component any more.
|
||||
|
||||
// ---- the overflow menu -------------------------------------------------
|
||||
let menuOpen = $state(false);
|
||||
@@ -215,13 +132,149 @@
|
||||
);
|
||||
|
||||
// Canvas colors; played/hover follow the adaptive accent theme.
|
||||
const UNPLAYED = "rgba(255,255,255,0.24)";
|
||||
/** The rail's unplayed colour — `--text-muted`, copied verbatim so both
|
||||
* lanes fade to the same grey. A canvas fill cannot use the CSS var;
|
||||
* if the token ever changes, change this with it. */
|
||||
const UNPLAYED = "#7a7a7a";
|
||||
const PREVIEW = "rgba(255,255,255,0.66)";
|
||||
/** Bar geometry, identical to NowPlaying's BAR_PITCH / BAR_W. */
|
||||
const BAR_PITCH = 6;
|
||||
const BAR_W = 2;
|
||||
const BAR_GAP = BAR_PITCH - BAR_W;
|
||||
|
||||
// The rail's `color-mix(in srgb, accent × fill%, --text-muted)` for the one
|
||||
// bar the playhead is inside. `theme.accent` is always "#rrggbb" (state's
|
||||
// rgbToHex emits nothing else), parsed once per accent value — it only
|
||||
// changes while a crossfade lerps.
|
||||
//
|
||||
// The bar's crossfade target must match its NEIGHBOURS, not a constant:
|
||||
// normally it fades accent → the rail's grey, but while the hover wash is
|
||||
// up, every bar to its right is wash-white — fading toward grey then left
|
||||
// one dark notch stranded between the accent and the wash. So under a wash
|
||||
// it fades accent → the wash's own resolved colour instead.
|
||||
const MUTED_RGB = [122, 122, 122];
|
||||
/** PREVIEW (66% white) composited over an UNPLAYED bar, precomputed:
|
||||
* 0.66·255 + 0.34·122 — what a washed bar actually renders as. */
|
||||
const WASHED_RGB = [210, 210, 210];
|
||||
let blendParsedFor = "";
|
||||
let blendRgb = MUTED_RGB;
|
||||
function blendAccent(accent, fill, towardWash) {
|
||||
if (accent !== blendParsedFor) {
|
||||
const n = parseInt(accent.slice(1), 16);
|
||||
blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
blendParsedFor = accent;
|
||||
}
|
||||
const toward = towardWash ? WASHED_RGB : MUTED_RGB;
|
||||
const t = 1 - fill;
|
||||
const r = Math.round(blendRgb[0] + (toward[0] - blendRgb[0]) * t);
|
||||
const g = Math.round(blendRgb[1] + (toward[1] - blendRgb[1]) * t);
|
||||
const b = Math.round(blendRgb[2] + (toward[2] - blendRgb[2]) * t);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
// ---- loading / streaming motion ---------------------------------------
|
||||
// The lane is ALWAYS the bar waveform now. With nothing to show it rests
|
||||
// as dim dots (the rail's pending look, mirrored); while a waveform is
|
||||
// being computed the loading SWELL crosses those dots; while a stream
|
||||
// downloads, bars stand up left→right on a fixed rhythm (the RISE) —
|
||||
// released one per RISE_CADENCE_MS behind the transfer, each easing up
|
||||
// over RISE_MS, so a fast peer cannot machine-gun the lane. Numbers and
|
||||
// provenance live in waveshape.js. The solid fallback track this replaces
|
||||
// also carried the buffer fill; that story is the rise frontier now.
|
||||
let riseKey = null; // which queue item the rise bookkeeping belongs to
|
||||
let riseReleased = 0; // bars allowed to stand so far
|
||||
let riseLastRelease = 0; // clock of the newest release, for the cadence
|
||||
let riseBorn = []; // per-bar release clock, for the ease
|
||||
let riseLevels = [];
|
||||
let riseLevelsPeaks = null;
|
||||
let riseLevelsCount = 0;
|
||||
let animRaf = null;
|
||||
|
||||
function paintBar(ctx, x, mid, h, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, mid - h / 2, BAR_W, h, 1);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/** One frame while the lane animates; re-arms itself from drawWave as long
|
||||
* as the state it painted still moves. Position events also repaint, but
|
||||
* they arrive ~5/s — the swell and the rise need real frames. */
|
||||
function scheduleAnim(active) {
|
||||
if (!active || animRaf !== null || !windowActive) return;
|
||||
animRaf = requestAnimationFrame(() => {
|
||||
animRaf = null;
|
||||
drawWave(true);
|
||||
});
|
||||
}
|
||||
$effect(() => () => {
|
||||
if (animRaf !== null) cancelAnimationFrame(animRaf);
|
||||
});
|
||||
|
||||
/** Resting dots, with the loading swell crossing them while a waveform is
|
||||
* actually being computed (`player.wavePending`). No accent here — accent
|
||||
* means played, and this lane has nothing to play yet. */
|
||||
function drawUnloaded(ctx, W, H, mid, count, now) {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const pending = player.wavePending;
|
||||
const period = SWELL_SWEEP_MS + SWELL_REST_MS;
|
||||
const u = (now % period) / SWELL_SWEEP_MS; // > 1 during the rest beat
|
||||
const centre = u * (count + SWELL_WIDTH_BARS * 2) - SWELL_WIDTH_BARS;
|
||||
for (let i = 0; i < count; i++) {
|
||||
let env = 0;
|
||||
if (pending && u <= 1) {
|
||||
const d = (i - centre) / (SWELL_WIDTH_BARS * 0.55);
|
||||
env = Math.exp(-d * d);
|
||||
}
|
||||
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})`);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The streaming rise. Released bars ease from the floor to their height;
|
||||
* queued and beyond-frontier bars are pending dots. The colour pass is
|
||||
* the full painter's fade — accent, boundary blend, wash, muted — done
|
||||
* per bar because the heights differ every frame while bars grow. The
|
||||
* partial waveform describes only the audio that has ARRIVED, so it is
|
||||
* folded across the covered fraction of the lane, not the whole width. */
|
||||
function drawRise(ctx, W, H, mid, count, now, frontier, playFrac, playedBars) {
|
||||
const coveredBars = Math.max(1, frontier);
|
||||
if (riseLevelsPeaks !== player.waveform || riseLevelsCount !== coveredBars) {
|
||||
riseLevels = waveLevels(player.waveform, coveredBars);
|
||||
riseLevelsPeaks = player.waveform;
|
||||
riseLevelsCount = coveredBars;
|
||||
}
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const playX = playFrac * W;
|
||||
const washing = hoverX > playX;
|
||||
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
|
||||
const full = Math.floor(playedBars);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = i * BAR_PITCH;
|
||||
if (i >= riseReleased) {
|
||||
paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`);
|
||||
continue;
|
||||
}
|
||||
const grow = Math.min(1, (now - (riseBorn[i] ?? now)) / RISE_MS);
|
||||
const eased = 1 - Math.pow(1 - grow, 3);
|
||||
const target = Math.max(2, (riseLevels[i] ?? 0.12) * H);
|
||||
const h = 2 + (target - 2) * eased;
|
||||
let color;
|
||||
if (i < full) color = theme.accent;
|
||||
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);
|
||||
}
|
||||
if (hoverX >= 0) {
|
||||
const LINE_H = 18;
|
||||
ctx.fillStyle = theme.accentHover;
|
||||
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(secs) {
|
||||
if (secs == null || !isFinite(secs)) return "–:––";
|
||||
const s = Math.max(0, Math.floor(secs));
|
||||
@@ -355,13 +408,15 @@
|
||||
}
|
||||
|
||||
// Wave geometry is invariant while a track and player-bar size stay the
|
||||
// same. Cache it instead of rebuilding ~1,600 line segments and resetting
|
||||
// the canvas backing store on every 60 ms position event.
|
||||
let wavePath = null;
|
||||
// same. Cache the shaped bars instead of refolding 2000 peaks and
|
||||
// resetting the canvas backing store on every 60 ms position event.
|
||||
let wavePath = null; // one Path2D of every bar, for the grouped fills
|
||||
let waveHeights = []; // per-bar heights, for the one bar the playhead is in
|
||||
let waveCount = 0;
|
||||
let wavePathPeaks = null;
|
||||
let wavePathWidth = 0;
|
||||
let wavePathHeight = 0;
|
||||
let lastPlayX = Number.NEGATIVE_INFINITY;
|
||||
let lastPlayedBars = Number.NEGATIVE_INFINITY;
|
||||
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
|
||||
let lastAccent = "";
|
||||
let lastAccentHover = "";
|
||||
@@ -378,7 +433,7 @@
|
||||
const canvas = waveCanvas;
|
||||
const wrap = waveWrap;
|
||||
const peaks = player.waveform;
|
||||
if (!canvas || !wrap || !peaks || peaks.length === 0) return;
|
||||
if (!canvas || !wrap) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = wrap.clientWidth;
|
||||
@@ -395,52 +450,130 @@
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const N = peaks.length;
|
||||
const mid = H / 2;
|
||||
const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
|
||||
const now = performance.now();
|
||||
|
||||
// Nothing to draw yet: resting dots, with the swell crossing them
|
||||
// while a waveform request is actually working.
|
||||
if (!peaks || peaks.length === 0) {
|
||||
riseKey = null;
|
||||
const moving = drawUnloaded(ctx, W, H, mid, count, now);
|
||||
lastPlayedBars = Number.NEGATIVE_INFINITY;
|
||||
scheduleAnim(moving);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
// frontier is the WAVEFORM'S OWN coverage — the audio that has
|
||||
// actually been decoded, reported by the backend with the peaks —
|
||||
// never the live byte counter, whose fresher number stretched the
|
||||
// drawn shape between refreshes. Where the bars end IS how much of
|
||||
// the track has loaded: the edge parks when it catches the download
|
||||
// and marches again when the next chunk lands. A track whose FIRST
|
||||
// decode already covers everything — a cached replay, a local file —
|
||||
// never rises at all; it pops in complete.
|
||||
const waveCov = Math.max(0, Math.min(1, player.waveformCoverage));
|
||||
const riseCandidate = player.track?.source === "stream";
|
||||
const riseTrackKey = currentItem?.path ?? player.track?.title ?? "?";
|
||||
if (riseCandidate && (waveCov < 1 || riseKey === riseTrackKey)) {
|
||||
if (riseKey !== riseTrackKey) {
|
||||
riseKey = riseTrackKey;
|
||||
riseReleased = 0;
|
||||
riseLastRelease = now;
|
||||
riseBorn = [];
|
||||
}
|
||||
const frontier = Math.floor(waveCov * count);
|
||||
let guard = 0;
|
||||
while (
|
||||
riseReleased < frontier &&
|
||||
now - riseLastRelease >= RISE_CADENCE_MS &&
|
||||
guard++ < 600
|
||||
) {
|
||||
riseBorn[riseReleased] = now;
|
||||
riseReleased += 1;
|
||||
riseLastRelease += RISE_CADENCE_MS;
|
||||
}
|
||||
// An idle cadence clock would otherwise bank a backlog and dump it
|
||||
// all at once when the next coverage arrives.
|
||||
if (riseReleased >= frontier) {
|
||||
riseLastRelease = Math.max(riseLastRelease, now - RISE_CADENCE_MS);
|
||||
}
|
||||
const playFracNow = player.duration
|
||||
? Math.max(0, Math.min(1, player.position / player.duration))
|
||||
: 0;
|
||||
const playedBarsNow = playFracNow * count;
|
||||
// Played audio is never hidden behind the queue.
|
||||
for (let i = riseReleased; i <= Math.min(Math.floor(playedBarsNow), frontier - 1); i++) {
|
||||
riseBorn[i] = now - RISE_MS;
|
||||
riseReleased = i + 1;
|
||||
}
|
||||
const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0;
|
||||
const growing = riseReleased > 0 && now - newest < RISE_MS;
|
||||
if (waveCov < 1 || riseReleased < count || growing) {
|
||||
drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow);
|
||||
lastPlayedBars = playedBarsNow;
|
||||
lastHoverDrawn = hoverX;
|
||||
lastAccent = theme.accent;
|
||||
lastAccentHover = theme.accentHover;
|
||||
lastDuration = player.duration;
|
||||
scheduleAnim(growing || riseReleased < frontier);
|
||||
return;
|
||||
}
|
||||
// Fully covered, fully grown: hand the lane back to the cached
|
||||
// painter for good.
|
||||
riseKey = null;
|
||||
} else if (!riseCandidate) {
|
||||
riseKey = null;
|
||||
}
|
||||
|
||||
const geometryChanged =
|
||||
resized ||
|
||||
wavePathPeaks !== peaks ||
|
||||
wavePathWidth !== W ||
|
||||
wavePathHeight !== H;
|
||||
if (geometryChanged) {
|
||||
// Discrete bars on the SAME pitch the Now Playing rail draws — a
|
||||
// 2px bar on a 4px gap, 1px radius — so the two waveforms in the
|
||||
// window read as one instrument rather than two. Both derive their
|
||||
// bar count from that pitch, so neither can drift from the other.
|
||||
// Mirrored about the centre line here, where the lane is short and
|
||||
// wide; the rail stands its bars on the floor because it sits above
|
||||
// a caption.
|
||||
const path = new Path2D();
|
||||
// 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
|
||||
// instrument and neither can drift from the other. Mirrored about
|
||||
// the centre line here, where the lane is short and wide; the rail
|
||||
// stands its bars on the floor because it sits above a caption.
|
||||
const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
|
||||
const levels = waveLevels(peaks, count);
|
||||
const path = new Path2D();
|
||||
waveHeights = levels.map((level) => Math.max(2, level * H));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const from = Math.floor((i / count) * N);
|
||||
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * N));
|
||||
let sum = 0;
|
||||
for (let j = from; j < to; j++) sum += peaks[j] ?? 0;
|
||||
// The rail's shaping, verbatim: capped so a loud passage cannot
|
||||
// flatten into a ruler, then gamma'd back up.
|
||||
const v = sum / (to - from);
|
||||
const h = Math.max(2, Math.pow(Math.min(0.88, v * 1.18), 0.8) * H);
|
||||
path.roundRect(i * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
|
||||
path.roundRect(i * BAR_PITCH, mid - waveHeights[i] / 2, BAR_W, waveHeights[i], 1);
|
||||
}
|
||||
wavePath = path;
|
||||
waveCount = count;
|
||||
wavePathPeaks = peaks;
|
||||
wavePathWidth = W;
|
||||
wavePathHeight = H;
|
||||
}
|
||||
|
||||
const playX = player.duration
|
||||
? Math.max(0, Math.min(W, (player.position / player.duration) * W))
|
||||
const playFrac = player.duration
|
||||
? Math.max(0, Math.min(1, player.position / player.duration))
|
||||
: 0;
|
||||
const playX = playFrac * W;
|
||||
const playedBars = playFrac * waveCount;
|
||||
const visualsChanged =
|
||||
geometryChanged ||
|
||||
hoverX !== lastHoverDrawn ||
|
||||
theme.accent !== lastAccent ||
|
||||
theme.accentHover !== lastAccentHover ||
|
||||
player.duration !== lastDuration;
|
||||
// A sub-device-pixel playhead move cannot materially change the image.
|
||||
// Keep exact redraws for hover, resize, theme, seeks, and short tracks.
|
||||
if (!force && !visualsChanged && Math.abs(playX - lastPlayX) < 0.75 / dpr) return;
|
||||
// The playhead's visible quantum is the boundary bar's BLEND, not a
|
||||
// clip edge: the fills cut on bar boundaries, so the only thing a
|
||||
// small move changes is that one bar's colour. The old skip here — no
|
||||
// redraw under 0.75px of playhead travel — was written for the
|
||||
// continuous fill and quantized that crossfade into 12% jumps at
|
||||
// ~200ms, visibly chunkier than the rail blending the same amount
|
||||
// every position event. Redraw when the blend has moved ≥2% of a bar
|
||||
// (every event on a normal lane) and still skip the no-op repaints of
|
||||
// a paused player.
|
||||
if (!force && !visualsChanged && Math.abs(playedBars - lastPlayedBars) < 0.02) return;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const fillClip = (x0, x1, color) => {
|
||||
@@ -454,11 +587,31 @@
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
fillClip(0, W, UNPLAYED);
|
||||
if (hoverX > playX) fillClip(playX, hoverX, PREVIEW);
|
||||
// Match the primary Play button and the sidebar's completed bars.
|
||||
// The rail's fade, copied: a bar is one colour, blended from accent to
|
||||
// the rail's grey by how far the playhead is through it. Fully played
|
||||
// and unplayed regions clip on bar BOUNDARIES — the cut lands in the
|
||||
// 4px gap — and only the single bar the playhead is inside gets the
|
||||
// mixed fill, so no bar is ever chopped down its middle. The accent
|
||||
// matches the primary Play button and the sidebar's completed bars;
|
||||
// `accentHover` remains reserved for hover previews and controls.
|
||||
fillClip(0, playX, theme.accent);
|
||||
const full = Math.floor(playedBars);
|
||||
const washing = hoverX > playX;
|
||||
fillClip(0, W, UNPLAYED);
|
||||
if (washing) {
|
||||
// Bar-quantized for the same reason: the wash recolours whole bars
|
||||
// between the playhead's bar and the cursor, never a sliver.
|
||||
const washEnd = (Math.floor(hoverX / BAR_PITCH) + 1) * BAR_PITCH;
|
||||
fillClip((full + 1) * BAR_PITCH, washEnd, PREVIEW);
|
||||
}
|
||||
fillClip(0, full * BAR_PITCH, theme.accent);
|
||||
const frac = playedBars - full;
|
||||
if (frac > 0 && full < waveCount) {
|
||||
const h = waveHeights[full];
|
||||
ctx.fillStyle = blendAccent(theme.accent, frac, washing);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// No standing playhead rule any more: at this pitch the accent's own
|
||||
// leading edge marks the position, and a white line through the bars
|
||||
@@ -470,7 +623,7 @@
|
||||
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
||||
}
|
||||
|
||||
lastPlayX = playX;
|
||||
lastPlayedBars = playedBars;
|
||||
lastHoverDrawn = hoverX;
|
||||
lastAccent = theme.accent;
|
||||
lastAccentHover = theme.accentHover;
|
||||
@@ -541,27 +694,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Who is sending this, and how far along. Its OWN slot in the bar, not a
|
||||
child of `.now-playing`: sharing that budget meant a long transfer line
|
||||
("peer · 50% · 0.3 MB/s · 51s left") crowded the artist and album down
|
||||
to "Po… · Terrestria…". The two now have independent room, and this one
|
||||
is what the container query drops when the bar gets tight. Absent
|
||||
entirely for a library track — see `sourceParts`. -->
|
||||
{#if sourceParts.length || qualityChip}
|
||||
<div class="src-slot" bind:this={srcSlotEl}>
|
||||
<div class="src-line" bind:this={srcLineEl}>
|
||||
{#each sourceParts as part, i}
|
||||
{#if i > 0}<span class="sep">·</span>{/if}
|
||||
<span class:peer={i === 0 && srcState === "transferring"}>{part}</span>
|
||||
{/each}
|
||||
{#if qualityChip}
|
||||
{#if sourceParts.length}<span class="sep">·</span>{/if}
|
||||
<span class="qchip" class:loss={LOSSLESS.has(quality?.format)}>{qualityChip}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="ctl small"
|
||||
@@ -592,46 +724,31 @@
|
||||
</div>
|
||||
<div class="scrubber">
|
||||
<span class="time">{fmt(player.position)}</span>
|
||||
{#if hasWave}
|
||||
<div
|
||||
class="wave-wrap"
|
||||
bind:this={waveWrap}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
tabindex="0"
|
||||
onmousemove={onWaveHover}
|
||||
onmouseleave={() => (hoverX = -1)}
|
||||
class:dragging={draggingSeek}
|
||||
onpointerdown={(e) => startScrub(e, waveWrap)}
|
||||
onpointermove={(e) => moveScrub(e, waveWrap)}
|
||||
onpointerup={(e) => endScrub(e, waveWrap)}
|
||||
onpointercancel={(e) => endScrub(e, waveWrap)}
|
||||
>
|
||||
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
|
||||
{#if hoverX >= 0}
|
||||
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="track"
|
||||
class:dragging={draggingSeek}
|
||||
onpointerdown={(e) => startScrub(e, e.currentTarget)}
|
||||
onpointermove={(e) => moveScrub(e, e.currentTarget)}
|
||||
onpointerup={(e) => endScrub(e, e.currentTarget)}
|
||||
onpointercancel={(e) => endScrub(e, e.currentTarget)}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
tabindex="0"
|
||||
>
|
||||
{#if bufferPct > 0 && player.track?.source === "stream"}
|
||||
<div class="buffer" style="width: {bufferPct}%"></div>
|
||||
{/if}
|
||||
<div class="fill" style="width: {pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- ALWAYS the bar lane — the solid fallback track is gone. With no
|
||||
waveform the canvas rests as dots (swelling while one loads,
|
||||
standing up as a stream arrives), and it stays a seek surface
|
||||
the whole time: same handlers in every state. The tooltip
|
||||
needs a duration to have anything truthful to say. -->
|
||||
<div
|
||||
class="wave-wrap"
|
||||
bind:this={waveWrap}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
tabindex="0"
|
||||
onmousemove={onWaveHover}
|
||||
onmouseleave={() => (hoverX = -1)}
|
||||
class:dragging={draggingSeek}
|
||||
onpointerdown={(e) => startScrub(e, waveWrap)}
|
||||
onpointermove={(e) => moveScrub(e, waveWrap)}
|
||||
onpointerup={(e) => endScrub(e, waveWrap)}
|
||||
onpointercancel={(e) => endScrub(e, waveWrap)}
|
||||
>
|
||||
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
|
||||
{#if hoverX >= 0 && player.duration}
|
||||
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="time">{fmt(player.duration)}</span>
|
||||
</div>
|
||||
|
||||
@@ -701,9 +818,10 @@
|
||||
at 560px while the flanks stretch, which is what left a wide window with
|
||||
a small waveform adrift in the middle.
|
||||
|
||||
`container-type` is for the shelf and the source slot below. This element
|
||||
spans all three grid columns, so its inline size IS the window's usable
|
||||
width — no sidebar or rail sits inside it. */
|
||||
Nothing here is width-queried any more — the source slot was the only
|
||||
container query, and it is gone — so there is no `container-type`. The
|
||||
row fills its grid area at every window size; the scrubber absorbs
|
||||
whatever the name, transport and extras do not take. */
|
||||
.player {
|
||||
grid-area: player;
|
||||
display: flex;
|
||||
@@ -716,7 +834,6 @@
|
||||
padding: 0 12px;
|
||||
background: var(--bg-player);
|
||||
border-radius: var(--radius);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.now-playing {
|
||||
@@ -724,12 +841,14 @@
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 170px;
|
||||
/* Capped, or a long title takes the bar: an unbounded cluster shrank
|
||||
the waveform to 106px on "A Very Long Track Title…". The lane is the
|
||||
point of this bar, so the title ellipsises before the lane starves.
|
||||
This is now the meta ALONE — the source line has its own slot — so
|
||||
the budget no longer moves when a transfer line grows. */
|
||||
max-width: min(32%, 420px);
|
||||
/* The song name sets this width, uncapped. `0 1 auto` means the cluster
|
||||
sits at its natural content width and gives ground ONLY once the
|
||||
scrubber has reached its 260px floor — at which point the title
|
||||
ellipsises, and not before. Chosen off
|
||||
`research/playbar-redesign/width-rule.html` against a capped variant:
|
||||
the cap bounded how far the transport travelled between tracks (525px
|
||||
vs 597px on the longest title in that set) at the cost of truncating
|
||||
names that had room to be read. The name won. */
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
@@ -787,7 +906,15 @@
|
||||
.who .sub {
|
||||
min-width: 0;
|
||||
}
|
||||
.who .sub:not(.album) {
|
||||
/* ...but ONLY when there is an album to give way to. `55%` resolves against
|
||||
`.who`, whose own width is shrink-to-fit of its children — so when the
|
||||
artist is the only child, the cap is 55% of the artist's own width and it
|
||||
eats itself: "M83" rendered as "M…" in a 12px box. That is exactly the
|
||||
state the bar is in while a track loads, where the buffering branch puts
|
||||
one child in this line. Measured, not reasoned: 23px of text in a 12px
|
||||
box, with and without the old cluster cap, so this predates that change.
|
||||
Alone, it keeps the default `0 1 auto` and ellipsises like anything else. */
|
||||
.who .sub:not(.album):not(:only-child) {
|
||||
flex: 0 0 auto;
|
||||
max-width: 55%;
|
||||
}
|
||||
@@ -837,52 +964,6 @@
|
||||
}
|
||||
.sub.album.link:hover { color: var(--text-primary); }
|
||||
|
||||
/* The source line. Width is written by sizeSourceSlot(), never by the
|
||||
content: while a transfer runs its text changes on every position event,
|
||||
and a slot that hugged it would slide the transport and the scrubber
|
||||
sideways continuously. Border-box is global, so that width INCLUDES the
|
||||
padding — otherwise the padding eats the content and clips the last chip. */
|
||||
.src-slot {
|
||||
flex: 0 0 auto;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 0.34s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
padding 0.34s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.src-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: max-content;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
/* So a ticking percentage and a counting-down ETA do not reflow. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.src-line .sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.src-line .peer {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.qchip {
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
padding: 1.5px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qchip.loss {
|
||||
color: #7ee08a;
|
||||
background: rgba(80, 200, 110, 0.12);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -988,13 +1069,6 @@
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.buffer {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: hsla(0, 0%, 100%, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Always the accent — matching the waveform scrubber's played region
|
||||
(user decision 2026-07-23; the old white-until-hover read as off). */
|
||||
.fill {
|
||||
@@ -1099,12 +1173,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* What gives way as the window narrows, ordered by how much is lost. The
|
||||
source line goes first — it is enrichment, and the lane is not. */
|
||||
@container (max-width: 1180px) {
|
||||
.src-slot { display: none; }
|
||||
}
|
||||
|
||||
.vol-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
+62
-8
@@ -3391,6 +3391,7 @@ export async function refreshSourcePrefs() {
|
||||
// settings unavailable (fresh install / backend restart) → defaults
|
||||
}
|
||||
}
|
||||
|
||||
void refreshSourcePrefs();
|
||||
|
||||
/** Whether a peer file satisfies the user's quality window for this mode.
|
||||
@@ -4597,6 +4598,16 @@ export const player = $state({
|
||||
repeat: "off", // off | all | one
|
||||
// waveform peaks (0..1) for the current local track, [] while loading
|
||||
waveform: [],
|
||||
// How much of the TRACK `waveform` actually describes, 0..1 — reported by
|
||||
// the backend with the peaks themselves, so the two can never disagree.
|
||||
// 1 for local files and complete streams; a mid-transfer decode covers
|
||||
// exactly this prefix and the lanes fold it across exactly this fraction.
|
||||
// Where the standing bars end IS how much of the track has loaded.
|
||||
waveformCoverage: 1,
|
||||
// True while a waveform request for the CURRENT track is still working —
|
||||
// what separates "computing, show the loading swell" from "there is no
|
||||
// waveform, rest as dots". Cleared on success and on final failure.
|
||||
wavePending: false,
|
||||
// Which catalog entity is playing (drives now-playing badges).
|
||||
catalogRef: { albumId: null, trackId: null },
|
||||
// True for a track restored from the last session whose AUDIO did not
|
||||
@@ -4999,7 +5010,12 @@ export function initEvents() {
|
||||
} else if (
|
||||
ui.windowActive
|
||||
&& cur?.stream
|
||||
&& performance.now() - waveRefreshAt > 3000
|
||||
// Until the FIRST peaks land, retry fast — the earliest decodes
|
||||
// race the transfer's opening seconds and often see too little
|
||||
// file to succeed, and audio audibly playing over a still-empty
|
||||
// lane was the gap being closed here. Once bars exist, the
|
||||
// geometric decode thresholds make 3s the right cadence.
|
||||
&& performance.now() - waveRefreshAt > (player.waveform.length ? 3000 : 700)
|
||||
) {
|
||||
// Still downloading: refresh the partial waveform so it grows
|
||||
// with the transfer.
|
||||
@@ -5207,6 +5223,10 @@ function playQueueIndex(i) {
|
||||
// Remote track streamed from a peer while it downloads to cache.
|
||||
player.streamReceived = 0;
|
||||
player.streamTotal = item.stream.size;
|
||||
// A fresh track must not inherit the previous one's refresh clock —
|
||||
// that throttled the FIRST partial waveform by up to three seconds of
|
||||
// audible-but-laneless playback.
|
||||
waveRefreshAt = 0;
|
||||
resetStreamRate();
|
||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||
appendedNextPath = null;
|
||||
@@ -5453,21 +5473,35 @@ async function maybeExtendAlbumQueue() {
|
||||
|
||||
// ---- waveform ----
|
||||
|
||||
const WAVE_BUCKETS = 400;
|
||||
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
|
||||
// by the time the lanes folded buckets into display bars every bar was ~2s
|
||||
// averaged twice, and the transients the lane exists to show were gone before
|
||||
// shaping ever ran (measured in waveshape.js's header). 2000 is ~128ms per
|
||||
// bucket on a typical track — finer than any lane's bar pitch — and the
|
||||
// command's ceiling is 4096, so this is only a constant.
|
||||
const WAVE_BUCKETS = 2000;
|
||||
let waveReqPath = null;
|
||||
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
||||
let waveRefreshAt = 0;
|
||||
|
||||
async function loadWaveform(path) {
|
||||
player.waveform = [];
|
||||
player.wavePending = !!path;
|
||||
waveReqPath = path;
|
||||
if (!path) return;
|
||||
try {
|
||||
const peaks = await api.waveform(path, WAVE_BUCKETS);
|
||||
// Ignore if the track changed while we were computing.
|
||||
if (waveReqPath === path) player.waveform = peaks;
|
||||
if (waveReqPath === path) {
|
||||
player.waveform = peaks;
|
||||
player.waveformCoverage = 1;
|
||||
}
|
||||
} catch {
|
||||
if (waveReqPath === path) player.waveform = [];
|
||||
} finally {
|
||||
// Either way the request is over; a failed local decode rests as dots
|
||||
// rather than swelling forever at a waveform that will never come.
|
||||
if (waveReqPath === path) player.wavePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5480,7 +5514,13 @@ let queuedStreamWave = null;
|
||||
|
||||
async function loadStreamWaveform(item, final = false) {
|
||||
const key = `stream:${item.stream.username}|${item.path}`;
|
||||
if (waveReqPath !== key) player.waveform = [];
|
||||
if (waveReqPath !== key) {
|
||||
player.waveform = [];
|
||||
// Pending until the FIRST partial decode lands; after that the lane
|
||||
// has bars and the streaming rise owns the story. A mid-stream refresh
|
||||
// that fails just keeps the bars it already has.
|
||||
player.wavePending = true;
|
||||
}
|
||||
waveReqPath = key;
|
||||
// A late partial FLAC decode can take close to the three-second refresh
|
||||
// interval. Coalesce all overlap into the newest request, retaining a
|
||||
@@ -5498,7 +5538,12 @@ async function loadStreamWaveform(item, final = false) {
|
||||
next = null;
|
||||
const seq = ++waveSeq;
|
||||
try {
|
||||
const peaks = await api.streamWaveform(
|
||||
// { coverage, peaks }: the backend reports how much of the
|
||||
// track the peaks describe, measured from the SAME file read
|
||||
// that produced them — the lanes' rise frontier is anchored to
|
||||
// this, never to the live byte counter, so the drawn shape
|
||||
// cannot stretch between refreshes.
|
||||
const slice = await api.streamWaveform(
|
||||
request.item.stream.username,
|
||||
request.item.path,
|
||||
request.item.stream.size,
|
||||
@@ -5507,12 +5552,19 @@ async function loadStreamWaveform(item, final = false) {
|
||||
if (
|
||||
waveReqPath === request.key
|
||||
&& seq === waveSeq
|
||||
&& peaks.length > 0
|
||||
&& slice.peaks.length > 0
|
||||
) {
|
||||
player.waveform = peaks;
|
||||
player.waveform = slice.peaks;
|
||||
player.waveformCoverage = Math.max(0, Math.min(1, slice.coverage));
|
||||
player.wavePending = false;
|
||||
}
|
||||
} catch {
|
||||
// stays empty — the buffer/progress bar renders instead
|
||||
// Peaks stay as they were. A failed FINAL decode is the end of
|
||||
// the line though — stop the loading swell so the lane rests
|
||||
// as dots instead of promising a waveform that will not come.
|
||||
if (request.final && waveReqPath === request.key) {
|
||||
player.wavePending = false;
|
||||
}
|
||||
}
|
||||
const queued = queuedStreamWave;
|
||||
queuedStreamWave = null;
|
||||
@@ -5598,6 +5650,8 @@ function stopPlayback() {
|
||||
player.queue = [];
|
||||
player.queueIndex = -1;
|
||||
player.waveform = [];
|
||||
player.waveformCoverage = 1;
|
||||
player.wavePending = false;
|
||||
player.catalogRef = { albumId: null, trackId: null };
|
||||
latestPlayerPosition = 0;
|
||||
latestPlayerDuration = null;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// The ONE waveform shaping, shared by the player bar's canvas and the Now
|
||||
// Playing rail's DOM bars. Both fold the same source array with the same
|
||||
// statistic and stretch it by the same rule, so the two lanes in the window
|
||||
// are the same instrument — change a number here and both surfaces move.
|
||||
//
|
||||
// Why this exists (measured, 2026-08-03, on a real decode of "Only Shallow" —
|
||||
// 27.7MB FLAC, 11,093 RMS windows; the harness is
|
||||
// `bucketing_strategies_on_a_real_track` in crates/player/src/waveform.rs and
|
||||
// the approved before/after is research/playbar-redesign/waveform-shaping.html):
|
||||
//
|
||||
// The old rule — mean-fold, then `min(0.88, v * 1.18) ** 0.8` — was a fixed
|
||||
// gain into a hard clamp. On that track the MEDIAN source value crossed the
|
||||
// clamp, so 53.8% of the lane rendered at literally one height, and the rail
|
||||
// hid the ruler under per-bar noise (deleted the same day). This rule flat-tops
|
||||
// 5.0% (the designed outlier clip), σ 0.161 → 0.269, IQR 0.084 → 0.230.
|
||||
//
|
||||
// The steps, and what each buys:
|
||||
// 1. Fold with a mean+peak BLEND, leaning peak: a pure mean averages the
|
||||
// loudest ~128ms window in a bar's span away, which is precisely the
|
||||
// transient a scrub lane exists to show.
|
||||
// 2. Stretch the folded values across the TRACK'S OWN range — its quiet
|
||||
// percentile to 0, its loud percentile to 1. A loud master no longer pins
|
||||
// to the ceiling and a quiet one is no longer a hairline, because the scale
|
||||
// comes from the record, not from a constant. The few bars above the
|
||||
// ceiling percentile clip to full height by design: outlier control, ~3%,
|
||||
// not half the lane. This mapping is scale-invariant, so the Rust side's
|
||||
// normalize-by-loudest-bucket stops mattering.
|
||||
// 3. A small gamma and a floor: the gamma keeps the mid-range readable, the
|
||||
// floor keeps a silence from vanishing into the lane background.
|
||||
|
||||
/** Bar geometry both lanes draw on: a 2px bar on a 4px gap. Pitch 6 chosen off
|
||||
* research/nowplaying-fans-waveform/mockup.html (W1). */
|
||||
export const BAR_PITCH = 6;
|
||||
export const BAR_W = 2;
|
||||
|
||||
// The lane's motion, tuned by Kyle on
|
||||
// research/playbar-redesign/waveform-loading2.html (2026-08-03). Two motions
|
||||
// exist: the LOADING SWELL (one crest crossing the resting dots while a
|
||||
// waveform is being computed) and the STREAMING RISE (bars standing up
|
||||
// left→right as the download arrives — released on a fixed rhythm, so a fast
|
||||
// transfer cannot machine-gun the lane). The Now Playing rail mirrors both in
|
||||
// CSS; its keyframe/transition literals pair with these numbers and must be
|
||||
// changed together (see NowPlaying.svelte's lane styles).
|
||||
export const SWELL_SWEEP_MS = 2000; // one crossing of the lane
|
||||
export const SWELL_REST_MS = 500; // calm between crossings
|
||||
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 RISE_MS = 2000; // one bar, floor → full height
|
||||
export const RISE_CADENCE_MS = 40; // gap between one bar starting and the next
|
||||
/** 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;
|
||||
|
||||
/** Mean+peak blend per display bar, leaning peak — see step 1 above. */
|
||||
const BLEND_MEAN = 0.35;
|
||||
const BLEND_PEAK = 0.65;
|
||||
/** The track's own range: p05 maps to the floor, p97 to full height. */
|
||||
const STRETCH_LO = 0.05;
|
||||
const STRETCH_HI = 0.97;
|
||||
/** Mild gamma; <1 lifts the mid-range without crushing the top. */
|
||||
const CURVE = 0.85;
|
||||
/** No bar renders below 12% of the lane, so silence still reads as a lane. */
|
||||
const FLOOR = 0.12;
|
||||
|
||||
/**
|
||||
* Fold `peaks` (the raw array from the `waveform` command) into `count`
|
||||
* display levels in 0..1, shaped as above. Returns [] when there is nothing
|
||||
* to show, matching the callers' "no waveform yet" branches.
|
||||
*/
|
||||
export function waveLevels(peaks, count) {
|
||||
if (!peaks || peaks.length === 0 || count <= 0) return [];
|
||||
const folded = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const from = Math.floor((i / count) * peaks.length);
|
||||
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * peaks.length));
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (let j = from; j < to; j++) {
|
||||
const v = peaks[j] ?? 0;
|
||||
sum += v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
folded[i] = (sum / (to - from)) * BLEND_MEAN + max * BLEND_PEAK;
|
||||
}
|
||||
const sorted = [...folded].sort((a, b) => a - b);
|
||||
const q = (f) => sorted[Math.min(sorted.length - 1, Math.floor(f * (sorted.length - 1)))];
|
||||
const lo = q(STRETCH_LO);
|
||||
const span = Math.max(1e-6, q(STRETCH_HI) - lo);
|
||||
return folded.map((v) => {
|
||||
const stretched = Math.max(0, Math.min(1, (v - lo) / span));
|
||||
return FLOOR + Math.pow(stretched, CURVE) * (1 - FLOOR);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user