diff --git a/src/App.svelte b/src/App.svelte index 83a8b90..93fdb26 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -19,6 +19,7 @@ import CatalogSearch from "./views/CatalogSearch.svelte"; import CatalogArtist from "./views/CatalogArtist.svelte"; import CatalogAlbum from "./views/CatalogAlbum.svelte"; + import CatalogDiscography from "./views/CatalogDiscography.svelte"; import FollowToast from "./components/FollowToast.svelte"; import { ui, @@ -42,6 +43,7 @@ catalog: CatalogSearch, catalogArtist: CatalogArtist, catalogAlbum: CatalogAlbum, + catalogDiscography: CatalogDiscography, }; const CurrentView = $derived(views[ui.view] ?? Home); @@ -54,8 +56,19 @@ A page added here MUST also take `padding-top: var(--pane-pad)`: the bar cancels the pane's top padding so a page's artwork can run underneath it, and the page restores that inset for its own content. Without it the page - renders 24px too high; with it but not listed here, 24px too low. */ - const BAR_VIEWS = new Set(["catalogArtist", "catalogAlbum", "catalog"]); + renders 24px too high; with it but not listed here, 24px too low. + + `catalogDiscography` is the one exception to the padding rule above, and + deliberately: it pins a blurred ambience layer behind the glass, and that + layer has to begin at the very top of the pane. A padding-top here would + start it a --pane-pad down — halfway through the bar. It takes the inset + on its header's margin instead, which comes to the same place. */ + const BAR_VIEWS = new Set([ + "catalogArtist", + "catalogAlbum", + "catalog", + "catalogDiscography", + ]); initEvents(); autoConnect(); diff --git a/src/lib/navigation.svelte.js b/src/lib/navigation.svelte.js index 61a23dc..5d0418c 100644 --- a/src/lib/navigation.svelte.js +++ b/src/lib/navigation.svelte.js @@ -35,6 +35,8 @@ function entryKey(e) { switch (e.view) { case "catalogArtist": return `artist:${e.artistId}`; + case "catalogDiscography": + return `disco:${e.artistId}`; case "catalogAlbum": return `album:${e.albumId}`; case "catalog": @@ -227,7 +229,11 @@ export function trail() { // artist crumb is taken from history when it is there, so it carries the // portrait and id already resolved, and is synthesised from the record's // own artist name when the album was reached directly. - if (here.view === "catalogAlbum" && here.artist) { + // A discography sits under its artist too, so both branches want the same + // crumb — found in history when it is there (it carries the portrait and id + // already resolved) and synthesised from the record's own artist name when + // the album was reached directly. + if ((here.view === "catalogAlbum" || here.view === "catalogDiscography") && here.artist) { const known = [...nav.stack] .reverse() .find((e) => e.view === "catalogArtist" && e.label === here.artist); diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 074434e..71d1fed 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -966,6 +966,17 @@ export const catalog = $state({ // artistPage (same id, new object, several times a second) from a real one // — keying on that would drag a reader back to the top mid-read. artistOpenSeq: 0, + // The artist page's OWN computed discography tabs, published by it as it + // derives them: `{ artistId, tabs }`. The discography page renders these + // rather than classifying the release list a second time. + // + // It has to work this way. The filing depends on `descHints` — editorial + // release-type evidence the artist page harvests in the background while + // you look at it — and on the MusicBrainz type hints from the deferred + // identity pass. Neither is derivable from the payload alone, so a second + // classifier would read neither and would file some releases differently + // from the page that linked to it. One list, computed once, handed over. + discoTabs: null, albumLoading: false, albumPage: null, // { album, tracks } // What the thing you clicked already knew about the record — cover, title, @@ -1426,6 +1437,10 @@ export async function openEntry(entry) { switch (entry.view) { case "catalogArtist": return openCatalogArtist(entry.artistId); + case "catalogDiscography": + // Falls back to the artist page by itself when the tabs it needs + // are not the ones in hand — see openCatalogDiscography. + return openCatalogDiscography(entry.artistId); case "catalogAlbum": // A local record lives on this same view but is composed from the // library rather than fetched — the back arrow has to route on the @@ -2661,6 +2676,31 @@ export async function openCatalogArtist(artistId, seedArtist = null) { } } +/** + * Open the artist's whole discography on a page of its own. + * + * Reached from the artist page's Discography section, and only from there — + * the tabs it renders are the ones that page published (see `discoTabs`). If + * they are missing or belong to somebody else, this lands on the artist page + * instead, which republishes them and puts "Show all" one click away. That is + * the honest failure: an empty discography would be a lie about the catalogue. + */ +export async function openCatalogDiscography(artistId, seedArtist = null) { + const artist = seedArtist ?? catalog.artistPage?.artist ?? null; + if (!catalog.discoTabs || catalog.discoTabs.artistId !== artistId) { + return openCatalogArtist(artistId, artist); + } + captureScroll(); + ui.view = "catalogDiscography"; + recordVisit({ + view: "catalogDiscography", + label: "Discography", + artistId, + artist: artist?.name ?? null, + glyph: "library", + }); +} + /** Open an album's catalog page (cover + tracklist). `opts.trackId` flags a * track to highlight/scroll to (used by the now-playing song link). */ export async function openCatalogAlbum(albumId, opts = {}) { diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 57dceb8..77f3736 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -31,6 +31,7 @@ albumDisambiguator, openCatalogAlbum, openAlbumFromPreviewItem, + openCatalogDiscography, loadFollows, isFollowed, setFollowed, @@ -2326,6 +2327,16 @@ .filter((tab) => tab.items.length > 0); }); + // Hand the finished filing to the discography page, which renders THIS + // list rather than classifying the releases a second time. It re-publishes + // as the background description harvest and the MB identity pass land, so + // the two pages can never show a release in different tabs — see + // `catalog.discoTabs`. + $effect(() => { + const id = page?.artist?.id; + if (id != null && tabs.length) catalog.discoTabs = { artistId: id, tabs }; + }); + // Albums is the default landing (like Spotify's "Popular releases"); if // an artist has no albums, fall back to the first non-empty tab. The // effect also re-validates as the staged catalog rebuilds `tabs`. Reset @@ -5052,6 +5063,15 @@ {#if tabs.length}

