From 588acf09efe69c5ae453ea7b2e9eea6e453a2962 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 23 Feb 2026 11:10:21 -0500 Subject: [PATCH] added genres page --- frontend/index.ts | 15 + .../components/genre-details/genre-details.ts | 271 +++++ .../src/components/genres-view/genres-view.ts | 1056 +++++++++++++++++ .../src/components/sidebar/app-sidebar.ts | 3 +- .../src/components/track-list/track-list.ts | 47 +- frontend/src/store/search-store.ts | 2 +- 6 files changed, 1386 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/genre-details/genre-details.ts create mode 100644 frontend/src/components/genres-view/genres-view.ts diff --git a/frontend/index.ts b/frontend/index.ts index 175d50a..cbbe0af 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -9,6 +9,8 @@ import '@components/library-manager/library-manager.ts'; import '@components/config-page/config-page.ts'; import '@components/artists-view/artists-view.ts'; import '@components/artist-details/artist-details.ts'; +import '@components/genres-view/genres-view.ts'; +import '@components/genre-details/genre-details.ts'; import '@components/search-bar/search-bar.ts'; import '@components/track-details/track-details.ts'; import type { SearchBar } from '@components/search-bar/search-bar.ts'; @@ -69,6 +71,19 @@ document.addEventListener('navigate', (e: Event) => { mainContent.appendChild(el); break; } + case 'genres': + mainContent.innerHTML = ''; + break; + case 'genre-details': { + const { genreName } = + (e as CustomEvent).detail; + const genreEl = document.createElement('genre-details'); + + genreEl.setAttribute('genre-name', genreName); + mainContent.innerHTML = ''; + mainContent.appendChild(genreEl); + break; + } case 'libraries': mainContent.innerHTML = ''; break; diff --git a/frontend/src/components/genre-details/genre-details.ts b/frontend/src/components/genre-details/genre-details.ts new file mode 100644 index 0000000..6533850 --- /dev/null +++ b/frontend/src/components/genre-details/genre-details.ts @@ -0,0 +1,271 @@ +import { LitElement, html, css } from 'lit'; +import { + customElement, + property, + state, +} from 'lit/decorators.js'; +import { EventsOn } from '@runtime/runtime'; +import { library } from '@go/models'; +import { LibraryController } from '@store/controllers/library-controller'; +import { Events } from '../../events'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import '@components/track-list/track-list.js'; + +@customElement('genre-details') +export class GenreDetails extends LitElement { + @property({ type: String, attribute: 'genre-name' }) + genreName = ''; + + @state() + private tracks: library.Track[] = []; + + @state() + private loading = true; + + private libraryCtrl = new LibraryController(this); + private cancelScanComplete?: () => void; + + static override styles = css` + :host { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + } + + /* ==================================== + * Header + * ==================================== */ + + .genre-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; + } + + .genre-avatar { + width: 80px; + height: 80px; + 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; + } + + .genre-avatar .initial { + color: var( + --yj-text-secondary, + #b3b3b3 + ); + font-size: 32px; + font-weight: 600; + text-transform: uppercase; + user-select: none; + line-height: 1; + } + + .genre-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .genre-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; + } + + .track-count { + font-size: 13px; + color: var( + --yj-text-secondary, + #b3b3b3 + ); + } + + /* ==================================== + * Content + * ==================================== */ + + .content { + flex: 1; + overflow: hidden; + } + + track-list { + width: 100%; + height: 100%; + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.loadTracks(); + this.cancelScanComplete = EventsOn( + Events.LibraryScanComplete, + () => this.loadTracks(), + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.cancelScanComplete?.(); + } + + /* ================================================================ + * Data loading + * ================================================================ */ + + private async loadTracks() { + if (!this.genreName) return; + + try { + const allTracks = + await this.libraryCtrl.getTracks(); + + this.tracks = (allTracks ?? []).filter( + (t) => + (t.Genre ?? []).includes( + this.genreName, + ), + ); + } catch (error) { + console.error( + 'Error loading genre tracks:', + error, + ); + this.tracks = []; + } finally { + this.loading = false; + } + } + + /* ================================================================ + * Navigation + * ================================================================ */ + + private navigateBack() { + this.dispatchEvent( + new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { view: 'genres' }, + }), + ); + } + + /* ================================================================ + * Helpers + * ================================================================ */ + + private getInitial(name: string): string { + if (!name) return '?'; + + return name.charAt(0).toUpperCase(); + } + + /* ================================================================ + * Rendering + * ================================================================ */ + + override render() { + const trackCount = this.tracks.length; + const trackLabel = + trackCount === 1 ? 'track' : 'tracks'; + + return html` +
+ +
+ + ${this.getInitial( + this.genreName, + )} + +
+
+

+ ${this.genreName} +

+ ${!this.loading + ? html` + + ${trackCount} + ${trackLabel} + + ` + : ''} +
+
+
+ +
+ `; + } +} diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts new file mode 100644 index 0000000..5b74f70 --- /dev/null +++ b/frontend/src/components/genres-view/genres-view.ts @@ -0,0 +1,1056 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { + customElement, + state, + query, +} from 'lit/decorators.js'; +import { EventsOn } from '@runtime/runtime'; +import '@lit-labs/virtualizer'; +import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; +import { library } from '@go/models'; +import { LibraryController } from '@store/controllers/library-controller'; +import { SearchController } from '@store/controllers/search-controller'; +import { queueStore } from '@store/queue-store'; +import { Events } from '../../events'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import '@awesome.me/webawesome/dist/components/popup/popup.js'; +import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; +import '@components/playlist-picker/playlist-picker.js'; +import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; + +/** 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; + +/** A genre extracted from the track library. */ +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 LitElement { + private libraryCtrl = new LibraryController(this); + private searchCtrl = new SearchController(this); + private cancelScanComplete?: () => void; + private wheelListenerAttached = false; + + /** All tracks from the library (used to derive genres). */ + private allTracks: library.Track[] = []; + + @state() + private genres: Genre[] = []; + + @state() + private loading = true; + + @state() + private cardSize: number = CARD_SIZE_DEFAULT; + + // ----- Context menu state ----- + + @state() + private contextMenuOpen = false; + + @state() + private playlistSubmenuOpen = false; + + @state() + private playlistFilePaths: string[] = []; + + /** The genre targeted by the current context menu. */ + private contextMenuGenre: Genre | null = null; + + @query('#context-menu') + private contextMenuPopup!: HTMLElement; + + @query('#playlist-submenu') + private playlistSubmenuPopup!: HTMLElement; + + private submenuCloseTimer: ReturnType< + typeof setTimeout + > | null = null; + + // ----- Close handlers ----- + + private closeHandler = () => + this.closeContextMenu(); + + private mousedownCloseHandler = ( + e: MouseEvent, + ) => { + const path = e.composedPath(); + const popup = this.contextMenuPopup; + const submenu = this.playlistSubmenuPopup; + + if (popup && path.includes(popup)) return; + if (submenu && path.includes(submenu)) return; + + this.closeContextMenu(); + }; + + // ----- Grid spacing constants ----- + + private static readonly GRID_GAP = 8; + private static readonly GRID_PADDING = 8; + 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; + const gap = GenresView.GRID_GAP; + const pad = GenresView.GRID_PADDING; + + return grid({ + itemSize: { + width: `${w}px`, + height: `${h}px`, + }, + gap: `${gap}px`, + padding: `${pad}px`, + justify: 'center', + }); + } + + /** Filtered genres based on search term. */ + private get filteredGenres(): Genre[] { + const term = + this.searchCtrl.term.toLowerCase(); + + if (!term) { + return this.genres; + } + + return this.genres.filter((g) => + g.name.toLowerCase().includes(term), + ); + } + + /** Build grid entries from filtered genres. */ + private get gridEntries(): GenreEntry[] { + return this.filteredGenres.map( + (genre, index) => ({ + genre, + index, + }), + ); + } + + static override styles = css` + :host { + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + } + + .grid-scroll-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + } + + lit-virtualizer { + width: 100%; + min-height: 100%; + } + + .genre-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 5px; + border-radius: 8px; + cursor: pointer; + transition: + background-color 0.15s ease, + transform 0.1s ease; + overflow: hidden; + } + + .genre-card:hover { + background-color: var( + --yj-bg-overlay, + rgba(255, 255, 255, 0.06) + ); + } + + .genre-card:active { + transform: scale(0.97); + } + + .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-indicator { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 5; + pointer-events: none; + background: var( + --yj-bg-overlay, + #495057 + ); + color: var( + --yj-text-secondary, + #b3b3b3 + ); + font-size: 12px; + padding: 4px 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; + } + + /* ==================================== + * Context menu + * ==================================== */ + + #context-menu { + z-index: 200; + } + + .context-menu-panel { + background-color: var( + --yj-bg-elevated, + #343a40 + ); + border: 1px solid + var(--yj-border, #444); + border-radius: 6px; + padding: 4px 0; + box-shadow: 0 8px 24px + rgba(0, 0, 0, 0.5); + min-width: 160px; + } + + .context-menu-panel wa-dropdown-item { + cursor: pointer; + --wa-color-text-normal: var( + --yj-text-primary, + #fff + ); + font-size: 13px; + } + + .context-menu-panel + wa-dropdown-item:hover { + background-color: var( + --yj-hover-overlay, + rgba(255, 255, 255, 0.1) + ); + } + + .submenu-item { + position: relative; + } + + .submenu-arrow { + font-size: 10px; + margin-left: auto; + padding-left: 12px; + } + + #playlist-submenu { + z-index: 210; + } + `; + + /* ================================================================ + * Lifecycle + * ================================================================ */ + + override connectedCallback() { + super.connectedCallback(); + this.loadCardSize(); + this.loadGenres(); + this.cancelScanComplete = EventsOn( + Events.LibraryScanComplete, + () => this.loadGenres(), + ); + document.addEventListener( + 'click', + this.closeHandler, + ); + document.addEventListener( + 'contextmenu', + this.closeHandler, + ); + document.addEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.cancelScanComplete?.(); + this.detachWheelListener(); + document.removeEventListener( + 'click', + this.closeHandler, + ); + document.removeEventListener( + 'contextmenu', + this.closeHandler, + ); + document.removeEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); + } + + override updated() { + this.updateSizeProperties(); + this.ensureWheelListener(); + this.updateGridLayout(); + } + + /* ================================================================ + * Data loading + * ================================================================ */ + + private async loadGenres() { + try { + this.loading = true; + + const tracks = + await this.libraryCtrl.getTracks(); + + this.allTracks = tracks ?? []; + this.genres = + this.extractGenres(this.allTracks); + } catch (error) { + console.error( + 'Error loading genres:', + error, + ); + this.allTracks = []; + this.genres = []; + } finally { + this.loading = false; + } + } + + /** + * Extract unique genres from all tracks, + * sorted alphabetically by name. + */ + private extractGenres( + tracks: library.Track[], + ): Genre[] { + const counts = new Map(); + + for (const track of tracks) { + const genres = track.Genre ?? []; + + for (const name of genres) { + if (!name) continue; + + counts.set( + name, + (counts.get(name) ?? 0) + 1, + ); + } + } + + const result: Genre[] = []; + + for (const [name, trackCount] of counts) { + result.push({ name, trackCount }); + } + + result.sort((a, b) => + a.name.localeCompare(b.name), + ); + + return result; + } + + /* ================================================================ + * 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 updateGridLayout() { + if ( + this.cardSize === this.lastLayoutWidth + ) { + 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 card click + * ================================================================ */ + + private onGenreClick(genre: Genre) { + 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.contextMenuGenre = genre; + this.openContextMenuAt( + e.clientX, + e.clientY, + ); + }; + + private openContextMenuAt( + clientX: number, + clientY: number, + ) { + this.contextMenuOpen = true; + + this.updateComplete.then(() => { + const popup = this.contextMenuPopup; + + if (popup) { + (popup as any).anchor = { + getBoundingClientRect() { + return { + width: 0, + height: 0, + x: clientX, + y: clientY, + top: clientY, + left: clientX, + right: clientX, + bottom: clientY, + }; + }, + }; + (popup as any).active = true; + } + }); + } + + private closeContextMenu() { + if (!this.contextMenuOpen) return; + + this.closePlaylistSubmenu(); + this.contextMenuOpen = false; + this.playlistFilePaths = []; + this.contextMenuGenre = null; + + const popup = this.contextMenuPopup; + + if (popup) { + (popup as any).active = false; + } + } + + private onContextMenuAction(action: string) { + const genre = this.contextMenuGenre; + + if (!genre) return; + + const filePaths = + this.getGenreFilePaths(genre.name); + + if (filePaths.length === 0) return; + + switch (action) { + case 'play': + queueStore.setQueue(filePaths, 0); + break; + case 'add-to-queue': + queueStore.addTracksToQueue( + filePaths, + ); + break; + case 'play-next': + queueStore.playTracksNext( + filePaths, + ); + break; + } + + this.closeContextMenu(); + } + + /* ================================================================ + * Playlist submenu + * ================================================================ */ + + private clearSubmenuCloseTimer() { + if (this.submenuCloseTimer !== null) { + clearTimeout(this.submenuCloseTimer); + this.submenuCloseTimer = null; + } + } + + private scheduleSubmenuClose = () => { + this.clearSubmenuCloseTimer(); + this.submenuCloseTimer = setTimeout(() => { + this.submenuCloseTimer = null; + this.closePlaylistSubmenu(); + }, 150); + }; + + private showPlaylistSubmenu() { + this.clearSubmenuCloseTimer(); + + if (this.playlistSubmenuOpen) return; + + const genre = this.contextMenuGenre; + + if (!genre) return; + + this.playlistFilePaths = + this.getGenreFilePaths(genre.name); + + this.playlistSubmenuOpen = true; + + void this.updateComplete.then(() => { + const submenu = + this.playlistSubmenuPopup; + + const trigger = + this.shadowRoot?.querySelector( + '.submenu-item', + ); + + if (submenu && trigger) { + (submenu as any).anchor = trigger; + (submenu as any).active = true; + } + + const picker = + this.shadowRoot?.querySelector( + 'playlist-picker', + ) as PlaylistPicker | null; + + picker?.reset(); + }); + } + + private closePlaylistSubmenu() { + this.clearSubmenuCloseTimer(); + + if (!this.playlistSubmenuOpen) return; + + this.playlistSubmenuOpen = false; + + const submenu = this.playlistSubmenuPopup; + + if (submenu) { + (submenu as any).active = false; + } + } + + private onPlaylistActionComplete = () => { + this.closeContextMenu(); + }; + + /* ================================================================ + * File path resolution + * ================================================================ */ + + /** + * Returns all file paths for tracks that + * contain the given genre name. + */ + private getGenreFilePaths( + genreName: string, + ): string[] { + return this.allTracks + .filter((t) => + (t.Genre ?? []).includes(genreName), + ) + .map((t) => t.FilePath); + } + + /* ================================================================ + * Helpers + * ================================================================ */ + + private getGenreInitial(name: string): string { + if (!name) return '?'; + + return name.charAt(0).toUpperCase(); + } + + /* ================================================================ + * Rendering + * ================================================================ */ + + private renderGenreCard(entry: GenreEntry) { + const { genre } = entry; + const imgSize = this.imageSize; + const placeholderFont = Math.round( + imgSize * 0.38, + ); + + return html` +
+ this.onGenreClick(genre)} + @contextmenu=${(e: MouseEvent) => + this.onGenreContextMenu( + e, + genre, + )} + @keydown=${(e: KeyboardEvent) => { + if ( + e.key === 'Enter' || + e.key === ' ' + ) { + e.preventDefault(); + this.onGenreClick(genre); + } + }} + > +
+ + ${this.getGenreInitial( + genre.name, + )} + +
+
+ ${genre.name} +
+
+ `; + } + + private renderContextMenu() { + return html` + + ${this.contextMenuOpen + ? html` +
+ + this.onContextMenuAction( + 'play', + )} + @mouseenter=${() => + this.closePlaylistSubmenu()} + > + + Play + + + this.onContextMenuAction( + 'add-to-queue', + )} + @mouseenter=${() => + this.closePlaylistSubmenu()} + > + + Add to Queue + + + this.onContextMenuAction( + 'play-next', + )} + @mouseenter=${() => + this.closePlaylistSubmenu()} + > + + Play Next + + { + this.clearSubmenuCloseTimer(); + this.showPlaylistSubmenu(); + }} + @mouseleave=${this + .scheduleSubmenuClose} + @click=${( + e: Event, + ) => { + e.stopPropagation(); + this.showPlaylistSubmenu(); + }} + > + + Add to Playlist + + +
+ ` + : nothing} +
+ + + ${this.playlistSubmenuOpen + ? html` +
+ this.clearSubmenuCloseTimer()} + @mouseleave=${this + .scheduleSubmenuClose} + > + + e.stopPropagation()} + > +
+ ` + : nothing} +
+ `; + } + + override render() { + if (this.loading) { + return html` +
+ Loading genres... +
+ `; + } + + const entries = this.gridEntries; + + if (entries.length === 0) { + return html` +
+ ${this.searchCtrl.term + ? 'No genres match your search.' + : 'No genres in library.'} +
+ `; + } + + return html` + ${this.searchCtrl.term + ? html`
+ Showing results for + “${this.searchCtrl + .term}” +
` + : nothing} +
+ + this.renderGenreCard(entry)} + .layout=${this.gridLayout} + > +
+ ${this.renderContextMenu()} + `; + } +} diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index edb76cd..7bd0646 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -4,7 +4,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js'; import type { DragActiveDetail } from '@utils/drag-controller'; -type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks' | 'settings'; +type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'settings'; interface NavItem { id: View; @@ -141,6 +141,7 @@ export class AppSidebar extends LitElement { { id: 'libraries', label: 'Libraries', icon: 'folder-open' }, { id: 'playlists', label: 'Playlists', icon: 'list' }, { id: 'artists', label: 'Artists', icon: 'user-group' }, + { id: 'genres', label: 'Genres', icon: 'masks-theater' }, { id: 'albums', label: 'Albums', icon: 'compact-disc' }, { id: 'tracks', label: 'Tracks', icon: 'music' }, { id: 'settings', label: 'Settings', icon: 'gear' }, diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index fefad59..6a2b68c 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -1,6 +1,11 @@ import { library } from '@go/models'; import { LitElement, html, css, nothing } from 'lit'; -import { customElement, state, query } from 'lit/decorators.js'; +import { + customElement, + property, + state, + query, +} from 'lit/decorators.js'; import { EventsOn } from '@runtime/runtime'; import { SelectionController } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller'; @@ -49,6 +54,15 @@ type SortDirection = 'asc' | 'desc'; @customElement('track-list') export class TrackList extends LitElement implements SelectionHost { + /** + * When set, the list displays these tracks instead of + * fetching all tracks from the library store. The + * component also skips the LibraryScanComplete listener + * since the parent is responsible for reloading. + */ + @property({ type: Array, attribute: false }) + externalTracks?: library.Track[]; + private player = new PlayerController(this); private libraryCtrl = new LibraryController(this); private searchCtrl = new SearchController(this); @@ -924,11 +938,16 @@ export class TrackList extends LitElement implements SelectionHost { override connectedCallback() { super.connectedCallback(); this.restoreSortPreferences(); - this.loadTracks(); - this.cancelScanComplete = EventsOn( - Events.LibraryScanComplete, - () => this.loadTracks(), - ); + + if (this.externalTracks) { + this.tracks = this.externalTracks; + } else { + this.loadTracks(); + this.cancelScanComplete = EventsOn( + Events.LibraryScanComplete, + () => this.loadTracks(), + ); + } document.addEventListener('click', this.closeHandler); document.addEventListener('contextmenu', this.closeHandler); document.addEventListener('mousedown', this.mousedownCloseHandler); @@ -966,6 +985,22 @@ export class TrackList extends LitElement implements SelectionHost { this.resizeObserver = null; } + override willUpdate( + changed: Map, + ) { + super.willUpdate(changed); + + // When the parent provides a new external track + // list, update local tracks and reset selection. + if ( + changed.has('externalTracks') && + this.externalTracks + ) { + this.tracks = this.externalTracks; + this.selection.clear(); + } + } + override firstUpdated() { this.initColumnWidths(); } diff --git a/frontend/src/store/search-store.ts b/frontend/src/store/search-store.ts index 665e0a3..ed84053 100644 --- a/frontend/src/store/search-store.ts +++ b/frontend/src/store/search-store.ts @@ -1,5 +1,5 @@ /** Searchable views that respond to the global search term. */ -const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'artists']); +const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'artists', 'genres']); type Subscriber = () => void;