diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte
index 90f6dfb..a949eb2 100644
--- a/src/components/Icon.svelte
+++ b/src/components/Icon.svelte
@@ -44,6 +44,12 @@
refresh: '',
check: '',
"list-plus": '',
+ // Player-bar overflow. Filled dots rather than stroked circles: at 17px
+ // a 2px-stroke ring closes into a blob anyway, and the fill stays even.
+ "more-horizontal":
+ '',
+ // Lyrics: ragged lines, so it cannot be mistaken for the queue's list.
+ lyrics: '',
shuffle: '',
repeat: '',
"repeat-one": '',
@@ -61,6 +67,7 @@
trophy: '',
tag: '',
globe: '',
+ alert: '',
"brand-instagram": '',
"brand-x": '',
"brand-facebook": '',
diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte
index a5f653a..d34157c 100644
--- a/src/components/PlayerBar.svelte
+++ b/src/components/PlayerBar.svelte
@@ -7,13 +7,13 @@
prevTrack,
toggleShuffle,
cycleRepeat,
- toggleNowPlaying,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
getArt,
getCatalogArt,
playerArtSource,
+ resumePlayback,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
@@ -33,8 +33,19 @@
// 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);
- // Split "Artist — Album" for the separate, clickable meta lines.
+ /** 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())
@@ -48,6 +59,123 @@
: "volume",
);
+ // ---- the source line ---------------------------------------------------
+ // Every value below already exists in state — these are the same fields
+ // NowPlaying reads for its status line, so this needed no new command.
+ // The derivations are deliberately identical to that component's, because
+ // two surfaces disagreeing about whether a track is "cached" would be worse
+ // than either of them being wrong.
+ const isStream = $derived(!!currentItem?.stream);
+ const quality = $derived(currentItem?.quality ?? null);
+ /** Same set NowPlaying uses, so the green dot means the same thing here. */
+ const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
+ const transferring = $derived(
+ isStream && player.streamTotal > 0 && player.streamReceived < player.streamTotal,
+ );
+ const cachedStream = $derived(
+ isStream && player.streamTotal > 0 && player.streamReceived >= player.streamTotal,
+ );
+ /** What the line is ABOUT, as opposed to what it currently reads. The slot
+ * is re-measured when THIS changes and never when a percentage ticks. */
+ const srcState = $derived(
+ !isStream
+ ? "library"
+ : cachedStream
+ ? "cached"
+ : transferring
+ ? "transferring"
+ : "connecting",
+ );
+ const peerName = $derived(currentItem?.stream?.username ?? null);
+ const rateText = $derived(
+ player.streamSpeed > 0 ? `${(player.streamSpeed / 1048576).toFixed(1)} MB/s` : null,
+ );
+ const etaText = $derived.by(() => {
+ if (!transferring || player.streamSpeed <= 0) return null;
+ const secs = (player.streamTotal - player.streamReceived) / player.streamSpeed;
+ if (!isFinite(secs) || secs < 1) return null;
+ return secs >= 60
+ ? `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s left`
+ : `${Math.round(secs)}s left`;
+ });
+ const sizeText = (bytes) => (bytes ? `${(bytes / 1048576).toFixed(1)} MB` : null);
+ const qualityChip = $derived.by(() => {
+ if (!quality?.format) return null;
+ return quality.bitrate
+ ? `${quality.format} ${Math.round(quality.bitrate)}k`
+ : quality.format;
+ });
+ /** A library track reports NOTHING: it has no peer and no transfer, and
+ * zeroes would read as a stalled one. Same call NowPlaying makes. */
+ const sourceParts = $derived.by(() => {
+ if (!isStream) return [];
+ if (cachedStream) return ["Cached", sizeText(player.streamTotal)].filter(Boolean);
+ if (transferring) {
+ return [peerName, `${Math.round(bufferPct)}%`, rateText, etaText].filter(Boolean);
+ }
+ return peerName ? [`Streaming from ${peerName}`] : [];
+ });
+
+ // The slot reserves a width rather than hugging its text: while a transfer
+ // runs, the percentage, rate and ETA change every position event, and a
+ // slot that tracked them would slide the transport and the whole scrubber
+ // sideways continuously. It GROWS the instant the text needs more room and
+ // SHRINKS only when the state changes, so nothing ever clips and nothing
+ // twitches. Written straight to the node: routing it through $state would
+ // make the effect that measures it depend on its own output.
+ let srcSlotEl = $state(null);
+ let srcLineEl = $state(null);
+ const SLOT_CAP = 340;
+ const SLOT_PAD_L = 10;
+ const SLOT_PAD_R = 8;
+ function sizeSourceSlot(reset = false) {
+ if (!srcSlotEl) return;
+ const natural = srcLineEl ? Math.ceil(srcLineEl.getBoundingClientRect().width) : 0;
+ const want = natural ? Math.min(natural + SLOT_PAD_L + SLOT_PAD_R, SLOT_CAP) : 0;
+ const current = parseFloat(srcSlotEl.style.width) || 0;
+ if (!reset && want <= current) return;
+ srcSlotEl.style.width = `${want}px`;
+ srcSlotEl.style.paddingLeft = want ? `${SLOT_PAD_L}px` : "0px";
+ srcSlotEl.style.paddingRight = want ? `${SLOT_PAD_R}px` : "0px";
+ }
+ $effect(() => {
+ srcState;
+ qualityChip;
+ sizeSourceSlot(true);
+ });
+ $effect(() => {
+ sourceParts;
+ sizeSourceSlot(false);
+ });
+ // The slot's width is written imperatively, so a window resize — which can
+ // cross the @container threshold that hides it — has to re-measure.
+ $effect(() => {
+ const onResize = () => sizeSourceSlot(true);
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ });
+
+ // ---- the 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);
@@ -89,7 +217,10 @@
// Canvas colors; played/hover follow the adaptive accent theme.
const UNPLAYED = "rgba(255,255,255,0.24)";
const PREVIEW = "rgba(255,255,255,0.66)";
- const PLAYHEAD = "#ffffff";
+ /** Bar geometry, identical to NowPlaying's BAR_PITCH / BAR_W. */
+ const BAR_PITCH = 6;
+ const BAR_W = 2;
+ const BAR_GAP = BAR_PITCH - BAR_W;
function fmt(secs) {
if (secs == null || !isFinite(secs)) return "–:––";
@@ -99,7 +230,9 @@
function toggle() {
if (player.status === "playing") api.playerControl("pause");
- else if (player.status === "paused") api.playerControl("resume");
+ // 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) {
@@ -249,7 +382,7 @@
const dpr = window.devicePixelRatio || 1;
const W = wrap.clientWidth;
- const H = 26;
+ 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));
@@ -264,27 +397,32 @@
const N = peaks.length;
const mid = H / 2;
- // Thin waveform floating in the middle: cap the amplitude well short
- // of the edges so loud sections don't clip flat, and keep a small
- // baseline so quiet sections still read as a hairline.
- const maxAmp = 7;
- const amp = (v) => Math.max(0.7, v * maxAmp);
const geometryChanged =
resized ||
wavePathPeaks !== peaks ||
wavePathWidth !== W ||
wavePathHeight !== H;
if (geometryChanged) {
+ // Discrete bars on the SAME pitch the Now Playing rail draws — a
+ // 2px bar on a 4px gap, 1px radius — so the two waveforms in the
+ // window read as one instrument rather than two. Both derive their
+ // bar count from that pitch, so neither can drift from the other.
+ // Mirrored about the centre line here, where the lane is short and
+ // wide; the rail stands its bars on the floor because it sits above
+ // a caption.
const path = new Path2D();
- path.moveTo(0, mid - amp(peaks[0]));
- const divisor = Math.max(1, N - 1);
- for (let i = 0; i < N; i++) {
- path.lineTo((i / divisor) * W, mid - amp(peaks[i]));
+ const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
+ for (let i = 0; i < count; i++) {
+ const from = Math.floor((i / count) * N);
+ const to = Math.max(from + 1, Math.floor(((i + 1) / count) * N));
+ let sum = 0;
+ for (let j = from; j < to; j++) sum += peaks[j] ?? 0;
+ // The rail's shaping, verbatim: capped so a loud passage cannot
+ // flatten into a ruler, then gamma'd back up.
+ const v = sum / (to - from);
+ const h = Math.max(2, Math.pow(Math.min(0.88, v * 1.18), 0.8) * H);
+ path.roundRect(i * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
}
- for (let j = N - 1; j >= 0; j--) {
- path.lineTo((j / divisor) * W, mid + amp(peaks[j]));
- }
- path.closePath();
wavePath = path;
wavePathPeaks = peaks;
wavePathWidth = W;
@@ -322,15 +460,14 @@
// `accentHover` remains reserved for hover previews and controls.
fillClip(0, playX, theme.accent);
- // Playhead / hover markers: fixed height, padded off the edges,
- // so they read as small ticks rather than full-height rules.
- const LINE_H = 18;
- const lineY = (H - LINE_H) / 2;
- ctx.fillStyle = PLAYHEAD;
- ctx.fillRect(playX - 0.75, lineY, 1.5, LINE_H);
+ // 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, lineY, 1, LINE_H);
+ ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
}
lastPlayX = playX;
@@ -381,24 +518,50 @@
Nothing playing
{/if}
+
{#if player.status === "buffering"}
- {metaParts[0] || "buffering…"}
- {:else if player.track}
- {#if metaParts[0]}
-
- {/if}
- {#if metaParts[1]}
-
- {/if}
+ {metaParts[0] || "buffering…"}
+ {:else if player.track && (metaParts[0] || metaParts[1])}
+
+ {#if metaParts[0]}
+
+ {/if}
+ {#if metaParts[0] && metaParts[1]}·{/if}
+ {#if metaParts[1]}
+
+ {/if}
+
{/if}
-