perf(frontend): make the lists and grids pay per row, not per library

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.
This commit is contained in:
2026-08-12 01:19:37 -04:00
parent 4ae6e13391
commit 559e1ed077
7 changed files with 558 additions and 145 deletions
@@ -1,6 +1,7 @@
import {
GetAlbumTracks,
GetAlbumTracksByLibrary,
GetFilePathsByAlbums,
} from '@go/library/Library';
import { libraryStore } from '@store/library-store';
import type { library } from '@go/models';
@@ -62,6 +63,31 @@ export class AlbumSelectionManager {
: GetAlbumTracks(albumId);
}
/**
* File paths for several albums in one call, keyed
* by album id.
*
* perf.m2: every caller below used to loop over the
* albums awaiting `GetAlbumTracks` per album, so
* Ctrl+A over 5 000 albums was 5 000 sequential
* round trips — each returning whole track rows to
* read one field off them.
*/
private async fetchAlbumPaths(
albumIds: Iterable<number>,
): Promise<Record<number, string[]>> {
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<number>,
): Promise<string[]> {
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<number>,
): Promise<void> {
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).
@@ -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<LitVirtualizer>('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 <img> 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`
<div
class=${classes}
tabindex="0"
tabindex=${this.roving.tabIndexFor(index)}
@focus=${() => this.roving.noteFocus(index)}
role="button"
data-index=${index}
aria-label="${album.Name} by ${album.ArtistName}"
@@ -1879,6 +1901,7 @@ export class CoverGrid
<div
class="grid-scroll-container"
@click=${this.onGridClick}
@keydown=${this.roving.handleKeydown}
>
${gridContent}
</div>