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 1fa086c..aa2fc0e 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -2295,6 +2295,8 @@ export class ExploreAlbumDetails extends LitElement { status=${libraryStatusFor(Boolean(track.inLibrary), track.mbid)} entity-type="track" label=${track.title} + request-mbid=${track.mbid} + request-artist=${this.artistName} > `, 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 5b3ada4..09696ee 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -2117,6 +2117,8 @@ export class ExploreArtistDetails extends LitElement { status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)} entity-type="track" label=${t.trackName} + request-mbid=${t.recordingMbid} + request-artist=${t.artistName ?? ''} > `, @@ -2229,6 +2231,8 @@ export class ExploreArtistDetails extends LitElement { status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)} entity-type="album" label=${rg.title} + request-mbid=${rg.releaseGroupMbid} + request-artist=${this.artist?.name ?? ''} size="18" > @@ -2358,6 +2362,8 @@ export class ExploreArtistDetails extends LitElement { status=${status} entity-type="album" label=${rg.title} + request-mbid=${rg.mbid} + request-artist=${this.artist?.name ?? ''} > diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index fddda97..ca360ce 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -1854,6 +1854,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) { status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)} entity-type="album" label=${rg.title} + request-mbid=${rg.mbid} + request-artist=${rg.artistCredit ?? ''} > @@ -1892,6 +1894,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) { status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)} entity-type="track" label=${r.title} + request-mbid=${r.mbid} + request-artist=${r.artistCredit ?? ''} > `, diff --git a/frontend/src/components/library-status-indicator/library-status-indicator.ts b/frontend/src/components/library-status-indicator/library-status-indicator.ts index 2a87de9..6bc4c9f 100644 --- a/frontend/src/components/library-status-indicator/library-status-indicator.ts +++ b/frontend/src/components/library-status-indicator/library-status-indicator.ts @@ -1,6 +1,9 @@ import { LitElement, html, css, nothing } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import { toggleRequest } from '@utils/library-status'; +import { notificationStore } from '@store/notification-store'; +import { describeError } from '@utils/describe-error'; /** * Library status for an entity (artist, album, or track). @@ -17,18 +20,23 @@ export type LibraryStatus = 'in-library' | 'queued' | 'not-in-library'; * Tri-state library status indicator: a small circular badge embedded * in track rows, album cards, and artist cards. * - * **It is a badge, not a control.** It was a ` + `; + } return html` - ${this.iconName() - ? html`` - : nothing} + ${icon} `; } diff --git a/frontend/src/components/top-results-row/top-results-row.ts b/frontend/src/components/top-results-row/top-results-row.ts index 3d97b10..7259d5c 100644 --- a/frontend/src/components/top-results-row/top-results-row.ts +++ b/frontend/src/components/top-results-row/top-results-row.ts @@ -341,6 +341,8 @@ export class TopResultsRow extends LitElement { status=${status} entity-type=${entityType} label=${r.name} + request-mbid=${r.mbid} + request-artist=${r.artistCredit ?? ''} size="22" >`} diff --git a/frontend/src/utils/library-status.ts b/frontend/src/utils/library-status.ts index 8c6326e..50be773 100644 --- a/frontend/src/utils/library-status.ts +++ b/frontend/src/utils/library-status.ts @@ -1,4 +1,6 @@ import { downloadStore } from '@store/download-store'; +import { libraryStore } from '@store/library-store'; +import type { download } from '@go/models'; import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator'; /** @@ -43,3 +45,62 @@ export function libraryStatusFor( return 'not-in-library'; } + +/** What a badge can ask for. Artists are deliberately absent: a + * discography subscription is `explore-artist-details`'s Follow + * button, which can say what it is committing to. */ +export type RequestableEntity = 'album' | 'track'; + +const ENTITY: Record = { + album: 'release-group', + track: 'recording', +}; + +/** + * Add or drop a request for one entity, and report which way it went. + * + * The counterpart to `libraryStatusFor`, here rather than in the badge + * because the badge is one of several things that can ask — + * `explore-album-details`'s "Want this" button is the other, and two + * implementations of "what does wanting something mean" is exactly what + * phase 1 was about. + * + * Returns `'wanted'` or `'cancelled'` so a caller can announce what + * happened; throws if the backend refused, because a badge that + * silently does nothing is what this whole plan is about. + */ +export async function toggleRequest(input: { + mbid: string; + entity: RequestableEntity; + title: string; + artist?: string; +}): Promise<'wanted' | 'cancelled'> { + const existing = downloadStore.requestFor(input.mbid); + + if (existing) { + await downloadStore.removeRequest(existing.id); + + return 'cancelled'; + } + + // A request belongs to a library because that is where its files + // will land. There is always at least one by the time anything is + // on screen — the first-run wizard blocks every pointer event until + // there is — but an explicit failure beats a request filed against + // library 0, which no import would ever match. + const libraryId = await libraryStore.getDefaultLibraryId(); + + if (!libraryId) throw new Error('no library to add this to'); + + await downloadStore.addRequest({ + mbid: input.mbid, + entity: ENTITY[input.entity], + libraryId, + artist: input.artist ?? '', + title: input.title, + scope: 'future', + secondary: false, + } as download.RequestInput); + + return 'wanted'; +} diff --git a/frontend/test/components/library-status.test.ts b/frontend/test/components/library-status.test.ts index c567b5a..af069b9 100644 --- a/frontend/test/components/library-status.test.ts +++ b/frontend/test/components/library-status.test.ts @@ -21,8 +21,16 @@ import '@components/explore-view/explore-view'; import type { Request } from '@store/download-store'; import { libraryStatusFor } from '@utils/library-status'; import { Events } from '../../src/events'; -import { emit, flush, stub } from '@test/support/harness'; -import { fixture, shadow, shadowAll } from '@test/support/render'; +import { notificationStore } from '@store/notification-store'; +import { + calls, + emit, + flush, + lastArgs, + stub, + stubFailure, +} from '@test/support/harness'; +import { fixture, shadow, shadowAll, update } from '@test/support/render'; const SEARCH = 'explore.Service.SearchLocal'; @@ -183,3 +191,171 @@ describe(' badges', () => { ); }); }); + +/** + * Plan 009 phase 3: the badge becomes a button where it can act. + * + * 007 made it `role="img"` because a control that cannot act is worse + * than none, and wrote down what would change the answer: a `