diff --git a/src/i18n/lang/en-US/download.ts b/src/i18n/lang/en-US/download.ts index c133192..e0cfbcc 100644 --- a/src/i18n/lang/en-US/download.ts +++ b/src/i18n/lang/en-US/download.ts @@ -64,6 +64,8 @@ export default { noPathSelected: 'Please select download path first', select: 'Select Folder', open: 'Open Folder', + saveLyric: 'Save Lyrics File', + saveLyricDesc: 'Save a separate .lrc lyrics file alongside the downloaded song', fileFormat: 'Filename Format', fileFormatDesc: 'Set how downloaded music files will be named', customFormat: 'Custom Format', diff --git a/src/i18n/lang/en-US/songItem.ts b/src/i18n/lang/en-US/songItem.ts index 969b776..2a8a7b8 100644 --- a/src/i18n/lang/en-US/songItem.ts +++ b/src/i18n/lang/en-US/songItem.ts @@ -3,6 +3,7 @@ export default { play: 'Play', playNext: 'Play Next', download: 'Download', + downloadLyric: 'Download Lyrics', addToPlaylist: 'Add to Playlist', favorite: 'Like', unfavorite: 'Unlike', @@ -15,7 +16,10 @@ export default { downloadFailed: 'Download failed', downloadQueued: 'Added to download queue', addedToNextPlay: 'Added to play next', - getUrlFailed: 'Failed to get music download URL, please check if logged in' + getUrlFailed: 'Failed to get music download URL, please check if logged in', + noLyric: 'No lyrics available for this song', + lyricDownloaded: 'Lyrics downloaded successfully', + lyricDownloadFailed: 'Failed to download lyrics' }, dialog: { dislike: { diff --git a/src/i18n/lang/ja-JP/download.ts b/src/i18n/lang/ja-JP/download.ts index 710cca9..0d69fa6 100644 --- a/src/i18n/lang/ja-JP/download.ts +++ b/src/i18n/lang/ja-JP/download.ts @@ -64,6 +64,8 @@ export default { noPathSelected: 'まずダウンロードパスを選択してください', select: 'フォルダを選択', open: 'フォルダを開く', + saveLyric: '歌詞ファイルを個別に保存', + saveLyricDesc: '楽曲ダウンロード時に .lrc 歌詞ファイルも一緒に保存します', fileFormat: 'ファイル名形式', fileFormatDesc: '音楽ダウンロード時のファイル命名形式を設定', customFormat: 'カスタム形式', diff --git a/src/i18n/lang/ja-JP/songItem.ts b/src/i18n/lang/ja-JP/songItem.ts index a342022..133f7d9 100644 --- a/src/i18n/lang/ja-JP/songItem.ts +++ b/src/i18n/lang/ja-JP/songItem.ts @@ -3,6 +3,7 @@ export default { play: '再生', playNext: '次に再生', download: '楽曲をダウンロード', + downloadLyric: '歌詞をダウンロード', addToPlaylist: 'プレイリストに追加', favorite: 'いいね', unfavorite: 'いいね解除', @@ -15,7 +16,11 @@ export default { downloadFailed: 'ダウンロードに失敗しました', downloadQueued: 'ダウンロードキューに追加しました', addedToNextPlay: '次の再生に追加しました', - getUrlFailed: '音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください' + getUrlFailed: + '音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください', + noLyric: 'この楽曲には歌詞がありません', + lyricDownloaded: '歌詞のダウンロードが完了しました', + lyricDownloadFailed: '歌詞のダウンロードに失敗しました' }, dialog: { dislike: { diff --git a/src/i18n/lang/ko-KR/download.ts b/src/i18n/lang/ko-KR/download.ts index 9bf8ad8..c41813a 100644 --- a/src/i18n/lang/ko-KR/download.ts +++ b/src/i18n/lang/ko-KR/download.ts @@ -64,6 +64,8 @@ export default { noPathSelected: '먼저 다운로드 경로를 선택해주세요', select: '폴더 선택', open: '폴더 열기', + saveLyric: '가사 파일 별도 저장', + saveLyricDesc: '곡 다운로드 시 .lrc 가사 파일도 함께 저장합니다', fileFormat: '파일명 형식', fileFormatDesc: '음악 다운로드 시 파일 이름 형식 설정', customFormat: '사용자 정의 형식', diff --git a/src/i18n/lang/ko-KR/songItem.ts b/src/i18n/lang/ko-KR/songItem.ts index d2c1de5..b81d83e 100644 --- a/src/i18n/lang/ko-KR/songItem.ts +++ b/src/i18n/lang/ko-KR/songItem.ts @@ -3,6 +3,7 @@ export default { play: '재생', playNext: '다음에 재생', download: '곡 다운로드', + downloadLyric: '가사 다운로드', addToPlaylist: '플레이리스트에 추가', favorite: '좋아요', unfavorite: '좋아요 취소', @@ -15,7 +16,10 @@ export default { downloadFailed: '다운로드 실패', downloadQueued: '다운로드 대기열에 추가됨', addedToNextPlay: '다음 재생에 추가됨', - getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요' + getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요', + noLyric: '이 곡에는 가사가 없습니다', + lyricDownloaded: '가사 다운로드 완료', + lyricDownloadFailed: '가사 다운로드 실패' }, dialog: { dislike: { diff --git a/src/i18n/lang/zh-CN/download.ts b/src/i18n/lang/zh-CN/download.ts index 81ecead..b16833d 100644 --- a/src/i18n/lang/zh-CN/download.ts +++ b/src/i18n/lang/zh-CN/download.ts @@ -63,6 +63,8 @@ export default { noPathSelected: '请先选择下载路径', select: '选择文件夹', open: '打开文件夹', + saveLyric: '单独保存歌词文件', + saveLyricDesc: '下载歌曲时同时保存一份 .lrc 歌词文件', fileFormat: '文件名格式', fileFormatDesc: '设置下载音乐时的文件命名格式', customFormat: '自定义格式', diff --git a/src/i18n/lang/zh-CN/songItem.ts b/src/i18n/lang/zh-CN/songItem.ts index c6e98f4..4e38d1e 100644 --- a/src/i18n/lang/zh-CN/songItem.ts +++ b/src/i18n/lang/zh-CN/songItem.ts @@ -3,6 +3,7 @@ export default { play: '播放', playNext: '下一首播放', download: '下载歌曲', + downloadLyric: '下载歌词', addToPlaylist: '添加到歌单', favorite: '喜欢', unfavorite: '取消喜欢', @@ -15,7 +16,10 @@ export default { downloadFailed: '下载失败', downloadQueued: '已加入下载队列', addedToNextPlay: '已添加到下一首播放', - getUrlFailed: '获取音乐下载地址失败,请检查是否登录' + getUrlFailed: '获取音乐下载地址失败,请检查是否登录', + noLyric: '该歌曲暂无歌词', + lyricDownloaded: '歌词下载成功', + lyricDownloadFailed: '歌词下载失败' }, dialog: { dislike: { diff --git a/src/i18n/lang/zh-Hant/download.ts b/src/i18n/lang/zh-Hant/download.ts index eb63dc4..f39747c 100644 --- a/src/i18n/lang/zh-Hant/download.ts +++ b/src/i18n/lang/zh-Hant/download.ts @@ -63,6 +63,8 @@ export default { noPathSelected: '請先選擇下載路徑', select: '選擇資料夾', open: '開啟資料夾', + saveLyric: '單獨儲存歌詞檔案', + saveLyricDesc: '下載歌曲時同時儲存一份 .lrc 歌詞檔案', fileFormat: '檔名格式', fileFormatDesc: '設定下載音樂時的檔案命名格式', customFormat: '自訂格式', diff --git a/src/i18n/lang/zh-Hant/songItem.ts b/src/i18n/lang/zh-Hant/songItem.ts index 81e7da5..583e0b2 100644 --- a/src/i18n/lang/zh-Hant/songItem.ts +++ b/src/i18n/lang/zh-Hant/songItem.ts @@ -3,6 +3,7 @@ export default { play: '播放', playNext: '下一首播放', download: '下載歌曲', + downloadLyric: '下載歌詞', addToPlaylist: '新增至播放清單', favorite: '喜歡', unfavorite: '取消喜歡', @@ -15,7 +16,10 @@ export default { downloadFailed: '下載失敗', downloadQueued: '已加入下載佇列', addedToNextPlay: '已新增至下一首播放', - getUrlFailed: '取得音樂下載位址失敗,請檢查是否登入' + getUrlFailed: '取得音樂下載位址失敗,請檢查是否登入', + noLyric: '該歌曲暫無歌詞', + lyricDownloaded: '歌詞下載成功', + lyricDownloadFailed: '歌詞下載失敗' }, dialog: { dislike: { diff --git a/src/main/modules/fileManager.ts b/src/main/modules/fileManager.ts index 6ff35c7..d213b7f 100644 --- a/src/main/modules/fileManager.ts +++ b/src/main/modules/fileManager.ts @@ -246,6 +246,33 @@ export function initializeFileManager() { }; }); + // 保存歌词文件 + ipcMain.handle( + 'save-lyric-file', + async (_, { filename, lrcContent }: { filename: string; lrcContent: string }) => { + try { + const configStore = getStore(); + const downloadPath = + (configStore.get('set.downloadPath') as string) || app.getPath('downloads'); + const sanitizedName = sanitizeFilename(filename); + let filePath = path.join(downloadPath, `${sanitizedName}.lrc`); + + // 文件已存在时添加序号 + let counter = 1; + while (fs.existsSync(filePath)) { + filePath = path.join(downloadPath, `${sanitizedName} (${counter}).lrc`); + counter++; + } + + await fs.promises.writeFile(filePath, lrcContent, 'utf-8'); + return { success: true, path: filePath }; + } catch (error: any) { + console.error('保存歌词文件失败:', error); + return { success: false, error: error.message }; + } + } + ); + // 添加清除下载历史的处理函数 ipcMain.on('clear-downloads-history', () => { downloadStore.set('history', []); @@ -786,6 +813,17 @@ async function downloadMusic( } } + // 如果启用了单独保存歌词文件,将歌词保存为 .lrc 文件 + if (lyricsContent && configStore.get('set.downloadSaveLyric')) { + try { + const lrcFilePath = finalFilePath.replace(/\.[^.]+$/, '.lrc'); + await fs.promises.writeFile(lrcFilePath, lyricsContent, 'utf-8'); + console.log('歌词文件已保存:', lrcFilePath); + } catch (lrcError) { + console.error('保存歌词文件失败:', lrcError); + } + } + // 保存下载信息 try { const songInfos = configStore.get('downloadedSongs', {}) as Record; diff --git a/src/renderer/components/common/songItemCom/BaseSongItem.vue b/src/renderer/components/common/songItemCom/BaseSongItem.vue index b5e2fc8..9689c4b 100644 --- a/src/renderer/components/common/songItemCom/BaseSongItem.vue +++ b/src/renderer/components/common/songItemCom/BaseSongItem.vue @@ -25,6 +25,7 @@ @play="playMusicEvent(item)" @play-next="handlePlayNext" @download="downloadMusic(item)" + @download-lyric="downloadLyric(item)" @toggle-favorite="toggleFavorite" @toggle-dislike="toggleDislike" @remove="$emit('remove-song', $event)" @@ -71,7 +72,8 @@ const { handleArtistClick, handleMouseEnter, handleMouseLeave, - downloadMusic + downloadMusic, + downloadLyric } = useSongItem(props); // 处理图片加载 diff --git a/src/renderer/components/common/songItemCom/HomeSongItem.vue b/src/renderer/components/common/songItemCom/HomeSongItem.vue index 0b5ff2c..e798271 100644 --- a/src/renderer/components/common/songItemCom/HomeSongItem.vue +++ b/src/renderer/components/common/songItemCom/HomeSongItem.vue @@ -68,6 +68,7 @@ @play="onPlayMusic" @play-next="handlePlayNext" @download="downloadMusic" + @download-lyric="downloadLyric(item)" @toggle-favorite="toggleFavorite" @toggle-dislike="toggleDislike" @remove="$emit('remove-song', $event)" @@ -121,7 +122,8 @@ const { handlePlayNext, handleMenuClick, handleArtistClick, - downloadMusic + downloadMusic, + downloadLyric } = useSongItem(props); const onPlayMusic = () => { diff --git a/src/renderer/components/common/songItemCom/SongItemDropdown.vue b/src/renderer/components/common/songItemCom/SongItemDropdown.vue index db42901..d251056 100644 --- a/src/renderer/components/common/songItemCom/SongItemDropdown.vue +++ b/src/renderer/components/common/songItemCom/SongItemDropdown.vue @@ -41,6 +41,7 @@ const emits = defineEmits([ 'play', 'play-next', 'download', + 'download-lyric', 'add-to-playlist', 'toggle-favorite', 'toggle-dislike', @@ -153,6 +154,11 @@ const dropdownOptions = computed(() => { key: 'download', icon: () => h('i', { class: 'iconfont ri-download-line' }) }, + { + label: t('songItem.menu.downloadLyric'), + key: 'downloadLyric', + icon: () => h('i', { class: 'iconfont ri-file-text-line' }) + }, { label: t('songItem.menu.addToPlaylist'), key: 'addToPlaylist', @@ -203,6 +209,9 @@ const handleSelect = (key: string | number) => { case 'download': emits('download'); break; + case 'downloadLyric': + emits('download-lyric'); + break; case 'playNext': emits('play-next'); break; diff --git a/src/renderer/hooks/useDownload.ts b/src/renderer/hooks/useDownload.ts index a7ecacb..e76e98a 100644 --- a/src/renderer/hooks/useDownload.ts +++ b/src/renderer/hooks/useDownload.ts @@ -3,6 +3,7 @@ import { useMessage } from 'naive-ui'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; +import { getMusicLrc } from '@/api/music'; import { getSongUrl } from '@/store/modules/player'; import type { SongResult } from '@/types/music'; import { isElectron } from '@/utils'; @@ -302,9 +303,91 @@ export const useDownload = () => { } }; + /** + * 下载单首歌曲的歌词(.lrc 文件) + * @param song 歌曲信息 + */ + const downloadLyric = async (song: SongResult) => { + try { + const res = await getMusicLrc(song.id as number); + const lyricData = res?.data; + + if (!lyricData?.lrc?.lyric) { + message.warning(t('songItem.message.noLyric')); + return; + } + + // 构建 LRC 内容:保留原始歌词,如有翻译则合并 + let lrcContent = lyricData.lrc.lyric; + if (lyricData.tlyric?.lyric) { + lrcContent = mergeLrcWithTranslation(lyricData.lrc.lyric, lyricData.tlyric.lyric); + } + + // 构建文件名 + const artistNames = (song.ar || song.song?.artists)?.map((a) => a.name).join(','); + const filename = `${song.name} - ${artistNames}`; + + const result = await ipcRenderer?.invoke('save-lyric-file', { filename, lrcContent }); + + if (result?.success) { + message.success(t('songItem.message.lyricDownloaded')); + } else { + message.error(t('songItem.message.lyricDownloadFailed')); + } + } catch (error) { + console.error('Download lyric error:', error); + message.error(t('songItem.message.lyricDownloadFailed')); + } + }; + return { isDownloading, downloadMusic, + downloadLyric, batchDownloadMusic }; }; + +/** + * 将原文歌词和翻译歌词合并为一个 LRC 字符串 + */ +function mergeLrcWithTranslation(originalText: string, translationText: string): string { + const originalMap = parseLrcText(originalText); + const translationMap = parseLrcText(translationText); + + const mergedLines: string[] = []; + + for (const [timeTag, content] of originalMap.entries()) { + mergedLines.push(`${timeTag}${content}`); + const translated = translationMap.get(timeTag); + if (translated) { + mergedLines.push(`${timeTag}${translated}`); + } + } + + // 按时间排序 + mergedLines.sort((a, b) => { + const ta = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; + const tb = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; + return ta.localeCompare(tb); + }); + + return mergedLines.join('\n'); +} + +/** + * 解析 LRC 文本为 Map + */ +function parseLrcText(text: string): Map { + const map = new Map(); + for (const line of text.split('\n')) { + const tags = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g); + if (!tags) continue; + const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim(); + if (!content) continue; + for (const tag of tags) { + map.set(tag, content); + } + } + return map; +} diff --git a/src/renderer/hooks/useSongItem.ts b/src/renderer/hooks/useSongItem.ts index 929ae83..7a29477 100644 --- a/src/renderer/hooks/useSongItem.ts +++ b/src/renderer/hooks/useSongItem.ts @@ -16,7 +16,7 @@ export function useSongItem(props: { item: SongResult; canRemove?: boolean }) { const playerStore = usePlayerStore(); const recommendStore = useRecommendStore(); const message = useMessage(); - const { downloadMusic } = useDownload(); + const { downloadMusic, downloadLyric } = useDownload(); const { navigateToArtist } = useArtist(); // 状态变量 @@ -220,6 +220,7 @@ export function useSongItem(props: { item: SongResult; canRemove?: boolean }) { handleArtistClick, handleMouseEnter, handleMouseLeave, - downloadMusic + downloadMusic, + downloadLyric }; } diff --git a/src/renderer/views/download/DownloadPage.vue b/src/renderer/views/download/DownloadPage.vue index 51eeca9..7c0b439 100644 --- a/src/renderer/views/download/DownloadPage.vue +++ b/src/renderer/views/download/DownloadPage.vue @@ -316,6 +316,21 @@ + +
+
+
+

+ {{ t('download.settingsPanel.saveLyric') }} +

+

+ {{ t('download.settingsPanel.saveLyricDesc') }} +

+
+ +
+
+

@@ -875,7 +890,8 @@ const showSettingsDrawer = ref(false); const downloadSettings = ref({ path: '', nameFormat: '{songName} - {artistName}', - separator: ' - ' + separator: ' - ', + saveLyric: false }); // 格式组件(用于拖拽排序) @@ -992,6 +1008,11 @@ const saveDownloadSettings = () => { 'set.downloadSeparator', downloadSettings.value.separator ); + window.electron.ipcRenderer.send( + 'set-store-value', + 'set.downloadSaveLyric', + downloadSettings.value.saveLyric + ); // 如果是在已下载页面,刷新列表以更新显示 if (tabName.value === 'downloaded') { @@ -1014,11 +1035,16 @@ const initDownloadSettings = async () => { 'get-store-value', 'set.downloadSeparator' ); + const saveLyric = await window.electron.ipcRenderer.invoke( + 'get-store-value', + 'set.downloadSaveLyric' + ); downloadSettings.value = { path: path || (await window.electron.ipcRenderer.invoke('get-downloads-path')), nameFormat: nameFormat || '{songName} - {artistName}', - separator: separator || ' - ' + separator: separator || ' - ', + saveLyric: saveLyric || false }; // 初始化排序组件