diff --git a/frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts b/frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts new file mode 100644 index 0000000..286f3cd --- /dev/null +++ b/frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts @@ -0,0 +1,170 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import { designTokens } from '../../styles/tokens.css'; + +/** + * How much of an album or artist page the user is actually looking at. + * + * The album and artist pages draw from two sources — the MusicBrainz + * catalog, and the local library — and until now the page looked the + * same either way. That is the confusing part: a page showing one + * track because that is all you own is indistinguishable from a page + * showing one track because that is all the album has, and a page that + * is still waiting on a background catalog fetch looks like a page that + * has finished and found nothing. + * + * - `catalog` — full catalog data. Nothing is rendered; the normal + * case does not need a banner. + * - `loading` — a catalog fetch is in flight; what is on screen is + * the library copy, standing in. + * - `library` — the entity carries no MusicBrainz ID, so the catalog + * has nothing to say about it, now or later. + * - `unavailable` — the catalog was asked and did not answer (offline, + * timeout, error). Retrying is meaningful here, and + * only here. + */ +export type CatalogScope = 'catalog' | 'loading' | 'library' | 'unavailable'; + +/** + * One-line banner naming the source of what is on screen. + * + * Emits `catalog-retry` (bubbling, composed) when the user asks for + * another attempt, which only appears for the `unavailable` scope. + */ +@customElement('catalog-scope-notice') +export class CatalogScopeNotice extends LitElement { + @property({ type: String }) + scope: CatalogScope = 'catalog'; + + /** What the page is about, so the copy can name it. */ + @property({ type: String, attribute: 'entity-type' }) + entityType: 'album' | 'artist' = 'album'; + + static override styles = [ + designTokens, + css` + :host { + display: block; + } + + .notice { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + font-size: var(--yj-text-sm, 12px); + line-height: 1.4; + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); + color: var(--yj-text-secondary, #b3b3b3); + } + + .notice.unavailable { + color: var(--yj-text-primary, #fff); + } + + wa-icon { + flex-shrink: 0; + } + + .text { + flex: 1; + min-width: 0; + } + + button { + flex-shrink: 0; + background: none; + border: 1px solid var(--yj-border-subtle, #333); + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: var(--yj-text-sm, 12px); + padding: 3px 10px; + } + + button:hover { + border-color: var(--yj-accent, #ffd43b); + } + + .spin { + animation: spin 1.4s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `, + ]; + + override render() { + if (this.scope === 'catalog') return nothing; + + const entity = this.entityType; + const copy = this.copyFor(entity); + + return html` +
+ + ${copy.text} + ${this.scope === 'unavailable' + ? html`` + : nothing} +
+ `; + } + + private copyFor(entity: 'album' | 'artist'): { + icon: string; + text: string; + } { + const thing = entity === 'album' ? 'album' : 'artist'; + + switch (this.scope) { + case 'loading': + return { + icon: 'rotate', + text: `Showing what your library has while the full ${thing} details load\u2026`, + }; + case 'library': + return { + icon: 'database', + text: + `Library only \u2014 this ${thing} isn't matched to MusicBrainz, ` + + 'so only what you already have is shown. Tag it in Autotag ' + + 'to see the rest.', + }; + case 'unavailable': + return { + icon: 'triangle-exclamation', + // Deliberately covers both "the fetch failed" and + // "the catalog has nothing for this one": the user + // cannot tell those apart and does not need to — + // what matters is that this page is their own copy. + text: + `No catalog details for this ${thing} right now, so this is your ` + + 'library copy \u2014 anything you do not own is missing from this page.', + }; + default: + return { icon: 'circle-info', text: '' }; + } + } + + private retry = () => { + this.dispatchEvent( + new CustomEvent('catalog-retry', { bubbles: true, composed: true }), + ); + }; +} + +declare global { + interface HTMLElementTagNameMap { + 'catalog-scope-notice': CatalogScopeNotice; + } +} diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index 7d2d88e..e387488 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -19,6 +19,8 @@ import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '../library-status-indicator/library-status-indicator.js'; +import '../catalog-scope-notice/catalog-scope-notice.js'; +import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js'; import '@awesome.me/webawesome/dist/components/button/button.js'; import '../download-picker/download-picker'; import { downloadStore } from '../../store/download-store'; @@ -113,6 +115,13 @@ export class ExploreAlbumDetails extends LitElement { @state() private loadingReleases = true; @state() private errorInfo = ''; @state() private errorReleases = ''; + /** True once the versions/tracklist on screen came from the catalog + * rather than standing in from the local library. */ + @state() private catalogReleasesLoaded = false; + /** True while a catalog fetch (foreground or background) may still + * land. Distinct from loadingReleases, which goes false as soon as + * *something* is renderable — including a library stand-in. */ + @state() private catalogPending = false; /** Unified entries shown in the dropdown — synthetics first, then real clusters. */ @state() private versionEntries: VersionEntry[] = []; /** Currently-selected dropdown entry (by VersionEntry.key). */ @@ -548,6 +557,7 @@ export class ExploreAlbumDetails extends LitElement { if (this.releasesReloaded.has(mbid)) return; this.releasesReloaded.add(mbid); + this.catalogPending = false; if (this.releases.length === 0) this.loadingReleases = false; }, 12000); } @@ -646,6 +656,8 @@ export class ExploreAlbumDetails extends LitElement { this.errorReleases = ''; this.loadingInfo = true; this.loadingReleases = true; + this.catalogReleasesLoaded = false; + this.catalogPending = Boolean(this.releaseGroupMBID); this.releases = []; this.versionEntries = []; this.selectedVersionKey = ''; @@ -930,6 +942,8 @@ export class ExploreAlbumDetails extends LitElement { if (releases && releases.length > 0) { // Warm cache hit (or the background re-fetch landed): // authoritative MB versions replace any local placeholder. + this.catalogReleasesLoaded = true; + this.catalogPending = false; this.releases = releases; this.buildClusters(); this.loadingReleases = false; @@ -944,11 +958,18 @@ export class ExploreAlbumDetails extends LitElement { if (this.releases.length > 0 || this.releasesReloaded.has(mbid)) { this.loadingReleases = false; } + + // A cold miss after the background fetch already signalled + // ready is as far as the catalog is going to get. + if (this.releasesReloaded.has(mbid)) { + this.catalogPending = false; + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.errorReleases = msg; console.error(`[explore-album] BrowseReleases error: ${msg}`); this.loadingReleases = false; + this.catalogPending = false; } } @@ -1452,6 +1473,11 @@ export class ExploreAlbumDetails extends LitElement { return html` ${this.renderHeader()}
+ ${this.renderVersionSelector()} ${this.renderTracklist()}
@@ -1675,6 +1701,38 @@ export class ExploreAlbumDetails extends LitElement { `; } + /** + * Where the tracklist on screen came from. The distinction the + * user cares about is not "did a fetch fail" but "is what I am + * looking at everything, or only my own copy" — so an album with + * no MBID is `library` permanently, while one whose catalog fetch + * has not landed is `loading` and then either resolves or degrades + * to `unavailable`. + */ + private catalogScope(): CatalogScope { + if (!this.releaseGroupMBID) return 'library'; + if (this.catalogReleasesLoaded) return 'catalog'; + if (this.catalogPending) return 'loading'; + + // Not pending and no catalog data: the browse errored, came + // back empty, or the fallback timer gave up. Whatever is on + // screen is the library copy, and retrying is worth offering. + return 'unavailable'; + } + + /** Ask the catalog again after a failed or empty fetch. */ + private retryCatalog = () => { + const mbid = this.releaseGroupMBID; + if (!mbid) return; + + this.errorReleases = ''; + this.loadingReleases = this.releases.length === 0; + this.catalogPending = true; + this.releasesReloaded.delete(mbid); + this.armReleasesFallback(mbid); + void this.fetchReleases(mbid); + }; + /** Tracks of the version currently selected in the dropdown. */ private currentTracks(): MBTrack[] { const entry = this.versionEntries.find( diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index c7a4f3f..403cf9a 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -32,6 +32,8 @@ import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '../library-status-indicator/library-status-indicator.js'; +import '../catalog-scope-notice/catalog-scope-notice.js'; +import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js'; /* ── Constants ── */ @@ -103,6 +105,12 @@ export class ExploreArtistDetails extends LitElement { @state() private loadingReleases = true; @state() private errorArtist = ''; @state() private errorReleases = ''; + /** True once the discography on screen came from the catalog rather + * than standing in from the local library. */ + @state() private catalogLoaded = false; + /** True while a catalog fetch — foreground or the background + * discography build — may still land. */ + @state() private catalogPending = false; @state() private similarArtists: LBSimilarArtist[] = []; @state() private loadingSimilar = true; @state() private artistImageURL = ''; @@ -920,6 +928,7 @@ export class ExploreArtistDetails extends LitElement { this.discogReloaded.add(mbid); this.similarReloaded.add(mbid); + this.catalogPending = false; if (this.topTracks.length === 0) this.loadingTracks = false; if (this.topReleaseGroups.length === 0) this.loadingTopReleases = false; if (this.releaseGroups.length === 0) this.loadingReleases = false; @@ -1037,6 +1046,8 @@ export class ExploreArtistDetails extends LitElement { // forever if ArtistDiscographyReady never arrives. this.discogReloaded.delete(mbid); this.similarReloaded.delete(mbid); + this.catalogLoaded = false; + this.catalogPending = true; this.armDiscogFallback(mbid); // Phase 1: fire all API requests independently so the UI @@ -1246,6 +1257,35 @@ export class ExploreArtistDetails extends LitElement { } } + /** + * Where this page's discography came from. An artist with no MBID + * can only ever show what the library holds; one whose catalog + * fetch is still in flight says so rather than looking finished. + */ + private catalogScope(): CatalogScope { + if (!this.artistMBID) return 'library'; + if (this.catalogLoaded) return 'catalog'; + if (this.catalogPending) return 'loading'; + + return 'unavailable'; + } + + /** Ask the catalog again after a failed or empty discography fetch. */ + private retryCatalog = () => { + const mbid = this.artistMBID; + if (!mbid) return; + + this.errorReleases = ''; + this.catalogPending = true; + this.discogReloaded.delete(mbid); + this.similarReloaded.delete(mbid); + this.armDiscogFallback(mbid); + void this.fetchTopTracks(mbid); + void this.fetchTopReleaseGroups(mbid); + void this.fetchReleaseGroups(mbid); + void this.fetchSimilarArtists(mbid); + }; + private async fetchArtist(mbid: string) { try { this.artist = await LookupArtist(mbid); @@ -1396,7 +1436,17 @@ export class ExploreArtistDetails extends LitElement { private async fetchReleaseGroups(mbid: string) { try { const rgs = await BrowseReleaseGroups(mbid); - this.releaseGroups = rgs ?? []; + + // An empty result is "the index has not built this artist + // yet", not "this artist released nothing" — so it must not + // wipe the library albums hydrateFromCache put on screen. + if (rgs && rgs.length > 0) { + this.releaseGroups = rgs; + this.catalogLoaded = true; + this.catalogPending = false; + } else if (this.discogReloaded.has(mbid)) { + this.catalogPending = false; + } // Populate libraryMBIDs from the inLibrary flag (already // set by the backend via local_release_group_id cross-ref). @@ -1414,6 +1464,7 @@ export class ExploreArtistDetails extends LitElement { } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.errorReleases = msg; + this.catalogPending = false; console.error( `[explore-artist] BrowseReleaseGroups error: ${msg}`, ); @@ -1889,6 +1940,11 @@ export class ExploreArtistDetails extends LitElement {
+ ${this.renderTopSection()} ${this.renderDiscography()} ${this.renderSimilarArtists()}
diff --git a/frontend/test/components/catalog-scope-notice.test.ts b/frontend/test/components/catalog-scope-notice.test.ts new file mode 100644 index 0000000..e85ba66 --- /dev/null +++ b/frontend/test/components/catalog-scope-notice.test.ts @@ -0,0 +1,66 @@ +/** + * The scope notice exists because an album or artist page looked + * identical whether it was showing the catalog, a library stand-in, or + * nothing yet. So the assertions here are about the one thing it must + * never do — stay silent when the page is not the whole story — and + * about staying out of the way when it is. + */ +import { describe, expect, it } from 'vitest'; + +import '@components/catalog-scope-notice/catalog-scope-notice'; +import { fixture, shadow, text } from '@test/support/render'; + +describe('catalog scope notice', () => { + it('renders nothing at all for full catalog data', async () => { + const el = await fixture('catalog-scope-notice', { scope: 'catalog' }); + + expect(shadow(el, '.notice')).toBeNull(); + }); + + it('names the entity it is talking about', async () => { + const album = await fixture('catalog-scope-notice', { + scope: 'library', + entityType: 'album', + }); + const artist = await fixture('catalog-scope-notice', { + scope: 'library', + entityType: 'artist', + }); + + expect(text(album, '.text')).toContain('album'); + expect(text(artist, '.text')).toContain('artist'); + }); + + it('distinguishes "still loading" from "this is all there is"', async () => { + const loading = await fixture('catalog-scope-notice', { scope: 'loading' }); + const library = await fixture('catalog-scope-notice', { scope: 'library' }); + + expect(text(loading, '.text')).toContain('load'); + expect(text(library, '.text')).toContain('Library only'); + }); + + it('offers a retry only where retrying could change anything', async () => { + for (const scope of ['loading', 'library']) { + const el = await fixture('catalog-scope-notice', { scope }); + + expect(shadow(el, 'button')).toBeNull(); + } + + const el = await fixture('catalog-scope-notice', { scope: 'unavailable' }); + + expect(shadow(el, 'button')).not.toBeNull(); + }); + + it('asks its host to retry rather than fetching anything itself', async () => { + const el = await fixture('catalog-scope-notice', { scope: 'unavailable' }); + + let asked = 0; + el.addEventListener('catalog-retry', () => { + asked += 1; + }); + + shadow(el, 'button')!.click(); + + expect(asked).toBe(1); + }); +});