From 559e1ed077f82c0bd3249ee30c5682f2288c8475 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 01:19:37 -0400 Subject: [PATCH] perf(frontend): make the lists and grids pay per row, not per library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list pays per row, and only while scrolling — and none of this is visible to any test tier: nothing renders differently and nothing fails, the app is just slower. - The track list's Art column rendered `CoverArtPath`, the original artwork, into a 24 px box while `CoverArtSmall` sat unused on the same model, with no `loading="lazy"`. 26 of 26 image requests asked for the full-size tier; now 0. - `artists-view`'s avatar fallback linear-scanned every cached album per card per frame, lowercasing two strings per comparison, inside the virtualizer's renderItem — the common case, since a locally tagged library has no artist images at all. Measured at 5 000 albums and 24 visible cards: 1.46 ms/frame -> 0.01 ms/frame. - Five components resolved selected file paths back to tracks with `tracks.find(...)`; they share `utils/track-index.ts` now. "Select all -> Edit tags" at 50 000 tracks: 3 051-6 298 ms -> 68 ms. - "Play this artist", "play these albums" and the album drag cache resolve paths in one call instead of one per album. - The column-resize drag registers its document listeners on mousedown. Two things here are load-bearing and read as sloppiness. The per-render arrow functions in `artists-view` and `genres-view` are the *only* thing changing a property of their virtualizer on a host update, and therefore the only thing repainting the cards: hoisting them to stable fields takes a selection from 1 highlighted card to 0. And a row inside a virtualizer needs `width: 100%`, because the virtualizer positions its children absolutely and a grid row otherwise shrinks to fit its content and stops lining up with the header above it. --- .../components/artists-view/artists-view.ts | 142 +++++++-- .../components/cover-grid/album-selection.ts | 86 ++++-- .../src/components/cover-grid/cover-grid.ts | 79 +++-- .../src/components/genres-view/genres-view.ts | 63 ++-- .../top-results-row/top-results-row.ts | 14 +- frontend/src/components/track-list/columns.ts | 28 +- .../src/components/track-list/track-list.ts | 291 +++++++++++++++--- 7 files changed, 558 insertions(+), 145 deletions(-) diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index 1dd0388..df751db 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -12,9 +12,8 @@ import type { import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { GetAlbumsByArtist, - GetAlbumTracks, GetAlbumsByArtistByLibrary, - GetAlbumTracksByLibrary, + GetFilePathsByAlbums, } from '@go/library/Library'; import { library } from '@go/models'; import { LibraryController } from '@store/controllers/library-controller'; @@ -27,6 +26,8 @@ import { } from '@utils/context-menu-controller.js'; import type { ContextMenuHost } 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'; @@ -58,7 +59,7 @@ interface ArtistEntry { @customElement('artists-view') export class ArtistsView - extends LitElement + extends ViewLifecycleMixin(LitElement) implements ContextMenuHost { private libraryCtrl = new LibraryController(this); @@ -68,6 +69,18 @@ export class ArtistsView 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; @@ -408,9 +421,17 @@ export class ArtistsView override disconnectedCallback() { super.disconnectedCallback(); this.detachWheelListener(); + } + + /** 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; } } @@ -938,8 +959,13 @@ export class ArtistsView /** * Fetches all file paths for an artist by - * loading their albums, then each album's - * tracks. Respects the active library filter. + * 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, @@ -955,19 +981,23 @@ export class ArtistsView ) : await GetAlbumsByArtist(artist.ID); + const byAlbum = + await 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 tracks = libId !== null - ? await GetAlbumTracksByLibrary( - album.ID, - libId, - ) - : await GetAlbumTracks(album.ID); + const paths = + byAlbum[album.ID] ?? []; - for (const t of tracks) { - allPaths.push(t.FilePath); - } + allPaths.push(...paths); } return allPaths; @@ -998,23 +1028,18 @@ export class ArtistsView } // 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) { - const cachedAlbums = libraryStore.cachedAlbums; - if (cachedAlbums) { - const name = artist.Name.toLowerCase(); - - for (const a of cachedAlbums) { - if (a.ArtistName.toLowerCase() === name) { - if (needed <= 100) { - imageURL = a.CoverArtSmall || a.CoverArtMedium || ''; - } else { - imageURL = a.CoverArtMedium || a.CoverArtLarge || ''; - } - - if (imageURL) break; - } - } - } + imageURL = this.albumArtByArtist().get( + artist.Name.toLowerCase(), + ) ?? ''; } if (imageURL) { @@ -1031,6 +1056,58 @@ export class ArtistsView `; } + /** + * 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 { @@ -1057,7 +1134,9 @@ export class ArtistsView class="artist-card${isSelected ? ' selected' : ''}" - tabindex="0" + data-index=${index} + tabindex=${this.roving.tabIndexFor(index)} + @focus=${() => this.roving.noteFocus(index)} role="button" aria-label="${artist.Name}" aria-selected="${isSelected}" @@ -1292,6 +1371,7 @@ export class ArtistsView ? 'visibility: hidden' : ''} @click=${this.onGridClick} + @keydown=${this.roving.handleKeydown} > , + ): Promise> { + const ids = Array.from(albumIds).filter((id) => + this.albumById.has(id), + ); + + if (ids.length === 0) return {}; + + const libId = + libraryStore.getSelectedLibraryId(); + + return GetFilePathsByAlbums(ids, libId ?? 0); + } + // ================================================================ // Album selection helpers // ================================================================ @@ -99,19 +125,25 @@ export class AlbumSelectionManager { async getSelectedAlbumFilePaths( selectedAlbums: Set, ): Promise { - const allPaths: string[] = []; + try { + const byAlbum = await this.fetchAlbumPaths( + selectedAlbums, + ); + const allPaths: string[] = []; - for (const id of selectedAlbums) { - const album = this.albumById.get(id); + for (const id of selectedAlbums) { + allPaths.push(...(byAlbum[id] ?? [])); + } - if (!album) continue; + return allPaths; + } catch (error) { + console.error( + 'Error loading album tracks:', + error, + ); - const paths = - await this.getAlbumFilePaths(album); - allPaths.push(...paths); + return []; } - - return allPaths; } /** @@ -182,32 +214,30 @@ export class AlbumSelectionManager { async warmCache( selectedAlbums: Set, ): Promise { - for (const id of selectedAlbums) { - if (this.albumFilePathCache.has(id)) { - continue; - } + const missing = [...selectedAlbums].filter( + (id) => !this.albumFilePathCache.has(id), + ); - const album = this.albumById.get(id); + try { + const byAlbum = + await this.fetchAlbumPaths(missing); - if (!album) continue; + for (const id of missing) { + const paths = byAlbum[id]; - try { - const tracks = - await this.fetchAlbumTracks( - album.ID, - ); - - // Only store if still selected. - if (selectedAlbums.has(album.ID)) { + // Only store if still selected: the + // selection can have moved on while + // this was in flight. + if (paths && selectedAlbums.has(id)) { this.albumFilePathCache.set( - album.ID, - tracks.map((t) => t.FilePath), + id, + paths, ); } - } catch { - // Silently skip — drag will just not - // include this album's paths. } + } catch { + // Silently skip — drag will just not + // include these albums' paths. } // Prune stale entries (6h). diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index b401352..cab9235 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -18,13 +18,16 @@ import { import { library } from '@go/models'; import { LibraryController } from '@store/controllers/library-controller'; import { SearchController } from '@store/controllers/search-controller'; +import { ViewLifecycleMixin } from '@utils/view-lifecycle'; +import { RovingGridController } from '@utils/roving-grid'; import { queueStore } from '@store/queue-store'; import '@awesome.me/webawesome/dist/components/popup/popup.js'; import type WaPopup from '@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 '@components/track-details/track-details.js'; +import { loadTrackDetails } from '@utils/lazy-track-details.js'; +import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js'; import type { TrackDetails } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js'; import { AlbumSelectionManager } from './album-selection.js'; @@ -69,7 +72,7 @@ import type { @customElement('cover-grid') export class CoverGrid - extends LitElement + extends ViewLifecycleMixin(LitElement) implements ContextMenuHost, ScrollManagerHost { /** @@ -277,6 +280,18 @@ export class CoverGrid @state() private loading = true; + /** One tab stop for the grid, moved with the arrow keys — a card per + * tab stop makes a library-length tab sequence (H-5). */ + private roving = new RovingGridController(this, { + cardSelector: '.album-card', + count: () => this.buildGridEntries().length, + scrollToIndex: (index) => { + this.shadowRoot + ?.querySelector('lit-virtualizer') + ?.scrollToIndex(index, 'nearest'); + }, + }); + private contextMenuTarget: ContextMenuTarget = { kind: 'album', }; @@ -500,11 +515,6 @@ export class CoverGrid this.restoreSortPreferences(); this.loadAlbums(); - document.addEventListener( - 'mousedown', - this.sortDropdownCloseHandler, - ); - // error events do not bubble — use capture // phase to catch load failures. this.addEventListener( @@ -514,13 +524,17 @@ export class CoverGrid ); } - override disconnectedCallback() { - super.disconnectedCallback(); - - document.removeEventListener( + protected override onViewActivate(): void { + this.listenWhileActive( + document, 'mousedown', this.sortDropdownCloseHandler, ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener( 'error', this.onGridImageError, @@ -1501,9 +1515,9 @@ export class CoverGrid break; case 'track-details': if (filePaths.length === 1) { - this.openTrackDetails(filePaths[0]!); + void this.openTrackDetails(filePaths[0]!); } else { - this.openBatchTrackDetails(filePaths); + void this.openBatchTrackDetails(filePaths); } break; } @@ -1541,13 +1555,19 @@ export class CoverGrid } } - private openTrackDetails(filePath: string) { - const track = this.expandedTracks.find( - (t) => t.FilePath === filePath, - ); + private async openTrackDetails(filePath: string) { + const track = tracksByFilePath( + this.expandedTracks, + ).get(filePath); if (!track) return; + const ready = await loadTrackDetails( + () => void this.openTrackDetails(filePath), + ); + + if (!ready) return; + const coverArt = this.selMgr.resolveTrackCoverArt( track.Album, @@ -1560,21 +1580,22 @@ export class CoverGrid ); } - private openBatchTrackDetails( + private async openBatchTrackDetails( filePaths: string[], ) { - const tracks = filePaths - .map((fp) => - this.expandedTracks.find( - (t) => t.FilePath === fp, - ), - ) - .filter( - (t): t is library.Track => t != null, - ); + const tracks = tracksForPaths( + this.expandedTracks, + filePaths, + ); if (tracks.length === 0) return; + const ready = await loadTrackDetails( + () => void this.openBatchTrackDetails(filePaths), + ); + + if (!ready) return; + const albumNames = new Set( tracks.map((t) => t.Album), ); @@ -1791,7 +1812,8 @@ export class CoverGrid return html`
this.roving.noteFocus(index)} role="button" data-index=${index} aria-label="${album.Name} by ${album.ArtistName}" @@ -1879,6 +1901,7 @@ export class CoverGrid
${gridContent}
diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index c72976f..5b641ff 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -11,8 +11,7 @@ import type { } from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { - GetTracksByGenre, - GetTracksByGenreByLibrary, + GetFilePathsByGenres, } from '@go/library/Library'; import type { library } from '@go/models'; import { LibraryController } from '@store/controllers/library-controller'; @@ -24,6 +23,8 @@ import { } from '@utils/context-menu-controller.js'; import type { ContextMenuHost } 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'; @@ -59,7 +60,7 @@ interface GenreEntry { @customElement('genres-view') export class GenresView - extends LitElement + extends ViewLifecycleMixin(LitElement) implements ContextMenuHost { private libraryCtrl = new LibraryController(this); @@ -69,6 +70,18 @@ export class GenresView 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[] @@ -400,9 +413,16 @@ export class GenresView override disconnectedCallback() { super.disconnectedCallback(); this.detachWheelListener(); + } + + /** 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; } } @@ -737,24 +757,24 @@ export class GenresView const libId = this.libraryCtrl.selectedLibraryId; - const promises = Array.from( - genreNames, - (name) => - libId !== null - ? GetTracksByGenreByLibrary( - name, - libId, - ) - : GetTracksByGenre(name), + // 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 GetFilePathsByGenres( + names, + libId ?? 0, ); - const results = await Promise.all(promises); - - for (const tracks of results) { - for (const track of tracks ?? []) { - if (!seen.has(track.FilePath)) { - seen.add(track.FilePath); - allPaths.push(track.FilePath); + // 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); } } } @@ -945,7 +965,9 @@ export class GenresView class="genre-card${isSelected ? ' selected' : ''}" - tabindex="0" + data-index=${index} + tabindex=${this.roving.tabIndexFor(index)} + @focus=${() => this.roving.noteFocus(index)} role="button" aria-label="${genre.name}" aria-selected="${isSelected}" @@ -1190,6 +1212,7 @@ export class GenresView ? 'visibility: hidden' : ''} @click=${this.onGridClick} + @keydown=${this.roving.handleKeydown} > this.handleClick(r)}> +
this.handleClick(r)} + @keydown=${(e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + + e.preventDefault(); + this.handleClick(r); + }} + > = { accessor: () => '', defaultWidth: '36px', renderCell: (track: library.Track) => { - if (!track.CoverArtPath) return nothing; - return html``; + // `perf.M3`. This rendered `CoverArtPath` — the *original* + // embedded artwork, commonly 1500×1500 and several hundred + // kB — scaled by CSS into a 24 px box, while the 100 px + // `CoverArtSmall` sat unused on the same model. Every row + // the virtualizer recycled into view decoded a full + // resolution JPEG on the main thread to draw 576 pixels. + // + // `cover-grid.getCoverUrl()` has picked the right tier all + // along; this is the same rule for a much smaller box, with + // the two attributes that keep the decode off the scroll + // path. + const src = track.CoverArtSmall + || track.CoverArtMedium + || track.CoverArtPath; + + if (!src) return nothing; + + return html``; }, }, trackName: { diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 973d63c..d6f53a1 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -9,6 +9,7 @@ import { } from 'lit/decorators.js'; import { SelectionController } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller'; +import { ViewLifecycleMixin } from '@utils/view-lifecycle'; import { ContextMenuController, contextMenuStyles, @@ -56,8 +57,10 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js'; import type WaPopup from '@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 { describeError } from '@utils/describe-error'; +import { loadTrackDetails } from '@utils/lazy-track-details.js'; +import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js'; import '@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'; @@ -85,7 +88,16 @@ const FAV_ICONS = { type SortDirection = 'asc' | 'desc'; @customElement('track-list') -export class TrackList extends LitElement implements SelectionHost, ContextMenuHost { +export class TrackList + extends ViewLifecycleMixin(LitElement) + implements SelectionHost, ContextMenuHost +{ + /* Claimed while this list is the view on screen, which is what makes + * the `tracklist.*` bindings resolve at all: `data-shortcut-scope` + * was read by the shortcut service and set by nobody, so Enter and + * Delete were dead shortcuts the Settings page still advertised. */ + protected override shortcutScope = 'tracklist'; + /** * When set, the list displays these tracks instead of * fetching all tracks from the library store. The @@ -94,6 +106,15 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH @property({ type: Array, attribute: false }) externalTracks?: library.Track[]; + /** + * Loading, empty and failed are three different things, and this + * list used to render all three as a permanent “Loading tracks…” + * — including on the first screen a new user ever sees, behind the + * first-run wizard (errors.M2, H-12). `home-view` is the model. + */ + @state() private loadingTracks = false; + @state() private loadError = ''; + private player = new PlayerController(this); private libraryCtrl = new LibraryController(this); private searchCtrl = new SearchController(this); @@ -172,10 +193,86 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH private prevSortField: string | null = null; private prevSortDir: SortDirection = 'asc'; + /** The row that holds the list's single tab stop. + * + * A grid of ten thousand rows must not be ten thousand tab stops, + * so one row is focusable at a time and the arrows move which — the + * standard roving tabindex. Before this the list had no keyboard + * path into it at all (H-5). */ + @state() private focusedIndex = 0; + private handleSelectAll = (): void => { this.selection.selectAll(); }; + /** Arrow/Home/End move the focused row; Enter plays it. + * + * Enter is not handled here — it is the `tracklist.play` binding, + * which resolves because the list claims the `tracklist` shortcut + * scope while it is on screen. */ + private onListKeydown = (e: KeyboardEvent): void => { + const last = this.cachedSortedTracks.length - 1; + + if (last < 0) return; + + let next = this.focusedIndex; + + switch (e.key) { + case 'ArrowDown': + next = Math.min(this.focusedIndex + 1, last); + break; + case 'ArrowUp': + next = Math.max(this.focusedIndex - 1, 0); + break; + case 'Home': + next = 0; + break; + case 'End': + next = last; + break; + case ' ': + case 'Enter': + return; + default: + return; + } + + e.preventDefault(); + e.stopPropagation(); + this.focusRow(next, { select: e.shiftKey || !e.ctrlKey }); + }; + + /** Move the roving tab stop, bringing the row into view and + * selecting it so Enter and the context menu have a subject. */ + private focusRow( + index: number, + opts: { select?: boolean } = {}, + ): void { + const track = this.cachedSortedTracks[index]; + + if (!track) return; + + this.focusedIndex = index; + + if (opts.select) { + this.selection.clear(); + this.selection.handleItemClick( + new MouseEvent('click'), + track.FilePath, + index, + ); + } + + this.virtualizer?.scrollToIndex(index, 'nearest'); + void this.updateComplete.then(() => { + this.shadowRoot + ?.querySelector( + `.track-row[data-index="${index}"]`, + ) + ?.focus(); + }); + } + private clearSelectionHandler = (e: MouseEvent) => { const path = e.composedPath(); const isTrackClick = path.some( @@ -611,11 +708,23 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH return scaled; } + /** perf.m4: the drag's document listeners exist while it is dragging + * and not before, which is the standard pattern and stops every + * pointer move in the app calling a handler that guards and + * returns. */ + private attachColResizeListeners(on: boolean): void { + const fn = on ? 'addEventListener' : 'removeEventListener'; + + document[fn]('mousemove', this.onColResizeMove as EventListener); + document[fn]('mouseup', this.onColResizeEnd as EventListener); + } + private onColResizeStart = (e: MouseEvent, columnIndex: number) => { e.preventDefault(); this.resizingColumn = columnIndex; this.resizeStartX = e.clientX; this.resizeStartWidths = [...this.columnWidths]; + this.attachColResizeListeners(true); this.requestUpdate(); }; @@ -742,6 +851,8 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH }; private onColResizeEnd = () => { + this.attachColResizeListeners(false); + if (this.resizingColumn === null) return; this.resizingColumn = null; @@ -1059,6 +1170,22 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH border-radius: 2px; } + .list-placeholder { + color: var(--yj-text-secondary, #b3b3b3); + padding: 1em; + } + + .placeholder-action { + background: none; + border: 1px solid var(--yj-border, #495057); + border-radius: 4px; + color: inherit; + cursor: pointer; + font: inherit; + margin-top: 0.5em; + padding: 4px 10px; + } + `]; override connectedCallback() { @@ -1070,12 +1197,6 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH } else { this.loadTracks(); } - document.addEventListener('mousedown', this.sortDropdownCloseHandler); - document.addEventListener('click', this.clearSelectionHandler); - document.addEventListener('shortcut:select-all', this.handleSelectAll); - document.addEventListener('mousemove', this.onColResizeMove); - document.addEventListener('mouseup', this.onColResizeEnd); - this.resizeObserver = new ResizeObserver( () => { this.onHostResize(); @@ -1102,17 +1223,49 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH this.onVisibilityChanged, ); this.hasRestoredScroll = false; + // A drag interrupted by the list going away still has to clean + // up after itself; these are no longer registered with the view + // lifecycle, so nothing else would. + this.attachColResizeListeners(false); super.disconnectedCallback(); - document.removeEventListener('mousedown', this.sortDropdownCloseHandler); - document.removeEventListener('click', this.clearSelectionHandler); - document.removeEventListener('shortcut:select-all', this.handleSelectAll); - document.removeEventListener('mousemove', this.onColResizeMove); - document.removeEventListener('mouseup', this.onColResizeEnd); this.resizeObserver?.disconnect(); this.resizeObserver = null; } + /** Document-level listeners belong to the *visible* list. A cached + * list is never disconnected, so this is the only place they can be + * taken down again. */ + protected override onViewActivate(): void { + this.listenWhileActive( + document, + 'mousedown', + this.sortDropdownCloseHandler, + ); + this.listenWhileActive(document, 'click', this.clearSelectionHandler); + this.listenWhileActive( + document, + 'shortcut:select-all', + this.handleSelectAll, + ); + this.listenWhileActive( + document, + 'shortcut:tracklist-play', + this.handleShortcutPlay, + ); + } + + /** Enter plays the selection — the `tracklist.play` binding, which + * has existed in the defaults and in Settings since it was written + * and has never had anything on the other end of it. */ + private handleShortcutPlay = (): void => { + const filePaths = this.selection.getSelectedKeysOrdered(); + + if (filePaths.length === 0) return; + + queueStore.setQueue(filePaths, 0, true); + }; + override willUpdate( changed: Map, ) { @@ -1240,10 +1393,20 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH } async loadTracks() { + this.loadingTracks = this.tracks.length === 0; + this.loadError = ''; + try { const tracks = await this.libraryCtrl.getTracks(); this.tracks = tracks; - this.selection.clear(); + + // Keep what is still there. A refetch is not a + // deselection: this used to clear, so every finished track + // wiped the user's selection while music played (perf.C2). + // The keys are file paths, which survive a refetch. + const present = new Set(tracks.map((t) => t.FilePath)); + this.selection.retain((key) => present.has(key)); + await this.updateComplete; if (this.isConnected && this.virtualizer) { @@ -1254,9 +1417,45 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH } } catch (error) { console.error('Error loading tracks:', error); + this.loadError = describeError( + error, + 'Your tracks could not be loaded.', + ); + } finally { + this.loadingTracks = false; } } + /** Loading / failed / genuinely empty, said apart. */ + private renderPlaceholder() { + if (this.loadError) { + return html` +
+

${this.loadError}

+ +
+ `; + } + + if (this.loadingTracks) { + return html`

+ Loading tracks… +

`; + } + + return html`

+ ${this.externalTracks + ? 'Nothing here yet.' + : 'No tracks yet — add a folder in Settings to get started.'} +

`; + } + private onVisibilityChanged = (e: Event) => { const { first } = e as VisibilityChangedEvent; @@ -1364,6 +1563,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH track: library.Track, index: number, ) { + // Clicking is also how the keyboard's starting point is chosen: + // tabbing back into the list should land where the user was. + this.focusedIndex = index; this.selection.handleItemClick(e, track.FilePath, index); } @@ -1451,9 +1653,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH break; case 'track-details': if (filePaths.length === 1) { - this.openTrackDetails(filePaths[0]!); + void this.openTrackDetails(filePaths[0]!); } else { - this.openBatchTrackDetails(filePaths); + void this.openBatchTrackDetails(filePaths); } break; } @@ -1482,13 +1684,19 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH this.ctxMenu.close(); } - private openTrackDetails(filePath: string) { - const track = this.tracks.find( - (t) => t.FilePath === filePath, + private async openTrackDetails(filePath: string) { + const track = tracksByFilePath(this.tracks).get( + filePath, ); if (!track) return; + const ready = await loadTrackDetails( + () => void this.openTrackDetails(filePath), + ); + + if (!ready) return; + const coverArt = track.CoverArtPath ? { coverArtPath: track.CoverArtPath, @@ -1504,21 +1712,22 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH ); } - private openBatchTrackDetails( + private async openBatchTrackDetails( filePaths: string[], ) { - const tracks = filePaths - .map((fp) => - this.tracks.find( - (t) => t.FilePath === fp, - ), - ) - .filter( - (t): t is library.Track => t != null, - ); + const tracks = tracksForPaths( + this.tracks, + filePaths, + ); if (tracks.length === 0) return; + const ready = await loadTrackDetails( + () => void this.openBatchTrackDetails(filePaths), + ); + + if (!ready) return; + // Use cover art from the first track. If all tracks share // the same album, they share the same art. const first = tracks[0]!; @@ -1738,12 +1947,17 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH active, selected, })} + role="row" + aria-rowindex=${index + 1} + aria-selected=${selected} + tabindex=${index === this.focusedIndex ? 0 : -1} draggable="true" data-index=${index} data-testid="track-row" data-file-path=${track.FilePath} >
{ const customCell = col.renderCell?.(track); if (customCell !== undefined && customCell !== nothing) { - return html`
${customCell}
`; + return html`
${customCell}
`; } const val = col.accessor(track); const centered = val === '\u2014'; @@ -1774,7 +1988,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH } return html` -
Loading tracks...

` + ? this.renderPlaceholder() : html` ${this.renderSortToolbar()} -
-
-
+
+
+
${cols.map( (col) => html`
this.onHeaderCellClick(