From 0b8ae551502657253acededed5e7eb277acba217 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 30 Nov 2025 17:55:12 -0800 Subject: [PATCH] persist lyrics offset per song --- .../features/lyrics/lyrics-actions.tsx | 27 +++----- src/renderer/features/lyrics/lyrics.tsx | 66 +++++++++++++++++-- .../features/lyrics/synchronized-lyrics.tsx | 13 ++-- src/shared/types/domain-types.ts | 1 + 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index 304a01e2d..2fbb53081 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -2,12 +2,7 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form'; -import { - useLyricsSettings, - usePlayerSong, - useSettingsStore, - useSettingsStoreActions, -} from '/@/renderer/store'; +import { useLyricsSettings, usePlayerSong } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; import { Center } from '/@/shared/components/center/center'; @@ -20,9 +15,11 @@ import { LyricsOverride } from '/@/shared/types/domain-types'; interface LyricsActionsProps { index: number; languages: { label: string; value: string }[]; + offsetMs: number; onRemoveLyric: () => void; onSearchOverride: (params: LyricsOverride) => void; onTranslateLyric?: () => void; + onUpdateOffset: (offsetMs: number) => void; setIndex: (idx: number) => void; synced?: boolean; } @@ -30,23 +27,19 @@ interface LyricsActionsProps { export const LyricsActions = ({ index, languages, + offsetMs, onRemoveLyric, onSearchOverride, onTranslateLyric, + onUpdateOffset, setIndex, }: LyricsActionsProps) => { const { t } = useTranslation(); const currentSong = usePlayerSong(); - const { setSettings } = useSettingsStoreActions(); - const { delayMs, sources } = useLyricsSettings(); + const { sources } = useLyricsSettings(); const handleLyricOffset = (e: number | string) => { - setSettings({ - lyrics: { - ...useSettingsStore.getState().lyrics, - delayMs: Number(e), - }, - }); + onUpdateOffset(Number(e)); }; const isActionsDisabled = !currentSong; @@ -86,7 +79,7 @@ export const LyricsActions = ({ handleLyricOffset(delayMs - 50)} + onClick={() => handleLyricOffset(offsetMs - 50)} tooltip={{ label: t('common.slower', { postProcess: 'sentenceCase' }), openDelay: 0, @@ -101,14 +94,14 @@ export const LyricsActions = ({ aria-label="Lyric offset" onChange={handleLyricOffset} styles={{ input: { textAlign: 'center' } }} - value={delayMs || 0} + value={offsetMs || 0} width={70} /> handleLyricOffset(delayMs + 50)} + onClick={() => handleLyricOffset(offsetMs + 50)} tooltip={{ label: t('common.faster', { postProcess: 'sentenceCase' }), openDelay: 0, diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index 5073add48..f3b26c4a6 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -25,7 +25,12 @@ 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 } from '/@/shared/types/domain-types'; +import { + FullLyricsMetadata, + LyricSource, + LyricsOverride, + StructuredLyric, +} from '/@/shared/types/domain-types'; export const Lyrics = () => { const currentSong = usePlayerSong(); @@ -66,6 +71,19 @@ export const Lyrics = () => { }), ); + // 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]); + const [lyrics, synced] = useMemo(() => { // If override data is available, use it if (override && overrideData) { @@ -73,6 +91,7 @@ export const Lyrics = () => { artist: override.artist, lyrics: overrideData, name: override.name, + offsetMs: currentOffsetMs, remote: override.remote ?? true, source: override.source, }; @@ -90,19 +109,20 @@ export const Lyrics = () => { } return [undefined, false]; - }, [data, index, override, overrideData]); + }, [data, index, override, overrideData, currentOffsetMs]); const handleOnSearchOverride = useCallback((params: LyricsOverride) => { setOverride(params); }, []); - // Persist override lyrics to cache + // Persist override lyrics to cache with current offset useEffect(() => { if (override && overrideData && currentSong) { const persistedLyrics: FullLyricsMetadata = { artist: override.artist, lyrics: overrideData, name: override.name, + offsetMs: currentOffsetMs, remote: override.remote ?? true, source: override.source, }; @@ -112,7 +132,42 @@ export const Lyrics = () => { persistedLyrics, ); } - }, [override, overrideData, currentSong]); + }, [override, overrideData, currentSong, currentOffsetMs]); + + // Callback to update the song's persisted offset + const handleUpdateOffset = useCallback( + (offsetMs: number) => { + if (!currentSong) 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, + offsetMs, + }; + }, + ); + }, + [currentSong, index], + ); // const handleOnResetLyric = useCallback(() => { // setOverride(undefined); @@ -242,6 +297,7 @@ export const Lyrics = () => { {synced ? ( ) : ( @@ -258,6 +314,7 @@ export const Lyrics = () => { { ? handleOnTranslateLyric : undefined } + onUpdateOffset={handleUpdateOffset} setIndex={setIndex} /> diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 355054b19..bf92bdaf9 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -21,6 +21,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; export interface SynchronizedLyricsProps extends Omit { lyrics: SynchronizedLyricsArray; + offsetMs?: number; style?: React.CSSProperties; translatedLyrics?: null | string; } @@ -29,6 +30,7 @@ export const SynchronizedLyrics = ({ artist, lyrics, name, + offsetMs, remote, source, style, @@ -40,6 +42,8 @@ export const SynchronizedLyrics = ({ const status = usePlayerStatus(); const timestamp = usePlayerTimestamp(); + const effectiveOffsetMs = offsetMs ?? 0; + const handleSeek = useCallback( (time: number) => { if (playbackType === PlayerType.LOCAL && mpvPlayer) { @@ -66,7 +70,7 @@ export const SynchronizedLyrics = ({ // whether to proceed or stop const timerEpoch = useRef(0); - const delayMsRef = useRef(settings.delayMs); + const delayMsRef = useRef(effectiveOffsetMs); const followRef = useRef(settings.follow); const userScrollingRef = useRef(false); const scrollTimeoutRef = useRef>(null); @@ -195,7 +199,8 @@ export const SynchronizedLyrics = ({ // This handler is used to deal with changes to the current delay. If the offset // changes, we should immediately stop the current listening set and calculate // the correct one using the new offset. Afterwards, timing can be calculated like normal - const changed = delayMsRef.current !== settings.delayMs; + const newOffset = offsetMs ?? 0; + const changed = delayMsRef.current !== newOffset; if (!changed) { return; @@ -205,11 +210,11 @@ export const SynchronizedLyrics = ({ clearTimeout(lyricTimer.current); } - delayMsRef.current = settings.delayMs; + delayMsRef.current = newOffset; // Use the current timestamp from player events setCurrentLyric(timestamp * 1000 + delayMsRef.current); - }, [setCurrentLyric, settings.delayMs, timestamp]); + }, [setCurrentLyric, offsetMs, timestamp]); useEffect(() => { // This handler is used specifically for dealing with seeking and progress updates. diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index a8a133222..d921e3939 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1060,6 +1060,7 @@ export type ArtistInfoQuery = { export type FullLyricsMetadata = Omit & { lyrics: LyricsResponse; + offsetMs?: number; remote: boolean; source: string; };