From 3f4f5c305af7b26c0c0da4cbd61935f0de4a395a Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Wed, 29 Jul 2026 18:46:31 -0500
Subject: [PATCH] now playing: the transfer moves into the bar, and the panel
speaks the app's headings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hover popover is deleted. It showed the peer, the rate, a percentage and
a second progress bar — every one of which the status line and the hairline
beside it already carry — so it was a duplicate you had to hover for. The two
things unique to it move up: the save action into the bar, and nothing else.
Deleted with it, the reveal action, its 250ms close grace and the
`.np-statuswrap` hit area, all of which existed only to make a popover
reachable across the 25px gap between the status line and itself.
Release sections were the one heading in the app that was 13px, Title Case,
full white on an opaque sticky bar; every other one — `.lab` here,
`.disc-head` and `.rail-lab` on the catalog pages — is 11px uppercase muted.
They now match, with the hairline rule the catalog pages use, and they no
longer pin: the bar existed only to hide rows sliding under a sticky heading.
Those headings sit on the blurred record, not a flat page, so `--text-muted`
and #5c5c5c were chosen against a background that is not there. Each carries
a dark plate now, years get the album page's over-artwork treatment, and the
hairline goes 0.07 -> 0.14, which over a bright cover was the difference
between a faint line and no line.
"From the album" rendered `player.queue`, which the album fill grows ONE
TRACK AT A TIME as each Soulseek source resolves — a nine-track record read
"5 tracks" while you watched the rest arrive. It renders the record's own
tracklist now, matched to queue items by provider id, and a row with no
source yet resolves on demand when clicked.
The About pane gains a record head, a lede, stat tiles, pills and a source
line, all re-laid-out from data already in hand. The artist page's expander
gets the same lede and pills — no record head or tiles there, because its
rail already carries them.
---
src/components/NowPlaying.svelte | 552 +++++++++++++++++++++----------
src/views/CatalogArtist.svelte | 92 +++++-
2 files changed, 459 insertions(+), 185 deletions(-)
diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte
index b79ac25..4c354d6 100644
--- a/src/components/NowPlaying.svelte
+++ b/src/components/NowPlaying.svelte
@@ -7,6 +7,7 @@
getArt,
getCatalogArt,
jumpToQueueIndex,
+ streamSong,
discovery,
loadDiscovery,
openCatalogArtist,
@@ -29,6 +30,7 @@
import SaveMenu from "./SaveMenu.svelte";
import * as api from "../lib/api.js";
import { floatScroll } from "../lib/scrollbar.js";
+ import { splitDescription } from "../lib/editions.js";
import { accentFromImage } from "../lib/color.js";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
@@ -81,9 +83,11 @@
const quality = $derived(currentItem?.quality ?? null);
// Album context: the queue is a catalog album/continuation with >1 track.
- const albumMode = $derived(
- !!player.catalogRef.albumId && player.queue.length > 1,
- );
+ // Knowing the record is enough. It used to also require a queue longer than
+ // one, which meant the album section could not appear until the background
+ // fill had sourced a SECOND track — so playing a single track showed
+ // nothing, then a partial list.
+ const albumMode = $derived(!!player.catalogRef.albumId);
// Artist name for the Save action: album items carry "artist — album".
const artistName = $derived.by(() => {
@@ -245,13 +249,31 @@
let previous = null;
const rows = items.map((album) => {
const year = key === "upcoming" ? "Upcoming" : albumYear(album) || "—";
- const heading = year === previous ? null : year;
+ // A divider that only repeats the section it sits under is
+ // noise: Upcoming headed its rows with the word "Upcoming"
+ // again, directly below a heading already reading Upcoming.
+ const heading =
+ year === previous || year.toLowerCase() === label.toLowerCase()
+ ? null
+ : year;
previous = year;
return { album, heading };
});
return [{ key, label, count: items.length, rows }];
});
});
+ /** The artwork this row is already painting, handed to the album page so
+ * its skeleton opens with the record's cover rather than a grey block. */
+ function albumSeed(album) {
+ if (!album?.cover || artBroken[album.cover]) return null;
+ return {
+ cover: album.cover,
+ title: album.title ?? null,
+ artist: album.artist ?? discovery?.name ?? null,
+ kind: album.record_type ?? null,
+ tracks: album.track_count ?? null,
+ };
+ }
function fmtFans(n) {
if (!n) return "—";
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
@@ -416,6 +438,15 @@
/** The hairline has no percentage to show until bytes start arriving. */
const hairlineSeeking = $derived(isStream && player.streamTotal === 0);
+ // The hover-revealed transfer popover is gone. It showed the peer, the
+ // rate, a percentage and a second progress bar — every one of which the
+ // status line and the hairline beside it already carry — so it was a
+ // duplicate that had to be hovered for. What was unique to it now lives in
+ // the bar: the peer as a monogram, and the save action. Deleted with it:
+ // the reveal action, its 250ms close grace, and the `.np-statuswrap` hit
+ // area, all of which existed only to make a popover reachable across the
+ // 25px gap between the status line and itself.
+
// ---- drag to resize ----------------------------------------------------
// Pointer capture, like the player bar's scrubber: the pointer keeps
// delivering moves after it leaves the 9px grip, which a mousemove
@@ -576,29 +607,133 @@
const listenersOf = (item) =>
item?.trackId != null ? (listenersById.get(String(item.trackId)) ?? null) : null;
- /** "7 October 2014 · 13 tracks · 42 min", from whichever parts are known. */
- const creditsLine = $derived.by(() => {
- const parts = [];
- const date = catalogPage?.album?.release_date ?? null;
- if (date) {
- const d = new Date(date);
- parts.push(
- isNaN(d)
- ? date
- : d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }),
- );
+ /** The RECORD's tracklist, not the queue's progress through it.
+ *
+ * This section rendered `player.queue` directly, and the queue is grown one
+ * track at a time by the album fill — each row appeared only once its
+ * Soulseek source had resolved. So a nine-track record read "5 tracks"
+ * while you watched the rest arrive one by one. `catalogPage.tracks` is the
+ * whole record in a single fetch, and it now lands early (see the
+ * `catalogRef` assignment in `streamSong`), so the list is complete
+ * immediately and the queue is demoted to what it actually is: playback
+ * state layered on top.
+ *
+ * A row with no queue item yet is still playable — clicking it resolves a
+ * source on demand, exactly as the fill would have. Falls back to the queue
+ * whenever the record is unknown (a local album, a plain queue). */
+ const albumRows = $derived.by(() => {
+ const queue = player.queue ?? [];
+ const tracks = catalogPage?.tracks ?? [];
+ if (!albumMode || !tracks.length) {
+ return queue.map((item, index) => ({
+ key: `q${index}`,
+ title: item.title,
+ duration: item.duration ?? null,
+ listeners: listenersOf(item),
+ queueIndex: index,
+ trackId: item.trackId ?? null,
+ }));
}
- const n = catalogPage?.tracks?.length ?? player.queue.length;
- if (n) parts.push(`${n} track${n === 1 ? "" : "s"}`);
- const secs = (catalogPage?.tracks ?? player.queue).reduce(
+ // Match by provider id first; a title match is the fallback for items
+ // queued before an id was known.
+ const byTrackId = new Map();
+ const byTitle = new Map();
+ queue.forEach((item, index) => {
+ if (item.trackId != null) byTrackId.set(String(item.trackId), index);
+ const key = norm(item.title);
+ if (key && !byTitle.has(key)) byTitle.set(key, index);
+ });
+ return tracks.map((t, i) => ({
+ key: `t${t.id ?? i}`,
+ title: t.title,
+ duration: t.duration_secs ?? t.duration ?? null,
+ listeners: t.listeners ?? null,
+ queueIndex:
+ (t.id != null ? byTrackId.get(String(t.id)) : undefined)
+ ?? byTitle.get(norm(t.title))
+ ?? -1,
+ trackId: t.id ?? null,
+ }));
+ });
+
+ /** Play a row. Already sourced → jump to it; not yet → resolve it now. */
+ function playAlbumRow(row) {
+ if (row.queueIndex >= 0) {
+ jumpToQueueIndex(row.queueIndex);
+ return;
+ }
+ streamSong(artistName, row.title, {
+ cover: catalogPage?.album?.cover ?? currentItem?.artUrl ?? null,
+ trackId: row.trackId,
+ albumId: player.catalogRef.albumId,
+ album: albumTitle || null,
+ expectSecs: row.duration ?? null,
+ });
+ }
+
+ // ---- About pane facts ---------------------------------------------------
+ // Everything below is re-laid-out from data already in hand — the record's
+ // catalog payload, its editorial info, and the artist's own releases. None
+ // of it costs a request the panel was not already making.
+
+ /** "My Bloody Valentine · 1991 · 11 tracks", under the record's name. */
+ const recordSub = $derived.by(() => {
+ const year = catalogPage?.album?.release_date?.slice(0, 4) ?? null;
+ const n = catalogPage?.tracks?.length ?? 0;
+ return [artistName, year, n ? `${n} track${n === 1 ? "" : "s"}` : null]
+ .filter(Boolean)
+ .join(" · ");
+ });
+
+ /** Lede + body, from the shared splitter — the album page and the artist
+ * page's expander promote their descriptions with the same function. */
+ const descParts = $derived(splitDescription(albumInfo?.description));
+ const descLede = $derived(descParts.lede);
+ const descRest = $derived(descParts.rest);
+
+ /** Year / tracks / runtime / listeners, from the record's own payload. */
+ const recordTiles = $derived.by(() => {
+ const tiles = [];
+ const year = catalogPage?.album?.release_date?.slice(0, 4);
+ if (year) tiles.push({ label: "Released", value: year });
+ const n = catalogPage?.tracks?.length ?? 0;
+ if (n) tiles.push({ label: "Tracks", value: String(n) });
+ const secs = (catalogPage?.tracks ?? []).reduce(
(sum, t) => sum + (t.duration_secs ?? t.duration ?? 0),
0,
);
if (secs) {
const mins = Math.round(secs / 60);
- parts.push(mins >= 60 ? `${Math.floor(mins / 60)} hr ${mins % 60} min` : `${mins} min`);
+ tiles.push({
+ label: "Runtime",
+ value: mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`,
+ });
}
- return parts.join(" · ");
+ const listeners = catalogPage?.album_listeners ?? null;
+ if (listeners) tiles.push({ label: "Listeners", value: fmtListeners(listeners) });
+ return tiles;
+ });
+
+ /** "3 November 1991" for the pill row. */
+ const releasedOn = $derived.by(() => {
+ const value = catalogPage?.album?.release_date;
+ if (!value) return "";
+ const date = new Date(value);
+ return isNaN(date)
+ ? value
+ : date.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
+ });
+
+ /** "1988–2013" — the span of the artist's own releases, nothing fetched. */
+ const activeYears = $derived.by(() => {
+ const years = (discovery.albums ?? [])
+ .map((a) => Number(a.release_date?.slice(0, 4)))
+ .filter((y) => Number.isFinite(y) && y > 1900);
+ if (!years.length) return "";
+ const first = Math.min(...years);
+ const last = Math.max(...years);
+ if (first === last) return String(first);
+ return last >= new Date().getFullYear() - 1 ? `${first}–present` : `${first}–${last}`;
});
// Width-responsive: the pill's stats spread rather than bunch, and the
@@ -846,38 +981,16 @@
{metaParts[1] || player.track?.title || "Nothing playing"}
+
+ {#if isStream && currentItem?.stream}
+