mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-18 08:26:29 +02:00
simplify lyrics implementation
- removes complex lyrics fetch and override logic, and instead uses a single query as a source of truth for the lyrics - properly handles loading state, invalidation, and refetch
This commit is contained in:
@@ -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 | string>(null);
|
||||
const [showTranslation, setShowTranslation] = useState(false);
|
||||
const [pendingSongId, setPendingSongId] = useState<string | undefined>(currentSong?.id);
|
||||
const lyricsFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const previousSongIdRef = useRef<string | undefined>(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<LyricsOverride | undefined>(undefined);
|
||||
const [autoRemoteLyrics, setAutoRemoteLyrics] = useState<FullLyricsMetadata | null>(null);
|
||||
const clearedOverrideRef = useRef<string | undefined>(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<LyricsQueryResult>(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<LyricsQueryResult>(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<LyricsQueryResult>(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<LyricsQueryResult>(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'
|
||||
<div className={styles.actionsContainer}>
|
||||
<LyricsActions
|
||||
hasLyrics={!!lyrics}
|
||||
index={index}
|
||||
index={indexToUse}
|
||||
languages={languages}
|
||||
offsetMs={currentOffsetMs}
|
||||
onExportLyrics={handleExportLyrics}
|
||||
|
||||
Reference in New Issue
Block a user