Skip to content

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:

  1. Increments _epochSeq so stale resolver responses from previous calls are ignored.
  2. Sets searchResolving = true.
  3. Calls engine.query(query, enabledProviders) which returns a SearchSession.
  4. Watches session.results with watch(session.results, _applyFromSession).
  5. _applyFromSession calls registerEntities(session.results.value) and copies searchEntities, 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:

export const currentView = ref<ViewName>("search");
export const navHistory = ref<ViewName[]>([]);

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.