import { library } from '@go/models'; import { LitElement, html, css, nothing } from 'lit'; 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'; import { PlayerController } from '@store/controllers/player-controller'; import { SearchController } from '@store/controllers/search-controller'; import { TrackListController } from '@store/controllers/tracklist-controller'; import { queueStore } from '@store/queue-store'; import { LibraryController } from '@store/controllers/library-controller'; import { Events } from '../../events'; import { COLUMN_DEFS, DEFAULT_COLUMN_IDS, } from './columns'; import type { ColumnDef } from './columns'; import { setDragPayload, emitDragActive, } from '@utils/drag-controller'; import { createDragImage, createTrackCardDragImage, removeDragImage, } from '@utils/drag-image'; import '@lit-labs/virtualizer'; import type { LitVirtualizer, VisibilityChangedEvent, } from '@lit-labs/virtualizer'; import { flow } from '@lit-labs/virtualizer/layouts/flow.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/playlist-picker/playlist-picker.js'; import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; import '@components/track-details/track-details.js'; import type { TrackDetails } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js'; const COLUMN_STORAGE_KEY = 'track-list-column-widths'; const SORT_FIELD_KEY = 'track-list-sort-field'; const SORT_DIR_KEY = 'track-list-sort-direction'; const MIN_COLUMN_WIDTH = 50; const DEFAULT_FIXED_WIDTH = 80; 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); private trackListCtrl = new TrackListController(this); private selection = new SelectionController(this); private cancelScanComplete?: () => void; private lastSearchTerm = ''; /** * Resolved column definitions for the currently configured * column IDs. Falls back to defaults for any unknown ID. */ private get activeColumns(): ColumnDef[] { const ids = this.trackListCtrl.columnIds; if (!ids || ids.length === 0) { return DEFAULT_COLUMN_IDS .map((id) => COLUMN_DEFS[id]) .filter( (d): d is ColumnDef => d !== undefined, ); } return ids .map((id) => COLUMN_DEFS[id]) .filter( (d): d is ColumnDef => d !== undefined, ); } @state() private tracks: library.Track[] = []; @state() private contextMenuOpen = false; @state() private playlistSubmenuOpen = false; @query('#context-menu') private contextMenuPopup!: HTMLElement; @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; @query('track-details') private trackDetailsDialog!: TrackDetails; @query('lit-virtualizer') private virtualizer!: LitVirtualizer; private lastActiveTrackPath: string | null = null; private submenuCloseTimer: ReturnType< typeof setTimeout > | null = null; // -- Memoisation caches for filtered / sorted tracks -- private cachedFilteredTracks: library.Track[] = []; private cachedSortedTracks: library.Track[] = []; private prevFilterTracks: library.Track[] = []; private prevFilterTerm = ''; private prevFilterColIds = ''; private prevSortFiltered: library.Track[] = []; private prevSortField: string | null = null; private prevSortDir: SortDirection = 'asc'; 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(); }; private clearSelectionHandler = (e: MouseEvent) => { const path = e.composedPath(); const isTrackClick = path.some( (el) => el instanceof HTMLElement && el.classList.contains('track-row') && this.shadowRoot?.contains(el), ); if (!isTrackClick) { this.selection.clear(); } }; private dragImageEl: HTMLElement | null = null; @state() private columnWidths: number[] = []; /** Column ID to sort by, or null for default order. */ @state() private sortField: string | null = null; /** Current sort direction. */ @state() private sortDirection: SortDirection = 'asc'; /** Whether the sort dropdown popup is open. */ @state() private sortDropdownOpen = false; @query('#sort-dropdown') private sortDropdownPopup!: HTMLElement; private resizingColumn: number | null = null; private resizeStartX = 0; private resizeStartWidths: number[] = []; private resizeObserver: ResizeObserver | null = null; private flowLayout = flow(); private hasRestoredScroll = false; // ================================================================= // Filtered / sorted tracks (memoised) // ================================================================= /** * Recompute the filtered and sorted track caches when * their inputs have changed. Called from willUpdate() * so the caches are ready before render(). */ private recomputeTrackCaches() { const term = this.searchCtrl.term; const colIds = this.trackListCtrl.columnIds.join(','); if ( this.tracks !== this.prevFilterTracks || term !== this.prevFilterTerm || colIds !== this.prevFilterColIds ) { this.prevFilterTracks = this.tracks; this.prevFilterTerm = term; this.prevFilterColIds = colIds; this.cachedFilteredTracks = this.computeFilteredTracks(); } if ( this.cachedFilteredTracks !== this.prevSortFiltered || this.sortField !== this.prevSortField || this.sortDirection !== this.prevSortDir ) { this.prevSortFiltered = this.cachedFilteredTracks; this.prevSortField = this.sortField; this.prevSortDir = this.sortDirection; this.cachedSortedTracks = this.computeSortedTracks(); } } private computeFilteredTracks(): library.Track[] { const term = this.searchCtrl.term.toLowerCase(); if (!term) return this.tracks; const cols = this.activeColumns; return this.tracks.filter((t) => cols.some((col) => col .accessor(t) .toLowerCase() .includes(term), ), ); } private computeSortedTracks(): library.Track[] { const tracks = this.cachedFilteredTracks; if (!this.sortField) return tracks; const col = COLUMN_DEFS[this.sortField]; if (!col?.comparator) return tracks; const dir = this.sortDirection === 'asc' ? 1 : -1; return [...tracks].sort( (a, b) => dir * col.comparator!(a, b), ); } // ================================================================= // SelectionHost interface // ================================================================= getItemKey(index: number): string | undefined { return this.cachedSortedTracks[index]?.FilePath; } getItemCount(): number { return this.cachedSortedTracks.length; } onSelectionChanged(): void { this.virtualizer?.requestUpdate(); } private get gridTemplateColumns(): string { const cols = this.activeColumns; if (this.columnWidths.length === 0) { return cols .map((c) => c.defaultWidth) .join(' '); } return this.columnWidths .map((w) => `${w}px`) .join(' '); } private get colBoundaryPositions(): number[] { if (this.columnWidths.length === 0) return []; const padding = 8; const positions: number[] = []; let cumulative = padding; for ( let i = 0; i < this.columnWidths.length - 1; i++ ) { cumulative += this.columnWidths[i] ?? 0; positions.push(cumulative); } return positions; } private initColumnWidths() { const saved = this.loadColumnWidths(); if (saved) { this.columnWidths = saved; return; } this.computeDefaultWidths(); } private computeDefaultWidths() { const totalWidth = this.clientWidth; if (totalWidth <= 0) return; const cols = this.activeColumns; if (cols.length === 0) return; // Fixed-width columns use their pixel default; // flex columns share the remainder equally. const fixedTotal = cols.reduce((sum, c) => { if (c.defaultWidth.endsWith('px')) { return ( sum + parseInt(c.defaultWidth, 10) ); } return sum; }, 0); const flexCols = cols.filter( (c) => !c.defaultWidth.endsWith('px'), ); const remaining = Math.max( 0, totalWidth - fixedTotal, ); const perFlex = flexCols.length > 0 ? Math.floor( remaining / flexCols.length, ) : DEFAULT_FIXED_WIDTH; const raw = cols.map((c) => { if (c.defaultWidth.endsWith('px')) { return parseInt(c.defaultWidth, 10); } return Math.max( MIN_COLUMN_WIDTH, perFlex, ); }); this.columnWidths = this.normalizeWidths(raw); } private loadColumnWidths(): number[] | null { try { const raw = localStorage.getItem( COLUMN_STORAGE_KEY, ); if (!raw) return null; const parsed: unknown = JSON.parse(raw); // Support new id-keyed format: Record. if ( parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ) { const map = parsed as Record< string, unknown >; const cols = this.activeColumns; const widths = cols.map((c) => { const w = map[c.id]; if ( typeof w === 'number' && w >= MIN_COLUMN_WIDTH ) { return w; } // Fallback for columns without saved width. if ( c.defaultWidth.endsWith('px') ) { return parseInt( c.defaultWidth, 10, ); } return MIN_COLUMN_WIDTH; }); return this.normalizeWidths(widths); } // Legacy array format — discard on column count mismatch. return null; } catch { return null; } } private saveColumnWidths() { try { const cols = this.activeColumns; const map: Record = {}; cols.forEach((c, i) => { map[c.id] = this.columnWidths[i] ?? MIN_COLUMN_WIDTH; }); localStorage.setItem( COLUMN_STORAGE_KEY, JSON.stringify(map), ); } catch { // Ignore storage errors. } } /** * Scale widths so they sum to exactly the container width. * Every column is guaranteed at least MIN_COLUMN_WIDTH. */ private normalizeWidths( widths: number[], ): number[] { const container = this.clientWidth; if (container <= 0 || widths.length === 0) { return widths; } const minTotal = widths.length * MIN_COLUMN_WIDTH; // If the container can't even fit minimums, // give every column the minimum. if (container <= minTotal) { return widths.map( () => MIN_COLUMN_WIDTH, ); } const sum = widths.reduce( (a, b) => a + b, 0, ); if (sum <= 0) { const even = Math.floor( container / widths.length, ); return widths.map(() => Math.max(MIN_COLUMN_WIDTH, even), ); } // Scale proportionally. const scale = container / sum; const scaled = widths.map((w) => Math.max( MIN_COLUMN_WIDTH, Math.round(w * scale), ), ); // Fix rounding remainder so the total is // exactly containerWidth. const scaledSum = scaled.reduce( (a, b) => a + b, 0, ); const diff = container - scaledSum; if (diff !== 0) { // Apply remainder to the widest column. let maxIdx = 0; for (let i = 1; i < scaled.length; i++) { if ( (scaled[i] ?? 0) > (scaled[maxIdx] ?? 0) ) { maxIdx = i; } } scaled[maxIdx] = (scaled[maxIdx] ?? 0) + diff; } return scaled; } private onColResizeStart = (e: MouseEvent, columnIndex: number) => { e.preventDefault(); this.resizingColumn = columnIndex; this.resizeStartX = e.clientX; this.resizeStartWidths = [...this.columnWidths]; this.requestUpdate(); }; private onColResizeMove = (e: MouseEvent) => { if (this.resizingColumn === null) return; const container = this.clientWidth; if (container <= 0) return; const delta = e.clientX - this.resizeStartX; const col = this.resizingColumn; const starts = this.resizeStartWidths; // Sum of columns to the left (unchanged). let leftSum = 0; for (let i = 0; i < col; i++) { leftSum += starts[i] ?? 0; } // Count and sum of columns to the right. const rightCount = starts.length - col - 1; let rightSum = 0; for ( let i = col + 1; i < starts.length; i++ ) { rightSum += starts[i] ?? 0; } // Clamp dragged column: leave at least // MIN_COLUMN_WIDTH for each right column. const maxWidth = container - leftSum - rightCount * MIN_COLUMN_WIDTH; let newWidth = Math.max( MIN_COLUMN_WIDTH, Math.min( maxWidth, (starts[col] ?? 0) + delta, ), ); const updated: number[] = new Array( starts.length, ); // Left columns keep starting widths. for (let i = 0; i < col; i++) { updated[i] = starts[i] ?? 0; } updated[col] = newWidth; // Right columns always fill remaining space // proportionally (handles both grow & shrink). const availableForRight = container - leftSum - newWidth; if (rightCount === 0 || rightSum <= 0) { // Nothing to distribute. } else { const scale = availableForRight / rightSum; let roundedSum = 0; let maxIdx = -1; let maxVal = 0; for ( let i = col + 1; i < starts.length; i++ ) { const scaled = Math.max( MIN_COLUMN_WIDTH, Math.round( (starts[i] ?? 0) * scale, ), ); updated[i] = scaled; roundedSum += scaled; if (scaled > maxVal) { maxVal = scaled; maxIdx = i; } } // Fix rounding remainder on the widest // right column. const diff = availableForRight - roundedSum; if (diff !== 0 && maxIdx >= 0) { updated[maxIdx] = (updated[maxIdx] ?? 0) + diff; } // Re-derive dragged width so total is // exactly container. newWidth = container - leftSum - roundedSum - diff; if (newWidth < MIN_COLUMN_WIDTH) { newWidth = MIN_COLUMN_WIDTH; } updated[col] = newWidth; } this.columnWidths = updated; }; private onColResizeEnd = () => { if (this.resizingColumn === null) return; this.resizingColumn = null; this.saveColumnWidths(); this.requestUpdate(); }; static override styles = css` :host { display: flex; flex-direction: column; overflow: hidden; } .table-container { position: relative; flex: 1; display: flex; flex-direction: column; overflow: hidden; } /* ---- Sort toolbar ---- */ .sort-toolbar { display: flex; align-items: center; gap: 6px; padding: 4px 8px; font-size: 12px; color: var(--yj-text-secondary, #b3b3b3); border-bottom: 1px solid var(--yj-border-subtle, #333); flex-shrink: 0; user-select: none; } .sort-anchor { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 4px; background: transparent; border: none; color: inherit; font: inherit; } .sort-anchor:hover { background: var( --yj-hover-overlay, rgba(255, 255, 255, 0.05) ); } .sort-anchor .sort-label { color: var(--yj-text-primary, #fff); } .sort-dir-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; cursor: pointer; border: none; border-radius: 4px; background: transparent; color: var(--yj-text-secondary, #b3b3b3); font-size: 12px; padding: 0; } .sort-dir-btn:hover { background: var( --yj-hover-overlay, rgba(255, 255, 255, 0.05) ); color: var(--yj-text-primary, #fff); } .sort-dropdown-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: 140px; } .sort-dropdown-panel wa-dropdown-item { cursor: pointer; --wa-color-text-normal: var( --yj-text-primary, #fff ); font-size: 13px; } .sort-dropdown-panel wa-dropdown-item:hover { background-color: var( --yj-hover-overlay, rgba(255, 255, 255, 0.1) ); } .sort-dropdown-panel wa-dropdown-item.active-sort { color: var(--yj-accent, #ffd43b); --wa-color-text-normal: var( --yj-accent, #ffd43b ); } #sort-dropdown { z-index: 200; } /* ---- Header row ---- */ .header-row { display: grid; grid-template-columns: var(--grid-cols); padding: 8px; font-weight: bold; color: var(--yj-text-primary, #fff); border-bottom: 1px solid var(--yj-text-tertiary, #666); flex-shrink: 0; overflow: hidden; } .header-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; display: flex; align-items: center; gap: 4px; } .header-cell:hover { color: var(--yj-accent, #ffd43b); } .sort-arrow { font-size: 10px; flex-shrink: 0; color: var(--yj-accent, #ffd43b); } .resize-overlay { position: absolute; inset: 0; pointer-events: none; z-index: 2; } .col-resize-handle { position: absolute; top: 0; height: 100%; width: 1px; cursor: col-resize; pointer-events: auto; background-color: var(--yj-border, #444); transition: background-color 0.15s ease; } .col-resize-handle::before { content: ''; position: absolute; top: 0; left: -3px; width: 7px; height: 100%; } .col-resize-handle:hover, .col-resize-handle.active { background-color: var(--yj-text-tertiary, #6c757d); } .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; } .no-results { padding: 24px 16px; color: var(--yj-text-secondary, #b3b3b3); font-size: 13px; } lit-virtualizer { flex: 1; overflow-x: hidden; overflow-y: auto; user-select: none; } .track-row { display: grid; grid-template-columns: var(--grid-cols); font-size: 12px; padding: 8px; border-bottom: 1px solid var(--yj-border-subtle, #333); align-items: center; width: 100%; cursor: default; user-select: none; overflow: hidden; } .track-row > * { min-width: 0; } .header-cell + .header-cell, .track-row > :not(:first-child) { padding-left: 6px; } .track-row:hover { background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05)); } .track-row.selected { background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15)); } .track-row.active { background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1)); } .track-row.active { color: var(--yj-accent, #ffd43b); } .track-row.selected.active { background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15)); } .cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: default; user-select: none; } .cell-right { text-align: right; } .cell-center { text-align: center; } #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; } .context-menu-panel wa-dropdown-item { --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; } `; override connectedCallback() { super.connectedCallback(); this.restoreSortPreferences(); 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); document.addEventListener('mousedown', this.sortDropdownCloseHandler); document.addEventListener('click', this.clearSelectionHandler); document.addEventListener('mousemove', this.onColResizeMove); document.addEventListener('mouseup', this.onColResizeEnd); this.resizeObserver = new ResizeObserver( () => { this.onHostResize(); }, ); this.resizeObserver.observe(this); } override disconnectedCallback() { this.virtualizer?.removeEventListener( 'visibilityChanged', this.onVisibilityChanged, ); this.hasRestoredScroll = false; super.disconnectedCallback(); this.cancelScanComplete?.(); document.removeEventListener('click', this.closeHandler); document.removeEventListener('contextmenu', this.closeHandler); document.removeEventListener('mousedown', this.mousedownCloseHandler); document.removeEventListener('mousedown', this.sortDropdownCloseHandler); document.removeEventListener('click', this.clearSelectionHandler); document.removeEventListener('mousemove', this.onColResizeMove); document.removeEventListener('mouseup', this.onColResizeEnd); this.resizeObserver?.disconnect(); 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(); } this.recomputeTrackCaches(); } override firstUpdated() { this.initColumnWidths(); } override updated(changed: Map) { // Recompute widths when the column config changes. const colKey = this.trackListCtrl.columnIds.join( ',', ); if (colKey !== this.previousColumnIds) { this.previousColumnIds = colKey; this.initColumnWidths(); } if (changed.has('columnWidths')) { this.style.setProperty( '--grid-cols', this.gridTemplateColumns, ); } const currentPath = this.player.currentTrack?.filePath ?? null; if (currentPath !== this.lastActiveTrackPath) { this.lastActiveTrackPath = currentPath; this.virtualizer?.requestUpdate(); } // Clear selection when search term changes. const currentTerm = this.searchCtrl.term; if (currentTerm !== this.lastSearchTerm) { this.lastSearchTerm = currentTerm; this.selection.clear(); } } private previousHostWidth = 0; private previousColumnIds = ''; private onHostResize() { const newWidth = this.clientWidth; if ( newWidth <= 0 || this.columnWidths.length === 0 || this.resizingColumn !== null ) { return; } if (this.previousHostWidth === 0) { this.previousHostWidth = newWidth; return; } this.columnWidths = this.normalizeWidths( this.columnWidths, ); this.previousHostWidth = newWidth; this.saveColumnWidths(); } async loadTracks() { try { const tracks = await this.libraryCtrl.getTracks(); this.tracks = tracks; this.selection.clear(); await this.updateComplete; if (this.isConnected && this.virtualizer) { this.virtualizer.addEventListener( 'visibilityChanged', this.onVisibilityChanged, ); } } catch (error) { console.error('Error loading tracks:', error); } } private onVisibilityChanged = (e: Event) => { const { first } = e as VisibilityChangedEvent; if (!this.hasRestoredScroll) { this.hasRestoredScroll = true; const savedIndex = this.libraryCtrl.getScrollPosition('tracks'); if (savedIndex > 0) { requestAnimationFrame(() => { this.virtualizer?.scrollToIndex( savedIndex, 'start', ); }); return; } } this.libraryCtrl.setScrollPosition('tracks', first); }; private onTrackRowClick( e: MouseEvent, track: library.Track, index: number, ) { this.selection.handleItemClick(e, track.FilePath, index); } private onTrackRowDblClick(track: library.Track) { this.selection.clear(); queueStore.setQueue([track.FilePath], 0); } private onTrackContextMenu(e: MouseEvent, track: library.Track) { e.preventDefault(); e.stopPropagation(); this.selection.handleContextMenu(track.FilePath); this.contextMenuOpen = true; // Position the popup at the mouse cursor using a virtual anchor. this.updateComplete.then(() => { const popup = this.contextMenuPopup; if (popup) { (popup as any).anchor = { getBoundingClientRect() { return { width: 0, height: 0, x: e.clientX, y: e.clientY, top: e.clientY, left: e.clientX, right: e.clientX, bottom: e.clientY, }; }, }; (popup as any).active = true; } }); } // ================================================================= // Drag source // ================================================================= private onTrackDragStart = ( e: DragEvent, track: library.Track, ) => { // Gather file paths: all selected if this track is selected, // otherwise just the dragged track. let filePaths: string[]; if (this.selection.isSelected(track.FilePath)) { filePaths = this.selection.getSelectedKeysOrdered(); } else { filePaths = [track.FilePath]; } if (filePaths.length === 0) return; setDragPayload(e, { filePaths, source: 'track-list', }); // Custom drag image. this.dragImageEl = filePaths.length === 1 ? createTrackCardDragImage( track.TrackName, track.ArtistName, track.FilePath, ) : createDragImage(filePaths.length); e.dataTransfer?.setDragImage( this.dragImageEl, 0, 0, ); emitDragActive(true); }; private onTrackDragEnd = () => { if (this.dragImageEl) { removeDragImage(this.dragImageEl); this.dragImageEl = null; } emitDragActive(false); }; private onContextMenuAction(action: string) { const filePaths = this.selection.getSelectedKeysOrdered(); if (filePaths.length === 0) return; switch (action) { case 'play': queueStore.setQueue(filePaths, 0, true); break; case 'add-to-queue': queueStore.addTracksToQueue(filePaths); break; case 'play-next': queueStore.playTracksNext(filePaths); break; case 'track-details': this.openTrackDetails(filePaths[0]!); break; } this.closeContextMenu(true); } private openTrackDetails(filePath: string) { const track = this.tracks.find( (t) => t.FilePath === filePath, ); if (!track) return; const coverArt = this.resolveCoverArt(track.Album); this.trackDetailsDialog?.show( track, coverArt ?? undefined, ); } private resolveCoverArt( albumName: string, ): CoverArtUrls | null { if (!albumName) return null; const albums = this.libraryCtrl.cachedAlbums; if (!albums) return null; const album = albums.find( (a) => a.Name === albumName, ); if (!album || !album.CoverArtPath) return null; return { coverArtPath: album.CoverArtPath, coverArtSmall: album.CoverArtSmall, coverArtMedium: album.CoverArtMedium, coverArtLarge: album.CoverArtLarge, }; } private closeContextMenu(clearSelection = false) { if (!this.contextMenuOpen) return; this.closePlaylistSubmenu(); this.contextMenuOpen = false; if (clearSelection) { this.selection.clear(); } const popup = this.contextMenuPopup; if (popup) { (popup as any).active = false; } } 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 async showPlaylistSubmenu() { this.clearSubmenuCloseTimer(); if (this.playlistSubmenuOpen) return; this.playlistSubmenuOpen = true; await this.updateComplete; 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(true); }; // ================================================================= // Sort controls // ================================================================= /** Restore sort preferences from localStorage. */ private restoreSortPreferences() { try { const field = localStorage.getItem(SORT_FIELD_KEY); const dir = localStorage.getItem(SORT_DIR_KEY); if ( field && COLUMN_DEFS[field]?.comparator ) { this.sortField = field; } if (dir === 'asc' || dir === 'desc') { this.sortDirection = dir; } } catch { // Ignore storage errors. } } /** Persist sort preferences to localStorage. */ private saveSortPreferences() { try { if (this.sortField) { localStorage.setItem( SORT_FIELD_KEY, this.sortField, ); } else { localStorage.removeItem( SORT_FIELD_KEY, ); } localStorage.setItem( SORT_DIR_KEY, this.sortDirection, ); } catch { // Ignore storage errors. } } /** * Handle a click on a column header to toggle sorting. * First click: sort ascending. Second: descending. * Third: clear sort (back to default order). */ private onHeaderCellClick(colId: string) { const col = COLUMN_DEFS[colId]; if (!col?.comparator) return; if (this.sortField === colId) { if (this.sortDirection === 'asc') { this.sortDirection = 'desc'; } else { this.sortField = null; this.sortDirection = 'asc'; } } else { this.sortField = colId; this.sortDirection = 'asc'; } this.saveSortPreferences(); } /** Set sort from the dropdown and close it. */ private onSortDropdownSelect( colId: string | null, ) { if (colId === null) { this.sortField = null; this.sortDirection = 'asc'; } else { this.sortField = colId; } this.saveSortPreferences(); this.closeSortDropdown(); } /** Toggle sort direction via the toolbar button. */ private toggleSortDirection() { this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; this.saveSortPreferences(); } private toggleSortDropdown() { if (this.sortDropdownOpen) { this.closeSortDropdown(); } else { this.openSortDropdown(); } } private async openSortDropdown() { this.sortDropdownOpen = true; await this.updateComplete; const popup = this.sortDropdownPopup; const anchor = this.shadowRoot?.querySelector( '.sort-anchor', ); if (popup && anchor) { (popup as any).anchor = anchor; (popup as any).active = true; } } private closeSortDropdown() { if (!this.sortDropdownOpen) return; this.sortDropdownOpen = false; const popup = this.sortDropdownPopup; if (popup) { (popup as any).active = false; } } private sortDropdownCloseHandler = ( e: MouseEvent, ) => { if (!this.sortDropdownOpen) return; const path = e.composedPath(); const popup = this.sortDropdownPopup; if (popup && path.includes(popup)) return; const anchor = this.shadowRoot?.querySelector( '.sort-anchor', ); if (anchor && path.includes(anchor)) return; this.closeSortDropdown(); }; private isActiveTrack(track: library.Track): boolean { const currentTrack = this.player.currentTrack; if (!currentTrack) return false; return currentTrack.filePath === track.FilePath; } private renderTrackRow = ( track: library.Track, index: number, ): unknown => { const active = this.isActiveTrack(track); const selected = this.selection.isSelected( track.FilePath, ); const classes = [ 'track-row', active ? 'active' : '', selected ? 'selected' : '', ] .filter(Boolean) .join(' '); const cols = this.activeColumns; return html`
this.onTrackRowClick(e, track, index)} @dblclick=${() => this.onTrackRowDblClick(track)} @contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)} @dragstart=${(e: DragEvent) => this.onTrackDragStart(e, track)} @dragend=${this.onTrackDragEnd} > ${cols.map((col) => { const val = col.accessor(track); const centered = val === '\u2014'; const align = centered ? 'cell-center' : col.align === 'right' ? 'cell-right' : ''; return html`
${val}
`; })}
`; }; /** Render the sort toolbar above the header row. */ private renderSortToolbar() { const activeCol = this.sortField ? COLUMN_DEFS[this.sortField] : null; const label = activeCol ? activeCol.label : 'Default'; const dirIcon = this.sortDirection === 'asc' ? 'arrow-up-short-wide' : 'arrow-down-wide-short'; return html`
Sort: ${this.sortField ? html` ` : nothing}
${this.renderSortDropdownPopup()} `; } /** Render the sort dropdown popup. */ private renderSortDropdownPopup() { const cols = this.activeColumns; return html` ${this.sortDropdownOpen ? html`
this.onSortDropdownSelect( null, )} > Default ${cols .filter( (c) => c.comparator, ) .map( (col) => html` this.onSortDropdownSelect( col.id, )} > ${col.label} `, )}
` : nothing}
`; } override render() { const visibleTracks = this.cachedSortedTracks; const cols = this.activeColumns; return html` ${this.tracks.length === 0 ? html`

Loading tracks...

` : html` ${this.renderSortToolbar()}
${cols.map( (col) => html`
this.onHeaderCellClick( col.id, )} > ${col.label} ${this.sortField === col.id ? html` ${this.sortDirection === 'asc' ? '\u25B2' : '\u25BC'} ` : nothing}
`, )}
${visibleTracks.length === 0 ? html`

No tracks match your search.

` : html` `} ${this.searchCtrl.term && visibleTracks.length > 0 ? html`
Showing results for “${this.searchCtrl.term}”
` : nothing}
${this.colBoundaryPositions.map( (pos, i) => html`
this.onColResizeStart(e, i)} >
`, )}
`} ${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(); void this.showPlaylistSubmenu(); }} @mouseleave=${this.scheduleSubmenuClose} @click=${(e: Event) => { e.stopPropagation(); void this.showPlaylistSubmenu(); }} > Add to Playlist ${this.selection.selectionCount === 1 ? html` this.onContextMenuAction( 'track-details', )} @mouseenter=${() => this.closePlaylistSubmenu()} > Track Details ` : nothing}
` : nothing}
${this.playlistSubmenuOpen && this.selection.hasSelection ? html`
this.clearSubmenuCloseTimer()} @mouseleave=${this.scheduleSubmenuClose} > e.stopPropagation()} >
` : nothing}
`; } }