lyrics support + lookup

This commit is contained in:
Boof2015
2026-07-08 23:16:10 -04:00
parent acc6bdf3eb
commit 6ccc73401c
19 changed files with 2478 additions and 29 deletions
+94
View File
@@ -0,0 +1,94 @@
// In-memory UI state for lyrics — one entry per track path, backed by the
// cache-first orchestrator (src/lyrics/lyrics.ts). The orchestrator already
// dedupes in-flight network work and persists to SQLite; this store adds the
// loading flag + last result the now-playing lyrics band renders, plus a small
// LRU cap so revisiting tracks stays instant without growing unbounded.
import { create } from 'zustand';
import { buildLyricsQuery, getLyricsForTrack } from '@/lyrics/lyrics';
import type { LyricsLookupResult } from '@/lyrics/types';
import type { Track } from '@/types/audio';
const MAX_ENTRIES = 64;
export interface LyricsUiEntry {
loading: boolean;
result: LyricsLookupResult | null;
}
interface LyricsStore {
onlineEnabled: boolean;
byPath: Record<string, LyricsUiEntry>;
loadForTrack: (track: Track | null, options?: { force?: boolean }) => Promise<void>;
setOnlineEnabled: (enabled: boolean) => void;
}
// Latest request id per path — guards against a stale response overwriting a
// newer one (e.g. rapid track changes reusing the same store entry).
const requestIds = new Map<string, number>();
let requestSeq = 0;
function pruneToLru(byPath: Record<string, LyricsUiEntry>): Record<string, LyricsUiEntry> {
const keys = Object.keys(byPath);
if (keys.length <= MAX_ENTRIES) return byPath;
const next = { ...byPath };
for (const key of keys.slice(0, keys.length - MAX_ENTRIES)) {
delete next[key];
requestIds.delete(key);
}
return next;
}
export const useLyricsStore = create<LyricsStore>((set, get) => ({
onlineEnabled: true,
byPath: {},
loadForTrack: async (track, options = {}) => {
if (!track?.path) return;
const path = track.path;
const force = Boolean(options.force);
const existing = get().byPath[path];
if (!force && existing && (existing.result || existing.loading)) return;
const requestId = ++requestSeq;
requestIds.set(path, requestId);
const query = buildLyricsQuery(track);
if (!query) {
set((state) => ({
byPath: pruneToLru({
...state.byPath,
[path]: { loading: false, result: { status: 'not_found', reason: 'embedded-missing' } },
}),
}));
return;
}
set((state) => ({
byPath: pruneToLru({
...state.byPath,
[path]: { loading: true, result: existing?.result ?? null },
}),
}));
let result: LyricsLookupResult;
try {
result = await getLyricsForTrack(query, { forceRefresh: force, onlineEnabled: get().onlineEnabled });
} catch (error) {
result = {
status: 'transient_error',
message: error instanceof Error ? error.message : 'Lyrics lookup failed.',
};
}
// Drop the response if a newer request for this path superseded it.
if (requestIds.get(path) !== requestId) return;
set((state) => ({
byPath: pruneToLru({ ...state.byPath, [path]: { loading: false, result } }),
}));
},
setOnlineEnabled: (enabled) => set({ onlineEnabled: enabled }),
}));
+15 -1
View File
@@ -12,6 +12,7 @@ const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
const INCLUDE_SINGLES_KEY = 'album_include_singles';
const SCOPE_MODE_KEY = 'scope_mode';
const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible';
const LYRICS_VISIBLE_KEY = 'lyrics_visible';
/** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope';
@@ -34,12 +35,15 @@ interface SettingsStore {
includeSingles: boolean;
scopeMode: ScopeMode;
scopeStageVisible: boolean;
/** Whether the now-playing top half shows lyrics instead of art/scope. */
lyricsVisible: boolean;
loaded: boolean;
load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
setIncludeSingles: (include: boolean) => Promise<void>;
setScopeMode: (mode: ScopeMode) => Promise<void>;
setScopeStageVisible: (visible: boolean) => Promise<void>;
setLyricsVisible: (visible: boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -47,22 +51,25 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
includeSingles: false,
scopeMode: 'spectrum',
scopeStageVisible: false,
lyricsVisible: false,
loaded: false,
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [grouping, includeSingles, scope, scopeStageVisible] = await Promise.all([
const [grouping, includeSingles, scope, scopeStageVisible, lyricsVisible] = await Promise.all([
getSetting(db, ARTIST_GROUPING_KEY),
getSetting(db, INCLUDE_SINGLES_KEY),
getSetting(db, SCOPE_MODE_KEY),
getSetting(db, SCOPE_STAGE_VISIBLE_KEY),
getSetting(db, LYRICS_VISIBLE_KEY),
]);
set({
artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles),
scopeMode: parseScopeMode(scope),
scopeStageVisible: parseBoolean(scopeStageVisible),
lyricsVisible: parseBoolean(lyricsVisible),
loaded: true,
});
},
@@ -94,4 +101,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
const db = await openLibraryDb();
await setSetting(db, SCOPE_STAGE_VISIBLE_KEY, visible ? 'true' : 'false');
},
setLyricsVisible: async (visible) => {
if (get().lyricsVisible === visible) return;
set({ lyricsVisible: visible });
const db = await openLibraryDb();
await setSetting(db, LYRICS_VISIBLE_KEY, visible ? 'true' : 'false');
},
}));