forked from Keelrleek/SoulStream
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
+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;
|
||||
|
||||
Reference in new issue
Block a user