Add support for OpenSubsonic enhanced lyrics (#2208)

This commit is contained in:
Jeff
2026-07-09 21:32:05 -07:00
committed by GitHub
parent 0c89fea1ea
commit 355b19c1cb
55 changed files with 4213 additions and 480 deletions
+134 -17
View File
@@ -13,12 +13,20 @@ import {
lyricsQueries,
type LyricsQueryResult,
} from '/@/renderer/features/lyrics/api/lyrics-api';
import {
formatStructuredLyricLabel,
getLyricLineText,
getLyricsLayers,
lyricsHasWordCues,
} from '/@/renderer/features/lyrics/api/lyrics-utils';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
import {
useFuriganaLyrics,
useRomajiLyrics,
useSyncedRomajiLyrics,
} from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import { SynchronizedKaraokeLyrics } from '/@/renderer/features/lyrics/synchronized-karaoke-lyrics';
import {
SynchronizedLyrics,
SynchronizedLyricsProps,
@@ -64,6 +72,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const [index, setIndexState] = useState(0);
const [translatedLyrics, setTranslatedLyrics] = useState<null | string>(null);
const [showTranslation, setShowTranslation] = useState(false);
const [showTranslationLayer, setShowTranslationLayer] = useState(false);
const [showPronunciationLayer, setShowPronunciationLayer] = 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);
@@ -98,7 +108,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
return queryKeys.songs.lyrics(currentSong._serverId, { songId: currentSong.id });
}, [currentSong]);
const { data, isLoading } = useQuery(
const shouldFetchLyrics = !isLyricsDisabled && !!currentSong?._serverId && !!currentSong?.id;
const isWaitingToFetchLyrics = shouldFetchLyrics && pendingSongId !== currentSong?.id;
const { data, isLoading, isRefetching } = useQuery(
lyricsQueries.songLyrics(
{
options: {
@@ -125,6 +138,14 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji);
const rawSyncedLyrics = useMemo(() => {
if (!synced || !lyrics || !('lyrics' in lyrics) || !Array.isArray(lyrics.lyrics)) {
return null;
}
return lyrics.lyrics;
}, [lyrics, synced]);
const displayLyrics = useMemo(() => {
if (isLyricsDisabled || !lyrics) return null;
if (enableFurigana && furiganaConvertedLyrics) {
@@ -138,7 +159,90 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local);
}, [data, indexToUse, lyrics]);
const layers = useMemo(() => {
if (!Array.isArray(data?.local)) {
return null;
}
return getLyricsLayers(data.local);
}, [data]);
const translationLyricsOverlay = useMemo(() => {
if (!showTranslationLayer || !layers?.translation?.synced) {
return null;
}
return layers.translation.lyrics;
}, [layers, showTranslationLayer]);
const pronunciationLyricsOverlay = useMemo(() => {
if (!showPronunciationLayer || !layers?.pronunciation?.synced) {
return null;
}
return layers.pronunciation.lyrics;
}, [layers, showPronunciationLayer]);
const selectedAgents = useMemo(() => {
if (!lyrics || !('synced' in lyrics) || !lyrics.synced) {
return undefined;
}
return lyrics.agents;
}, [lyrics]);
const displayOffsetMs = isLyricsDisabled ? 0 : currentOffsetMs;
const useServerPronunciation = !!pronunciationLyricsOverlay;
const { data: syncedRomajiLyrics } = useSyncedRomajiLyrics(
rawSyncedLyrics,
!!enableRomaji &&
!!rawSyncedLyrics &&
lyricsHasWordCues(rawSyncedLyrics) &&
!useServerPronunciation,
);
const isKaraoke = useMemo(() => {
if (!synced || !displayLyrics || !('lyrics' in displayLyrics)) {
return false;
}
return Array.isArray(displayLyrics.lyrics) && lyricsHasWordCues(displayLyrics.lyrics);
}, [displayLyrics, synced]);
const syncedLyricsProps = useMemo(() => {
if (!displayLyrics) {
return null;
}
return {
...(displayLyrics as SynchronizedLyricsProps),
offsetMs: displayOffsetMs,
pronunciationLyrics: pronunciationLyricsOverlay,
romajiLyrics:
enableRomaji && !useServerPronunciation
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
: null,
settingsKey,
syncedRomajiLyrics:
enableRomaji && !useServerPronunciation ? (syncedRomajiLyrics ?? null) : null,
translatedLyrics: showTranslation && !showTranslationLayer ? translatedLyrics : null,
translationLyrics: translationLyricsOverlay,
};
}, [
displayLyrics,
displayOffsetMs,
enableRomaji,
pronunciationLyricsOverlay,
romajiConvertedLyrics,
syncedRomajiLyrics,
settingsKey,
showTranslation,
showTranslationLayer,
translatedLyrics,
translationLyricsOverlay,
useServerPronunciation,
]);
const handleOnSearchOverride = useCallback(
(params: LyricsOverride) => {
@@ -219,7 +323,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const fetchTranslation = useCallback(async () => {
if (!lyrics || isLyricsDisabled) return;
const originalLyrics = Array.isArray(lyrics.lyrics)
? lyrics.lyrics.map(([, line]) => line).join('\n')
? lyrics.lyrics.map((line) => getLyricLineText(line)).join('\n')
: lyrics.lyrics;
const TranslatedText: null | string = await translateLyrics(
originalLyrics,
@@ -250,6 +354,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
onCurrentSongChange: () => {
setIndexState(0);
setShowTranslation(false);
setShowTranslationLayer(false);
setShowPronunciationLayer(false);
setTranslatedLyrics(null);
},
},
@@ -265,7 +371,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const languages = useMemo(() => {
const local = data?.local;
if (Array.isArray(local)) {
return local.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
return local.map((lyric, idx) => ({
label: formatStructuredLyricLabel(lyric),
value: idx.toString(),
}));
}
if (local && !Array.isArray(local) && 'lyrics' in local) {
return [{ label: 'xxx', value: '0' }];
@@ -273,7 +382,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
return [];
}, [data?.local]);
const isLoadingLyrics = isLoading && !isLyricsDisabled;
const isLoadingLyrics =
shouldFetchLyrics && (isWaitingToFetchLyrics || isLoading || isRefetching);
const hasNoLyrics = !displayLyrics;
const [shouldFadeOut, setShouldFadeOut] = useState(false);
@@ -325,7 +435,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
) : (
<AnimatePresence mode="sync">
{hasNoLyrics ? (
<Center w="100%">
<Center flex={1} w="100%">
<motion.div
animate={{ opacity: shouldFadeOut ? 0 : 1 }}
initial={{ opacity: 1 }}
@@ -345,18 +455,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{synced ? (
<SynchronizedLyrics
{...(displayLyrics as SynchronizedLyricsProps)}
offsetMs={displayOffsetMs}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
{synced && syncedLyricsProps ? (
isKaraoke ? (
<SynchronizedKaraokeLyrics
{...syncedLyricsProps}
agents={selectedAgents}
/>
) : (
<SynchronizedLyrics {...syncedLyricsProps} />
)
) : (
<UnsynchronizedLyrics
{...(displayLyrics as UnsynchronizedLyricsProps)}
@@ -376,12 +483,20 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
<div className={styles.actionsContainer}>
<LyricsActions
hasLyrics={!!displayLyrics}
hasPronunciationLayer={!!layers?.pronunciation?.synced}
hasTranslationLayer={!!layers?.translation?.synced}
index={indexToUse}
languages={languages}
offsetMs={displayOffsetMs}
onExportLyrics={handleExportLyrics}
onRemoveLyric={handleOnRemoveLyric}
onSearchOverride={handleOnSearchOverride}
onTogglePronunciationLayer={() =>
setShowPronunciationLayer((current) => !current)
}
onToggleTranslationLayer={() =>
setShowTranslationLayer((current) => !current)
}
onTranslateLyric={
translationApiProvider && translationApiKey
? handleOnTranslateLyric
@@ -390,6 +505,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
onUpdateOffset={handleUpdateOffset}
setIndex={setIndex}
settingsKey={settingsKey}
showPronunciationLayer={showPronunciationLayer}
showTranslationLayer={showTranslationLayer}
/>
</div>
</div>