Frontend Stores¶
The frontend uses a module-level singleton pattern — no Pinia, no Vuex. Each store is a plain TypeScript module that exports Vue reactive primitives (ref, shallowRef, computed). Components import exactly the symbols they need.
Sources: src/stores/
entities.ts — global entity registry¶
The entity registry is the single source of truth for every Track and Album the app has ever seen. Providers emit data; the registry converts it to class instances and notifies consumers.
const _byId = shallowRef<Map<string, Entity>>(new Map());
export const entitiesVersion = shallowRef(0);
shallowRef wraps a Map. Vue's reactivity doesn't track Map mutations natively — so every write calls triggerRef(_byId) and increments entitiesVersion. Consumers that call getTrack(id) inside a computed must also read entitiesVersion.value to subscribe to the invalidation signal.
Registration¶
registerEntity(entity) normalizes the input (plain data → class instance, via buildTrack / buildAlbum), merges persisted fields from trackCache, and writes to the map. For tracks, it also calls putTrack (writes to trackCache) and recordGeneralList (appends to general-list.json debug registry).
registerEntities(entities[]) is the batch version — one bump() call for the whole batch.
Later copies win. Provider re-emits (new snapshot from the same session) replace existing entries. This means a higher-score or merged-source version of the same entity wins on subsequent flush cycles.
Accessors¶
getEntity(id) → Entity | null
getTrack(id) → Track | null
getAlbum(id) → Album | null
getTracksOfAlbum(alb) → Track[] // in trackIds order
allEntities() → Entity[] // expensive copy
queue.ts — playback queue¶
The queue stores only track IDs. Metadata (title, URL, cover) is resolved through the entities registry at read time. This avoids duplicating data and means catalog enrichment (e.g., a cover loaded after the track was queued) automatically appears.
PlaybackQueue class¶
class PlaybackQueue {
readonly ids: Ref<string[]>;
readonly pos: Ref<number>;
readonly repeatMode: Ref<RepeatMode>; // "off" | "all" | "one"
readonly shuffleOn: Ref<boolean>;
readonly nowPlaying: ComputedRef<Track | null>;
readonly next: ComputedRef<Track | null>; // respects repeatMode
readonly secondNext: ComputedRef<Track | null>; // used for warm prefetch
readonly hasPrev: ComputedRef<boolean>;
readonly hasNext: ComputedRef<boolean>;
readonly tracks: ComputedRef<Track[]>; // full queue as Track[]
readonly suppressAutoplay: Ref<boolean>; // blocks HTML autoplay on restore
}
nowPlaying, next, and secondNext all read entitiesVersion.value as a dep so they re-compute when the registry changes (e.g., cover art arrives for the current track).
suppressAutoplay is set during seedFromSnapshot (cold-start restore) so the audio element doesn't start playing automatically when src is assigned from the saved queue. It's cleared when the user clicks play (allowAutoplay()).
Repeat and shuffle¶
repeatMode and shuffleOn are persisted in localStorage (neegde.player.repeatMode, neegde.player.shuffle). hasPrev and hasNext account for repeatMode = "all" — the queue wraps around.
A shuffle implementation is planned but not yet in place; shuffleOn is tracked for UI state.
Persistence¶
Every mutation calls saveQueueSnapshot which writes { ids, pos } to localStorage. On app start, seedFromSnapshot re-populates the queue and calls hydrateTrack for each ID (reads from trackCache) so the queue survives app restarts without a new search.
search.ts — search store¶
The search store owns the SearchEngine singleton and bridges it to reactive Vue state that components consume.
export const searchEntities = shallowRef<Entity[]>([]);
export const searchLoadingRt = ref<boolean>(false);
export const searchLoadingSlsk = ref<boolean>(false);
export const searchRtError = ref<string | null>(null);
export const searchSlskError = ref<string | null>(null);
export const searchError = ref<string | null>(null);
export const searchResultsEpoch = ref<number>(0); // for Results.vue infinite-scroll reset
export const searchResolving = ref<boolean>(false);
export const searchResolved = shallowRef<unknown>(null); // resolver ResolveResult
export const searchProviderQuery = ref<string>("");
runSearch(query)¶
The main entry point:
- Increments
_epochSeqso stale resolver responses from previous calls are ignored. - Sets
searchResolving = true. - Calls
engine.query(query, enabledProviders)which returns aSearchSession. - Watches
session.resultswithwatch(session.results, _applyFromSession). _applyFromSessioncallsregisterEntities(session.results.value)and copiessearchEntities,searchLoadingRt,searchLoadingSlsk, and error fields from the session.
When the session is replaced by a new query, _detach() stops the watcher and clears the session reference.
Error handling¶
After both providers complete:
- 0 entities + no auth (not logged in to either) → searchError = null (no noise)
- 0 entities + provider error → show the error message
- 0 entities + no error → "Ничего не найдено."
library.ts — likes and playlists¶
The library store wraps two class-backed singletons: LikesCollection and Library.
Likes¶
export const likes = new LikesCollection(saveLikesSnapshot);
export const likedTrackIds = likes.trackIds; // Ref<Set<string>>
export const likedAlbumIds = likes.albumIds; // Ref<Set<string>>
export const likedAt = likes.likedAt; // Ref<Map<string, timestamp>>
likedTracks and likedAlbums are computed accessors that sort by likedAt timestamp (most recent first) and resolve IDs through the entities registry.
isTrackLiked(id) / isAlbumLiked(id) — O(1) set lookup.
Every mutation syncs to localStorage via saveLikesSnapshot.
Playlists¶
The Library class owns an ordered list of Playlist objects. Each playlist stores track IDs and a name. Like the queue, playlist entries resolve through the entities registry at read time.
auth.ts — connection status¶
Reactive login/connection state for both providers:
export const rtLoggedIn = ref<boolean>(false);
export const rtUsername = ref<string | null>(null);
export const rtAvatarUrl = ref<string | null>(null);
export const slskConnected = ref<boolean>(false);
export const slskUsername = ref<string | null>(null);
These are set by the auth command handlers (login/logout/restore-session responses) and read by AppAuthPanel, SearchBar, and the search store.
view.ts — navigation state¶
Current visible view and navigation history:
Used by App.vue to render the correct component and by NavArrows for back/forward navigation.
Reactivity invariant¶
All stores use shallowRef (not ref) for objects and maps. shallowRef does not make the object's internals reactive — only reassignment of .value triggers watchers. This is intentional: entity objects are large and deeply nested, and making them deeply reactive would cause excessive re-renders. The entitiesVersion counter is the explicit signal that drives re-computation.