diff --git a/CHANGELOG.md b/CHANGELOG.md index 646da186..79cf5b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Lyrics — sources can be turned off entirely + +**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by sddania, PR [#855](https://github.com/Psychotoxical/psysonic/pull/855)** + +* YouLyPlus is now an independent toggle instead of an either/or with the standard sources, so lyrics can be switched off completely — turn off YouLyPlus and every source under **Settings → Lyrics**. With nothing selected, no lyrics are fetched or shown and the queue lyric tab says so. Fresh installs start with all sources off. + + + ## Fixed ### In-page browse — virtual scroll and cover-art priority diff --git a/src/components/LyricsPane.tsx b/src/components/LyricsPane.tsx index e93136fa..0ccc1185 100644 --- a/src/components/LyricsPane.tsx +++ b/src/components/LyricsPane.tsx @@ -23,10 +23,14 @@ export default function LyricsPane({ currentTrack }: Props) { const { t } = useTranslation(); const { syncedLines, wordLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack); - const { staticOnly, sidebarLyricsStyle } = useAuthStore(useShallow(s => ({ + const { staticOnly, sidebarLyricsStyle, youLyPlusEnabled, lyricsSources } = useAuthStore(useShallow(s => ({ staticOnly: s.lyricsStaticOnly, sidebarLyricsStyle: s.sidebarLyricsStyle, + youLyPlusEnabled: s.youLyPlusEnabled, + lyricsSources: s.lyricsSources, }))); + // Lyrics fully off: YouLyPlus off and no source enabled (issue #810). + const lyricsDisabled = !youLyPlusEnabled && !lyricsSources.some(s => s.enabled); const useWords = !staticOnly && wordLines !== null && wordLines.length > 0; const hasSynced = !staticOnly && !useWords && syncedLines !== null && syncedLines.length > 0; @@ -146,6 +150,14 @@ export default function LyricsPane({ currentTrack }: Props) { ); } + if (lyricsDisabled) { + return ( +
+

{t('player.lyricsNoSources')}

+
+ ); + } + const sourceLabel = source === 'server' ? t('player.lyricsSourceServer') : source === 'lrclib' diff --git a/src/components/settings/LyricsSourcesCustomizer.tsx b/src/components/settings/LyricsSourcesCustomizer.tsx index 1d69d432..a4dc21a5 100644 --- a/src/components/settings/LyricsSourcesCustomizer.tsx +++ b/src/components/settings/LyricsSourcesCustomizer.tsx @@ -36,13 +36,13 @@ export function LyricsSourcesCustomizer() { const { t } = useTranslation(); const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources)); const setLyricsSources = useAuthStore(s => s.setLyricsSources); - const lyricsMode = useAuthStore(s => s.lyricsMode); - const setLyricsMode = useAuthStore(s => s.setLyricsMode); + const youLyPlusEnabled = useAuthStore(s => s.youLyPlusEnabled); + const setYouLyPlusEnabled = useAuthStore(s => s.setYouLyPlusEnabled); const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly); const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly); const { isDragging: isPsyDragging } = useDragDrop(); - // useState (not useRef) so the listener-effect re-runs when the container - // gets unmounted/remounted by the {lyricsMode === 'standard'} wrapper. + // useState (not useRef) so the listener-effect re-binds if the container + // element is ever remounted. const [containerEl, setContainerEl] = useState(null); const [dropTarget, setDropTarget] = useState(null); const dropTargetRef = useRef(null); @@ -107,48 +107,35 @@ export function LyricsSourcesCustomizer() { {t('settings.lyricsSourcesDesc')}

