Files
yellowjacket/frontend/src/components/explore-artist-details/explore-artist-details.ts
T
logan 9e7e7ce5a1 feat(shell): put every menu in the app through the one surface
Fourteen call sites, one tag name each and nothing else -- which is what
menu-surface's shape buys: the host's panel is slotted into whichever
presentation is up, so no item model, no keyboard model and no styling
moved.  The 48px rows come from contextMenuStyles, the one stylesheet
every one of these hosts already includes, because the panel is the
host's own light DOM and only the host's stylesheet can reach it.

Two of the fourteen were found by the source sweep rather than by the
conversion: queue-panel's add-to-playlist popup, which is a real menu.
now-playing's cover preview is allowlisted instead -- it is a hover
affordance in the bottom bar, so a touch device never opens it and
nothing clips it.

The playlist submenu had to come too, and that is the one place this
change made something worse before it made it better.  It is a
placement="right-start" flyout anchored to its row, and making the menu
full-width moved that anchor to x=0 -- so the flip put the picker at
x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at
all.  Before the change the row started at x~245 and the same flip
landed on screen.  It is a sheet now and stacks over the first, which
is also why menu-shown does not re-assert focus while it is open.

The three hosts that do not use ContextMenuController -- page-header's
overflow menu, playlist-view's hand-rolled menu, queue-panel's picker
-- bind menu-dismiss themselves, or Escape would close the sheet and
leave their own open flag set.

page-header is included deliberately: the clipping does not bite there,
since it opens downward from the top of a full-height view, but on a
phone every action of an overflowing page lives in that menu at
wa-dropdown-item defaults.  One surface, so there is no second answer
to what a menu looks like.
2026-08-21 02:23:48 -04:00

