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
+8
View File
@@ -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
+13 -1
View File
@@ -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 (
<div className="lyrics-pane-empty">
<p className="lyrics-status">{t('player.lyricsNoSources')}</p>
</div>
);
}
const sourceLabel = source === 'server'
? t('player.lyricsSourceServer')
: source === 'lrclib'
@@ -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<HTMLDivElement | null>(null);
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
const dropTargetRef = useRef<LyricsDropTarget>(null);
@@ -107,45 +107,32 @@ export function LyricsSourcesCustomizer() {
{t('settings.lyricsSourcesDesc')}
</p>
{/* 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. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeLyricsplus')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeLyricsplusDesc')}</div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsYouLyPlus')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsYouLyPlusDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsModeLyricsplus')}>
<label className="toggle-switch" aria-label={t('settings.lyricsYouLyPlus')}>
<input
type="checkbox"
checked={lyricsMode === 'lyricsplus'}
onChange={e => { if (e.target.checked) setLyricsMode('lyricsplus'); else setLyricsMode('standard'); }}
/>
<span className="toggle-track" />
</label>
</div>
</div>
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeStandard')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeStandardDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsModeStandard')}>
<input
type="checkbox"
checked={lyricsMode === 'standard'}
onChange={e => { if (e.target.checked) setLyricsMode('standard'); else setLyricsMode('lyricsplus'); }}
checked={youLyPlusEnabled}
onChange={e => setYouLyPlusEnabled(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</div>
{lyricsMode === 'standard' && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
</div>
<div
className="settings-card"
style={{ padding: '4px 0', marginBottom: '0.75rem', marginLeft: '1rem' }}
style={{ padding: '4px 0', marginBottom: '0.75rem' }}
ref={setContainerEl}
onMouseMove={handleMouseMove}
>
@@ -173,7 +160,6 @@ export function LyricsSourcesCustomizer() {
);
})}
</div>
)}
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
+23 -8
View File
@@ -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<LrcLine[] | null>(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<boolean> => {
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 };
}
+1
View File
@@ -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',
+4 -4
View File
@@ -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',
+1
View File
@@ -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',
+4 -4
View File
@@ -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',
+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;