- {/* Mode switch — standard three-provider pipeline vs. YouLyPlus karaoke. - YouLyPlus misses silently fall back to the standard pipeline. */} + {/* YouLyPlus (karaoke) — independent toggle. When on it is tried first and + the enabled sources below act as fallback; when off only those sources + are used. YouLyPlus off + every source off = lyrics fully disabled. */}
-
{t('settings.lyricsModeLyricsplus')}
-
{t('settings.lyricsModeLyricsplusDesc')}
+
{t('settings.lyricsYouLyPlus')}
+
{t('settings.lyricsYouLyPlusDesc')}
-
-
-
-
-
-
{t('settings.lyricsModeStandard')}
-
{t('settings.lyricsModeStandardDesc')}
-
-
- {lyricsMode === 'standard' && ( -
+
+ {youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')} +
+
{lyricsSources.map((src, i) => { const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]); const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before; @@ -173,7 +160,6 @@ export function LyricsSourcesCustomizer() { ); })}
- )} {/* Static-only toggle — suppresses line/word tracking in both modes. */}
diff --git a/src/hooks/useLyrics.ts b/src/hooks/useLyrics.ts index 6bf5876c..c4faf4e3 100644 --- a/src/hooks/useLyrics.ts +++ b/src/hooks/useLyrics.ts @@ -71,11 +71,13 @@ export interface UseLyricsResult { } export function useLyrics(currentTrack: Track | null): UseLyricsResult { - const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined; - const { lyricsSources, lyricsMode } = useAuthStore(useShallow(s => ({ + const { lyricsSources, youLyPlusEnabled } = useAuthStore(useShallow(s => ({ lyricsSources: s.lyricsSources, - lyricsMode: s.lyricsMode, + youLyPlusEnabled: s.youLyPlusEnabled, }))); + // Lyrics are fully off when YouLyPlus is off and no source is enabled. + const lyricsActive = youLyPlusEnabled || lyricsSources.some(s => s.enabled); + const cached = (currentTrack && lyricsActive) ? lyricsCache.get(currentTrack.id) : undefined; const [loading, setLoading] = useState(!cached && !!currentTrack); const [syncedLines, setSyncedLines] = useState(cached?.syncedLines ?? null); @@ -87,6 +89,19 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { useEffect(() => { if (!currentTrack) return; + // Lyrics fully disabled (YouLyPlus off + every source off): fetch nothing, + // show nothing — not even embedded/cache (issue #810). LyricsPane surfaces + // the "no sources selected" hint. + if (!lyricsActive) { + setSyncedLines(null); + setWordLines(null); + setPlainLyrics(null); + setSource(null); + setNotFound(false); + setLoading(false); + return; + } + const hit = lyricsCache.get(currentTrack.id); if (hit) { setSyncedLines(hit.syncedLines); @@ -200,7 +215,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { /** * lyricsplus (YouLyPlus). Silent miss → caller falls back to the standard - * pipeline. Only consumed when `lyricsMode === 'lyricsplus'`. + * pipeline. Only consumed when `youLyPlusEnabled`. */ const fetchLyricsPlusFn = async (): Promise => { try { @@ -258,7 +273,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { // Skip for 'lyricsplus' mode since the persisted entry might be from // the standard pipeline (no word-level sync) and the user explicitly // wants a fresh lyricsplus attempt. - if (lyricsMode !== 'lyricsplus') { + if (!youLyPlusEnabled) { const serverId = useAuthStore.getState().activeServerId ?? ''; const persisted = await getCachedLyrics(lyricsCacheKey(serverId, currentTrack.id)); if (cancelled) return; @@ -270,8 +285,8 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { } } - // YouLyPlus mode: try lyricsplus first, silent fallback to standard. - if (lyricsMode === 'lyricsplus') { + // YouLyPlus on: try lyricsplus first, silent fallback to enabled sources. + if (youLyPlusEnabled) { if (cancelled) return; if (await fetchLyricsPlusFn()) return; } @@ -288,7 +303,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { })(); return () => { cancelled = true; }; - }, [currentTrack?.id, lyricsSources, lyricsMode]); // eslint-disable-line react-hooks/exhaustive-deps + }, [currentTrack?.id, lyricsSources, youLyPlusEnabled]); // eslint-disable-line react-hooks/exhaustive-deps return { syncedLines, wordLines, plainLyrics, source, loading, notFound }; } diff --git a/src/locales/de/player.ts b/src/locales/de/player.ts index 368b19f7..0d951006 100644 --- a/src/locales/de/player.ts +++ b/src/locales/de/player.ts @@ -51,6 +51,7 @@ export const player = { fsLyricsToggle: 'Lyrics im Vollbild', lyricsLoading: 'Lyrics werden geladen…', lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden', + lyricsNoSources: 'Keine Lyric-Quellen ausgewählt — in Einstellungen → Lyrics aktivieren', lyricsSourceServer: 'Quelle: Server', lyricsSourceLrclib: 'Quelle: LRCLIB', lyricsSourceNetease: 'Quelle: Netease', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index d5cb9a05..21ab9f18 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -217,10 +217,10 @@ export const settings = { lyricsSourceServer: 'Server', lyricsSourceLrclib: 'LRCLIB', lyricsSourceNetease: 'Netease Cloud Music', - lyricsModeStandard: 'Standard', - lyricsModeStandardDesc: 'Drei Quellen mit frei wählbarer Reihenfolge: Server-Tags, LRCLIB, Netease.', - lyricsModeLyricsplus: 'YouLyPlus (Karaoke)', - lyricsModeLyricsplusDesc: 'Wort-für-Wort-Sync aus Apple Music, Spotify, Musixmatch & QQ (Community-Backend). Fällt still zurück auf die Standard-Quellen, wenn nichts gefunden wird.', + lyricsYouLyPlus: 'YouLyPlus (Karaoke)', + lyricsYouLyPlusDesc: 'Wort-für-Wort-Sync über ein Community-Backend (Apple Music, Spotify, Musixmatch, QQ). Wird zuerst versucht; die aktivierten Quellen unten dienen als Fallback.', + lyricsSourcesFallbackHint: 'Dienen als Fallback, wenn YouLyPlus nichts findet. Zum Sortieren ziehen.', + lyricsSourcesPrimaryHint: 'Werden der Reihe nach genutzt. Zum Sortieren ziehen. Alle aus = Lyrics deaktiviert.', lyricsStaticOnly: 'Nur statische Lyrics anzeigen', lyricsStaticOnlyDesc: 'Synchronisierte Lyrics werden ohne Auto-Scroll und ohne Wort-Hervorhebung als statischer Text dargestellt.', downloadsTitle: 'ZIP-Export & Archivierung', diff --git a/src/locales/en/player.ts b/src/locales/en/player.ts index d5cd53c3..de49f770 100644 --- a/src/locales/en/player.ts +++ b/src/locales/en/player.ts @@ -51,6 +51,7 @@ export const player = { fsLyricsToggle: 'Lyrics in fullscreen', lyricsLoading: 'Loading lyrics…', lyricsNotFound: 'No lyrics found for this track', + lyricsNoSources: 'No lyrics sources selected — enable some in Settings → Lyrics', lyricsSourceServer: 'Source: Server', lyricsSourceLrclib: 'Source: LRCLIB', lyricsSourceNetease: 'Source: Netease', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index d498720e..2d3f29c8 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -220,10 +220,10 @@ export const settings = { lyricsSourceServer: 'Server', lyricsSourceLrclib: 'LRCLIB', lyricsSourceNetease: 'Netease Cloud Music', - lyricsModeStandard: 'Standard', - lyricsModeStandardDesc: 'Three sources in a freely orderable list: server tags, LRCLIB, Netease.', - lyricsModeLyricsplus: 'YouLyPlus (Karaoke)', - lyricsModeLyricsplusDesc: 'Word-by-word sync sourced from Apple Music, Spotify, Musixmatch & QQ (community backend). Silently falls back to the standard sources when nothing is found.', + lyricsYouLyPlus: 'YouLyPlus (Karaoke)', + lyricsYouLyPlusDesc: 'Word-by-word sync from a community backend (Apple Music, Spotify, Musixmatch, QQ). Tried first; the enabled sources below act as fallback.', + lyricsSourcesFallbackHint: 'Used as fallback when YouLyPlus finds nothing. Drag to reorder.', + lyricsSourcesPrimaryHint: 'Tried in order. Drag to reorder. All off = lyrics disabled.', lyricsStaticOnly: 'Show lyrics as static text only', lyricsStaticOnlyDesc: 'Render synced lyrics without auto-scroll and without word highlighting.', downloadsTitle: 'ZIP Export & Archiving', diff --git a/src/store/authLyricsSettingsActions.ts b/src/store/authLyricsSettingsActions.ts index 311d9d70..684da1da 100644 --- a/src/store/authLyricsSettingsActions.ts +++ b/src/store/authLyricsSettingsActions.ts @@ -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 }), }; } diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index 2219b416..afd6be6e 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -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'); diff --git a/src/store/authStore.ts b/src/store/authStore.ts index eadac5ca..a5f96e53 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -79,7 +79,7 @@ export const useAuthStore = create()( lyricsServerFirst: true, enableNeteaselyrics: false, lyricsSources: DEFAULT_LYRICS_SOURCES, - lyricsMode: 'standard', + youLyPlusEnabled: false, lyricsStaticOnly: false, showFullscreenLyrics: true, fsLyricsStyle: 'rail', diff --git a/src/store/authStoreDefaults.ts b/src/store/authStoreDefaults.ts index 0dc9f512..96034a65 100644 --- a/src/store/authStoreDefaults.ts +++ b/src/store/authStoreDefaults.ts @@ -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 }, ]; diff --git a/src/store/authStoreRehydrate.test.ts b/src/store/authStoreRehydrate.test.ts index 1ed7a929..10769b95 100644 --- a/src/store/authStoreRehydrate.test.ts +++ b/src/store/authStoreRehydrate.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index 2ba29aa5..4273d89b 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -40,10 +40,13 @@ export function computeAuthStoreRehydration(state: AuthState): Partial }; - 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 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;