import { avatarBackground } from '@utils/avatar-color'; import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { designTokens } from '../../styles/tokens.css'; import { LookupArtist, BrowseReleaseGroups, TopRecordingsForArtist, TopReleaseGroupsForArtist, SimilarArtists, GetArtistImageURL, GetArtistImageCachedPath, GetThumbnail, GetThumbnails, GetTrackThumbnail, GetTrackThumbnails, ResolveReleaseGroupMBIDs, PrefetchReleases, } from '@go/explore/service.js'; import type * as explore from '@go/explore/models.js'; type MBArtist = explore.MBArtist; type MBReleaseGroup = explore.MBReleaseGroup; type LBTopRecording = explore.LBTopRecording; type LBTopReleaseGroup = explore.LBTopReleaseGroup; type LBSimilarArtist = explore.LBSimilarArtist; import { exploreCache } from '../../store/explore-cache'; import { libraryStore } from '../../store/library-store'; import { downloadStore } from '../../store/download-store'; import '@awesome.me/webawesome/dist/components/button/button.js'; import { trackLink, exploreLinkStyles } from '../../utils/explore-link'; import { describeError } from '../../utils/describe-error'; import { GetAlbumsByArtist, GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs, } from '@go/library/library.js'; 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 { albumBadgeFor, libraryStatusFor, toggleRequest, } from '@utils/library-status'; import { isOwned, ownershipLabel, unownedStyles, } from '@utils/ownership'; import { completenessStore } from '@store/completeness-store'; import '../catalog-scope-notice/catalog-scope-notice.js'; import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js'; import { queueStore } from '../../store/queue-store'; import type { QueueSource } from '../../store/queue-store'; import { notificationStore } from '../../store/notification-store'; import '../notifications/inline-notice'; import { ContextMenuController, contextMenuStyles, isContextMenuKey, } from '@utils/context-menu-controller.js'; import type { ContextMenuHost } from '@utils/context-menu-controller.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js'; import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import { dict, dictByName } from '@utils/binding'; import type { TrackDetails } from '@components/track-details/track-details.js'; import { showTrackDetailsForPath } from '@utils/track-details-opener.js'; import '@components/playlist-picker/playlist-picker.js'; import { ICON_CAN_REQUEST, ICON_PLAYLIST, ICON_QUEUE, ICON_REQUESTED, } from '@utils/icon-language'; /* ── Constants ── */ /** The region the artist header's own failures are rendered in. */ export const ExploreArtistRegion = 'explore-artist'; /** * A release the context menu can act on, whichever shape it came from. * * The page shows release groups in two forms — the top section's * `LBTopReleaseGroup` and the discography's `MBReleaseGroup` — and the * three questions the menu asks are not the same question: playback * needs a *local album id*, a request needs a *catalog MBID*, and * "owned" is neither on its own. */ interface ReleaseMenuTarget { /** The catalog release-group MBID, or '' for a library-only release. */ mbid: string; /** The local album id, or 0 when nothing local backs it. */ localId: number; title: string; owned: boolean; } /** What the shared context menu panel is currently about. */ type ContextMenuTarget = | { kind: 'track'; track: LBTopRecording } | { kind: 'release'; release: ReleaseMenuTarget }; /** Desired section order for grouping release types. */ const TYPE_ORDER = ['Albums', 'EP', 'Single', 'Other Albums']; /** * Secondary types that move an "Album" out of the studio albums * bucket and into "Other Albums". */ const NON_STUDIO_SECONDARY_TYPES = new Set([ 'Compilation', 'Soundtrack', 'Live', 'Remix', 'Spokenword', 'Interview', 'DJ-mix', 'Mixtape/Street', 'Demo', 'Audio drama', ]); /* ── Utility functions (duplicated from explore-view per design decision) ── */ function extractYear(dateStr: string): string { if (!dateStr) return ''; return dateStr.substring(0, 4); } function formatListenCount(count: number): string { if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; return String(count); } /* ── Component ── */ @customElement('explore-artist-details') export class ExploreArtistDetails extends LitElement implements ContextMenuHost { /* ── Public attributes ── */ @property({ type: String, attribute: 'artist-mbid' }) artistMBID = ''; @property({ type: String, attribute: 'artist-name' }) artistName = ''; @property({ type: Number, attribute: 'local-artist-id' }) localArtistId = 0; /* ── Internal state ── */ @state() private artist: MBArtist | null = null; @state() private topTracks: LBTopRecording[] = []; @state() private topReleaseGroups: LBTopReleaseGroup[] = []; @state() private releaseGroups: MBReleaseGroup[] = []; @state() private loadingArtist = true; @state() private loadingTracks = true; @state() private loadingTopReleases = true; @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 = ''; @state() private similarImageURLs = new Map(); /** Resolved cover art data URLs keyed by release group MBID. */ @state() private thumbnailURLs = new Map(); /** Track thumbnails keyed by recording MBID — resolved via RG * when possible, or the release MBID as fallback. */ @state() private trackThumbnails = new Map(); @state() private topTracksExpanded = false; @state() private topReleasesExpanded = false; private topSectionStacked = false; private topSectionObserver?: ResizeObserver; @state() private expandedDiscoGroups = new Set(); /** Number of album cards that fit in one row of the discography grid. */ @state() private discoRowSize = 5; private discoObserver?: ResizeObserver; @state() private similarExpanded = false; /* ── Release prefetch ── */ /** Top-section mbids awaiting the next coalesced prefetch. */ private pendingTopPrefetch = new Set(); /** Discography mbids awaiting the next coalesced prefetch. */ private pendingPrefetch = new Set(); private prefetchScheduled = false; /** Every mbid already sent, so a refetch does not re-ask. */ private prefetchRequested = new Set(); /* ── Context menu ── */ private ctxMenu = new ContextMenuController(this); /** * What the open context menu applies to. * * A discriminated union rather than one nullable field per kind, * because the panel is shared between the top-tracks list and the * release cards: that is what keeps `aria-label` moving with the * target, which is the fault `cover-grid` shipped — every menu * announced as "Album actions". */ @state() private ctxMenuTarget: ContextMenuTarget | null = null; @query('#context-menu') private contextMenuPopup!: WaPopup; @query('#playlist-submenu') private playlistSubmenuPopup?: WaPopup; @query('track-details') private trackDetailsDialog?: TrackDetails; /** * The open menu's file paths, resolved once per open — see the same * field on the album page. Only a track menu ever has any: a * release's tracks are a different question, and adding a whole * album to a playlist from here is not what this item says. */ private ctxMenuPaths: Promise | null = null; // -- ContextMenuHost interface -- getContextMenuPopup(): WaPopup | undefined { return this.contextMenuPopup; } getPlaylistSubmenuPopup(): WaPopup | undefined { return this.playlistSubmenuPopup; } onContextMenuClose(): void { this.ctxMenuTarget = null; this.ctxMenuPaths = null; } /** The open menu's track, or null when it is not a track menu. */ private get ctxMenuTrack(): LBTopRecording | null { return this.ctxMenuTarget?.kind === 'track' ? this.ctxMenuTarget.track : null; } /** The open menu's release, or null when it is not a release menu. */ private get ctxMenuRelease(): ReleaseMenuTarget | null { return this.ctxMenuTarget?.kind === 'release' ? this.ctxMenuTarget.release : null; } /* ── Styles ── */ static override styles = [ designTokens, exploreLinkStyles, contextMenuStyles, unownedStyles, css` :host { display: flex; flex-direction: column; overflow: hidden; height: 100%; box-sizing: border-box; } /* ── Header ── */ .artist-header { display: flex; align-items: center; gap: 20px; padding: 16px 20px; flex-shrink: 0; border-bottom: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); } .back-button { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: none; border-radius: 50%; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-primary, #fff); cursor: pointer; flex-shrink: 0; transition: background-color 0.15s ease; } .back-button:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12)); } .back-button wa-icon { font-size: 16px; } .artist-avatar { width: 80px; height: 80px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 600; font-size: 32px; text-transform: uppercase; user-select: none; flex-shrink: 0; line-height: 1; overflow: hidden; } .artist-avatar img { width: 100%; height: 100%; object-fit: cover; } .artist-follow { margin-top: 10px; } .artist-info { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .artist-title { font-size: 24px; font-weight: 700; color: var(--yj-text-primary, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 0; line-height: 1.2; } .artist-native-name { font-size: var(--yj-text-md); color: var(--yj-text-secondary, #b3b3b3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; } .artist-meta { font-size: var(--yj-text-md); color: var(--yj-text-secondary, #b3b3b3); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .meta-separator { opacity: 0.4; } /* ── Scrollable content ── */ .content { flex: 1; overflow-y: auto; padding: 20px 24px 32px; display: flex; flex-direction: column; gap: 32px; } /* ── Section headers ── */ .section-header { font-size: 11px; font-weight: 600; color: var(--yj-text-secondary, #b3b3b3); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; } /* ── Loading / error states ── */ .section-loading { color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-md); animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .section-error { display: flex; align-items: center; gap: 6px; color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-sm); } .section-error wa-icon { color: #e5534b; font-size: var(--yj-icon-sm); flex-shrink: 0; } /* ── Top tracks ── */ .track-list { display: flex; flex-direction: column; } .track-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: 6px; cursor: default; transition: background 0.1s ease; } .track-item.owned { cursor: pointer; } .track-item:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.04) ); } .track-item:focus-visible { outline: 2px solid var(--yj-accent-text, #ffd43b); outline-offset: -2px; } .artist-play-actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .track-rank { width: 24px; text-align: right; color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md); font-variant-numeric: tabular-nums; flex-shrink: 0; } .track-art { width: 32px; height: 32px; border-radius: 4px; overflow: hidden; flex-shrink: 0; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); position: relative; display: flex; align-items: center; justify-content: center; } .track-art img { width: 100%; height: 100%; object-fit: cover; display: block; } .track-art wa-icon { font-size: 16px; color: var(--yj-text-tertiary, #888); opacity: 0.5; } .track-info { flex: 1; min-width: 0; } .track-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-artist { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-listens { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-sm); flex-shrink: 0; font-variant-numeric: tabular-nums; white-space: nowrap; } .track-item library-status-indicator { flex-shrink: 0; } /* ── Top section (tracks + releases side-by-side) ── */ .top-section-wrapper { container-type: inline-size; } .top-section-columns { display: grid; grid-template-columns: 1fr minmax(300px, 1fr); gap: 24px; } .top-section-col { min-width: 0; } /* Wide: releases column uses absolute positioning so tracks alone determine the row height. */ .top-section-col-releases { position: relative; } .top-releases-content { position: absolute; inset: 0; display: flex; flex-direction: column; } .top-releases-grid { flex: 1; min-height: 0; } .top-releases-inner { height: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } /* Per-column toggles: hidden in wide mode */ .column-toggle { display: none; } /* Shared toggle: visible in wide mode */ .top-section-toggle { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 12px; margin-top: 12px; border: none; border-radius: 6px; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-sm); cursor: pointer; transition: background 0.15s ease, color 0.15s ease; width: 100%; } .top-section-toggle:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1)); color: var(--yj-text-primary, #fff); } .top-section-toggle wa-icon { font-size: 12px; transition: transform 0.2s ease; } .top-section-toggle[aria-expanded='true'] wa-icon { transform: rotate(180deg); } /* ── Narrow / stacked layout ── */ @container (max-width: 650px) { .top-section-columns { display: flex; flex-direction: column; gap: 16px; } /* Releases column goes back to normal flow */ .top-section-col-releases { position: static; } .top-releases-content { position: static; display: flex; flex-direction: column; } .top-releases-inner { height: auto; } /* Per-column toggles visible */ .column-toggle { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 12px; margin-top: 8px; border: none; border-radius: 6px; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-sm); cursor: pointer; transition: background 0.15s ease, color 0.15s ease; width: 100%; } .column-toggle:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1)); color: var(--yj-text-primary, #fff); } .column-toggle wa-icon { font-size: 12px; transition: transform 0.2s ease; } .column-toggle[aria-expanded='true'] wa-icon { transform: rotate(180deg); } /* Shared toggle hidden */ .top-section-toggle { display: none; } } .top-release-card { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; transition: background 0.15s ease; padding: 4px; border-radius: 6px; min-width: 0; min-height: 0; overflow: hidden; } .top-release-card:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .top-release-card:active { transform: scale(0.97); } .top-release-art { width: 100%; flex: 1; min-height: 0; border-radius: 4px; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; } .top-release-art img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; margin: auto; } .top-release-art .album-art-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } .top-release-art .album-art-fallback wa-icon { font-size: 20px; color: var(--yj-text-tertiary, #888); opacity: 0.5; } .top-release-text { min-width: 0; text-align: center; } .top-release-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .top-release-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); min-height: 18px; } .top-release-meta-text { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .top-release-meta library-status-indicator { flex-shrink: 0; } /* ── Discography grid ── */ .disco-group { display: flex; flex-direction: column; gap: 12px; } .disco-type-header { font-size: var(--yj-text-md); font-weight: 600; color: var(--yj-text-primary, #fff); margin: 0; } .album-grid { display: grid; grid-template-columns: repeat(auto-fill, 140px); gap: 16px; } .album-grid.collapsed { grid-template-rows: 1fr; overflow: hidden; } .disco-toggle { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; margin-top: 4px; border: none; border-radius: 6px; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-xs); cursor: pointer; transition: background 0.15s ease, color 0.15s ease; width: 100%; } .disco-toggle:hover { background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1)); color: var(--yj-text-primary, #fff); } .disco-toggle wa-icon { font-size: 11px; transition: transform 0.2s ease; } .disco-toggle[aria-expanded='true'] wa-icon { transform: rotate(180deg); } .album-card { display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 8px; cursor: pointer; transition: background 0.15s ease; } .album-card:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .album-card:active { transform: scale(0.97); } .album-art-container { width: 100%; aspect-ratio: 1; border-radius: 4px; overflow: hidden; flex-shrink: 0; position: relative; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); } .album-art-container img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: 4px; } .album-art-fallback { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; position: absolute; inset: 0; } .album-art-fallback wa-icon { color: var(--yj-text-tertiary, #888); font-size: 24px; opacity: 0.5; } .album-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .album-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); min-height: 20px; } .album-meta-text { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .album-meta library-status-indicator { flex-shrink: 0; margin-left: auto; } /* ── Similar artists ── */ .similar-row { display: grid; grid-template-columns: repeat(auto-fill, 140px); gap: 16px; overflow: hidden; } .similar-row.collapsed { grid-template-rows: 1fr; overflow: hidden; } .similar-artist-card { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 10px; border-radius: 8px; cursor: pointer; text-align: center; transition: background 0.15s ease; } .similar-artist-card:hover { background: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .similar-artist-card:active { transform: scale(0.97); } .similar-avatar { width: 67px; height: 67px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 600; font-size: 28px; text-transform: uppercase; user-select: none; flex-shrink: 0; overflow: hidden; } .similar-avatar img { width: 100%; height: 100%; object-fit: cover; } .similar-name { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; } `, ]; /* ── Lifecycle ── */ private unsubDiscogReady?: () => void; private unsubSimilarReady?: () => void; /** MBIDs whose ArtistSimilarReady event we've already handled, so a * background similar-artists fetch re-hydrates that section once. */ private similarReloaded = new Set(); /** MBIDs whose ArtistDiscographyReady event we've already handled, * so an artist with no discography can't trigger a re-fetch loop. */ private discogReloaded = new Set(); /** Fallback timer that stops the top-section spinners if the * background discography fetch never signals readiness. */ private discogFallbackTimer?: number; /** Unsubscribe handle for the requests list. */ private unsubRequests: (() => void) | null = null; /** Unsubscribes the "how much of this album is here" repaint. */ private unsubCompleteness: (() => void) | null = null; override connectedCallback() { super.connectedCallback(); if (this.artistMBID || this.localArtistId) { void this.loadAllData(); } // Keep the follow button in step with the requests list, which a // background reconcile pass can change without this page doing // anything. this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate()); void downloadStore.init().then(() => this.requestUpdate()); // The count behind a partly-held album lands a frame after the // cards do, since the store batches a screenful into one query. this.unsubCompleteness = completenessStore.subscribe(() => this.requestUpdate(), ); // A background discography fetch (top tracks / top releases for an // artist that wasn't indexed yet) finished — re-fetch those two // sections, once per artist, so they fill in without the initial // request having blocked. this.unsubDiscogReady = EventsOn( Events.ArtistDiscographyReady, (mbid: string) => { if (mbid !== this.artistMBID) return; if (this.discogReloaded.has(mbid)) return; if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer); this.discogReloaded.add(mbid); void this.fetchTopTracks(mbid); void this.fetchTopReleaseGroups(mbid); // Full discography section is also index-first + async now, // so re-read it from the freshly-populated index too. void this.fetchReleaseGroups(mbid); }, ); // A background similar-artists fetch (LB labs, first view of an // artist) finished — re-fetch that section once per artist. this.unsubSimilarReady = EventsOn( Events.ArtistSimilarReady, (mbid: string) => { if (mbid !== this.artistMBID) return; if (this.similarReloaded.has(mbid)) return; this.similarReloaded.add(mbid); void this.fetchSimilarArtists(mbid); }, ); } override disconnectedCallback() { super.disconnectedCallback(); this.unsubRequests?.(); this.unsubRequests = null; this.unsubCompleteness?.(); this.unsubCompleteness = null; this.unsubDiscogReady?.(); this.unsubSimilarReady?.(); if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer); this.topSectionObserver?.disconnect(); this.discoObserver?.disconnect(); } /** * Arm a one-shot fallback that clears the top-section loading state * if ArtistDiscographyReady never arrives (e.g. the artist genuinely * has no discography, or the background fetch stalled). Treated as a * "reload happened" so the finally blocks resolve to empty state. */ private armDiscogFallback(mbid: string) { if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer); this.discogFallbackTimer = window.setTimeout(() => { if (this.discogReloaded.has(mbid)) return; 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; if (this.similarArtists.length === 0) this.loadingSimilar = false; }, 12000); } protected override firstUpdated() { this.observeTopSectionWidth(); this.observeDiscoWidth(); } protected override updated() { // Re-attach observers if elements appeared after initial render. if (!this.topSectionObserver) { this.observeTopSectionWidth(); } if (!this.discoObserver) { this.observeDiscoWidth(); } } /** * Watch the top-section-wrapper width. When transitioning from * stacked (≤650px) to side-by-side (>650px), sync expand states * so both columns match. */ private observeTopSectionWidth() { const wrapper = this.renderRoot.querySelector('.top-section-wrapper'); if (!wrapper) return; this.topSectionObserver = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; const nowStacked = width <= 650; if (this.topSectionStacked && !nowStacked) { // Transitioning stacked → side-by-side: sync states. // If either is expanded, expand both. if (this.topTracksExpanded || this.topReleasesExpanded) { this.topTracksExpanded = true; this.topReleasesExpanded = true; } } this.topSectionStacked = nowStacked; } }); this.topSectionObserver.observe(wrapper); } /** * Watch the .content width and compute how many album cards * fit in one row of the discography grid. * Grid uses: repeat(auto-fill, minmax(140px, 1fr)) with 16px gap * and album-card has 8px padding on each side. */ private observeDiscoWidth() { const content = this.renderRoot.querySelector('.content'); if (!content) return; const CARD_MIN = 140; const GAP = 16; this.discoObserver = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; const cols = Math.max(1, Math.floor((width + GAP) / (CARD_MIN + GAP))); if (cols !== this.discoRowSize) { this.discoRowSize = cols; } } }); this.discoObserver.observe(content); } /* ── Data Loading ── */ private async loadAllData() { const mbid = this.artistMBID; // Local-only artist (no MBID) — populate from library store. if (!mbid && this.localArtistId) { this.loadingArtist = false; this.loadingTracks = false; this.loadingTopReleases = false; this.loadingReleases = false; this.loadingSimilar = false; this.similarArtists = []; this.hydrateLocalOnly(); return; } // Phase 0: hydrate from caches (instant, no Go calls). this.hydrateFromCache(mbid); // Fresh load for this artist: allow the top sections one // background-fetch re-fetch, and arm a fallback so they can't spin // 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 // renders each section as its data arrives, rather than // waiting for the slowest call to finish. void this.fetchArtist(mbid); void this.fetchTopTracks(mbid); void this.fetchTopReleaseGroups(mbid); void this.fetchReleaseGroups(mbid); void this.fetchSimilarArtists(mbid); // Artist image is fire-and-forget — doesn't block the page. if (!this.artistImageURL) { this.fetchArtistImage(mbid); } // Play count comes from LookupArtist (already populated from index). // checkLibrary now runs from fetchReleaseGroups when data arrives. } /** * Hydrate state from the explore cache and library store. * Shows cached data instantly before API calls complete. */ private hydrateFromCache(mbid: string) { // Artist image: check explore cache first, then library store. if (!this.artistImageURL) { const cachedArtist = exploreCache.getArtist(mbid); if (cachedArtist) { this.artistImageURL = cachedArtist.imageURL || cachedArtist.imageMedium || cachedArtist.imageSmall || ''; } } if (!this.artistImageURL) { const cachedArtists = libraryStore.cachedArtists; if (cachedArtists) { for (const a of cachedArtists) { if (a.MBID === mbid) { this.artistImageURL = a.ImageMedium || a.ImageSmall || ''; break; } } } } // Library albums by this artist — show as discography instantly. const cachedAlbums = libraryStore.cachedAlbums; if (cachedAlbums) { const artistName = this.artistName.toLowerCase(); const libraryAlbums: MBReleaseGroup[] = []; const thumbUpdates = new Map(this.thumbnailURLs); let thumbsChanged = false; for (const a of cachedAlbums) { if (a.ArtistName.toLowerCase() === artistName) { const mbid = a.MBID || `local:${a.ID}`; libraryAlbums.push({ mbid, title: a.Name, primaryType: 'Album', artistCredit: a.ArtistName, firstReleaseDate: a.Year ? String(a.Year) : '', inLibrary: true, localId: a.ID, } as MBReleaseGroup); // Seed the thumbnail map directly from the library // cover art so the renderer doesn't need to call // GetThumbnail. Album cover art lives at // /covers/... served by the Wails dev server. const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art && !thumbUpdates.has(mbid)) { thumbUpdates.set(mbid, art); thumbsChanged = true; } } } if (libraryAlbums.length > 0) { this.releaseGroups = libraryAlbums; this.loadingReleases = false; } if (thumbsChanged) { this.thumbnailURLs = thumbUpdates; } } // Fallback: use album cover art if no artist image found. if (!this.artistImageURL && cachedAlbums) { const artistName = this.artistName.toLowerCase(); for (const a of cachedAlbums) { if (a.ArtistName.toLowerCase() === artistName) { const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art) { this.artistImageURL = art; break; } } } } } /** * Populate the view entirely from library data when the artist * has no MusicBrainz ID. Uses the local artist ID to fetch * albums and resolves the artist image from album cover art. */ private async hydrateLocalOnly() { // Ensure library store is populated before reading. if (!libraryStore.cachedArtists || !libraryStore.cachedAlbums) { try { const pending: Promise[] = []; if (!libraryStore.cachedArtists) { pending.push(libraryStore.getArtists()); } if (!libraryStore.cachedAlbums) { pending.push(libraryStore.getAlbums()); } await Promise.all(pending); } catch { // Non-fatal — continue with whatever's loaded. } } // Artist image: use library store cached artist, then try // the on-disk image cache by MBID if available, then fall // back to album art. const cachedArtists = libraryStore.cachedArtists; let artistMbidForImage = ''; if (cachedArtists) { for (const a of cachedArtists) { if (a.ID === this.localArtistId) { this.artistImageURL = a.ImageMedium || a.ImageSmall || ''; artistMbidForImage = a.MBID || ''; break; } } } if (!this.artistImageURL && artistMbidForImage) { try { const url = await GetArtistImageCachedPath(artistMbidForImage); if (url) this.artistImageURL = url; } catch { // Ignore. } } // Discography: fetch the artist's albums and seed thumbnails // directly from library cover art. try { const albums = await GetAlbumsByArtist( this.artistName, libraryStore.libraryFilter(), ); const thumbUpdates = new Map(this.thumbnailURLs); let thumbsChanged = false; this.releaseGroups = (albums ?? []).map((a) => { const mbid = a.MBID || `local:${a.ID}`; const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art && !thumbUpdates.has(mbid)) { thumbUpdates.set(mbid, art); thumbsChanged = true; } return { mbid, title: a.Name, primaryType: 'Album', artistCredit: a.ArtistName, firstReleaseDate: a.Year ? String(a.Year) : '', inLibrary: true, localId: a.ID, _coverArt: art || '', _inLibrary: true, } as MBReleaseGroup & { _coverArt: string; _inLibrary: boolean }; }); if (thumbsChanged) { this.thumbnailURLs = thumbUpdates; } } catch { this.releaseGroups = []; } // Final fallback: artist image from first album's cover art. if (!this.artistImageURL) { for (const rg of this.releaseGroups) { const art = (rg as MBReleaseGroup & { _coverArt?: string })._coverArt; if (art) { this.artistImageURL = art; break; } } } } /** * 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); } catch (err) { console.error('[explore-artist] LookupArtist error', err); this.errorArtist = describeError( err, 'The catalog did not answer.', ); } finally { this.loadingArtist = false; } } private async fetchTopTracks(mbid: string) { try { const tracks = await TopRecordingsForArtist(mbid); this.topTracks = tracks ?? []; if (!tracks || tracks.length === 0) return; // Resolve track CAA release MBIDs to their parent release // group MBIDs where possible. When a track's CAA release // belongs to a release group in the index, we reuse the // shared RG cache (discography, top releases). When it // doesn't, we fall back to fetching by the release MBID // directly via the backend's GetTrackThumbnail. const caaMbids = tracks .map((t) => t.caaReleaseMbid) .filter((m): m is string => !!m); let mapping: Record = {}; if (caaMbids.length > 0) { try { mapping = await dictByName(ResolveReleaseGroupMBIDs(caaMbids)); } catch (err) { console.warn('[explore-artist] failed to resolve release groups for tracks:', err); } } void this.batchResolveTrackThumbnails(tracks, mapping); } catch (err) { console.error('[explore-artist] TopRecordingsForArtist error', err); } finally { // An empty first pass may mean a background discography fetch // is still in flight (the artist wasn't indexed yet). Keep // the loading state up until the ArtistDiscographyReady // re-fetch runs, so the section doesn't flash empty. if (this.topTracks.length > 0 || this.discogReloaded.has(mbid)) { this.loadingTracks = false; } } } /** * Two-phase track thumbnail loader: checks the backend cache in * one batch call, then fires individual network fetches for any * that are missing. Each track's thumbnail is keyed by the * track's recording MBID so the renderer doesn't have to know * whether the art came from RG or release fallback. */ private async batchResolveTrackThumbnails( tracks: LBTopRecording[], releaseToRG: Record, ) { const needed = tracks.filter( (t) => t.recordingMbid && !this.trackThumbnails.has(t.recordingMbid), ); if (needed.length === 0) return; // Mark all as in-flight. for (const t of needed) { this.trackThumbnails.set(t.recordingMbid, ''); } const requests = needed.map((t) => ({ key: t.recordingMbid, releaseMbid: t.caaReleaseMbid || '', releaseGroupMbid: releaseToRG[t.caaReleaseMbid] || '', albumName: t.releaseName || '', artistName: t.artistName || '', })); // Phase 1: batched cached lookup. let cached: Record = {}; try { cached = await dictByName(GetTrackThumbnails(requests)); if (Object.keys(cached).length > 0) { const updated = new Map(this.trackThumbnails); for (const [key, url] of Object.entries(cached)) { if (url) updated.set(key, url); } this.trackThumbnails = updated; } } catch { // Best-effort. } // Phase 2: per-track network fetches for anything still missing. const uncached = requests.filter((r) => !cached[r.key]); for (const req of uncached) { GetTrackThumbnail( req.releaseMbid, req.releaseGroupMbid, req.albumName, req.artistName, ) .then((url) => { if (url) { this.trackThumbnails = new Map(this.trackThumbnails).set(req.key, url); } }) .catch(() => {}); } } private async fetchTopReleaseGroups(mbid: string) { try { const rgs = await TopReleaseGroupsForArtist(mbid); this.topReleaseGroups = rgs ?? []; // Batch-resolve cover art for top releases. void this.batchResolveThumbnails( rgs?.map((r) => ({ mbid: r.releaseGroupMbid, albumName: r.title, artistName: r.artistName })) ?? [], ); // Warm the release/tracklist cache for the top albums — these // are the most likely to be clicked from the artist page. this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? [], true); } catch (err) { console.error('[explore-artist] TopReleaseGroupsForArtist error', err); this.topReleaseGroups = []; } finally { // See fetchTopTracks: hold the spinner while a background // discography fetch may still populate this section. if (this.topReleaseGroups.length > 0 || this.discogReloaded.has(mbid)) { this.loadingTopReleases = false; } } } private async fetchReleaseGroups(mbid: string) { try { const rgs = await BrowseReleaseGroups(mbid); // 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; } // Batch-resolve cover art for discography (lower priority — loaded after top sections). void this.batchResolveThumbnails( rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit })) ?? [], ); // Warm the release/tracklist cache for these albums so opening // one from here is instant instead of a cold MB browse. this.prefetchReleases(rgs?.map((r) => r.mbid) ?? []); } catch (err) { console.error('[explore-artist] BrowseReleaseGroups error', err); this.errorReleases = describeError( err, 'The catalog did not answer for this artist\u2019s albums.', ); this.catalogPending = false; } finally { // BrowseReleaseGroups is index-first + async: an empty result on // a cold artist means the discography is still being fetched in // the background. Hold the spinner until it arrives (via // ArtistDiscographyReady) or the fallback fires. if (this.releaseGroups.length > 0 || this.discogReloaded.has(mbid)) { this.loadingReleases = false; } } } /** * Warm the backend's release/tracklist cache for a set of release * groups so opening an album from this page is instant. * Fire-and-forget. * * The page's two sections resolve independently and both want this, * so the mbids are collected and sent once on a microtask rather * than once per section — `BrowseReleases` is the most expensive * call the app makes, on a 1 req/s limiter, and asking twice for an * overlapping set spends that limiter on nothing. * * `prefetchRequested` is what stops the cold-artist refetch — which * re-runs every fetch on `ArtistDiscographyReady` — asking again for * everything it already asked for. */ private prefetchReleases(mbids: string[], top = false) { const pending = top ? this.pendingTopPrefetch : this.pendingPrefetch; for (const mbid of mbids) { if (mbid) pending.add(mbid); } if (this.pendingTopPrefetch.size + this.pendingPrefetch.size === 0) { return; } if (this.prefetchScheduled) return; this.prefetchScheduled = true; queueMicrotask(() => { this.prefetchScheduled = false; // Top releases lead: they are what the page shows first, and // the backend takes the list in order. const batch = [ ...new Set([ ...this.pendingTopPrefetch, ...this.pendingPrefetch, ]), ].filter((mbid) => !this.prefetchRequested.has(mbid)); this.pendingTopPrefetch.clear(); this.pendingPrefetch.clear(); if (batch.length === 0) return; for (const mbid of batch) this.prefetchRequested.add(mbid); void PrefetchReleases(batch).catch(() => { /* best-effort cache warming — ignore failures */ }); }); } private async fetchSimilarArtists(mbid: string) { try { const artists = await SimilarArtists(mbid); this.similarArtists = artists ?? []; } catch (err) { // D024: graceful degradation — silently omit similar artists on failure. console.error('[explore-artist] SimilarArtists error', err); this.similarArtists = []; } finally { // SimilarArtists is DB-first + async: an empty result on the // first view means the LB labs fetch is still running. Hold the // spinner until ArtistSimilarReady re-fetches (or the fallback // fires); once reloaded, an empty list is genuinely "none". if (this.similarArtists.length > 0 || this.similarReloaded.has(mbid)) { this.loadingSimilar = false; } } // Fire-and-forget: resolve images for similar artists in parallel. if (this.similarArtists.length > 0) { void this.fetchSimilarArtistImages(); } } /** * Batch-resolve thumbnails in two phases: * 1. Batch call for cached/local art — instant. * 2. Individual async calls for uncached items — stream in one by one. * This prevents a slow CAA fetch from blocking cached items from rendering. */ private async batchResolveThumbnails( items: Array<{ mbid: string; albumName: string; artistName: string }>, ) { // Filter out already-resolved or in-flight items. const needed = items.filter((i) => i.mbid && !this.thumbnailURLs.has(i.mbid)); if (needed.length === 0) return; // Mark all as in-flight. for (const item of needed) { this.thumbnailURLs.set(item.mbid, ''); } // Phase 1: batch cached lookup — instant return. let cached: Record = {}; try { const requests = needed.map((i) => ({ mbid: i.mbid, albumName: i.albumName, artistName: i.artistName, })); cached = await dictByName(GetThumbnails(requests)); if (Object.keys(cached).length > 0) { const updated = new Map(this.thumbnailURLs); for (const [mbid, url] of Object.entries(cached)) { if (url) updated.set(mbid, url); } this.thumbnailURLs = updated; } } catch { // Best-effort. } // Phase 2: fire individual fetches for uncached items so they // stream in as CAA responds, rather than blocking on the slowest. const uncached = needed.filter((i) => !cached[i.mbid]); for (const item of uncached) { GetThumbnail(item.mbid, item.albumName, item.artistName) .then((url) => { if (url) { this.thumbnailURLs = new Map(this.thumbnailURLs).set(item.mbid, url); } }) .catch(() => {}); } } /** * Populate similarImageURLs for the current similarArtists list * using only library-store data and disk-cached artist images. * Makes ZERO network calls. * * Resolution priority per artist: * 1. libraryStore.cachedArtists[mbid].ImageMedium (in-memory) * 2. GetArtistImageCachedPath(mbid) (disk-only Wails call) * 3. First library album cover art by that artist name (fallback) */ private async seedSimilarArtistImagesFromLibrary() { if (this.similarArtists.length === 0) return; const cachedArtists = libraryStore.cachedArtists; const cachedAlbums = libraryStore.cachedAlbums; // Build an MBID → ImageMedium map from library store. const byMbid = new Map(); if (cachedArtists) { for (const a of cachedArtists) { if (!a.MBID) continue; const img = a.ImageMedium || a.ImageSmall; if (img) byMbid.set(a.MBID, img); } } // Seed synchronous hits first. let updates = new Map(this.similarImageURLs); let changed = false; for (const s of this.similarArtists) { if (updates.has(s.artistMbid)) continue; const img = byMbid.get(s.artistMbid); if (img) { updates.set(s.artistMbid, img); changed = true; } } if (changed) { this.similarImageURLs = updates; changed = false; updates = new Map(this.similarImageURLs); } // Disk-only Wails calls for any similars that weren't in the // library store cache (e.g. artist row exists but ImageMedium // is empty because primary_md.jpg was added after the eager // fetch). Fire in parallel, update the map as each returns. const pending: Promise[] = []; for (const s of this.similarArtists) { if (updates.has(s.artistMbid) && updates.get(s.artistMbid)) continue; if (!s.artistMbid) continue; pending.push( GetArtistImageCachedPath(s.artistMbid) .then((url) => { if (url) { this.similarImageURLs = new Map(this.similarImageURLs).set( s.artistMbid, url, ); return; } // Last-resort fallback: first library album by // this artist name (covers artists without // cached portraits). if (!cachedAlbums) return; const targetName = (s.name || '').toLowerCase(); for (const album of cachedAlbums) { if (album.ArtistName.toLowerCase() !== targetName) continue; const art = album.CoverArtMedium || album.CoverArtSmall || album.CoverArtPath; if (art) { this.similarImageURLs = new Map(this.similarImageURLs).set( s.artistMbid, art, ); return; } } }) .catch(() => { // Ignore — letter avatar stays. }), ); } await Promise.allSettled(pending); } private async fetchSimilarArtistImages() { // Phase 1: instant seed from library store + disk cache, so any // card whose image is already on disk appears immediately, // without waiting for a network-enabled GetArtistImageURL // round-trip. await this.seedSimilarArtistImagesFromLibrary(); // Phase 2: network fetch for any similars still without // an image. GetArtistImageURL triggers MB → Wikidata → // Wikimedia resolution which is slow (~1-5s per artist), // so we only hit it for cards that weren't resolved // instantly from local sources above. const artists = this.similarArtists; await Promise.allSettled( artists.map(async (a) => { if (!a.artistMbid) return; if (this.similarImageURLs.get(a.artistMbid)) return; try { const url = await GetArtistImageURL(a.artistMbid); if (url) { this.similarImageURLs = new Map(this.similarImageURLs).set( a.artistMbid, url, ); } } catch { // No image — letter avatar stays. } }), ); } private async fetchArtistImage(mbid: string) { try { const url = await GetArtistImageURL(mbid); if (url) { this.artistImageURL = url; } } catch { // No image available. } // Fallback: album cover art if no artist image resolved. if (!this.artistImageURL) { this.fallbackArtistImageFromAlbumArt(); } } /** * Populate artistImageURL from the first matching library album's * cover art. Used as a last-resort fallback when no portrait is * available and we don't want to make a network call. */ private fallbackArtistImageFromAlbumArt() { if (this.artistImageURL) return; const cachedAlbums = libraryStore.cachedAlbums; if (!cachedAlbums) return; const artistName = this.artistName.toLowerCase(); for (const a of cachedAlbums) { if (a.ArtistName.toLowerCase() !== artistName) continue; const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath; if (art) { this.artistImageURL = art; return; } } } /* ── Playback ── */ /** * Local album ids for every release group this page already knows * is owned. `releaseGroups` holds the artist's full discography, so * this covers everything the "Play library tracks" button promises * — not just what is currently expanded on screen. */ private ownedLocalAlbumIds(): number[] { const ids = new Set(); for (const rg of this.releaseGroups) { if (rg.localId && rg.localId > 0) ids.add(rg.localId); } return [...ids]; } /** * File paths for every track this page can show is owned, across * the whole discography. Each id came from a release group the * backend or the library cache already cross-referenced, and * `GetFilePathsByAlbums` only ever returns files that actually * exist for that local album — so this cannot pull in a track the * user does not have, even when the catalog release itself is only * partially owned. */ private async libraryFilePaths(): Promise { const albumIds = this.ownedLocalAlbumIds(); if (albumIds.length === 0) return []; const libraryID = libraryStore.getSelectedLibraryId() ?? 0; const byAlbum = await dict(GetFilePathsByAlbums(albumIds, libraryID)); const paths: string[] = []; for (const id of albumIds) paths.push(...(byAlbum[id] ?? [])); return paths; } /** * The local artist id for "Playing from" purposes — the `local- * artist-id` navigation attribute when the caller had one, else a * lookup by MBID against the cached library artists (the same * cross-reference `hydrateFromCache`/`checkLibrary` already use). */ private resolveLocalArtistId(): number { if (this.localArtistId > 0) return this.localArtistId; if (!this.artistMBID) return 0; for (const a of libraryStore.cachedArtists ?? []) { if (a.MBID === this.artistMBID) return a.ID; } return 0; } private queueSource(): QueueSource | undefined { const id = this.resolveLocalArtistId(); if (id === 0) return undefined; return { type: 'artist', id, label: this.displayName }; } /** Play every owned track by this artist, optionally shuffled. */ private async playLibraryTracks(shuffle: boolean): Promise { try { const paths = await this.libraryFilePaths(); if (paths.length === 0) { notificationStore.inline(ExploreArtistRegion, { text: 'None of this artist’s tracks could be found in your library.', }); return; } if (shuffle && !queueStore.getState().shuffleMode) { queueStore.toggleShuffle(); } queueStore.setQueue(paths, 0, shuffle, this.queueSource()); } catch (error) { console.error('Could not play artist:', error); notificationStore.inline(ExploreArtistRegion, { text: describeError(error, 'Could not play this artist’s library tracks.'), }); } } /** * File path for one top track, resolved by recording MBID — the * same key `localId` was set from. Works whether or not the * containing release itself matched a local album. * * Gated on the same answer the row is drawn from, or a row drawn * dimmed and `aria-disabled` would still try to play and fail. */ private async trackFilePath(track: LBTopRecording): Promise { if (!isOwned(track) || !track.recordingMbid) return null; const libraryID = libraryStore.getSelectedLibraryId() ?? 0; const byMBID = await dictByName( GetFilePathsByRecordingMBIDs([track.recordingMbid], libraryID), ); return byMBID[track.recordingMbid]?.[0] ?? null; } private async playTrack(track: LBTopRecording): Promise { try { const path = await this.trackFilePath(track); if (!path) { notificationStore.inline(ExploreArtistRegion, { text: 'This track could not be found in your library.', }); return; } queueStore.setQueue([path], 0, false, this.queueSource()); } catch (error) { console.error('Could not play track:', error); notificationStore.inline(ExploreArtistRegion, { text: describeError(error, 'Could not play this track.'), }); } } private async queueTrackNext(track: LBTopRecording): Promise { const path = await this.trackFilePath(track); if (path) queueStore.playNext(path); } private async addTrackToQueue(track: LBTopRecording): Promise { const path = await this.trackFilePath(track); if (path) queueStore.addToQueue(path); } private isTrackOwned(track: LBTopRecording): boolean { return isOwned(track); } private onTrackRowDblClick(track: LBTopRecording): void { if (!this.isTrackOwned(track)) return; void this.playTrack(track); } private onTrackRowKeydown(e: KeyboardEvent, track: LBTopRecording): void { if (isContextMenuKey(e)) { e.preventDefault(); this.ctxMenuTarget = { kind: 'track', track }; this.ctxMenu.openFrom(e.currentTarget as HTMLElement); return; } if ((e.key === 'Enter' || e.key === ' ') && this.isTrackOwned(track)) { e.preventDefault(); void this.playTrack(track); } } private onTrackContextMenu(e: MouseEvent, track: LBTopRecording): void { e.preventDefault(); e.stopPropagation(); this.ctxMenuTarget = { kind: 'track', track }; this.ctxMenu.openAt(e.clientX, e.clientY); } /* ── Release context menu ── */ // The two release shapes on this page are normalised at the moment // the menu opens, so the union does not reach the action handlers. /** Normalise a top-section release group. */ private topReleaseTarget(rg: LBTopReleaseGroup): ReleaseMenuTarget { return { mbid: rg.releaseGroupMbid || '', localId: rg.localId ?? 0, title: rg.title, owned: isOwned(rg), }; } /** * Normalise a discography release group. * * A library-only release arrives as `local:`, which names nothing * upstream — so its catalog MBID is empty and the local id is * unwrapped from it, the same way `navigateToAlbum` does. */ private albumTarget(rg: MBReleaseGroup): ReleaseMenuTarget { const isLocal = typeof rg.mbid === 'string' && rg.mbid.startsWith('local:'); const localId = rg.localId || (isLocal ? Number(rg.mbid.slice(6)) : 0); return { mbid: isLocal ? '' : rg.mbid || '', localId: Number.isFinite(localId) ? localId : 0, title: rg.title, // The same answer the menu gates Play on, which is the // point: this used to be `inLibrary` too, so a card could // report itself owned, be offered no Play (that item is // gated on the local id) and be offered no request either // (that one is gated on *not* owned). owned: localId > 0, }; } private onReleaseContextMenu(e: MouseEvent, release: ReleaseMenuTarget): void { e.preventDefault(); e.stopPropagation(); this.ctxMenuTarget = { kind: 'release', release }; this.ctxMenu.openAt(e.clientX, e.clientY); } private onReleaseKeydown( e: KeyboardEvent, release: ReleaseMenuTarget, activate: () => void, ): void { if (isContextMenuKey(e)) { e.preventDefault(); this.ctxMenuTarget = { kind: 'release', release }; this.ctxMenu.openFrom(e.currentTarget as HTMLElement); return; } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } } /** * The release's files, keyed on the local album id. * * Keyed on the id rather than the MBID for the reason the album page * is: an owned but untagged release has no recording MBIDs, so an * MBID-keyed lookup returns nothing while looking entirely correct. */ private async releaseFilePaths( release: ReleaseMenuTarget, ): Promise { if (release.localId <= 0) return []; const libraryID = libraryStore.getSelectedLibraryId() ?? 0; const byAlbum = await dict( GetFilePathsByAlbums([release.localId], libraryID), ); return byAlbum[release.localId] ?? []; } private async onReleaseAction( action: 'play' | 'add-to-queue' | 'play-next', ): Promise { const release = this.ctxMenuRelease; this.ctxMenu.close(); if (!release) return; try { const paths = await this.releaseFilePaths(release); if (paths.length === 0) { notificationStore.inline(ExploreArtistRegion, { text: `No files for ${release.title} were found in your library.`, }); return; } switch (action) { case 'play': queueStore.setQueue(paths, 0, false, this.queueSource()); break; case 'add-to-queue': for (const path of paths) queueStore.addToQueue(path); break; case 'play-next': for (const path of [...paths].reverse()) queueStore.playNext(path); break; } } catch (err) { console.error('Could not queue release:', err); notificationStore.inline(ExploreArtistRegion, { text: describeError(err, `Could not play ${release.title}.`), }); } } private async onReleaseRequestToggle(): Promise { const release = this.ctxMenuRelease; this.ctxMenu.close(); if (!release?.mbid) return; try { await toggleRequest({ mbid: release.mbid, entity: 'album', title: release.title, artist: this.artist?.name ?? this.artistName, }); } catch (err) { console.error('Could not change the request:', err); notificationStore.inline(ExploreArtistRegion, { text: describeError(err, `Could not request ${release.title}.`), }); } } private viewReleaseOnMusicBrainz(): void { const release = this.ctxMenuRelease; this.ctxMenu.close(); if (!release?.mbid) return; window.open( `https://musicbrainz.org/release-group/${release.mbid}`, '_blank', 'noopener', ); } private onContextMenuAction( action: 'play' | 'add-to-queue' | 'play-next' | 'track-details', ): void { const track = this.ctxMenuTrack; this.ctxMenu.close(); if (!track || !this.isTrackOwned(track)) return; switch (action) { case 'play': void this.playTrack(track); break; case 'add-to-queue': void this.addTrackToQueue(track); break; case 'play-next': void this.queueTrackNext(track); break; case 'track-details': void this.openTrackDetails(track); break; } } /** * Open the "Add to Playlist" submenu for the track the menu is on. * * The path is resolved on demand rather than at menu-open time — * the release menus that share this panel never need one, and a * right-click on a track is not a statement that a playlist is * coming. A menu closed while the lookup was in flight must not * sprout a submenu afterwards. */ private async openPlaylistSubmenu(explicit: boolean): Promise { const track = this.ctxMenuTrack; if (!track || !this.isTrackOwned(track)) return; this.ctxMenu.clearSubmenuCloseTimer(); this.ctxMenuPaths ??= this.trackFilePath(track).then((p) => p ? [p] : []); let paths: string[] = []; try { paths = await this.ctxMenuPaths; } catch (error) { console.error('Could not resolve the track’s file:', error); this.ctxMenuPaths = null; } if (!this.ctxMenu.contextMenuOpen) return; if (paths.length === 0) { // Only an explicit activation gets an answer. A hover is how // a submenu is *reached*, including on the way to the item // below it — reporting a failure from one would put an error // on screen for a menu the user was only passing through, // and closing the menu under the pointer is worse still. if (!explicit) return; this.ctxMenu.close(); notificationStore.inline(ExploreArtistRegion, { text: 'This track could not be found in your library.', }); return; } await this.ctxMenu.showPlaylistSubmenu(paths); } /** * The details dialog for an owned top track. * * The dialog wants the library's `Track` and this page has the * catalog's recording, so the route in is the same MBID → file path * resolution the playback actions use. */ private async openTrackDetails(track: LBTopRecording): Promise { try { const path = await this.trackFilePath(track); const outcome = path ? await showTrackDetailsForPath( () => this.trackDetailsDialog, path, () => void this.openTrackDetails(track), ) : 'not-in-library'; if (outcome === 'not-in-library') { notificationStore.inline(ExploreArtistRegion, { text: 'This track could not be found in your library.', }); } } catch (error) { console.error('Could not open track details:', error); notificationStore.inline(ExploreArtistRegion, { text: describeError(error, 'Could not open track details.'), }); } } private viewTrackOnMusicBrainz(): void { const track = this.ctxMenuTrack; this.ctxMenu.close(); if (!track?.recordingMbid) return; window.open(`https://musicbrainz.org/recording/${track.recordingMbid}`, '_blank', 'noopener'); } /* ── Navigation ── */ private navigateBack() { this.dispatchEvent( new CustomEvent('navigate-back', { bubbles: true, composed: true, }), ); } private navigateToAlbum(rg: MBReleaseGroup) { // Strip the synthetic local: prefix that hydrateFromCache uses // for library-only albums without an MBID — those navigate by // local ID instead. const isLocal = typeof rg.mbid === 'string' && rg.mbid.startsWith('local:'); const realMBID = isLocal ? '' : (rg.mbid || ''); const localId = (rg as MBReleaseGroup & { localId?: number }).localId || (isLocal ? Number(rg.mbid.slice('local:'.length)) : 0); this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-album-details', releaseGroupMBID: realMBID, albumName: rg.title, artistName: rg.artistCredit || this.artistName, localAlbumId: localId, }, }), ); } private navigateToSimilarArtist(artist: LBSimilarArtist) { this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-artist-details', artistMBID: artist.artistMbid, artistName: artist.name, }, }), ); } /* ── Image Error Handling ── */ private handleImageError(e: Event) { const img = e.target as HTMLImageElement; img.style.display = 'none'; const fallback = img.nextElementSibling as HTMLElement | null; if (fallback) { fallback.style.display = 'flex'; } } /** On similar-artist image error, remove the img so the letter initial shows. */ private handleSimilarImageError(e: Event) { const img = e.target as HTMLImageElement; img.remove(); } /* ── Helpers ── */ private getInitial(name: string): string { if (!name) return '?'; return name.charAt(0).toUpperCase(); } /** English name if available, otherwise the native name. */ private get displayName(): string { return this.artist?.englishName || this.artistName; } /** * Group release groups by type, returning entries in the * canonical order: Albums → EP → Single → Other Albums → ...rest. * * "Albums" contains release groups with primaryType "Album" and * no non-studio secondary types. "Other Albums" collects * compilations, soundtracks, live albums, etc. */ private groupByType(): Array<{ type: string; items: MBReleaseGroup[] }> { const map = new Map(); for (const rg of this.releaseGroups) { let key = rg.primaryType || 'Other'; // Split "Album" into studio vs other based on secondary types. if (key === 'Album') { const hasNonStudio = rg.secondaryTypes?.some((t) => NON_STUDIO_SECONDARY_TYPES.has(t), ); key = hasNonStudio ? 'Other Albums' : 'Albums'; } let bucket = map.get(key); if (!bucket) { bucket = []; map.set(key, bucket); } bucket.push(rg); } // Sort each bucket by firstReleaseDate descending (newest first). for (const bucket of map.values()) { bucket.sort((a, b) => { const da = a.firstReleaseDate || ''; const db = b.firstReleaseDate || ''; return db.localeCompare(da); }); } // Build ordered result following TYPE_ORDER, then any remaining types. const result: Array<{ type: string; items: MBReleaseGroup[] }> = []; const seen = new Set(); for (const type of TYPE_ORDER) { const items = map.get(type); if (items && items.length > 0) { result.push({ type, items }); seen.add(type); } } // Remaining types not in TYPE_ORDER (alphabetical). const remaining = [...map.keys()] .filter((k) => !seen.has(k)) .sort(); for (const type of remaining) { const items = map.get(type); if (items && items.length > 0) { result.push({ type, items }); } } return result; } /* ── Render ── */ override render() { return html`
${this.artistImageURL ? html`${this.displayName}` : this.getInitial(this.displayName)}

${this.displayName}

${this.artist?.englishName ? html`
${this.artist.name}
` : nothing} ${this.renderArtistMeta()} ${this.artist?.popularity && this.artist.popularity > 0 ? html`${formatListenCount(this.artist.popularity)} plays on ListenBrainz` : nothing} ${this.renderPlayLibraryAction()} ${this.renderFollowAction()}
${this.renderTopSection()} ${this.renderDiscography()} ${this.renderSimilarArtists()}
${this.renderContextMenu()} `; } /** * The artist-page equivalent of the album page's Play button: play * everything by this artist that is actually in the library. Only * rendered when at least one release group is owned — an artist * page with nothing local has nothing for this button to do. */ private renderPlayLibraryAction() { if (this.ownedLocalAlbumIds().length === 0) return nothing; return html`
void this.playLibraryTracks(false)} > Play library tracks void this.playLibraryTracks(true)} > Shuffle
`; } /** * The one context menu panel, shared by the top tracks and the * release cards. * * The label is computed from the target rather than written down, * which is the fault `cover-grid` shipped — a shared panel that * announced every menu as "Album actions". */ private renderContextMenu() { const target = this.ctxMenuTarget; return html` ${this.ctxMenu.contextMenuOpen && target ? html` ` : nothing} ${this.ctxMenu.playlistSubmenuOpen ? html`
this.ctxMenu.clearSubmenuCloseTimer()} @mouseleave=${this.ctxMenu.scheduleSubmenuClose} > e.stopPropagation()} >
` : nothing}
`; } private renderTrackMenuItems(track: LBTopRecording) { return html` ${this.isTrackOwned(track) ? html` this.onContextMenuAction('play')} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > Play this.onContextMenuAction('add-to-queue')} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > Add to Queue this.onContextMenuAction('play-next')} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > Play Next void this.openPlaylistSubmenu(false)} @mouseleave=${this.ctxMenu.scheduleSubmenuClose} @click=${(e: Event) => { e.stopPropagation(); void this.openPlaylistSubmenu(true); }} > Add to Playlist this.onContextMenuAction('track-details')} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > Track Details ` : nothing} this.viewTrackOnMusicBrainz()} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > View on MusicBrainz `; } /** * Which items a release gets, decided by what it can actually do. * * Playback is gated on a local album id rather than on "owned": a * release matched by MBID with no local album behind it has nothing * to queue. The request needs the opposite — a catalog MBID — so it * is absent for a library-only release, which is also the one case * where wanting it makes no sense. */ private renderReleaseMenuItems(release: ReleaseMenuTarget) { const requested = libraryStatusFor(release.owned, release.mbid) === 'queued'; return html` ${release.localId > 0 ? html` void this.onReleaseAction('play')}> Play void this.onReleaseAction('add-to-queue')}> Add to Queue void this.onReleaseAction('play-next')}> Play Next ` : nothing} ${!release.owned && release.mbid ? html` void this.onReleaseRequestToggle()}> ${requested ? 'Cancel Request' : 'Request This'} ` : nothing} ${release.mbid ? html` this.viewReleaseOnMusicBrainz()}> View on MusicBrainz ` : nothing} `; } /** * Subscribes to an artist: their new releases go on the requests * list as they come out. * * The default is new releases only. Following an artist should not * silently queue forty albums — someone who wants the back * catalogue can widen it from the requests list, and will not be * surprised by having done so. */ private renderFollowAction() { if (!this.artistMBID) return nothing; const request = downloadStore.requestFor(this.artistMBID); return html`
void this.toggleFollow(request?.id)} > ${request ? 'Following' : 'Follow for new releases'}
`; } private async toggleFollow(requestId: number | undefined): Promise { if (!this.artistMBID) return; try { if (requestId) { await downloadStore.removeRequest(requestId); } else { const libraryId = await libraryStore.getDefaultLibraryId(); if (!libraryId) { console.error('Could not update the requests list: no library available'); return; } await downloadStore.addRequest({ mbid: this.artistMBID, entity: 'artist', libraryId, artist: this.displayName, title: this.displayName, scope: 'future', secondary: false, } as never); } } catch (err) { console.error('Could not update the requests list:', err); } this.requestUpdate(); } private renderArtistMeta() { if (this.loadingArtist) { return html`Loading\u2026`; } if (this.errorArtist) { return html`${this.errorArtist}`; } if (!this.artist) return nothing; const parts: string[] = []; if (this.artist.type) parts.push(this.artist.type); if (this.artist.country) parts.push(this.artist.country); if (this.artist.disambiguation) parts.push(this.artist.disambiguation); if (parts.length === 0) return nothing; return html` ${parts.map( (p, i) => html`${i > 0 ? html`\u00B7` : nothing}${p}`, )} `; } /* ── Top Section (tracks + releases side-by-side) ── */ /** Shared toggle (wide mode) — expands both at once. */ private toggleTopSection() { const next = !(this.topTracksExpanded && this.topReleasesExpanded); this.topTracksExpanded = next; this.topReleasesExpanded = next; } /** Per-column toggle (narrow/stacked mode). */ private toggleTopTracks() { this.topTracksExpanded = !this.topTracksExpanded; } private toggleTopReleases() { this.topReleasesExpanded = !this.topReleasesExpanded; } private toggleDiscoGroup(type: string) { const next = new Set(this.expandedDiscoGroups); if (next.has(type)) { next.delete(type); } else { next.add(type); } this.expandedDiscoGroups = next; } private renderTopSection() { const hasTracks = !this.loadingTracks && this.topTracks.length > 0; const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0; const tracksLoading = this.loadingTracks; const releasesLoading = this.loadingTopReleases; // Both still loading — show single loading state. if (tracksLoading && releasesLoading) { return html`

