diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index b22b59304..6c97d38bb 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -410,15 +410,7 @@ export const queryKeys: Record< if (query) return [serverId, 'songs', 'randomSongList', query] as const; return [serverId, 'songs', 'randomSongList'] as const; }, - remoteLyrics: (serverId: string, query?: LyricsQuery) => { - if (query) return [serverId, 'song', 'lyrics', 'remote', query] as const; - return [serverId, 'song', 'lyrics', 'remote'] as const; - }, root: (serverId: string) => [serverId, 'songs'] as const, - serverLyrics: (serverId: string, query?: LyricsQuery) => { - if (query) return [serverId, 'song', 'lyrics', 'server', query] as const; - return [serverId, 'song', 'lyrics', 'server'] as const; - }, similar: (serverId: string, query?: SimilarSongsQuery) => { if (query) return [serverId, 'song', 'similar', query] as const; return [serverId, 'song', 'similar'] as const; diff --git a/src/renderer/features/lyrics/api/lyrics-api.ts b/src/renderer/features/lyrics/api/lyrics-api.ts index 778cf89c9..4991ee1f6 100644 --- a/src/renderer/features/lyrics/api/lyrics-api.ts +++ b/src/renderer/features/lyrics/api/lyrics-api.ts @@ -12,16 +12,31 @@ import { InternetProviderLyricSearchResponse, LyricGetQuery, LyricSearchQuery, + LyricsOverride, LyricsQuery, QueueSong, + Song, StructuredLyric, SynchronizedLyricsArray, } from '/@/shared/types/domain-types'; import { LyricSource } from '/@/shared/types/domain-types'; +import { LyricsResponse } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; const lyricsIpc = isElectron() ? window.api.lyrics : null; +export type LyricsQueryResult = { + local: FullLyricsMetadata | null | StructuredLyric[]; + overrideData: LyricsResponse | null; + overrideSelection: LyricsOverride | null; + remoteAuto: FullLyricsMetadata | null; + selected: FullLyricsMetadata | null | StructuredLyric; + selectedOffsetMs: number; + selectedStructuredIndex: number; + selectedSynced: boolean; + suppressRemoteAuto: boolean; +}; + // Match LRC lyrics format by https://github.com/ustbhuangyi/lyric-parser // [mm:ss.SSS] text const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)(\n|$)/g; @@ -63,35 +78,177 @@ const formatLyrics = (lyrics: string) => { return lyrics; }; +export const formatLyricsForDisplay = formatLyrics; + +export function computeSelectedFromResult( + result: Pick< + LyricsQueryResult, + 'local' | 'overrideData' | 'overrideSelection' | 'remoteAuto' | 'selectedOffsetMs' + >, + preferLocalLyrics: boolean, + selectedStructuredIndex: number, +): { + selected: FullLyricsMetadata | null | StructuredLyric; + selectedSynced: boolean; +} { + const { local, overrideData, overrideSelection, remoteAuto, selectedOffsetMs } = result; + + // Override takes precedence over local and remote lyrics in all scenarios if available + if (overrideSelection && overrideData) { + const overrideLyrics: FullLyricsMetadata = { + artist: overrideSelection.artist, + lyrics: overrideData, + name: overrideSelection.name, + offsetMs: selectedOffsetMs, + remote: overrideSelection.remote ?? true, + source: overrideSelection.source, + }; + return { + selected: overrideLyrics, + selectedSynced: Array.isArray(overrideData), + }; + } + + const hasLocalLocal = + (Array.isArray(local) && local.length > 0) || + (local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics)); + + // If setting is set to prefer local lyrics, return the local lyrics if available + if (preferLocalLyrics && hasLocalLocal) { + if (Array.isArray(local) && local.length > 0) { + const item = local[Math.min(selectedStructuredIndex, local.length - 1)]; + return { selected: item, selectedSynced: item.synced }; + } + + if (local != null && !Array.isArray(local) && 'lyrics' in local && local.lyrics) { + return { selected: local, selectedSynced: Array.isArray(local.lyrics) }; + } + } + + // If remote lyrics are automatically fetched and available, return the remote auto lyrics + if (remoteAuto) { + return { + selected: remoteAuto, + selectedSynced: Array.isArray(remoteAuto.lyrics), + }; + } + + // Otherwise, we just return the local lyrics if available, using structured lyrics if available + if (Array.isArray(local) && local.length > 0) { + const item = local[Math.min(selectedStructuredIndex, local.length - 1)]; + return { selected: item, selectedSynced: item.synced }; + } + + if (local != null && !Array.isArray(local) && 'lyrics' in local && local.lyrics) { + return { selected: local, selectedSynced: Array.isArray(local.lyrics) }; + } + + // If no lyrics are available, return null + return { selected: null, selectedSynced: false }; +} + +export async function fetchLocalLyrics(params: { + serverId: string; + signal?: AbortSignal; + song: QueueSong; +}): Promise { + const { serverId, signal, song } = params; + const server = getServerById(serverId); + if (!server) throw new Error('Server not found'); + + if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) { + const subsonicLyrics = await api.controller + .getStructuredLyrics({ + apiClientProps: { serverId, signal }, + query: { songId: song.id }, + }) + .catch(console.error); + if (subsonicLyrics?.length) return subsonicLyrics; + } else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) { + const jfLyrics = await api.controller + .getLyrics({ + apiClientProps: { serverId, signal }, + query: { songId: song.id }, + }) + .catch((err) => console.error(err)); + if (jfLyrics) { + return { + artist: song.artists?.[0]?.name, + lyrics: jfLyrics, + name: song.name, + remote: false, + source: server?.name ?? 'music server', + }; + } + } else if (song.lyrics) { + return { + artist: song.artists?.[0]?.name, + lyrics: formatLyrics(song.lyrics), + name: song.name, + remote: false, + source: server?.name ?? 'music server', + }; + } + return null; +} + +export async function fetchRemoteLyricsAuto(song: QueueSong): Promise { + 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; +} + +export async function fetchRemoteLyricsById(params: { + remoteSongId: string; + remoteSource: LyricSource; + song?: QueueSong | Song; +}): Promise { + const result = await lyricsIpc?.getRemoteLyricsByRemoteId(params as LyricGetQuery); + if (result) return formatLyrics(result); + return null; +} + +export function getDisplayOffset( + selected: FullLyricsMetadata | null | StructuredLyric, + storedOffsetMs: number, + selectedStructuredIndex: number, + local: FullLyricsMetadata | null | StructuredLyric[], +): number { + if (selected && 'offsetMs' in selected && selected.offsetMs !== undefined) { + return selected.offsetMs; + } + + if (Array.isArray(local) && local.length > 0) { + const item = local[Math.min(selectedStructuredIndex, local.length - 1)]; + return item.offsetMs ?? storedOffsetMs; + } + + return storedOffsetMs; +} + +const emptyResult = (): LyricsQueryResult => ({ + local: null, + overrideData: null, + overrideSelection: null, + remoteAuto: null, + selected: null, + selectedOffsetMs: 0, + selectedStructuredIndex: 0, + selectedSynced: false, + suppressRemoteAuto: false, +}); + 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, @@ -106,83 +263,82 @@ export const lyricsQueries = { ...args.options, }); }, - serverLyrics: (args: QueryHookArgs, song: QueueSong | undefined) => { - return queryOptions({ - gcTime: Infinity, - queryFn: async ({ signal }): Promise => { - const server = getServerById(args.serverId); - if (!server) throw new Error('Server not found'); - if (!song) return null; - - let localLyrics: FullLyricsMetadata | null | StructuredLyric[] = null; - - if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) { - const subsonicLyrics = await api.controller - .getStructuredLyrics({ - apiClientProps: { serverId: args.serverId, signal }, - query: { songId: song.id }, - }) - .catch(console.error); - - if (subsonicLyrics?.length) { - localLyrics = subsonicLyrics; - } - } else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) { - const jfLyrics = await api.controller - .getLyrics({ - apiClientProps: { serverId: args.serverId, signal }, - query: { songId: song.id }, - }) - .catch((err) => console.error(err)); - - if (jfLyrics) { - localLyrics = { - artist: song.artists?.[0]?.name, - lyrics: jfLyrics, - name: song.name, - remote: false, - source: server?.name ?? 'music server', - }; - } - } else if (song.lyrics) { - localLyrics = { - artist: song.artists?.[0]?.name, - lyrics: formatLyrics(song.lyrics), - name: song.name, - remote: false, - source: server?.name ?? 'music server', - }; - } - - return localLyrics; - }, - queryKey: queryKeys.songs.serverLyrics(args.serverId, args.query), - staleTime: Infinity, - ...args.options, - }); - }, songLyrics: (args: QueryHookArgs, song: QueueSong | undefined) => { + const lyricsKey = queryKeys.songs.lyrics(args.serverId, args.query); return queryOptions({ gcTime: Infinity, - queryFn: async ({ signal }): Promise => { - if (!song) return null; + queryFn: async ({ signal }): Promise => { + if (!song) return emptyResult(); - const serverLyricsQuery = lyricsQueries.serverLyrics(args, song); - const serverLyrics = await queryClient.fetchQuery({ - ...serverLyricsQuery, - queryFn: async (context) => { - return ( - serverLyricsQuery.queryFn?.({ - ...context, - signal, - }) ?? null - ); - }, - }); + const prev = queryClient.getQueryData(lyricsKey); + const overrideSelection = prev?.overrideSelection ?? null; + const suppressRemoteAuto = prev?.suppressRemoteAuto ?? false; + const selectedStructuredIndex = prev?.selectedStructuredIndex ?? 0; + const selectedOffsetMs = prev?.selectedOffsetMs ?? 0; + const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics; - return serverLyrics; + // Fetch local lyrics + const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song }); + + // Fetch remote auto lyrics + const remoteAutoPromise = + suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch + ? null + : fetchRemoteLyricsAuto(song); + + // Fetch override data + const overrideDataPromise = overrideSelection + ? fetchRemoteLyricsById({ + remoteSongId: overrideSelection.id, + remoteSource: overrideSelection.source as LyricSource, + song, + }) + : null; + + const [local, remoteAuto, overrideData] = await Promise.all([ + localPromise, + remoteAutoPromise, + overrideDataPromise, + ]); + + const partial: Pick< + LyricsQueryResult, + | 'local' + | 'overrideData' + | 'overrideSelection' + | 'remoteAuto' + | 'selectedOffsetMs' + > = { + local, + overrideData, + overrideSelection, + remoteAuto, + selectedOffsetMs, + }; + const { selected, selectedSynced } = computeSelectedFromResult( + partial, + preferLocalLyrics, + selectedStructuredIndex, + ); + const displayOffset = getDisplayOffset( + selected, + selectedOffsetMs, + selectedStructuredIndex, + local, + ); + const resultSelectedOffsetMs = displayOffset; + + return { + ...emptyResult(), + ...partial, + selected, + selectedOffsetMs: resultSelectedOffsetMs, + selectedStructuredIndex, + selectedSynced, + suppressRemoteAuto, + }; }, - queryKey: queryKeys.songs.lyrics(args.serverId, args.query), + queryKey: lyricsKey, staleTime: Infinity, ...args.options, }); @@ -191,15 +347,13 @@ export const lyricsQueries = { return queryOptions({ gcTime: Infinity, queryFn: async () => { - const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId( - args.query as any, - ); - - if (remoteLyricsResult) { - return formatLyrics(remoteLyricsResult); - } - - return null; + const q = args.query; + if (!q?.remoteSongId || !q?.remoteSource) return null; + return fetchRemoteLyricsById({ + remoteSongId: q.remoteSongId, + remoteSource: q.remoteSource as LyricSource, + song: q.song as QueueSong | Song | undefined, + }); }, queryKey: queryKeys.songs.lyricsByRemoteId(args.query), staleTime: Infinity, diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.tsx b/src/renderer/features/lyrics/components/lyrics-search-form.tsx index 9adea447b..03bc7eb40 100644 --- a/src/renderer/features/lyrics/components/lyrics-search-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-search-form.tsx @@ -117,13 +117,13 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch const [debouncedArtist] = useDebouncedValue(form.values.artist, 500); const [debouncedName] = useDebouncedValue(form.values.name, 500); - const { data, isInitialLoading } = useQuery( + const { data, isLoading } = useQuery( lyricsQueries.search({ query: { artist: debouncedArtist, name: debouncedName }, }), ); - const { data: previewData, isInitialLoading: isPreviewLoading } = useQuery( + const { data: previewData, isLoading: isPreviewLoading } = useQuery( lyricsQueries.songLyricsByRemoteId({ options: { enabled: !!selectedResult, @@ -228,7 +228,7 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch paddingRight: '1rem', }} > - {isInitialLoading ? ( + {isLoading ? ( ) : ( diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index e6b96e3fe..baf4c3ddc 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -7,7 +7,12 @@ import styles from './lyrics.module.css'; import { queryKeys } from '/@/renderer/api/query-keys'; import { translateLyrics } from '/@/renderer/features/lyrics/api/lyric-translate'; -import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api'; +import { + computeSelectedFromResult, + getDisplayOffset, + lyricsQueries, + type LyricsQueryResult, +} from '/@/renderer/features/lyrics/api/lyrics-api'; import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form'; import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; import { @@ -22,18 +27,13 @@ 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, useSettingsStore } from '/@/renderer/store'; +import { useLyricsSettings, usePlayerSong } 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'; import { Spinner } from '/@/shared/components/spinner/spinner'; import { Text } from '/@/shared/components/text/text'; -import { - FullLyricsMetadata, - LyricSource, - LyricsOverride, - StructuredLyric, -} from '/@/shared/types/domain-types'; +import { LyricsOverride } from '/@/shared/types/domain-types'; type LyricsProps = { fadeOutNoLyricsMessage?: boolean; @@ -50,14 +50,13 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' translationTargetLanguage, } = useLyricsSettings(); const { t } = useTranslation(); - const [index, setIndex] = useState(0); + const [index, setIndexState] = useState(0); const [translatedLyrics, setTranslatedLyrics] = useState(null); const [showTranslation, setShowTranslation] = useState(false); const [pendingSongId, setPendingSongId] = useState(currentSong?.id); const lyricsFetchTimeoutRef = useRef | undefined>(undefined); const previousSongIdRef = useRef(currentSong?.id); - // Use a timeout to prevent fetching lyrics when switching songs quickly useEffect(() => { const currentSongId = currentSong?.id; const previousSongId = previousSongIdRef.current; @@ -67,7 +66,6 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' } previousSongIdRef.current = currentSongId; - setPendingSongId(undefined); if (!currentSongId) { @@ -75,7 +73,6 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' } clearTimeout(lyricsFetchTimeoutRef.current); - lyricsFetchTimeoutRef.current = setTimeout(() => { setPendingSongId(currentSongId); }, 500); @@ -85,7 +82,12 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }; }, [currentSong?.id]); - const { data, isInitialLoading } = useQuery( + const lyricsKey = useMemo(() => { + if (!currentSong?._serverId || !currentSong?.id) return null; + return queryKeys.songs.lyrics(currentSong._serverId, { songId: currentSong.id }); + }, [currentSong]); + + const { data, isLoading } = useQuery( lyricsQueries.songLyrics( { options: { @@ -98,250 +100,96 @@ 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, isInitialLoading: isRemoteLyricsLoading } = 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 + const indexToUse = data?.selectedStructuredIndex ?? index; 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; - } + if (data != null) setIndexState(data.selectedStructuredIndex); + }, [data]); - // Check if local lyrics are available - const hasLocalLyrics = - (Array.isArray(data) && data.length > 0) || - (data && !Array.isArray(data) && data.lyrics); + const { selected: lyrics, selectedSynced: synced } = useMemo(() => { + if (!data) return { selected: null, selectedSynced: false }; + return computeSelectedFromResult(data, preferLocalLyrics, indexToUse); + }, [data, indexToUse, preferLocalLyrics]); - // 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 && !!override.id, - }, - query: { - remoteSongId: override?.id, - remoteSource: override?.source as LyricSource | undefined, - song: currentSong, - }, - serverId: currentSong?._serverId || '', - }), - ); - - // Get the current song's offset from persisted lyrics, default to 0 const currentOffsetMs = useMemo(() => { - if (Array.isArray(data)) { - if (data.length > 0) { - const selectedLyric = data[Math.min(index, data.length - 1)]; - return selectedLyric.offsetMs ?? 0; - } - } else if (data?.offsetMs !== undefined) { - return data.offsetMs; - } - return 0; - }, [data, index]); + if (!data) return 0; + return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local); + }, [data, indexToUse, lyrics]); - const [lyrics, synced] = useMemo(() => { - // 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, - name: override.name, - offsetMs: currentOffsetMs, - remote: override.remote ?? true, - source: override.source, - }; - return [overrideLyrics, Array.isArray(overrideData), 'override'] as const; - } - - // 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, 'server'] as const; - } - } else if (data?.lyrics) { - return [data, Array.isArray(data.lyrics), 'server'] as const; - } - - return [undefined, false, 'server'] as const; - }, [data, index, override, overrideData, autoRemoteLyrics, currentOffsetMs, preferLocalLyrics]); - - const handleOnSearchOverride = useCallback((params: LyricsOverride) => { - setOverride(params); - }, []); - - // Persist override lyrics to cache with current offset - useEffect(() => { - if (override && overrideData && currentSong && override.id) { - const persistedLyrics: FullLyricsMetadata = { - artist: override.artist, - lyrics: overrideData, - name: override.name, - offsetMs: currentOffsetMs, - remote: override.remote ?? true, - source: override.source, - }; - - queryClient.setQueryData( - queryKeys.songs.lyrics(currentSong._serverId, { songId: currentSong.id }), - persistedLyrics, + const handleOnSearchOverride = useCallback( + (params: LyricsOverride) => { + if (!lyricsKey) return; + queryClient.setQueryData(lyricsKey, (prev) => + prev ? { ...prev, overrideSelection: params } : prev, ); - } - }, [override, overrideData, currentSong, currentOffsetMs]); + queryClient.invalidateQueries({ queryKey: lyricsKey }); + }, + [lyricsKey], + ); - // Callback to update the song's persisted offset const handleUpdateOffset = useCallback( (offsetMs: number) => { - if (!currentSong) return; + if (!currentSong || !lyricsKey) return; - queryClient.setQueryData( - queryKeys.songs.lyrics(currentSong._serverId, { songId: currentSong.id }), - (prev: FullLyricsMetadata | null | StructuredLyric[] | undefined) => { - if (!prev) return prev; - - // Handle array of structured lyrics - if (Array.isArray(prev)) { - if (prev.length > 0) { - const selectedIndex = Math.min(index, prev.length - 1); - const updated = [...prev]; - updated[selectedIndex] = { - ...updated[selectedIndex], - offsetMs, - }; - return updated; - } - return prev; - } - - // Handle single lyrics object - return { - ...prev, + queryClient.setQueryData(lyricsKey, (prev) => { + if (!prev) return prev; + const updated = { ...prev, selectedOffsetMs: offsetMs }; + if (Array.isArray(prev.local) && prev.local.length > 0) { + const idx = Math.min(indexToUse, prev.local.length - 1); + updated.local = [...prev.local]; + updated.local[idx] = { + ...updated.local[idx], offsetMs, }; - }, + } + return updated; + }); + }, + [currentSong, indexToUse, lyricsKey], + ); + + const setIndex = useCallback( + (newIndex: number) => { + setIndexState(newIndex); + if (!lyricsKey || !data) return; + const { selected: nextSelected, selectedSynced: nextSynced } = + computeSelectedFromResult(data, preferLocalLyrics, newIndex); + const nextOffset = getDisplayOffset( + nextSelected, + data.selectedOffsetMs, + newIndex, + data.local, + ); + queryClient.setQueryData(lyricsKey, (prev) => + prev + ? { + ...prev, + selected: nextSelected, + selectedOffsetMs: nextOffset, + selectedStructuredIndex: newIndex, + selectedSynced: nextSynced, + } + : prev, ); }, - [currentSong, index], + [data, lyricsKey, preferLocalLyrics], ); const handleOnRemoveLyric = useCallback(async () => { - if (!currentSong) return; + if (!currentSong || !lyricsKey) return; - const currentOverride = override; - clearedOverrideRef.current = currentSong.id; - - // Clear the override state and auto remote lyrics - setOverride(undefined); - setAutoRemoteLyrics(null); - - // Clear the override query cache if it exists - if (currentOverride?.id) { - queryClient.removeQueries({ - queryKey: queryKeys.songs.lyricsByRemoteId({ - remoteSongId: currentOverride.id, - remoteSource: currentOverride.source, - }), - }); - } - - // 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]); + queryClient.setQueryData(lyricsKey, (prev) => + prev + ? { + ...prev, + overrideData: null, + overrideSelection: null, + remoteAuto: null, + suppressRemoteAuto: true, + } + : prev, + ); + await queryClient.invalidateQueries({ queryKey: lyricsKey }); + }, [currentSong, lyricsKey]); const fetchTranslation = useCallback(async () => { if (!lyrics) return; @@ -369,10 +217,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' usePlayerEvents( { onCurrentSongChange: () => { - setOverride(undefined); - setAutoRemoteLyrics(null); - clearedOverrideRef.current = undefined; - setIndex(0); + setIndexState(0); setShowTranslation(false); setTranslatedLyrics(null); }, @@ -387,46 +232,20 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }, [lyrics, translatedLyrics, enableAutoTranslation, fetchTranslation]); const languages = useMemo(() => { - if (Array.isArray(data)) { - return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() })); - } else if (data?.lyrics) { - // xxx denotes undefined lyrics language. If it's a single lyric (from a remote source) - // the language is most likely not available, so leave it undefined + const local = data?.local; + if (Array.isArray(local)) { + return local.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() })); + } + if (local && !Array.isArray(local) && 'lyrics' in local) { return [{ label: 'xxx', value: '0' }]; } return []; - }, [data]); - - const shouldShowRemoteLoading = useMemo(() => { - if (!isRemoteLyricsLoading) { - return false; - } - - // If preferLocalLyrics is disabled, always show remote loading - if (!preferLocalLyrics) { - return true; - } - - // If preferLocalLyrics is enabled, only show remote loading if: - // - Local lyrics have finished loading (!isInitialLoading) - // - No local lyrics are available - if (isInitialLoading) return false; - - // Check if local lyrics are available - const hasLocalLyrics = - (Array.isArray(data) && data.length > 0) || - (data && !Array.isArray(data) && data.lyrics); - - // Show remote loading only if no local lyrics are available - return !hasLocalLyrics; - }, [isRemoteLyricsLoading, preferLocalLyrics, data, isInitialLoading]); - - const isLoadingLyrics = isInitialLoading || isOverrideLoading || shouldShowRemoteLoading; + }, [data?.local]); + const isLoadingLyrics = isLoading; const hasNoLyrics = !lyrics; const [shouldFadeOut, setShouldFadeOut] = useState(false); - // Trigger fade out after a few seconds when no lyrics are found useEffect(() => { if (!fadeOutNoLyricsMessage) { setShouldFadeOut(false); @@ -434,11 +253,9 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' } if (!isLoadingLyrics && hasNoLyrics) { - // Start fade out after 3 seconds (message visible for 3s, then 0.5s fade) const timer = setTimeout(() => { setShouldFadeOut(true); }, 3000); - return () => clearTimeout(timer); } @@ -520,7 +337,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'