feat(explore): say whether a page is the catalog or your own copy

The album and artist pages draw from two sources and rendered
identically either way. An album showing one track because that is all
you own was indistinguishable from an album that has one track, and
both were indistinguishable from a page still waiting on a background
catalog fetch — so the answer to "is more coming?" was to keep
reloading and find out.

<catalog-scope-notice> names the source in one line: silent for full
catalog data, "still loading" while a fetch may land, "library only"
for an entity with no MBID (which will never fill in, so it points at
Autotag), and a retryable notice when the catalog had nothing to say.

Both pages needed a new distinction to drive it. loadingReleases and
its artist-side equivalents mean "something is renderable", which a
library stand-in satisfies — so catalogPending/catalogLoaded track the
different question of whether the catalog has actually answered.

Also fixes the artist page clobbering its library-hydrated discography
with an empty catalog result. An empty BrowseReleaseGroups means the
index has not built this artist yet, not that they released nothing.
This commit is contained in:
2026-08-11 01:15:11 -04:00
parent 7c3c0e25b9
commit ba35858208
4 changed files with 351 additions and 1 deletions
@@ -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`
<div class="notice ${this.scope}" role="status">
<wa-icon
class=${this.scope === 'loading' ? 'spin' : ''}
name=${copy.icon}
></wa-icon>
<span class="text">${copy.text}</span>
${this.scope === 'unavailable'
? html`<button @click=${this.retry}>Retry</button>`
: nothing}
</div>
`;
}
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;
}
}