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}
+