diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1c44c054..a429a65c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -609,6 +609,8 @@ "mpvExtraParameters_help": "one per line", "musicbrainz": "show musicbrainz links", "musicbrainz_description": "show links to musicbrainz on artist/album pages, where mbid exists", + "neteaseTranslation": "Enable NetEase translations", + "neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available.", "passwordStore": "passwords/secret store", "passwordStore_description": "what password/secret store to use. change this if you are having issues storing passwords.", "playbackStyle": "playback style", diff --git a/src/i18n/locales/zh-Hans.json b/src/i18n/locales/zh-Hans.json index 4d18abda..da44d80f 100644 --- a/src/i18n/locales/zh-Hans.json +++ b/src/i18n/locales/zh-Hans.json @@ -180,6 +180,8 @@ "followLyric_description": "滚动歌词到当前播放位置", "audioExclusiveMode": "音频独占模式", "font": "字体", + "neteaseTranslation": "启用网易云歌词翻译", + "neteaseTranslation_description": "启用后,在获取歌词时将包含并显示网易云音乐提供的翻译(如果存在)。", "crossfadeDuration_description": "设置淡入淡出持续时间", "audioDevice": "音频设备", "enableRemote": "启用远程控制服务器", diff --git a/src/main/features/core/lyrics/netease.ts b/src/main/features/core/lyrics/netease.ts index bf702e5b..a28d96ab 100644 --- a/src/main/features/core/lyrics/netease.ts +++ b/src/main/features/core/lyrics/netease.ts @@ -6,6 +6,7 @@ import { LyricSearchQuery, LyricSource, } from '.'; +import { store } from '../settings'; import { orderSearchResults } from './shared'; const SEARCH_URL = 'https://music.163.com/api/search/get'; @@ -76,14 +77,20 @@ export async function getLyricsBySongId(songId: string): Promise id: songId, kv: '-1', lv: '-1', + tv: '-1', }, }); } catch (e) { console.error('NetEase lyrics request got an error!', e); return null; } - - return result.data.klyric?.lyric || result.data.lrc?.lyric; + const enableTranslation = store.get('enableNeteaseTranslation', true) as boolean; + const originalLrc = result.data.lrc?.lyric; + if (!enableTranslation) { + return originalLrc || null; + } + const translatedLrc = result.data.tlyric?.lyric; + return mergeLyrics(originalLrc, translatedLrc); } export async function getSearchResults( @@ -166,3 +173,54 @@ async function getMatchedLyrics( return firstMatch; } + +function mergeLyrics(original: string | undefined, translated: string | undefined): null | string { + if (!original) { + return null; + } + if (!translated) { + return original; + } + + const lrcLineRegex = /\[(\d{2}:\d{2}\.\d{2,3})\](.*)/; + const translatedMap = new Map(); + + // Parse the translated LRC and store it in a Map for efficient timestamp-based lookups. + translated.split('\n').forEach((line) => { + const match = line.match(lrcLineRegex); + if (match) { + const timestamp = match[1]; + const text = match[2].trim(); + if (text) { + translatedMap.set(timestamp, text); + } + } + }); + + if (translatedMap.size === 0) { + return original; + } + + // Iterate through each line of the original LRC. If a translation exists for + // the same timestamp, insert it as a new, fully-formatted LRC line. + const finalLines = original.split('\n').flatMap((line) => { + const match = line.match(lrcLineRegex); + + if (match) { + const timestamp = match[1]; + const translatedText = translatedMap.get(timestamp); + + if (translatedText) { + // Return an array containing both the original line and the new translated line. + // flatMap will flatten this into the final array of lines. + const translatedLine = `[${timestamp}]${translatedText}`; + return [line, translatedLine]; + } + } + + // If no match or no translation is found, return only the original line. + return [line]; + }); + + return finalLines.join('\n'); +} diff --git a/src/renderer/features/settings/components/playback/lyric-settings.tsx b/src/renderer/features/settings/components/playback/lyric-settings.tsx index a2a14ea5..9d7f94ba 100644 --- a/src/renderer/features/settings/components/playback/lyric-settings.tsx +++ b/src/renderer/features/settings/components/playback/lyric-settings.tsx @@ -101,6 +101,30 @@ export const LyricSettings = () => { isHidden: !isElectron(), title: t('setting.lyricFetchProvider', { postProcess: 'sentenceCase' }), }, + { + control: ( + { + const isChecked = e.currentTarget.checked; + setSettings({ + lyrics: { + ...settings, + enableNeteaseTranslation: e.currentTarget.checked, + }, + }); + localSettings?.set('enableNeteaseTranslation', isChecked); + }} + /> + ), + description: t('setting.neteaseTranslation', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !isElectron(), + title: t('setting.neteaseTranslation', { postProcess: 'sentenceCase' }), + }, { control: (