perf(frontend): patch the stores instead of invalidating them
An event carries what a consumer needs so it never has to invalidate. - `library-store` answers `TrackPlayCountChanged` by patching one track, replacing the tracks array (consumers key memoized caches on its identity) while sharing every unchanged Track — instead of discarding four collections and refetching 25 MB per song. - `playlist-store` answers `PlaylistTracksChanged` by refetching the one playlist the event names, plus the summaries, since `UpdatedAt` is a sort key. 2 668 kB and 172 ms for one heart, against 2.0 kB. It falls back to a full invalidate only where a patch cannot be shown to be equivalent: no id, a cold cache, an unknown id, or a fetch already in flight. And a store with no subscriber fetches nothing — the singleton's constructor used to put every track of every playlist on the path to first paint for a view the user might never open. - `library-store` guards every fetch with a cache generation and holds the request itself instead of deriving a promise from subscriber notifications, which fixes the library-filter race and the never-settling waiter together: they are the same bug seen from either end. - `explore-cache`'s two art caches are bounded, sharing one exported cap constant — the artist photo's data URL is held by both, so capping either alone frees nothing at all and reads as a fix that did not work. - `search-store` deliberately does *not* coalesce its notify: deferring makes a subscriber that unsubscribes synchronously after a `setTerm` miss the notification entirely, which is a semantic change rather than an optimisation, and this is the store on the keystroke path. - `selection-controller` retains its keys across a refetch rather than clearing them, since they are file paths and those survive one, and `getSelectedKeysOrdered()` gains an early exit. It stays a walk of the list: an index goes stale on any re-sort, re-filter or refetch while a file path survives all three, and 3 ms does not buy a silently mis-ordered queue insert.
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
import type { PlayerState, TrackInfo } from '../player-store';
|
import type {
|
||||||
|
PlayerState,
|
||||||
|
PositionInfo,
|
||||||
|
TrackInfo,
|
||||||
|
} from '../player-store';
|
||||||
import { playerStore } from '../player-store';
|
import { playerStore } from '../player-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +70,11 @@ export class PlayerController implements ReactiveController {
|
|||||||
return this.state.muted;
|
return this.state.muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The backend's last position report, or null before the first. */
|
||||||
|
get position(): PositionInfo | null {
|
||||||
|
return this.state.position;
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// ACTIONS
|
// ACTIONS
|
||||||
// Delegate to store (which delegates to backend)
|
// Delegate to store (which delegates to backend)
|
||||||
|
|||||||
@@ -10,9 +10,27 @@
|
|||||||
* album detail page → check cache before API calls
|
* album detail page → check cache before API calls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { explore } from '@go/models';
|
import { registerCacheProbe } from '../utils/cache-stats';
|
||||||
type MBReleaseGroup = explore.MBReleaseGroup;
|
import { LRUMap } from '../utils/lru-map';
|
||||||
type LBTopRecording = explore.LBTopRecording;
|
|
||||||
|
/**
|
||||||
|
* Cap for artist entries (`perf.M8`).
|
||||||
|
*
|
||||||
|
* An entry's `imageURL` is the artist photo's base64 data URL — ~128 kB
|
||||||
|
* measured — and it is the *same string* `explore-view`'s own
|
||||||
|
* `artistImageCache` holds. Two unbounded maps referencing one string
|
||||||
|
* means bounding either alone frees nothing, so this constant is
|
||||||
|
* exported and both use it. Changing it here changes both.
|
||||||
|
*/
|
||||||
|
export const ARTIST_IMAGE_CACHE_LIMIT = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap for album entries. These are small — measured at 61 chars each,
|
||||||
|
* since `coverArt` is a local `/coverart/…` path rather than a data URL
|
||||||
|
* — so the cap is generous and exists to make the map bounded at all
|
||||||
|
* rather than because it costs anything today.
|
||||||
|
*/
|
||||||
|
const ALBUM_CACHE_LIMIT = 512;
|
||||||
|
|
||||||
/** Cached artist data from search results. */
|
/** Cached artist data from search results. */
|
||||||
export interface CachedArtist {
|
export interface CachedArtist {
|
||||||
@@ -33,10 +51,8 @@ export interface CachedAlbum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ExploreCacheStore {
|
class ExploreCacheStore {
|
||||||
private artists = new Map<string, CachedArtist>();
|
private artists = new LRUMap<string, CachedArtist>(ARTIST_IMAGE_CACHE_LIMIT);
|
||||||
private albums = new Map<string, CachedAlbum>();
|
private albums = new LRUMap<string, CachedAlbum>(ALBUM_CACHE_LIMIT);
|
||||||
private artistAlbums = new Map<string, MBReleaseGroup[]>();
|
|
||||||
private artistTopTracks = new Map<string, LBTopRecording[]>();
|
|
||||||
|
|
||||||
// -- Artists --
|
// -- Artists --
|
||||||
|
|
||||||
@@ -58,26 +74,6 @@ class ExploreCacheStore {
|
|||||||
return this.albums.get(mbid);
|
return this.albums.get(mbid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Artist → Albums (release groups) --
|
|
||||||
|
|
||||||
setArtistAlbums(artistMBID: string, albums: MBReleaseGroup[]) {
|
|
||||||
if (artistMBID) this.artistAlbums.set(artistMBID, albums);
|
|
||||||
}
|
|
||||||
|
|
||||||
getArtistAlbums(artistMBID: string): MBReleaseGroup[] | undefined {
|
|
||||||
return this.artistAlbums.get(artistMBID);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Artist → Top tracks --
|
|
||||||
|
|
||||||
setArtistTopTracks(artistMBID: string, tracks: LBTopRecording[]) {
|
|
||||||
if (artistMBID) this.artistTopTracks.set(artistMBID, tracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
getArtistTopTracks(artistMBID: string): LBTopRecording[] | undefined {
|
|
||||||
return this.artistTopTracks.get(artistMBID);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Bulk populate from search results --
|
// -- Bulk populate from search results --
|
||||||
|
|
||||||
populateFromSearch(artists: any[], releaseGroups: any[]) {
|
populateFromSearch(artists: any[], releaseGroups: any[]) {
|
||||||
@@ -104,6 +100,34 @@ class ExploreCacheStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entries and retained string length, for `window.__yjCacheStats()`. */
|
||||||
|
stats() {
|
||||||
|
const size = (o: object) => {
|
||||||
|
let n = 0;
|
||||||
|
|
||||||
|
for (const v of Object.values(o)) {
|
||||||
|
if (typeof v === 'string') n += v.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const of = (m: LRUMap<string, object>) => {
|
||||||
|
let chars = 0;
|
||||||
|
|
||||||
|
for (const v of m.values()) chars += size(v);
|
||||||
|
|
||||||
|
return { entries: m.size, chars, limit: m.limit };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
artists: of(this.artists as LRUMap<string, object>),
|
||||||
|
albums: of(this.albums as LRUMap<string, object>),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const exploreCache = new ExploreCacheStore();
|
export const exploreCache = new ExploreCacheStore();
|
||||||
|
|
||||||
|
registerCacheProbe('exploreCache.artists', () => exploreCache.stats().artists);
|
||||||
|
registerCacheProbe('exploreCache.albums', () => exploreCache.stats().albums);
|
||||||
|
|||||||
@@ -15,9 +15,28 @@ import {
|
|||||||
SetPinDefaultPlaylist,
|
SetPinDefaultPlaylist,
|
||||||
} from '@go/config/Config';
|
} from '@go/config/Config';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
|
import { describeError } from '@utils/describe-error';
|
||||||
|
import { notificationStore } from './notification-store';
|
||||||
|
|
||||||
export type IconStyle = 'heart' | 'star';
|
export type IconStyle = 'heart' | 'star';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A revert the user can see is not an explanation (errors.m2): the
|
||||||
|
* heart fills, and half a second later it empties again. Transient by
|
||||||
|
* the plan's rule — the state has already put itself back, so there is
|
||||||
|
* nothing to do but say why.
|
||||||
|
*/
|
||||||
|
function reportRevert(what: string, err: unknown): void {
|
||||||
|
console.error(`favorites: ${what} failed`, err);
|
||||||
|
notificationStore.transient({
|
||||||
|
key: 'favorites',
|
||||||
|
text: `Could not ${what}. ${describeError(err)}`,
|
||||||
|
detail: String(err),
|
||||||
|
coalescedText: (count) =>
|
||||||
|
`Could not ${what} — ${count} changes were undone.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface FavoritesState {
|
export interface FavoritesState {
|
||||||
playlistId: number;
|
playlistId: number;
|
||||||
playlistName: string;
|
playlistName: string;
|
||||||
@@ -152,7 +171,7 @@ class FavoritesStore {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await ToggleDefaultPlaylistTrack(filePath);
|
await ToggleDefaultPlaylistTrack(filePath);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Revert optimistic update.
|
// Revert optimistic update.
|
||||||
if (wasIn) {
|
if (wasIn) {
|
||||||
this.favoritedPaths.add(filePath);
|
this.favoritedPaths.add(filePath);
|
||||||
@@ -161,6 +180,10 @@ class FavoritesStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.notify();
|
this.notify();
|
||||||
|
reportRevert(
|
||||||
|
wasIn ? 'remove that favourite' : 'save that favourite',
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +198,9 @@ class FavoritesStore {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await AddToDefaultPlaylist(filePaths);
|
await AddToDefaultPlaylist(filePaths);
|
||||||
} catch {
|
} catch (err) {
|
||||||
void this.loadPaths();
|
void this.loadPaths();
|
||||||
|
reportRevert('save those favourites', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +215,9 @@ class FavoritesStore {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await RemoveFromDefaultPlaylist(filePaths);
|
await RemoveFromDefaultPlaylist(filePaths);
|
||||||
} catch {
|
} catch (err) {
|
||||||
void this.loadPaths();
|
void this.loadPaths();
|
||||||
|
reportRevert('remove those favourites', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type JobState =
|
|||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
/** Job kinds. Mirrors backend/jobs.Kind. */
|
/** Job kinds. Mirrors backend/jobs.Kind. */
|
||||||
export type JobKind = 'library-scan' | 'index-build';
|
export type JobKind = 'library-scan' | 'index-build' | 'autotag-apply';
|
||||||
|
|
||||||
type Subscriber = () => void;
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
|||||||
+206
-124
@@ -57,6 +57,17 @@ class LibraryStore {
|
|||||||
private subscribers = new Set<Subscriber>();
|
private subscribers = new Set<Subscriber>();
|
||||||
private notifyScheduled = false;
|
private notifyScheduled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one in-flight request per collection.
|
||||||
|
*
|
||||||
|
* Concurrent readers used to be deduplicated by deriving a promise
|
||||||
|
* from subscriber notifications, which never settled when the fetch
|
||||||
|
* *failed* (errors.M1) — the waiter tested for "loaded and not
|
||||||
|
* loading", and a failed fetch is neither. Holding the request
|
||||||
|
* itself makes every waiter settle exactly as the fetch did.
|
||||||
|
*/
|
||||||
|
private inFlight = new Map<ViewName, Promise<unknown>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monotonic counter incremented only when actual data changes
|
* Monotonic counter incremented only when actual data changes
|
||||||
* (not loading flag transitions). Subscribers can compare against
|
* (not loading flag transitions). Subscribers can compare against
|
||||||
@@ -64,6 +75,15 @@ class LibraryStore {
|
|||||||
*/
|
*/
|
||||||
private changeGen = 0;
|
private changeGen = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremented only by invalidation — a scan, a retag, or a library
|
||||||
|
* filter switch. A fetch captures it at request time and discards
|
||||||
|
* its answer if it no longer matches, which is what stops library
|
||||||
|
* A's tracks being cached while library B is selected (errors.C4).
|
||||||
|
* Separate from `changeGen`, which any collection landing bumps.
|
||||||
|
*/
|
||||||
|
private cacheGen = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
EventsOn(Events.LibraryScanComplete, () => {
|
EventsOn(Events.LibraryScanComplete, () => {
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
@@ -85,6 +105,9 @@ class LibraryStore {
|
|||||||
EventsOn(Events.TrackMetadataChanged, () => {
|
EventsOn(Events.TrackMetadataChanged, () => {
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
});
|
});
|
||||||
|
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
|
||||||
|
this.applyPlayCount(payload);
|
||||||
|
});
|
||||||
|
|
||||||
this.loadCoverSize();
|
this.loadCoverSize();
|
||||||
this.deferEagerFetch();
|
this.deferEagerFetch();
|
||||||
@@ -130,32 +153,87 @@ class LibraryStore {
|
|||||||
return this.albums;
|
return this.albums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a fetch: guard its answer against an invalidation that
|
||||||
|
* happened while it was in flight, hold it as *the* in-flight
|
||||||
|
* request for its collection, and clear the loading flag when it
|
||||||
|
* settles either way.
|
||||||
|
*
|
||||||
|
* A stale answer is not cached and not returned — the caller is
|
||||||
|
* chained onto whatever the current selection is fetching instead,
|
||||||
|
* so "give me the tracks" always answers with the tracks of the
|
||||||
|
* library that is selected when it answers.
|
||||||
|
*/
|
||||||
|
private track<T>(
|
||||||
|
slot: ViewName,
|
||||||
|
request: Promise<T>,
|
||||||
|
commit: (value: T) => void,
|
||||||
|
current: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const gen = this.cacheGen;
|
||||||
|
const guarded = request.then((value) => {
|
||||||
|
if (gen !== this.cacheGen) return current();
|
||||||
|
|
||||||
|
commit(value);
|
||||||
|
this.changeGen++;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
const tracked: Promise<T> = guarded.finally(() => {
|
||||||
|
if (this.inFlight.get(slot) !== tracked) return;
|
||||||
|
|
||||||
|
this.inFlight.delete(slot);
|
||||||
|
this.setLoading(slot, false);
|
||||||
|
this.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inFlight.set(slot, tracked);
|
||||||
|
this.setLoading(slot, true);
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pending<T>(slot: ViewName): Promise<T> | undefined {
|
||||||
|
return this.inFlight.get(slot) as Promise<T> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(slot: ViewName, loading: boolean): void {
|
||||||
|
switch (slot) {
|
||||||
|
case 'tracks':
|
||||||
|
this.tracksLoading = loading;
|
||||||
|
break;
|
||||||
|
case 'albums':
|
||||||
|
this.albumsLoading = loading;
|
||||||
|
break;
|
||||||
|
case 'artists':
|
||||||
|
this.artistsLoading = loading;
|
||||||
|
break;
|
||||||
|
case 'genres':
|
||||||
|
this.genresLoading = loading;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getTracks(): Promise<library.Track[]> {
|
async getTracks(): Promise<library.Track[]> {
|
||||||
if (this.tracks !== null) {
|
if (this.tracks !== null) {
|
||||||
return this.tracks;
|
return this.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.tracksLoading) {
|
const pending = this.pending<library.Track[]>('tracks');
|
||||||
return this.waitForTracks();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tracksLoading = true;
|
if (pending) return pending;
|
||||||
this.notify();
|
|
||||||
|
|
||||||
try {
|
const id = this.selectedLibraryIdValue;
|
||||||
const id = this.selectedLibraryIdValue;
|
|
||||||
const tracks = id !== null
|
|
||||||
? await GetAllTracksByLibrary(id)
|
|
||||||
: await GetAllTracks();
|
|
||||||
|
|
||||||
this.tracks = tracks;
|
return this.track(
|
||||||
this.changeGen++;
|
'tracks',
|
||||||
|
id !== null ? GetAllTracksByLibrary(id) : GetAllTracks(),
|
||||||
return tracks;
|
(tracks) => {
|
||||||
} finally {
|
this.tracks = tracks;
|
||||||
this.tracksLoading = false;
|
},
|
||||||
this.notify();
|
() => this.getTracks(),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAlbums(): Promise<library.Album[]> {
|
async getAlbums(): Promise<library.Album[]> {
|
||||||
@@ -163,27 +241,20 @@ class LibraryStore {
|
|||||||
return this.albums;
|
return this.albums;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.albumsLoading) {
|
const pending = this.pending<library.Album[]>('albums');
|
||||||
return this.waitForAlbums();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.albumsLoading = true;
|
if (pending) return pending;
|
||||||
this.notify();
|
|
||||||
|
|
||||||
try {
|
const id = this.selectedLibraryIdValue;
|
||||||
const id = this.selectedLibraryIdValue;
|
|
||||||
const albums = id !== null
|
|
||||||
? await GetAllAlbumsByLibrary(id)
|
|
||||||
: await GetAllAlbums();
|
|
||||||
|
|
||||||
this.albums = albums;
|
return this.track(
|
||||||
this.changeGen++;
|
'albums',
|
||||||
|
id !== null ? GetAllAlbumsByLibrary(id) : GetAllAlbums(),
|
||||||
return albums;
|
(albums) => {
|
||||||
} finally {
|
this.albums = albums;
|
||||||
this.albumsLoading = false;
|
},
|
||||||
this.notify();
|
() => this.getAlbums(),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getArtists(): Promise<library.Artist[]> {
|
async getArtists(): Promise<library.Artist[]> {
|
||||||
@@ -191,27 +262,20 @@ class LibraryStore {
|
|||||||
return this.artists;
|
return this.artists;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.artistsLoading) {
|
const pending = this.pending<library.Artist[]>('artists');
|
||||||
return this.waitForArtists();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.artistsLoading = true;
|
if (pending) return pending;
|
||||||
this.notify();
|
|
||||||
|
|
||||||
try {
|
const id = this.selectedLibraryIdValue;
|
||||||
const id = this.selectedLibraryIdValue;
|
|
||||||
const artists = id !== null
|
|
||||||
? await GetAllArtistsByLibrary(id)
|
|
||||||
: await GetAllArtists();
|
|
||||||
|
|
||||||
this.artists = artists;
|
return this.track(
|
||||||
this.changeGen++;
|
'artists',
|
||||||
|
id !== null ? GetAllArtistsByLibrary(id) : GetAllArtists(),
|
||||||
return artists;
|
(artists) => {
|
||||||
} finally {
|
this.artists = artists;
|
||||||
this.artistsLoading = false;
|
},
|
||||||
this.notify();
|
() => this.getArtists(),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGenres(): Promise<library.GenreWithCount[]> {
|
async getGenres(): Promise<library.GenreWithCount[]> {
|
||||||
@@ -219,27 +283,22 @@ class LibraryStore {
|
|||||||
return this.genres;
|
return this.genres;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.genresLoading) {
|
const pending = this.pending<library.GenreWithCount[]>('genres');
|
||||||
return this.waitForGenres();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.genresLoading = true;
|
if (pending) return pending;
|
||||||
this.notify();
|
|
||||||
|
|
||||||
try {
|
const id = this.selectedLibraryIdValue;
|
||||||
const id = this.selectedLibraryIdValue;
|
|
||||||
const genres = id !== null
|
|
||||||
? await GetAllGenresWithCountsByLibrary(id)
|
|
||||||
: await GetAllGenresWithCounts();
|
|
||||||
|
|
||||||
this.genres = genres;
|
return this.track(
|
||||||
this.changeGen++;
|
'genres',
|
||||||
|
id !== null
|
||||||
return genres;
|
? GetAllGenresWithCountsByLibrary(id)
|
||||||
} finally {
|
: GetAllGenresWithCounts(),
|
||||||
this.genresLoading = false;
|
(genres) => {
|
||||||
this.notify();
|
this.genres = genres;
|
||||||
}
|
},
|
||||||
|
() => this.getGenres(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAlbumsByArtist(
|
async getAlbumsByArtist(
|
||||||
@@ -442,11 +501,75 @@ class LibraryStore {
|
|||||||
// INVALIDATION
|
// INVALIDATION
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch one track's play statistics in place.
|
||||||
|
*
|
||||||
|
* Finishing a track used to arrive as TrackMetadataChanged, so every
|
||||||
|
* song invalidated the whole cache and refetched tracks, albums,
|
||||||
|
* artists and genres — ~37 MB across the IPC and ~0.8 s of blocked
|
||||||
|
* main thread per song at 50 000 tracks (perf.C1), and it cleared
|
||||||
|
* the user's track-list selection while it did (perf.C2).
|
||||||
|
*
|
||||||
|
* A play count changes one integer on one row. The backend sends
|
||||||
|
* everything needed to write it, so nothing is refetched and no
|
||||||
|
* collection identity changes except the tracks array itself.
|
||||||
|
*
|
||||||
|
* The array *is* replaced rather than mutated: consumers key their
|
||||||
|
* memoized filter/sort caches on its identity (`track-list`'s
|
||||||
|
* `cachedTracks` check is the load-bearing one), so an in-place
|
||||||
|
* mutation would be invisible to every one of them. The individual
|
||||||
|
* Track objects other than the patched one are shared, which is
|
||||||
|
* what keeps this cheap.
|
||||||
|
*/
|
||||||
|
private applyPlayCount(payload: unknown): void {
|
||||||
|
if (this.tracks === null) return;
|
||||||
|
|
||||||
|
const p = payload as {
|
||||||
|
filePath?: string;
|
||||||
|
playCount?: number;
|
||||||
|
lastPlayed?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
if (!p?.filePath) return;
|
||||||
|
|
||||||
|
const idx = this.tracks.findIndex((t) => t.FilePath === p.filePath);
|
||||||
|
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const existing = this.tracks[idx];
|
||||||
|
|
||||||
|
if (existing === undefined) return;
|
||||||
|
|
||||||
|
const patched = Object.assign(
|
||||||
|
Object.create(Object.getPrototypeOf(existing) as object),
|
||||||
|
existing,
|
||||||
|
{
|
||||||
|
PlayCount: p.playCount ?? existing.PlayCount,
|
||||||
|
LastPlayed: p.lastPlayed ?? existing.LastPlayed,
|
||||||
|
},
|
||||||
|
) as library.Track;
|
||||||
|
|
||||||
|
this.tracks = [
|
||||||
|
...this.tracks.slice(0, idx),
|
||||||
|
patched,
|
||||||
|
...this.tracks.slice(idx + 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
this.changeGen++;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
private invalidate(): void {
|
private invalidate(): void {
|
||||||
this.tracks = null;
|
this.tracks = null;
|
||||||
this.albums = null;
|
this.albums = null;
|
||||||
this.artists = null;
|
this.artists = null;
|
||||||
this.genres = null;
|
this.genres = null;
|
||||||
|
// Anything still in flight was asked for on behalf of a
|
||||||
|
// selection that no longer applies: forget it, so the eager
|
||||||
|
// refetch below starts a request for the current one rather
|
||||||
|
// than adopting the old one's answer.
|
||||||
|
this.inFlight.clear();
|
||||||
|
this.cacheGen++;
|
||||||
this.changeGen++;
|
this.changeGen++;
|
||||||
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
||||||
this.notify();
|
this.notify();
|
||||||
@@ -461,10 +584,17 @@ class LibraryStore {
|
|||||||
* needing their own LibraryScanComplete listener.
|
* needing their own LibraryScanComplete listener.
|
||||||
*/
|
*/
|
||||||
private eagerFetch(): void {
|
private eagerFetch(): void {
|
||||||
void this.getTracks();
|
// A failed fetch is reported by whichever view asked for the
|
||||||
void this.getAlbums();
|
// data (it is that panel's failure, not the app's), but the
|
||||||
void this.getArtists();
|
// eager refetch has no caller to reject to — without a catch it
|
||||||
void this.getGenres();
|
// is an unhandled rejection.
|
||||||
|
const logged = (what: string) => (err: unknown) =>
|
||||||
|
console.error(`library: could not load ${what}`, err);
|
||||||
|
|
||||||
|
void this.getTracks().catch(logged('tracks'));
|
||||||
|
void this.getAlbums().catch(logged('albums'));
|
||||||
|
void this.getArtists().catch(logged('artists'));
|
||||||
|
void this.getGenres().catch(logged('genres'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -486,54 +616,6 @@ class LibraryStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// HELPERS
|
|
||||||
// Wait for an in-flight fetch to complete.
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
private waitForTracks(): Promise<library.Track[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const unsub = this.subscribe(() => {
|
|
||||||
if (!this.tracksLoading && this.tracks !== null) {
|
|
||||||
unsub();
|
|
||||||
resolve(this.tracks);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForAlbums(): Promise<library.Album[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const unsub = this.subscribe(() => {
|
|
||||||
if (!this.albumsLoading && this.albums !== null) {
|
|
||||||
unsub();
|
|
||||||
resolve(this.albums);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForArtists(): Promise<library.Artist[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const unsub = this.subscribe(() => {
|
|
||||||
if (!this.artistsLoading && this.artists !== null) {
|
|
||||||
unsub();
|
|
||||||
resolve(this.artists);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForGenres(): Promise<library.GenreWithCount[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const unsub = this.subscribe(() => {
|
|
||||||
if (!this.genresLoading && this.genres !== null) {
|
|
||||||
unsub();
|
|
||||||
resolve(this.genres);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance.
|
// Singleton instance.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
import * as Player from '@go/player/Player';
|
import * as Player from '@go/player/Player';
|
||||||
|
import { notificationStore } from './notification-store';
|
||||||
|
|
||||||
|
/** The region `<inline-notice>` renders these in: the player bar. */
|
||||||
|
export const PlayerRegion = 'player';
|
||||||
|
|
||||||
// TrackInfo mirrors the player.TrackInfo struct in the Go backend.
|
// TrackInfo mirrors the player.TrackInfo struct in the Go backend.
|
||||||
// Fields are serialized as camelCase JSON via struct tags.
|
// Fields are serialized as camelCase JSON via struct tags.
|
||||||
@@ -23,6 +27,26 @@ export interface TrackInfo {
|
|||||||
recordingMbid: string; // MusicBrainz recording ID or empty string
|
recordingMbid: string; // MusicBrainz recording ID or empty string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PositionInfo mirrors player.PositionInfo in the Go backend: the
|
||||||
|
// player's own answer to "where are we", pushed once a second while
|
||||||
|
// playing and immediately after any seek, pause, resume or track
|
||||||
|
// change.
|
||||||
|
export interface PositionInfo {
|
||||||
|
positionSeconds: number;
|
||||||
|
trackLength: number;
|
||||||
|
trackChangeId: number;
|
||||||
|
seq: number; // increments per report, so an unchanged second is still a fresh reading
|
||||||
|
playing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackFailure mirrors queue.PlaybackFailure.
|
||||||
|
export interface PlaybackFailure {
|
||||||
|
filePath: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
// Cached from backend
|
// Cached from backend
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -30,19 +54,30 @@ export interface PlayerState {
|
|||||||
volume: number; // 0-100
|
volume: number; // 0-100
|
||||||
muted: boolean; // silenced independently of the volume level
|
muted: boolean; // silenced independently of the volume level
|
||||||
|
|
||||||
// Frontend-only state (for future use)
|
// The backend's position report. The seek bar renders this and
|
||||||
// selectedTrackIds: Set<number>;
|
// interpolates only between reports; it does not count.
|
||||||
// isQueuePanelOpen: boolean;
|
position: PositionInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscriber = () => void;
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindings are fire-and-forget by design — the backend reports what
|
||||||
|
* happened through events, not return values — but a rejected bridge
|
||||||
|
* call still has to land somewhere other than an unhandled rejection
|
||||||
|
* (errors.m1).
|
||||||
|
*/
|
||||||
|
function reportBindingFailure(name: string): (err: unknown) => void {
|
||||||
|
return (err: unknown) => console.error(`${name} failed`, err);
|
||||||
|
}
|
||||||
|
|
||||||
class PlayerStore {
|
class PlayerStore {
|
||||||
private state: PlayerState = {
|
private state: PlayerState = {
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
volume: 50,
|
volume: 50,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
position: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
private subscribers = new Set<Subscriber>();
|
private subscribers = new Set<Subscriber>();
|
||||||
@@ -66,6 +101,41 @@ class PlayerStore {
|
|||||||
this.update({ currentTrack: trackInfo ?? null });
|
this.update({ currentTrack: trackInfo ?? null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaybackPositionChanged, (position: PositionInfo) => {
|
||||||
|
this.update({ position });
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaybackFailed, (failure: PlaybackFailure) => {
|
||||||
|
// The raw Go reason is a debugging tool, not a sentence; it stays
|
||||||
|
// in the console and rides along as `detail`.
|
||||||
|
console.error(`playback failed: ${failure.filePath}: ${failure.reason}`);
|
||||||
|
|
||||||
|
const name = failure.title || failure.filePath;
|
||||||
|
|
||||||
|
// Inline by the plan's own rule: the useful response to a track
|
||||||
|
// that will not play is to keep playing, which the backend is
|
||||||
|
// already doing by skipping it.
|
||||||
|
notificationStore.inline(PlayerRegion, {
|
||||||
|
key: 'playback-failed',
|
||||||
|
tone: 'warning',
|
||||||
|
text: `Could not play “${name}” — the file may have moved.`,
|
||||||
|
coalescedText: (count) =>
|
||||||
|
`Skipped ${count} tracks that could not be played.`,
|
||||||
|
detail: failure.reason,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.SeekFailed, () => {
|
||||||
|
// The backend re-reports its real position alongside this, so the
|
||||||
|
// optimistic move the seek bar made is already being taken back;
|
||||||
|
// all that is missing is saying why.
|
||||||
|
notificationStore.inline(PlayerRegion, {
|
||||||
|
key: 'seek-failed',
|
||||||
|
tone: 'warning',
|
||||||
|
text: 'Could not seek in this track.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
EventsOn(Events.PlaybackFinished, () => {
|
EventsOn(Events.PlaybackFinished, () => {
|
||||||
this.update({ isPlaying: false });
|
this.update({ isPlaying: false });
|
||||||
// Queue auto-advance is handled by the backend queue package.
|
// Queue auto-advance is handled by the backend queue package.
|
||||||
@@ -94,23 +164,27 @@ class PlayerStore {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
pause(): void {
|
pause(): void {
|
||||||
Player.Pause();
|
void Player.Pause().catch(reportBindingFailure('Player.Pause'));
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTrack(filePath: string): void {
|
loadTrack(filePath: string): void {
|
||||||
Player.LoadFile(filePath);
|
void Player.LoadFile(filePath).catch(
|
||||||
|
reportBindingFailure('Player.LoadFile'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(seconds: number): void {
|
seek(seconds: number): void {
|
||||||
Player.Seek(seconds);
|
void Player.Seek(seconds).catch(reportBindingFailure('Player.Seek'));
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(level: number): void {
|
setVolume(level: number): void {
|
||||||
Player.SetVolume(level);
|
void Player.SetVolume(level).catch(
|
||||||
|
reportBindingFailure('Player.SetVolume'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMute(): void {
|
toggleMute(): void {
|
||||||
void Player.MuteToggle();
|
void Player.MuteToggle().catch(reportBindingFailure('Player.MuteToggle'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { GetAllPlaylistsWithTracks } from '@go/playlist/Service';
|
import {
|
||||||
|
GetAllPlaylists,
|
||||||
|
GetAllPlaylistsWithTracks,
|
||||||
|
GetPlaylistTracks,
|
||||||
|
} from '@go/playlist/Service';
|
||||||
import type { playlist } from '@go/models';
|
import type { playlist } from '@go/models';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ class PlaylistStore {
|
|||||||
private playlistsLoading = false;
|
private playlistsLoading = false;
|
||||||
private scrollPosition = 0;
|
private scrollPosition = 0;
|
||||||
private subscribers = new Set<Subscriber>();
|
private subscribers = new Set<Subscriber>();
|
||||||
|
private notifyScheduled = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
EventsOn(Events.LibraryScanComplete, () => {
|
EventsOn(Events.LibraryScanComplete, () => {
|
||||||
@@ -28,15 +33,29 @@ class PlaylistStore {
|
|||||||
this.invalidate();
|
this.invalidate();
|
||||||
});
|
});
|
||||||
|
|
||||||
EventsOn(Events.PlaylistTracksChanged, () => {
|
// `PlaylistTracksChanged` carries the playlist that changed,
|
||||||
this.invalidate();
|
// and toggling one heart in the track list is by far its most
|
||||||
});
|
// frequent source. Answering it with a full invalidate meant
|
||||||
|
// `GetAllPlaylistsWithTracks` — every row of every playlist,
|
||||||
|
// with full track metadata — for a one-row edit: measured at
|
||||||
|
// 2.61 MB and 172 ms across ten 500-track playlists. Patch the
|
||||||
|
// one playlist instead; the id is right there.
|
||||||
|
EventsOn(
|
||||||
|
Events.PlaylistTracksChanged,
|
||||||
|
(playlistId?: number | null) => {
|
||||||
|
void this.patchPlaylist(playlistId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
EventsOn(Events.PlaylistsRestored, () => {
|
EventsOn(Events.PlaylistsRestored, () => {
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
});
|
});
|
||||||
|
|
||||||
void this.getPlaylists();
|
// Deliberately no eager fetch here. This store is a singleton
|
||||||
|
// constructed at import time, so warming it put every track of
|
||||||
|
// every playlist on the path to first paint — for a view the
|
||||||
|
// user may never open. `getPlaylists()` fetches on first
|
||||||
|
// access, and `playlist-view` awaits it when it loads.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -125,7 +144,115 @@ class PlaylistStore {
|
|||||||
this.playlists = null;
|
this.playlists = null;
|
||||||
this.scrollPosition = 0;
|
this.scrollPosition = 0;
|
||||||
this.notify();
|
this.notify();
|
||||||
void this.getPlaylists();
|
|
||||||
|
// Only refetch eagerly if something is actually rendering
|
||||||
|
// playlists. `playlist-view` is the sole subscriber and is
|
||||||
|
// created lazily on first navigation, so before it has ever
|
||||||
|
// been opened this used to fetch every track of every playlist
|
||||||
|
// in answer to an event nobody was listening for. Once it
|
||||||
|
// exists it is a cached view and stays subscribed, so the
|
||||||
|
// refetch-then-rerender path it depends on is unchanged.
|
||||||
|
if (this.subscribers.size > 0) {
|
||||||
|
void this.getPlaylists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// PATCHING
|
||||||
|
// Refetch one playlist rather than all of them.
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a single playlist's tracks in the cache.
|
||||||
|
*
|
||||||
|
* Falls back to a full invalidate whenever the patch cannot be
|
||||||
|
* shown to be equivalent: an event with no id (the bulk restore and
|
||||||
|
* reorder paths emit one), a cold cache, an id we have never seen,
|
||||||
|
* or a fetch already in flight — which would otherwise land on top
|
||||||
|
* of the patch and undo it.
|
||||||
|
*
|
||||||
|
* Summaries come along because `updated_at` moves with the edit and
|
||||||
|
* `playlist-view` sorts on it; `GetAllPlaylists` is summaries only,
|
||||||
|
* so its cost does not scale with how many tracks a playlist holds.
|
||||||
|
*/
|
||||||
|
private async patchPlaylist(
|
||||||
|
playlistId?: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const cached = this.playlists;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof playlistId !== 'number' ||
|
||||||
|
cached === null ||
|
||||||
|
this.playlistsLoading ||
|
||||||
|
!cached.some((p) => p.Summary?.ID === playlistId)
|
||||||
|
) {
|
||||||
|
this.invalidate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [summaries, tracks] = await Promise.all([
|
||||||
|
GetAllPlaylists(),
|
||||||
|
GetPlaylistTracks(playlistId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The cache may have been replaced while those were in
|
||||||
|
// flight, in which case this patch describes a state that
|
||||||
|
// no longer exists and the newer one is already correct.
|
||||||
|
if (this.playlists !== cached) return;
|
||||||
|
|
||||||
|
const summaryById = new Map(
|
||||||
|
(summaries ?? []).map((s) => [s.ID, s]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new array identity, because `playlist-view` keys its
|
||||||
|
// reload off it — while sharing the `Tracks` array of every
|
||||||
|
// playlist that did not change.
|
||||||
|
//
|
||||||
|
// `WithTracks` is a generated *class* (it carries
|
||||||
|
// `convertValues`), so an object spread would produce
|
||||||
|
// something that no longer is one. Clone through the
|
||||||
|
// prototype instead.
|
||||||
|
const withChanges = (
|
||||||
|
entry: playlist.WithTracks,
|
||||||
|
changes: Partial<playlist.WithTracks>,
|
||||||
|
): playlist.WithTracks =>
|
||||||
|
Object.assign(
|
||||||
|
Object.create(
|
||||||
|
Object.getPrototypeOf(entry) as object,
|
||||||
|
) as playlist.WithTracks,
|
||||||
|
entry,
|
||||||
|
changes,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.playlists = cached.map((entry) => {
|
||||||
|
const summary =
|
||||||
|
summaryById.get(entry.Summary?.ID) ??
|
||||||
|
entry.Summary;
|
||||||
|
|
||||||
|
if (entry.Summary?.ID !== playlistId) {
|
||||||
|
return summary === entry.Summary
|
||||||
|
? entry
|
||||||
|
: withChanges(entry, {
|
||||||
|
Summary: summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return withChanges(entry, {
|
||||||
|
Summary: summary,
|
||||||
|
Tracks: tracks ?? [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'Failed to patch playlist after track change:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -138,8 +265,24 @@ class PlaylistStore {
|
|||||||
return () => this.subscribers.delete(callback);
|
return () => this.subscribers.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesced to a microtask, the way every other store in this app
|
||||||
|
* does it (perf.p3). A patch writes the tracks and the summaries in two statements;
|
||||||
|
* without coalescing that is two synchronous passes over every
|
||||||
|
* subscriber.
|
||||||
|
* Lit batches the resulting `requestUpdate()`s anyway, so the win is
|
||||||
|
* small; the point is that five stores doing this and two not is
|
||||||
|
* where a real double-notify hides.
|
||||||
|
*/
|
||||||
private notify(): void {
|
private notify(): void {
|
||||||
this.subscribers.forEach((callback) => callback());
|
if (this.notifyScheduled) return;
|
||||||
|
|
||||||
|
this.notifyScheduled = true;
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.notifyScheduled = false;
|
||||||
|
this.subscribers.forEach((callback) => callback());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ interface TracksModified {
|
|||||||
|
|
||||||
type Subscriber = () => void;
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue bindings are fire-and-forget by design: what happened is
|
||||||
|
* reported by events (QueueChanged, PlaybackFailed), not by a return
|
||||||
|
* value. A rejected bridge call still needs somewhere to land other
|
||||||
|
* than an unhandled rejection (errors.m1).
|
||||||
|
*/
|
||||||
|
function reportBindingFailure(name: string): (err: unknown) => void {
|
||||||
|
return (err: unknown) => console.error(`${name} failed`, err);
|
||||||
|
}
|
||||||
|
|
||||||
class QueueStore {
|
class QueueStore {
|
||||||
private state: QueueState = {
|
private state: QueueState = {
|
||||||
tracks: [],
|
tracks: [],
|
||||||
@@ -190,15 +200,21 @@ class QueueStore {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
play(): void {
|
play(): void {
|
||||||
Queue.Play();
|
void Queue.Play().catch(
|
||||||
|
reportBindingFailure('Queue.Play'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
next(): void {
|
next(): void {
|
||||||
Queue.Next();
|
void Queue.Next().catch(
|
||||||
|
reportBindingFailure('Queue.Next'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
previous(): void {
|
previous(): void {
|
||||||
Queue.Previous();
|
void Queue.Previous().catch(
|
||||||
|
reportBindingFailure('Queue.Previous'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setQueue(
|
setQueue(
|
||||||
@@ -206,61 +222,87 @@ class QueueStore {
|
|||||||
startIndex: number,
|
startIndex: number,
|
||||||
shuffleStart = false,
|
shuffleStart = false,
|
||||||
): void {
|
): void {
|
||||||
Queue.SetQueue(filePaths, startIndex, shuffleStart);
|
void Queue.SetQueue(filePaths, startIndex, shuffleStart).catch(
|
||||||
|
reportBindingFailure('Queue.SetQueue'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addToQueue(filePath: string): void {
|
addToQueue(filePath: string): void {
|
||||||
Queue.AddTrack(filePath);
|
void Queue.AddTrack(filePath).catch(
|
||||||
|
reportBindingFailure('Queue.AddTrack'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
playNext(filePath: string): void {
|
playNext(filePath: string): void {
|
||||||
Queue.InsertNext(filePath);
|
void Queue.InsertNext(filePath).catch(
|
||||||
|
reportBindingFailure('Queue.InsertNext'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromQueue(position: number): void {
|
removeFromQueue(position: number): void {
|
||||||
Queue.RemoveTrack(position);
|
void Queue.RemoveTrack(position).catch(
|
||||||
|
reportBindingFailure('Queue.RemoveTrack'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeTracksFromQueue(positions: number[]): void {
|
removeTracksFromQueue(positions: number[]): void {
|
||||||
Queue.RemoveTracks(positions);
|
void Queue.RemoveTracks(positions).catch(
|
||||||
|
reportBindingFailure('Queue.RemoveTracks'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addTracksToQueue(filePaths: string[]): void {
|
addTracksToQueue(filePaths: string[]): void {
|
||||||
Queue.AddTracks(filePaths);
|
void Queue.AddTracks(filePaths).catch(
|
||||||
|
reportBindingFailure('Queue.AddTracks'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
playTracksNext(filePaths: string[]): void {
|
playTracksNext(filePaths: string[]): void {
|
||||||
Queue.InsertNextTracks(filePaths);
|
void Queue.InsertNextTracks(filePaths).catch(
|
||||||
|
reportBindingFailure('Queue.InsertNextTracks'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleShuffle(): void {
|
toggleShuffle(): void {
|
||||||
Queue.ToggleShuffle();
|
void Queue.ToggleShuffle().catch(
|
||||||
|
reportBindingFailure('Queue.ToggleShuffle'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cycleRepeat(): void {
|
cycleRepeat(): void {
|
||||||
Queue.CycleRepeat();
|
void Queue.CycleRepeat().catch(
|
||||||
|
reportBindingFailure('Queue.CycleRepeat'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
playAtIndex(index: number): void {
|
playAtIndex(index: number): void {
|
||||||
Queue.PlayIndex(index);
|
void Queue.PlayIndex(index).catch(
|
||||||
|
reportBindingFailure('Queue.PlayIndex'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
insertTracksAtIndex(
|
insertTracksAtIndex(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
index: number,
|
index: number,
|
||||||
): void {
|
): void {
|
||||||
Queue.InsertTracksAt(filePaths, index);
|
void Queue.InsertTracksAt(filePaths, index).catch(
|
||||||
|
reportBindingFailure('Queue.InsertTracksAt'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
moveTracksInQueue(
|
moveTracksInQueue(
|
||||||
fromIndices: number[],
|
fromIndices: number[],
|
||||||
toIndex: number,
|
toIndex: number,
|
||||||
): void {
|
): void {
|
||||||
Queue.MoveQueueTracks(fromIndices, toIndex);
|
void Queue.MoveQueueTracks(fromIndices, toIndex).catch(
|
||||||
|
reportBindingFailure('Queue.MoveQueueTracks'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearQueue(): void {
|
clearQueue(): void {
|
||||||
Queue.Clear();
|
void Queue.Clear().catch(
|
||||||
|
reportBindingFailure('Queue.Clear'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ class SearchStore {
|
|||||||
return () => this.subscribers.delete(callback);
|
return () => this.subscribers.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately *not* microtask-coalesced, unlike every other store
|
||||||
|
* here (perf.p3 asks for it, and it is wrong about this one).
|
||||||
|
*
|
||||||
|
* Two reasons. Deferring makes an unsubscribe that happens
|
||||||
|
* synchronously after a set drop the notification entirely, which
|
||||||
|
* is a semantic change, not an optimisation — `view-stores.test.ts`
|
||||||
|
* pins both halves. And this store is on the keystroke path, where
|
||||||
|
* the batching the audit says would hide the cost is Lit's, not
|
||||||
|
* ours: the `requestUpdate()`s are already coalesced one layer
|
||||||
|
* down, so the microtask buys nothing and costs a frame of term
|
||||||
|
* staleness in the one place a frame is visible.
|
||||||
|
*/
|
||||||
private notify(): void {
|
private notify(): void {
|
||||||
this.subscribers.forEach((callback) => callback());
|
this.subscribers.forEach((callback) => callback());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ export class SelectionController implements ReactiveController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether `next` holds exactly the currently selected keys. */
|
||||||
|
private sameMembership(next: ReadonlySet<string>): boolean {
|
||||||
|
if (next.size !== this._selectedItems.size) return false;
|
||||||
|
|
||||||
|
for (const key of next) {
|
||||||
|
if (!this._selectedItems.has(key)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear the entire selection. */
|
/** Clear the entire selection. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
if (this._selectedItems.size === 0) return;
|
if (this._selectedItems.size === 0) return;
|
||||||
@@ -135,6 +146,40 @@ export class SelectionController implements ReactiveController {
|
|||||||
this.host.onSelectionChanged?.();
|
this.host.onSelectionChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop selected keys that are no longer in the list, keeping the
|
||||||
|
* rest.
|
||||||
|
*
|
||||||
|
* The reason this exists rather than `clear()`: a refetch is not a
|
||||||
|
* deselection. Every naturally finished track used to invalidate
|
||||||
|
* the library cache, and `track-list` answered the new array by
|
||||||
|
* clearing the selection — so selecting forty tracks to drag into a
|
||||||
|
* playlist was impossible while music was playing (audit perf.C2).
|
||||||
|
* Keys are file paths, which survive a refetch, so the selection
|
||||||
|
* survives with them.
|
||||||
|
*
|
||||||
|
* `lastSelectedIndex` is dropped regardless: it is an index into a
|
||||||
|
* list that has just been replaced, and a shift-click against a
|
||||||
|
* stale one selects the wrong range.
|
||||||
|
*/
|
||||||
|
retain(isStillPresent: (key: string) => boolean): void {
|
||||||
|
if (this._selectedItems.size === 0) return;
|
||||||
|
|
||||||
|
const next = new Set<string>();
|
||||||
|
|
||||||
|
for (const key of this._selectedItems) {
|
||||||
|
if (isStillPresent(key)) next.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSelectedIndex = null;
|
||||||
|
|
||||||
|
if (next.size === this._selectedItems.size) return;
|
||||||
|
|
||||||
|
this._selectedItems = next;
|
||||||
|
this.host.requestUpdate();
|
||||||
|
this.host.onSelectionChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
/** Select all items. */
|
/** Select all items. */
|
||||||
selectAll(): void {
|
selectAll(): void {
|
||||||
const count = this.host.getItemCount();
|
const count = this.host.getItemCount();
|
||||||
@@ -145,7 +190,12 @@ export class SelectionController implements ReactiveController {
|
|||||||
if (key !== undefined) next.add(key);
|
if (key !== undefined) next.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.size === this._selectedItems.size) return;
|
// Membership, not cardinality. The guard used to compare sizes
|
||||||
|
// alone, so selecting four rows and then Select All over a
|
||||||
|
// *different* four was a no-op (audit perf.p5) — reachable now
|
||||||
|
// that `retain()` above carries a selection across a refetch
|
||||||
|
// that replaced the list.
|
||||||
|
if (this.sameMembership(next)) return;
|
||||||
|
|
||||||
this._selectedItems = next;
|
this._selectedItems = next;
|
||||||
this.lastSelectedIndex = count > 0 ? count - 1 : null;
|
this.lastSelectedIndex = count > 0 ? count - 1 : null;
|
||||||
@@ -156,8 +206,26 @@ export class SelectionController implements ReactiveController {
|
|||||||
/**
|
/**
|
||||||
* Return the selected keys in the order they appear in the host's
|
* Return the selected keys in the order they appear in the host's
|
||||||
* item list. This preserves positional ordering for queue operations.
|
* item list. This preserves positional ordering for queue operations.
|
||||||
|
*
|
||||||
|
* This walks the *list*, not the selection, which audit `perf.m6`
|
||||||
|
* calls out: every `dragstart`, every context-menu action and every
|
||||||
|
* favourite toggle pays it. Measured at 50 000 tracks it is **3 ms**
|
||||||
|
* — real, and a fifth of a frame, so the loop stays. What it does
|
||||||
|
* not do any more is keep going after it has found everything.
|
||||||
|
*
|
||||||
|
* It walks the list rather than the selection *deliberately*: the
|
||||||
|
* only way to order the selection directly is to store each key's
|
||||||
|
* index with it, and an index goes stale whenever the list is
|
||||||
|
* re-sorted, re-filtered or refetched, while the keys (file paths)
|
||||||
|
* survive all three — which is exactly why `retain()` drops
|
||||||
|
* `lastSelectedIndex` and keeps the keys. Trading 3 ms for a
|
||||||
|
* silently mis-ordered queue insert is not a trade.
|
||||||
*/
|
*/
|
||||||
getSelectedKeysOrdered(): string[] {
|
getSelectedKeysOrdered(): string[] {
|
||||||
|
const wanted = this._selectedItems.size;
|
||||||
|
|
||||||
|
if (wanted === 0) return [];
|
||||||
|
|
||||||
const count = this.host.getItemCount();
|
const count = this.host.getItemCount();
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|
||||||
@@ -166,6 +234,7 @@ export class SelectionController implements ReactiveController {
|
|||||||
|
|
||||||
if (key !== undefined && this._selectedItems.has(key)) {
|
if (key !== undefined && this._selectedItems.has(key)) {
|
||||||
result.push(key);
|
result.push(key);
|
||||||
|
if (result.length === wanted) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +243,15 @@ export class SelectionController implements ReactiveController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the selected indices in ascending order.
|
* Return the selected indices in ascending order.
|
||||||
|
*
|
||||||
|
* Same shape, same reasoning, same early exit as
|
||||||
|
* `getSelectedKeysOrdered()` above.
|
||||||
*/
|
*/
|
||||||
getSelectedIndices(): number[] {
|
getSelectedIndices(): number[] {
|
||||||
|
const wanted = this._selectedItems.size;
|
||||||
|
|
||||||
|
if (wanted === 0) return [];
|
||||||
|
|
||||||
const count = this.host.getItemCount();
|
const count = this.host.getItemCount();
|
||||||
const result: number[] = [];
|
const result: number[] = [];
|
||||||
|
|
||||||
@@ -184,6 +260,7 @@ export class SelectionController implements ReactiveController {
|
|||||||
|
|
||||||
if (key !== undefined && this._selectedItems.has(key)) {
|
if (key !== undefined && this._selectedItems.has(key)) {
|
||||||
result.push(i);
|
result.push(i);
|
||||||
|
if (result.length === wanted) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user