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
@@ -12,9 +12,8 @@ import type {
import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { import {
GetAlbumsByArtist, GetAlbumsByArtist,
GetAlbumTracks,
GetAlbumsByArtistByLibrary, GetAlbumsByArtistByLibrary,
GetAlbumTracksByLibrary, GetFilePathsByAlbums,
} from '@go/library/Library'; } from '@go/library/Library';
import { library } from '@go/models'; import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller'; import { LibraryController } from '@store/controllers/library-controller';
@@ -27,6 +26,8 @@ import {
} from '@utils/context-menu-controller.js'; } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js'; import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller'; 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/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -58,7 +59,7 @@ interface ArtistEntry {
@customElement('artists-view') @customElement('artists-view')
export class ArtistsView export class ArtistsView
extends LitElement extends ViewLifecycleMixin(LitElement)
implements ContextMenuHost implements ContextMenuHost
{ {
private libraryCtrl = new LibraryController(this); private libraryCtrl = new LibraryController(this);
@@ -68,6 +69,18 @@ export class ArtistsView
private wheelListenerAttached = false; private wheelListenerAttached = false;
private lastSearchTerm = ''; 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<LitVirtualizer>('lit-virtualizer')
?.scrollToIndex(index, 'nearest');
},
});
/** Tracks the store's cached array reference to detect refreshes. */ /** Tracks the store's cached array reference to detect refreshes. */
private lastArtistsRef: library.Artist[] | null = private lastArtistsRef: library.Artist[] | null =
null; null;
@@ -408,9 +421,17 @@ export class ArtistsView
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.detachWheelListener(); 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) { if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer); clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
} }
} }
@@ -938,8 +959,13 @@ export class ArtistsView
/** /**
* Fetches all file paths for an artist by * Fetches all file paths for an artist by
* loading their albums, then each album's * loading their albums, then every album's paths
* tracks. Respects the active library filter. * 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( private async getArtistFilePaths(
artist: library.Artist, artist: library.Artist,
@@ -955,19 +981,23 @@ export class ArtistsView
) )
: await GetAlbumsByArtist(artist.ID); : await GetAlbumsByArtist(artist.ID);
const byAlbum =
await GetFilePathsByAlbums(
albums.map((a) => a.ID),
libId ?? 0,
);
const allPaths: string[] = []; 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) { for (const album of albums) {
const tracks = libId !== null const paths =
? await GetAlbumTracksByLibrary( byAlbum[album.ID] ?? [];
album.ID,
libId,
)
: await GetAlbumTracks(album.ID);
for (const t of tracks) { allPaths.push(...paths);
allPaths.push(t.FilePath);
}
} }
return allPaths; return allPaths;
@@ -998,23 +1028,18 @@ export class ArtistsView
} }
// Fallback: use album cover art if no artist image. // 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) { if (!imageURL) {
const cachedAlbums = libraryStore.cachedAlbums; imageURL = this.albumArtByArtist().get(
if (cachedAlbums) { artist.Name.toLowerCase(),
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;
}
}
}
} }
if (imageURL) { if (imageURL) {
@@ -1031,6 +1056,58 @@ export class ArtistsView
</span>`; </span>`;
} }
/**
* 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<string, string>;
};
private albumArtByArtist(): Map<string, string> {
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<string, string>();
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( private getArtistInitial(
name: string, name: string,
): string { ): string {
@@ -1057,7 +1134,9 @@ export class ArtistsView
class="artist-card${isSelected class="artist-card${isSelected
? ' selected' ? ' selected'
: ''}" : ''}"
tabindex="0" data-index=${index}
tabindex=${this.roving.tabIndexFor(index)}
@focus=${() => this.roving.noteFocus(index)}
role="button" role="button"
aria-label="${artist.Name}" aria-label="${artist.Name}"
aria-selected="${isSelected}" aria-selected="${isSelected}"
@@ -1292,6 +1371,7 @@ export class ArtistsView
? 'visibility: hidden' ? 'visibility: hidden'
: ''} : ''}
@click=${this.onGridClick} @click=${this.onGridClick}
@keydown=${this.roving.handleKeydown}
> >
<lit-virtualizer <lit-virtualizer
.items=${entries} .items=${entries}
@@ -1,6 +1,7 @@
import { import {
GetAlbumTracks, GetAlbumTracks,
GetAlbumTracksByLibrary, GetAlbumTracksByLibrary,
GetFilePathsByAlbums,
} from '@go/library/Library'; } from '@go/library/Library';
import { libraryStore } from '@store/library-store'; import { libraryStore } from '@store/library-store';
import type { library } from '@go/models'; import type { library } from '@go/models';
@@ -62,6 +63,31 @@ export class AlbumSelectionManager {
: GetAlbumTracks(albumId); : 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 // Album selection helpers
// ================================================================ // ================================================================
@@ -99,19 +125,25 @@ export class AlbumSelectionManager {
async getSelectedAlbumFilePaths( async getSelectedAlbumFilePaths(
selectedAlbums: Set<number>, selectedAlbums: Set<number>,
): Promise<string[]> { ): Promise<string[]> {
const allPaths: string[] = []; try {
const byAlbum = await this.fetchAlbumPaths(
selectedAlbums,
);
const allPaths: string[] = [];
for (const id of selectedAlbums) { for (const id of selectedAlbums) {
const album = this.albumById.get(id); allPaths.push(...(byAlbum[id] ?? []));
}
if (!album) continue; return allPaths;
} catch (error) {
console.error(
'Error loading album tracks:',
error,
);
const paths = return [];
await this.getAlbumFilePaths(album);
allPaths.push(...paths);
} }
return allPaths;
} }
/** /**
@@ -182,32 +214,30 @@ export class AlbumSelectionManager {
async warmCache( async warmCache(
selectedAlbums: Set<number>, selectedAlbums: Set<number>,
): Promise<void> { ): Promise<void> {
for (const id of selectedAlbums) { const missing = [...selectedAlbums].filter(
if (this.albumFilePathCache.has(id)) { (id) => !this.albumFilePathCache.has(id),
continue; );
}
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 { // Only store if still selected: the
const tracks = // selection can have moved on while
await this.fetchAlbumTracks( // this was in flight.
album.ID, if (paths && selectedAlbums.has(id)) {
);
// Only store if still selected.
if (selectedAlbums.has(album.ID)) {
this.albumFilePathCache.set( this.albumFilePathCache.set(
album.ID, id,
tracks.map((t) => t.FilePath), 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). // Prune stale entries (6h).
@@ -18,13 +18,16 @@ import {
import { library } from '@go/models'; import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller'; import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-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 { queueStore } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@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/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.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 { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { AlbumSelectionManager } from './album-selection.js'; import { AlbumSelectionManager } from './album-selection.js';
@@ -69,7 +72,7 @@ import type {
@customElement('cover-grid') @customElement('cover-grid')
export class CoverGrid export class CoverGrid
extends LitElement extends ViewLifecycleMixin(LitElement)
implements ContextMenuHost, ScrollManagerHost implements ContextMenuHost, ScrollManagerHost
{ {
/** /**
@@ -277,6 +280,18 @@ export class CoverGrid
@state() @state()
private loading = true; 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 = { private contextMenuTarget: ContextMenuTarget = {
kind: 'album', kind: 'album',
}; };
@@ -500,11 +515,6 @@ export class CoverGrid
this.restoreSortPreferences(); this.restoreSortPreferences();
this.loadAlbums(); this.loadAlbums();
document.addEventListener(
'mousedown',
this.sortDropdownCloseHandler,
);
// error events do not bubble — use capture // error events do not bubble — use capture
// phase to catch <img> load failures. // phase to catch <img> load failures.
this.addEventListener( this.addEventListener(
@@ -514,13 +524,17 @@ export class CoverGrid
); );
} }
override disconnectedCallback() { protected override onViewActivate(): void {
super.disconnectedCallback(); this.listenWhileActive(
document,
document.removeEventListener(
'mousedown', 'mousedown',
this.sortDropdownCloseHandler, this.sortDropdownCloseHandler,
); );
}
override disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener( this.removeEventListener(
'error', 'error',
this.onGridImageError, this.onGridImageError,
@@ -1501,9 +1515,9 @@ export class CoverGrid
break; break;
case 'track-details': case 'track-details':
if (filePaths.length === 1) { if (filePaths.length === 1) {
this.openTrackDetails(filePaths[0]!); void this.openTrackDetails(filePaths[0]!);
} else { } else {
this.openBatchTrackDetails(filePaths); void this.openBatchTrackDetails(filePaths);
} }
break; break;
} }
@@ -1541,13 +1555,19 @@ export class CoverGrid
} }
} }
private openTrackDetails(filePath: string) { private async openTrackDetails(filePath: string) {
const track = this.expandedTracks.find( const track = tracksByFilePath(
(t) => t.FilePath === filePath, this.expandedTracks,
); ).get(filePath);
if (!track) return; if (!track) return;
const ready = await loadTrackDetails(
() => void this.openTrackDetails(filePath),
);
if (!ready) return;
const coverArt = const coverArt =
this.selMgr.resolveTrackCoverArt( this.selMgr.resolveTrackCoverArt(
track.Album, track.Album,
@@ -1560,21 +1580,22 @@ export class CoverGrid
); );
} }
private openBatchTrackDetails( private async openBatchTrackDetails(
filePaths: string[], filePaths: string[],
) { ) {
const tracks = filePaths const tracks = tracksForPaths(
.map((fp) => this.expandedTracks,
this.expandedTracks.find( filePaths,
(t) => t.FilePath === fp, );
),
)
.filter(
(t): t is library.Track => t != null,
);
if (tracks.length === 0) return; if (tracks.length === 0) return;
const ready = await loadTrackDetails(
() => void this.openBatchTrackDetails(filePaths),
);
if (!ready) return;
const albumNames = new Set( const albumNames = new Set(
tracks.map((t) => t.Album), tracks.map((t) => t.Album),
); );
@@ -1791,7 +1812,8 @@ export class CoverGrid
return html` return html`
<div <div
class=${classes} class=${classes}
tabindex="0" tabindex=${this.roving.tabIndexFor(index)}
@focus=${() => this.roving.noteFocus(index)}
role="button" role="button"
data-index=${index} data-index=${index}
aria-label="${album.Name} by ${album.ArtistName}" aria-label="${album.Name} by ${album.ArtistName}"
@@ -1879,6 +1901,7 @@ export class CoverGrid
<div <div
class="grid-scroll-container" class="grid-scroll-container"
@click=${this.onGridClick} @click=${this.onGridClick}
@keydown=${this.roving.handleKeydown}
> >
${gridContent} ${gridContent}
</div> </div>
@@ -11,8 +11,7 @@ import type {
} from '@lit-labs/virtualizer'; } from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { import {
GetTracksByGenre, GetFilePathsByGenres,
GetTracksByGenreByLibrary,
} from '@go/library/Library'; } from '@go/library/Library';
import type { library } from '@go/models'; import type { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller'; import { LibraryController } from '@store/controllers/library-controller';
@@ -24,6 +23,8 @@ import {
} from '@utils/context-menu-controller.js'; } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js'; import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller'; 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/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -59,7 +60,7 @@ interface GenreEntry {
@customElement('genres-view') @customElement('genres-view')
export class GenresView export class GenresView
extends LitElement extends ViewLifecycleMixin(LitElement)
implements ContextMenuHost implements ContextMenuHost
{ {
private libraryCtrl = new LibraryController(this); private libraryCtrl = new LibraryController(this);
@@ -69,6 +70,18 @@ export class GenresView
private wheelListenerAttached = false; private wheelListenerAttached = false;
private lastSearchTerm = ''; 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<LitVirtualizer>('lit-virtualizer')
?.scrollToIndex(index, 'nearest');
},
});
/** Tracks the store's cached array reference to detect refreshes. */ /** Tracks the store's cached array reference to detect refreshes. */
private lastGenresRef: private lastGenresRef:
| library.GenreWithCount[] | library.GenreWithCount[]
@@ -400,9 +413,16 @@ export class GenresView
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.detachWheelListener(); 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) { if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer); clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
} }
} }
@@ -737,24 +757,24 @@ export class GenresView
const libId = const libId =
this.libraryCtrl.selectedLibraryId; this.libraryCtrl.selectedLibraryId;
const promises = Array.from( // perf.m2: one call per genre, each returning
genreNames, // whole track rows so the file path could be
(name) => // read off them — 6 MB over the IPC for five
libId !== null // genres of a 50 000-track library.
? GetTracksByGenreByLibrary( const names = Array.from(genreNames);
name, const byGenre = await GetFilePathsByGenres(
libId, names,
) libId ?? 0,
: GetTracksByGenre(name),
); );
const results = await Promise.all(promises); // Still de-duplicated here: a track with two of
// the selected genres appears under both, and
for (const tracks of results) { // the caller owns the order.
for (const track of tracks ?? []) { for (const name of names) {
if (!seen.has(track.FilePath)) { for (const path of byGenre[name] ?? []) {
seen.add(track.FilePath); if (!seen.has(path)) {
allPaths.push(track.FilePath); seen.add(path);
allPaths.push(path);
} }
} }
} }
@@ -945,7 +965,9 @@ export class GenresView
class="genre-card${isSelected class="genre-card${isSelected
? ' selected' ? ' selected'
: ''}" : ''}"
tabindex="0" data-index=${index}
tabindex=${this.roving.tabIndexFor(index)}
@focus=${() => this.roving.noteFocus(index)}
role="button" role="button"
aria-label="${genre.name}" aria-label="${genre.name}"
aria-selected="${isSelected}" aria-selected="${isSelected}"
@@ -1190,6 +1212,7 @@ export class GenresView
? 'visibility: hidden' ? 'visibility: hidden'
: ''} : ''}
@click=${this.onGridClick} @click=${this.onGridClick}
@keydown=${this.roving.handleKeydown}
> >
<lit-virtualizer <lit-virtualizer
.items=${entries} .items=${entries}
@@ -264,7 +264,19 @@ export class TopResultsRow extends LitElement {
: 'track'; : 'track';
return html` return html`
<div class="card" @click=${() => this.handleClick(r)}> <div
class="card"
role="button"
tabindex="0"
aria-label=${`${badgeLabel(r.entityType)}: ${r.name}`}
@click=${() => this.handleClick(r)}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
this.handleClick(r);
}}
>
<span <span
class="badge" class="badge"
style="background: ${badgeColor(r.entityType)}" style="background: ${badgeColor(r.entityType)}"
+26 -2
View File
@@ -57,8 +57,32 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
accessor: () => '', accessor: () => '',
defaultWidth: '36px', defaultWidth: '36px',
renderCell: (track: library.Track) => { renderCell: (track: library.Track) => {
if (!track.CoverArtPath) return nothing; // `perf.M3`. This rendered `CoverArtPath` — the *original*
return html`<img src="${track.CoverArtPath}" alt="" style="width:24px;height:24px;border-radius:3px;object-fit:cover;display:block;" />`; // 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`<img
src="${src}"
alt=""
loading="lazy"
decoding="async"
width="24"
height="24"
style="width:24px;height:24px;border-radius:3px;object-fit:cover;display:block;"
/>`;
}, },
}, },
trackName: { trackName: {
+256 -35
View File
@@ -9,6 +9,7 @@ import {
} from 'lit/decorators.js'; } from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller'; import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { import {
ContextMenuController, ContextMenuController,
contextMenuStyles, 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 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/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.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/playlist-picker/playlist-picker.js';
import '@components/track-details/track-details.js';
import type { TrackDetails } from '@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'; import type { CoverArtUrls } from '@components/track-details/track-details.js';
@@ -85,7 +88,16 @@ const FAV_ICONS = {
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
@customElement('track-list') @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 * When set, the list displays these tracks instead of
* fetching all tracks from the library store. The * 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 }) @property({ type: Array, attribute: false })
externalTracks?: library.Track[]; 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 player = new PlayerController(this);
private libraryCtrl = new LibraryController(this); private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this); private searchCtrl = new SearchController(this);
@@ -172,10 +193,86 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
private prevSortField: string | null = null; private prevSortField: string | null = null;
private prevSortDir: SortDirection = 'asc'; 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 => { private handleSelectAll = (): void => {
this.selection.selectAll(); 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<HTMLElement>(
`.track-row[data-index="${index}"]`,
)
?.focus();
});
}
private clearSelectionHandler = (e: MouseEvent) => { private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath(); const path = e.composedPath();
const isTrackClick = path.some( const isTrackClick = path.some(
@@ -611,11 +708,23 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
return scaled; 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) => { private onColResizeStart = (e: MouseEvent, columnIndex: number) => {
e.preventDefault(); e.preventDefault();
this.resizingColumn = columnIndex; this.resizingColumn = columnIndex;
this.resizeStartX = e.clientX; this.resizeStartX = e.clientX;
this.resizeStartWidths = [...this.columnWidths]; this.resizeStartWidths = [...this.columnWidths];
this.attachColResizeListeners(true);
this.requestUpdate(); this.requestUpdate();
}; };
@@ -742,6 +851,8 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
}; };
private onColResizeEnd = () => { private onColResizeEnd = () => {
this.attachColResizeListeners(false);
if (this.resizingColumn === null) return; if (this.resizingColumn === null) return;
this.resizingColumn = null; this.resizingColumn = null;
@@ -1059,6 +1170,22 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
border-radius: 2px; 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() { override connectedCallback() {
@@ -1070,12 +1197,6 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
} else { } else {
this.loadTracks(); 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.resizeObserver = new ResizeObserver(
() => { () => {
this.onHostResize(); this.onHostResize();
@@ -1102,17 +1223,49 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
this.onVisibilityChanged, this.onVisibilityChanged,
); );
this.hasRestoredScroll = false; 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(); 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?.disconnect();
this.resizeObserver = null; 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( override willUpdate(
changed: Map<PropertyKey, unknown>, changed: Map<PropertyKey, unknown>,
) { ) {
@@ -1240,10 +1393,20 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
} }
async loadTracks() { async loadTracks() {
this.loadingTracks = this.tracks.length === 0;
this.loadError = '';
try { try {
const tracks = await this.libraryCtrl.getTracks(); const tracks = await this.libraryCtrl.getTracks();
this.tracks = tracks; 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; await this.updateComplete;
if (this.isConnected && this.virtualizer) { if (this.isConnected && this.virtualizer) {
@@ -1254,9 +1417,45 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
} }
} catch (error) { } catch (error) {
console.error('Error loading tracks:', 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`
<div class="list-placeholder" data-testid="track-list-error">
<p>${this.loadError}</p>
<button
type="button"
class="placeholder-action"
@click=${() => void this.loadTracks()}
>
Try again
</button>
</div>
`;
}
if (this.loadingTracks) {
return html`<p class="list-placeholder" data-testid="track-list-loading">
Loading tracks…
</p>`;
}
return html`<p class="list-placeholder" data-testid="track-list-empty">
${this.externalTracks
? 'Nothing here yet.'
: 'No tracks yet — add a folder in Settings to get started.'}
</p>`;
}
private onVisibilityChanged = (e: Event) => { private onVisibilityChanged = (e: Event) => {
const { first } = e as VisibilityChangedEvent; const { first } = e as VisibilityChangedEvent;
@@ -1364,6 +1563,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
track: library.Track, track: library.Track,
index: number, 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); this.selection.handleItemClick(e, track.FilePath, index);
} }
@@ -1451,9 +1653,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
break; break;
case 'track-details': case 'track-details':
if (filePaths.length === 1) { if (filePaths.length === 1) {
this.openTrackDetails(filePaths[0]!); void this.openTrackDetails(filePaths[0]!);
} else { } else {
this.openBatchTrackDetails(filePaths); void this.openBatchTrackDetails(filePaths);
} }
break; break;
} }
@@ -1482,13 +1684,19 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
this.ctxMenu.close(); this.ctxMenu.close();
} }
private openTrackDetails(filePath: string) { private async openTrackDetails(filePath: string) {
const track = this.tracks.find( const track = tracksByFilePath(this.tracks).get(
(t) => t.FilePath === filePath, filePath,
); );
if (!track) return; if (!track) return;
const ready = await loadTrackDetails(
() => void this.openTrackDetails(filePath),
);
if (!ready) return;
const coverArt = track.CoverArtPath const coverArt = track.CoverArtPath
? { ? {
coverArtPath: track.CoverArtPath, coverArtPath: track.CoverArtPath,
@@ -1504,21 +1712,22 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
); );
} }
private openBatchTrackDetails( private async openBatchTrackDetails(
filePaths: string[], filePaths: string[],
) { ) {
const tracks = filePaths const tracks = tracksForPaths(
.map((fp) => this.tracks,
this.tracks.find( filePaths,
(t) => t.FilePath === fp, );
),
)
.filter(
(t): t is library.Track => t != null,
);
if (tracks.length === 0) return; 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 // Use cover art from the first track. If all tracks share
// the same album, they share the same art. // the same album, they share the same art.
const first = tracks[0]!; const first = tracks[0]!;
@@ -1738,12 +1947,17 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
active, active,
selected, selected,
})} })}
role="row"
aria-rowindex=${index + 1}
aria-selected=${selected}
tabindex=${index === this.focusedIndex ? 0 : -1}
draggable="true" draggable="true"
data-index=${index} data-index=${index}
data-testid="track-row" data-testid="track-row"
data-file-path=${track.FilePath} data-file-path=${track.FilePath}
> >
<div <div
role="gridcell"
class=${classMap({ class=${classMap({
'fav-icon': true, 'fav-icon': true,
favorited: isFav, favorited: isFav,
@@ -1756,7 +1970,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
${cols.map((col) => { ${cols.map((col) => {
const customCell = col.renderCell?.(track); const customCell = col.renderCell?.(track);
if (customCell !== undefined && customCell !== nothing) { if (customCell !== undefined && customCell !== nothing) {
return html`<div class="cell">${customCell}</div>`; return html`<div role="gridcell" class="cell">${customCell}</div>`;
} }
const val = col.accessor(track); const val = col.accessor(track);
const centered = val === '\u2014'; const centered = val === '\u2014';
@@ -1774,7 +1988,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
} }
return html` return html`
<div class=${classMap({ <div role="gridcell" class=${classMap({
cell: true, cell: true,
'cell-center': centered, 'cell-center': centered,
'cell-right': !centered && col.align === 'right', 'cell-right': !centered && col.align === 'right',
@@ -1899,15 +2113,22 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
return html` return html`
${this.tracks.length === 0 ${this.tracks.length === 0
? html`<p>Loading tracks...</p>` ? this.renderPlaceholder()
: html` : html`
${this.renderSortToolbar()} ${this.renderSortToolbar()}
<div class="table-container"> <div
<div class="header-row"> class="table-container"
<div></div> role="grid"
aria-label="Tracks"
aria-rowcount=${visibleTracks.length}
@keydown=${this.onListKeydown}
>
<div class="header-row" role="row">
<div role="columnheader"></div>
${cols.map( ${cols.map(
(col) => html` (col) => html`
<div <div
role="columnheader"
class="header-cell ${col.align === 'right' ? 'cell-right' : ''}" class="header-cell ${col.align === 'right' ? 'cell-right' : ''}"
@click=${() => @click=${() =>
this.onHeaderCellClick( this.onHeaderCellClick(