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')}
-
-
- { if (e.target.checked) setLyricsMode('standard'); else setLyricsMode('lyricsplus'); }}
+ checked={youLyPlusEnabled}
+ onChange={e => setYouLyPlusEnabled(e.target.checked)}
/>
- {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;