Files
SoulStream/src/components/PlayerBar.svelte
T
KeelrLeek 8cd56a433e player bar: the album line survives library plays, and lands with the title
Two halves of one defect family. The meta line preferred the queue item's
artist/album fields as a PAIR whenever either existed — but a library play
(playAlbum) writes only `artist`, with the record's name living inside
`sub` ("artist — album"), so `album: null` was taken at face value and
every library album lost its name from the bar (and the morning comment
claiming local rows carry neither field was simply wrong). Each half now
falls back to the `sub` split on its own, here and in the rail's byline —
which also restores the album for continuation items whose `sub` is a
bare artist.

And the line no longer trickles in: the buffering branch used to render
the artist alone, so the album flashed in a beat after the title when
playback started. The status gate is replaced by a REAL distinction — a
resolver-phase track has no `path` (playQueueIndex always writes one), so
"finding best source…" still shows as a bare label, and the moment a real
queue item owns the transport the full clickable line renders, buffering
or not. Measured: the first frame carrying the new title already carries
artist · album; no artist-only frame exists.
2026-08-03 17:18:25 -05:00

1360 lines
51 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import {
player,
theme,
ui,
nextTrack,
prevTrack,
toggleShuffle,
cycleRepeat,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
getArt,
getCatalogArt,
playerArtSource,
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,
ARRIVE_MS,
ARRIVE_HOLD_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 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
// keeps the reliable seek-on-drag behavior until it has been saved/cached.
const canScratch = $derived(!currentItem?.stream);
/** Artist and album for the separate, clickable meta lines.
*
* Per FIELD, not all-or-nothing. The queue paths disagree about what an
* item carries: the album-continuation fill writes artist AND album
* fields beside a bare-artist `sub`; a library play (`playAlbum`) writes
* only `artist`, with the record's name living inside `sub`
* ("artist — album"); old restored items may carry neither. Preferring
* the item's fields as a PAIR whenever either existed took `album: null`
* at face value and erased the record from every library play — each
* half now falls back to the `sub` split on its own. */
const metaParts = $derived.by(() => {
const sub = player.track?.sub ?? "";
const split = sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
return [
currentItem?.artist ?? split[0] ?? "",
currentItem?.album ?? split[1] ?? "",
];
});
const volumeIcon = $derived(
player.muted || player.volume === 0
? "volume-mute"
: player.volume < 0.5
? "volume-low"
: "volume",
);
// 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);
let menuWrap = $state(null);
$effect(() => {
if (!menuOpen) return;
const away = (e) => {
if (menuWrap && !menuWrap.contains(e.target)) menuOpen = false;
};
const esc = (e) => e.key === "Escape" && (menuOpen = false);
window.addEventListener("pointerdown", away);
window.addEventListener("keydown", esc);
return () => {
window.removeEventListener("pointerdown", away);
window.removeEventListener("keydown", esc);
};
});
function openQueue() {
menuOpen = false;
ui.nowPlaying = true; // the queue lives in the panel; always reveal it
}
let artUrl = $state(null);
let volTrack = $state(null);
let draggingVolume = $state(false);
let draggingSeek = $state(false);
let lastAudibleVolume = 0.7;
let lastScrubSeekAt = 0;
let scrubStartX = 0;
let scrubLastX = 0;
let scrubLastAt = 0;
let scratchActive = false;
$effect(() => {
const item = currentItem;
artUrl = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art).then((url) => {
if (active) artUrl = url;
});
} else if (item.artUrl) {
// Streamed catalog track: cover comes from the catalog CDN, upgraded
// to the verified high-res source (Apple/CAA/Deezer-max) when ready.
getCatalogArt(playerArtSource(item)).then((url) => {
if (active) artUrl = url;
});
}
return () => (active = false);
});
// Waveform canvas + hover state.
let waveWrap = $state(null);
let waveCanvas = $state(null);
let hoverX = $state(-1);
const hoverTime = $derived(
waveWrap && player.duration ? (hoverX / waveWrap.clientWidth) * player.duration : 0,
);
// Canvas colors; played/hover follow the adaptive accent theme.
/** 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)";
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;
// The arrival morph (M2 on waveform-arrive.html): when a COMPLETE
// waveform lands, every bar eases from whatever the lane painted last —
// resting dots, a mid-sweep swell, the previous shape — straight into the
// new one over ARRIVE_MS. `lastDrawnHeights` is kept current by every
// painter so the morph always starts from the truth on screen.
let lastDrawnHeights = null;
let morphFrom = null;
let morphStart = 0;
// Track-change hold: the outgoing shape stays painted for ARRIVE_HOLD_MS
// so a quick arrival morphs shape→shape instead of dipping through dots.
let hadPeaks = false;
let holdUntil = 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;
const heights = new Array(count);
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;
heights[i] = 2 + env * SWELL_AMP_PX * 2;
paintBar(ctx, i * BAR_PITCH, mid, heights[i], `rgba(122,122,122,${alpha})`);
}
lastDrawnHeights = heights;
return pending;
}
/** Resample a heights array onto a different bar count. The lane's WIDTH
* follows the song title now (the cluster hugs the name), so a track
* change routinely changes the bar count mid-hold — and bailing on the
* mismatch threw the held shape away and turned the morph into a coin
* flip. Nearest-neighbour is plenty: the result is a moving start point,
* on screen for under a second. */
function fitHeights(src, count) {
if (!src?.length) return null;
if (src.length === count) return src;
const out = new Array(count);
const top = src.length - 1;
for (let i = 0; i < count; i++) {
out[i] = src[Math.min(top, Math.round((i / Math.max(1, count - 1)) * top))] ?? 2;
}
return out;
}
/** The fade, one bar at a time — shared by the rise and the arrival
* morph, whose heights differ every frame and cannot use the grouped
* clip-fills of the settled painter. */
function barColor(i, full, playedBars, washing, washEndBar) {
if (i < full) return theme.accent;
if (i === full && playedBars - full > 0) {
return blendAccent(theme.accent, playedBars - full, washing);
}
if (washing && i > full && i <= washEndBar) return `rgb(${WASHED_RGB.join(",")})`;
return UNPLAYED;
}
/** 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);
const heights = new Array(count);
for (let i = 0; i < count; i++) {
const x = i * BAR_PITCH;
if (i >= riseReleased) {
heights[i] = 2;
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;
heights[i] = h;
paintBar(ctx, x, mid, h, barColor(i, full, playedBars, washing, washEndBar));
}
lastDrawnHeights = heights;
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));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function toggle() {
if (player.status === "playing") api.playerControl("pause");
// Not a bare `resume`: a track restored from the last session has no
// audio loaded behind it and has to be re-sourced from the queue.
else if (player.status === "paused") resumePlayback();
}
function seekTarget(e, el) {
if (!player.duration || idle) return;
const rect = el.getBoundingClientRect();
const f = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
return f * player.duration;
}
function previewScrub(e, el) {
const target = seekTarget(e, el);
if (target == null) return;
player.position = target; // instant visual feedback while dragging
return target;
}
function regularScrub(target, force = false) {
const now = performance.now();
if (force || now - lastScrubSeekAt >= 75) {
lastScrubSeekAt = now;
api.playerControl("seek", target);
}
}
function startScrub(e, el) {
if (!player.duration || idle) return;
draggingSeek = true;
player.scrubbing = true;
el.setPointerCapture(e.pointerId);
scrubStartX = scrubLastX = e.clientX;
scrubLastAt = performance.now();
scratchActive = false;
previewScrub(e, el);
}
function moveScrub(e, el) {
if (!draggingSeek) return;
const target = previewScrub(e, el);
if (target == null) return;
const now = performance.now();
if (canScratch && Math.abs(e.clientX - scrubStartX) > 3) {
const activatingScratch = !scratchActive;
scratchActive = true;
const elapsed = Math.max(1, now - scrubLastAt);
// Map hand velocity (pixels/ms) to a musical 0.25×–4× rate.
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
if (activatingScratch || now - lastScrubSeekAt >= 50) {
lastScrubSeekAt = now;
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
}
} else if (!canScratch) {
regularScrub(target);
}
scrubLastX = e.clientX;
scrubLastAt = now;
}
function endScrub(e, el) {
if (!draggingSeek) return;
const target = previewScrub(e, el);
if (target != null) {
if (scratchActive) api.playerControl("end-scratch", target);
else regularScrub(target, true); // a simple click remains exact seek
}
draggingSeek = false;
player.scrubbing = false;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
}
function onWaveHover(e) {
const rect = waveWrap.getBoundingClientRect();
hoverX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
}
function setVolume(e) {
const rect = volTrack.getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
player.volume = frac;
player.muted = frac === 0;
if (frac > 0) lastAudibleVolume = frac;
api.playerControl("volume", frac);
}
function startVolumeDrag(e) {
draggingVolume = true;
volTrack.setPointerCapture(e.pointerId);
setVolume(e);
}
function endVolumeDrag(e) {
draggingVolume = false;
if (volTrack.hasPointerCapture(e.pointerId)) volTrack.releasePointerCapture(e.pointerId);
}
function volumeKeydown(e) {
const step = e.shiftKey ? 0.1 : 0.05;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.max(0, player.volume - step) * volTrack.clientWidth });
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.min(1, player.volume + step) * volTrack.clientWidth });
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + (e.key === "Home" ? 0 : volTrack.clientWidth) });
}
}
function toggleMute() {
if (player.muted || player.volume === 0) {
const volume = lastAudibleVolume || 0.7;
player.volume = volume;
player.muted = false;
api.playerControl("volume", volume);
} else {
lastAudibleVolume = player.volume;
player.muted = true;
api.playerControl("volume", 0);
}
}
// Wave geometry is invariant while a track and player-bar size stay the
// 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 lastPlayedBars = Number.NEGATIVE_INFINITY;
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
let lastAccent = "";
let lastAccentHover = "";
let lastDuration = null;
const windowActive = $derived(ui.windowActive);
// Draw one solid mirrored waveform, colored by played / hover / unplayed,
// with a white playhead line and a green hover line.
function drawWave(force = false) {
// Position events still update playback/history while the window is
// minimized, but there is no visible canvas to repaint. Visibility
// return below forces one exact catch-up frame.
if (!windowActive) return;
const canvas = waveCanvas;
const wrap = waveWrap;
const peaks = player.waveform;
if (!canvas || !wrap) return;
const dpr = window.devicePixelRatio || 1;
const W = wrap.clientWidth;
const H = 24; // matches .wave-wrap
if (W <= 0) return;
const pixelWidth = Math.max(1, Math.round(W * dpr));
const pixelHeight = Math.max(1, Math.round(H * dpr));
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
if (resized) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
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 — but a TRACK CHANGE
// first holds the outgoing shape for ARRIVE_HOLD_MS, so when the next
// waveform lands quickly (local file, cached stream) the arrival
// morph runs one shape into the other instead of up from the dots.
// The hold paints in unplayed grey: the new track owns the transport
// now, and none of it has played.
if (!peaks || peaks.length === 0) {
riseKey = null;
morphFrom = null;
if (hadPeaks) {
holdUntil = now + ARRIVE_HOLD_MS;
hadPeaks = false;
}
const held = player.wavePending && now < holdUntil
? fitHeights(lastDrawnHeights, count)
: null;
if (held) {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < count; i++) {
paintBar(ctx, i * BAR_PITCH, mid, held[i], UNPLAYED);
}
// Write the fitted shape back so a mid-hold resize composes
// and the arrival morph starts from exactly what is showing.
lastDrawnHeights = held;
lastPlayedBars = Number.NEGATIVE_INFINITY;
scheduleAnim(true);
return;
}
const moving = drawUnloaded(ctx, W, H, mid, count, now);
lastPlayedBars = Number.NEGATIVE_INFINITY;
scheduleAnim(moving);
return;
}
hadPeaks = true;
// 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) {
morphFrom = null; // the rise owns the lane's motion
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) {
const peaksSwapped = wavePathPeaks !== peaks;
// 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++) {
path.roundRect(i * BAR_PITCH, mid - waveHeights[i] / 2, BAR_W, waveHeights[i], 1);
}
wavePath = path;
waveCount = count;
wavePathPeaks = peaks;
wavePathWidth = W;
wavePathHeight = H;
// A NEW complete waveform arrives on the morph — from the dots,
// from the swell mid-sweep, from the previous track's shape, or
// from the progressive peaks the final stream decode replaces.
// Only on a peaks swap: a pure resize re-lays the same shape and
// stays instant. A count mismatch is refitted, not rejected — the
// lane's width follows the title, so track changes change counts.
if (peaksSwapped && lastDrawnHeights?.length) {
const from = fitHeights(lastDrawnHeights, count);
morphFrom = from === lastDrawnHeights ? [...from] : from;
morphStart = now;
}
}
const playFrac = player.duration
? Math.max(0, Math.min(1, player.position / player.duration))
: 0;
const playX = playFrac * W;
const playedBars = playFrac * waveCount;
// Mid-arrival: every bar eases from where it was to where the new
// waveform puts it, all together, with the full fade colours live.
if (morphFrom) {
const t = (now - morphStart) / ARRIVE_MS;
if (t >= 1) {
morphFrom = null;
} else {
const k = 1 - Math.pow(1 - t, 3);
const full = Math.floor(playedBars);
const washing = hoverX > playX;
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
ctx.clearRect(0, 0, W, H);
const heights = new Array(waveCount);
for (let i = 0; i < waveCount; i++) {
const from = morphFrom[i] ?? 2;
const h = from + (waveHeights[i] - from) * k;
heights[i] = h;
paintBar(ctx, i * BAR_PITCH, mid, h, barColor(i, full, playedBars, washing, washEndBar));
}
lastDrawnHeights = heights;
if (hoverX >= 0) {
const LINE_H = 18;
ctx.fillStyle = theme.accentHover;
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
}
lastPlayedBars = playedBars;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
scheduleAnim(true);
return;
}
}
const visualsChanged =
geometryChanged ||
hoverX !== lastHoverDrawn ||
theme.accent !== lastAccent ||
theme.accentHover !== lastAccentHover ||
player.duration !== lastDuration;
// 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) => {
if (x1 <= x0) return;
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, x1 - x0, H);
ctx.clip();
ctx.fillStyle = color;
ctx.fill(wavePath);
ctx.restore();
};
// 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.
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
// read as a seam. The hover tick stays — it is the only thing that
// says where a click would land.
if (hoverX >= 0) {
const LINE_H = 18;
ctx.fillStyle = theme.accentHover;
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
}
lastDrawnHeights = waveHeights;
lastPlayedBars = playedBars;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
}
// Redraw whenever the waveform, position, hover, or accent changes
// (the accent lerps every frame during a crossfade, animating the fill).
$effect(() => {
player.waveform;
player.position;
player.duration;
hoverX;
theme.accent;
theme.accentHover;
drawWave();
});
// Redraw on player-bar resize.
$effect(() => {
if (!waveWrap) return;
const ro = new ResizeObserver(() => drawWave(true));
ro.observe(waveWrap);
return () => ro.disconnect();
});
</script>
<footer class="player">
<div class="now-playing">
{#if artUrl}
<img class="art cover" src={artUrl} alt="" draggable="false" />
{:else}
<div class="art placeholder" class:pulsing={player.status === "buffering"}>
{#if player.track}<Icon name="music" size={22} />{/if}
</div>
{/if}
<div class="meta">
{#if player.error}
<span class="title err">⚠ {player.error}</span>
{:else if player.track}
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
{player.track.title}
</button>
{:else}
<span class="title muted">Nothing playing</span>
{/if}
<!-- Artist and album on ONE line, which is what a 62px bar can pay
for. The `.who` idiom from the album page: a 50%-opacity middot
between them, and no underline until hover. -->
<!-- A resolver-phase track has no `path` (playQueueIndex always
writes one), and its `sub` IS the status line — show it bare.
The moment a real queue item owns the transport, the full
clickable line renders, buffering or not: gating it on
status=playing made the album flash in a beat after the
title and artist. -->
{#if player.track && !player.track.path}
<span class="who"><span class="sub">{player.track.sub || "buffering…"}</span></span>
{:else if player.track && (metaParts[0] || metaParts[1])}
<span class="who">
{#if metaParts[0]}
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
{metaParts[0]}
</button>
{/if}
{#if metaParts[0] && metaParts[1]}<span class="sep">·</span>{/if}
{#if metaParts[1]}
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
{metaParts[1]}
</button>
{/if}
</span>
{/if}
</div>
</div>
<div class="buttons">
<button
class="ctl small"
class:on={player.shuffle}
title="Shuffle"
onclick={toggleShuffle}
><Icon name="shuffle" size={16} /></button>
<button class="ctl" title="Previous" onclick={prevTrack} disabled={idle}>
<Icon name="skip-back" size={18} />
</button>
<button
class="play"
title="Play/Pause"
onclick={toggle}
disabled={idle || player.status === "buffering"}
>
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
</button>
<button class="ctl" title="Next" onclick={() => nextTrack(false)} disabled={idle}>
<Icon name="skip-forward" size={18} />
</button>
<button
class="ctl small"
class:on={player.repeat !== "off"}
title={player.repeat === "one" ? "Repeat one" : player.repeat === "all" ? "Repeat all" : "Repeat"}
onclick={cycleRepeat}
><Icon name={player.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
</div>
<div class="scrubber">
<span class="time">{fmt(player.position)}</span>
<!-- 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>
<!-- The now-playing panel toggle used to live here. It is gone: the panel
carries its own collapse control, and two ways to do one thing is one
too many. -->
<div class="extras">
<SaveMenu align="up" />
<!-- Everything past Save goes behind one control, so landing lyrics,
a mini player and output switching later costs no width. -->
<div class="menu-wrap" bind:this={menuWrap}>
<button
class="vol-icon"
class:on={menuOpen}
title="More"
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
<Icon name="more-horizontal" size={17} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<button role="menuitem" onclick={openQueue}>
<Icon name="playlist" size={15} /> Queue
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="lyrics" size={15} /> Lyrics <span class="soon">soon</span>
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="panel-right" size={15} /> Mini player <span class="soon">soon</span>
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="volume" size={15} /> Output device <span class="soon">soon</span>
</button>
</div>
{/if}
</div>
<button class="vol-icon" title={player.muted ? "Unmute" : "Mute"} onclick={toggleMute}>
<Icon name={volumeIcon} size={18} />
</button>
<div
class="track vol"
bind:this={volTrack}
class:dragging={draggingVolume}
onpointerdown={startVolumeDrag}
onpointermove={(e) => draggingVolume && setVolume(e)}
onpointerup={endVolumeDrag}
onpointercancel={endVolumeDrag}
onkeydown={volumeKeydown}
role="slider"
aria-label="Volume"
aria-valuenow={Math.round((player.muted ? 0 : player.volume) * 100)}
tabindex="0"
>
<div class="fill" style="width: {(player.muted ? 0 : player.volume) * 100}%"></div>
<div class="vol-thumb" style="left: {(player.muted ? 0 : player.volume) * 100}%"></div>
</div>
</div>
</footer>
<style>
/* ONE row. The transport, the scrubber and the meta share a baseline, and
the scrubber takes every pixel the others do not want — it no longer caps
at 560px while the flanks stretch, which is what left a wide window with
a small waveform adrift in the middle.
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;
align-items: center;
gap: 14px;
/* 12px, not 16: with the 8px app margin outside it that puts the cover's
left edge at x=20 and its centre at x=40 — the column every sidebar
cover and nav icon already shares, and which the sidebar holds
through its entire collapse. See the note in Sidebar.svelte. */
padding: 0 12px;
background: var(--bg-player);
border-radius: var(--radius);
}
.now-playing {
display: flex;
align-items: center;
gap: 11px;
min-width: 170px;
/* 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;
}
/* 40px, the size of a sidebar recents cover, for the same reason. */
.art {
width: 40px;
height: 40px;
border-radius: 4px;
flex-shrink: 0;
display: grid;
place-items: center;
color: var(--text-muted);
font-size: 20px;
}
.art.placeholder {
background: var(--bg-highlight);
}
.art.cover {
object-fit: cover;
background: var(--bg-highlight);
}
.art.pulsing {
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.5; }
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
gap: 1px;
}
/* Artist and album on one line — the `.who` idiom, copied rather than
invented: separator at 50% opacity, underline only on hover. A third
line is what 62px cannot pay for. */
.who {
display: flex;
align-items: center;
gap: 5px;
max-width: 100%;
min-width: 0;
}
/* When the line does have to give, the ALBUM gives. Shrinking both in
proportion turned a short artist into "Po…", which is unreadable and
saves almost nothing; the album is the longer half and the one you can
lose the tail of and still recognise. */
.who .sub {
min-width: 0;
}
/* ...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%;
}
.who .sub.album {
flex: 0 1 auto;
}
.who .sep {
opacity: 0.5;
flex: none;
font-size: 11px;
color: var(--text-secondary);
}
/* Separate, clickable meta lines. Buttons are width-constrained so the
hover underline only spans the text, and ellipsis-truncate. */
.title,
.sub {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
display: block;
}
.title {
font-weight: 600;
font-size: 13px;
}
.title.muted { color: var(--text-secondary); }
.title.err { color: #f15e6c; }
.title.link:hover { text-decoration: underline; }
.sub {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.3;
}
.sub.link:hover {
color: var(--text-primary);
text-decoration: underline;
}
.sub.album {
font-size: 11px;
color: var(--text-muted);
min-width: 0;
}
.sub.album.link:hover { color: var(--text-primary); }
.buttons {
display: flex;
align-items: center;
gap: 14px;
flex: none;
}
.ctl {
display: grid;
place-items: center;
color: var(--text-secondary);
}
.ctl.small {
color: var(--text-muted);
}
.ctl:disabled {
opacity: 0.3;
}
.ctl:hover:not(:disabled) {
color: var(--text-primary);
}
/* shuffle / repeat active state */
.ctl.on {
color: var(--accent-hover);
position: relative;
}
.ctl.on::after {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent-hover);
}
.play {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--text-primary);
color: #000;
font-size: 14px;
transition: transform 0.1s;
}
.play:disabled {
opacity: 0.4;
}
.play:hover:not(:disabled) {
transform: scale(1.06);
}
/* Fixed height so swapping between the thin bar and the waveform
canvas never reflows the buttons above. */
/* Takes the slack instead of capping: the wider the window, the longer the
waveform. `min-width` is the floor everything else yields to — the lane
is the point of the bar. */
.scrubber {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
/* Two 34px times plus the 10px gaps come out of this, so 260 is what
keeps ~170px of actual waveform at the app's 960px minimum window. */
min-width: 260px;
height: 24px;
}
.time {
font-size: 11px;
color: var(--text-secondary);
min-width: 32px;
text-align: center;
}
.track {
flex: 1;
height: 4px;
border-radius: 2px;
background: hsla(0, 0%, 100%, 0.2);
overflow: hidden;
position: relative;
cursor: pointer;
touch-action: none;
}
.track:hover {
height: 6px;
}
.track.dragging {
height: 6px;
}
/* Always the accent — matching the waveform scrubber's played region
(user decision 2026-07-23; the old white-until-hover read as off). */
.fill {
position: relative;
height: 100%;
background: var(--accent);
border-radius: 2px;
}
.track:hover .fill {
background: var(--accent-hover);
}
/* waveform scrubber */
.wave-wrap {
position: relative;
flex: 1;
min-width: 0;
height: 24px;
cursor: pointer;
touch-action: none;
}
.wave-canvas {
display: block;
width: 100%;
height: 100%;
}
.wave-tip {
position: absolute;
top: -22px;
transform: translateX(-50%);
background: #000;
color: #fff;
font-size: 11px;
padding: 2px 7px;
border-radius: 5px;
white-space: nowrap;
pointer-events: none;
}
.extras {
display: flex;
align-items: center;
gap: 9px;
justify-content: flex-end;
flex: none;
}
/* ---- the overflow menu ---- */
.menu-wrap {
position: relative;
display: grid;
place-items: center;
}
.menu {
position: absolute;
bottom: 30px;
right: -6px;
z-index: 40;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 186px;
padding: 7px;
border-radius: 9px;
background: #0d0d0d;
border: 1px solid var(--border);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
}
.menu button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
}
.menu button:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.menu button:disabled {
opacity: 0.45;
cursor: default;
}
.soon {
margin-left: auto;
font-size: 8px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #ffcf6a;
background: rgba(255, 180, 60, 0.13);
border: 1px solid rgba(255, 180, 60, 0.32);
padding: 1px 5px;
border-radius: 4px;
}
.vol-icon {
display: grid;
place-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.vol-icon:hover {
color: var(--text-primary);
}
.vol-icon.on {
color: var(--accent-hover);
}
.track.vol {
flex: 0 0 90px;
overflow: visible;
touch-action: none;
}
.track.vol::before {
content: "";
position: absolute;
inset: -8px 0;
}
.track.vol .fill {
pointer-events: none;
}
.vol-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-primary);
transform: translate(-50%, -50%) scale(0);
transition: transform 0.12s ease;
pointer-events: none;
}
.track.vol:hover .vol-thumb,
.track.vol.dragging .vol-thumb,
.track.vol:focus-visible .vol-thumb {
transform: translate(-50%, -50%) scale(1);
}
.track.vol:hover,
.track.vol.dragging {
height: 5px;
}
.track.vol:hover .fill,
.track.vol.dragging .fill {
background: var(--accent);
}
</style>