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 {
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<LitVirtualizer>('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
</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(
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}
>
<lit-virtualizer
.items=${entries}
@@ -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>
@@ -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<LitVirtualizer>('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}
>
<lit-virtualizer
.items=${entries}
@@ -264,7 +264,19 @@ export class TopResultsRow extends LitElement {
: 'track';
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
class="badge"
style="background: ${badgeColor(r.entityType)}"
+26 -2
View File
@@ -57,8 +57,32 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
accessor: () => '',
defaultWidth: '36px',
renderCell: (track: library.Track) => {
if (!track.CoverArtPath) return nothing;
return html`<img src="${track.CoverArtPath}" alt="" style="width:24px;height:24px;border-radius:3px;object-fit:cover;display:block;" />`;
// `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`<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: {
+256 -35
View File
@@ -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<HTMLElement>(
`.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<PropertyKey, unknown>,
) {
@@ -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`
<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) => {
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}
>
<div
role="gridcell"
class=${classMap({
'fav-icon': true,
favorited: isFav,
@@ -1756,7 +1970,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
${cols.map((col) => {
const customCell = col.renderCell?.(track);
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 centered = val === '\u2014';
@@ -1774,7 +1988,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
}
return html`
<div class=${classMap({
<div role="gridcell" class=${classMap({
cell: true,
'cell-center': centered,
'cell-right': !centered && col.align === 'right',
@@ -1899,15 +2113,22 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
return html`
${this.tracks.length === 0
? html`<p>Loading tracks...</p>`
? this.renderPlaceholder()
: html`
${this.renderSortToolbar()}
<div class="table-container">
<div class="header-row">
<div></div>
<div
class="table-container"
role="grid"
aria-label="Tracks"
aria-rowcount=${visibleTracks.length}
@keydown=${this.onListKeydown}
>
<div class="header-row" role="row">
<div role="columnheader"></div>
${cols.map(
(col) => html`
<div
role="columnheader"
class="header-cell ${col.align === 'right' ? 'cell-right' : ''}"
@click=${() =>
this.onHeaderCellClick(