mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-27 14:27:33 +02:00
feat: add romaji lyrics display (#2180)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user