All four collections were fetched at DOMContentLoaded and refetched on every invalidation, whichever view was showing. On a 26 138-track library the track list was 20.5 MB of that, and encoding it cost the backend ~170 MB of transient allocation — paid by someone looking at Home, which draws none of it. - The store warms only albums, artists and genres, on idle after first paint: 1.6 MB together, and what made those views instant. - A nav item prefetches on hover and on keyboard focus, which is the ~100 ms before the click that a cold open would otherwise wait. - An invalidation refetches what something had loaded, and nothing else — a scan no longer loads the track list of a library whose Tracks view nobody has opened. - `index.html`'s first-paint `<track-list>` is `view-hidden`, and `index.ts` no longer activates it: it is markup, not a decision about which view the launch lands on, and activating it was what fetched the whole list for a landing on Home. The track list itself loads on view activation, which the shell drives. Measured on 50 000 tracks: backend RSS at rest 543 → 296 MB, peak 571 → 296 MB, Go heap held 361 → 125 MB, JS heap 31.8 → 18 MB, binding bytes at rest 35.9 → 12.1 MB, heap after a browse 36.5 → 22.7 MB. Tracks first open 26 ms; slowest view open 57 ms. Closes #280
753 lines
24 KiB
TypeScript
753 lines
24 KiB
TypeScript
import { EventsOn } from '@runtime/runtime';
|
|
import {
|
|
GetTrackTable,
|
|
GetAlbums,
|
|
GetArtists,
|
|
GetGenres,
|
|
GetAlbumsByArtist,
|
|
GetAllLibrariesWithTrackCounts,
|
|
} from '@go/library/library.js';
|
|
import type * as library from '@go/library/models.js';
|
|
import { list } from '@utils/binding';
|
|
import { decodeTrackTable, type ListTrack } from '@utils/track-table';
|
|
import { Events } from '../events';
|
|
|
|
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
|
|
|
|
type Subscriber = () => void;
|
|
|
|
/** Minimum album card width in CSS pixels. */
|
|
const COVER_SIZE_MIN = 100;
|
|
|
|
/** Maximum album card width in CSS pixels. */
|
|
const COVER_SIZE_MAX = 350;
|
|
|
|
/** Default album card width in CSS pixels. */
|
|
const COVER_SIZE_DEFAULT = 176;
|
|
|
|
/** localStorage key for persisted cover size. */
|
|
const COVER_SIZE_KEY = 'cover-grid-size';
|
|
|
|
/**
|
|
* How long the small-collection warm-up will wait for idle before
|
|
* running anyway. It is speculative, but a busy main thread must not
|
|
* mean Albums is slow to open — the timeout is the promise that it is
|
|
* only ever deferred, never skipped.
|
|
*/
|
|
const WARM_IDLE_TIMEOUT_MS = 3_000;
|
|
|
|
class LibraryStore {
|
|
private tracks: ListTrack[] | null = null;
|
|
private albums: library.Album[] | null = null;
|
|
private artists: library.Artist[] | null = null;
|
|
private genres: library.GenreWithCount[] | null = null;
|
|
private libraries: library.Info[] | null = null;
|
|
|
|
private selectedLibraryIdValue: number | null = null;
|
|
|
|
private tracksLoading = false;
|
|
private albumsLoading = false;
|
|
private artistsLoading = false;
|
|
private genresLoading = false;
|
|
|
|
private coverSizeValue: number = COVER_SIZE_DEFAULT;
|
|
|
|
private scrollPositions: Record<ViewName, number> = {
|
|
tracks: 0,
|
|
albums: 0,
|
|
artists: 0,
|
|
genres: 0,
|
|
};
|
|
|
|
private subscribers = new Set<Subscriber>();
|
|
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
|
|
* (not loading flag transitions). Subscribers can compare against
|
|
* a saved value to skip requestUpdate when only loading state toggled.
|
|
*/
|
|
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() {
|
|
EventsOn(Events.LibraryScanComplete, () => {
|
|
this.invalidate();
|
|
});
|
|
EventsOn(Events.LibraryRemoved, () => {
|
|
this.libraries = null;
|
|
this.invalidate();
|
|
});
|
|
EventsOn(Events.LibraryAdded, () => {
|
|
this.libraries = null;
|
|
this.changeGen++;
|
|
this.notify();
|
|
});
|
|
EventsOn(Events.LibraryRenamed, () => {
|
|
this.libraries = null;
|
|
this.changeGen++;
|
|
this.notify();
|
|
});
|
|
EventsOn(Events.TrackMetadataChanged, () => {
|
|
this.invalidate();
|
|
});
|
|
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
|
|
this.applyPlayCount(payload);
|
|
});
|
|
EventsOn(Events.TracksRemovedFromLibrary, (payload: unknown) => {
|
|
this.applyTracksRemoved(payload);
|
|
});
|
|
|
|
this.loadCoverSize();
|
|
this.warmSmallCollectionsOnIdle();
|
|
}
|
|
|
|
/**
|
|
* Warm the three small collections once the app is idle after first
|
|
* paint, and deliberately not the tracks (#280).
|
|
*
|
|
* All four used to be fetched together at `DOMContentLoaded`,
|
|
* whichever view was showing. On a 26 138-track library the track
|
|
* list was 20.5 MB of that, and encoding it cost the backend ~170 MB
|
|
* of transient allocation — paid by someone looking at Home, which
|
|
* needs none of it. Measured on 50 000 tracks: 543 MB of backend RSS
|
|
* at rest before, 296 MB after, and 12.1 MB of binding bytes instead
|
|
* of 35.9.
|
|
*
|
|
* Albums, artists and genres are 1.6 MB together, so they are still
|
|
* fetched ahead of the click — that is what made those views
|
|
* instant. Tracks are 12 MB at 50 000 and are fetched by the view
|
|
* that draws them, or by `prefetch` from a hover.
|
|
*
|
|
* Idle rather than immediate: this is speculative, so it must not
|
|
* compete with the first paint.
|
|
*/
|
|
private warmSmallCollectionsOnIdle(): void {
|
|
const warm = () => {
|
|
const logged = this.failureReporter();
|
|
|
|
void this.getAlbums().catch(logged('albums'));
|
|
void this.getArtists().catch(logged('artists'));
|
|
void this.getGenres().catch(logged('genres'));
|
|
};
|
|
|
|
if (typeof requestIdleCallback === 'function') {
|
|
requestIdleCallback(warm, { timeout: WARM_IDLE_TIMEOUT_MS });
|
|
} else {
|
|
setTimeout(warm, 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start loading what a view will need, without waiting for it.
|
|
*
|
|
* A nav item calls this on hover or focus: that is the ~100 ms
|
|
* before the click, and it is what #280 trades for not paying for
|
|
* every collection at startup whether or not anyone goes there.
|
|
*
|
|
* A view this store holds nothing for is ignored rather than an
|
|
* error — it is a hint, and a hint about Home is not a mistake.
|
|
* The failure is swallowed here because the view that wanted the
|
|
* data reports it: this is the same request, already deduplicated
|
|
* by `inFlight`, and it has no caller to reject to.
|
|
*/
|
|
prefetch(view: string): void {
|
|
const started = (() => {
|
|
switch (view) {
|
|
case 'tracks':
|
|
return this.getTracks();
|
|
case 'albums':
|
|
return this.getAlbums();
|
|
case 'artists':
|
|
return this.getArtists();
|
|
case 'genres':
|
|
return this.getGenres();
|
|
default:
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
void started?.catch(() => undefined);
|
|
}
|
|
|
|
private failureReporter(): (what: string) => (err: unknown) => void {
|
|
return (what) => (err) =>
|
|
console.error(`library: could not load ${what}`, err);
|
|
}
|
|
|
|
// ===================================================================
|
|
// DATA ACCESS
|
|
// Returns cached data or fetches from backend on first access.
|
|
// ===================================================================
|
|
|
|
/** Synchronous access to cached artists (null if not yet loaded). */
|
|
get cachedArtists(): library.Artist[] | null {
|
|
return this.artists;
|
|
}
|
|
|
|
/** Synchronous access to cached albums (null if not yet loaded). */
|
|
get cachedAlbums(): library.Album[] | null {
|
|
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<ListTrack[]> {
|
|
if (this.tracks !== null) {
|
|
return this.tracks;
|
|
}
|
|
|
|
const pending = this.pending<ListTrack[]>('tracks');
|
|
|
|
if (pending) return pending;
|
|
|
|
return this.track(
|
|
'tracks',
|
|
GetTrackTable(this.libraryFilter()).then(decodeTrackTable),
|
|
(tracks) => {
|
|
this.tracks = tracks;
|
|
},
|
|
() => this.getTracks(),
|
|
);
|
|
}
|
|
|
|
async getAlbums(): Promise<library.Album[]> {
|
|
if (this.albums !== null) {
|
|
return this.albums;
|
|
}
|
|
|
|
const pending = this.pending<library.Album[]>('albums');
|
|
|
|
if (pending) return pending;
|
|
|
|
return this.track(
|
|
'albums',
|
|
list(GetAlbums(this.libraryFilter())),
|
|
(albums) => {
|
|
this.albums = albums;
|
|
},
|
|
() => this.getAlbums(),
|
|
);
|
|
}
|
|
|
|
async getArtists(): Promise<library.Artist[]> {
|
|
if (this.artists !== null) {
|
|
return this.artists;
|
|
}
|
|
|
|
const pending = this.pending<library.Artist[]>('artists');
|
|
|
|
if (pending) return pending;
|
|
|
|
return this.track(
|
|
'artists',
|
|
list(GetArtists(this.libraryFilter())),
|
|
(artists) => {
|
|
this.artists = artists;
|
|
},
|
|
() => this.getArtists(),
|
|
);
|
|
}
|
|
|
|
async getGenres(): Promise<library.GenreWithCount[]> {
|
|
if (this.genres !== null) {
|
|
return this.genres;
|
|
}
|
|
|
|
const pending = this.pending<library.GenreWithCount[]>('genres');
|
|
|
|
if (pending) return pending;
|
|
|
|
return this.track(
|
|
'genres',
|
|
list(GetGenres(this.libraryFilter())),
|
|
(genres) => {
|
|
this.genres = genres;
|
|
},
|
|
() => this.getGenres(),
|
|
);
|
|
}
|
|
|
|
async getAlbumsByArtist(
|
|
artist: string,
|
|
): Promise<library.Album[]> {
|
|
return list(GetAlbumsByArtist(artist, this.libraryFilter()));
|
|
}
|
|
|
|
/**
|
|
* The library id every backend query takes, where 0 means "all of
|
|
* them".
|
|
*
|
|
* Each of these used to be two bindings and a branch here, because
|
|
* the backend had a scoped and an unscoped query for every list.
|
|
* One query answers both now, so the branch is a `?? 0`.
|
|
*/
|
|
libraryFilter(): number {
|
|
return this.selectedLibraryIdValue ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Returns albums filtered by artist name from
|
|
* the in-memory cache, or null if the cache is
|
|
* not populated. This provides an instant
|
|
* result when the all-albums list has already
|
|
* been loaded (e.g. the user visited the albums
|
|
* view first).
|
|
*
|
|
* Returns null when a library filter is active
|
|
* because the cached albums are already filtered
|
|
* by library — the client-side ArtistName match
|
|
* is correct but forcing a backend query ensures
|
|
* consistency.
|
|
*/
|
|
getAlbumsByArtistNameCached(
|
|
artistName: string,
|
|
): library.Album[] | null {
|
|
if (this.selectedLibraryIdValue !== null) {
|
|
return null;
|
|
}
|
|
|
|
if (this.albums === null) return null;
|
|
|
|
return this.albums.filter(
|
|
(a) => a.ArtistName === artistName,
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// STATE ACCESSORS
|
|
// Synchronous access for controllers that need current cached values.
|
|
// ===================================================================
|
|
|
|
getCachedTracks(): ListTrack[] | null {
|
|
return this.tracks;
|
|
}
|
|
|
|
getCachedAlbums(): library.Album[] | null {
|
|
return this.albums;
|
|
}
|
|
|
|
isTracksLoading(): boolean {
|
|
return this.tracksLoading;
|
|
}
|
|
|
|
isAlbumsLoading(): boolean {
|
|
return this.albumsLoading;
|
|
}
|
|
|
|
getCachedArtists(): library.Artist[] | null {
|
|
return this.artists;
|
|
}
|
|
|
|
isArtistsLoading(): boolean {
|
|
return this.artistsLoading;
|
|
}
|
|
|
|
getCachedGenres(): library.GenreWithCount[] | null {
|
|
return this.genres;
|
|
}
|
|
|
|
isGenresLoading(): boolean {
|
|
return this.genresLoading;
|
|
}
|
|
|
|
/**
|
|
* Monotonic counter that increments only when cached data
|
|
* actually changes (tracks, albums, artists, genres, coverSize,
|
|
* or invalidation). Loading flag transitions do NOT increment.
|
|
* Controllers can compare against a saved value to skip
|
|
* unnecessary requestUpdate() calls.
|
|
*/
|
|
get changeGeneration(): number {
|
|
return this.changeGen;
|
|
}
|
|
|
|
// ===================================================================
|
|
// LIBRARY FILTER
|
|
// ===================================================================
|
|
|
|
getSelectedLibraryId(): number | null {
|
|
return this.selectedLibraryIdValue;
|
|
}
|
|
|
|
setSelectedLibrary(id: number | null): void {
|
|
if (id === this.selectedLibraryIdValue) return;
|
|
|
|
this.selectedLibraryIdValue = id;
|
|
this.invalidate();
|
|
}
|
|
|
|
async getLibraries(): Promise<library.Info[]> {
|
|
if (this.libraries !== null) {
|
|
return this.libraries;
|
|
}
|
|
|
|
const libs = await list(GetAllLibrariesWithTrackCounts());
|
|
|
|
this.libraries = libs;
|
|
|
|
return libs;
|
|
}
|
|
|
|
/**
|
|
* Resolves a library id to attach a download/want to: the selected
|
|
* filter if one is set, otherwise the first known library. Callers
|
|
* that need a library id (rather than "all libraries", which is
|
|
* what `getSelectedLibraryId()` returning null means for browsing)
|
|
* should use this instead of defaulting to 0 — id 0 never exists
|
|
* and trips the library_id foreign key on download_requests /
|
|
* download_wants.
|
|
*/
|
|
async getDefaultLibraryId(): Promise<number | null> {
|
|
if (this.selectedLibraryIdValue !== null) {
|
|
return this.selectedLibraryIdValue;
|
|
}
|
|
|
|
const libraries = await this.getLibraries();
|
|
|
|
return libraries[0]?.id ?? null;
|
|
}
|
|
|
|
// ===================================================================
|
|
// SCROLL POSITION
|
|
// ===================================================================
|
|
|
|
getScrollPosition(view: ViewName): number {
|
|
return this.scrollPositions[view];
|
|
}
|
|
|
|
setScrollPosition(view: ViewName, offset: number): void {
|
|
this.scrollPositions[view] = offset;
|
|
}
|
|
|
|
// ===================================================================
|
|
// COVER SIZE
|
|
// ===================================================================
|
|
|
|
getCoverSize(): number {
|
|
return this.coverSizeValue;
|
|
}
|
|
|
|
setCoverSize(size: number): void {
|
|
const clamped = Math.round(
|
|
Math.max(COVER_SIZE_MIN, Math.min(COVER_SIZE_MAX, size)),
|
|
);
|
|
|
|
if (clamped === this.coverSizeValue) return;
|
|
|
|
this.coverSizeValue = clamped;
|
|
this.changeGen++;
|
|
this.saveCoverSize();
|
|
this.notify();
|
|
}
|
|
|
|
private loadCoverSize(): void {
|
|
try {
|
|
const stored = localStorage.getItem(COVER_SIZE_KEY);
|
|
|
|
if (stored !== null) {
|
|
const parsed = parseInt(stored, 10);
|
|
|
|
if (!Number.isNaN(parsed)) {
|
|
this.coverSizeValue = Math.max(
|
|
COVER_SIZE_MIN,
|
|
Math.min(COVER_SIZE_MAX, parsed),
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
// localStorage may be unavailable.
|
|
}
|
|
}
|
|
|
|
private saveCoverSize(): void {
|
|
try {
|
|
localStorage.setItem(
|
|
COVER_SIZE_KEY,
|
|
String(this.coverSizeValue),
|
|
);
|
|
} catch {
|
|
// localStorage may be unavailable.
|
|
}
|
|
}
|
|
|
|
// ===================================================================
|
|
// 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;
|
|
|
|
// The list does not carry LastPlayed (#281); trackCache patches
|
|
// it on the whole tracks the details dialog reads.
|
|
const p = payload as {
|
|
filePath?: string;
|
|
playCount?: number;
|
|
} | 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: ListTrack = {
|
|
...existing,
|
|
PlayCount: p.playCount ?? existing.PlayCount,
|
|
};
|
|
|
|
this.tracks = [
|
|
...this.tracks.slice(0, idx),
|
|
patched,
|
|
...this.tracks.slice(idx + 1),
|
|
];
|
|
|
|
this.changeGen++;
|
|
this.notify();
|
|
}
|
|
|
|
/**
|
|
* Splice removed tracks out in place, and refetch only the
|
|
* summaries whose counts changed.
|
|
*
|
|
* `invalidate()` would be correct and is the expensive answer: it
|
|
* nulls `tracks` and eagerly refetches it, which is ~37 MB across
|
|
* the IPC at 50 000 tracks for an operation that removed three
|
|
* rows. The event carries the paths precisely so this does not have
|
|
* to happen — the same bargain `TrackPlayCountChanged` makes.
|
|
*
|
|
* The album, artist and genre lists really do change (their track
|
|
* counts, and the row itself when its last track goes), so they are
|
|
* dropped and refetched. They are the small collections.
|
|
*/
|
|
private applyTracksRemoved(payload: unknown): void {
|
|
const p = payload as { filePaths?: string[] } | null;
|
|
const removed = p?.filePaths;
|
|
|
|
if (!removed || removed.length === 0) return;
|
|
|
|
// A tracks fetch already in flight would land holding the rows
|
|
// that were just deleted, and it captured the cache generation
|
|
// this patch is about to leave behind. There is no patch that
|
|
// is equivalent to that, so fall back.
|
|
if (this.inFlight.has('tracks')) {
|
|
this.invalidate();
|
|
|
|
return;
|
|
}
|
|
|
|
if (this.tracks !== null) {
|
|
const gone = new Set(removed);
|
|
const kept = this.tracks.filter((t) => !gone.has(t.FilePath));
|
|
|
|
// A new array identity even when nothing matched would
|
|
// invalidate every memoized filter/sort cache keyed on it
|
|
// for no reason.
|
|
if (kept.length !== this.tracks.length) {
|
|
this.tracks = kept;
|
|
}
|
|
}
|
|
|
|
const loaded = this.loadedCollections();
|
|
|
|
this.albums = null;
|
|
this.artists = null;
|
|
this.genres = null;
|
|
// Bumping the cache generation is what stops an album fetch
|
|
// issued before the removal from committing its pre-removal
|
|
// answer. Safe for the tracks slot precisely because the guard
|
|
// above established there is nothing in flight for it.
|
|
this.cacheGen++;
|
|
this.inFlight.delete('albums');
|
|
this.inFlight.delete('artists');
|
|
this.inFlight.delete('genres');
|
|
|
|
this.changeGen++;
|
|
this.notify();
|
|
|
|
// Only the summaries something was showing (#280): a removal
|
|
// nobody was looking at does not load a collection to correct it.
|
|
this.refetchLoaded({
|
|
albums: loaded.albums,
|
|
artists: loaded.artists,
|
|
genres: loaded.genres,
|
|
});
|
|
}
|
|
|
|
private invalidate(): void {
|
|
// Which collections something has actually loaded, before they
|
|
// are dropped. Refetching all four here would undo #280: a scan
|
|
// finishing would fetch the track list of a library nobody has
|
|
// opened the Tracks view on.
|
|
const loaded = this.loadedCollections();
|
|
|
|
this.tracks = null;
|
|
this.albums = null;
|
|
this.artists = null;
|
|
this.genres = null;
|
|
// Anything still in flight was asked for on behalf of a
|
|
// selection that no longer applies: forget it, so the 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.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
|
this.notify();
|
|
this.refetchLoaded(loaded);
|
|
}
|
|
|
|
/** The collections currently held, keyed by the view that draws them. */
|
|
private loadedCollections(): Partial<Record<ViewName, boolean>> {
|
|
return {
|
|
tracks: this.tracks !== null,
|
|
albums: this.albums !== null,
|
|
artists: this.artists !== null,
|
|
genres: this.genres !== null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refetch exactly the collections named, and nothing else.
|
|
*
|
|
* A failed fetch is reported by whichever view asked for the data
|
|
* (it is that panel's failure, not the app's), but this refetch has
|
|
* no caller to reject to — without a catch it is an unhandled
|
|
* rejection.
|
|
*/
|
|
private refetchLoaded(loaded: Partial<Record<ViewName, boolean>>): void {
|
|
const logged = this.failureReporter();
|
|
|
|
if (loaded.tracks) void this.getTracks().catch(logged('tracks'));
|
|
if (loaded.albums) void this.getAlbums().catch(logged('albums'));
|
|
if (loaded.artists) void this.getArtists().catch(logged('artists'));
|
|
if (loaded.genres) void this.getGenres().catch(logged('genres'));
|
|
}
|
|
|
|
// ===================================================================
|
|
// SUBSCRIPTION SYSTEM
|
|
// ===================================================================
|
|
|
|
subscribe(callback: Subscriber): () => void {
|
|
this.subscribers.add(callback);
|
|
|
|
return () => this.subscribers.delete(callback);
|
|
}
|
|
|
|
private notify(): void {
|
|
if (this.notifyScheduled) return;
|
|
this.notifyScheduled = true;
|
|
queueMicrotask(() => {
|
|
this.notifyScheduled = false;
|
|
this.subscribers.forEach((callback) => callback());
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
// Singleton instance.
|
|
export const libraryStore = new LibraryStore();
|