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}
This artist's discography isn't loaded. Open the artist page first.
+ {:else} + +No tracklist for this record.
+ {:else} + + {/if} +