Files
SoulStream/src/components/PlayerBar.svelte
T
KeelrLeek b33d403634 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.
2026-08-03 16:08:26 -05:00

1235 lines
45 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,
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.
*
* The queue item's OWN `artist` / `album` fields come first, and splitting
* `sub` on the em dash is only the fallback. `sub` is a display convenience
* that not every path fills the same way: the album-continuation fill
* (`maybeExtendAlbumQueue`) writes a bare artist into it while carrying the
* album right alongside, so a record played straight through lost its album
* name from the second track on — which is exactly what it looked like.
* Local library rows carry neither field, and those still fall back. */
const metaParts = $derived.by(() => {
if (currentItem?.artist || currentItem?.album) {
return [currentItem.artist ?? "", currentItem.album ?? ""];
}
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
});
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;
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));
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.
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) {
// 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;
}
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;
// 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);
}
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. -->
{#if player.status === "buffering"}
<span class="who"><span class="sub">{metaParts[0] || "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>