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