Popular

Loading\u2026
`; } // Both done, neither has data. if (!tracksLoading && !releasesLoading && !hasTracks && !hasReleases) { return nothing; } const tracksExpanded = this.topTracksExpanded; const releasesExpanded = this.topReleasesExpanded; const bothExpanded = tracksExpanded && releasesExpanded; const trackLimit = tracksExpanded ? 10 : 5; const releaseLimit = releasesExpanded ? 4 : 2; const tracks = this.topTracks.slice(0, trackLimit); const releases = this.topReleaseGroups.slice(0, releaseLimit); const canExpandTracks = this.topTracks.length > 5; const canExpandReleases = this.topReleaseGroups.length > 2; const canExpand = canExpandTracks || canExpandReleases; return html`
${hasTracks ? html`

Top Tracks

${tracks.map((t, i) => { const owned = this.isTrackOwned(t); return html`
this.onTrackRowDblClick(t)} @contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)} @keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)} > ${i + 1}
${(() => { const art = this.trackThumbnails.get(t.recordingMbid) || ''; return art ? html` { const img = e.target as HTMLImageElement; img.style.display = 'none'; }} />` : html``; })()}
${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}
${t.artistName}
${formatListenCount(t.totalListenCount)} plays ${owned ? nothing : html``}
`; })}
${canExpandTracks ? html` ` : nothing}
` : nothing} ${hasReleases ? html`

Top Releases

${releases.map((rg) => this.renderTopReleaseCard(rg))}
${canExpandReleases ? html` ` : nothing}
` : releasesLoading ? html`

Top Releases

Loading\u2026
` : nothing}
${canExpand ? html` ` : nothing}
`; } private renderTopReleaseCard(rg: LBTopReleaseGroup) { const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || ''; const target = this.topReleaseTarget(rg); const owned = target.owned; const badge = albumBadgeFor( { localId: target.localId }, rg.releaseGroupMbid, ); return html`
this.navigateToTopRelease(rg)} role="button" tabindex="0" @contextmenu=${(e: MouseEvent) => this.onReleaseContextMenu(e, target)} @keydown=${(e: KeyboardEvent) => this.onReleaseKeydown(e, target, () => this.navigateToTopRelease(rg), )} >
${artURL ? html`${rg.title}` : nothing}
${rg.title}
${rg.date ? html`${extractYear(rg.date)}` : nothing}
${badge.status === 'in-library' ? nothing : html``}
`; } private navigateToTopRelease(rg: LBTopReleaseGroup) { this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-album-details', releaseGroupMBID: rg.releaseGroupMbid, albumName: rg.title, }, }), ); } /* ── Discography Section ── */ private renderDiscography() { if (this.loadingReleases) { return html`