3347 lines
121 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, MenuTarget } 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 type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
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<string, string>();
/** Resolved cover art data URLs keyed by release group MBID. */
@state() private thumbnailURLs = new Map<string, string>();
/** Track thumbnails keyed by recording MBID — resolved via RG
* when possible, or the release MBID as fallback. */
@state() private trackThumbnails = new Map<string, string>();
@state() private topTracksExpanded = false;
@state() private topReleasesExpanded = false;
private topSectionStacked = false;
private topSectionObserver?: ResizeObserver;
@state() private expandedDiscoGroups = new Set<string>();
/** 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<string>();
/** Discography mbids awaiting the next coalesced prefetch. */
private pendingPrefetch = new Set<string>();
private prefetchScheduled = false;
/** Every mbid already sent, so a refetch does not re-ask. */
private prefetchRequested = new Set<string>();
/* ── 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!: MenuSurface;
@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<string[]> | null = null;
// -- ContextMenuHost interface --
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): MenuTarget | 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<string>();
/** 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<string>();
/** 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<unknown>[] = [];
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<string, string> = {};
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<string, string>,
) {
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<string, string> = {};
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<string, string> = {};
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<string, string>();
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<void>[] = [];
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<number>();
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<string[]> {
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<void> {
try {
const paths = await this.libraryFilePaths();
if (paths.length === 0) {
notificationStore.inline(ExploreArtistRegion, {
text: 'None of this artists 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 artists 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<string | null> {
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<void> {
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<void> {
const path = await this.trackFilePath(track);
if (path) queueStore.playNext(path);
}
private async addTrackToQueue(track: LBTopRecording): Promise<void> {
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:<n>`, 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<string[]> {
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<void> {
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<void> {
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<void> {
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 tracks 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<void> {
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<string, MBReleaseGroup[]>();
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<string>();
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`
<div class="artist-header">
<button
class="back-button"
@click=${this.navigateBack}
title="Back to explore"
aria-label="Back to explore"
>
<wa-icon name="arrow-left"></wa-icon>
</button>
<div
class="artist-avatar"
style="background: ${avatarBackground(this.artistName)}"
>
${this.artistImageURL
? html`<img
src="${this.artistImageURL}"
alt="${this.displayName}"
/>`
: this.getInitial(this.displayName)}
</div>
<div class="artist-info">
<h1 class="artist-title" title="${this.displayName}">
${this.displayName}
</h1>
${this.artist?.englishName
? html`<div class="artist-native-name">${this.artist.name}</div>`
: nothing}
${this.renderArtistMeta()}
${this.artist?.popularity && this.artist.popularity > 0
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
: nothing}
${this.renderPlayLibraryAction()}
${this.renderFollowAction()}
</div>
</div>
<div class="content">
<catalog-scope-notice
scope=${this.catalogScope()}
entity-type="artist"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderTopSection()} ${this.renderDiscography()}
${this.renderSimilarArtists()}
</div>
<inline-notice
region=${ExploreArtistRegion}
testid="artist-action-message"
></inline-notice>
${this.renderContextMenu()}
<track-details></track-details>
`;
}
/**
* 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`
<div class="artist-play-actions">
<wa-button
size="small"
appearance="filled"
data-testid="artist-play-library"
@click=${() => void this.playLibraryTracks(false)}
>
<wa-icon slot="start" name="play"></wa-icon>
Play library tracks
</wa-button>
<wa-button
size="small"
appearance="outlined"
data-testid="artist-shuffle-library"
@click=${() => void this.playLibraryTracks(true)}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle
</wa-button>
</div>
`;
}
/**
* 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`
<menu-surface
id="context-menu"
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
? html`
<div
class="context-menu-panel"
role="menu"
aria-label=${target.kind === 'track'
? 'Track actions'
: 'Release actions'}
>
${target.kind === 'track'
? this.renderTrackMenuItems(target.track)
: this.renderReleaseMenuItems(target.release)}
</div>
`
: nothing}
</menu-surface>
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
? html`
<div
@mouseenter=${() => this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${this.ctxMenu.playlistFilePaths}
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
@click=${(e: Event) => e.stopPropagation()}
></playlist-picker>
</div>
`
: nothing}
</menu-surface>
`;
}
private renderTrackMenuItems(track: LBTopRecording) {
return html`
${this.isTrackOwned(track)
? html`
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('add-to-queue')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play-next')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => void this.openPlaylistSubmenu(false)}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.openPlaylistSubmenu(true);
}}
>
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
Add to Playlist
<span class="submenu-arrow">&#9654;</span>
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('track-details')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="circle-info"></wa-icon>
Track Details
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item
@click=${() => this.viewTrackOnMusicBrainz()}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
`;
}
/**
* 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`
<wa-dropdown-item @click=${() => void this.onReleaseAction('play')}>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => void this.onReleaseAction('add-to-queue')}>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => void this.onReleaseAction('play-next')}>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
`
: nothing}
${!release.owned && release.mbid
? html`
<wa-dropdown-item @click=${() => void this.onReleaseRequestToggle()}>
<wa-icon
slot="icon"
name=${requested ? ICON_REQUESTED : ICON_CAN_REQUEST}
></wa-icon>
${requested ? 'Cancel Request' : 'Request This'}
</wa-dropdown-item>
`
: nothing}
${release.mbid
? html`
<wa-dropdown-item @click=${() => this.viewReleaseOnMusicBrainz()}>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
`
: 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`
<div class="artist-follow">
<wa-button
size="small"
appearance=${request ? 'filled' : 'outlined'}
@click=${() => void this.toggleFollow(request?.id)}
>
<!-- This was bookmark-check, which is not in
names.txt and so has rendered the missing-icon
fallback — a circled question mark — on every
followed artist since it was written. A
backtick around that name would end this
template literal, which is why there is none. -->
<wa-icon
slot="start"
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
></wa-icon>
${request ? 'Following' : 'Follow for new releases'}
</wa-button>
</div>
`;
}
private async toggleFollow(requestId: number | undefined): Promise<void> {
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`<span class="artist-meta section-loading"
>Loading\u2026</span
>`;
}
if (this.errorArtist) {
return html`<span class="artist-meta"
>${this.errorArtist}</span
>`;
}
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`
<span class="artist-meta">
${parts.map(
(p, i) =>
html`${i > 0
? html`<span class="meta-separator">\u00B7</span>`
: nothing}${p}`,
)}
</span>
`;
}
/* ── 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`
<section>
<h3 class="section-header">Popular</h3>
<div class="section-loading">Loading\u2026</div>
</section>
`;
}
// 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`
<section>
<div class="top-section-wrapper">
<div class="top-section-columns">
${hasTracks
? html`
<div class="top-section-col top-section-col-tracks">
<h3 class="section-header">Top Tracks</h3>
<div class="track-list">
${tracks.map((t, i) => {
const owned = this.isTrackOwned(t);
return html`
<div
class=${classMap({
'track-item': true,
owned,
unowned: !owned,
})}
tabindex="0"
role="button"
aria-disabled=${owned ? 'false' : 'true'}
aria-label=${ownershipLabel(owned, 'Play', t.trackName, 'track')}
@dblclick=${() => this.onTrackRowDblClick(t)}
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)}
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)}
>
<span class="track-rank">${i + 1}</span>
<div class="track-art">
${(() => {
const art = this.trackThumbnails.get(t.recordingMbid) || '';
return art
? html`<img src="${art}" alt="" loading="lazy"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
img.style.display = 'none';
}} />`
: html`<wa-icon name="compact-disc"></wa-icon>`;
})()}
</div>
<div class="track-info">
<div class="track-title">${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}</div>
<div class="track-artist">${t.artistName}</div>
</div>
<span class="track-listens">
${formatListenCount(t.totalListenCount)} plays
</span>
${owned
? nothing
: html`<library-status-indicator
status=${libraryStatusFor(false, t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>`}
</div>
`;
})}
</div>
${canExpandTracks
? html`
<button
class="column-toggle"
@click=${this.toggleTopTracks}
aria-expanded="${tracksExpanded}"
>
${tracksExpanded ? 'Show less' : 'Show more'}
<wa-icon name="chevron-down"></wa-icon>
</button>
`
: nothing}
</div>
`
: nothing}
${hasReleases
? html`
<div class="top-section-col top-section-col-releases">
<div class="top-releases-content">
<h3 class="section-header">Top Releases</h3>
<div class="top-releases-grid">
<div class="top-releases-inner">
${releases.map((rg) => this.renderTopReleaseCard(rg))}
</div>
</div>
</div>
${canExpandReleases
? html`
<button
class="column-toggle"
@click=${this.toggleTopReleases}
aria-expanded="${releasesExpanded}"
>
${releasesExpanded ? 'Show less' : 'Show more'}
<wa-icon name="chevron-down"></wa-icon>
</button>
`
: nothing}
</div>
`
: releasesLoading
? html`
<div class="top-section-col top-section-col-releases">
<h3 class="section-header">Top Releases</h3>
<div class="section-loading">Loading\u2026</div>
</div>
`
: nothing}
</div>
${canExpand
? html`
<button
class="top-section-toggle"
@click=${this.toggleTopSection}
aria-expanded="${bothExpanded}"
>
${bothExpanded ? 'Show less' : 'Show more'}
<wa-icon name="chevron-down"></wa-icon>
</button>
`
: nothing}
</div>
</section>
`;
}
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`
<div
class=${classMap({ 'top-release-card': true, unowned: !owned })}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToTopRelease(rg)}
role="button"
tabindex="0"
@contextmenu=${(e: MouseEvent) =>
this.onReleaseContextMenu(e, target)}
@keydown=${(e: KeyboardEvent) =>
this.onReleaseKeydown(e, target, () =>
this.navigateToTopRelease(rg),
)}
>
<div class="top-release-art">
${artURL
? html`<img
src="${artURL}"
alt="${rg.title}"
loading="lazy"
@error=${this.handleImageError}
/>`
: nothing}
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
<wa-icon name="compact-disc"></wa-icon>
</div>
</div>
<div class="top-release-text">
<div class="top-release-title" title="${rg.title}">
${rg.title}
</div>
<div class="top-release-meta">
<div class="top-release-meta-text">
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
</div>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>`}
</div>
</div>
</div>
`;
}
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`
<section>
<h3 class="section-header">Discography</h3>
<div class="section-loading">Loading\u2026</div>
</section>
`;
}
if (this.errorReleases) {
return html`
<section>
<h3 class="section-header">Discography</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.errorReleases}
</div>
</section>
`;
}
if (this.releaseGroups.length === 0) return nothing;
const groups = this.groupByType();
return html`
<section>
<h3 class="section-header">Discography</h3>
${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`
<div class="disco-group">
<h4 class="disco-type-header">
${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}
</h4>
<div class="album-grid">
${visibleItems.map((rg) => this.renderAlbumCard(rg))}
</div>
${showToggle
? html`
<button
class="disco-toggle"
aria-expanded="${isExpanded}"
@click=${() => this.toggleDiscoGroup(g.type)}
>
${isExpanded
? 'Show less'
: `Show all ${g.items.length}`}
<wa-icon name="chevron-down"></wa-icon>
</button>
`
: nothing}
</div>
`;
},
)}
</section>
`;
}
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`
<div
class=${classMap({ 'album-card': true, unowned: !owned })}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToAlbum(rg)}
role="button"
tabindex="0"
@contextmenu=${(e: MouseEvent) =>
this.onReleaseContextMenu(e, target)}
@keydown=${(e: KeyboardEvent) =>
this.onReleaseKeydown(e, target, () =>
this.navigateToAlbum(rg),
)}
>
<div class="album-art-container">
${artURL
? html`<img
src="${artURL}"
alt="${rg.title}"
loading="lazy"
@error=${this.handleImageError}
/>`
: nothing}
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
<wa-icon name="compact-disc"></wa-icon>
</div>
</div>
<div class="album-title" title="${rg.title}">${rg.title}</div>
<div class="album-meta">
<div class="album-meta-text">
${year ? html`<span>${year}</span>` : nothing}
</div>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>`}
</div>
</div>
`;
}
/* ── 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`
<section>
<h3 class="section-header">Similar Artists</h3>
<div class="similar-row ${collapsed ? 'collapsed' : ''}">
${visible.map((a) => {
const imgURL = this.similarImageURLs.get(a.artistMbid);
return html`
<div
class="similar-artist-card"
@click=${() => this.navigateToSimilarArtist(a)}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault();
this.navigateToSimilarArtist(a);
}
}}
>
<div
class="similar-avatar"
style="background: ${avatarBackground(a.name)}"
>
${imgURL
? html`<img
src="${imgURL}"
alt="${a.name}"
@error=${this.handleSimilarImageError}
/>`
: a.name.charAt(0).toUpperCase()}
</div>
<div
class="similar-name"
title="${a.name}"
>
${a.name}
</div>
</div>
`;
})}
</div>
${showToggle
? html`
<button
class="disco-toggle"
aria-expanded="${this.similarExpanded}"
@click=${() => { this.similarExpanded = !this.similarExpanded; }}
>
${this.similarExpanded
? 'Show less'
: `Show all ${artists.length}`}
<wa-icon name="chevron-down"></wa-icon>
</button>
`
: nothing}
</section>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'explore-artist-details': ExploreArtistDetails;
}
}