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

+337 -269
View File
@@ -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;