diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 364422ff0..17ca1d0bb 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -383,6 +383,14 @@ export const queryKeys: Record< lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => { return ['song', 'lyrics', 'remote', searchQuery] as const; }, + remoteLyrics: (serverId: string, query?: LyricsQuery) => { + if (query) return [serverId, 'song', 'lyrics', 'remote', query] as const; + return [serverId, 'song', 'lyrics', 'remote'] as const; + }, + serverLyrics: (serverId: string, query?: LyricsQuery) => { + if (query) return [serverId, 'song', 'lyrics', 'server', query] as const; + return [serverId, 'song', 'lyrics', 'server'] as const; + }, lyricsSearch: (query?: LyricSearchQuery) => { if (query) return ['lyrics', 'search', query] as const; return ['lyrics', 'search'] as const; diff --git a/src/renderer/features/lyrics/api/lyrics-api.ts b/src/renderer/features/lyrics/api/lyrics-api.ts index a67bc6f21..778cf89c9 100644 --- a/src/renderer/features/lyrics/api/lyrics-api.ts +++ b/src/renderer/features/lyrics/api/lyrics-api.ts @@ -3,7 +3,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { queryClient, QueryHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSettingsStore } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; import { @@ -14,7 +14,6 @@ import { LyricSearchQuery, LyricsQuery, QueueSong, - ServerType, StructuredLyric, SynchronizedLyricsArray, } from '/@/shared/types/domain-types'; @@ -65,6 +64,34 @@ const formatLyrics = (lyrics: string) => { }; export const lyricsQueries = { + remoteLyrics: (args: QueryHookArgs, song: QueueSong | undefined) => { + return queryOptions({ + gcTime: Infinity, + queryFn: async (): Promise => { + if (!song) return null; + + const { fetch } = useSettingsStore.getState().lyrics; + + if (!fetch) return null; + + const remoteLyricsResult: InternetProviderLyricResponse | null = + await lyricsIpc?.getRemoteLyricsBySong(song); + + if (remoteLyricsResult) { + return { + ...remoteLyricsResult, + lyrics: formatLyrics(remoteLyricsResult.lyrics), + remote: true, + }; + } + + return null; + }, + queryKey: queryKeys.songs.remoteLyrics(args.serverId, args.query), + staleTime: Infinity, + ...args.options, + }); + }, search: (args: Omit, 'serverId'>) => { return queryOptions({ gcTime: 1000 * 60 * 1, @@ -79,34 +106,15 @@ export const lyricsQueries = { ...args.options, }); }, - serverLyrics: (args: QueryHookArgs) => { - return queryOptions({ - queryFn: ({ signal }) => { - const server = getServerById(args.serverId); - if (!server) throw new Error('Server not found'); - // This should only be called for Jellyfin. Return null to ignore errors - if (server.type !== ServerType.JELLYFIN) return null; - return api.controller.getLyrics({ - apiClientProps: { serverId: args.serverId, signal }, - query: args.query, - }); - }, - queryKey: queryKeys.songs.lyrics(args.serverId, args.query), - ...args.options, - }); - }, - songLyrics: (args: QueryHookArgs, song: QueueSong | undefined) => { + serverLyrics: (args: QueryHookArgs, song: QueueSong | undefined) => { return queryOptions({ gcTime: Infinity, queryFn: async ({ signal }): Promise => { - const server = getServerById(song?._serverId); + const server = getServerById(args.serverId); if (!server) throw new Error('Server not found'); if (!song) return null; - const { preferLocalLyrics } = useSettingsStore.getState().lyrics; - let localLyrics: FullLyricsMetadata | null | StructuredLyric[] = null; - let remoteLyrics: FullLyricsMetadata | null | StructuredLyric[] = null; if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) { const subsonicLyrics = await api.controller @@ -146,34 +154,33 @@ export const lyricsQueries = { }; } - if (preferLocalLyrics && localLyrics) { - return localLyrics; - } + return localLyrics; + }, + queryKey: queryKeys.songs.serverLyrics(args.serverId, args.query), + staleTime: Infinity, + ...args.options, + }); + }, + songLyrics: (args: QueryHookArgs, song: QueueSong | undefined) => { + return queryOptions({ + gcTime: Infinity, + queryFn: async ({ signal }): Promise => { + if (!song) return null; - const { fetch } = useSettingsStore.getState().lyrics; + const serverLyricsQuery = lyricsQueries.serverLyrics(args, song); + const serverLyrics = await queryClient.fetchQuery({ + ...serverLyricsQuery, + queryFn: async (context) => { + return ( + serverLyricsQuery.queryFn?.({ + ...context, + signal, + }) ?? null + ); + }, + }); - if (fetch) { - const remoteLyricsResult: InternetProviderLyricResponse | null = - await lyricsIpc?.getRemoteLyricsBySong(song); - - if (remoteLyricsResult) { - remoteLyrics = { - ...remoteLyricsResult, - lyrics: formatLyrics(remoteLyricsResult.lyrics), - remote: true, - }; - } - } - - if (remoteLyrics) { - return remoteLyrics; - } - - if (localLyrics) { - return localLyrics; - } - - return null; + return serverLyrics; }, queryKey: queryKeys.songs.lyrics(args.serverId, args.query), staleTime: Infinity, @@ -182,6 +189,7 @@ export const lyricsQueries = { }, songLyricsByRemoteId: (args: QueryHookArgs>) => { return queryOptions({ + gcTime: Infinity, queryFn: async () => { const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId( args.query as any, @@ -194,6 +202,7 @@ export const lyricsQueries = { return null; }, queryKey: queryKeys.songs.lyricsByRemoteId(args.query), + staleTime: Infinity, ...args.options, }); }, diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index 3f8a77017..21570bba9 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -62,7 +62,12 @@ export const LyricsActions = ({ value={index.toString()} /> )} - diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index 5fc62d2a1..fa34cec8f 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -22,7 +22,7 @@ import { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open- import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary'; import { queryClient } from '/@/renderer/lib/react-query'; -import { useLyricsSettings, usePlayerSong } from '/@/renderer/store'; +import { useLyricsSettings, usePlayerSong, useSettingsStore } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Center } from '/@/shared/components/center/center'; import { Group } from '/@/shared/components/group/group'; @@ -44,6 +44,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' const currentSong = usePlayerSong(); const { enableAutoTranslation, + preferLocalLyrics, translationApiKey, translationApiProvider, translationTargetLanguage, @@ -98,11 +99,68 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' ); const [override, setOverride] = useState(undefined); + const [autoRemoteLyrics, setAutoRemoteLyrics] = useState(null); + const clearedOverrideRef = useRef(undefined); + + // Fetch remote lyrics automatically (but not if we've explicitly cleared the override) + const { data: remoteLyricsData } = useQuery( + lyricsQueries.remoteLyrics( + { + options: { + enabled: + !!pendingSongId && + pendingSongId === currentSong?.id && + !override && + clearedOverrideRef.current !== currentSong?.id && + useSettingsStore.getState().lyrics.fetch, + }, + query: { songId: currentSong?.id || '' }, + serverId: currentSong?._serverId || '', + }, + currentSong, + ), + ); + + // Automatically set remote lyrics as override when fetched + // Only auto-apply if preferLocalLyrics is disabled OR if local lyrics are not available + useEffect(() => { + // Don't auto-set if we've explicitly cleared the override for this song + if ( + remoteLyricsData && + !override && + currentSong && + clearedOverrideRef.current !== currentSong.id + ) { + // If preferLocalLyrics is enabled, wait for local lyrics to finish loading + if (preferLocalLyrics && isInitialLoading) { + return; + } + + // Check if local lyrics are available + const hasLocalLyrics = + (Array.isArray(data) && data.length > 0) || + (data && !Array.isArray(data) && data.lyrics); + + // Only auto-apply remote lyrics if: + // 1. preferLocalLyrics is disabled, OR + // 2. preferLocalLyrics is enabled but no local lyrics are available + if (!preferLocalLyrics || !hasLocalLyrics) { + // Store the remote lyrics data directly (it already contains the lyrics) + setAutoRemoteLyrics(remoteLyricsData); + } else { + // Clear auto remote lyrics if local lyrics are preferred and available + setAutoRemoteLyrics(null); + } + } else if (!remoteLyricsData || override) { + // Clear auto remote lyrics if override is set or remote lyrics are cleared + setAutoRemoteLyrics(null); + } + }, [remoteLyricsData, override, currentSong, preferLocalLyrics, data, isInitialLoading]); const { data: overrideData, isInitialLoading: isOverrideLoading } = useQuery( lyricsQueries.songLyricsByRemoteId({ options: { - enabled: !!override, + enabled: !!override && !!override.id, }, query: { remoteSongId: override?.id, @@ -127,8 +185,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }, [data, index]); const [lyrics, synced] = useMemo(() => { - // If override data is available, use it - if (override && overrideData) { + // If override data is available, use it (manual override always takes priority) + if (override && overrideData && override.id) { const overrideLyrics: FullLyricsMetadata = { artist: override.artist, lyrics: overrideData, @@ -137,21 +195,42 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' remote: override.remote ?? true, source: override.source, }; - return [overrideLyrics, Array.isArray(overrideData)]; + return [overrideLyrics, Array.isArray(overrideData), 'override'] as const; } - // Otherwise, use the regular data + // Check if local lyrics are available + const hasLocalLyrics = + (Array.isArray(data) && data.length > 0) || + (data && !Array.isArray(data) && data.lyrics); + + // If preferLocalLyrics is enabled and local lyrics are available, prioritize them + if (preferLocalLyrics && hasLocalLyrics) { + if (Array.isArray(data)) { + const selectedLyric = data[Math.min(index, data.length - 1)]; + return [selectedLyric, selectedLyric.synced, 'server'] as const; + } else if (data?.lyrics) { + return [data, Array.isArray(data.lyrics), 'server'] as const; + } + } + + // If auto-fetched remote lyrics are available, use them + // (This will only be set if preferLocalLyrics is disabled OR no local lyrics exist) + if (autoRemoteLyrics) { + return [autoRemoteLyrics, Array.isArray(autoRemoteLyrics.lyrics), 'override'] as const; + } + + // Otherwise, use the server-side lyrics data if (Array.isArray(data)) { if (data.length > 0) { const selectedLyric = data[Math.min(index, data.length - 1)]; - return [selectedLyric, selectedLyric.synced]; + return [selectedLyric, selectedLyric.synced, 'server'] as const; } } else if (data?.lyrics) { - return [data, Array.isArray(data.lyrics)]; + return [data, Array.isArray(data.lyrics), 'server'] as const; } - return [undefined, false]; - }, [data, index, override, overrideData, currentOffsetMs]); + return [undefined, false, 'server'] as const; + }, [data, index, override, overrideData, autoRemoteLyrics, currentOffsetMs, preferLocalLyrics]); const handleOnSearchOverride = useCallback((params: LyricsOverride) => { setOverride(params); @@ -159,7 +238,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' // Persist override lyrics to cache with current offset useEffect(() => { - if (override && overrideData && currentSong) { + if (override && overrideData && currentSong && override.id) { const persistedLyrics: FullLyricsMetadata = { artist: override.artist, lyrics: overrideData, @@ -211,46 +290,58 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' [currentSong, index], ); - // const handleOnResetLyric = useCallback(() => { - // setOverride(undefined); - // queryClient.invalidateQueries({ - // exact: true, - // queryKey: queryKeys.songs.lyrics(currentSong?._serverId, { songId: currentSong?.id }), - // }); - // }, [currentSong?.id, currentSong?._serverId]); + const handleOnRemoveLyric = useCallback(async () => { + if (!currentSong) return; - const handleOnRemoveLyric = useCallback(() => { + const currentOverride = override; + clearedOverrideRef.current = currentSong.id; + + // Clear the override state and auto remote lyrics setOverride(undefined); - - // Clear the main lyrics query cache - queryClient.setQueryData( - queryKeys.songs.lyrics(currentSong?._serverId, { songId: currentSong?.id }), - (prev: FullLyricsMetadata | StructuredLyric[] | undefined) => { - if (!prev) { - return undefined; - } - - if (Array.isArray(prev)) { - return undefined; - } - - return { - ...prev, - lyrics: '', - }; - }, - ); + setAutoRemoteLyrics(null); // Clear the override query cache if it exists - if (override) { + if (currentOverride?.id) { queryClient.removeQueries({ queryKey: queryKeys.songs.lyricsByRemoteId({ - remoteSongId: override.id, - remoteSource: override.source, + remoteSongId: currentOverride.id, + remoteSource: currentOverride.source, }), }); } - }, [currentSong?.id, currentSong?._serverId, override]); + + // Clear the remote lyrics cache + queryClient.removeQueries({ + queryKey: queryKeys.songs.remoteLyrics(currentSong._serverId, { + songId: currentSong.id, + }), + }); + + // Clear the server lyrics cache so it refetches from server + const serverLyricsQueryKey = queryKeys.songs.serverLyrics(currentSong._serverId, { + songId: currentSong.id, + }); + queryClient.removeQueries({ + queryKey: serverLyricsQueryKey, + }); + + // Remove the main lyrics query cache + const lyricsQueryKey = queryKeys.songs.lyrics(currentSong._serverId, { + songId: currentSong.id, + }); + queryClient.removeQueries({ + exact: true, + queryKey: lyricsQueryKey, + }); + + // Refetch server lyrics first, then song lyrics to ensure fresh data from server + await queryClient.refetchQueries({ + queryKey: serverLyricsQueryKey, + }); + await queryClient.refetchQueries({ + queryKey: lyricsQueryKey, + }); + }, [currentSong, override]); const fetchTranslation = useCallback(async () => { if (!lyrics) return; @@ -279,6 +370,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' { onCurrentSongChange: () => { setOverride(undefined); + setAutoRemoteLyrics(null); + clearedOverrideRef.current = undefined; setIndex(0); setShowTranslation(false); setTranslatedLyrics(null);