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
@@ -114,6 +114,8 @@
"idb-keyval": "^6.2.5", "idb-keyval": "^6.2.5",
"immer": "^10.2.0", "immer": "^10.2.0",
"is-electron": "^2.2.2", "is-electron": "^2.2.2",
"kuroshiro": "^1.2.0",
"kuroshiro-analyzer-kuromoji": "^1.1.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"md5": "^2.3.0", "md5": "^2.3.0",
"motion": "^12.40.0", "motion": "^12.40.0",
+47
View File
@@ -138,6 +138,12 @@ importers:
is-electron: is-electron:
specifier: ^2.2.2 specifier: ^2.2.2
version: 2.2.2 version: 2.2.2
kuroshiro:
specifier: ^1.2.0
version: 1.2.0
kuroshiro-analyzer-kuromoji:
specifier: ^1.1.0
version: 1.1.0
lodash: lodash:
specifier: ^4.18.1 specifier: ^4.18.1
version: 4.18.1 version: 4.18.1
@@ -2392,6 +2398,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
async@2.6.4:
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
async@3.2.6: async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
@@ -2926,6 +2935,9 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'} engines: {node: '>=12'}
doublearray@0.0.2:
resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3959,6 +3971,16 @@ packages:
known-css-properties@0.37.0: known-css-properties@0.37.0:
resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==}
kuromoji@0.1.2:
resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==}
kuroshiro-analyzer-kuromoji@1.1.0:
resolution: {integrity: sha512-BSJFhpsQdPwfFLfjKxfLA9iL+/PC6LCR9vgwgb5Jc7jZwk9ilX8SAV6CwhAQZY611tiuhbB52ONYKDO8hgY1bA==}
kuroshiro@1.2.0:
resolution: {integrity: sha512-yBGCK9oDOY3LGZ/KXaN9m7ADcAuSczOR2FoMRYwHLUlis3/o/uxdMVROAjENFO0NQJgALhIdWxI/vIBVrMCk9w==}
engines: {node: '>=6.5.0'}
lazy-val@1.0.5: lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
@@ -5756,6 +5778,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zlibjs@0.3.1:
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
zod-validation-error@4.0.2: zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -7948,6 +7973,10 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
async@2.6.4:
dependencies:
lodash: 4.18.1
async@3.2.6: {} async@3.2.6: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
@@ -8544,6 +8573,8 @@ snapshots:
dotenv@16.6.1: {} dotenv@16.6.1: {}
doublearray@0.0.2: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -9829,6 +9860,20 @@ snapshots:
known-css-properties@0.37.0: {} known-css-properties@0.37.0: {}
kuromoji@0.1.2:
dependencies:
async: 2.6.4
doublearray: 0.0.2
zlibjs: 0.3.1
kuroshiro-analyzer-kuromoji@1.1.0:
dependencies:
kuromoji: 0.1.2
kuroshiro@1.2.0:
dependencies:
'@babel/runtime': 7.29.7
lazy-val@1.0.5: {} lazy-val@1.0.5: {}
lead@4.0.0: {} lead@4.0.0: {}
@@ -11818,6 +11863,8 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zlibjs@0.3.1: {}
zod-validation-error@4.0.2(zod@3.25.76): zod-validation-error@4.0.2(zod@3.25.76):
dependencies: dependencies:
zod: 3.25.76 zod: 3.25.76
+2
View File
@@ -843,6 +843,8 @@
"discordUpdateInterval_description": "The time in seconds between each update (minimum 15 seconds)", "discordUpdateInterval_description": "The time in seconds between each update (minimum 15 seconds)",
"enableAutoTranslation_description": "Enable translation automatically when lyrics are loaded", "enableAutoTranslation_description": "Enable translation automatically when lyrics are loaded",
"enableAutoTranslation": "Enable auto translation", "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_description": "Enables the remote control server to allow other devices to control the application",
"enableRemote": "Enable remote control server", "enableRemote": "Enable remote control server",
"exitToTray_description": "Exit the application to the system tray", "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 { ipcMain } from 'electron';
import { store } from '../settings'; import { store } from '../settings';
import { convertFurigana } from './furigana';
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius'; import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib'; import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease'; 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); const lyricResults = await getRemoteLyricsById(params);
return lyricResults; 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; return result;
}; };
const convertFurigana = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-furigana', text);
};
export const lyrics = { export const lyrics = {
convertFurigana,
getRemoteLyricsByRemoteId, getRemoteLyricsByRemoteId,
getRemoteLyricsBySong, getRemoteLyricsBySong,
searchRemoteLyrics, searchRemoteLyrics,
@@ -291,6 +291,22 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.lyricFetchProvider'), 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: ( control: (
<Switch <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 styles from './lyric-line.module.css';
import { sanitize } from '/@/renderer/utils/sanitize';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
@@ -28,7 +29,7 @@ export const LyricLine = memo(
<Box className={clsx(styles.lyricLine, className)} style={style} {...props}> <Box className={clsx(styles.lyricLine, className)} style={style} {...props}>
<Stack gap={0}> <Stack gap={0}>
{lines.map((line, index) => ( {lines.map((line, index) => (
<span key={index}>{line}</span> <span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
))} ))}
</Stack> </Stack>
</Box> </Box>
+11 -1
View File
@@ -14,6 +14,7 @@ import {
type LyricsQueryResult, type LyricsQueryResult,
} from '/@/renderer/features/lyrics/api/lyrics-api'; } from '/@/renderer/features/lyrics/api/lyrics-api';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form'; 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 { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import { import {
SynchronizedLyrics, SynchronizedLyrics,
@@ -49,6 +50,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const { const {
enableAutoTranslation, enableAutoTranslation,
enableFurigana,
preferLocalLyrics, preferLocalLyrics,
translationApiKey, translationApiKey,
translationApiProvider, translationApiProvider,
@@ -116,7 +118,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
return computeSelectedFromResult(data, preferLocalLyrics, indexToUse); return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);
}, [data, indexToUse, preferLocalLyrics]); }, [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(() => { const currentOffsetMs = useMemo(() => {
if (!data) return 0; if (!data) return 0;
@@ -93,6 +93,20 @@ export const LyricSettings = memo(() => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.lyricFetchProvider'), 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: ( control: (
<Switch <Switch
+2
View File
@@ -576,6 +576,7 @@ const LyricsSettingsSchema = z.object({
alignment: z.enum(['center', 'left', 'right']), alignment: z.enum(['center', 'left', 'right']),
delayMs: z.number(), delayMs: z.number(),
enableAutoTranslation: z.boolean(), enableAutoTranslation: z.boolean(),
enableFurigana: z.boolean().optional(),
enableNeteaseTranslation: z.boolean(), enableNeteaseTranslation: z.boolean(),
fetch: z.boolean(), fetch: z.boolean(),
follow: z.boolean(), follow: z.boolean(),
@@ -1845,6 +1846,7 @@ const initialState: SettingsState = {
alignment: 'center', alignment: 'center',
delayMs: 0, delayMs: 0,
enableAutoTranslation: false, enableAutoTranslation: false,
enableFurigana: false,
enableNeteaseTranslation: false, enableNeteaseTranslation: false,
fetch: true, fetch: true,
follow: true, follow: true,
+1 -1
View File
@@ -2,7 +2,7 @@ import DomPurify, { Config } from 'dompurify';
const SANITIZE_OPTIONS: Config = { const SANITIZE_OPTIONS: Config = {
ALLOWED_ATTR: ['href'], 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) // allow http://, https://, and // (mapped to https)
ALLOWED_URI_REGEXP: /^(http(s?):)?\/\/.+/i, ALLOWED_URI_REGEXP: /^(http(s?):)?\/\/.+/i,
}; };