Discography

+ +
{#each tabs as tab (tab.key)} @@ -7697,6 +7717,23 @@ color: var(--text-primary); } + /* Sits at the right end of the head, which is already space-between. The + "Show more" control under Popular is the shape being matched. */ + .disco-all { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12.5px; + font-weight: 800; + color: var(--text-secondary); + padding: 5px 8px; + border-radius: 6px; + } + .disco-all:hover { + color: var(--text-primary); + background: var(--bg-highlight); + } + /* Discography filter tabs. The selected tab uses the app's "accent glass" recipe (same as .ed-badge / .now-badge), so it tints to the artist's scoped accent and reads as one system with the cards below. */ diff --git a/src/views/CatalogDiscography.svelte b/src/views/CatalogDiscography.svelte new file mode 100644 index 0000000..2363e2b --- /dev/null +++ b/src/views/CatalogDiscography.svelte @@ -0,0 +1,1610 @@ + + + (menuOpen = false)} /> + +
+ + + + {#if !artist || !allTabs.length} +

This artist's discography isn't loaded. Open the artist page first.

+ {:else} + +
+
+ {#if artist.picture} +
+ {/if} +
+
+
+ +
+
Discography
+
{artist.name}
+
+ {#if artist.fans != null} + {fmtCount(artist.fans)}fans + {/if} + {items.length}releases + {someUnknown ? "≥" : ""}{known}tracks +
+
+ + +
+
+
+
+
+ + +
+
+ {#each allTabs as t (t.key)} + + {/each} +
+
+ + {#if menuOpen} + + + + {/if} +
+
+ +
+ {items.length} releases · {someUnknown ? "≥" : ""}{known} tracks +
+ + + {#if years.length > 2} +
+
+ {#each years as y (y)} + + {/each} +
+
+ {/if} + + {#each items as r (r.familyKey ?? r.id)} + {@const rows = tracksById[r.id]} + {@const eds = r.editions && r.editions.length > 1 ? r.editions : null} +
+
+
+ +
+ {(r.record_type ?? "album").toUpperCase()} +

22}>{r.title}

+

+ + {#if year(r)}· {year(r)}{/if} + {#if rows?.length ?? r.track_count} + · + {rows?.length ?? r.track_count} tracks + {/if} + {#if runtimeOf(rows)}· {runtimeOf(rows)}{/if} +

+
+ + + +
+
+
+
+ + {#if eds} +
+ Editions +
+ {#each eds as e, i (e.id)} + + {/each} +
+
+ {/if} + + {#if rows?.length} + {@const peak = Math.max(1, ...rows.map((x) => x.listeners || 0))} + {@const hasCount = rows.some((x) => x.listeners != null)} +
+
+ #Title + {#if hasCount}Listeners{/if} + +
+ {#each rows as t (t.id)} + {@const heat = (t.listeners || 0) / peak} +
+ {t.track_no ?? "·"} + {t.title} + {#if hasCount} + + + {fmtCount(t.listeners)} + + + {/if} + {fmtDur(t.duration_secs)} + + + +
+ {/each} +
+ {:else if rows} +

No tracklist for this record.

+ {:else} + + {/if} +
+ {/each} + {/if} +
+ +