implement OS enhanced/karaoke lyrics

This commit is contained in:
jeffvli
2026-07-08 18:08:48 -07:00
parent ef3f3043bc
commit 88b570cacb
43 changed files with 3873 additions and 415 deletions
@@ -1,10 +1,78 @@
import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron';
import { LyricsResponse, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
import {
alignFuriganaToWordCues,
alignRomajiTokensToWordCues,
LyricTextToken,
RomajiToken,
} from '/@/renderer/features/lyrics/api/lyric-conversion';
import { LyricsResponse, SyncedCueLine, SynchronizedLyrics } from '/@/shared/types/domain-types';
const lyricsApi = isElectron() ? window.api.lyrics : null;
const convertSyncedLyricsFurigana = async (
lyrics: SynchronizedLyrics,
): Promise<SynchronizedLyrics> => {
if (!lyricsApi) {
return lyrics;
}
return Promise.all(
lyrics.map(async (line) => ({
...line,
cueLines: line.cueLines
? await Promise.all(
line.cueLines.map(async (cueLine) => {
const tokens = (await lyricsApi.parseLyricsTextTokens(
cueLine.value,
)) as LyricTextToken[];
const alignedWords = cueLine.words.length
? await alignFuriganaToWordCues(
cueLine.value,
cueLine.words,
tokens,
(text) => lyricsApi.convertFuriganaFragment(text),
)
: cueLine.words;
return {
...cueLine,
value: await lyricsApi.convertFurigana(cueLine.value),
words: alignedWords ?? cueLine.words,
};
}),
)
: undefined,
text: await lyricsApi.convertFurigana(line.text),
})),
);
};
const convertSyncedLyricsRomaji = async (
lyrics: SynchronizedLyrics,
convert: (text: string) => Promise<string>,
): Promise<SynchronizedLyrics> =>
Promise.all(
lyrics.map(async (line) => ({
...line,
cueLines: line.cueLines
? await Promise.all(
line.cueLines.map(async (cueLine) => ({
...cueLine,
value: await convert(cueLine.value),
words: await Promise.all(
cueLine.words.map(async (word) => ({
...word,
text: await convert(word.text),
})),
),
})),
)
: undefined,
text: await convert(line.text),
})),
);
export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
@@ -13,15 +81,12 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
if (typeof lyrics === 'string') {
return await lyricsApi.convertFurigana(lyrics);
} else if (Array.isArray(lyrics)) {
const converted = await Promise.all(
lyrics.map(async ([time, line]) => [
time,
await lyricsApi.convertFurigana(line),
]),
);
return converted as SynchronizedLyricsArray;
}
if (Array.isArray(lyrics)) {
return convertSyncedLyricsFurigana(lyrics);
}
return lyrics;
},
queryKey: ['furigana', lyrics],
@@ -37,15 +102,80 @@ export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabl
if (typeof lyrics === 'string') {
return await lyricsApi.convertRomaji(lyrics);
} else if (Array.isArray(lyrics)) {
const converted = await Promise.all(
lyrics.map(async ([time, line]) => [time, await lyricsApi.convertRomaji(line)]),
);
return converted as SynchronizedLyricsArray;
}
if (Array.isArray(lyrics)) {
return convertSyncedLyricsRomaji(lyrics, (text) => lyricsApi.convertRomaji(text));
}
return lyrics;
},
queryKey: ['romaji', lyrics],
staleTime: Infinity,
});
};
export type SyncedRomajiLyrics = (null | SyncedCueLine[])[];
const buildSyncedRomajiLine = async (
cueLines: SyncedCueLine[],
): Promise<null | SyncedCueLine[]> => {
const romajiCueLines: SyncedCueLine[] = [];
for (const cueLine of cueLines) {
if (!cueLine.words.length) {
romajiCueLines.push({ ...cueLine });
continue;
}
if (!lyricsApi) {
return null;
}
const tokens = (await lyricsApi.convertRomajiTokens(cueLine.value)) as RomajiToken[];
const alignedWords = alignRomajiTokensToWordCues(cueLine.value, cueLine.words, tokens);
if (!alignedWords) {
return null;
}
romajiCueLines.push({
...cueLine,
words: alignedWords,
});
}
return romajiCueLines;
};
export const useSyncedRomajiLyrics = (
lyrics: null | SynchronizedLyrics | undefined,
enabled: boolean,
) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
queryFn: async (): Promise<null | SyncedRomajiLyrics> => {
if (!lyrics || !lyricsApi || !enabled) {
return null;
}
const result: SyncedRomajiLyrics = [];
for (const line of lyrics) {
if (
!line.cueLines?.length ||
!line.cueLines.some((cueLine) => cueLine.words.length)
) {
result.push(null);
continue;
}
result.push(await buildSyncedRomajiLine(line.cueLines));
}
return result;
},
queryKey: ['romaji-synced', lyrics],
staleTime: Infinity,
});
};