import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query, } from 'lit/decorators.js'; import '@lit-labs/virtualizer'; import type { LitVirtualizer, VisibilityChangedEvent, } from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { gridSpacingFor } from '@utils/grid-spacing'; import { GetAlbumsByArtist, GetFilePathsByAlbums, } from '@go/library/library.js'; import * as library from '@go/library/models.js'; import { LibraryController } from '@store/controllers/library-controller'; import { libraryStore } from '@store/library-store'; import { SearchController } from '@store/controllers/search-controller'; import '@components/page-header/page-header'; import { queueStore } from '@store/queue-store'; import { ContextMenuController, contextMenuStyles, isContextMenuKey, } from '@utils/context-menu-controller.js'; import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js'; import { FavoritesController } from '@store/controllers/favorites-controller'; import { ViewLifecycleMixin } from '@utils/view-lifecycle'; import { RovingGridController } from '@utils/roving-grid'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@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 '@components/playlist-picker/playlist-picker.js'; import { dict, list } from '@utils/binding'; import { ICON_PLAYLIST, ICON_QUEUE, } from '@utils/icon-language'; /** Pixels to change card width per scroll tick. */ const ZOOM_STEP = 16; /** localStorage key for persisted artist card size. */ const CARD_SIZE_KEY = 'artists-view-card-size'; /** Card size limits. */ const CARD_SIZE_MIN = 100; const CARD_SIZE_MAX = 350; const CARD_SIZE_DEFAULT = 176; /** Debounce delay for saving scroll position. */ const SCROLL_DEBOUNCE_MS = 100; /** * Grid entry for the virtualized artist grid. */ const ARTIST_SORT_DIR_KEY = 'artists-view-sort-dir'; /** One key, so the header renders a label and a direction button. */ const ARTIST_SORT_OPTIONS = [{ id: 'name', label: 'Name' }]; interface ArtistEntry { artist: library.Artist; index: number; } @customElement('artists-view') export class ArtistsView extends ViewLifecycleMixin(LitElement) implements ContextMenuHost { private libraryCtrl = new LibraryController(this); private searchCtrl = new SearchController(this); private ctxMenu = new ContextMenuController(this); private favCtrl = new FavoritesController(this); private wheelListenerAttached = false; private lastSearchTerm = ''; /** One tab stop for the whole grid, moved with the arrows — a card * per tab stop makes a library-length tab sequence (H-5). */ private roving = new RovingGridController(this, { cardSelector: '.artist-card', count: () => this.cachedGridEntries.length, scrollToIndex: (index) => { this.shadowRoot ?.querySelector('lit-virtualizer') ?.scrollToIndex(index, 'nearest'); }, }); /** Tracks the store's cached array reference to detect refreshes. */ private lastArtistsRef: library.Artist[] | null = null; private scrollDebounceTimer: ReturnType< typeof setTimeout > | null = null; @state() private artists: library.Artist[] = []; @state() private loading = true; @state() private restoringScroll = false; @state() private cardSize: number = CARD_SIZE_DEFAULT; // ----- Multi-select state ----- @state() private selectedArtists: Set = new Set(); private lastSelectedArtistIndex: number | null = null; // ----- Context menu state ----- /** * Artist ID that was right-clicked to open the * context menu. Used as fallback when the * right-clicked artist is not in the current * visual selection. */ private contextMenuArtistId: number | null = null; @query('#context-menu') private contextMenuPopup!: MenuSurface; @query('#playlist-submenu') private playlistSubmenuPopup!: MenuSurface; getContextMenuPopup(): MenuTarget | undefined { return this.contextMenuPopup; } getPlaylistSubmenuPopup(): | MenuTarget | undefined { return this.playlistSubmenuPopup; } onContextMenuClose(): void { this.contextMenuArtistId = null; } // ----- Grid spacing constants ----- private static readonly CARD_PADDING = 5; private get imageSize(): number { return ( this.cardSize - ArtistsView.CARD_PADDING * 2 ); } private get cardTextHeight(): number { const w = this.cardSize; if (w < 160) return 30; if (w > 250) return 42; return 36; } /** Wheel handler reference for add/remove. */ private wheelHandler = (e: WheelEvent) => { this.onWheel(e); }; private gridLayout = this.createGridLayout(); private createGridLayout() { const w = this.cardSize ?? CARD_SIZE_DEFAULT; const h = w + this.cardTextHeight; // One number for the gap, the row gap and the padding: whatever // a row could not spend on another card, shared out equally, so // the outside is never wider than the inside. See // `utils/grid-spacing.ts`. const spacing = this.spacingFor(this.containerWidth); this.lastLayoutSpacing = spacing; return grid({ itemSize: { width: `${w}px`, height: `${h}px`, }, gap: `${spacing}px`, padding: `${spacing}px`, justify: 'start', }); } /** The width the grid lays itself out in. */ private get containerWidth(): number { return ( this.renderRoot?.querySelector( '.grid-scroll-container', )?.clientWidth || this.clientWidth || 0 ); } private spacingFor(width: number): number { return gridSpacingFor(width, this.cardSize); } /** Sort direction for the artist grid. * * There is only one key to sort by: `library.Artist` carries a * name, an MBID and three image URLs, and nothing countable — so * the header shows "Sort: Name" and this button, rather than a * select with one option in it (H-19). */ @state() private sortDirection: 'asc' | 'desc' = 'asc'; // -- Memoisation caches for filtered artists -- private cachedFilteredArtists: library.Artist[] = []; private cachedGridEntries: ArtistEntry[] = []; private prevFilterArtists: library.Artist[] = []; private prevFilterTerm = ''; private prevFilterDir: 'asc' | 'desc' | '' = ''; /** * Recompute the filtered-artists and grid-entries * caches when their inputs have changed. Called * from willUpdate() so the caches are ready * before render(). */ private recomputeArtistCaches() { const term = this.searchCtrl.term; if ( this.artists !== this.prevFilterArtists || term !== this.prevFilterTerm || this.sortDirection !== this.prevFilterDir ) { this.prevFilterArtists = this.artists; this.prevFilterTerm = term; this.prevFilterDir = this.sortDirection; this.cachedFilteredArtists = this.computeFilteredArtists(); this.cachedGridEntries = this.cachedFilteredArtists.map( (artist, index) => ({ artist, index, }), ); } } private computeFilteredArtists(): library.Artist[] { const term = this.searchCtrl.term.toLowerCase(); const matching = term ? this.artists.filter((a) => a.Name.toLowerCase().includes(term), ) : this.artists; // Descending is the only reordering available, so ascending // keeps the backend's order rather than re-sorting it: the // array's identity is what tells the virtualizer to repaint, // and copying it every pass would repaint on every keystroke. if (this.sortDirection === 'asc') return matching; return [...matching].sort((a, b) => b.Name.localeCompare(a.Name), ); } private onPageHeaderSort = ( e: CustomEvent<{ direction: 'asc' | 'desc' }>, ) => { this.sortDirection = e.detail.direction; try { localStorage.setItem( ARTIST_SORT_DIR_KEY, this.sortDirection, ); } catch { // Ignore storage errors. } }; static override styles = [ contextMenuStyles, css` :host { display: flex; flex-direction: column; overflow: hidden; height: 100%; position: relative; contain: layout style; } .grid-scroll-container { flex: 1; overflow-y: auto; overflow-x: hidden; contain: paint; } lit-virtualizer { width: 100%; min-height: 100%; } .artist-card { display: flex; flex-direction: column; align-items: center; padding: 5px; border-radius: 8px; cursor: pointer; /* transitions removed — software rendering repaints per frame */ overflow: hidden; } .artist-card:hover { background-color: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .artist-card:active { transform: scale(0.97); } .artist-card.selected { outline: 2px solid var(--yj-accent, #ffd43b); outline-offset: 2px; } .artist-card.selected .avatar-container { scale: 0.95; } .artist-card.selected .artist-name { scale: 0.95; } .avatar-container { width: var(--avatar-size); height: var(--avatar-size); border-radius: 50%; overflow: hidden; background: linear-gradient( 135deg, var(--yj-bg-overlay, #404040) 0%, var(--yj-bg-surface, #282828) 100% ); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .avatar-image { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } .avatar-placeholder { color: var( --yj-text-secondary, #b3b3b3 ); font-size: var( --placeholder-font, 48px ); font-weight: 600; text-transform: uppercase; user-select: none; line-height: 1; } .artist-name { width: 100%; text-align: center; font-size: var( --artist-name-font, 14px ); font-weight: 500; color: var( --yj-text-primary, #fff ); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: var(--artist-name-pad, 6px) 2px 0; line-height: 1.3; } .search-bar-row { position: relative; display: flex; align-items: center; justify-content: center; min-height: 30px; border-bottom: 1px solid var(--yj-border-subtle, #333); flex-shrink: 0; user-select: none; } .search-indicator { position: absolute; left: 50%; transform: translateX(-50%); pointer-events: none; background: var( --yj-bg-overlay, #495057 ); color: var( --yj-text-secondary, #b3b3b3 ); font-size: 12px; padding: 2px 14px; border-radius: 12px; border: 1px solid var(--yj-border-subtle, #555); white-space: nowrap; opacity: 0.92; } .loading-message, .empty-message { display: flex; align-items: center; justify-content: center; height: 100%; color: var( --yj-text-secondary, #b3b3b3 ); font-size: 14px; } `, ]; /* ================================================================ * Lifecycle * ================================================================ */ override willUpdate( changed: Map, ) { super.willUpdate(changed); this.recomputeArtistCaches(); } override connectedCallback() { super.connectedCallback(); this.loadCardSize(); this.loadSortDirection(); this.loadArtists(); } private loadSortDirection() { try { const saved = localStorage.getItem( ARTIST_SORT_DIR_KEY, ); if (saved === 'asc' || saved === 'desc') { this.sortDirection = saved; } } catch { // Ignore storage errors. } } override disconnectedCallback() { super.disconnectedCallback(); this.detachWheelListener(); this.gridResizeObserver?.disconnect(); this.gridResizeObserver = null; } /** The wheel listener and the scroll debounce belong to the grid * while it is on screen; off-screen it cannot be scrolled, and a * cached view is never disconnected. */ protected override onViewDeactivate(): void { this.detachWheelListener(); if (this.scrollDebounceTimer !== null) { clearTimeout(this.scrollDebounceTimer); this.scrollDebounceTimer = null; } } override updated() { this.updateSizeProperties(); this.ensureWheelListener(); this.updateGridLayout(); // Clear selection when search term changes. const currentTerm = this.searchCtrl.term; if (currentTerm !== this.lastSearchTerm) { this.lastSearchTerm = currentTerm; this.clearSelection(); } // Re-fetch when the store delivers fresh // data after eager refetch on invalidation. const cached = this.libraryCtrl.cachedArtists; if ( cached !== null && cached !== this.lastArtistsRef ) { this.lastArtistsRef = cached; this.loadArtists(); } } /* ================================================================ * Data loading * ================================================================ */ private async loadArtists() { try { this.loading = true; const artists = await this.libraryCtrl.getArtists(); this.artists = artists ?? []; } catch (error) { console.error( 'Error loading artists:', error, ); this.artists = []; } finally { const saved = this.libraryCtrl.getScrollPosition( 'artists', ); this.restoringScroll = saved > 0; this.loading = false; } await this.updateComplete; this.restoreScrollPosition(); } /* ================================================================ * Scroll position persistence * ================================================================ */ /** * Save the first visible item index on scroll. */ private onVisibilityChanged = ( e: VisibilityChangedEvent, ) => { if (this.restoringScroll) return; if (this.scrollDebounceTimer !== null) { clearTimeout(this.scrollDebounceTimer); } this.scrollDebounceTimer = setTimeout( () => { this.libraryCtrl.setScrollPosition( 'artists', e.first, ); }, SCROLL_DEBOUNCE_MS, ); }; /** * Restore scroll position from the store. */ private restoreScrollPosition(): void { const saved = this.libraryCtrl.getScrollPosition( 'artists', ); if (saved <= 0) { this.restoringScroll = false; return; } const virt = this.shadowRoot?.querySelector( 'lit-virtualizer', ) as LitVirtualizer | null; if (!virt) { this.restoringScroll = false; return; } const safeIndex = Math.min( saved, this.cachedFilteredArtists.length - 1, ); if (safeIndex <= 0) { this.restoringScroll = false; return; } virt.scrollToIndex(safeIndex, 'start'); this.restoringScroll = false; } /* ================================================================ * Card size (zoom) * ================================================================ */ private loadCardSize(): void { try { const stored = localStorage.getItem(CARD_SIZE_KEY); if (stored !== null) { const parsed = parseInt(stored, 10); if (!Number.isNaN(parsed)) { this.cardSize = Math.max( CARD_SIZE_MIN, Math.min( CARD_SIZE_MAX, parsed, ), ); } } } catch { // localStorage may be unavailable. } } private saveCardSize(): void { try { localStorage.setItem( CARD_SIZE_KEY, String(this.cardSize), ); } catch { // localStorage may be unavailable. } } private setCardSize(size: number): void { const clamped = Math.round( Math.max( CARD_SIZE_MIN, Math.min(CARD_SIZE_MAX, size), ), ); if (clamped === this.cardSize) return; this.cardSize = clamped; this.saveCardSize(); } /* ================================================================ * Wheel zoom (Ctrl+scroll) * ================================================================ */ private onWheel(e: WheelEvent) { if (!e.ctrlKey) return; e.preventDefault(); const delta = e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP; this.setCardSize(this.cardSize + delta); } private ensureWheelListener() { const container = this.shadowRoot?.querySelector( '.grid-scroll-container', ); if ( container && !this.wheelListenerAttached ) { container.addEventListener( 'wheel', this .wheelHandler as EventListener, { passive: false }, ); this.wheelListenerAttached = true; } } private detachWheelListener() { const container = this.shadowRoot?.querySelector( '.grid-scroll-container', ); if ( container && this.wheelListenerAttached ) { container.removeEventListener( 'wheel', this .wheelHandler as EventListener, ); this.wheelListenerAttached = false; } } /* ================================================================ * Grid layout * ================================================================ */ private lastLayoutWidth = 0; private lastLayoutSpacing = 0; /** Watches the scroller so a window resize rebuilds the layout: * the spacing is derived from its width, and nothing else asks * this view to update when only that changes. */ private gridResizeObserver: ResizeObserver | null = null; private observeGridWidth() { const container = this.renderRoot?.querySelector( '.grid-scroll-container', ); if (!container || this.gridResizeObserver) return; this.gridResizeObserver = new ResizeObserver(() => this.requestUpdate(), ); this.gridResizeObserver.observe(container); } private updateGridLayout() { this.observeGridWidth(); if ( this.cardSize === this.lastLayoutWidth && this.lastLayoutSpacing === this.spacingFor(this.containerWidth) ) { return; } this.lastLayoutWidth = this.cardSize; this.gridLayout = this.createGridLayout(); } /* ================================================================ * Dynamic size properties * ================================================================ */ private updateSizeProperties() { const w = this.cardSize; if (w < 160) { this.style.setProperty( '--artist-name-font', '12px', ); this.style.setProperty( '--artist-name-pad', '4px', ); } else if (w > 250) { this.style.setProperty( '--artist-name-font', '15px', ); this.style.setProperty( '--artist-name-pad', '8px', ); } else { this.style.setProperty( '--artist-name-font', '14px', ); this.style.setProperty( '--artist-name-pad', '6px', ); } } /* ================================================================ * Artist selection helpers * ================================================================ */ /** * Select a contiguous range of artist IDs * between two indices in filteredArtists. */ private selectArtistRange( from: number, to: number, ): Set { const filtered = this.cachedFilteredArtists; const start = Math.min(from, to); const end = Math.max(from, to); const ids = new Set(); for (let i = start; i <= end; i++) { const artist = filtered[i]; if (artist) { ids.add(artist.ID); } } return ids; } /** * Fetches all file paths for every selected * artist. */ private async getSelectedArtistFilePaths(): Promise< string[] > { const selected = this.artists.filter((a) => this.selectedArtists.has(a.ID), ); const allPaths: string[] = []; for (const artist of selected) { const paths = await this.getArtistFilePaths( artist, ); allPaths.push(...paths); } return allPaths; } /** * Return file paths for the context menu target. * If the right-clicked artist is part of the * current selection, return paths for all selected * artists. Otherwise return paths for the * right-clicked artist only. */ private async getContextMenuArtistFilePaths(): Promise< string[] > { if ( this.contextMenuArtistId !== null && !this.selectedArtists.has( this.contextMenuArtistId, ) ) { const artist = this.artists.find( (a) => a.ID === this.contextMenuArtistId, ); if (artist) { return this.getArtistFilePaths( artist, ); } return []; } return this.getSelectedArtistFilePaths(); } /** Clear the current artist selection. */ private clearSelection() { this.selectedArtists = new Set(); this.lastSelectedArtistIndex = null; } /* ================================================================ * Artist card click * ================================================================ */ private onArtistClick( e: MouseEvent, artist: library.Artist, index: number, ) { const isCtrl = e.ctrlKey || e.metaKey; const isShift = e.shiftKey; if ( isShift && this.lastSelectedArtistIndex !== null ) { const range = this.selectArtistRange( this.lastSelectedArtistIndex, index, ); const next = new Set( this.selectedArtists, ); for (const id of range) { next.add(id); } this.selectedArtists = next; } else if (isCtrl) { const next = new Set( this.selectedArtists, ); if (next.has(artist.ID)) { next.delete(artist.ID); } else { next.add(artist.ID); } this.selectedArtists = next; this.lastSelectedArtistIndex = index; } else { // Plain click: navigate to details. this.clearSelection(); this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-artist-details', artistMBID: artist.MBID || '', artistName: artist.Name, localArtistId: artist.ID, }, }), ); } } /* ================================================================ * Context menu * ================================================================ */ private onArtistContextMenu = ( e: MouseEvent, artist: library.Artist, ) => { e.preventDefault(); e.stopPropagation(); this.contextMenuArtistId = artist.ID; this.ctxMenu.openAt( e.clientX, e.clientY, ); }; /** Shift+F10 / ContextMenu on a focused card, anchored to the card * so the menu appears where the artist is and focus goes back * there when it closes. */ private openArtistMenuFromKey( e: KeyboardEvent, artist: library.Artist, ): void { const card = e.currentTarget as HTMLElement | null; if (!card) return; e.preventDefault(); e.stopPropagation(); this.contextMenuArtistId = artist.ID; this.ctxMenu.openFrom(card); } private async onContextMenuAction( action: string, ) { const filePaths = await this.getContextMenuArtistFilePaths(); if (filePaths.length === 0) return; const artist = this.artists.find( (a) => a.ID === this.contextMenuArtistId, ); switch (action) { case 'play': queueStore.setQueue( filePaths, 0, true, artist ? { type: 'artist', id: artist.ID, label: artist.Name } : undefined, ); break; case 'add-to-queue': queueStore.addTracksToQueue( filePaths, ); break; case 'play-next': queueStore.playTracksNext( filePaths, ); break; } this.ctxMenu.close(); } /** * Resolve artist file paths and show the * playlist submenu. */ private async handleShowPlaylistSubmenu() { const paths = await this.getContextMenuArtistFilePaths(); void this.ctxMenu.showPlaylistSubmenu(paths); } private async onContextMenuFavoriteToggle() { const filePaths = await this.getContextMenuArtistFilePaths(); if (filePaths.length === 0) return; if (this.favCtrl.allFavorited(filePaths)) { void this.favCtrl.removeFromFavorites( filePaths, ); } else { void this.favCtrl.addToFavorites( filePaths, ); } this.ctxMenu.close(); } /* ================================================================ * File path resolution * ================================================================ */ /** * Fetches all file paths for an artist by * loading their albums, then every album's paths * in one call. Respects the active library filter. * * perf.m2: this was a `for await` over the albums, * so a 16-album artist was 17 sequential round * trips returning whole track rows to read one * field off each. */ private async getArtistFilePaths( artist: library.Artist, ): Promise { try { const libId = this.libraryCtrl.selectedLibraryId; const albums = await list( GetAlbumsByArtist(artist.Name, libId ?? 0), ); const byAlbum = await dict( GetFilePathsByAlbums( albums.map((a) => a.ID), libId ?? 0, ), ); const allPaths: string[] = []; // The album order is the caller's, which is // why the backend groups rather than // flattens. Keys arrive as strings, JSON // having no integer keys. for (const album of albums) { const paths = byAlbum[album.ID] ?? []; allPaths.push(...paths); } return allPaths; } catch (error) { console.error( 'Error loading artist tracks:', error, ); return []; } } /* ================================================================ * Helpers * ================================================================ */ private renderArtistAvatar(artist: library.Artist) { const needed = (this.imageSize ?? 176) * window.devicePixelRatio; let imageURL = ''; if (needed <= 100) { imageURL = artist.ImageSmall || artist.ImageMedium || artist.ImageLarge || ''; } else if (needed <= 200) { imageURL = artist.ImageMedium || artist.ImageLarge || ''; } else { imageURL = artist.ImageLarge || ''; } // Fallback: use album cover art if no artist image. // // `perf.M4`. This was a linear scan of every cached album, // lowercasing two strings per comparison, run from inside the // virtualizer's `renderItem` — so per card, per frame. It is // also the *common* case rather than an edge one: a // locally-tagged library has no artist images at all (the bulk // measurement seed has 440 artists, 4 988 albums and zero // images), so every visible card paid it on every pass. if (!imageURL) { imageURL = this.albumArtByArtist().get( artist.Name.toLowerCase(), ) ?? ''; } if (imageURL) { return html`${artist.Name}`; } return html` ${this.getArtistInitial(artist.Name)} `; } /** * Lowercased artist name → that artist's cover art, built once per * identity of the album cache rather than per card per frame. * * Keyed on the array identity because that is exactly what * `library-store` gives it: the store replaces the array when its * contents change and shares the unchanged members, which is the * same signal every memoized cache in `track-list` keys on. The * size hint is included so a *tier* change (the grid resizing past * one of `getCoverUrl`'s breakpoints) also rebuilds. */ private albumArtCache?: { albums: readonly library.Album[]; needed: number; map: Map; }; private albumArtByArtist(): Map { const albums = libraryStore.cachedAlbums; const needed = (this.imageSize ?? 176) * window.devicePixelRatio; if (!albums) return new Map(); if ( this.albumArtCache && this.albumArtCache.albums === albums && this.albumArtCache.needed === needed ) { return this.albumArtCache.map; } const map = new Map(); for (const a of albums) { const key = a.ArtistName.toLowerCase(); // First album wins, which is what the original scan did by // breaking on its first match. if (map.has(key)) continue; const url = needed <= 100 ? a.CoverArtSmall || a.CoverArtMedium || '' : a.CoverArtMedium || a.CoverArtLarge || ''; if (url) map.set(key, url); } this.albumArtCache = { albums, needed, map }; return map; } private getArtistInitial( name: string, ): string { if (!name) return '?'; return name.charAt(0).toUpperCase(); } /* ================================================================ * Rendering * ================================================================ */ private renderArtistCard(entry: ArtistEntry) { const { artist, index } = entry; const imgSize = this.imageSize; const placeholderFont = Math.round( imgSize * 0.38, ); const isSelected = this.selectedArtists.has(artist.ID); return html`
this.roving.noteFocus(index)} role="option" aria-label="${artist.Name}" aria-selected="${isSelected}" style=" --avatar-size: ${imgSize}px; --placeholder-font: ${placeholderFont}px; " @click=${(e: MouseEvent) => this.onArtistClick( e, artist, index, )} @contextmenu=${(e: MouseEvent) => this.onArtistContextMenu( e, artist, )} @keydown=${(e: KeyboardEvent) => { if (isContextMenuKey(e)) { this.openArtistMenuFromKey( e, artist, ); return; } if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); this.clearSelection(); this.dispatchEvent( new CustomEvent( 'navigate', { bubbles: true, composed: true, detail: { view: 'explore-artist-details', artistMBID: artist.MBID || '', artistName: artist.Name, localArtistId: artist.ID, }, }, ), ); } }} >
${this.renderArtistAvatar(artist)}
${artist.Name}
`; } private renderContextMenu() { return html` ${this.ctxMenu.contextMenuOpen ? html` ` : nothing} ${this.ctxMenu.playlistSubmenuOpen ? html`
this.ctxMenu.clearSubmenuCloseTimer()} @mouseleave=${this .ctxMenu .scheduleSubmenuClose} > e.stopPropagation()} >
` : nothing}
`; } override render() { if (this.loading) { // The header keeps its place while the view loads: a // heading that appears only once the data does is the // shifting layout this component exists to stop. return html`
Loading artists...
`; } const entries = this.cachedGridEntries; const searchBar = html` `; if (entries.length === 0) { return html` ${searchBar}
${this.searchCtrl.term ? 'No artists match your search.' : 'No artists in library.'}
`; } return html` ${searchBar}
this.renderArtistCard(entry)} .keyFunction=${(entry: ArtistEntry) => entry.artist.ID} .layout=${this.gridLayout} @visibilityChanged=${this.onVisibilityChanged} >
${this.renderContextMenu()} `; } /** * Click on empty area of the grid clears the * selection. */ private onGridClick = (e: MouseEvent) => { const path = e.composedPath(); const clickedCard = path.some( (el) => el instanceof HTMLElement && el.classList.contains('artist-card'), ); if (!clickedCard) { this.clearSelection(); } }; }