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 { GetFilePathsByGenres, } from '@go/library/library.js'; import type * as library from '@go/library/models.js'; import { LibraryController } from '@store/controllers/library-controller'; 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 { dictByName } 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 genre card size. */ const CARD_SIZE_KEY = 'genres-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; /** A genre extracted from the track library. */ const GENRE_SORT_KEY = 'genres-view-sort'; const GENRE_SORT_OPTIONS = [ { id: 'name', label: 'Name' }, { id: 'tracks', label: 'Tracks' }, ]; interface Genre { name: string; trackCount: number; } /** Grid entry for the virtualized genre grid. */ interface GenreEntry { genre: Genre; index: number; } @customElement('genres-view') export class GenresView 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 = ''; /** See artists-view: one tab stop for the grid, moved with the * arrow keys. */ private roving = new RovingGridController(this, { cardSelector: '.genre-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 lastGenresRef: | library.GenreWithCount[] | null = null; private scrollDebounceTimer: ReturnType< typeof setTimeout > | null = null; @state() private genres: Genre[] = []; @state() private loading = true; @state() private restoringScroll = false; @state() private cardSize: number = CARD_SIZE_DEFAULT; // ----- Multi-select state ----- @state() private selectedGenres: Set = new Set(); private lastSelectedGenreIndex: number | null = null; // ----- Context menu state ----- /** * Genre name that was right-clicked to open the * context menu. Used as fallback when the * right-clicked genre is not in the current * visual selection. */ private contextMenuGenreName: string | null = null; @query('#context-menu') private contextMenuPopup!: MenuSurface; @query('#playlist-submenu') private playlistSubmenuPopup!: MenuSurface; // ----- ContextMenuHost interface ----- getContextMenuPopup(): MenuTarget | undefined { return this.contextMenuPopup; } getPlaylistSubmenuPopup(): | MenuTarget | undefined { return this.playlistSubmenuPopup; } onContextMenuClose(): void { this.contextMenuGenreName = null; } // ----- Grid spacing constants ----- private static readonly CARD_PADDING = 5; private get imageSize(): number { return ( this.cardSize - GenresView.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 key and direction for the genre grid (H-19: it had none). */ @state() private sortField: 'name' | 'tracks' = 'name'; @state() private sortDirection: 'asc' | 'desc' = 'asc'; // -- Memoisation caches for filtered genres -- private cachedFilteredGenres: Genre[] = []; private cachedGridEntries: GenreEntry[] = []; private prevFilterGenres: Genre[] = []; private prevFilterTerm = ''; private prevFilterSort = ''; /** * Recompute the filtered-genres and grid-entries * caches when their inputs have changed. Called * from willUpdate() so the caches are ready * before render(). */ private recomputeGenreCaches() { const term = this.searchCtrl.term; const sortKey = `${this.sortField}:${this.sortDirection}`; if ( this.genres !== this.prevFilterGenres || term !== this.prevFilterTerm || sortKey !== this.prevFilterSort ) { this.prevFilterGenres = this.genres; this.prevFilterTerm = term; this.prevFilterSort = sortKey; this.cachedFilteredGenres = this.computeFilteredGenres(); this.cachedGridEntries = this.cachedFilteredGenres.map( (genre, index) => ({ genre, index, }), ); } } private computeFilteredGenres(): Genre[] { const term = this.searchCtrl.term.toLowerCase(); const matching = term ? this.genres.filter((g) => g.name.toLowerCase().includes(term), ) : this.genres; // The default order is the backend's, and the array's identity // is what makes the virtualizer repaint — so leave it alone // unless the user asked for something else. if (this.sortField === 'name' && this.sortDirection === 'asc') { return matching; } const dir = this.sortDirection === 'asc' ? 1 : -1; return [...matching].sort((a, b) => this.sortField === 'tracks' ? dir * (a.trackCount - b.trackCount) : dir * a.name.localeCompare(b.name), ); } private onPageHeaderSort = ( e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>, ) => { this.sortField = e.detail.field === 'tracks' ? 'tracks' : 'name'; this.sortDirection = e.detail.direction; try { localStorage.setItem( GENRE_SORT_KEY, `${this.sortField}:${this.sortDirection}`, ); } catch { // Ignore storage errors. } }; private loadSortPreferences() { try { const saved = localStorage.getItem(GENRE_SORT_KEY); const [field, dir] = (saved ?? '').split(':'); if (field === 'name' || field === 'tracks') { this.sortField = field; } if (dir === 'asc' || dir === 'desc') { this.sortDirection = dir; } } 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%; } .genre-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; } .genre-card:hover { background-color: var( --yj-bg-overlay, rgba(255, 255, 255, 0.06) ); } .genre-card:active { transform: scale(0.97); } .genre-card.selected { outline: 2px solid var(--yj-accent, #ffd43b); outline-offset: 2px; } .genre-card.selected .avatar-container { scale: 0.95; } .genre-card.selected .genre-name { scale: 0.95; } .avatar-container { width: var(--avatar-size); height: var(--avatar-size); border-radius: 8px; 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-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; } .genre-name { width: 100%; text-align: center; font-size: var( --genre-name-font, 14px ); font-weight: 500; color: var(--yj-text-primary, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: var(--genre-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.recomputeGenreCaches(); } override connectedCallback() { super.connectedCallback(); this.loadCardSize(); this.loadSortPreferences(); this.loadGenres(); } override disconnectedCallback() { super.disconnectedCallback(); this.detachWheelListener(); this.gridResizeObserver?.disconnect(); this.gridResizeObserver = null; } /** See artists-view: off-screen the grid cannot be scrolled, and * being cached it 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.cachedGenres; if ( cached !== null && cached !== this.lastGenresRef ) { this.lastGenresRef = cached; this.loadGenres(); } } /* ================================================================ * Data loading * ================================================================ */ private async loadGenres() { try { this.loading = true; const rows = await this.libraryCtrl.getGenres(); this.genres = (rows ?? []).map((r) => ({ name: r.Name, trackCount: r.TrackCount, })); } catch (error) { console.error( 'Error loading genres:', error, ); this.genres = []; } finally { const saved = this.libraryCtrl.getScrollPosition( 'genres', ); 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( 'genres', e.first, ); }, SCROLL_DEBOUNCE_MS, ); }; /** * Restore scroll position from the store. */ private restoreScrollPosition(): void { const saved = this.libraryCtrl.getScrollPosition( 'genres', ); 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.cachedFilteredGenres.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( '--genre-name-font', '12px', ); this.style.setProperty( '--genre-name-pad', '4px', ); } else if (w > 250) { this.style.setProperty( '--genre-name-font', '15px', ); this.style.setProperty( '--genre-name-pad', '8px', ); } else { this.style.setProperty( '--genre-name-font', '14px', ); this.style.setProperty( '--genre-name-pad', '6px', ); } } /* ================================================================ * Genre selection helpers * ================================================================ */ /** * Select a contiguous range of genre names * between two indices in filteredGenres. */ private selectGenreRange( from: number, to: number, ): Set { const filtered = this.cachedFilteredGenres; const start = Math.min(from, to); const end = Math.max(from, to); const names = new Set(); for (let i = start; i <= end; i++) { const genre = filtered[i]; if (genre) { names.add(genre.name); } } return names; } /** * Fetch file paths for a set of genre names by * querying the backend for each genre. * Respects the active library filter. */ private async getFilePathsForGenres( genreNames: Iterable, ): Promise { const seen = new Set(); const allPaths: string[] = []; const libId = this.libraryCtrl.selectedLibraryId; // perf.m2: one call per genre, each returning // whole track rows so the file path could be // read off them — 6 MB over the IPC for five // genres of a 50 000-track library. const names = Array.from(genreNames); const byGenre = await dictByName( GetFilePathsByGenres(names, libId ?? 0), ); // Still de-duplicated here: a track with two of // the selected genres appears under both, and // the caller owns the order. for (const name of names) { for (const path of byGenre[name] ?? []) { if (!seen.has(path)) { seen.add(path); allPaths.push(path); } } } return allPaths; } /** * Return file paths for the context menu target. * If the right-clicked genre is part of the * current selection, return paths for all selected * genres. Otherwise return paths for the * right-clicked genre only. */ private async getContextMenuGenreFilePaths(): Promise< string[] > { if ( this.contextMenuGenreName !== null && !this.selectedGenres.has( this.contextMenuGenreName, ) ) { return this.getFilePathsForGenres([ this.contextMenuGenreName, ]); } return this.getFilePathsForGenres( this.selectedGenres, ); } /** Clear the current genre selection. */ private clearSelection() { this.selectedGenres = new Set(); this.lastSelectedGenreIndex = null; } /* ================================================================ * Genre card click * ================================================================ */ private onGenreClick( e: MouseEvent, genre: Genre, index: number, ) { const isCtrl = e.ctrlKey || e.metaKey; const isShift = e.shiftKey; if ( isShift && this.lastSelectedGenreIndex !== null ) { const range = this.selectGenreRange( this.lastSelectedGenreIndex, index, ); const next = new Set( this.selectedGenres, ); for (const name of range) { next.add(name); } this.selectedGenres = next; } else if (isCtrl) { const next = new Set( this.selectedGenres, ); if (next.has(genre.name)) { next.delete(genre.name); } else { next.add(genre.name); } this.selectedGenres = next; this.lastSelectedGenreIndex = index; } else { // Plain click: navigate to details. this.clearSelection(); this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'genre-details', genreName: genre.name, }, }), ); } } /* ================================================================ * Context menu * ================================================================ */ private onGenreContextMenu = ( e: MouseEvent, genre: Genre, ) => { e.preventDefault(); e.stopPropagation(); this.contextMenuGenreName = genre.name; this.ctxMenu.openAt( e.clientX, e.clientY, ); }; /** Shift+F10 / ContextMenu on a focused card. */ private openGenreMenuFromKey( e: KeyboardEvent, genre: Genre, ): void { const card = e.currentTarget as HTMLElement | null; if (!card) return; e.preventDefault(); e.stopPropagation(); this.contextMenuGenreName = genre.name; this.ctxMenu.openFrom(card); } private async onContextMenuAction( action: string, ) { const filePaths = await this.getContextMenuGenreFilePaths(); if (filePaths.length === 0) return; switch (action) { case 'play': queueStore.setQueue(filePaths, 0, true, { type: 'genre', id: 0, label: this.contextMenuGenreName ?? '', }); break; case 'add-to-queue': queueStore.addTracksToQueue( filePaths, ); break; case 'play-next': queueStore.playTracksNext( filePaths, ); break; } this.ctxMenu.close(); } private async onContextMenuFavoriteToggle() { const filePaths = await this.getContextMenuGenreFilePaths(); if (filePaths.length === 0) return; if (this.favCtrl.allFavorited(filePaths)) { void this.favCtrl.removeFromFavorites( filePaths, ); } else { void this.favCtrl.addToFavorites( filePaths, ); } this.ctxMenu.close(); } /* ================================================================ * Helpers * ================================================================ */ private getGenreInitial(name: string): string { if (!name) return '?'; return name.charAt(0).toUpperCase(); } /* ================================================================ * Rendering * ================================================================ */ private renderGenreCard(entry: GenreEntry) { const { genre, index } = entry; const imgSize = this.imageSize; const placeholderFont = Math.round( imgSize * 0.38, ); const isSelected = this.selectedGenres.has(genre.name); return html`
this.roving.noteFocus(index)} role="option" aria-label="${genre.name}" aria-selected="${isSelected}" style=" --avatar-size: ${imgSize}px; --placeholder-font: ${placeholderFont}px; " @click=${(e: MouseEvent) => this.onGenreClick( e, genre, index, )} @contextmenu=${(e: MouseEvent) => this.onGenreContextMenu( e, genre, )} @keydown=${(e: KeyboardEvent) => { if (isContextMenuKey(e)) { this.openGenreMenuFromKey( e, genre, ); return; } if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); this.clearSelection(); this.dispatchEvent( new CustomEvent( 'navigate', { bubbles: true, composed: true, detail: { view: 'genre-details', genreName: genre.name, }, }, ), ); } }} >
${this.getGenreInitial( genre.name, )}
${genre.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 genres...
`; } const entries = this.cachedGridEntries; const searchBar = html` `; if (entries.length === 0) { return html` ${searchBar}
${this.searchCtrl.term ? 'No genres match your search.' : 'No genres in library.'}
`; } return html` ${searchBar}
this.renderGenreCard(entry)} .keyFunction=${(entry: GenreEntry) => entry.genre.name} .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('genre-card'), ); if (!clickedCard) { this.clearSelection(); } }; }