mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-05 18:19:56 +02:00
feat: add romaji lyrics display (#2180)
This commit is contained in:
@@ -845,6 +845,8 @@
|
|||||||
"enableAutoTranslation": "Enable auto translation",
|
"enableAutoTranslation": "Enable auto translation",
|
||||||
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
|
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
|
||||||
"enableFurigana": "Enable furigana generation",
|
"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_descriptionMpv": "Parametric equalizer via FFmpeg lavfi (MPV)",
|
||||||
"equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API",
|
"equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API",
|
||||||
"equalizer": "Equalizer",
|
"equalizer": "Equalizer",
|
||||||
|
|||||||
@@ -7,16 +7,19 @@ let kuroshiroInstance: any = null;
|
|||||||
let initPromise: null | Promise<void> = null;
|
let initPromise: null | Promise<void> = null;
|
||||||
|
|
||||||
const getKuroshiro = async () => {
|
const getKuroshiro = async () => {
|
||||||
if (kuroshiroInstance) return kuroshiroInstance;
|
|
||||||
if (initPromise) {
|
if (initPromise) {
|
||||||
await initPromise;
|
await initPromise;
|
||||||
return kuroshiroInstance;
|
return kuroshiroInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kuroshiroInstance) return kuroshiroInstance;
|
||||||
|
|
||||||
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
|
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
|
||||||
kuroshiroInstance = new KuroshiroClass();
|
kuroshiroInstance = new KuroshiroClass();
|
||||||
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
|
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
|
||||||
await initPromise;
|
await initPromise;
|
||||||
|
|
||||||
|
initPromise = null;
|
||||||
return kuroshiroInstance;
|
return kuroshiroInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,3 +38,17 @@ export const convertFurigana = async (text: string): Promise<string> => {
|
|||||||
return text;
|
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 { ipcMain } from 'electron';
|
||||||
|
|
||||||
import { store } from '../settings';
|
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 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';
|
||||||
@@ -236,3 +236,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
|
|||||||
ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => {
|
ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => {
|
||||||
return await convertFurigana(text);
|
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);
|
return ipcRenderer.invoke('lyric-convert-furigana', text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const convertRomaji = (text: string): Promise<string> => {
|
||||||
|
return ipcRenderer.invoke('lyric-convert-romaji', text);
|
||||||
|
};
|
||||||
|
|
||||||
export const lyrics = {
|
export const lyrics = {
|
||||||
convertFurigana,
|
convertFurigana,
|
||||||
|
convertRomaji,
|
||||||
getRemoteLyricsByRemoteId,
|
getRemoteLyricsByRemoteId,
|
||||||
getRemoteLyricsBySong,
|
getRemoteLyricsBySong,
|
||||||
searchRemoteLyrics,
|
searchRemoteLyrics,
|
||||||
|
|||||||
@@ -307,6 +307,20 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
|
|||||||
isHidden: !isElectron(),
|
isHidden: !isElectron(),
|
||||||
title: t('setting.enableFurigana'),
|
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: (
|
control: (
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -28,3 +28,27 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
|
|||||||
staleTime: Infinity,
|
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) {
|
.lyric-line:global(.synchronized) {
|
||||||
cursor: pointer;
|
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'> {
|
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
|
||||||
alignment: 'center' | 'left' | 'right';
|
alignment: 'center' | 'left' | 'right';
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
romajiText?: null | string;
|
||||||
text: string;
|
text: string;
|
||||||
|
translatedText?: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LyricLine = memo(
|
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 lines = useMemo(() => text.split('_BREAK_'), [text]);
|
||||||
|
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
@@ -31,6 +41,15 @@ export const LyricLine = memo(
|
|||||||
{lines.map((line, index) => (
|
{lines.map((line, index) => (
|
||||||
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
|
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
|
||||||
))}
|
))}
|
||||||
|
{romajiText && (
|
||||||
|
<span
|
||||||
|
className={styles.romajiLine}
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{translatedText && (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ 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 {
|
||||||
|
useFuriganaLyrics,
|
||||||
|
useRomajiLyrics,
|
||||||
|
} 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,
|
||||||
@@ -51,6 +54,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
const {
|
const {
|
||||||
enableAutoTranslation,
|
enableAutoTranslation,
|
||||||
enableFurigana,
|
enableFurigana,
|
||||||
|
enableRomaji,
|
||||||
preferLocalLyrics,
|
preferLocalLyrics,
|
||||||
translationApiKey,
|
translationApiKey,
|
||||||
translationApiProvider,
|
translationApiProvider,
|
||||||
@@ -119,6 +123,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
}, [data, indexToUse, preferLocalLyrics]);
|
}, [data, indexToUse, preferLocalLyrics]);
|
||||||
|
|
||||||
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
|
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
|
||||||
|
const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji);
|
||||||
|
|
||||||
const displayLyrics = useMemo(() => {
|
const displayLyrics = useMemo(() => {
|
||||||
if (isLyricsDisabled || !lyrics) return null;
|
if (isLyricsDisabled || !lyrics) return null;
|
||||||
@@ -344,12 +349,22 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
<SynchronizedLyrics
|
<SynchronizedLyrics
|
||||||
{...(displayLyrics as SynchronizedLyricsProps)}
|
{...(displayLyrics as SynchronizedLyricsProps)}
|
||||||
offsetMs={displayOffsetMs}
|
offsetMs={displayOffsetMs}
|
||||||
|
romajiLyrics={
|
||||||
|
enableRomaji
|
||||||
|
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
|
||||||
|
: null
|
||||||
|
}
|
||||||
settingsKey={settingsKey}
|
settingsKey={settingsKey}
|
||||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UnsynchronizedLyrics
|
<UnsynchronizedLyrics
|
||||||
{...(displayLyrics as UnsynchronizedLyricsProps)}
|
{...(displayLyrics as UnsynchronizedLyricsProps)}
|
||||||
|
romajiLyrics={
|
||||||
|
enableRomaji
|
||||||
|
? (romajiConvertedLyrics as UnsynchronizedLyricsProps['romajiLyrics'])
|
||||||
|
: null
|
||||||
|
}
|
||||||
settingsKey={settingsKey}
|
settingsKey={settingsKey}
|
||||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
|
|||||||
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||||
lyrics: SynchronizedLyricsArray;
|
lyrics: SynchronizedLyricsArray;
|
||||||
offsetMs?: number;
|
offsetMs?: number;
|
||||||
|
romajiLyrics?: null | SynchronizedLyricsArray;
|
||||||
settingsKey?: string;
|
settingsKey?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
translatedLyrics?: null | string;
|
translatedLyrics?: null | string;
|
||||||
@@ -34,6 +35,7 @@ export const SynchronizedLyrics = ({
|
|||||||
name,
|
name,
|
||||||
offsetMs,
|
offsetMs,
|
||||||
remote,
|
remote,
|
||||||
|
romajiLyrics,
|
||||||
settingsKey = 'default',
|
settingsKey = 'default',
|
||||||
source,
|
source,
|
||||||
style,
|
style,
|
||||||
@@ -368,10 +370,9 @@ export const SynchronizedLyrics = ({
|
|||||||
handleSeek(time / 1000);
|
handleSeek(time / 1000);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
text={
|
romajiText={romajiLyrics?.[idx]?.[1]}
|
||||||
text +
|
text={text}
|
||||||
(translatedLyrics ? `_BREAK_${translatedLyrics.split('\n')[idx]}` : '')
|
translatedText={translatedLyrics?.split('\n')[idx]}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types';
|
|||||||
|
|
||||||
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||||
lyrics: string;
|
lyrics: string;
|
||||||
|
romajiLyrics?: null | string;
|
||||||
settingsKey?: string;
|
settingsKey?: string;
|
||||||
translatedLyrics?: null | string;
|
translatedLyrics?: null | string;
|
||||||
}
|
}
|
||||||
@@ -17,6 +18,7 @@ export const UnsynchronizedLyrics = ({
|
|||||||
lyrics,
|
lyrics,
|
||||||
name,
|
name,
|
||||||
remote,
|
remote,
|
||||||
|
romajiLyrics,
|
||||||
settingsKey = 'default',
|
settingsKey = 'default',
|
||||||
source,
|
source,
|
||||||
translatedLyrics,
|
translatedLyrics,
|
||||||
@@ -42,6 +44,10 @@ export const UnsynchronizedLyrics = ({
|
|||||||
return translatedLyrics ? translatedLyrics.split('\n') : [];
|
return translatedLyrics ? translatedLyrics.split('\n') : [];
|
||||||
}, [translatedLyrics]);
|
}, [translatedLyrics]);
|
||||||
|
|
||||||
|
const romajiLines = useMemo(() => {
|
||||||
|
return romajiLyrics ? romajiLyrics.split('\n') : [];
|
||||||
|
}, [romajiLyrics]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
|
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
|
||||||
{settings.showProvider && source && (
|
{settings.showProvider && source && (
|
||||||
@@ -67,7 +73,9 @@ export const UnsynchronizedLyrics = ({
|
|||||||
fontSize={settings.fontSizeUnsync}
|
fontSize={settings.fontSizeUnsync}
|
||||||
id={`lyric-${idx}`}
|
id={`lyric-${idx}`}
|
||||||
key={idx}
|
key={idx}
|
||||||
text={text + (translatedLines[idx] ? `_BREAK_${translatedLines[idx]}` : '')}
|
romajiText={romajiLines[idx]}
|
||||||
|
text={text}
|
||||||
|
translatedText={translatedLines[idx]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ export const LyricSettings = memo(() => {
|
|||||||
isHidden: !isElectron(),
|
isHidden: !isElectron(),
|
||||||
title: t('setting.enableFurigana'),
|
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: (
|
control: (
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ const LyricsSettingsSchema = z.object({
|
|||||||
enableAutoTranslation: z.boolean(),
|
enableAutoTranslation: z.boolean(),
|
||||||
enableFurigana: z.boolean().optional(),
|
enableFurigana: z.boolean().optional(),
|
||||||
enableNeteaseTranslation: z.boolean(),
|
enableNeteaseTranslation: z.boolean(),
|
||||||
|
enableRomaji: z.boolean().optional(),
|
||||||
fetch: z.boolean(),
|
fetch: z.boolean(),
|
||||||
follow: z.boolean(),
|
follow: z.boolean(),
|
||||||
preferLocalLyrics: z.boolean(),
|
preferLocalLyrics: z.boolean(),
|
||||||
@@ -1848,6 +1849,7 @@ const initialState: SettingsState = {
|
|||||||
enableAutoTranslation: false,
|
enableAutoTranslation: false,
|
||||||
enableFurigana: false,
|
enableFurigana: false,
|
||||||
enableNeteaseTranslation: false,
|
enableNeteaseTranslation: false,
|
||||||
|
enableRomaji: false,
|
||||||
fetch: true,
|
fetch: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
preferLocalLyrics: true,
|
preferLocalLyrics: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user