Lyrics Translation and Romaji (Fulfill #732) [Translation Part] (#747)

This commit is contained in:
Xudong Zhou 2024-09-24 11:25:17 +08:00 committed by GitHub
parent e3946a9413
commit 31492fa9ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 243 additions and 46 deletions

13
package-lock.json generated
View File

@ -20286,10 +20286,11 @@
}
},
"node_modules/serialize-javascript": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz",
"integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
}
@ -38353,9 +38354,9 @@
}
},
"serialize-javascript": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz",
"integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"requires": {
"randombytes": "^2.1.0"

View File

@ -309,8 +309,8 @@
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"auto-text-size": "^0.2.3",
"audiomotion-analyzer": "^4.5.0",
"auto-text-size": "^0.2.3",
"axios": "^1.6.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",

View File

@ -2311,7 +2311,8 @@
"ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"requires": {}
},
"xml2js": {
"version": "0.4.23",

View File

@ -109,6 +109,7 @@
"trackNumber": "track",
"trackGain": "track gain",
"trackPeak": "track peak",
"translation": "translation",
"unknown": "unknown",
"version": "version",
"year": "year",
@ -355,7 +356,8 @@
},
"lyrics": "lyrics",
"related": "related",
"upNext": "up next"
"upNext": "up next",
"visualizer": "visualizer"
},
"genreList": {
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
@ -653,6 +655,12 @@
"transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick",
"transcodeFormat": "format to transcode",
"transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide",
"translationApiProvider": "translation api provider",
"translationApiProvider_description": "api provider for translation",
"translationApiKey": "translation api key",
"translationApiKey_description": "api key for translation (Support global service endpoint only)",
"translationTargetLanguage": "translation target language",
"translationTargetLanguage_description": "target language for translation",
"trayEnabled": "show tray",
"trayEnabled_description": "show/hide tray icon/menu. if disabled, also disables minimize/exit to tray",
"useSystemTheme": "use system theme",

View File

@ -19,6 +19,7 @@ interface LyricsActionsProps {
onRemoveLyric: () => void;
onResetLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void;
onTranslateLyric: () => void;
setIndex: (idx: number) => void;
}
@ -28,6 +29,7 @@ export const LyricsActions = ({
onRemoveLyric,
onResetLyric,
onSearchOverride,
onTranslateLyric,
setIndex,
}: LyricsActionsProps) => {
const { t } = useTranslation();
@ -120,7 +122,6 @@ export const LyricsActions = ({
{isDesktop && sources.length ? (
<Button
uppercase
color="red"
disabled={isActionsDisabled}
variant="subtle"
onClick={onRemoveLyric}
@ -129,6 +130,19 @@ export const LyricsActions = ({
</Button>
) : null}
</Box>
<Box style={{ position: 'absolute', right: 0, top: -50 }}>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={onTranslateLyric}
>
{t('common.translation', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Box>
</Box>
);
};

View File

@ -5,6 +5,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { RiInformationFill } from 'react-icons/ri';
import styled from 'styled-components';
import { useSongLyricsByRemoteId, useSongLyricsBySong } from './queries/lyric-query';
import { translateLyrics } from './queries/lyric-translate';
import { SynchronizedLyrics, SynchronizedLyricsProps } from './synchronized-lyrics';
import { Spinner, TextTitle } from '/@/renderer/components';
import { ErrorFallback } from '/@/renderer/features/action-required';
@ -12,7 +13,7 @@ import {
UnsynchronizedLyrics,
UnsynchronizedLyricsProps,
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
import { useCurrentSong, usePlayerStore, useLyricsSettings } from '/@/renderer/store';
import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/renderer/api/types';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import { queryKeys } from '/@/renderer/api/query-keys';
@ -84,7 +85,10 @@ const ScrollContainer = styled(motion.div)`
export const Lyrics = () => {
const currentSong = useCurrentSong();
const lyricsSettings = useLyricsSettings();
const [index, setIndex] = useState(0);
const [translatedLyrics, setTranslatedLyrics] = useState<string | null>(null);
const [showTranslation, setShowTranslation] = useState(false);
const { data, isInitialLoading } = useSongLyricsBySong(
{
@ -96,6 +100,19 @@ export const Lyrics = () => {
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
const [lyrics, synced] = useMemo(() => {
if (Array.isArray(data)) {
if (data.length > 0) {
const selectedLyric = data[Math.min(index, data.length)];
return [selectedLyric, selectedLyric.synced];
}
} else if (data?.lyrics) {
return [data, Array.isArray(data.lyrics)];
}
return [undefined, false];
}, [data, index]);
const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
setOverride(params);
}, []);
@ -123,6 +140,27 @@ export const Lyrics = () => {
);
}, [currentSong?.id, currentSong?.serverId]);
const handleOnTranslateLyric = useCallback(async () => {
if (translatedLyrics) {
setShowTranslation(!showTranslation);
return;
}
if (!lyrics) return;
const originalLyrics = Array.isArray(lyrics.lyrics)
? lyrics.lyrics.map(([, line]) => line).join('\n')
: lyrics.lyrics;
const { translationApiKey, translationApiProvider, translationTargetLanguage } =
lyricsSettings;
const TranslatedText: string | null = await translateLyrics(
originalLyrics,
translationApiKey,
translationApiProvider,
translationTargetLanguage,
);
setTranslatedLyrics(TranslatedText);
setShowTranslation(true);
}, [lyrics, lyricsSettings, translatedLyrics, showTranslation]);
const { isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({
options: {
enabled: !!override,
@ -150,19 +188,6 @@ export const Lyrics = () => {
};
}, []);
const [lyrics, synced] = useMemo(() => {
if (Array.isArray(data)) {
if (data.length > 0) {
const selectedLyric = data[Math.min(index, data.length)];
return [selectedLyric, selectedLyric.synced];
}
} else if (data?.lyrics) {
return [data, Array.isArray(data.lyrics)];
}
return [undefined, false];
}, [data, index]);
const languages = useMemo(() => {
if (Array.isArray(data)) {
return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
@ -203,10 +228,14 @@ export const Lyrics = () => {
transition={{ duration: 0.5 }}
>
{synced ? (
<SynchronizedLyrics {...(lyrics as SynchronizedLyricsProps)} />
<SynchronizedLyrics
{...(lyrics as SynchronizedLyricsProps)}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
) : (
<UnsynchronizedLyrics
{...(lyrics as UnsynchronizedLyricsProps)}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
)}
</ScrollContainer>
@ -221,6 +250,7 @@ export const Lyrics = () => {
onRemoveLyric={handleOnRemoveLyric}
onResetLyric={handleOnResetLyric}
onSearchOverride={handleOnSearchOverride}
onTranslateLyric={handleOnTranslateLyric}
/>
</ActionsContainer>
</LyricsContainer>

View File

@ -0,0 +1,50 @@
import axios from 'axios';
export const translateLyrics = async (
originalLyrics: string,
translationApiKey: string,
translationApiProvider: string | null,
translationTargetLanguage: string | null,
) => {
let TranslatedText = '';
if (translationApiProvider === 'Microsoft Azure') {
try {
const response = await axios({
data: [
{
Text: originalLyrics,
},
],
headers: {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': translationApiKey,
},
method: 'post',
url: `https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=${translationTargetLanguage as string}`,
});
TranslatedText = response.data[0].translations[0].text;
} catch (e) {
console.error('Microsoft Azure translate request got an error!', e);
return null;
}
} else if (translationApiProvider === 'Google Cloud') {
try {
const response = await axios({
data: {
format: 'text',
q: originalLyrics,
},
headers: {
'Content-Type': 'application/json',
},
method: 'post',
url: `https://translation.googleapis.com/language/translate/v2?target=${translationTargetLanguage as string}&key=${translationApiKey}`,
});
TranslatedText = response.data.data.translations[0].translatedText;
} catch (e) {
console.error('Google Cloud translate request got an error!', e);
return null;
}
}
return TranslatedText;
};

View File

@ -55,6 +55,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray;
translatedLyrics?: string | null;
}
export const SynchronizedLyrics = ({
@ -63,6 +64,7 @@ export const SynchronizedLyrics = ({
name,
remote,
source,
translatedLyrics,
}: SynchronizedLyricsProps) => {
const playersRef = PlayersRef;
const status = useCurrentStatus();
@ -364,8 +366,8 @@ export const SynchronizedLyrics = ({
/>
)}
{lyrics.map(([time, text], idx) => (
<div key={idx}>
<LyricLine
key={idx}
alignment={settings.alignment}
className="lyric-line synchronized"
fontSize={settings.fontSize}
@ -373,6 +375,16 @@ export const SynchronizedLyrics = ({
text={text}
onClick={() => handleSeek(time / 1000)}
/>
{translatedLyrics && (
<LyricLine
alignment={settings.alignment}
className="lyric-line synchronized translation"
fontSize={settings.fontSize * 0.8}
text={translatedLyrics.split('\n')[idx]}
onClick={() => handleSeek(time / 1000)}
/>
)}
</div>
))}
</SynchronizedLyricsContainer>
);

View File

@ -6,6 +6,7 @@ import { useLyricsSettings } from '/@/renderer/store';
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
translatedLyrics?: string | null;
}
const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
@ -45,12 +46,17 @@ export const UnsynchronizedLyrics = ({
name,
remote,
source,
translatedLyrics,
}: UnsynchronizedLyricsProps) => {
const settings = useLyricsSettings();
const lines = useMemo(() => {
return lyrics.split('\n');
}, [lyrics]);
const translatedLines = useMemo(() => {
return translatedLyrics ? translatedLyrics.split('\n') : [];
}, [translatedLyrics]);
return (
<UnsynchronizedLyricsContainer
$gap={settings.gapUnsync}
@ -73,14 +79,23 @@ export const UnsynchronizedLyrics = ({
/>
)}
{lines.map((text, idx) => (
<div key={idx}>
<LyricLine
key={idx}
alignment={settings.alignment}
className="lyric-line unsynchronized"
fontSize={settings.fontSizeUnsync}
id={`lyric-${idx}`}
text={text}
/>
{translatedLines[idx] && (
<LyricLine
alignment={settings.alignment}
className="lyric-line unsynchronized translation"
fontSize={settings.fontSizeUnsync * 0.8}
text={translatedLines[idx]}
/>
)}
</div>
))}
</UnsynchronizedLyricsContainer>
);

View File

@ -98,7 +98,7 @@ export const FullScreenPlayerQueue = () => {
items.push({
active: activeTab === 'visualizer',
icon: <RiFileTextLine size="1.5rem" />,
label: 'Visualizer',
label: t('page.fullscreenPlayer.visualizer'),
onClick: () => setStore({ activeTab: 'visualizer' }),
});
}

View File

@ -5,12 +5,12 @@ import styled from 'styled-components';
import { useSettingsStore } from '/@/renderer/store';
const StyledContainer = styled.div`
margin: auto;
max-width: 100%;
margin: auto;
canvas {
margin: auto;
width: 100%;
margin: auto;
}
`;

View File

@ -3,11 +3,19 @@ import {
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useLyricsSettings, useSettingsStoreActions } from '/@/renderer/store';
import { MultiSelect, MultiSelectProps, NumberInput, Switch } from '/@/renderer/components';
import {
Select,
MultiSelect,
MultiSelectProps,
TextInput,
NumberInput,
Switch,
} from '/@/renderer/components';
import isElectron from 'is-electron';
import styled from 'styled-components';
import { LyricSource } from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
import { languages } from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;
@ -116,6 +124,58 @@ export const LyricSettings = () => {
isHidden: !isElectron(),
title: t('setting.lyricOffset', { postProcess: 'sentenceCase' }),
},
{
control: (
<Select
data={languages}
value={settings.translationTargetLanguage}
onChange={(value) => {
setSettings({ lyrics: { ...settings, translationTargetLanguage: value } });
}}
/>
),
description: t('setting.translationTargetLanguage', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.translationTargetLanguage', { postProcess: 'sentenceCase' }),
},
{
control: (
<Select
data={['Microsoft Azure', 'Google Cloud']}
value={settings.translationApiProvider}
onChange={(value) => {
setSettings({ lyrics: { ...settings, translationApiProvider: value } });
}}
/>
),
description: t('setting.translationApiProvider', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.translationApiProvider', { postProcess: 'sentenceCase' }),
},
{
control: (
<TextInput
value={settings.translationApiKey}
onChange={(e) => {
setSettings({
lyrics: { ...settings, translationApiKey: e.currentTarget.value },
});
}}
/>
),
description: t('setting.translationApiKey', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.translationApiKey', { postProcess: 'sentenceCase' }),
},
];
return (

View File

@ -275,6 +275,9 @@ export interface SettingsState {
showMatch: boolean;
showProvider: boolean;
sources: LyricSource[];
translationApiKey: string;
translationApiProvider: string | null;
translationTargetLanguage: string | null;
};
playback: {
audioDeviceId?: string | null;
@ -449,6 +452,9 @@ const initialState: SettingsState = {
showMatch: true,
showProvider: true,
sources: [],
translationApiKey: '',
translationApiProvider: '',
translationTargetLanguage: 'en',
},
playback: {
audioDeviceId: undefined,