mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-04 06:50:49 +08:00
Compare commits
15 Commits
feat/windo
...
v4.3.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76e55d4e6b | ||
|
|
b7de5fc173 | ||
|
|
7bc8405df0 | ||
|
|
a7f2045c7b | ||
|
|
b9b52f4d9f | ||
|
|
09f8837fe4 | ||
|
|
d7fea7f888 | ||
|
|
bb7d1e332f | ||
|
|
2dc907a20f | ||
|
|
ad3e52f6e1 | ||
|
|
b593ca3011 | ||
|
|
988418e8d1 | ||
|
|
1922311238 | ||
|
|
3b1488f147 | ||
|
|
5f4b53c167 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,22 +1,17 @@
|
||||
# 更新日志
|
||||
|
||||
## v4.2.0
|
||||
## v4.3.0
|
||||
|
||||
### ✨ 新功能
|
||||
- 添加迷你播放器模式 ([0f55795](https://github.com/algerkong/AlgerMusicPlayer/commit/0f55795))
|
||||
- 更新网易云音乐API版本,添加B站视频搜索功能和播放器组件 ([280fec1](https://github.com/algerkong/AlgerMusicPlayer/commit/280fec1))
|
||||
- mac端添加状态栏 显示当前播放歌曲和操作按钮 ([374a7a8](https://github.com/algerkong/AlgerMusicPlayer/commit/374a7a8))
|
||||
- 添加音频URL过期事件监听,自动重新获取B站和网易云音乐音频URL并恢复播放 ([ee6e9d4](https://github.com/algerkong/AlgerMusicPlayer/commit/ee6e9d4))
|
||||
- 优化搜索功能,改进搜索历史管理和路由处理逻辑 ([477f8bb](https://github.com/algerkong/AlgerMusicPlayer/commit/477f8bb))
|
||||
- 在播放列表中添加歌曲删除功能,优化播放列表管理逻辑 ([a5f694e](https://github.com/algerkong/AlgerMusicPlayer/commit/a5f694e)) (#94)
|
||||
- 优化歌词窗口字体控制按钮样式 ([c5e50c9](https://github.com/algerkong/AlgerMusicPlayer/commit/c5e50c9))
|
||||
- 优化首页banner加载逻辑 ([01ccad4](https://github.com/algerkong/AlgerMusicPlayer/commit/01ccad4))
|
||||
- 优化歌手详情页面 由抽屉改为页面 ([dfb8f55](https://github.com/algerkong/AlgerMusicPlayer/commit/dfb8f55))
|
||||
- 增加用户关注列表和关注用户详情页 可查看听歌排行和用户歌单 ([2924ad6](https://github.com/algerkong/AlgerMusicPlayer/commit/2924ad6))
|
||||
- 优化进度条 鼠标悬停直接显示进度信息 ([9ce872e](https://github.com/algerkong/AlgerMusicPlayer/commit/9ce872e))
|
||||
- 优化应用更新下载功能 可后台下载 弹出下载完成提示 不再自动关闭应用 ([23b2340](https://github.com/algerkong/AlgerMusicPlayer/commit/23b2340))
|
||||
- 歌曲下载内置封面歌词歌曲信息,添加无限制下载功能,优化下载页面添加下载记录清除功能 ([3b1488f](https://github.com/algerkong/AlgerMusicPlayer/commit/3b1488f)) (#123) ([988418e](https://github.com/algerkong/AlgerMusicPlayer/commit/988418e))
|
||||
- 添加搜索功能至歌曲列表,支持名称、歌手、专辑搜索,支持拼音匹配 ([b593ca3](https://github.com/algerkong/AlgerMusicPlayer/commit/b593ca3)) (#126)
|
||||
- 添加快捷键管理功能,支持全局和应用内快捷键的启用/禁用 ([c2983ba](https://github.com/algerkong/AlgerMusicPlayer/commit/c2983ba)) (#119)
|
||||
- 优化歌单加载、播放逻辑,提升大型歌单加载性能 ([7bc8405](https://github.com/algerkong/AlgerMusicPlayer/commit/7bc8405))、([d7fea7f](https://github.com/algerkong/AlgerMusicPlayer/commit/d7fea7f))
|
||||
- 添加直接播放首页歌单功能 ([5f4b53c](https://github.com/algerkong/AlgerMusicPlayer/commit/5f4b53c))
|
||||
- 添加统计服务([a7f2045](https://github.com/algerkong/AlgerMusicPlayer/commit/a7f2045))
|
||||
- 优化历史和收藏视图的加载体验 ([09f8837](https://github.com/algerkong/AlgerMusicPlayer/commit/09f8837))
|
||||
- 优化歌词界面配置,提供更好的用户体验 ([55b50d7](https://github.com/algerkong/AlgerMusicPlayer/commit/55b50d7))
|
||||
|
||||
### 🐛 Bug 修复
|
||||
- 修复进度条多次拖动和多次暂停播放引发的歌曲重复播放bug ([cfe197c](https://github.com/algerkong/AlgerMusicPlayer/commit/cfe197c)) (#104)
|
||||
- 修复关闭按钮最小化 还在任务栏显示的bug ([e0d1305](https://github.com/algerkong/AlgerMusicPlayer/commit/e0d1305))
|
||||
- 修复播放列表中歌曲删除时类型不匹配的问题 ([8d6d052](https://github.com/algerkong/AlgerMusicPlayer/commit/8d6d052))
|
||||
- 优化音乐封面显示逻辑,确保在缺失封面时使用默认图片 ([bb7d1e3](https://github.com/algerkong/AlgerMusicPlayer/commit/bb7d1e3))
|
||||
- 优化桌面歌词行动态样式计算,提升歌词显示效果 ([541ff2b](https://github.com/algerkong/AlgerMusicPlayer/commit/541ff2b))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "AlgerMusicPlayer",
|
||||
"version": "4.2.0",
|
||||
"version": "4.3.0",
|
||||
"description": "Alger Music Player",
|
||||
"author": "Alger <algerkc@qq.com>",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -28,6 +28,8 @@
|
||||
"electron-updater": "^6.1.7",
|
||||
"font-list": "^1.5.1",
|
||||
"netease-cloud-music-api-alger": "^4.26.1",
|
||||
"node-id3": "^0.2.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"vue-i18n": "9"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -68,6 +70,7 @@
|
||||
"marked": "^15.0.4",
|
||||
"naive-ui": "^2.41.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinyin-match": "^1.2.6",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.3.2",
|
||||
"remixicon": "^4.6.0",
|
||||
|
||||
@@ -101,5 +101,9 @@ export default {
|
||||
},
|
||||
userPlayList: {
|
||||
title: "{name}'s Playlist"
|
||||
},
|
||||
musicList: {
|
||||
searchSongs: 'Search Songs',
|
||||
noSearchResults: 'No search results'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export default {
|
||||
title: 'Download Manager',
|
||||
localMusic: 'Local Music',
|
||||
count: '{count} songs in total',
|
||||
clearAll: 'Clear All',
|
||||
tabs: {
|
||||
downloading: 'Downloading',
|
||||
downloaded: 'Downloaded'
|
||||
@@ -27,7 +29,17 @@ export default {
|
||||
confirm: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
success: 'Successfully deleted',
|
||||
failed: 'Failed to delete'
|
||||
failed: 'Failed to delete',
|
||||
fileNotFound: 'File not found or moved, removed from records',
|
||||
recordRemoved: 'Failed to delete file, but removed from records'
|
||||
},
|
||||
clear: {
|
||||
title: 'Clear Download Records',
|
||||
message:
|
||||
'Are you sure you want to clear all download records? This will not delete the actual music files, but will clear all records.',
|
||||
confirm: 'Clear',
|
||||
cancel: 'Cancel',
|
||||
success: 'Download records cleared'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} download completed',
|
||||
|
||||
@@ -71,6 +71,8 @@ export default {
|
||||
shortcutDesc: 'Customize global shortcuts',
|
||||
download: 'Download Management',
|
||||
downloadDesc: 'Always show download list button',
|
||||
unlimitedDownload: 'Unlimited Download',
|
||||
unlimitedDownloadDesc: 'Enable unlimited download mode for music , default limit 300 songs',
|
||||
downloadPath: 'Download Directory',
|
||||
downloadPathDesc: 'Choose download location for music files'
|
||||
},
|
||||
|
||||
@@ -13,6 +13,6 @@ export default {
|
||||
downloadFailed: 'Download failed',
|
||||
downloadQueued: 'Added to download queue',
|
||||
addedToNextPlay: 'Added to play next',
|
||||
getUrlFailed: 'Failed to get music download URL'
|
||||
getUrlFailed: 'Failed to get music download URL, please check if logged in'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,5 +99,9 @@ export default {
|
||||
},
|
||||
userPlayList: {
|
||||
title: '{name}的常听'
|
||||
},
|
||||
musicList: {
|
||||
searchSongs: '搜索歌曲',
|
||||
noSearchResults: '没有找到相关歌曲'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export default {
|
||||
title: '下载管理',
|
||||
localMusic: '本地音乐',
|
||||
count: '共 {count} 首歌曲',
|
||||
clearAll: '清空记录',
|
||||
tabs: {
|
||||
downloading: '下载中',
|
||||
downloaded: '已下载'
|
||||
@@ -27,7 +29,16 @@ export default {
|
||||
confirm: '确定删除',
|
||||
cancel: '取消',
|
||||
success: '删除成功',
|
||||
failed: '删除失败'
|
||||
failed: '删除失败',
|
||||
fileNotFound: '文件不存在或已被移动,已从记录中移除',
|
||||
recordRemoved: '文件删除失败,但已从记录中移除'
|
||||
},
|
||||
clear: {
|
||||
title: '清空下载记录',
|
||||
message: '确定要清空所有下载记录吗?此操作不会删除已下载的音乐文件,但将清空所有记录。',
|
||||
confirm: '确定清空',
|
||||
cancel: '取消',
|
||||
success: '下载记录已清空'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} 下载完成',
|
||||
|
||||
@@ -71,6 +71,8 @@ export default {
|
||||
shortcutDesc: '自定义全局快捷键',
|
||||
download: '下载管理',
|
||||
downloadDesc: '是否始终显示下载列表按钮',
|
||||
unlimitedDownload: '无限制下载',
|
||||
unlimitedDownloadDesc: '开启后将无限制下载音乐(可能出现下载失败的情况), 默认限制 300 首',
|
||||
downloadPath: '下载目录',
|
||||
downloadPathDesc: '选择音乐文件的下载位置'
|
||||
},
|
||||
|
||||
@@ -13,6 +13,6 @@ export default {
|
||||
downloadFailed: '下载失败',
|
||||
downloadQueued: '已加入下载队列',
|
||||
addedToNextPlay: '已添加到下一首播放',
|
||||
getUrlFailed: '获取音乐下载地址失败'
|
||||
getUrlFailed: '获取音乐下载地址失败,请检查是否登录'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { initializeConfig } from './modules/config';
|
||||
import { initializeFileManager } from './modules/fileManager';
|
||||
import { initializeFonts } from './modules/fonts';
|
||||
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
|
||||
import { initializeStats, setupStatsHandlers } from './modules/statsService';
|
||||
import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
|
||||
import { setupUpdateHandlers } from './modules/update';
|
||||
import { createMainWindow, initializeWindowManager } from './modules/window';
|
||||
@@ -50,6 +51,12 @@ function initialize() {
|
||||
// 初始化托盘
|
||||
initializeTray(iconPath, mainWindow);
|
||||
|
||||
// 初始化统计服务
|
||||
initializeStats();
|
||||
|
||||
// 设置统计相关的IPC处理程序
|
||||
setupStatsHandlers(ipcMain);
|
||||
|
||||
// 启动音乐API
|
||||
startMusicApi();
|
||||
|
||||
|
||||
63
src/main/modules/deviceInfo.ts
Normal file
63
src/main/modules/deviceInfo.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { app } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { machineIdSync } from 'node-machine-id';
|
||||
import os from 'os';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
/**
|
||||
* 获取设备唯一标识符
|
||||
* 优先使用存储的ID,如果没有则获取机器ID并存储
|
||||
*/
|
||||
export function getDeviceId(): string {
|
||||
let deviceId = store.get('deviceId') as string | undefined;
|
||||
|
||||
if (!deviceId) {
|
||||
try {
|
||||
// 使用node-machine-id获取设备唯一标识
|
||||
deviceId = machineIdSync(true);
|
||||
} catch (error) {
|
||||
console.error('获取机器ID失败:', error);
|
||||
// 如果获取失败,使用主机名和MAC地址组合作为备选方案
|
||||
const networkInterfaces = os.networkInterfaces();
|
||||
let macAddress = '';
|
||||
|
||||
// 尝试获取第一个非内部网络接口的MAC地址
|
||||
Object.values(networkInterfaces).forEach((interfaces) => {
|
||||
if (interfaces) {
|
||||
interfaces.forEach((iface) => {
|
||||
if (!iface.internal && !macAddress && iface.mac !== '00:00:00:00:00:00') {
|
||||
macAddress = iface.mac;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
deviceId = `${os.hostname()}-${macAddress}`.replace(/:/g, '');
|
||||
}
|
||||
|
||||
// 存储设备ID
|
||||
if (deviceId) {
|
||||
store.set('deviceId', deviceId);
|
||||
} else {
|
||||
// 如果所有方法都失败,使用随机ID
|
||||
deviceId = Math.random().toString(36).substring(2, 15);
|
||||
store.set('deviceId', deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统信息
|
||||
*/
|
||||
export function getSystemInfo() {
|
||||
return {
|
||||
osType: os.type(),
|
||||
osVersion: os.release(),
|
||||
osArch: os.arch(),
|
||||
platform: process.platform,
|
||||
appVersion: app.getVersion()
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import { app, dialog, ipcMain, protocol, shell } from 'electron';
|
||||
import { app, dialog, ipcMain, Notification, protocol, shell } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as NodeID3 from 'node-id3';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getStore } from './config';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 3;
|
||||
const downloadQueue: { url: string; filename: string; songInfo: any; type?: string }[] = [];
|
||||
let activeDownloads = 0;
|
||||
@@ -269,7 +274,7 @@ function sanitizeFilename(filename: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载音乐功能
|
||||
* 下载音乐和歌词
|
||||
*/
|
||||
async function downloadMusic(
|
||||
event: Electron.IpcMainEvent,
|
||||
@@ -284,8 +289,11 @@ async function downloadMusic(
|
||||
let writer: fs.WriteStream | null = null;
|
||||
|
||||
try {
|
||||
const store = new Store();
|
||||
const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads');
|
||||
// 使用配置Store来获取设置
|
||||
const configStore = getStore();
|
||||
const downloadPath =
|
||||
(configStore.get('set.downloadPath') as string) || app.getPath('downloads');
|
||||
const apiPort = configStore.get('set.musicApiPort') || 30488;
|
||||
|
||||
// 清理文件名中的非法字符
|
||||
const sanitizedFilename = sanitizeFilename(filename);
|
||||
@@ -313,7 +321,9 @@ async function downloadMusic(
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 30000 // 30秒超时
|
||||
timeout: 30000, // 30秒超时
|
||||
httpAgent: new http.Agent({ keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true })
|
||||
});
|
||||
|
||||
writer = fs.createWriteStream(finalFilePath);
|
||||
@@ -351,9 +361,121 @@ async function downloadMusic(
|
||||
throw new Error('文件下载不完整');
|
||||
}
|
||||
|
||||
// 下载歌词
|
||||
let lyricData = null;
|
||||
let lyricsContent = '';
|
||||
try {
|
||||
if (songInfo?.id) {
|
||||
// 下载歌词,使用配置的端口
|
||||
const lyricsResponse = await axios.get(
|
||||
`http://localhost:${apiPort}/lyric?id=${songInfo.id}`
|
||||
);
|
||||
if (lyricsResponse.data && (lyricsResponse.data.lrc || lyricsResponse.data.tlyric)) {
|
||||
lyricData = lyricsResponse.data;
|
||||
|
||||
// 处理歌词内容
|
||||
if (lyricsResponse.data.lrc && lyricsResponse.data.lrc.lyric) {
|
||||
lyricsContent = lyricsResponse.data.lrc.lyric;
|
||||
|
||||
// 如果有翻译歌词,合并到主歌词中
|
||||
if (lyricsResponse.data.tlyric && lyricsResponse.data.tlyric.lyric) {
|
||||
// 解析原歌词和翻译
|
||||
const originalLyrics = parseLyrics(lyricsResponse.data.lrc.lyric);
|
||||
const translatedLyrics = parseLyrics(lyricsResponse.data.tlyric.lyric);
|
||||
|
||||
// 合并歌词
|
||||
const mergedLyrics = mergeLyrics(originalLyrics, translatedLyrics);
|
||||
lyricsContent = mergedLyrics;
|
||||
}
|
||||
}
|
||||
|
||||
// 不再单独写入歌词文件,只保存在ID3标签中
|
||||
console.log('歌词已准备好,将写入ID3标签');
|
||||
}
|
||||
}
|
||||
} catch (lyricError) {
|
||||
console.error('下载歌词失败:', lyricError);
|
||||
// 继续处理,不影响音乐下载
|
||||
}
|
||||
|
||||
// 下载封面
|
||||
let coverImageBuffer: Buffer | null = null;
|
||||
try {
|
||||
if (songInfo?.picUrl || songInfo?.al?.picUrl) {
|
||||
const picUrl = songInfo.picUrl || songInfo.al?.picUrl;
|
||||
if (picUrl && picUrl !== '/images/default_cover.png') {
|
||||
const coverResponse = await axios({
|
||||
url: picUrl.replace('http://', 'https://'),
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// 获取封面图片的buffer
|
||||
coverImageBuffer = Buffer.from(coverResponse.data);
|
||||
|
||||
// 不再单独保存封面文件,只保存在ID3标签中
|
||||
console.log('封面已准备好,将写入ID3标签');
|
||||
}
|
||||
}
|
||||
} catch (coverError) {
|
||||
console.error('下载封面失败:', coverError);
|
||||
// 继续处理,不影响音乐下载
|
||||
}
|
||||
|
||||
// 在写入ID3标签前,先移除可能存在的旧标签
|
||||
try {
|
||||
NodeID3.removeTags(finalFilePath);
|
||||
} catch (err) {
|
||||
console.error('Error removing existing ID3 tags:', err);
|
||||
}
|
||||
|
||||
// 强化ID3标签的写入格式
|
||||
|
||||
const artistNames =
|
||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家';
|
||||
const tags = {
|
||||
title: filename,
|
||||
artist: artistNames,
|
||||
TPE1: artistNames,
|
||||
TPE2: artistNames,
|
||||
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
|
||||
APIC: {
|
||||
// 专辑封面
|
||||
imageBuffer: coverImageBuffer,
|
||||
type: {
|
||||
id: 3,
|
||||
name: 'front cover'
|
||||
},
|
||||
description: 'Album cover',
|
||||
mime: 'image/jpeg'
|
||||
},
|
||||
USLT: {
|
||||
// 歌词
|
||||
language: 'chi',
|
||||
description: 'Lyrics',
|
||||
text: lyricsContent || ''
|
||||
},
|
||||
trackNumber: songInfo?.no || undefined,
|
||||
year: songInfo?.publishTime
|
||||
? new Date(songInfo.publishTime).getFullYear().toString()
|
||||
: undefined
|
||||
};
|
||||
|
||||
try {
|
||||
const success = NodeID3.write(tags, finalFilePath);
|
||||
if (!success) {
|
||||
console.error('Failed to write ID3 tags');
|
||||
} else {
|
||||
console.log('ID3 tags written successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error writing ID3 tags:', err);
|
||||
}
|
||||
|
||||
// 保存下载信息
|
||||
try {
|
||||
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
|
||||
const songInfos = configStore.get('downloadedSongs', {}) as Record<string, any>;
|
||||
const defaultInfo = {
|
||||
name: filename,
|
||||
ar: [{ name: '本地音乐' }],
|
||||
@@ -364,24 +486,48 @@ async function downloadMusic(
|
||||
id: songInfo?.id || 0,
|
||||
name: songInfo?.name || filename,
|
||||
filename,
|
||||
picUrl: songInfo?.picUrl || defaultInfo.picUrl,
|
||||
picUrl: songInfo?.picUrl || songInfo?.al?.picUrl || defaultInfo.picUrl,
|
||||
ar: songInfo?.ar || defaultInfo.ar,
|
||||
al: songInfo?.al || {
|
||||
picUrl: songInfo?.picUrl || defaultInfo.picUrl,
|
||||
name: songInfo?.name || filename
|
||||
},
|
||||
size: totalSize,
|
||||
path: finalFilePath,
|
||||
downloadTime: Date.now(),
|
||||
al: songInfo?.al || { picUrl: songInfo?.picUrl || defaultInfo.picUrl },
|
||||
type: type || 'mp3'
|
||||
type: type || 'mp3',
|
||||
lyric: lyricData
|
||||
};
|
||||
|
||||
// 保存到下载记录
|
||||
songInfos[finalFilePath] = newSongInfo;
|
||||
store.set('downloadedSongs', songInfos);
|
||||
configStore.set('downloadedSongs', songInfos);
|
||||
|
||||
// 添加到下载历史
|
||||
const history = downloadStore.get('history', []) as any[];
|
||||
history.unshift(newSongInfo);
|
||||
downloadStore.set('history', history);
|
||||
|
||||
// 发送桌面通知
|
||||
try {
|
||||
const artistNames =
|
||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/') ||
|
||||
'未知艺术家';
|
||||
const notification = new Notification({
|
||||
title: '下载完成',
|
||||
body: `${songInfo?.name || filename} - ${artistNames}`,
|
||||
silent: false
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
shell.showItemInFolder(finalFilePath);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
} catch (notifyError) {
|
||||
console.error('发送通知失败:', notifyError);
|
||||
}
|
||||
|
||||
// 发送下载完成事件
|
||||
event.reply('music-download-complete', {
|
||||
success: true,
|
||||
@@ -416,3 +562,56 @@ async function downloadMusic(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数 - 解析歌词文本成时间戳和内容的映射
|
||||
function parseLyrics(lyricsText: string): Map<string, string> {
|
||||
const lyricMap = new Map<string, string>();
|
||||
const lines = lyricsText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// 匹配时间标签,形如 [00:00.000]
|
||||
const timeTagMatches = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g);
|
||||
if (!timeTagMatches) continue;
|
||||
|
||||
// 提取歌词内容(去除时间标签)
|
||||
const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim();
|
||||
if (!content) continue;
|
||||
|
||||
// 将每个时间标签与歌词内容关联
|
||||
for (const timeTag of timeTagMatches) {
|
||||
lyricMap.set(timeTag, content);
|
||||
}
|
||||
}
|
||||
|
||||
return lyricMap;
|
||||
}
|
||||
|
||||
// 辅助函数 - 合并原文歌词和翻译歌词
|
||||
function mergeLyrics(
|
||||
originalLyrics: Map<string, string>,
|
||||
translatedLyrics: Map<string, string>
|
||||
): string {
|
||||
const mergedLines: string[] = [];
|
||||
|
||||
// 对每个时间戳,组合原始歌词和翻译
|
||||
for (const [timeTag, originalContent] of originalLyrics.entries()) {
|
||||
const translatedContent = translatedLyrics.get(timeTag);
|
||||
|
||||
// 添加原始歌词行
|
||||
mergedLines.push(`${timeTag}${originalContent}`);
|
||||
|
||||
// 如果有翻译,添加翻译行(时间戳相同,这样可以和原歌词同步显示)
|
||||
if (translatedContent) {
|
||||
mergedLines.push(`${timeTag}${translatedContent}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间顺序排序
|
||||
mergedLines.sort((a, b) => {
|
||||
const timeA = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
|
||||
const timeB = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
|
||||
return timeA.localeCompare(timeB);
|
||||
});
|
||||
|
||||
return mergedLines.join('\n');
|
||||
}
|
||||
|
||||
122
src/main/modules/statsService.ts
Normal file
122
src/main/modules/statsService.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import axios from 'axios';
|
||||
import { app } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
import { getDeviceId, getSystemInfo } from './deviceInfo';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
// 统计服务配置
|
||||
const STATS_API_URL = 'http://donate.alger.fun/state/api/stats';
|
||||
|
||||
/**
|
||||
* 记录应用安装/启动
|
||||
*/
|
||||
export async function recordInstallation(): Promise<void> {
|
||||
try {
|
||||
const deviceId = getDeviceId();
|
||||
const systemInfo = getSystemInfo();
|
||||
|
||||
// 发送请求到统计服务器
|
||||
await axios.post(`${STATS_API_URL}/installation`, {
|
||||
deviceId,
|
||||
osType: systemInfo.osType,
|
||||
osVersion: systemInfo.osVersion,
|
||||
appVersion: systemInfo.appVersion
|
||||
});
|
||||
|
||||
console.log('应用启动统计已记录');
|
||||
|
||||
// 记录最后一次启动时间
|
||||
store.set('lastStartTime', new Date().toISOString());
|
||||
} catch (error) {
|
||||
console.error('记录应用启动统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 IPC 处理程序以接收渲染进程的统计请求
|
||||
* @param ipcMain Electron IPC主对象
|
||||
*/
|
||||
export function setupStatsHandlers(ipcMain: Electron.IpcMain): void {
|
||||
// 处理页面访问统计
|
||||
ipcMain.handle('record-visit', async (_, page: string, userId?: string) => {
|
||||
try {
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
await axios.post(`${STATS_API_URL}/visit`, {
|
||||
deviceId,
|
||||
userId,
|
||||
page
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('记录页面访问统计失败:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 处理播放统计
|
||||
ipcMain.handle(
|
||||
'record-play',
|
||||
async (
|
||||
_,
|
||||
songData: {
|
||||
userId: string | null;
|
||||
songId: string | number;
|
||||
songName: string;
|
||||
artistName: string;
|
||||
duration?: number;
|
||||
completedPlay?: boolean;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
const { songId, songName, artistName, duration = 0, completedPlay = false } = songData;
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
await axios.post(`${STATS_API_URL}/play`, {
|
||||
deviceId,
|
||||
userId: songData.userId,
|
||||
songId: songId.toString(),
|
||||
songName,
|
||||
artistName,
|
||||
duration,
|
||||
completedPlay
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('记录播放统计失败:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 处理获取统计摘要
|
||||
ipcMain.handle('get-stats-summary', async () => {
|
||||
try {
|
||||
const response = await axios.get(`${STATS_API_URL}/summary`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取统计摘要失败:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时初始化统计服务
|
||||
*/
|
||||
export function initializeStats(): void {
|
||||
// 记录应用启动统计
|
||||
recordInstallation().catch((error) => {
|
||||
console.error('初始化统计服务失败:', error);
|
||||
});
|
||||
|
||||
// 注册应用退出时的回调
|
||||
app.on('will-quit', () => {
|
||||
// 可以在这里添加应用退出时的统计逻辑
|
||||
console.log('应用退出');
|
||||
});
|
||||
}
|
||||
@@ -20,5 +20,6 @@
|
||||
"autoPlay": false,
|
||||
"downloadPath": "",
|
||||
"language": "zh-CN",
|
||||
"alwaysShowDownloadButton": false
|
||||
"alwaysShowDownloadButton": false,
|
||||
"unlimitedDownload": false
|
||||
}
|
||||
|
||||
57
src/preload/index.d.ts
vendored
57
src/preload/index.d.ts
vendored
@@ -1,31 +1,42 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
interface API {
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
dragStart: (data: any) => void;
|
||||
miniTray: () => void;
|
||||
miniWindow: () => void;
|
||||
restore: () => void;
|
||||
restart: () => void;
|
||||
resizeWindow: (width: number, height: number) => void;
|
||||
resizeMiniWindow: (showPlaylist: boolean) => void;
|
||||
openLyric: () => void;
|
||||
sendLyric: (data: any) => void;
|
||||
sendSong: (data: any) => void;
|
||||
unblockMusic: (id: number, data: any) => Promise<any>;
|
||||
onLyricWindowClosed: (callback: () => void) => void;
|
||||
startDownload: (url: string) => void;
|
||||
onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
|
||||
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
|
||||
onLanguageChanged: (callback: (locale: string) => void) => void;
|
||||
removeDownloadListeners: () => void;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
}
|
||||
|
||||
// 自定义IPC渲染进程通信接口
|
||||
interface IpcRenderer {
|
||||
send: (channel: string, ...args: any[]) => void;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
on: (channel: string, listener: (...args: any[]) => void) => () => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
api: {
|
||||
sendLyric: (data: string) => void;
|
||||
openLyric: () => void;
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
dragStart: (data: string) => void;
|
||||
miniTray: () => void;
|
||||
miniWindow: () => void;
|
||||
restore: () => void;
|
||||
restart: () => void;
|
||||
resizeWindow: (width: number, height: number) => void;
|
||||
resizeMiniWindow: (showPlaylist: boolean) => void;
|
||||
unblockMusic: (id: number, data: any) => Promise<any>;
|
||||
onLyricWindowClosed: (callback: () => void) => void;
|
||||
startDownload: (url: string) => void;
|
||||
onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
|
||||
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
|
||||
removeDownloadListeners: () => void;
|
||||
onLanguageChanged: (callback: (locale: string) => void) => void;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
sendSong: (data: any) => void;
|
||||
};
|
||||
api: API;
|
||||
ipcRenderer: IpcRenderer;
|
||||
$message: any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,11 @@ const api = {
|
||||
'get-system-fonts',
|
||||
'get-cached-lyric',
|
||||
'cache-lyric',
|
||||
'clear-lyric-cache'
|
||||
'clear-lyric-cache',
|
||||
// 统计相关
|
||||
'record-visit',
|
||||
'record-play',
|
||||
'get-stats-summary'
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
@@ -56,6 +60,29 @@ const api = {
|
||||
}
|
||||
};
|
||||
|
||||
// 创建带类型的ipcRenderer对象,暴露给渲染进程
|
||||
const ipc = {
|
||||
// 发送消息到主进程(无返回值)
|
||||
send: (channel: string, ...args: any[]) => {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
},
|
||||
// 调用主进程方法(有返回值)
|
||||
invoke: (channel: string, ...args: any[]) => {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
// 监听主进程消息
|
||||
on: (channel: string, listener: (...args: any[]) => void) => {
|
||||
ipcRenderer.on(channel, (_, ...args) => listener(...args));
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
};
|
||||
},
|
||||
// 移除所有监听器
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
};
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
@@ -63,6 +90,7 @@ if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', ipc);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -71,4 +99,6 @@ if (process.contextIsolated) {
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api;
|
||||
// @ts-ignore (define in dts)
|
||||
window.ipcRenderer = ipc;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,9 @@ onMounted(async () => {
|
||||
// 使用 nextTick 确保 DOM 更新后再初始化
|
||||
await nextTick();
|
||||
initAudioListeners();
|
||||
window.api.sendSong(cloneDeep(playerStore.playMusic));
|
||||
if (isElectron) {
|
||||
window.api.sendSong(cloneDeep(playerStore.playMusic));
|
||||
}
|
||||
}
|
||||
// 初始化快捷键
|
||||
initShortcut();
|
||||
|
||||
@@ -15,6 +15,6 @@ export interface Donor {
|
||||
* 获取捐赠列表
|
||||
*/
|
||||
export const getDonationList = async (): Promise<Donor[]> => {
|
||||
const { data } = await axios.get('http://110.42.251.190:8766/api/donations');
|
||||
const { data } = await axios.get('http://donate.alger.fun/api/donations');
|
||||
return data;
|
||||
};
|
||||
|
||||
75
src/renderer/api/stats.ts
Normal file
75
src/renderer/api/stats.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { isElectron } from '@/utils';
|
||||
|
||||
import { useUserStore } from '../store/modules/user';
|
||||
|
||||
/**
|
||||
* 获取用户ID
|
||||
* @returns 用户ID或null
|
||||
*/
|
||||
function getUserId(): string | null {
|
||||
const userStore = useUserStore();
|
||||
return userStore.user?.userId?.toString() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录页面访问
|
||||
* @param page 页面名称或路径
|
||||
*/
|
||||
export async function recordVisit(page: string): Promise<void> {
|
||||
if (!isElectron) return;
|
||||
try {
|
||||
const userId = getUserId();
|
||||
await window.api.invoke('record-visit', page, userId);
|
||||
console.log(`页面访问已记录: ${page}`);
|
||||
} catch (error) {
|
||||
console.error('记录页面访问失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录歌曲播放
|
||||
* @param songId 歌曲ID
|
||||
* @param songName 歌曲名称
|
||||
* @param artistName 艺术家名称
|
||||
* @param duration 时长(秒)
|
||||
* @param completedPlay 是否完整播放
|
||||
*/
|
||||
export async function recordPlay(
|
||||
songId: string | number,
|
||||
songName: string,
|
||||
artistName: string,
|
||||
duration: number = 0,
|
||||
completedPlay: boolean = false
|
||||
): Promise<void> {
|
||||
if (!isElectron) return;
|
||||
try {
|
||||
const userId = getUserId();
|
||||
|
||||
await window.api.invoke('record-play', {
|
||||
userId,
|
||||
songId,
|
||||
songName,
|
||||
artistName,
|
||||
duration,
|
||||
completedPlay
|
||||
});
|
||||
|
||||
console.log(`歌曲播放已记录: ${songName}`);
|
||||
} catch (error) {
|
||||
console.error('记录歌曲播放失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计摘要
|
||||
* @returns 统计数据摘要
|
||||
*/
|
||||
export async function getStatsSummary(): Promise<any> {
|
||||
if (!isElectron) return null;
|
||||
try {
|
||||
return await window.api.invoke('get-stats-summary');
|
||||
} catch (error) {
|
||||
console.error('获取统计摘要失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -12,21 +12,39 @@
|
||||
>
|
||||
<div class="music-page">
|
||||
<div class="music-header h-12 flex items-center justify-between">
|
||||
<n-ellipsis :line-clamp="1">
|
||||
<n-ellipsis :line-clamp="1" class="flex-shrink-0 mr-3">
|
||||
<div class="music-title">
|
||||
{{ name }}
|
||||
</div>
|
||||
</n-ellipsis>
|
||||
<div class="music-close">
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="flex-grow flex-1 flex items-center justify-end">
|
||||
<div class="search-container">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="t('comp.musicList.searchSongs')"
|
||||
clearable
|
||||
round
|
||||
size="small"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon iconfont ri-search-line text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-close flex-shrink-0 ml-3">
|
||||
<i class="icon iconfont ri-close-line" @click="close"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="music-content">
|
||||
<!-- 左侧歌单信息 -->
|
||||
<div class="music-info">
|
||||
<div class="music-cover">
|
||||
<n-image
|
||||
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '500y500')"
|
||||
:src="getCoverImgUrl"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
:class="setAnimationClass('animate__fadeIn')"
|
||||
@@ -49,33 +67,36 @@
|
||||
<!-- 右侧歌曲列表 -->
|
||||
<div class="music-list-container">
|
||||
<div class="music-list">
|
||||
<n-scrollbar @scroll="handleScroll">
|
||||
<n-spin :show="loadingList || loading">
|
||||
<div class="music-list-content">
|
||||
<div
|
||||
v-for="(item, index) in displayedSongs"
|
||||
:key="item.id"
|
||||
class="double-item"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
>
|
||||
<song-item
|
||||
:item="formatSong(item)"
|
||||
:can-remove="canRemove"
|
||||
@play="handlePlay"
|
||||
@remove-song="(id) => emit('remove-song', id)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
{{ t('common.loadingMore') }}
|
||||
</div>
|
||||
<div v-if="!hasMore" class="loading-more">
|
||||
{{ t('common.noMore') }}
|
||||
</div>
|
||||
<play-bottom />
|
||||
<n-spin :show="loadingList || loading">
|
||||
<div class="music-list-content">
|
||||
<div v-if="filteredSongs.length === 0 && searchKeyword" class="no-result">
|
||||
{{ t('comp.musicList.noSearchResults') }}
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
|
||||
<!-- 虚拟列表,设置正确的固定高度 -->
|
||||
<n-virtual-list
|
||||
ref="songListRef"
|
||||
class="song-virtual-list"
|
||||
style="height: calc(70vh - 60px)"
|
||||
:items="filteredSongs"
|
||||
:item-size="70"
|
||||
item-resizable
|
||||
key-field="id"
|
||||
@scroll="handleVirtualScroll"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="double-item">
|
||||
<song-item
|
||||
:item="formatSong(item)"
|
||||
:can-remove="canRemove"
|
||||
@play="handlePlay"
|
||||
@remove-song="(id) => emit('remove-song', id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</div>
|
||||
@@ -85,19 +106,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PinyinMatch from 'pinyin-match';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { SongResult } from '@/type/music';
|
||||
import { getImgUrl, isMobile, setAnimationClass } from '@/utils';
|
||||
|
||||
import PlayBottom from './common/PlayBottom.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
@@ -125,12 +147,14 @@ const emit = defineEmits(['update:show', 'update:loading', 'remove-song']);
|
||||
const page = ref(0);
|
||||
const pageSize = 40;
|
||||
const isLoadingMore = ref(false);
|
||||
const displayedSongs = ref<any[]>([]);
|
||||
const displayedSongs = ref<SongResult[]>([]);
|
||||
const loadingList = ref(false);
|
||||
const loadedIds = ref(new Set<number>()); // 用于追踪已加载的歌曲ID
|
||||
const isPlaylistLoading = ref(false); // 标记是否正在加载播放列表
|
||||
const completePlaylist = ref<any[]>([]); // 存储完整的播放列表
|
||||
const completePlaylist = ref<SongResult[]>([]); // 存储完整的播放列表
|
||||
const hasMore = ref(true); // 标记是否还有更多数据可加载
|
||||
const searchKeyword = ref(''); // 搜索关键词
|
||||
const isFullPlaylistLoaded = ref(false); // 标记完整播放列表是否已加载完成
|
||||
|
||||
// 计算总数
|
||||
const total = computed(() => {
|
||||
@@ -140,8 +164,66 @@ const total = computed(() => {
|
||||
return props.songList.length;
|
||||
});
|
||||
|
||||
const getCoverImgUrl = computed(() => {
|
||||
if (props.listInfo?.coverImgUrl) {
|
||||
return props.listInfo.coverImgUrl;
|
||||
}
|
||||
|
||||
const song = props.songList[0];
|
||||
if (song?.picUrl) {
|
||||
return song.picUrl;
|
||||
}
|
||||
if (song?.al?.picUrl) {
|
||||
return song.al.picUrl;
|
||||
}
|
||||
if (song?.album?.picUrl) {
|
||||
return song.album.picUrl;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// 过滤歌曲列表
|
||||
const filteredSongs = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return displayedSongs.value;
|
||||
}
|
||||
|
||||
const keyword = searchKeyword.value.toLowerCase().trim();
|
||||
return displayedSongs.value.filter((song) => {
|
||||
const songName = song.name?.toLowerCase() || '';
|
||||
const albumName = song.al?.name?.toLowerCase() || '';
|
||||
const artists = song.ar || song.artists || [];
|
||||
|
||||
// 原始文本匹配
|
||||
const nameMatch = songName.includes(keyword);
|
||||
const albumMatch = albumName.includes(keyword);
|
||||
const artistsMatch = artists.some((artist: any) => {
|
||||
return artist.name?.toLowerCase().includes(keyword);
|
||||
});
|
||||
|
||||
// 拼音匹配
|
||||
const namePinyinMatch = song.name && PinyinMatch.match(song.name, keyword);
|
||||
const albumPinyinMatch = song.al?.name && PinyinMatch.match(song.al.name, keyword);
|
||||
const artistsPinyinMatch = artists.some((artist: any) => {
|
||||
return artist.name && PinyinMatch.match(artist.name, keyword);
|
||||
});
|
||||
|
||||
return (
|
||||
nameMatch ||
|
||||
albumMatch ||
|
||||
artistsMatch ||
|
||||
namePinyinMatch ||
|
||||
albumPinyinMatch ||
|
||||
artistsPinyinMatch
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 格式化歌曲数据
|
||||
const formatSong = (item: any) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
picUrl: item.al?.picUrl || item.picUrl,
|
||||
@@ -163,25 +245,44 @@ const loadSongs = async (ids: number[], appendToList = true, updateComplete = fa
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
try {
|
||||
console.log(`请求歌曲详情,ID数量: ${ids.length}`);
|
||||
const { data } = await getMusicDetail(ids);
|
||||
if (data?.songs) {
|
||||
// 更新已加载ID集合
|
||||
const newSongs = data.songs.filter((song: any) => !loadedIds.value.has(song.id));
|
||||
|
||||
newSongs.forEach((song: any) => {
|
||||
if (data?.songs) {
|
||||
console.log(`API返回歌曲数量: ${data.songs.length}`);
|
||||
|
||||
// 直接使用API返回的所有歌曲,不再过滤已加载的歌曲
|
||||
// 因为当需要完整加载列表时,我们希望获取所有歌曲,即使ID可能重复
|
||||
const { songs } = data;
|
||||
|
||||
// 只在非更新完整列表时执行过滤
|
||||
let newSongs = songs;
|
||||
if (!updateComplete) {
|
||||
// 在普通加载模式下继续过滤已加载的歌曲,避免重复
|
||||
newSongs = songs.filter((song: any) => !loadedIds.value.has(song.id));
|
||||
console.log(`过滤已加载ID后剩余歌曲数量: ${newSongs.length}`);
|
||||
}
|
||||
|
||||
// 更新已加载ID集合
|
||||
songs.forEach((song: any) => {
|
||||
loadedIds.value.add(song.id);
|
||||
});
|
||||
|
||||
// 追加到显示列表 - 仅当appendToList=true时添加到displayedSongs
|
||||
if (appendToList) {
|
||||
displayedSongs.value.push(...newSongs);
|
||||
}
|
||||
|
||||
// 更新完整播放列表 - 仅当updateComplete=true时添加到completePlaylist
|
||||
if (updateComplete) {
|
||||
completePlaylist.value.push(...newSongs);
|
||||
completePlaylist.value.push(...songs);
|
||||
console.log(`已添加到完整播放列表,当前完整列表长度: ${completePlaylist.value.length}`);
|
||||
}
|
||||
|
||||
return newSongs;
|
||||
return updateComplete ? songs : newSongs;
|
||||
}
|
||||
console.log('API返回无歌曲数据');
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('加载歌曲失败:', error);
|
||||
}
|
||||
@@ -191,41 +292,127 @@ const loadSongs = async (ids: number[], appendToList = true, updateComplete = fa
|
||||
|
||||
// 加载完整播放列表
|
||||
const loadFullPlaylist = async () => {
|
||||
if (isPlaylistLoading.value) return;
|
||||
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
|
||||
|
||||
isPlaylistLoading.value = true;
|
||||
completePlaylist.value = [...displayedSongs.value]; // 先用当前已加载的歌曲初始化
|
||||
// 记录开始时间
|
||||
const startTime = Date.now();
|
||||
console.log(`开始加载完整播放列表,当前显示列表长度: ${displayedSongs.value.length}`);
|
||||
|
||||
try {
|
||||
// 如果没有trackIds,直接使用当前歌曲列表
|
||||
// 如果没有trackIds,直接使用当前歌曲列表并标记为已完成
|
||||
if (!props.listInfo?.trackIds) {
|
||||
isFullPlaylistLoaded.value = true;
|
||||
console.log('无trackIds信息,使用当前列表作为完整列表');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有未加载的歌曲ID
|
||||
// 获取所有trackIds
|
||||
const allIds = props.listInfo.trackIds.map((item) => item.id);
|
||||
const unloadedIds = allIds.filter((id) => !loadedIds.value.has(id));
|
||||
console.log(`歌单共有歌曲ID: ${allIds.length}首`);
|
||||
|
||||
// 重置completePlaylist和当前显示歌曲ID集合,保证不会重复添加歌曲
|
||||
completePlaylist.value = [];
|
||||
|
||||
// 使用Set记录所有已加载的歌曲ID
|
||||
const loadedSongIds = new Set<number>();
|
||||
|
||||
// 将当前显示列表中的歌曲和ID添加到集合中
|
||||
displayedSongs.value.forEach((song) => {
|
||||
loadedSongIds.add(song.id as number);
|
||||
// 将已有歌曲添加到completePlaylist
|
||||
completePlaylist.value.push(song);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`已有显示歌曲: ${displayedSongs.value.length}首,已有ID数量: ${loadedSongIds.size}`
|
||||
);
|
||||
|
||||
// 过滤出尚未加载的歌曲ID
|
||||
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
|
||||
console.log(`还需要加载的歌曲ID数量: ${unloadedIds.length}`);
|
||||
|
||||
// 如果所有歌曲都已加载,直接返回
|
||||
if (unloadedIds.length === 0) {
|
||||
console.log('所有歌曲已加载,无需再次加载');
|
||||
isFullPlaylistLoaded.value = true;
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 分批加载未加载的歌曲
|
||||
// 分批加载所有未加载的歌曲
|
||||
const batchSize = 500; // 每批加载的歌曲数量
|
||||
|
||||
for (let i = 0; i < unloadedIds.length; i += batchSize) {
|
||||
const batchIds = unloadedIds.slice(i, i + batchSize);
|
||||
if (batchIds.length === 0) continue;
|
||||
|
||||
await loadSongs(batchIds, false, true);
|
||||
console.log(`请求第${Math.floor(i / batchSize) + 1}批歌曲,数量: ${batchIds.length}`);
|
||||
// 关键修改: 设置appendToList为false,避免loadSongs直接添加到displayedSongs
|
||||
const loadedBatch = await loadSongs(batchIds, false, false);
|
||||
|
||||
// 添加新加载的歌曲到displayedSongs
|
||||
if (loadedBatch.length > 0) {
|
||||
// 过滤掉已有的歌曲,确保不会重复添加
|
||||
const newSongs = loadedBatch.filter((song) => !loadedSongIds.has(song.id as number));
|
||||
|
||||
// 更新已加载ID集合
|
||||
newSongs.forEach((song) => {
|
||||
loadedSongIds.add(song.id as number);
|
||||
});
|
||||
|
||||
console.log(`新增${newSongs.length}首歌曲到显示列表`);
|
||||
|
||||
// 更新显示列表和完整播放列表
|
||||
if (newSongs.length > 0) {
|
||||
// 添加到显示列表
|
||||
displayedSongs.value = [...displayedSongs.value, ...newSongs];
|
||||
|
||||
// 添加到完整播放列表
|
||||
completePlaylist.value.push(...newSongs);
|
||||
|
||||
// 如果当前正在播放的列表与这个列表匹配,实时更新播放列表
|
||||
const currentPlaylist = playerStore.playList;
|
||||
if (currentPlaylist.length > 0 && currentPlaylist[0].id === displayedSongs.value[0]?.id) {
|
||||
console.log('实时更新当前播放列表');
|
||||
playerStore.setPlayList(displayedSongs.value.map(formatSong));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加小延迟避免请求过于密集
|
||||
if (i + batchSize < unloadedIds.length) {
|
||||
// 使用 setTimeout 直接延迟,避免 Promise 相关的 linter 错误
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 300);
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 加载完成,更新状态
|
||||
isFullPlaylistLoaded.value = true;
|
||||
hasMore.value = false;
|
||||
|
||||
// 计算加载耗时
|
||||
const endTime = Date.now();
|
||||
const timeUsed = Math.round(((endTime - startTime) / 1000) * 100) / 100;
|
||||
|
||||
console.log(
|
||||
`完整播放列表加载完成,共加载${displayedSongs.value.length}首歌曲,耗时${timeUsed}秒`
|
||||
);
|
||||
console.log(`歌单应有${allIds.length}首歌,实际加载${displayedSongs.value.length}首`);
|
||||
|
||||
// 检查加载的歌曲数量是否与预期相符
|
||||
if (displayedSongs.value.length !== allIds.length) {
|
||||
console.warn(
|
||||
`警告: 加载的歌曲数量(${displayedSongs.value.length})与歌单应有数量(${allIds.length})不符`
|
||||
);
|
||||
|
||||
// 如果数量不符,可能是API未返回所有歌曲,打印缺失的歌曲ID
|
||||
if (displayedSongs.value.length < allIds.length) {
|
||||
const loadedIds = new Set(displayedSongs.value.map((song) => song.id));
|
||||
const missingIds = allIds.filter((id) => !loadedIds.has(id));
|
||||
console.warn(`缺失的歌曲ID: ${missingIds.join(', ')}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载完整播放列表失败:', error);
|
||||
} finally {
|
||||
@@ -235,16 +422,31 @@ const loadFullPlaylist = async () => {
|
||||
|
||||
// 处理播放
|
||||
const handlePlay = async () => {
|
||||
// 先使用当前已加载的歌曲开始播放
|
||||
// 当搜索状态下播放时,只播放过滤后的歌曲
|
||||
if (searchKeyword.value) {
|
||||
playerStore.setPlayList(filteredSongs.value.map(formatSong));
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果完整播放列表已加载完成
|
||||
if (isFullPlaylistLoaded.value && completePlaylist.value.length > 0) {
|
||||
playerStore.setPlayList(completePlaylist.value.map(formatSong));
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果完整播放列表未加载完成,先使用当前已加载的歌曲开始播放
|
||||
playerStore.setPlayList(displayedSongs.value.map(formatSong));
|
||||
|
||||
// 在后台加载完整播放列表
|
||||
loadFullPlaylist().then(() => {
|
||||
// 加载完成后,更新播放列表为完整列表
|
||||
if (completePlaylist.value.length > 0) {
|
||||
playerStore.setPlayList(completePlaylist.value.map(formatSong));
|
||||
}
|
||||
});
|
||||
// 如果完整播放列表正在加载中,不需要重新触发加载
|
||||
if (isPlaylistLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 在后台继续加载完整播放列表(如果未加载完成)
|
||||
if (!isFullPlaylistLoaded.value) {
|
||||
console.log('播放时继续在后台加载完整列表');
|
||||
loadFullPlaylist();
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
@@ -253,19 +455,27 @@ const close = () => {
|
||||
|
||||
// 加载更多歌曲
|
||||
const loadMoreSongs = async () => {
|
||||
// 检查是否正在加载或已经加载完成
|
||||
if (isFullPlaylistLoaded.value) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchKeyword.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingMore.value || displayedSongs.value.length >= total.value) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingMore.value = true;
|
||||
|
||||
try {
|
||||
const start = displayedSongs.value.length;
|
||||
const end = Math.min(start + pageSize, total.value);
|
||||
|
||||
if (props.listInfo?.trackIds) {
|
||||
// 获取这一批次需要加载的所有ID
|
||||
const trackIdsToLoad = props.listInfo.trackIds
|
||||
.slice(start, end)
|
||||
.map((item) => item.id)
|
||||
@@ -275,7 +485,6 @@ const loadMoreSongs = async () => {
|
||||
await loadSongs(trackIdsToLoad, true, false);
|
||||
}
|
||||
} else if (start < props.songList.length) {
|
||||
// 直接使用 songList 分页
|
||||
const newSongs = props.songList.slice(start, end);
|
||||
newSongs.forEach((song) => {
|
||||
if (!loadedIds.value.has(song.id)) {
|
||||
@@ -285,7 +494,6 @@ const loadMoreSongs = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 更新是否还有更多数据的状态
|
||||
hasMore.value = displayedSongs.value.length < total.value;
|
||||
} catch (error) {
|
||||
console.error('加载更多歌曲失败:', error);
|
||||
@@ -295,28 +503,23 @@ const loadMoreSongs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 修改滚动处理函数
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target) return;
|
||||
// 处理虚拟列表滚动事件
|
||||
const handleVirtualScroll = (e: any) => {
|
||||
if (!e || !e.target) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const threshold = 200;
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < threshold &&
|
||||
!isLoadingMore.value &&
|
||||
hasMore.value
|
||||
hasMore.value &&
|
||||
!searchKeyword.value // 搜索状态下不触发加载更多
|
||||
) {
|
||||
loadMoreSongs();
|
||||
}
|
||||
};
|
||||
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % pageSize;
|
||||
return setAnimationDelay(currentPageIndex, 20);
|
||||
};
|
||||
|
||||
// 重置列表状态
|
||||
const resetListState = () => {
|
||||
page.value = 0;
|
||||
@@ -325,6 +528,8 @@ const resetListState = () => {
|
||||
completePlaylist.value = [];
|
||||
hasMore.value = true;
|
||||
loadingList.value = false;
|
||||
searchKeyword.value = ''; // 重置搜索关键词
|
||||
isFullPlaylistLoaded.value = false; // 重置完整播放列表状态
|
||||
};
|
||||
|
||||
// 初始化歌曲列表
|
||||
@@ -339,6 +544,15 @@ const initSongList = (songs: any[]) => {
|
||||
hasMore.value = displayedSongs.value.length < total.value;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.listInfo,
|
||||
(newListInfo) => {
|
||||
if (newListInfo?.trackIds) {
|
||||
loadFullPlaylist();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
// 修改 songList 监听器
|
||||
watch(
|
||||
() => props.songList,
|
||||
@@ -359,6 +573,14 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听搜索关键词变化
|
||||
watch(searchKeyword, () => {
|
||||
// 当搜索关键词为空时,考虑加载更多歌曲
|
||||
if (!searchKeyword.value && hasMore.value && displayedSongs.value.length < total.value) {
|
||||
loadMoreSongs();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时清理状态
|
||||
onUnmounted(() => {
|
||||
isPlaylistLoading.value = false;
|
||||
@@ -422,12 +644,34 @@ onUnmounted(() => {
|
||||
&-content {
|
||||
@apply min-h-[calc(80vh-60px)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.search-container {
|
||||
@apply max-w-md;
|
||||
|
||||
:deep(.n-input) {
|
||||
@apply bg-light-200 dark:bg-dark-200;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.no-result {
|
||||
@apply text-center py-8 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* 虚拟列表样式 */
|
||||
.song-virtual-list {
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: thin;
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 dark:bg-gray-600 rounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,6 +696,18 @@ onUnmounted(() => {
|
||||
@apply flex-1 ml-4;
|
||||
}
|
||||
}
|
||||
|
||||
.music-title {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply max-w-[50%];
|
||||
}
|
||||
|
||||
.song-virtual-list {
|
||||
height: calc(80vh - 120px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
|
||||
@@ -94,6 +94,17 @@
|
||||
<n-empty :description="t('download.empty.noDownloaded')" />
|
||||
</div>
|
||||
<div v-else class="downloaded-content">
|
||||
<div class="downloaded-header">
|
||||
<div class="header-title">
|
||||
{{ t('download.count', { count: downloadedList.length }) }}
|
||||
</div>
|
||||
<n-button secondary size="small" @click="showClearConfirm = true">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-delete-bin-line mr-1"></i>
|
||||
</template>
|
||||
{{ t('download.clearAll') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="downloaded-items">
|
||||
<div v-for="item in downList" :key="item.path" class="downloaded-item">
|
||||
<div class="downloaded-item-content">
|
||||
@@ -172,12 +183,38 @@
|
||||
}}</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 清空确认对话框 -->
|
||||
<n-modal
|
||||
v-model:show="showClearConfirm"
|
||||
preset="dialog"
|
||||
type="warning"
|
||||
:title="t('download.clear.title')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<i class="iconfont ri-delete-bin-line mr-2 text-xl"></i>
|
||||
<span>{{ t('download.clear.title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="delete-confirm-content">
|
||||
{{ t('download.clear.message') }}
|
||||
</div>
|
||||
<template #action>
|
||||
<n-button size="small" @click="showClearConfirm = false">{{
|
||||
t('download.clear.cancel')
|
||||
}}</n-button>
|
||||
<n-button size="small" type="warning" @click="clearDownloadRecords">{{
|
||||
t('download.clear.confirm')
|
||||
}}</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProgressStatus } from 'naive-ui';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
@@ -320,29 +357,49 @@ const confirmDelete = async () => {
|
||||
'delete-downloaded-music',
|
||||
itemToDelete.value.path
|
||||
);
|
||||
|
||||
// 无论删除文件是否成功,都从记录中移除
|
||||
localStorage.setItem(
|
||||
'downloadedList',
|
||||
JSON.stringify(
|
||||
downloadedList.value.filter((item) => item.id !== (itemToDelete.value as DownloadedItem).id)
|
||||
)
|
||||
);
|
||||
await refreshDownloadedList();
|
||||
|
||||
if (success) {
|
||||
localStorage.setItem(
|
||||
'downloadedList',
|
||||
JSON.stringify(
|
||||
downloadedList.value.filter(
|
||||
(item) => item.id !== (itemToDelete.value as DownloadedItem).id
|
||||
)
|
||||
)
|
||||
);
|
||||
await refreshDownloadedList();
|
||||
message.success(t('download.delete.success'));
|
||||
} else {
|
||||
message.error(t('download.delete.failed'));
|
||||
message.warning(t('download.delete.fileNotFound'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete music:', error);
|
||||
message.error(t('download.delete.failed'));
|
||||
// 即使删除文件出错,也从记录中移除
|
||||
localStorage.setItem(
|
||||
'downloadedList',
|
||||
JSON.stringify(
|
||||
downloadedList.value.filter((item) => item.id !== (itemToDelete.value as DownloadedItem).id)
|
||||
)
|
||||
);
|
||||
await refreshDownloadedList();
|
||||
message.warning(t('download.delete.recordRemoved'));
|
||||
} finally {
|
||||
showDeleteConfirm.value = false;
|
||||
itemToDelete.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 清空下载记录相关
|
||||
const showClearConfirm = ref(false);
|
||||
|
||||
// 清空下载记录
|
||||
const clearDownloadRecords = () => {
|
||||
localStorage.setItem('downloadedList', '[]');
|
||||
downloadedList.value = [];
|
||||
message.success(t('download.clear.success'));
|
||||
showClearConfirm.value = false;
|
||||
};
|
||||
|
||||
// 播放音乐
|
||||
// const handlePlay = async (musicInfo: SongResult) => {
|
||||
// await playerStore.setPlay(musicInfo);
|
||||
@@ -421,6 +478,12 @@ onMounted(() => {
|
||||
// 监听下载进度
|
||||
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||
|
||||
// 如果进度为100%,将状态设置为已完成
|
||||
if (data.progress === 100) {
|
||||
data.status = 'completed';
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
Object.assign(existingItem, {
|
||||
...data,
|
||||
@@ -523,9 +586,18 @@ const handleDrawerClose = () => {
|
||||
@apply flex-1 overflow-hidden pb-40;
|
||||
}
|
||||
|
||||
.downloaded-header {
|
||||
@apply flex items-center justify-between p-4 bg-light-100 dark:bg-dark-200 sticky top-0 z-10;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
.header-title {
|
||||
@apply text-sm font-medium text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.download-items,
|
||||
.downloaded-items {
|
||||
@apply space-y-3;
|
||||
@apply space-y-3 p-4;
|
||||
}
|
||||
|
||||
.total-progress {
|
||||
|
||||
@@ -293,14 +293,17 @@ const downloadMusic = async () => {
|
||||
// 构建文件名
|
||||
const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(',');
|
||||
const filename = `${props.item.name} - ${artistNames}`;
|
||||
console.log('props.item', props.item);
|
||||
|
||||
const songData = cloneDeep(props.item);
|
||||
songData.ar = songData.ar || songData.song?.artists;
|
||||
// 发送下载请求
|
||||
window.electron.ipcRenderer.send('download-music', {
|
||||
url: data.url,
|
||||
type: data.type,
|
||||
filename,
|
||||
songInfo: {
|
||||
...cloneDeep(props.item),
|
||||
...songData,
|
||||
downloadTime: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
v-model:show="showMusic"
|
||||
:name="albumName"
|
||||
:song-list="songList"
|
||||
:cover="false"
|
||||
:cover="true"
|
||||
:loading="loadingList"
|
||||
:list-info="albumInfo"
|
||||
/>
|
||||
@@ -62,17 +62,19 @@ const handleClick = async (item: any) => {
|
||||
loadingList.value = true;
|
||||
showMusic.value = true;
|
||||
const res = await getAlbum(item.id);
|
||||
songList.value = res.data.songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || item.picUrl;
|
||||
const { songs, album } = res.data;
|
||||
songList.value = songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || album.picUrl;
|
||||
song.picUrl = song.al.picUrl || album.picUrl || song.picUrl;
|
||||
return song;
|
||||
});
|
||||
albumInfo.value = {
|
||||
...res.data.album,
|
||||
...album,
|
||||
creator: {
|
||||
avatarUrl: res.data.album.artist.img1v1Url,
|
||||
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
|
||||
avatarUrl: album.artist.img1v1Url,
|
||||
nickname: `${album.artist.name} - ${album.company}`
|
||||
},
|
||||
description: res.data.album.description
|
||||
description: album.description
|
||||
};
|
||||
loadingList.value = false;
|
||||
};
|
||||
|
||||
@@ -61,19 +61,27 @@
|
||||
>
|
||||
<div class="user-play-item-img">
|
||||
<img :src="getImgUrl(item.coverImgUrl, '200y200')" alt="" />
|
||||
<div class="user-play-item-overlay">
|
||||
<div class="user-play-item-play-btn">
|
||||
<i class="iconfont icon-playfill text-3xl text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-play-item-title">
|
||||
<div class="user-play-item-title-name">{{ item.name }}</div>
|
||||
|
||||
<div class="user-play-item-list">
|
||||
<div
|
||||
v-for="song in item.tracks"
|
||||
:key="song.id"
|
||||
class="user-play-item-list-name"
|
||||
>
|
||||
{{ song.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-play-item-count">
|
||||
<div class="user-play-item-count-tag">
|
||||
{{ t('common.songCount', { count: item.trackCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-play-item-direct-play" @click.stop="handlePlayPlaylist(item.id)">
|
||||
<i class="iconfont icon-playfill text-xl text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,13 +150,15 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getDayRecommend, getHotSinger } from '@/api/home';
|
||||
import { getListDetail } from '@/api/list';
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import { getUserPlaylist } from '@/api/user';
|
||||
import MusicList from '@/components/MusicList.vue';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { useUserStore } from '@/store';
|
||||
import { usePlayerStore, useUserStore } from '@/store';
|
||||
import { IDayRecommend } from '@/type/day_recommend';
|
||||
import { Playlist } from '@/type/list';
|
||||
import type { IListDetail } from '@/type/listDetail';
|
||||
import { SongResult } from '@/type/music';
|
||||
import type { IHotSinger } from '@/type/singer';
|
||||
import {
|
||||
getImgUrl,
|
||||
@@ -159,6 +169,7 @@ import {
|
||||
} from '@/utils';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -293,6 +304,93 @@ const toPlaylist = async (id: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 添加直接播放歌单的方法
|
||||
const handlePlayPlaylist = async (id: number) => {
|
||||
try {
|
||||
// 先显示加载状态
|
||||
playlistLoading.value = true;
|
||||
|
||||
// 获取歌单详情
|
||||
const { data } = await getListDetail(id);
|
||||
|
||||
if (data?.playlist) {
|
||||
// 先使用已有的tracks开始播放(这些是已经在歌单详情中返回的前几首歌曲)
|
||||
if (data.playlist.tracks?.length > 0) {
|
||||
// 格式化歌曲列表
|
||||
const initialSongs = data.playlist.tracks.map((track) => ({
|
||||
...track,
|
||||
source: 'netease',
|
||||
picUrl: track.al.picUrl
|
||||
})) as unknown as SongResult[];
|
||||
|
||||
// 设置播放列表
|
||||
playerStore.setPlayList(initialSongs);
|
||||
|
||||
// 开始播放第一首
|
||||
await playerStore.setPlay(initialSongs[0]);
|
||||
|
||||
// 如果有trackIds,异步加载完整歌单
|
||||
if (data.playlist.trackIds?.length > initialSongs.length) {
|
||||
loadFullPlaylist(data.playlist.trackIds, initialSongs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭加载状态
|
||||
playlistLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error('播放歌单失败:', error);
|
||||
playlistLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 异步加载完整歌单
|
||||
const loadFullPlaylist = async (trackIds: { id: number }[], initialSongs: SongResult[]) => {
|
||||
try {
|
||||
// 获取已加载歌曲的ID集合,避免重复加载
|
||||
const loadedIds = new Set(initialSongs.map((song) => song.id));
|
||||
|
||||
// 筛选出未加载的ID
|
||||
const unloadedTrackIds = trackIds
|
||||
.filter((item) => !loadedIds.has(item.id as number))
|
||||
.map((item) => item.id);
|
||||
|
||||
if (unloadedTrackIds.length === 0) return;
|
||||
|
||||
// 分批获取歌曲详情,每批最多获取500首
|
||||
const batchSize = 500;
|
||||
const allSongs = [...initialSongs];
|
||||
|
||||
for (let i = 0; i < unloadedTrackIds.length; i += batchSize) {
|
||||
const batchIds = unloadedTrackIds.slice(i, i + batchSize);
|
||||
if (batchIds.length > 0) {
|
||||
try {
|
||||
const { data: songsData } = await getMusicDetail(batchIds);
|
||||
if (songsData?.songs?.length) {
|
||||
const formattedSongs = songsData.songs.map((item) => ({
|
||||
...item,
|
||||
source: 'netease',
|
||||
picUrl: item.al.picUrl
|
||||
})) as unknown as SongResult[];
|
||||
|
||||
allSongs.push(...formattedSongs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取批次歌曲详情失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新完整的播放列表但保持当前播放的歌曲不变
|
||||
if (allSongs.length > initialSongs.length) {
|
||||
console.log('更新播放列表,总歌曲数:', allSongs.length);
|
||||
playerStore.setPlayList(allSongs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载完整歌单失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听登录状态
|
||||
watchEffect(() => {
|
||||
if (userStore.user) {
|
||||
@@ -416,18 +514,12 @@ const getPlaylistGridClass = (length: number) => {
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
.user-play-item-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
@apply absolute inset-0 w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
&-overlay {
|
||||
@apply absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center opacity-0 transition-opacity duration-300;
|
||||
}
|
||||
&-title {
|
||||
@apply absolute top-0 left-0 right-0 p-2 bg-gradient-to-b from-black/70 to-transparent z-10;
|
||||
&-name {
|
||||
@@ -435,11 +527,17 @@ const getPlaylistGridClass = (length: number) => {
|
||||
}
|
||||
}
|
||||
&-count {
|
||||
@apply absolute bottom-2 right-2 z-10;
|
||||
@apply absolute bottom-2 left-2 z-10;
|
||||
&-tag {
|
||||
@apply px-2 py-0.5 text-xs text-white bg-black/50 backdrop-blur-sm rounded-full;
|
||||
}
|
||||
}
|
||||
&-direct-play {
|
||||
@apply absolute bottom-2 right-2 z-20 w-10 h-10 rounded-full bg-green-600 hover:bg-green-700 flex items-center justify-center cursor-pointer transform scale-90 hover:scale-100 transition-all;
|
||||
&:hover {
|
||||
@apply shadow-lg;
|
||||
}
|
||||
}
|
||||
&-play-btn {
|
||||
@apply flex items-center justify-center;
|
||||
transform: scale(0.8);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// musicHistoryHooks
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
import { recordPlay } from '@/api/stats';
|
||||
import type { SongResult } from '@/type/music';
|
||||
|
||||
export const useMusicHistory = () => {
|
||||
@@ -14,6 +15,25 @@ export const useMusicHistory = () => {
|
||||
} else {
|
||||
musicHistory.value.unshift({ ...music, count: 1 });
|
||||
}
|
||||
|
||||
// 记录播放统计
|
||||
if (music?.id && music?.name) {
|
||||
// 获取艺术家名称
|
||||
let artistName = '未知艺术家';
|
||||
|
||||
if (music.ar) {
|
||||
artistName = music.ar.map((artist) => artist.name).join('/');
|
||||
} else if (music.song?.artists && music.song.artists.length > 0) {
|
||||
artistName = music.song.artists.map((artist) => artist.name).join('/');
|
||||
} else if (music.artists) {
|
||||
artistName = music.artists.map((artist) => artist.name).join('/');
|
||||
}
|
||||
|
||||
// 发送播放统计
|
||||
recordPlay(music.id, music.name, artistName).catch((error) =>
|
||||
console.error('记录播放统计失败:', error)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const delMusic = (music: SongResult) => {
|
||||
|
||||
@@ -395,7 +395,9 @@ const setupAudioListeners = () => {
|
||||
// 监听播放
|
||||
audioService.on('play', () => {
|
||||
playerStore.setPlayMusic(true);
|
||||
window.api.sendSong(cloneDeep(playerStore.playMusic));
|
||||
if (isElectron) {
|
||||
window.api.sendSong(cloneDeep(playerStore.playMusic));
|
||||
}
|
||||
clearInterval();
|
||||
interval = window.setInterval(() => {
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ref } from 'vue';
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import type { ILyric, ILyricText, SongResult } from '@/type/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
@@ -13,12 +14,16 @@ const musicHistory = useMusicHistory();
|
||||
|
||||
// 获取歌曲url
|
||||
export const getSongUrl = async (id: any, songData: any, isDownloaded: boolean = false) => {
|
||||
const { data } = await getMusicUrl(id, isDownloaded);
|
||||
const settingsStore = useSettingsStore();
|
||||
const { unlimitedDownload } = settingsStore.setData;
|
||||
|
||||
const { data } = await getMusicUrl(id, !unlimitedDownload);
|
||||
let url = '';
|
||||
let songDetail = null;
|
||||
|
||||
try {
|
||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
||||
const res = await getParsingMusicUrl(id, songData);
|
||||
const res = await getParsingMusicUrl(id, cloneDeep(songData));
|
||||
url = res.data.data.url;
|
||||
songDetail = res.data.data;
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
import { recordVisit } from '@/api/stats';
|
||||
import AppLayout from '@/layout/AppLayout.vue';
|
||||
import MiniLayout from '@/layout/MiniLayout.vue';
|
||||
import homeRouter from '@/router/home';
|
||||
@@ -80,4 +81,13 @@ router.beforeEach((to, _, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 添加全局后置钩子,记录页面访问
|
||||
router.afterEach((to) => {
|
||||
const pageName = to.name?.toString() || to.path;
|
||||
// 使用setTimeout避免阻塞路由导航
|
||||
setTimeout(() => {
|
||||
recordVisit(pageName).catch((error) => console.error('记录页面访问失败:', error));
|
||||
}, 100);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -153,7 +153,15 @@ const getSongDetail = async (playMusic: SongResult) => {
|
||||
return { ...playMusic, backgroundColor, primaryColor } as SongResult;
|
||||
}
|
||||
|
||||
if (playMusic.expiredAt && playMusic.expiredAt < Date.now()) {
|
||||
console.info(`歌曲已过期,重新获取: ${playMusic.name}`);
|
||||
playMusic.playMusicUrl = undefined;
|
||||
}
|
||||
|
||||
const playMusicUrl = playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic));
|
||||
playMusic.createdAt = Date.now();
|
||||
// 半小时后过期
|
||||
playMusic.expiredAt = playMusic.createdAt + 1800000;
|
||||
const { backgroundColor, primaryColor } =
|
||||
playMusic.backgroundColor && playMusic.primaryColor
|
||||
? playMusic
|
||||
|
||||
@@ -24,7 +24,9 @@ export interface SongResult {
|
||||
program?: any;
|
||||
alg?: string;
|
||||
ar: Artist[];
|
||||
artists?: Artist[];
|
||||
al: Album;
|
||||
album?: Album;
|
||||
count: number;
|
||||
playMusicUrl?: string;
|
||||
playLoading?: boolean;
|
||||
@@ -36,6 +38,10 @@ export interface SongResult {
|
||||
cid: number;
|
||||
};
|
||||
source?: 'netease' | 'bilibili';
|
||||
// 过期时间
|
||||
expiredAt?: number;
|
||||
// 获取时间
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
|
||||
@@ -193,11 +193,18 @@ const handleBatchDownload = async () => {
|
||||
failCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
const songData = cloneDeep(song);
|
||||
const songInfo = {
|
||||
...songData,
|
||||
ar: songData.ar || songData.song?.artists,
|
||||
downloadTime: Date.now()
|
||||
};
|
||||
console.log('songInfo', songInfo);
|
||||
console.log('song', song);
|
||||
window.electron.ipcRenderer.send('download-music', {
|
||||
url,
|
||||
filename: `${song.name} - ${(song.ar || song.song?.artists)?.map((a) => a.name).join(',')}`,
|
||||
songInfo: cloneDeep(song),
|
||||
songInfo,
|
||||
type
|
||||
});
|
||||
});
|
||||
@@ -210,7 +217,7 @@ const handleBatchDownload = async () => {
|
||||
};
|
||||
|
||||
// 无限滚动相关
|
||||
const pageSize = 16;
|
||||
const pageSize = 100;
|
||||
const currentPage = ref(1);
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -56,7 +56,7 @@ const displayList = ref<SongResult[]>([]);
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// 无限滚动相关配置
|
||||
const pageSize = 20;
|
||||
const pageSize = 100;
|
||||
const currentPage = ref(1);
|
||||
|
||||
// 获取当前页的音乐详情
|
||||
|
||||
@@ -211,6 +211,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">{{ t('settings.application.unlimitedDownload') }}</div>
|
||||
<div class="set-item-content">
|
||||
<n-switch v-model:value="setData.unlimitedDownload" class="mr-2">
|
||||
<template #checked>{{ t('common.on') }}</template>
|
||||
<template #unchecked>{{ t('common.off') }}</template>
|
||||
</n-switch>
|
||||
{{ t('settings.application.unlimitedDownloadDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">{{ t('settings.application.downloadPath') }}</div>
|
||||
|
||||
Reference in New Issue
Block a user