Discography

Loading\u2026
`; } if (this.errorReleases) { return html`

Discography

${this.errorReleases}
`; } if (this.releaseGroups.length === 0) return nothing; const groups = this.groupByType(); return html`

Discography

${groups.map( (g) => { const isExpanded = this.expandedDiscoGroups.has(g.type); const rowSize = this.discoRowSize; const showToggle = g.items.length > rowSize; const visibleItems = isExpanded ? g.items : g.items.slice(0, rowSize); return html`

${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}

${visibleItems.map((rg) => this.renderAlbumCard(rg))}
${showToggle ? html` ` : nothing}
`; }, )}
`; } private renderAlbumCard(rg: MBReleaseGroup) { const artURL = this.thumbnailURLs.get(rg.mbid) || ''; const year = extractYear(rg.firstReleaseDate); const target = this.albumTarget(rg); const owned = target.owned; const badge = albumBadgeFor({ localId: target.localId }, target.mbid); return html`
this.navigateToAlbum(rg)} role="button" tabindex="0" @contextmenu=${(e: MouseEvent) => this.onReleaseContextMenu(e, target)} @keydown=${(e: KeyboardEvent) => this.onReleaseKeydown(e, target, () => this.navigateToAlbum(rg), )} >
${artURL ? html`${rg.title}` : nothing}
${rg.title}
${year ? html`${year}` : nothing}
${badge.status === 'in-library' ? nothing : html``}
`; } /* ── Similar Artists Section ── */ private renderSimilarArtists() { if (this.loadingSimilar || this.similarArtists.length === 0) { return nothing; } // Cap the similar-artists list at 10 to avoid a very long list. const maxSimilar = 10; const artists = this.similarArtists.slice(0, maxSimilar); const showToggle = artists.length > this.discoRowSize; const collapsed = !this.similarExpanded && showToggle; const visible = collapsed ? artists.slice(0, this.discoRowSize) : artists; return html`

Similar Artists

${visible.map((a) => { const imgURL = this.similarImageURLs.get(a.artistMbid); return html`
this.navigateToSimilarArtist(a)} role="button" tabindex="0" @keydown=${(e: KeyboardEvent) => { if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); this.navigateToSimilarArtist(a); } }} >
${imgURL ? html`${a.name}` : a.name.charAt(0).toUpperCase()}
${a.name}
`; })}
${showToggle ? html` ` : nothing}
`; } } declare global { interface HTMLElementTagNameMap { 'explore-artist-details': ExploreArtistDetails; } }