Files
SoulStream/src/components/PlayerBar.svelte
T

1868 lines
74 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,
togglePlayPause,
togglePlayerExpanded,
showNowPlaying,
saveStream,
goToView,
engineTime,
} 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";
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 fixed name box ------------------------------------------------
// ONE constant width (picked on research/playbar-redesign/namehover.html,
// 2026-08-03): the transport is welded in place no matter what plays. A
// name that fits simply sits in the box; a longer one rests under an edge
// fade — a mask, never "…" — and glides ONLY while the pointer is over
// it: out, hold, back, repeat, then eases home on leave. The tooltip
// still carries the full text. NAME_BOX_PX pairs with `.meta`'s width in
// the styles below; change them together.
const NAME_BOX_PX = 240;
const GLIDE_PX_PER_S = 40;
const GLIDE_HOLD_MS = 1100;
const GLIDE_START_DELAY_MS = 320;
function nameGlide(node) {
const inner = node.firstElementChild;
let overflow = 0;
let x = 0;
let phase = "idle";
let phaseT0 = 0;
let homeFrom = 0;
let hovered = false;
let raf = null;
const easeIO = (k) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
const paint = () => {
inner.style.transform = x ? `translateX(${x.toFixed(1)}px)` : "";
const right = overflow > 0 && x > -overflow + 1;
const left = overflow > 0 && x < -1;
node.classList.toggle("maskLR", left && right);
node.classList.toggle("maskR", right && !left);
node.classList.toggle("maskL", left && !right);
};
const step = () => {
raf = null;
const now = performance.now();
const dt = now - phaseT0;
const dur = Math.max(200, (overflow / GLIDE_PX_PER_S) * 1000);
switch (phase) {
case "hold0":
x = 0;
if (dt > GLIDE_START_DELAY_MS) {
phase = "out";
phaseT0 = now;
}
break;
case "out": {
const k = Math.min(1, dt / dur);
x = -overflow * easeIO(k);
if (k >= 1) {
phase = "hold1";
phaseT0 = now;
}
break;
}
case "hold1":
x = -overflow;
if (dt > GLIDE_HOLD_MS) {
phase = "back";
phaseT0 = now;
}
break;
case "back": {
const k = Math.min(1, dt / dur);
x = -overflow * (1 - easeIO(k));
if (k >= 1) {
x = 0;
phase = hovered ? "hold0" : "idle";
phaseT0 = now;
}
break;
}
case "home": {
const hdur = Math.max(120, (Math.abs(homeFrom) / (GLIDE_PX_PER_S * 2)) * 1000);
const k = Math.min(1, dt / hdur);
x = homeFrom * (1 - easeIO(k));
if (k >= 1) {
x = 0;
phase = "idle";
}
break;
}
}
paint();
if (phase !== "idle") raf = requestAnimationFrame(step);
};
const arm = () => {
if (raf === null) raf = requestAnimationFrame(step);
};
const measure = () => {
overflow = Math.max(0, Math.ceil(inner.scrollWidth - node.clientWidth));
x = 0;
phase = "idle";
if (raf !== null) {
cancelAnimationFrame(raf);
raf = null;
}
paint();
};
const onEnter = () => {
hovered = true;
if (overflow > 0 && (phase === "idle" || phase === "home")) {
phase = "hold0";
phaseT0 = performance.now();
arm();
}
};
const onLeave = () => {
hovered = false;
if (phase !== "idle" && phase !== "home") {
homeFrom = x;
phase = "home";
phaseT0 = performance.now();
arm();
}
};
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
// Track changes swap the text out from under the box; re-measure a
// beat later so the DOM (and on first load, the font) has settled.
const remeasure = () => queueMicrotask(measure);
const observer = new MutationObserver(remeasure);
observer.observe(inner, { childList: true, characterData: true, subtree: true });
window.addEventListener("resize", remeasure);
document.fonts?.ready.then(remeasure);
measure();
return {
destroy() {
observer.disconnect();
window.removeEventListener("resize", remeasure);
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave);
if (raf !== null) cancelAnimationFrame(raf);
},
};
}
// ---- the overflow menu -------------------------------------------------
// The popup renders OUTSIDE the footer and position:fixed (the SaveMenu
// idiom): the bar is overflow:hidden, so an absolute menu inside it got
// amputated at the bar's top edge — and what poked out still lost to the
// Now Playing panel's z-indexed layers. Fixed + measured coordinates
// escape both the clip and the stacking war. It must NOT live inside
// .extras: that element's centering transform would become a fixed
// element's containing block and hand it right back to the clip.
let menuOpen = $state(false);
let menuWrap = $state(null);
let menuEl = $state(null);
let menuPos = $state({ right: 0, bottom: 0 });
function toggleMenu() {
if (!menuOpen && menuWrap) {
const r = menuWrap.getBoundingClientRect();
// Right-aligned over the button, opening upward — the same seat
// the old absolute placement (bottom + right of the wrap) gave it.
menuPos = {
right: Math.max(8, window.innerWidth - r.right - 6),
bottom: window.innerHeight - r.top + 6,
};
}
menuOpen = !menuOpen;
}
$effect(() => {
if (!menuOpen) return;
const away = (e) => {
const inside =
(menuWrap && menuWrap.contains(e.target)) ||
(menuEl && menuEl.contains(e.target));
if (!inside) menuOpen = false;
};
const esc = (e) => e.key === "Escape" && (menuOpen = false);
// The menu is anchored to coordinates measured at open; a resize
// moves the anchor out from under them, so it closes instead.
const close = () => (menuOpen = false);
window.addEventListener("pointerdown", away);
window.addEventListener("keydown", esc);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("pointerdown", away);
window.removeEventListener("keydown", esc);
window.removeEventListener("resize", close);
};
});
function openQueue() {
menuOpen = false;
showNowPlaying(); // the queue lives in the panel; always reveal it
}
function openLyrics() {
menuOpen = false;
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
// is gone.
goToView("lyrics", {
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
});
}
// ---- Save to library, from the ⋯ menu ---------------------------------
// Only a stream can be saved; a single queued stream has one destination
// and needs no scope choice, which is the rule the old download button
// followed. `saveStream` is the same call it made.
const canSave = $derived(!!currentItem?.stream);
const savingAlbum = $derived(player.queue.filter((it) => it.stream).length > 1);
async function saveFromMenu(scope) {
menuOpen = false;
await saveStream(scope);
}
// The transport cluster and the extras are content-sized, and neither
// changes width while the bar expands — so ONE measurement each is enough
// and this observer fires at mount and on a real content change (the Save
// items appearing, the volume glyph swapping), never per animation frame.
let barEl = $state(null);
let btnEl = $state(null);
let extrasEl = $state(null);
$effect(() => {
if (!barEl || !btnEl || !extrasEl) return;
const apply = () => {
barEl.style.setProperty("--btn-w", `${Math.round(btnEl.offsetWidth)}px`);
barEl.style.setProperty("--extras-w", `${Math.round(extrasEl.offsetWidth)}px`);
};
apply();
const ro = new ResizeObserver(apply);
ro.observe(btnEl);
ro.observe(extrasEl);
return () => ro.disconnect();
});
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);
});
/* The OS media card (Windows SMTC — src-tauri/src/smtc.rs). Published from
HERE, not from the store: what Windows shows should be what this bar
shows, and this is where that is derived — `metaParts` reconciles the
item's own artist/album fields against the `sub` split, and `artUrl` is
the cover after the local/catalog fork. Deriving either a second time in
Rust would be a second set of rules to keep in step.
Status and position are NOT sent: the backend's player event pump
already has both and drives the card itself, for no IPC. */
let lastCard = "";
$effect(() => {
const title = player.track?.title ?? "";
const [artist, album] = metaParts;
const art = artUrl;
if (!title) return;
// Fires twice per track — once when the title lands, again when the
// cover finishes resolving — and that is deliberate: the card should
// not wait on artwork to say what is playing. Everything past those is
// a re-render carrying the same content, dropped here rather than
// crossing IPC with a few hundred KB of base64 cover for nothing.
const key = `${title}${artist}${album}${art ?? ""}`;
if (key === lastCard) return;
lastCard = key;
void api.smtcMetadata(title, artist, album, art);
});
// 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 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];
/** 66% white composited over an UNPLAYED bar, precomputed:
* 0.66·255 + 0.34·122 — what a fully washed bar renders as. Bars now
* cross-fade between MUTED_RGB and this rather than being overpainted
* with a translucent fill, so the pair has to stay in sync by hand. */
const WASHED_RGB = [210, 210, 210];
const WHITE_RGB = [255, 255, 255];
const easeOut3 = (t) => 1 - Math.pow(1 - t, 3);
function mixRgb(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
let blendParsedFor = "";
let blendRgb = MUTED_RGB;
/** `wash` is 0..1 — how washed this bar's NEIGHBOURS currently are, not a
* boolean any more: with the per-bar crossfade the bars to its right pass
* continuously through every value between grey and wash, and a boolean
* target snapped this one bar a step ahead of them. */
function blendAccentRgb(accent, fill, wash) {
if (accent !== blendParsedFor) {
const n = parseInt(accent.slice(1), 16);
blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
blendParsedFor = accent;
}
return mixRgb(blendRgb, mixRgb(MUTED_RGB, WASHED_RGB, wash), 1 - fill);
}
function blendAccent(accent, fill, wash) {
const c = blendAccentRgb(accent, fill, wash);
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
/** theme.accent as [r,g,b]; shares blendAccentRgb's one-shot parse. */
function accentRgb() {
blendAccentRgb(theme.accent, 1, 0);
return blendRgb;
}
// ---- the hover marker and wash ----------------------------------------
// Chosen with Kyle on research/playbar-redesign/hover-playhead.html
// (2026-08-04), variants A4 + B1 at 400ms, spread 9 bars.
//
// The pink 1px tick is GONE. The cursor is marked by the bar underneath it:
// that bar lifts while its neighbours dip away (A4 "stands proud"), so the
// signal is a silhouette and not only a colour. Growing the bar alone does
// not work — on a normal master the median bar already fills ~89% of the
// 24px lane, so ~87% of bars have no headroom to grow into and simply clip
// flat; the dip is what actually carries it at every loudness.
//
// The wash fades ONLY on enter and on leave (Kyle, 2026-08-04, watching the
// first build). B1's per-bar clocks were literally per bar, so moving the
// pointer left the wash edge easing along behind it — a white fill visibly
// trailing the cursor. Span membership is therefore instant: which bars are
// washed follows the pointer exactly, and the single thing that animates is
// how present the whole highlight is. One gesture, one ramp.
const HOVER_MS = 400;
/** Bars either side of the cursor that the marker touches — 9 across. */
const MARKER_SPREAD = 4;
const MARKER_LIFT = 0.3; // cursor bar, as a fraction of its own height
const MARKER_DIP = 0.34; // neighbours, scaled by the falloff
/** Gaussian σ, so the cluster has no hard edge at ±MARKER_SPREAD. */
const MARKER_SIGMA = MARKER_SPREAD / 1.7;
let hoverAlive = 0; // 0..1 presence of the whole highlight, the ONE ramp
let hoverLastX = -1; // remembered so the marker fades out where it was
/** Last bar of the wash span. Frozen on leave so the wash fades out over
* the span it had, instead of vanishing the instant the pointer goes. */
let hoverSpanEnd = -1;
let hoverTickAt = 0;
let hoverActive = false; // any wash or marker on screen — see tickHover
function resetHover() {
hoverAlive = 0;
hoverLastX = -1;
hoverSpanEnd = -1;
hoverTickAt = 0;
}
/** Advance the presence ramp one frame. Returns true only while that ramp
* is running — moving the pointer inside the lane changes the span, not
* the ramp, so a plain mouse move costs no animation frames at all.
* Position events arrive ~5/s and cannot carry a 400ms fade on their own. */
function tickHover(now, washEndBar, hovering) {
// Real elapsed time, capped: a backgrounded window or a long GC pause
// would otherwise hand us one enormous step and snap the fade.
const dt = hoverTickAt ? Math.min(64, now - hoverTickAt) : 16.7;
hoverTickAt = now;
const step = dt / HOVER_MS;
let moving = false;
const want = hovering ? 1 : 0;
if (hoverAlive !== want) {
hoverAlive = Math.max(0, Math.min(1, hoverAlive + (hovering ? step : -step)));
if (Math.abs(hoverAlive - want) < 0.002) hoverAlive = want;
moving = true;
}
// Tracks the pointer exactly while hovering; held through the fade-out.
if (hovering) hoverSpanEnd = washEndBar;
// Whether the lane still needs the per-bar painter at all. Once this is
// false the settled lane is three flat regions again and goes back to
// the cached Path2D clip-fills.
hoverActive = hovering || hoverAlive > 0.001;
if (!hoverActive) hoverSpanEnd = -1;
return moving;
}
/** How washed bar `i` is, 0..1. Membership is a hard test against the live
* span; the only continuous term is the shared presence ramp. */
function washAt(i, full) {
return i > full && i <= hoverSpanEnd ? easeOut3(hoverAlive) : 0;
}
/** Which bar the marker sits on, or -1. Held through the fade-out so the
* marker dies where it was rather than jumping to the lane's edge. */
let hoverMarkerBar = -1;
/** One lane bar, with the wash crossfade and the cursor marker applied.
* Every painter goes through here so the rise, the arrival morph and the
* settled lane cannot disagree about what a hovered bar looks like. */
function paintLaneBar(ctx, i, mid, h, full, playedBars, laneH) {
let col;
if (i < full) col = accentRgb();
else if (i === full && playedBars - full > 0) {
// The boundary bar fades toward whatever its RIGHT neighbour is
// currently showing, which during the ramp is a moving value.
col = blendAccentRgb(theme.accent, playedBars - full, washAt(i + 1, full));
} else col = mixRgb(MUTED_RGB, WASHED_RGB, washAt(i, full));
let scale = 1;
if (hoverMarkerBar >= 0 && hoverAlive > 0.001) {
const d = Math.abs(i - hoverMarkerBar);
if (d <= MARKER_SPREAD) {
const alive = easeOut3(hoverAlive);
const f = Math.exp(-(d * d) / (2 * MARKER_SIGMA * MARKER_SIGMA));
const k = f * alive;
if (k > 0.004) {
col = mixRgb(col, WHITE_RGB, k);
scale = d === 0 ? 1 + MARKER_LIFT * alive : 1 - MARKER_DIP * f * alive;
}
}
}
// Clamped to the lane: an unclamped lift does not grow a loud bar, it
// clips it flat against the canvas edge, which reads as damage.
paintBar(ctx, i * BAR_PITCH, mid, Math.min(laneH, h * scale), `rgb(${col[0]},${col[1]},${col[2]})`);
return Math.min(laneH, h * scale);
}
// ---- 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 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, 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 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] = paintLaneBar(ctx, i, mid, h, full, playedBars, H);
}
lastDrawnHeights = heights;
}
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")}`;
}
// Shared with the OS media card and the hardware media keys, so a media
// key cannot end up doing something subtly different from this button.
const toggle = togglePlayPause;
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;
// TRACK time in, ENGINE time out — a set-recording slice's
// engine clock is offset by the slice start.
api.playerControl("seek", engineTime(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", engineTime(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", engineTime(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;
// Nothing to wash or mark. Cleared rather than left to decay so a
// waveform arriving later does not inherit a stale half-fade.
resetHover();
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;
// ---- the hover crossfade, ticked exactly once per frame ------------
// Ahead of every painter and before the early-out below, because all
// three paint the marker and the fade has to advance on frames the
// playhead alone would have skipped.
const hovering = hoverX >= 0;
if (hovering) hoverLastX = hoverX;
const hoverFrac = player.duration
? Math.max(0, Math.min(1, player.position / player.duration))
: 0;
const hoverEnd = hovering && hoverX > hoverFrac * W ? Math.floor(hoverX / BAR_PITCH) : -1;
const hoverMoving = tickHover(now, hoverEnd, hovering);
hoverMarkerBar =
hoverLastX >= 0 && (hovering || hoverAlive > 0.001)
? Math.floor(hoverLastX / BAR_PITCH)
: -1;
// 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, playedBarsNow);
lastPlayedBars = playedBarsNow;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
scheduleAnim(growing || riseReleased < frontier || hoverMoving);
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 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);
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] = paintLaneBar(ctx, i, mid, h, full, playedBars, H);
}
lastDrawnHeights = heights;
lastPlayedBars = playedBars;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
scheduleAnim(true);
return;
}
}
const visualsChanged =
geometryChanged ||
hoverMoving ||
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);
// Under the pointer every bar carries its own wash value and the marker
// cluster its own heights, so the grouped clip-fills cannot express the
// lane — paint it a bar at a time, exactly as the rise and the morph do.
// This is the only state that pays for it: with no pointer on the lane
// the picture is three flat regions and the cached Path2D wins.
if (hoverActive) {
const heights = new Array(waveCount);
for (let i = 0; i < waveCount; i++) {
heights[i] = paintLaneBar(ctx, i, mid, waveHeights[i], full, playedBars, H);
}
lastDrawnHeights = heights;
} else {
fillClip(0, W, UNPLAYED);
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, 0);
ctx.beginPath();
ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
ctx.fill();
}
lastDrawnHeights = waveHeights;
}
// No standing playhead rule, and no hover tick either: the 1px
// accent-hover line that used to say "a click lands here" is replaced by
// the marker inside paintLaneBar — the bar itself lifts and whitens.
lastPlayedBars = playedBars;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
// The settled lane is otherwise repainted only by position events
// (~5/s), which cannot carry a 400ms fade — keep frames coming until
// the crossfade and the marker have both landed.
scheduleAnim(hoverMoving);
}
// 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" class:expanded={ui.playerExpanded} bind:this={barEl}>
<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">
<div class="namebox" use:nameGlide>
<span class="glider">
{#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}
</span>
</div>
<!-- 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. -->
<div class="namebox" use:nameGlide>
<span class="glider">
{#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}
</span>
</div>
</div>
</div>
<div class="buttons" bind:this={btnEl}>
<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" bind:this={extrasEl}>
<!-- The bar's own expand control, in the slot the Save button used to
hold. Not a new component: this is the sidebar's `.gicon` — 28x28,
radius 6, a 16px chevron on the shared curve — because the app
already has one way to say "this panel opens and closes". It
points UP collapsed, since that is the direction the bar grows.
Save to library moved into the ⋯ menu below, and still sits in the
Now Playing panel where it always has. -->
<button
class="gicon"
class:down={ui.playerExpanded}
title={ui.playerExpanded ? "Collapse player" : "Expand player"}
aria-expanded={ui.playerExpanded}
onclick={togglePlayerExpanded}
><Icon name="chevron-left" size={16} /></button>
<!-- 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={toggleMenu}
>
<Icon name="more-horizontal" size={17} />
</button>
</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>
<!-- Outside the footer on purpose: see the note at menuOpen. -->
{#if menuOpen}
<div
class="menu"
role="menu"
bind:this={menuEl}
style="right: {menuPos.right}px; bottom: {menuPos.bottom}px;"
>
<button role="menuitem" onclick={openQueue}>
<Icon name="playlist" size={15} /> Queue
</button>
<!-- Save's new home. The bar's download button became the expand
toggle, and this menu is where everything past Save was always
going to live. Only a stream can be saved, so the items appear
only for one. -->
{#if canSave}
<button role="menuitem" onclick={() => saveFromMenu("track")}>
<Icon name="music" size={15} /> Save this track
</button>
{#if savingAlbum}
<button role="menuitem" onclick={() => saveFromMenu("album")}>
<Icon name="library" size={15} /> Save whole album
</button>
{/if}
{/if}
<!-- Enabled only with a track: the overlay closes itself the moment
player.track is empty, so opening it then would just blink. -->
<button role="menuitem" disabled={!currentItem} onclick={openLyrics}>
<Icon name="lyrics" size={15} /> Lyrics
</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}
<style>
/* FOUR blocks, absolutely placed, so the collapsed row and the expanded
stack are the same layout at two sets of coordinates and the bar can
INTERPOLATE between them. Flex cannot: going from one row to a centred
two-row stack means changing `flex-direction`, which does not animate, so
the contents would snap into place while the height eased around them.
The name and the extras never move vertically — `top: 50%` plus a -50%
translate centres them at every height, in both states, with no rule per
state. Only the transport and the lane travel.
Nothing here is width-queried — the source slot was the only container
query and it is gone — so there is no `container-type`. */
.player {
grid-area: player;
position: relative;
overflow: hidden;
background: var(--bg-player);
border-radius: var(--radius);
/* One clock for the whole gesture. Chosen on
research/playbar-redesign/expand-collapse.html: 350ms on
cubic-bezier(.22,1,.36,1), the softer landing. It pairs with the
grid-template-rows transition in App.svelte — change them together or
the bar's contents and its height stop arriving at the same moment. */
--pb-dur: 350ms;
--pb-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* 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. */
--pb-pad: 12px;
--pb-gap: 14px;
/* 40 art + 11 gap + 240 name box. All three are fixed by design, so the
collapsed transport offset is a constant and needs no measurement. */
--np-w: 291px;
/* Measured once each — see the observer in the script. The defaults are
the real values, so the very first paint is already correct. */
--btn-w: 158px;
--extras-w: 170px;
/* The stack, expanded: 34 transport + 10 gap + 24 lane = 68. Each block
is offset from the bar's CENTRE by half the stack minus its own half,
which is what keeps the pair centred at any bar height. */
--pb-stack-gap: 10px;
--pb-btn-dy: calc((var(--pb-stack-gap) + 24px) / -2);
--pb-lane-dy: calc((var(--pb-stack-gap) + 34px) / 2);
/* Expanded, the lane is inset by the LARGER flank on both sides, so it
is genuinely centred in the bar rather than merely filling what the
name and the extras left over. */
--pb-lane-inset: max(
calc(var(--pb-pad) + var(--np-w) + var(--pb-gap)),
calc(var(--pb-pad) + var(--extras-w) + var(--pb-gap))
);
}
.player > .now-playing,
.player > .extras {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.player > .now-playing {
left: var(--pb-pad);
}
.player > .extras {
right: var(--pb-pad);
}
/* The two that travel. Both use the same 2-argument translate in both
states so the transforms interpolate component-wise instead of falling
back to a matrix. */
.player > .buttons,
.player > .scrubber {
position: absolute;
top: 50%;
transition:
left var(--pb-dur) var(--pb-ease),
right var(--pb-dur) var(--pb-ease),
transform var(--pb-dur) var(--pb-ease);
}
.player > .buttons {
left: calc(var(--pb-pad) + var(--np-w) + var(--pb-gap));
transform: translate(0, -50%);
}
.player > .scrubber {
left: calc(
var(--pb-pad) + var(--np-w) + var(--pb-gap) + var(--btn-w) + var(--pb-gap)
);
right: calc(var(--pb-pad) + var(--extras-w) + var(--pb-gap));
transform: translate(0, -50%);
}
/* Expanded: transport dead centre, lane centred on its own row beneath. */
.player.expanded > .buttons {
left: 50%;
transform: translate(-50%, calc(-50% + var(--pb-btn-dy)));
}
.player.expanded > .scrubber {
left: var(--pb-lane-inset);
right: var(--pb-lane-inset);
transform: translate(0, calc(-50% + var(--pb-lane-dy)));
}
/* The expand toggle, in the slot the download button held. Copied from
Sidebar.svelte's `.gicon` — same box, same glyph, same curve — because
the app already has one way to say "this panel opens and closes".
Rotated to point UP, which is the direction the bar grows. */
.gicon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
transform: rotate(90deg);
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
background 120ms,
color 120ms;
}
.gicon:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.gicon.down {
transform: rotate(-90deg);
}
/* FIXED width, deliberately: the name box is a constant (picked on
research/playbar-redesign/namehover.html, 2026-08-03), so the
transport, the lane — everything right of here — sits at the same
pixel on every track. The earlier content-hugging cluster read as the
whole bar jumping between songs. Long names glide under the pointer
instead of resizing anything; see nameGlide in the script. */
.now-playing {
display: flex;
align-items: center;
gap: 11px;
flex: none;
}
/* 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;
/* NAME_BOX_PX in the script — change them together. */
width: 240px;
gap: 1px;
}
/* The clipping window a long name glides inside. The gradient masks are
the "there is more" signal — never an ellipsis — and only the side(s)
actually hiding text get one, toggled by nameGlide as it moves. */
.namebox {
position: relative;
width: 100%;
overflow: hidden;
}
.glider {
display: inline-block;
white-space: nowrap;
will-change: transform;
}
/* `:global()` on the mask class, and ONLY on it. These three are applied by
nameGlide with `classList.toggle`, which the compiler cannot see — so it
called them unused and DELETED them, and the fade had never once rendered
since it was written. The warning was a deletion notice, not advice.
`.namebox` stays scoped, so this is still local to the player bar; only
the runtime-applied half is exempted from the usage check. */
.namebox:global(.maskR) {
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
}
.namebox:global(.maskL) {
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
}
.namebox:global(.maskLR) {
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
}
/* 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. Lays out at its NATURAL width inside
the glider: the name box clips and glides, so nothing here shrinks,
truncates, or gives way any more. */
.who {
display: inline-flex;
align-items: center;
gap: 5px;
}
.who .sep {
opacity: 0.5;
flex: none;
font-size: 11px;
color: var(--text-secondary);
}
/* Separate, clickable meta lines, at natural width — the name box owns
clipping now, so no ellipsis anywhere. */
.title,
.sub {
white-space: nowrap;
text-align: left;
}
.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;
height: 24px;
/* No min-width any more. It existed to stop a flex item being squeezed;
the lane's left and right edges are now stated outright, so a floor
could only fight them — at the 960px minimum the collapsed lane works
out at 259px, and a 260px floor would have pushed it past the bar's
right edge rather than protecting anything. */
}
.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 {
/* Fixed positioning escapes the bar's overflow clipping and every
panel stacking context (the SaveMenu idiom, same tier). The
coordinates come measured from the trigger at open. */
position: fixed;
z-index: 1000;
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>