diff --git a/package-lock.json b/package-lock.json index 804074459..5fb22d74f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 17153a39c..c7a1c2984 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release/app/package-lock.json b/release/app/package-lock.json index 2396932a2..b7bcb4af1 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -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", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1e759751d..e91b3527a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index b9edc7088..75d6b3a82 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -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 ? ( + ) : null} + ); }; diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index 1cf276a1d..33d8e32cc 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -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(null); + const [showTranslation, setShowTranslation] = useState(false); const { data, isInitialLoading } = useSongLyricsBySong( { @@ -96,6 +100,19 @@ export const Lyrics = () => { const [override, setOverride] = useState(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 ? ( - + ) : ( )} @@ -221,6 +250,7 @@ export const Lyrics = () => { onRemoveLyric={handleOnRemoveLyric} onResetLyric={handleOnResetLyric} onSearchOverride={handleOnSearchOverride} + onTranslateLyric={handleOnTranslateLyric} /> diff --git a/src/renderer/features/lyrics/queries/lyric-translate.ts b/src/renderer/features/lyrics/queries/lyric-translate.ts new file mode 100644 index 000000000..f6d6039b6 --- /dev/null +++ b/src/renderer/features/lyrics/queries/lyric-translate.ts @@ -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; +}; diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 913d56601..53ee960b4 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -55,6 +55,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>` export interface SynchronizedLyricsProps extends Omit { 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,15 +366,25 @@ export const SynchronizedLyrics = ({ /> )} {lyrics.map(([time, text], idx) => ( - handleSeek(time / 1000)} - /> +
+ handleSeek(time / 1000)} + /> + {translatedLyrics && ( + handleSeek(time / 1000)} + /> + )} +
))} ); diff --git a/src/renderer/features/lyrics/unsynchronized-lyrics.tsx b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx index 251f0196a..1d94baf26 100644 --- a/src/renderer/features/lyrics/unsynchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx @@ -6,6 +6,7 @@ import { useLyricsSettings } from '/@/renderer/store'; export interface UnsynchronizedLyricsProps extends Omit { 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 ( )} {lines.map((text, idx) => ( - +
+ + {translatedLines[idx] && ( + + )} +
))}
); diff --git a/src/renderer/features/player/components/full-screen-player-queue.tsx b/src/renderer/features/player/components/full-screen-player-queue.tsx index 6906477cf..16e072191 100644 --- a/src/renderer/features/player/components/full-screen-player-queue.tsx +++ b/src/renderer/features/player/components/full-screen-player-queue.tsx @@ -98,7 +98,7 @@ export const FullScreenPlayerQueue = () => { items.push({ active: activeTab === 'visualizer', icon: , - label: 'Visualizer', + label: t('page.fullscreenPlayer.visualizer'), onClick: () => setStore({ activeTab: 'visualizer' }), }); } diff --git a/src/renderer/features/player/components/visualizer.tsx b/src/renderer/features/player/components/visualizer.tsx index 8709f5a72..3e633eec6 100644 --- a/src/renderer/features/player/components/visualizer.tsx +++ b/src/renderer/features/player/components/visualizer.tsx @@ -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; } `; diff --git a/src/renderer/features/settings/components/playback/lyric-settings.tsx b/src/renderer/features/settings/components/playback/lyric-settings.tsx index 157536667..7d396f2ef 100644 --- a/src/renderer/features/settings/components/playback/lyric-settings.tsx +++ b/src/renderer/features/settings/components/playback/lyric-settings.tsx @@ -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: ( + { + setSettings({ lyrics: { ...settings, translationApiProvider: value } }); + }} + /> + ), + description: t('setting.translationApiProvider', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !isElectron(), + title: t('setting.translationApiProvider', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + setSettings({ + lyrics: { ...settings, translationApiKey: e.currentTarget.value }, + }); + }} + /> + ), + description: t('setting.translationApiKey', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !isElectron(), + title: t('setting.translationApiKey', { postProcess: 'sentenceCase' }), + }, ]; return ( diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index db64decec..dc03bda5a 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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,