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:
@@ -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)}"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user