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} + + {/if} {#if isStream} {/if} - - - {#if isStream && currentItem?.stream} -
-
-
{currentItem.stream.username.slice(0, 1).toUpperCase()}
-
- {currentItem.stream.username} - - {#if cached} - complete · {fmtSize(player.streamTotal)} - {:else if player.streamTotal > 0} - {fmtSize(player.streamReceived)} / {fmtSize(player.streamTotal)}{rateText ? ` · ${rateText}` : ""} - {:else} - connecting… - {/if} - -
- -
- {#if player.streamTotal > 0} -
- {/if} -
- {/if} -
@@ -1007,38 +1120,39 @@ {fmtTime(upNext.duration)} {/if} - {#if player.queue.length > 1} + {#if albumRows.length > 1}
{albumMode ? "From the album" : "Queue"} - {player.queue.length} tracks + {albumRows.length} tracks
- {#each player.queue as item, i} - {@const lis = listenersOf(item)} + {#each albumRows as row, i (row.key)} + {@const isNow = + row.queueIndex >= 0 && row.queueIndex === player.queueIndex} {/each}
@@ -1080,6 +1194,7 @@ {#each releaseSections as section (section.key)}
{section.label} + {section.count}
@@ -1090,7 +1205,7 @@ - {/if} - {#if creditsLine || albumInfo?.label || albumInfo?.genre} -
- Released - {creditsLine} - {#if albumInfo?.label || albumInfo?.genre} -
{albumInfo.label ?? ""}{albumInfo.label && albumInfo.genre ? " · " : ""}{albumInfo.genre ?? ""} +
About this record
+ {#if albumTitle} + +
+ {#if artUrl} + {/if} +
+
{albumTitle}
+ {#if recordSub}
{recordSub}
{/if} +
+
+ {/if} + {#if albumInfo?.description} + + {#if descLede} +

{descLede}

+ {/if} + {#if descRest} +

{descRest}

+ + {/if} + {/if} + {#if recordTiles.length} +
+ {#each recordTiles as tile (tile.label)} +
{tile.value}{tile.label}
+ {/each} +
+ {/if} + {#if albumInfo?.genre || albumInfo?.label || releasedOn} +
+ {#if albumInfo?.genre}{albumInfo.genre}{/if} + {#if albumInfo?.label}{albumInfo.label}{/if} + {#if releasedOn}{releasedOn}{/if}
{/if}
About the artist
{@render artistChip()} + {#if activeYears} +
Active {activeYears}
+ {/if} {#if discovery.bio}

{discovery.bio}

{/key} @@ -1782,20 +1939,31 @@ /* source card */ /* save action row */ /* tracklist */ + /* Every heading in this panel rides the blurred record, not a flat dark + page: `.bleed-fade` is only rgba(24,24,24,0.15) at the top and does not + reach solid until 78% down. `--text-muted` and #5c5c5c were chosen + against --bg-base and simply vanish into a bright cover. Each heading + carries its own ground instead — a dark plate fading out to the right, + so the label end is always backed and the row end still shows the + artwork through. */ .lab { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text-muted); + color: rgba(255, 255, 255, 0.74); font-weight: 600; display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; + margin-inline: -8px; + padding: 4px 8px; + border-radius: 5px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14)); } .lab .cnt { letter-spacing: 0; - color: #5c5c5c; + color: rgba(255, 255, 255, 0.5); } /* Section spacing belongs to the label, not to whatever happens to sit above it — an artist with no social links used to leave the releases @@ -2089,24 +2257,6 @@ min-width: 0; } - /* The album page's credits block, verbatim, so a record reads the same in - both places. */ - .credits { - margin-top: 18px; - color: var(--text-muted); - font-size: 11.5px; - line-height: 1.7; - } - .credits .clab { - display: block; - font-size: 10px; - font-weight: 800; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #5e5e5e; - margin-bottom: 4px; - } - p.bio { margin: 8px 0 0; font-size: 12.5px; @@ -2136,7 +2286,96 @@ transform: rotate(180deg); } - /* ---- transfer detail, on hovering the header ---- */ + /* ---- About pane ---- */ + /* The record naming itself, so the copy below need not carry the identity. */ + .rechead { display: flex; gap: 11px; margin-bottom: 9px; } + .rechead .cv { + width: 62px; + height: 62px; + border-radius: 6px; + flex: none; + object-fit: cover; + box-shadow: 0 8px 18px -8px rgba(0, 0, 0, 0.8); + } + .rh-copy { min-width: 0; } + .rechead .rt { + font-size: 14px; + font-weight: 800; + color: #fff; + line-height: 1.2; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + } + .rechead .rs { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + margin-top: 3px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + } + /* The description's own first sentence, promoted — see `descLede`. */ + .lede { + font-size: 13.5px; + line-height: 1.5; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + margin: 0 0 8px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + /* Auto-fit, because this pane is resizable: the row goes two-up rather than + crushing four tiles into 272px. */ + .tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(74px, 1fr)); + gap: 6px; + margin: 10px 0; + } + .tile { + padding: 8px 9px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.055); + } + .tile b { + display: block; + font-size: 14px; + font-weight: 800; + color: #fff; + line-height: 1.1; + font-variant-numeric: tabular-nums; + } + .tile i { + font-style: normal; + display: block; + margin-top: 3px; + font-size: 8.5px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 700; + } + .pills { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 2px; } + .pill { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 3px 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(255, 255, 255, 0.07); + } + .pill.accent { + background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4)); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + color: #fff; + } + .srcs { + margin-top: 14px; + font-size: 9.5px; + line-height: 1.6; + color: #5e5e5e; + } + .srcs b { color: #7a7a7a; font-weight: 700; } + .headzone { position: absolute; top: 0; @@ -2144,69 +2383,6 @@ right: 0; z-index: 6; } - .xpop { - position: absolute; - top: 46px; - left: 0; - right: 0; - z-index: -1; - padding: 10px 12px 12px; - background: rgba(22, 22, 26, 0.94); - backdrop-filter: blur(16px) saturate(1.5); - -webkit-backdrop-filter: blur(16px) saturate(1.5); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(255, 255, 255, 0.05); - opacity: 0; - transform: translateY(-8px); - pointer-events: none; - transition: opacity 150ms ease, transform 300ms cubic-bezier(0.22, 1, 0.36, 1); - } - .headzone:hover .xpop, - .headzone:focus-within .xpop { - opacity: 1; - transform: none; - pointer-events: auto; - } - .xrow { display: flex; align-items: center; gap: 9px; } - .xav { - width: 26px; - height: 26px; - border-radius: 50%; - flex: none; - display: grid; - place-items: center; - font-size: 11px; - font-weight: 700; - background: color-mix(in srgb, var(--accent) 42%, #333); - color: #fff; - } - .xwho { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; } - .xwho span { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .xwho small { - display: block; - font-weight: 600; - font-size: 10.5px; - color: var(--text-muted); - font-variant-numeric: tabular-nums; - } - .xbar { - height: 4px; - border-radius: 2px; - background: #2b2b2b; - overflow: hidden; - margin-top: 9px; - } - .xbar i { - display: block; - height: 100%; - border-radius: 2px; - background: var(--accent-hover); - transition: width 240ms linear; - } .list { display: flex; flex-direction: column; } .row { display: flex; @@ -2298,31 +2474,53 @@ Dense rows rather than a grid: at the panel's widths a row fits a whole title where a card clamps it to two lines, and twice as many releases fit on a screen. E4 of research/nowplaying-releases/dividers.html. */ + /* The app has exactly one section heading and this is now it: 11px, + uppercase, 0.1em, weight 800, muted, with a hairline running out to the + edge — `.disc-head` on both catalog pages, and a sibling of this panel's + own `.lab`. It used to be 13px Title Case in full white on an opaque + full-bleed bar, the only heading in the app shaped that way, which is + why it read as a design of its own. + + It is no longer sticky. The bar existed only to hide rows sliding under + a pinned heading; painted where it sits it scrolls away like every other + element, which is the same call the segmented control above it got. */ .rel-sec { - position: sticky; - top: 46px; - z-index: 3; - margin: 14px -14px 4px; - padding: 10px 14px 6px; - background: linear-gradient(var(--bg-base) 74%, transparent); display: flex; - align-items: baseline; - justify-content: space-between; + align-items: center; gap: 8px; - font-size: 13px; + margin: 18px -8px 7px; + padding: 4px 8px; + border-radius: 5px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14)); + font-size: 11px; font-weight: 800; - color: var(--text-primary); + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.74); } - .rel-sec .n { font-size: 11px; font-weight: 700; color: var(--text-muted); } - /* Deliberately quieter than the category above it, so the two never read - as the same level of heading. */ + /* 0.07 is the hairline's value on a flat page. Over a bright record it is + not a faint line, it is no line — this one has to survive the cover. */ + .rel-rule { + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.14); + } + .rel-sec .n { letter-spacing: 0; font-weight: 700; color: rgba(255, 255, 255, 0.5); } + /* Quieter than the section above it, so the two never read as the same + level of heading. A year that only repeats its section is dropped + upstream, in `releaseSections`. + + No plate on this one: a year is a minor divider, and a third band of + dark under it made the list read as stripes. It takes the treatment the + album page already gives text standing on artwork — brighter ink and a + drop shadow (`.kind`, `.stat`, `.np-from` all do exactly this). */ .rel-year { - margin: 12px 2px 3px; + margin: 10px 2px 3px; font-size: 10px; font-weight: 800; - letter-spacing: 0.12em; - color: var(--text-muted); - opacity: 0.75; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.72); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); font-variant-numeric: tabular-nums; } .rel-rows { display: flex; flex-direction: column; gap: 2px; } diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index f85c6f8..7c3b832 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -38,6 +38,7 @@ editionLabel, isEdition, releaseFamilies, + splitDescription, } from "../lib/editions.js"; import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { buildArtistManifest } from "../lib/preview/manifest.js"; @@ -2450,6 +2451,27 @@ return sized; } + /** Lede + body for the open record, from the shared splitter. */ + const railDesc = $derived(splitDescription(openInfo?.description)); + + /** What the expander is already showing, handed to the album page so its + * skeleton opens holding this record's artwork instead of a grey block. + * `railCover` on purpose: that exact URL is the one already decoded and in + * memory for the rail, so the seeded cover costs no request and cannot + * flash on its way in. */ + function albumSeed(album) { + if (!album?.cover || artBroken[album.cover]) return null; + return { + cover: railCover(album.cover), + title: album.title ?? null, + artist: album.artist ?? page?.artist?.name ?? null, + kind: album.record_type ?? null, + // The open expander has already fetched the real tracks; its count + // beats the card's metadata, which is missing on some rows. + tracks: openTracks.length || album.track_count || null, + }; + } + function handleCoverError(node, originalCover, title = "") { const fallback = coverLoadError( originalCover, @@ -4746,7 +4768,10 @@

@@ -4787,13 +4812,31 @@

{/if} {#if openInfo?.description} -

{openInfo.description}

- + + {#if railDesc.lede} +

{railDesc.lede}

+ {/if} + {#if railDesc.rest} +

{railDesc.rest}

+ + {/if} + {#if openInfo.genre || openInfo.label} +
+ {#if openInfo.genre}{openInfo.genre}{/if} + {#if openInfo.label}{openInfo.label}{/if} +
+ {/if} {/if} {#if openFamily && openFamily.editions.length > 1}
@@ -7904,6 +7947,39 @@ /* Clamped description dissolves at its bottom edge; the quiet text button below expands it, its chevron rotating. */ + /* The promoted first sentence. Only a little larger than the body: the rail + is 250px wide and the title above it is already 17px, so a bigger lede + would compete with the record's own name. */ + .rail-lede { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.5; + color: rgba(255, 255, 255, 0.9); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + /* Genre and label only — the date is already in `.rail-meta` directly + above, and repeating it cost a second wrapped row in a 210px column. */ + .rail-pills { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + .gpill { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 3px 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(255, 255, 255, 0.07); + } + .gpill.accent { + background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4)); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + color: #fff; + } .rail-desc { margin: 0; font-size: 12.5px;