feat(lyrics): make lyrics fully disablable (independent YouLyPlus toggle) (#855)

* feat(lyrics): independent YouLyPlus toggle + all-sources-off state

Replace the binary lyricsMode ('standard' | 'lyricsplus') with an
independent youLyPlusEnabled flag so YouLyPlus and the standard sources
are no longer mutually exclusive — turning one off no longer forces the
other on. YouLyPlus (when on) is tried first with the enabled sources as
fallback; off uses only the enabled sources. When YouLyPlus is off and no
source is enabled, useLyrics fetches nothing (issue #810).

Fresh installs ship with every source off; the rehydrate migration only
restores the old on-by-default set for genuine upgrades, not new installs.

* feat(lyrics): YouLyPlus toggle UI + queue 'no sources' hint

Settings: single YouLyPlus toggle replacing the two mutually exclusive
mode switches; the source list is always visible with a context hint
(fallback vs primary). Queue lyric tab shows a hint when no source is
active. en + de strings; other locales fall back to en.

* docs(changelog): lyrics fully disablable (#855)
This commit is contained in:
Frank Stellmacher
2026-05-22 21:06:04 +02:00
committed by GitHub
parent cb4d331f99
commit 02b2df1589
15 changed files with 145 additions and 66 deletions
+2 -2
View File
@@ -15,14 +15,14 @@ export function createLyricsSettingsActions(set: SetState): Pick<
| 'setLyricsServerFirst'
| 'setEnableNeteaselyrics'
| 'setLyricsSources'
| 'setLyricsMode'
| 'setYouLyPlusEnabled'
| 'setLyricsStaticOnly'
> {
return {
setLyricsServerFirst: (v) => set({ lyricsServerFirst: v }),
setEnableNeteaselyrics: (v) => set({ enableNeteaselyrics: v }),
setLyricsSources: (sources) => set({ lyricsSources: sources }),
setLyricsMode: (v) => set({ lyricsMode: v }),
setYouLyPlusEnabled: (v) => set({ youLyPlusEnabled: v }),
setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }),
};
}
+5 -3
View File
@@ -294,9 +294,11 @@ describe('lyrics source setters', () => {
expect(useAuthStore.getState().lyricsSources).toEqual(sources);
});
it('setLyricsMode + setFsLyricsStyle + setSidebarLyricsStyle write enum values through', () => {
useAuthStore.getState().setLyricsMode('lyricsplus');
expect(useAuthStore.getState().lyricsMode).toBe('lyricsplus');
it('setYouLyPlusEnabled + setFsLyricsStyle + setSidebarLyricsStyle write values through', () => {
useAuthStore.getState().setYouLyPlusEnabled(true);
expect(useAuthStore.getState().youLyPlusEnabled).toBe(true);
useAuthStore.getState().setYouLyPlusEnabled(false);
expect(useAuthStore.getState().youLyPlusEnabled).toBe(false);
useAuthStore.getState().setFsLyricsStyle('apple');
expect(useAuthStore.getState().fsLyricsStyle).toBe('apple');
+1 -1
View File
@@ -79,7 +79,7 @@ export const useAuthStore = create<AuthState>()(
lyricsServerFirst: true,
enableNeteaselyrics: false,
lyricsSources: DEFAULT_LYRICS_SOURCES,
lyricsMode: 'standard',
youLyPlusEnabled: false,
lyricsStaticOnly: false,
showFullscreenLyrics: true,
fsLyricsStyle: 'rail',
+5 -2
View File
@@ -28,9 +28,12 @@ export const DEFAULT_TRACK_PREVIEW_LOCATIONS: TrackPreviewLocations = {
randomMix: true,
};
// Fresh installs ship with every lyrics source off (issue #810 — users who
// don't want lyrics get none until they opt in). Existing users keep their
// persisted `lyricsSources`; the rehydrate migration preserves them.
export const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
{ id: 'server', enabled: true },
{ id: 'lrclib', enabled: true },
{ id: 'server', enabled: false },
{ id: 'lrclib', enabled: false },
{ id: 'netease', enabled: false },
];
+36
View File
@@ -40,3 +40,39 @@ describe('computeAuthStoreRehydration — queueDurationDisplayMode', () => {
},
);
});
describe('computeAuthStoreRehydration — lyrics', () => {
beforeEach(() => {
resetAuthStore();
localStorage.clear();
});
it('migrates legacy lyricsMode "lyricsplus" → youLyPlusEnabled true', () => {
const base = useAuthStore.getState();
const patch = computeAuthStoreRehydration({ ...base, lyricsMode: 'lyricsplus' } as AuthState);
expect(patch.youLyPlusEnabled).toBe(true);
});
it('migrates legacy lyricsMode "standard" → youLyPlusEnabled false', () => {
const base = useAuthStore.getState();
const patch = computeAuthStoreRehydration({ ...base, lyricsMode: 'standard' } as AuthState);
expect(patch.youLyPlusEnabled).toBe(false);
});
it('fresh install (no persisted state) keeps every source off — issue #810', () => {
localStorage.removeItem('psysonic-auth');
const patch = computeAuthStoreRehydration(useAuthStore.getState());
// No migration: the all-off default must survive.
expect(patch.lyricsSources).toBeUndefined();
});
it('upgrade from a build without lyricsSources migrates the old on-by-default set', () => {
localStorage.setItem('psysonic-auth', JSON.stringify({ state: { lyricsServerFirst: true } }));
const patch = computeAuthStoreRehydration(useAuthStore.getState());
expect(patch.lyricsSources).toEqual([
{ id: 'server', enabled: true },
{ id: 'lrclib', enabled: true },
{ id: 'netease', enabled: false },
]);
});
});
+15 -1
View File
@@ -40,10 +40,13 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
: {};
// Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time).
// Only for an *existing* persisted state (upgrade from a build without
// lyricsSources). Fresh installs have no persisted state → keep the
// all-off default (issue #810); don't resurrect the old on-by-default set.
let lyricsSourcesMigrated: { lyricsSources?: LyricsSourceConfig[] } = {};
try {
const raw = JSON.parse(localStorage.getItem('psysonic-auth') ?? '{}') as { state?: Record<string, unknown> };
if (!raw?.state?.lyricsSources) {
if (raw?.state && !raw.state.lyricsSources) {
const serverFirst = (raw?.state?.lyricsServerFirst as boolean | undefined) ?? true;
const neteaseOn = (raw?.state?.enableNeteaselyrics as boolean | undefined) ?? false;
const migrated: LyricsSourceConfig[] = serverFirst
@@ -53,6 +56,16 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
}
} catch { /* ignore */ }
// Migrate legacy `lyricsMode` ('standard' | 'lyricsplus') → `youLyPlusEnabled`
// (one-time). Existing users keep YouLyPlus on iff they were on lyricsplus
// mode; the legacy field is then stripped so it doesn't sit as cruft.
let youLyPlusMigrated: { youLyPlusEnabled?: boolean } = {};
const legacyLyricsMode = (state as { lyricsMode?: unknown }).lyricsMode;
if (legacyLyricsMode === 'lyricsplus' || legacyLyricsMode === 'standard') {
youLyPlusMigrated = { youLyPlusEnabled: legacyLyricsMode === 'lyricsplus' };
}
delete (state as { lyricsMode?: unknown }).lyricsMode;
// One-time: older builds could persist smooth=false as the default. Force smooth on once
// so updates do not leave users on discrete scrolling; after this flag exists, only an
// explicit toggle in Settings may turn it off (persisted in psysonic-auth).
@@ -145,6 +158,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
loudnessPreIsRefV1: true,
...conflictingLegacyState,
...lyricsSourcesMigrated,
...youLyPlusMigrated,
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...queueDurationDisplayModeMigrated,
+6 -5
View File
@@ -123,11 +123,12 @@ export interface AuthState {
enableNeteaselyrics: boolean;
lyricsSources: LyricsSourceConfig[];
/**
* `'standard'` → server + lrclib + netease pipeline (configurable order).
* `'lyricsplus'` → YouLyPlus / lyricsplus first, silent fallback to standard
* pipeline when no data is returned.
* YouLyPlus (karaoke) as the primary lyrics source. When on, it is tried
* first and the enabled `lyricsSources` act as fallback; when off, only the
* enabled `lyricsSources` are used. Independent of the source toggles, so all
* lyrics can be turned off (YouLyPlus off + every source off).
*/
lyricsMode: 'standard' | 'lyricsplus';
youLyPlusEnabled: boolean;
/**
* Render synced lines as static text (no auto-scroll, no word highlighting).
* Honoured in both lyrics modes.
@@ -294,7 +295,7 @@ export interface AuthState {
setLyricsServerFirst: (v: boolean) => void;
setEnableNeteaselyrics: (v: boolean) => void;
setLyricsSources: (sources: LyricsSourceConfig[]) => void;
setLyricsMode: (v: 'standard' | 'lyricsplus') => void;
setYouLyPlusEnabled: (v: boolean) => void;
setLyricsStaticOnly: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setFsLyricsStyle: (v: 'rail' | 'apple') => void;