feat: add romaji lyrics display (#2180)

This commit is contained in:
York
2026-06-27 12:07:58 +08:00
committed by GitHub
parent 26eea7422d
commit f8ca8861fc
13 changed files with 139 additions and 9 deletions
+2
View File
@@ -845,6 +845,8 @@
"enableAutoTranslation": "Enable auto translation",
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
"enableFurigana": "Enable furigana generation",
"enableRomaji_description": "Display a romaji pronunciation line under Japanese lyrics.",
"enableRomaji": "Enable romaji generation",
"equalizer_descriptionMpv": "Parametric equalizer via FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API",
"equalizer": "Equalizer",
+18 -1
View File
@@ -7,16 +7,19 @@ let kuroshiroInstance: any = null;
let initPromise: null | Promise<void> = null;
const getKuroshiro = async () => {
if (kuroshiroInstance) return kuroshiroInstance;
if (initPromise) {
await initPromise;
return kuroshiroInstance;
}
if (kuroshiroInstance) return kuroshiroInstance;
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
kuroshiroInstance = new KuroshiroClass();
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
await initPromise;
initPromise = null;
return kuroshiroInstance;
};
@@ -35,3 +38,17 @@ export const convertFurigana = async (text: string): Promise<string> => {
return text;
}
};
export const convertRomaji = async (text: string): Promise<string> => {
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
if (!KuroshiroClass.Util.hasKana(text)) return text;
try {
const kuroshiro = await getKuroshiro();
return await kuroshiro.convert(text, { mode: 'spaced', to: 'romaji' });
} catch (e) {
console.error('Romaji conversion error: ', e);
return text;
}
};
+5 -1
View File
@@ -1,7 +1,7 @@
import { ipcMain } from 'electron';
import { store } from '../settings';
import { convertFurigana } from './furigana';
import { convertFurigana, convertRomaji } from './furigana';
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
@@ -236,3 +236,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => {
return await convertFurigana(text);
});
ipcMain.handle('lyric-convert-romaji', async (_event, text: string) => {
return await convertRomaji(text);
});
+5
View File
@@ -30,8 +30,13 @@ const convertFurigana = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-furigana', text);
};
const convertRomaji = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-romaji', text);
};
export const lyrics = {
convertFurigana,
convertRomaji,
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
@@ -307,6 +307,20 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
control: (
<Switch
aria-label="Enable romaji"
defaultChecked={lyricsSettings.enableRomaji}
onChange={(e) => updateLyricsSetting({ enableRomaji: e.currentTarget.checked })}
/>
),
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{
control: (
<Switch
@@ -28,3 +28,27 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
staleTime: Infinity,
});
};
export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
queryFn: async () => {
if (!lyrics || !lyricsApi || !enabled) return lyrics;
if (typeof lyrics === 'string') {
return await lyricsApi.convertRomaji(lyrics);
} else if (Array.isArray(lyrics)) {
const text = lyrics.map(([, line]) => line).join('\n');
const converted = await lyricsApi.convertRomaji(text);
const convertedLines = converted.split('\n');
return lyrics.map(([time], i) => [
time,
convertedLines[i] ?? lyrics[i][1],
]) as SynchronizedLyricsArray;
}
return lyrics;
},
queryKey: ['romaji', lyrics],
staleTime: Infinity,
});
};
@@ -25,3 +25,8 @@
.lyric-line:global(.synchronized) {
cursor: pointer;
}
.romaji-line {
font-size: 0.8em;
font-weight: 600;
}
+20 -1
View File
@@ -10,11 +10,21 @@ import { Stack } from '/@/shared/components/stack/stack';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
alignment: 'center' | 'left' | 'right';
fontSize: number;
romajiText?: null | string;
text: string;
translatedText?: null | string;
}
export const LyricLine = memo(
({ alignment, className, fontSize, text, ...props }: LyricLineProps) => {
({
alignment,
className,
fontSize,
romajiText,
text,
translatedText,
...props
}: LyricLineProps) => {
const lines = useMemo(() => text.split('_BREAK_'), [text]);
const style = useMemo(
@@ -31,6 +41,15 @@ export const LyricLine = memo(
{lines.map((line, index) => (
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
))}
{romajiText && (
<span
className={styles.romajiLine}
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
/>
)}
{translatedText && (
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
)}
</Stack>
</Box>
);
+16 -1
View File
@@ -14,7 +14,10 @@ import {
type LyricsQueryResult,
} from '/@/renderer/features/lyrics/api/lyrics-api';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
import { useFuriganaLyrics } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
import {
useFuriganaLyrics,
useRomajiLyrics,
} from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import {
SynchronizedLyrics,
@@ -51,6 +54,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const {
enableAutoTranslation,
enableFurigana,
enableRomaji,
preferLocalLyrics,
translationApiKey,
translationApiProvider,
@@ -119,6 +123,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
}, [data, indexToUse, preferLocalLyrics]);
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji);
const displayLyrics = useMemo(() => {
if (isLyricsDisabled || !lyrics) return null;
@@ -344,12 +349,22 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
<SynchronizedLyrics
{...(displayLyrics as SynchronizedLyricsProps)}
offsetMs={displayOffsetMs}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
) : (
<UnsynchronizedLyrics
{...(displayLyrics as UnsynchronizedLyricsProps)}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as UnsynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
@@ -23,6 +23,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray;
offsetMs?: number;
romajiLyrics?: null | SynchronizedLyricsArray;
settingsKey?: string;
style?: React.CSSProperties;
translatedLyrics?: null | string;
@@ -34,6 +35,7 @@ export const SynchronizedLyrics = ({
name,
offsetMs,
remote,
romajiLyrics,
settingsKey = 'default',
source,
style,
@@ -368,10 +370,9 @@ export const SynchronizedLyrics = ({
handleSeek(time / 1000);
}
}}
text={
text +
(translatedLyrics ? `_BREAK_${translatedLyrics.split('\n')[idx]}` : '')
}
romajiText={romajiLyrics?.[idx]?.[1]}
text={text}
translatedText={translatedLyrics?.split('\n')[idx]}
/>
))}
</div>
@@ -8,6 +8,7 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types';
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
romajiLyrics?: null | string;
settingsKey?: string;
translatedLyrics?: null | string;
}
@@ -17,6 +18,7 @@ export const UnsynchronizedLyrics = ({
lyrics,
name,
remote,
romajiLyrics,
settingsKey = 'default',
source,
translatedLyrics,
@@ -42,6 +44,10 @@ export const UnsynchronizedLyrics = ({
return translatedLyrics ? translatedLyrics.split('\n') : [];
}, [translatedLyrics]);
const romajiLines = useMemo(() => {
return romajiLyrics ? romajiLyrics.split('\n') : [];
}, [romajiLyrics]);
return (
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
{settings.showProvider && source && (
@@ -67,7 +73,9 @@ export const UnsynchronizedLyrics = ({
fontSize={settings.fontSizeUnsync}
id={`lyric-${idx}`}
key={idx}
text={text + (translatedLines[idx] ? `_BREAK_${translatedLines[idx]}` : '')}
romajiText={romajiLines[idx]}
text={text}
translatedText={translatedLines[idx]}
/>
))}
</div>
@@ -107,6 +107,20 @@ export const LyricSettings = memo(() => {
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
control: (
<Switch
aria-label="Enable romaji generation"
defaultChecked={settings.enableRomaji}
onChange={(e) => updateSetting({ enableRomaji: e.currentTarget.checked })}
/>
),
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{
control: (
<Switch
+2
View File
@@ -578,6 +578,7 @@ const LyricsSettingsSchema = z.object({
enableAutoTranslation: z.boolean(),
enableFurigana: z.boolean().optional(),
enableNeteaseTranslation: z.boolean(),
enableRomaji: z.boolean().optional(),
fetch: z.boolean(),
follow: z.boolean(),
preferLocalLyrics: z.boolean(),
@@ -1848,6 +1849,7 @@ const initialState: SettingsState = {
enableAutoTranslation: false,
enableFurigana: false,
enableNeteaseTranslation: false,
enableRomaji: false,
fetch: true,
follow: true,
preferLocalLyrics: true,