feat: add Japanese Furigana support to lyrics display (#2161)

This commit is contained in:
York
2026-06-22 10:11:20 +08:00
committed by GitHub
parent 417365f091
commit 0ed68e8ebb
13 changed files with 174 additions and 3 deletions
+2
View File
@@ -843,6 +843,8 @@
"discordUpdateInterval_description": "The time in seconds between each update (minimum 15 seconds)",
"enableAutoTranslation_description": "Enable translation automatically when lyrics are loaded",
"enableAutoTranslation": "Enable auto translation",
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
"enableFurigana": "Enable furigana generation",
"enableRemote_description": "Enables the remote control server to allow other devices to control the application",
"enableRemote": "Enable remote control server",
"exitToTray_description": "Exit the application to the system tray",
+37
View File
@@ -0,0 +1,37 @@
import Kuroshiro from 'kuroshiro';
import KuromojiAnalyzer from 'kuroshiro-analyzer-kuromoji';
// doc: https://kuroshiro.org
let kuroshiroInstance: any = null;
let initPromise: null | Promise<void> = null;
const getKuroshiro = async () => {
if (kuroshiroInstance) return kuroshiroInstance;
if (initPromise) {
await initPromise;
return kuroshiroInstance;
}
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
kuroshiroInstance = new KuroshiroClass();
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
await initPromise;
return kuroshiroInstance;
};
export const convertFurigana = async (text: string): Promise<string> => {
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
// check if the text contains any Japanese kana (to distinguish Japanese from Chinese text, which shares Kanji)
// If no Japanese kana is detected, skip processing
if (!KuroshiroClass.Util.hasKana(text)) return text;
try {
const kuroshiro = await getKuroshiro();
return await kuroshiro.convert(text, { mode: 'furigana', to: 'hiragana' });
} catch (e) {
console.error('Furigana conversion error: ', e);
return text;
}
};
+5
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron';
import { store } from '../settings';
import { convertFurigana } 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';
@@ -231,3 +232,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
const lyricResults = await getRemoteLyricsById(params);
return lyricResults;
});
ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => {
return await convertFurigana(text);
});
+5
View File
@@ -26,7 +26,12 @@ const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => {
return result;
};
const convertFurigana = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-furigana', text);
};
export const lyrics = {
convertFurigana,
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
@@ -291,6 +291,22 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
isHidden: !isElectron(),
title: t('setting.lyricFetchProvider'),
},
{
control: (
<Switch
aria-label="Enable furigana"
defaultChecked={lyricsSettings.enableFurigana}
onChange={(e) =>
updateLyricsSetting({ enableFurigana: e.currentTarget.checked })
}
/>
),
description: t('setting.enableFurigana', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
control: (
<Switch
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron';
import { LyricsResponse, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
const lyricsApi = isElectron() ? window.api.lyrics : null;
export const useFuriganaLyrics = (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.convertFurigana(lyrics);
} else if (Array.isArray(lyrics)) {
const text = lyrics.map(([, line]) => line).join('\n');
const converted = await lyricsApi.convertFurigana(text);
const convertedLines = converted.split('\n');
return lyrics.map(([time], i) => [
time,
convertedLines[i] ?? lyrics[i][1],
]) as SynchronizedLyricsArray;
}
return lyrics;
},
queryKey: ['furigana', lyrics],
staleTime: Infinity,
});
};
+2 -1
View File
@@ -3,6 +3,7 @@ import { ComponentPropsWithoutRef, memo, useMemo } from 'react';
import styles from './lyric-line.module.css';
import { sanitize } from '/@/renderer/utils/sanitize';
import { Box } from '/@/shared/components/box/box';
import { Stack } from '/@/shared/components/stack/stack';
@@ -28,7 +29,7 @@ export const LyricLine = memo(
<Box className={clsx(styles.lyricLine, className)} style={style} {...props}>
<Stack gap={0}>
{lines.map((line, index) => (
<span key={index}>{line}</span>
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
))}
</Stack>
</Box>
+11 -1
View File
@@ -14,6 +14,7 @@ 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 { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import {
SynchronizedLyrics,
@@ -49,6 +50,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const {
enableAutoTranslation,
enableFurigana,
preferLocalLyrics,
translationApiKey,
translationApiProvider,
@@ -116,7 +118,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);
}, [data, indexToUse, preferLocalLyrics]);
const displayLyrics = isLyricsDisabled ? null : lyrics;
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
const displayLyrics = useMemo(() => {
if (isLyricsDisabled || !lyrics) return null;
if (enableFurigana && furiganaConvertedLyrics) {
return { ...lyrics, lyrics: furiganaConvertedLyrics };
}
return lyrics;
}, [enableFurigana, isLyricsDisabled, lyrics, furiganaConvertedLyrics]);
const currentOffsetMs = useMemo(() => {
if (!data) return 0;
@@ -93,6 +93,20 @@ export const LyricSettings = memo(() => {
isHidden: !isElectron(),
title: t('setting.lyricFetchProvider'),
},
{
control: (
<Switch
aria-label="Enable furigana generation"
defaultChecked={settings.enableFurigana}
onChange={(e) => updateSetting({ enableFurigana: e.currentTarget.checked })}
/>
),
description: t('setting.enableFurigana', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
control: (
<Switch
+2
View File
@@ -576,6 +576,7 @@ const LyricsSettingsSchema = z.object({
alignment: z.enum(['center', 'left', 'right']),
delayMs: z.number(),
enableAutoTranslation: z.boolean(),
enableFurigana: z.boolean().optional(),
enableNeteaseTranslation: z.boolean(),
fetch: z.boolean(),
follow: z.boolean(),
@@ -1845,6 +1846,7 @@ const initialState: SettingsState = {
alignment: 'center',
delayMs: 0,
enableAutoTranslation: false,
enableFurigana: false,
enableNeteaseTranslation: false,
fetch: true,
follow: true,
+1 -1
View File
@@ -2,7 +2,7 @@ import DomPurify, { Config } from 'dompurify';
const SANITIZE_OPTIONS: Config = {
ALLOWED_ATTR: ['href'],
ALLOWED_TAGS: ['a', 'b', 'div', 'em', 'i', 'p', 'span', 'strong'],
ALLOWED_TAGS: ['a', 'b', 'div', 'em', 'i', 'p', 'span', 'strong', 'ruby', 'rt', 'rp'],
// allow http://, https://, and // (mapped to https)
ALLOWED_URI_REGEXP: /^(http(s?):)?\/\/.+/i,
};