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:
2026-08-12 01:19:04 -04:00
parent 795f40acee
commit 7d9e0bf2fb
10 changed files with 677 additions and 188 deletions
@@ -1,5 +1,9 @@
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';
/**
@@ -66,6 +70,11 @@ export class PlayerController implements ReactiveController {
return this.state.muted;
}
/** The backend's last position report, or null before the first. */
get position(): PositionInfo | null {
return this.state.position;
}
// ===================================================================
// ACTIONS
// Delegate to store (which delegates to backend)
+51 -27
View File
@@ -10,9 +10,27 @@
* album detail page → check cache before API calls
*/
import type { explore } from '@go/models';
type MBReleaseGroup = explore.MBReleaseGroup;
type LBTopRecording = explore.LBTopRecording;
import { registerCacheProbe } from '../utils/cache-stats';
import { LRUMap } from '../utils/lru-map';
/**
* 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. */
export interface CachedArtist {
@@ -33,10 +51,8 @@ export interface CachedAlbum {
}
class ExploreCacheStore {
private artists = new Map<string, CachedArtist>();
private albums = new Map<string, CachedAlbum>();
private artistAlbums = new Map<string, MBReleaseGroup[]>();
private artistTopTracks = new Map<string, LBTopRecording[]>();
private artists = new LRUMap<string, CachedArtist>(ARTIST_IMAGE_CACHE_LIMIT);
private albums = new LRUMap<string, CachedAlbum>(ALBUM_CACHE_LIMIT);
// -- Artists --
@@ -58,26 +74,6 @@ class ExploreCacheStore {
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 --
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();
registerCacheProbe('exploreCache.artists', () => exploreCache.stats().artists);
registerCacheProbe('exploreCache.albums', () => exploreCache.stats().albums);
+28 -3
View File
@@ -15,9 +15,28 @@ import {
SetPinDefaultPlaylist,
} from '@go/config/Config';
import { Events } from '../events';
import { describeError } from '@utils/describe-error';
import { notificationStore } from './notification-store';
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 {
playlistId: number;
playlistName: string;
@@ -152,7 +171,7 @@ class FavoritesStore {
try {
await ToggleDefaultPlaylistTrack(filePath);
} catch {
} catch (err) {
// Revert optimistic update.
if (wasIn) {
this.favoritedPaths.add(filePath);
@@ -161,6 +180,10 @@ class FavoritesStore {
}
this.notify();
reportRevert(
wasIn ? 'remove that favourite' : 'save that favourite',
err,
);
}
}
@@ -175,8 +198,9 @@ class FavoritesStore {
try {
await AddToDefaultPlaylist(filePaths);
} catch {
} catch (err) {
void this.loadPaths();
reportRevert('save those favourites', err);
}
}
@@ -191,8 +215,9 @@ class FavoritesStore {
try {
await RemoveFromDefaultPlaylist(filePaths);
} catch {
} catch (err) {
void this.loadPaths();
reportRevert('remove those favourites', err);
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export type JobState =
| 'error';
/** 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;
+206 -124
View File
@@ -57,6 +57,17 @@ class LibraryStore {
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
@@ -64,6 +75,15 @@ class LibraryStore {
*/
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();
@@ -85,6 +105,9 @@ class LibraryStore {
EventsOn(Events.TrackMetadataChanged, () => {
this.invalidate();
});
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
this.applyPlayCount(payload);
});
this.loadCoverSize();
this.deferEagerFetch();
@@ -130,32 +153,87 @@ class LibraryStore {
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[]> {
if (this.tracks !== null) {
return this.tracks;
}
if (this.tracksLoading) {
return this.waitForTracks();
}
const pending = this.pending<library.Track[]>('tracks');
this.tracksLoading = true;
this.notify();
if (pending) return pending;
try {
const id = this.selectedLibraryIdValue;
const tracks = id !== null
? await GetAllTracksByLibrary(id)
: await GetAllTracks();
const id = this.selectedLibraryIdValue;
this.tracks = tracks;
this.changeGen++;
return tracks;
} finally {
this.tracksLoading = false;
this.notify();
}
return this.track(
'tracks',
id !== null ? GetAllTracksByLibrary(id) : GetAllTracks(),
(tracks) => {
this.tracks = tracks;
},
() => this.getTracks(),
);
}
async getAlbums(): Promise<library.Album[]> {
@@ -163,27 +241,20 @@ class LibraryStore {
return this.albums;
}
if (this.albumsLoading) {
return this.waitForAlbums();
}
const pending = this.pending<library.Album[]>('albums');
this.albumsLoading = true;
this.notify();
if (pending) return pending;
try {
const id = this.selectedLibraryIdValue;
const albums = id !== null
? await GetAllAlbumsByLibrary(id)
: await GetAllAlbums();
const id = this.selectedLibraryIdValue;
this.albums = albums;
this.changeGen++;
return albums;
} finally {
this.albumsLoading = false;
this.notify();
}
return this.track(
'albums',
id !== null ? GetAllAlbumsByLibrary(id) : GetAllAlbums(),
(albums) => {
this.albums = albums;
},
() => this.getAlbums(),
);
}
async getArtists(): Promise<library.Artist[]> {
@@ -191,27 +262,20 @@ class LibraryStore {
return this.artists;
}
if (this.artistsLoading) {
return this.waitForArtists();
}
const pending = this.pending<library.Artist[]>('artists');
this.artistsLoading = true;
this.notify();
if (pending) return pending;
try {
const id = this.selectedLibraryIdValue;
const artists = id !== null
? await GetAllArtistsByLibrary(id)
: await GetAllArtists();
const id = this.selectedLibraryIdValue;
this.artists = artists;
this.changeGen++;
return artists;
} finally {
this.artistsLoading = false;
this.notify();
}
return this.track(
'artists',
id !== null ? GetAllArtistsByLibrary(id) : GetAllArtists(),
(artists) => {
this.artists = artists;
},
() => this.getArtists(),
);
}
async getGenres(): Promise<library.GenreWithCount[]> {
@@ -219,27 +283,22 @@ class LibraryStore {
return this.genres;
}
if (this.genresLoading) {
return this.waitForGenres();
}
const pending = this.pending<library.GenreWithCount[]>('genres');
this.genresLoading = true;
this.notify();
if (pending) return pending;
try {
const id = this.selectedLibraryIdValue;
const genres = id !== null
? await GetAllGenresWithCountsByLibrary(id)
: await GetAllGenresWithCounts();
const id = this.selectedLibraryIdValue;
this.genres = genres;
this.changeGen++;
return genres;
} finally {
this.genresLoading = false;
this.notify();
}
return this.track(
'genres',
id !== null
? GetAllGenresWithCountsByLibrary(id)
: GetAllGenresWithCounts(),
(genres) => {
this.genres = genres;
},
() => this.getGenres(),
);
}
async getAlbumsByArtist(
@@ -442,11 +501,75 @@ class LibraryStore {
// 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 {
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 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.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
this.notify();
@@ -461,10 +584,17 @@ class LibraryStore {
* needing their own LibraryScanComplete listener.
*/
private eagerFetch(): void {
void this.getTracks();
void this.getAlbums();
void this.getArtists();
void this.getGenres();
// A failed fetch is reported by whichever view asked for the
// data (it is that panel's failure, not the app's), but the
// eager refetch has no caller to reject to — without a catch it
// 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.
+82 -8
View File
@@ -1,6 +1,10 @@
import { EventsOn } from '@runtime/runtime';
import { Events } from '../events';
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.
// Fields are serialized as camelCase JSON via struct tags.
@@ -23,6 +27,26 @@ export interface TrackInfo {
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 {
// Cached from backend
isPlaying: boolean;
@@ -30,19 +54,30 @@ export interface PlayerState {
volume: number; // 0-100
muted: boolean; // silenced independently of the volume level
// Frontend-only state (for future use)
// selectedTrackIds: Set<number>;
// isQueuePanelOpen: boolean;
// The backend's position report. The seek bar renders this and
// interpolates only between reports; it does not count.
position: PositionInfo | null;
}
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 {
private state: PlayerState = {
isPlaying: false,
currentTrack: null,
volume: 50,
muted: false,
position: null,
};
private subscribers = new Set<Subscriber>();
@@ -66,6 +101,41 @@ class PlayerStore {
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, () => {
this.update({ isPlaying: false });
// Queue auto-advance is handled by the backend queue package.
@@ -94,23 +164,27 @@ class PlayerStore {
// ===================================================================
pause(): void {
Player.Pause();
void Player.Pause().catch(reportBindingFailure('Player.Pause'));
}
loadTrack(filePath: string): void {
Player.LoadFile(filePath);
void Player.LoadFile(filePath).catch(
reportBindingFailure('Player.LoadFile'),
);
}
seek(seconds: number): void {
Player.Seek(seconds);
void Player.Seek(seconds).catch(reportBindingFailure('Player.Seek'));
}
setVolume(level: number): void {
Player.SetVolume(level);
void Player.SetVolume(level).catch(
reportBindingFailure('Player.SetVolume'),
);
}
toggleMute(): void {
void Player.MuteToggle();
void Player.MuteToggle().catch(reportBindingFailure('Player.MuteToggle'));
}
// ===================================================================
+150 -7
View File
@@ -1,5 +1,9 @@
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 { Events } from '../events';
@@ -10,6 +14,7 @@ class PlaylistStore {
private playlistsLoading = false;
private scrollPosition = 0;
private subscribers = new Set<Subscriber>();
private notifyScheduled = false;
constructor() {
EventsOn(Events.LibraryScanComplete, () => {
@@ -28,15 +33,29 @@ class PlaylistStore {
this.invalidate();
});
EventsOn(Events.PlaylistTracksChanged, () => {
this.invalidate();
});
// `PlaylistTracksChanged` carries the playlist that changed,
// 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, () => {
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.scrollPosition = 0;
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);
}
/**
* 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 {
this.subscribers.forEach((callback) => callback());
if (this.notifyScheduled) return;
this.notifyScheduled = true;
queueMicrotask(() => {
this.notifyScheduled = false;
this.subscribers.forEach((callback) => callback());
});
}
// ===================================================================
+58 -16
View File
@@ -47,6 +47,16 @@ interface TracksModified {
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 {
private state: QueueState = {
tracks: [],
@@ -190,15 +200,21 @@ class QueueStore {
// ===================================================================
play(): void {
Queue.Play();
void Queue.Play().catch(
reportBindingFailure('Queue.Play'),
);
}
next(): void {
Queue.Next();
void Queue.Next().catch(
reportBindingFailure('Queue.Next'),
);
}
previous(): void {
Queue.Previous();
void Queue.Previous().catch(
reportBindingFailure('Queue.Previous'),
);
}
setQueue(
@@ -206,61 +222,87 @@ class QueueStore {
startIndex: number,
shuffleStart = false,
): void {
Queue.SetQueue(filePaths, startIndex, shuffleStart);
void Queue.SetQueue(filePaths, startIndex, shuffleStart).catch(
reportBindingFailure('Queue.SetQueue'),
);
}
addToQueue(filePath: string): void {
Queue.AddTrack(filePath);
void Queue.AddTrack(filePath).catch(
reportBindingFailure('Queue.AddTrack'),
);
}
playNext(filePath: string): void {
Queue.InsertNext(filePath);
void Queue.InsertNext(filePath).catch(
reportBindingFailure('Queue.InsertNext'),
);
}
removeFromQueue(position: number): void {
Queue.RemoveTrack(position);
void Queue.RemoveTrack(position).catch(
reportBindingFailure('Queue.RemoveTrack'),
);
}
removeTracksFromQueue(positions: number[]): void {
Queue.RemoveTracks(positions);
void Queue.RemoveTracks(positions).catch(
reportBindingFailure('Queue.RemoveTracks'),
);
}
addTracksToQueue(filePaths: string[]): void {
Queue.AddTracks(filePaths);
void Queue.AddTracks(filePaths).catch(
reportBindingFailure('Queue.AddTracks'),
);
}
playTracksNext(filePaths: string[]): void {
Queue.InsertNextTracks(filePaths);
void Queue.InsertNextTracks(filePaths).catch(
reportBindingFailure('Queue.InsertNextTracks'),
);
}
toggleShuffle(): void {
Queue.ToggleShuffle();
void Queue.ToggleShuffle().catch(
reportBindingFailure('Queue.ToggleShuffle'),
);
}
cycleRepeat(): void {
Queue.CycleRepeat();
void Queue.CycleRepeat().catch(
reportBindingFailure('Queue.CycleRepeat'),
);
}
playAtIndex(index: number): void {
Queue.PlayIndex(index);
void Queue.PlayIndex(index).catch(
reportBindingFailure('Queue.PlayIndex'),
);
}
insertTracksAtIndex(
filePaths: string[],
index: number,
): void {
Queue.InsertTracksAt(filePaths, index);
void Queue.InsertTracksAt(filePaths, index).catch(
reportBindingFailure('Queue.InsertTracksAt'),
);
}
moveTracksInQueue(
fromIndices: number[],
toIndex: number,
): void {
Queue.MoveQueueTracks(fromIndices, toIndex);
void Queue.MoveQueueTracks(fromIndices, toIndex).catch(
reportBindingFailure('Queue.MoveQueueTracks'),
);
}
clearQueue(): void {
Queue.Clear();
void Queue.Clear().catch(
reportBindingFailure('Queue.Clear'),
);
}
// ===================================================================
+13
View File
@@ -52,6 +52,19 @@ class SearchStore {
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 {
this.subscribers.forEach((callback) => callback());
}
+78 -1
View File
@@ -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(): void {
if (this._selectedItems.size === 0) return;
@@ -135,6 +146,40 @@ export class SelectionController implements ReactiveController {
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. */
selectAll(): void {
const count = this.host.getItemCount();
@@ -145,7 +190,12 @@ export class SelectionController implements ReactiveController {
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.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
* 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[] {
const wanted = this._selectedItems.size;
if (wanted === 0) return [];
const count = this.host.getItemCount();
const result: string[] = [];
@@ -166,6 +234,7 @@ export class SelectionController implements ReactiveController {
if (key !== undefined && this._selectedItems.has(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.
*
* Same shape, same reasoning, same early exit as
* `getSelectedKeysOrdered()` above.
*/
getSelectedIndices(): number[] {
const wanted = this._selectedItems.size;
if (wanted === 0) return [];
const count = this.host.getItemCount();
const result: number[] = [];
@@ -184,6 +260,7 @@ export class SelectionController implements ReactiveController {
if (key !== undefined && this._selectedItems.has(key)) {
result.push(i);
if (result.length === wanted) break;
}
}