mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-23 20:37:42 +02:00
feat: add Japanese Furigana support to lyrics display (#2161)
This commit is contained in:
@@ -114,6 +114,8 @@
|
||||
"idb-keyval": "^6.2.5",
|
||||
"immer": "^10.2.0",
|
||||
"is-electron": "^2.2.2",
|
||||
"kuroshiro": "^1.2.0",
|
||||
"kuroshiro-analyzer-kuromoji": "^1.1.0",
|
||||
"lodash": "^4.18.1",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^12.40.0",
|
||||
|
||||
Generated
+47
@@ -138,6 +138,12 @@ importers:
|
||||
is-electron:
|
||||
specifier: ^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:
|
||||
specifier: ^4.18.1
|
||||
version: 4.18.1
|
||||
@@ -2392,6 +2398,9 @@ packages:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
async@2.6.4:
|
||||
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
@@ -2926,6 +2935,9 @@ packages:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
doublearray@0.0.2:
|
||||
resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3959,6 +3971,16 @@ packages:
|
||||
known-css-properties@0.37.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
|
||||
|
||||
@@ -5756,6 +5778,9 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zlibjs@0.3.1:
|
||||
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
|
||||
|
||||
zod-validation-error@4.0.2:
|
||||
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -7948,6 +7973,10 @@ snapshots:
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
async@2.6.4:
|
||||
dependencies:
|
||||
lodash: 4.18.1
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -8544,6 +8573,8 @@ snapshots:
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
doublearray@0.0.2: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -9829,6 +9860,20 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
lead@4.0.0: {}
|
||||
@@ -11818,6 +11863,8 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
zod-validation-error@4.0.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user