183 Commits

Author SHA1 Message Date
alger
d7169efcf5 feat: 更新版本至 4.8.0 2025-06-20 23:32:29 +08:00
alger
a7cce28050 feat: 在构建配置中添加对 Linux RPM 包的支持 2025-06-20 23:32:08 +08:00
alger
e99385c512 feat: 添加版本号比较函数,优化更新检查逻辑 2025-06-20 23:17:16 +08:00
alger
72c11eef6c 🎨 style: 更新歌词区域按钮样式,修复歌词时间调整按钮不显示问题 2025-06-20 23:06:57 +08:00
alger
facb03d3e1 feat: 添加 linux rpm 构建目标支持 2025-06-20 22:27:48 +08:00
alger
902feff2fb 🔧 chore: 降级 electron 版本至 35.2.0 2025-06-20 22:23:56 +08:00
alger
426cafd54c feat: 更新 AppMenu 组件,增加移动端适配,优化工具提示的禁用条件 2025-06-20 21:15:13 +08:00
alger
e4ed089085 chore: 更新 .gitignore 文件,添加 android/app/release 目录以排除构建文件 2025-06-20 21:08:43 +08:00
alger
7f6e11e508 feat: 移除代理配置,简化构建命令,更新环境变量设置 2025-06-20 21:08:26 +08:00
alger
81b61e4575 feat: 增强移动端播放页面效果,优化横屏效果,添加播放列表功能 2025-06-20 21:07:17 +08:00
Alger
66aa6b7aff Merge pull request #326 from hecai84/main
添加任务栏缩略图控制按钮
2025-06-20 17:49:09 +08:00
hecai
58ab9906cc 启动默认显示缩略图控制按钮。
(cherry picked from commit 1f438e391ab7bb37e38a31ec571724d33f35310b)
2025-06-19 09:26:28 +08:00
hecai
9bec67ebf9 添加任务栏缩略图控制按钮
(cherry picked from commit e0ddb7cb4821b5b48ed3ffb99a44c00c8cb4d46e)
2025-06-18 15:52:37 +08:00
Alger
386c9c7067 Merge pull request #320 from Hellodwadawd12312312/feature
修复音频初始化音量问题,完善翻译
2025-06-17 11:34:26 +08:00
Felix
b95f5e1b2f small fix 2025-06-16 08:44:50 +02:00
Qumo
090103bf1a Update audioService.ts 2025-06-16 07:47:40 +02:00
Qumo
5ee60d751e Update audioService.ts 2025-06-16 07:39:35 +02:00
Qumo
a85b5ff58b Merge branch 'algerkong:main' into feature 2025-06-16 07:28:28 +02:00
alger
58922dc91b feat: 更新类型定义文件路径,移除旧的 auto-imports 和 components 定义文件 2025-06-12 22:58:01 +08:00
alger
0d89e15e01 feat: 添加横屏模式支持,优化歌词和播放控制布局 2025-06-12 22:57:24 +08:00
algerkong
b9c38d257a feat: 重构播放控制逻辑,添加播放进度恢复功能并清理无用代码 2025-06-11 20:12:52 +08:00
algerkong
d227ac8b34 feat: 优化播放栏无法控制隐藏问题 2025-06-11 20:10:33 +08:00
algerkong
f9d85f11ad feat: 去除无用代码 2025-06-11 20:05:43 +08:00
Felix
49595ef57f more translation 2025-06-10 13:31:33 +02:00
Alger
cceb1de3fb Merge pull request #304 from Hellodwadawd12312312/feature
添加搜索类型的翻译
2025-06-09 10:41:25 +08:00
Felix
f59b5d5602 translation 2025-06-08 14:49:24 +02:00
alger
fabcf289dc feat: 优化歌曲列表组件布局,添加底部间距以提升视觉效果 2025-06-07 22:47:34 +08:00
alger
934580552d feat: 优化歌词组件和移动端界面设计 2025-06-07 22:30:39 +08:00
alger
6f1909a028 🐞 fix: 修复刷新后第一次播放出现的无法播放问题 2025-06-07 22:10:55 +08:00
alger
c5d71cf53c feat: 添加 Vite 配置文件并更新 package.json,支持开发模式下的 Web 预览 2025-06-07 21:34:19 +08:00
alger
21b2fc08be feat: 优化移动端界面设计以及歌词界面设计 添加播放模式选择 2025-06-07 10:48:54 +08:00
alger
155bdf246c feat: 优化提示组件,支持位置和图标显示选项 2025-06-06 23:37:24 +08:00
alger
e46df8a04e feat: 优化窗口大小管理功能,优化窗口状态保存与恢复逻辑
- 引入窗口大小管理器,初始化窗口大小管理
- 优化窗口状态保存与恢复,确保在迷你模式下正确应用窗口大小
- 移除不必要的代码,简化窗口管理逻辑
- 更新窗口创建逻辑,确保窗口大小和位置的正确性
2025-06-06 23:37:06 +08:00
alger
b203077cad feat: 添加下载设置功能,支持自定义文件名格式和下载路径配置
- 新增下载设置抽屉,允许用户设置下载路径和文件名格式
- 支持多种文件名格式预设和自定义格式
- 实现下载项的显示名称格式化
- 优化下载管理逻辑,避免重复通知
2025-06-05 23:02:41 +08:00
alger
a08fbf1ec8 style: 优化播放列表抽屉样式,调整标题和按钮颜色以提升可读性 2025-06-05 22:19:55 +08:00
alger
edd393c8ac feat: 新增歌单导入功能
添加歌单导入功能,支持通过链接、文本和元数据三种方式导入歌单
- 实现链接导入、文本导入和元数据导入三种方式
- 添加导入状态检查和显示功能
2025-06-04 22:53:49 +08:00
alger
8988cdb082 feat: 在音乐列表页面中添加 Electron 环境判断,优化多选下载操作的显示逻辑 2025-06-04 22:46:35 +08:00
alger
1221101821 feat: 列表添加多选下载功能,支持批量选择和下载音乐 2025-06-04 20:19:44 +08:00
alger
3ac3159058 feat: 添加下载管理页面, 引入文件类型检测库以支持多种音频格式 2025-06-03 22:35:04 +08:00
Alger
bfaa06b0d5 Merge pull request #281 from algerkong/feat/window-auto-size
feat: 添加主窗口自适应大小功能,页面缩放功能,支持缩放因子的调整和重置,并在搜索栏中提供缩放控制
2025-05-28 22:12:29 +08:00
alger
61700473b9 feat: 添加主窗口自适应大小功能,页面缩放功能,支持缩放因子的调整和重置,并在搜索栏中提供缩放控制 2025-05-28 22:08:17 +08:00
alger
bf4bcfcde6 chore: 更新 .gitignore 文件 2025-05-28 22:06:13 +08:00
Alger
475d7d2595 Merge pull request #280 from algerkong/fix/day-list
feat: 重构每日推荐数据加载逻辑,提取为独立函数并优化用户状态判断
2025-05-28 22:04:14 +08:00
alger
5e704a1f3c feat: 重构每日推荐数据加载逻辑,提取为独立函数并优化用户状态判断 2025-05-28 22:02:59 +08:00
Alger
6faab820da Merge pull request #279 from algerkong/fix/volume-color
feat: 添加mini播放栏鼠标滚轮调整音量 并优化音量滑块数字不展示问题
2025-05-28 22:01:07 +08:00
alger
5c7278544a feat: 添加mini播放栏鼠标滚轮调整音量 并优化音量滑块数字不展示问题 2025-05-28 21:58:32 +08:00
Alger
4c24bb9257 feat: Update README.md 2025-05-27 15:18:42 +08:00
alger
c975344dd0 feat: 添加歌词矫正功能,支持增加和减少矫正时间 2025-05-26 22:58:42 +08:00
Alger
08a14359a5 Merge pull request #268 from algerkong/fix/modal-zindex
fix: 修复更多设置弹窗被歌词窗口遮挡问题 并优化为互斥弹窗, 优化样式
2025-05-25 19:28:54 +08:00
alger
62e5166953 fix: 修复更多设置弹窗被歌词窗口遮挡问题 并优化为互斥弹窗, 优化样式 2025-05-25 19:26:24 +08:00
alger
7685ad3939 feat: 在配置中添加 publicDir 选项以指定资源目录 2025-05-25 11:35:16 +08:00
alger
d7c06586d6 feat: 在 EQControl 组件标题中添加桌面版可用提示标签 2025-05-25 11:07:06 +08:00
alger
5070a085e9 feat: 优化收藏和历史列表组件,添加加载状态管理和动画效果 2025-05-24 19:23:38 +08:00
alger
e5adb8aa72 fix: 修复设置页面动画速度滑块样式和文本错误 2025-05-24 19:23:13 +08:00
alger
d08439c99e feat:v4.7.1 2025-05-24 10:11:50 +08:00
alger
dee4515cb3 fix: 修复切换收藏和不喜欢状态时事件处理逻辑 2025-05-24 10:11:29 +08:00
alger
53bc1774ff fix: 修复下载请求中的音乐 URL 处理逻辑 2025-05-24 10:02:15 +08:00
alger
589540be29 feat: 优化歌曲组件事件处理,使用展开运算符简化事件传递 2025-05-23 22:43:01 +08:00
alger
2bcae85419 feat: 更新应用ico图标 2025-05-23 21:57:34 +08:00
alger
6e68927eec feat: 更新应用图标 2025-05-23 21:46:18 +08:00
alger
a4eea18fa5 feat:更新 4.7.0 2025-05-23 21:33:58 +08:00
alger
fe5b1d5de8 feat: 添加主窗口失去焦点时禁用最大化功能 2025-05-23 21:29:43 +08:00
alger
c8e6db11c9 feat: 更新应用图标,替换为新版本的图标文件 2025-05-23 20:43:13 +08:00
alger
5bef0e44a0 feat: 为歌曲下拉菜单添加圆角样式,优化歌曲预览布局 2025-05-23 20:08:57 +08:00
alger
d56a25eb3c feat: 在用户歌单中添加“我创建的”标签,优化获取用户歌单的逻辑 2025-05-23 20:08:40 +08:00
alger
a449b74ef2 feat: 添加 husky 预提交和预推送钩子,运行类型检查以确保代码质量 2025-05-23 20:07:29 +08:00
alger
ad7b504eef 🦄 refactor: 重构歌曲组件,添加基础组件和多种样式,优化播放列表抽屉功能 2025-05-23 19:39:46 +08:00
alger
6048e243c7 feat: 在歌手详情页添加歌曲操作工具栏,支持播放全部、添加到播放列表和搜索功能,布局切换功能 2025-05-23 19:39:32 +08:00
alger
0c74291a34 feat: 添加所有用户的关注和粉丝列表点击 优化播放排行获取和无权限展示 2025-05-23 19:39:26 +08:00
alger
7fa0fa5221 feat: 添加 macOS 下点击 Dock 图标激活主窗口的功能 2025-05-23 19:39:21 +08:00
alger
95af222da7 feat: 添加鼠标滚轮调整音量功能,并显示音量百分比 2025-05-23 19:39:16 +08:00
alger
9eefe62fba refactor: 移除未使用的导入和格式问题 2025-05-22 22:21:53 +08:00
Alger
b621995e24 Merge pull request #256 from algerkong/fix/downloadurl
fix: 修复并优化下载功能,重构添加 hook
2025-05-22 22:15:58 +08:00
Alger
91f97ff76b Merge branch 'main' into fix/downloadurl 2025-05-22 22:15:51 +08:00
Alger
cce2b96d29 Merge pull request #255 from algerkong/feat/nolike
feat: 歌曲右键 添加不喜欢功能以过滤每日推荐歌曲
2025-05-22 22:12:46 +08:00
alger
a0935c74fe feat: 歌曲右键 添加不喜欢功能以过滤每日推荐歌曲 2025-05-22 22:11:10 +08:00
alger
df5ecb6eb5 feat: 添加 tab监听以刷新下载列表 2025-05-22 20:59:06 +08:00
alger
ca51020602 refactor: 将下载逻辑提取到useDownload hook中 2025-05-22 20:58:47 +08:00
alger
258828ffbd feat: 双击播放由双击歌曲名改为双击整个组件都可以 2025-05-22 20:12:55 +08:00
alger
91b1ff7df9 fix: 修复播放音乐时URL未正确更新的问题 2025-05-22 20:12:47 +08:00
alger
8cc617a5f6 docs: 更新文档图片和README内容 2025-05-20 22:45:45 +08:00
alger
170ac45115 style: 顶部定时 添加悬停缩放效果和光标指针样式 2025-05-20 21:22:41 +08:00
alger
2dd45351e5 feat: 添加定时器过期检查功能 优化顶部定时点击 2025-05-20 20:57:16 +08:00
alger
f5f0dbb222 feat: 优化播放栏,整合高级控制菜单,将定时、均衡器、速度控制改为更多设置按钮显示, 添加定时关闭顶部显示功能 2025-05-19 23:13:06 +08:00
Alger
7fca6db2a3 Merge pull request #241 from Java-wyx/speed-up
feat: 添加播放速度控制功能
2025-05-19 19:17:26 +08:00
Java-wyx
655473699a feat: 添加播放速度控制功能
现有播放器不支持改变播放速度,用户无法实现 0.5×、1.5×、2.0× 等快进/慢放需求。为了提升可用性和灵活性,决定在播放栏增加速度选择菜单,并支持 Media Session API 同步速率
2025-05-19 17:59:20 +08:00
Alger
4d371df510 Merge pull request #236 from algerkong/dev
feat: 优化页面效果 音源解析优化
2025-05-18 13:12:18 +08:00
alger
a21521cc6f docs: 更新预览地址 2025-05-18 13:09:49 +08:00
alger
01a3a7a501 feat: 添加音乐平台链接,优化移动端样式 2025-05-18 12:45:19 +08:00
alger
e47c84e5eb feat:优化B站音频解析功能 2025-05-18 12:44:23 +08:00
alger
54cbb84e6e style(player): 统一音源选项的标签格式 2025-05-18 12:43:27 +08:00
alger
f68f49973a perf(请求): 增加请求超时时间至15000毫秒 2025-05-18 12:43:09 +08:00
alger
e9fe9000f6 🐞 fix(player): 修复播放状态判断逻辑
修复在播放相同ID但不同URL的音乐时,播放状态判断逻辑错误的问题。现在只有当音乐ID和URL都相同时才会切换播放/暂停状态。
2025-05-18 12:42:15 +08:00
alger
6d4e6ef214 feat: 移除不必要的Content-Security-Policy 2025-05-18 10:57:19 +08:00
alger
2379b2c9cc feat: 点击下一首自动播放,优化 https问题 2025-05-17 20:10:07 +08:00
Alger
8c6b69e762 Merge pull request #234 from algerkong/feat/control-status-bar
 feat: 添加mac状态栏播放按键控制功能开关
2025-05-17 14:47:14 +08:00
alger
ae1a7c963f 🌈 style: 移除未使用的SleepTimerPopover组件 2025-05-17 14:46:35 +08:00
alger
2476fbd6e3 feat: 添加mac状态栏播放按键控制功能开关 2025-05-17 14:45:39 +08:00
alger
f7951ec22f feat: 移动端去除定时关闭 2025-05-17 14:11:10 +08:00
alger
33a1057de9 feat: 修改移动端展示菜单 2025-05-17 13:53:52 +08:00
alger
2e96161bd0 feat: 修改播放列表展示形式,优化播放逻辑,添加清空播放列表功能 2025-05-17 13:27:50 +08:00
alger
56b3ecfd25 🔧 chore: 优化网页端下载程序功能 2025-05-15 22:06:12 +08:00
alger
54d66d05f4 🔧 chore: 更新 MusicListPage 组件,添加移动端布局判断,优化紧凑布局逻辑 2025-05-15 21:33:44 +08:00
alger
b32408b44e feat: 歌单列表相添加布局切换、播放全部、收藏、添加到播放列表 2025-05-15 21:20:01 +08:00
alger
3c792ce3cc 🔧 chore: 调整 PlaylistDrawer 组件的样式,增加内边距 2025-05-15 21:17:14 +08:00
alger
5084da333f feat: 在应用菜单中添加工具提示功能 2025-05-15 21:16:48 +08:00
alger
a8010c8ca7 feat: 添加排行榜页面 2025-05-15 21:16:33 +08:00
algerkong
e1ddffc8ae feat: 更新 README 2025-05-15 15:11:46 +08:00
alger
69b1e541c6 feat: 在收藏列表中添加歌曲点赞功能 2025-05-15 00:08:27 +08:00
alger
35b84f3e6a 🔧 chore: 更新收藏列表中活动项的背景颜色和文本颜色 2025-05-14 21:46:15 +08:00
alger
28b9fd5475 feat: 更新 README 和国际化文件,添加QQ 频道信息 2025-05-14 21:41:38 +08:00
Alger
dc70fde9e4 Merge pull request #227 from algerkong/fix/mini-bar-volume
🔧 chore:  mini播放栏不再显示音量调节
2025-05-14 21:26:57 +08:00
alger
278db37a88 🔧 chore: mini播放栏不再显示音量调节 2025-05-14 21:26:23 +08:00
alger
2803d40dd1 feat: 收藏列表添加升序降序排列 2025-05-14 21:18:42 +08:00
alger
54f82d384e feat: 退出登录 刷新页面 2025-05-14 21:18:11 +08:00
alger
7d1ffa603c 🔧 chore: 更新获取最新发布信息的 API URL 2025-05-13 22:38:03 +08:00
alger
49f7728eac feat: 更新至 v4.6.0 2025-05-11 21:51:42 +08:00
alger
890c0c86c1 🔧 chore: 尝试解决 windows 桌面歌词窗口标题出现的问题 2025-05-11 21:45:45 +08:00
alger
15f4ea4708 🔧 chore: 移除 MvPlayer 组件中未使用的 playerStore 引用,简化代码结构 2025-05-11 15:40:30 +08:00
Alger
dbb3fbcc09 Merge pull request #216 from algerkong/feat/search-music-play
 feat: 搜索列表添加下一首播放功能,修改播放逻辑搜索的歌曲点击播放不重新覆盖播放列表, 添加全部播放功能
2025-05-11 15:38:35 +08:00
alger
31640bb663 feat: 搜索列表添加下一首播放功能,修改播放逻辑搜索的歌曲点击播放不重新覆盖播放列表, 添加全部播放功能 2025-05-11 15:37:37 +08:00
Alger
10f4473c9d Merge pull request #215 from algerkong/feat/music-reparse
添加重新解析音乐功能
2025-05-11 15:13:00 +08:00
alger
3297eb5ccb Merge branch 'main' into feat/music-reparse 2025-05-11 15:10:18 +08:00
alger
82a69d0b00 feat: 增加音源重新解析功能 2025-05-11 15:09:56 +08:00
alger
3d66a890c2 feat: 优化歌手详情页的路由参数监听逻辑 2025-05-11 11:53:31 +08:00
alger
b3de2ae785 🔧 chore: 优化 MvPlayer 组件的关闭逻辑,简化音频暂停处理 2025-05-11 01:12:47 +08:00
alger
31ea3b7e0a 🔧 chore: 修改 MiniPlayBar 组件,调整音量滑块的样式和交互方式,优化悬停效果 2025-05-10 21:25:40 +08:00
alger
b8580efb17 feat: 修复图片加载过大问题 2025-05-10 20:41:27 +08:00
alger
9cc064c01b 🔧 chore:改进播放器组件的加载状态显示, 优化 GD音乐解析逻辑,增加超时处理,调整音源列表 2025-05-10 20:12:10 +08:00
alger
80450349c0 🐞 fix: 修复歌曲加入歌单失败问题 2025-05-09 19:44:31 +08:00
alger
9f125f88bd feat: 增加对 arm64 架构的支持,修改 Windows 图标和安装程序图标为新资源 2025-05-09 19:43:30 +08:00
alger
618c345a78 🔧 chore: 更新音乐源设置,移除 YouTube,添加 Kuwo,确保平台一致性 2025-05-09 19:43:01 +08:00
alger
44f9709bb3 🔧 chore: 更新 Electron 版本至 36.2.0,优化歌词视图的悬停效果 2025-05-09 19:42:49 +08:00
alger
3c1a144113 feat: 添加“收藏”功能至托盘菜单 2025-05-09 19:42:46 +08:00
alger
8ed13d4a85 🔧 chore: 优化播放器逻辑,改进播放失败处理,支持保持当前索引并增加重试机制 2025-05-07 23:55:14 +08:00
alger
3d71a293a1 🔧 chore: 在 App.vue 中引入 audioService,并在组件挂载时释放操作锁 2025-05-07 23:16:05 +08:00
alger
cb58abbbfd 🔧 chore: 优化操作锁逻辑,添加超时检查机制,确保操作锁在超时后自动释放 2025-05-07 22:36:55 +08:00
alger
e2527c3fb8 feat: 修改音乐列表为页面,优化专辑和歌单详情加载逻辑,支持通过路由跳转展示音乐列表 2025-05-07 22:36:52 +08:00
alger
3ca7e9a271 feat: 优化捐赠留言显示 2025-05-07 00:15:45 +08:00
algerkong
2f07550316 🔧 chore: 更新部署工作流,将主分支名称从 dev_electron 修改为 main 2025-05-03 23:54:16 +08:00
algerkong
eff9328a23 feat: 添加定时关闭功能,支持按时间、歌曲数和播放列表结束自动停止播放 2025-05-03 23:46:28 +08:00
algerkong
5f63ab6b4a 🐞 fix: 优化远程控制页面HTML路径获取逻辑,支持开发环境与生产环境的路径区分 2025-05-02 22:53:29 +08:00
Alger
c2e08db2e4 Merge pull request #186 from algerkong/feat/bili-favorite
 feat: 添加 B站视频 ID 匹配逻辑,优化收藏功能以支持 B站视频,确保收藏列表一致性
2025-05-02 22:43:44 +08:00
algerkong
903389e4bf 🐞 fix: 优化收藏列表样式,添加条件类以支持组件最大宽度 2025-05-02 22:42:43 +08:00
algerkong
327384ace5 feat: 添加 B站视频 ID 匹配逻辑,优化收藏功能以支持 B站视频,确保收藏列表一致性 2025-05-02 22:39:47 +08:00
algerkong
6ffe4daed0 🐞 fix: 优化播放列表索引更新逻辑,避免与 nextPlay/prevPlay 冲突,确保歌曲预加载一致性 2025-05-02 19:54:58 +08:00
algerkong
2b8c9bf22a 🔧 chore: 更新 Electron 版本至 36.1.0,修改应用图标 2025-05-02 19:40:53 +08:00
algerkong
c7d586407e 🐞 fix: 移除不必要的 i18n 导入,优化 MusicHook 逻辑 2025-05-02 19:35:32 +08:00
algerkong
c5af89e51f 🐞 fix: 移除不必要的监听器,优化音频播放逻辑,添加音频就绪事件处理,改进操作锁机制以防止并发操作 2025-05-02 19:25:12 +08:00
algerkong
2d8770b074 🐞 fix: 更新二维码检查接口,添加 noCookie 参数以优化登录状态检查 2025-05-02 18:45:04 +08:00
alger
4abb6a5a9f feat: 添加额外资源配置,优化远程控制页面HTML路径获取逻辑 2025-04-30 00:47:14 +08:00
alger
b1d515465a 🐞 fix: 修复远程控制页面找不到问题 2025-04-30 00:14:05 +08:00
alger
ea7dca7975 🌈 style: v4.5.0 2025-04-29 23:48:49 +08:00
alger
c98fa20a74 feat: 优化设置页面 拆分组件 2025-04-29 23:38:17 +08:00
Alger
16d6ff39c8 Merge pull request #173 from algerkong/fix/overlapping-playback
🐞 fix: 修复音乐播放重复声音的问题,添加锁机制,添加防抖机制,优化音频服务和快捷键处理逻辑
2025-04-29 23:33:44 +08:00
alger
159dd03a2c 🐞 fix: 修复音乐播放重复声音的问题,添加锁机制,添加防抖机制,优化音频服务和快捷键处理逻辑 2025-04-29 23:33:03 +08:00
Alger
167c8ad493 Merge pull request #171 from algerkong/feat/remote-control
 feat: 添加远程控制功能,支持远程控制音乐播放操作
2025-04-29 23:23:43 +08:00
alger
c82ffd0c7d feat: 添加远程控制功能,支持远程控制音乐播放操作 2025-04-29 23:21:16 +08:00
alger
0128662ed2 🎨 style: 优化应用图标 更新应用图标资源 2025-04-27 21:56:03 +08:00
alger
30695149d6 feat: 更新歌手数据加载逻辑,首页添加周杰伦歌手信息常驻 2025-04-25 23:10:02 +08:00
Alger
bbc1bb7436 Merge pull request #165 from algerkong/fix/artist-error
🐞 fix: 修复歌手页面数据加载问题
2025-04-25 23:08:31 +08:00
alger
57424f9e15 🐞 fix: 修复歌手页面数据加载问题 2025-04-25 23:07:09 +08:00
algerkong
32b93680b9 feat: 优化音源选择逻辑以去重 2025-04-25 09:07:19 +08:00
algerkong
0a22c7b5d7 feat: 优化设置模块,合并默认设置与存储设置,初始化时读取设置 2025-04-25 09:07:19 +08:00
algerkong
64f5fcaee4 🔧 chore: 移除不再使用的快捷键初始化功能 2025-04-25 09:07:19 +08:00
Alger
304c24a673 feat: Update README.md 2025-04-24 20:41:20 +08:00
Alger
a56bca98b2 feat: Update README.md 2025-04-24 20:38:25 +08:00
alger
1865bd95bc 🔧 chore: 更新版本号至 4.4.0 2025-04-23 00:26:43 +08:00
alger
fd37015466 🌈 style: v4.4.0 2025-04-23 00:18:02 +08:00
alger
7df1c25168 feat: 添加 GD 音乐台支持及相关设置,优化音源解析功能 2025-04-23 00:10:28 +08:00
alger
ed9cf9c4c5 feat: 优化音源解析功能,添加音源配置 2025-04-22 23:39:08 +08:00
alger
35b9cbfdbd 🔧 chore: 更新 electron 依赖版本至 35.2.0 2025-04-22 22:11:28 +08:00
algerkong
df6da2eb9e 🔧 chore: 移除 eslint-config-airbnb-base 依赖,并优化 .eslintrc.cjs 配置,以保持代码一致性 2025-04-21 21:17:10 +08:00
algerkong
2d966036bb 🔧 chore: 更新 @vue/eslint-config-prettier 和 @vue/eslint-config-typescript 依赖版本至最新,以保持代码质量和一致性 2025-04-21 21:15:36 +08:00
algerkong
499857a679 🔧 chore: 更新 @typescript-eslint 依赖版本至 8.30.1,以保持代码质量和一致性 2025-04-21 21:12:39 +08:00
algerkong
7624a1a71e 🔧 chore: 更新 eslint 版本至 9.0.0,以保持代码质量和一致性 2025-04-21 21:06:31 +08:00
Alger
05b85c4b7b Merge pull request #153 from algerkong/fix/download-froze
🐞 fix: 修复下载管理 切换tab程序卡死问题
2025-04-21 20:39:24 +08:00
algerkong
27d5bd8f81 🐞 fix: 修复下载管理 切换tab程序卡死问题 2025-04-21 20:38:05 +08:00
alger
c5da42b67d 🔧 chore: 修正音乐规则描述中的拼写错误,将 "Node.j" 更正为 "Node.js" 2025-04-20 00:14:00 +08:00
alger
5e484334de 🔧 chore: 更新 .gitignore 文件,添加 Android 资源目录以排除不必要的文件 2025-04-20 00:12:58 +08:00
algerkong
25b90fafdc feat: 调整 AppLayout 和 AppMenu 组件样式,优化底部菜单位置和间距 2025-04-18 19:18:37 +08:00
algerkong
a676136f48 🔧 chore: 更新依赖版本,优化 Electron 窗口设置,调整歌词窗口背景色样式 2025-04-18 19:18:31 +08:00
alger
76e55d4e6b 🐞 fix: 修复歌曲播放地址缓存导致播放失败问题 添加过期时间 2025-04-16 00:03:56 +08:00
155 changed files with 16519 additions and 4085 deletions

View File

@@ -1,92 +0,0 @@
---
description: 这个规则是项目描述
globs:
alwaysApply: false
---
您是 TypeScript、Node.j、Vue3、Electron、naive-ui、VueUse 和 Tailwind 方面的专家。
项目结构
- 这是 Electron 项目,使用 Vue3 和 Pinia 进行开发的第三方网易云音乐播放器。
- 使用 Vue3 和 Pinia 进行开发。
- 使用 Pinia 进行状态管理。
- 使用 VueUse 进行状态管理。
- 使用 naive-ui 进行 UI 设计。
- 使用 Tailwind 进行样式设计。
- 使用 remixicon 进行图标设计。
- 使用 vite 进行项目构建。
- 使用 electron-builder 进行项目打包。
- 使用 electron-vite 进行项目开发。
- 使用 netease-cloud-music-api 进行网易云音乐接口调用。
- 使用 electron-store 进行本地数据存储。
- 使用 axios 进行网络请求。
- 使用 @unblockneteasemusic/server 进行网易云音乐解锁。
- 使用 vue-i18n 进行国际化。目录为 src/i18n
代码风格和结构
- 编写简洁、技术性的 TypeScript 代码,并提供准确示例。
- 使用组合 API 和声明性编程模式;避免使用选项 API。
- 优先使用迭代和模块化,而不是代码重复。
- 使用带有助动词的描述性变量名称(例如 isLoading、hasError
- 结构文件:导出的组件、可组合项、帮助程序、静态内容、类型。
命名约定
- 使用带破折号的小写字母表示目录(例如 components/auth-wizard
- 使用 PascalCase 表示组件名称(例如 AuthWizard.vue
- 使用 camelCase 表示可组合项(例如 useAuthState.ts
TypeScript 用法
- 对所有代码使用 TypeScript优先使用类型而不是接口。
- 避免使用枚举;改用 const 对象。
- 将 Vue 3 与 TypeScript 结合使用,利用 defineComponent 和 PropType。
语法和格式
- 对方法和计算属性使用箭头函数。
- 避免在条件中使用不必要的花括号;对简单语句使用简洁的语法。
- 使用模板语法进行声明式渲染。
UI 和样式
- 使用 naive-ui 和 Tailwind 进行组件和样式设计。
- 使用 Tailwind CSS 实现响应式设计;采用移动优先方法。
图标
- 使用 remixicon 作为图标库。
性能优化
- 对异步组件使用 Suspense。
- 为路由和组件实现延迟加载。
关键约定
- 对常见可组合项和实用函数使用 VueUse。
- 使用 Pinia 进行状态管理。
- 优化 Web VitalsLCP、CLS、FID
Vue 3 和 Composition API 最佳实践
- 使用 <script setup lang="ts"> 语法进行简洁的组件定义。
- 利用 ref、reactive 和 computed 进行反应状态管理。
- 在适当的情况下使用 provide/inject 进行依赖注入。
- 实现自定义可组合项以实现可重用逻辑。
Electron 最佳实践
- 使用 Electron 和 Vue.js 进行跨平台桌面应用程序开发。
- 使用 Electron 的 API 和 Vue.js 的组合 API 进行开发。
- 实现自定义可组合项以实现可重用逻辑。
组件导入
- 使用 auto-import 进行组件导入。
- naive-ui 组件自动导入 不需要手动导入。
关注官方 Electron 和 Vue.js 文档,了解有关数据获取、渲染和路由的最新最佳实践。
问题修复
- 思考 5-7 种可能导致问题的来源,并根据可能性、对功能的影响以及在类似问题中的出现频率进行优先排序。仅考虑与错误日志、最近代码变更和系统约束相匹配的来源。忽略外部依赖,除非日志明确指向它们。
- 一旦缩小到 1-2 个最可能的来源,将其与历史错误日志、相关系统状态和预期行为进行交叉验证。如果发现不一致,调整你的假设。
- 在添加日志时,确保它们被策略性地放置,以便同时确认或排除多个潜在原因。如果日志不支持你的假设,请先提出替代的调试策略,再继续深入分析。
- 在实施修复之前,先总结问题现象、经过验证的假设,以及预期的日志输出,以确认问题是否真正得到解决。

View File

@@ -1,148 +0,0 @@
---
description: 这个规则是项目结构
globs:
alwaysApply: false
---
# AlgerMusicPlayer 项目结构
AlgerMusicPlayer 是一个基于 Electron、Vue 3、TypeScript 开发的网易云音乐第三方播放器。
## 技术栈
- **前端框架**Vue 3 + TypeScript
- **UI 组件库**naive-ui
- **样式框架**Tailwind CSS
- **图标库**remixicon
- **状态管理**Pinia
- **工具库**VueUse
- **构建工具**Vite, electron-vite
- **打包工具**electron-builder
- **国际化**vue-i18n
- **HTTP 客户端**axios
- **本地存储**electron-store localstorage
- **网易云音乐 API**netease-cloud-music-api
- **音乐解锁**@unblockneteasemusic/server
## 项目结构
```
AlgerMusicPlayer/
├── build/ # 构建相关文件
├── docs/ # 项目文档
├── node_modules/ # 依赖包
├── out/ # 构建输出目录
├── resources/ # 资源文件
├── src/ # 源代码
│ ├── i18n/ # 国际化配置
│ │ ├── lang/ # 语言包
│ │ ├── main.ts # 主进程国际化入口
│ │ └── renderer.ts # 渲染进程国际化入口
│ ├── main/ # Electron 主进程
│ │ ├── modules/ # 主进程模块
│ │ ├── index.ts # 主进程入口
│ │ ├── lyric.ts # 歌词处理
│ │ ├── server.ts # 服务器
│ │ ├── set.json # 设置
│ │ └── unblockMusic.ts # 音乐解锁
│ ├── preload/ # 预加载脚本
│ │ ├── index.ts # 预加载脚本入口
│ │ └── index.d.ts # 预加载脚本类型声明
│ └── renderer/ # Vue 渲染进程
│ ├── api/ # API 请求
│ ├── assets/ # 静态资源
│ ├── components/ # 组件
│ │ ├── common/ # 通用组件
│ │ ├── home/ # 首页组件
│ │ ├── lyric/ # 歌词组件
│ │ ├── settings/ # 设置组件
│ │ └── ... # 其他组件
│ ├── const/ # 常量定义
│ ├── directive/ # 自定义指令
│ ├── hooks/ # 自定义 Hooks
│ ├── layout/ # 布局组件
│ ├── router/ # 路由配置
│ ├── services/ # 服务
│ ├── store/ # Pinia 状态管理
│ │ ├── modules/ # Pinia 模块
│ │ └── index.ts # Pinia 入口
│ ├── type/ # 类型定义
│ ├── types/ # 更多类型定义
│ ├── utils/ # 工具函数
│ ├── views/ # 页面视图
│ ├── App.vue # 根组件
│ ├── index.css # 全局样式
│ ├── index.html # HTML 模板
│ ├── main.ts # 渲染进程入口
│ └── ... # 其他文件
├── .env.development # 开发环境变量
├── .env.development.local # 本地开发环境变量
├── .env.production.local # 本地生产环境变量
├── .eslintrc.cjs # ESLint 配置
├── .gitignore # Git 忽略文件
├── .prettierrc.yaml # Prettier 配置
├── electron-builder.yml # electron-builder 配置
├── electron.vite.config.ts # electron-vite 配置
├── package.json # 项目配置
├── postcss.config.js # PostCSS 配置
├── tailwind.config.js # Tailwind 配置
├── tsconfig.json # TypeScript 配置
├── tsconfig.node.json # 节点 TypeScript 配置
└── tsconfig.web.json # Web TypeScript 配置
```
## 主要组件说明
### 主进程 (src/main)
主进程负责创建窗口、处理系统层面的交互以及与渲染进程的通信。
- **index.ts**: 应用主入口,负责创建窗口和应用生命周期管理
- **lyric.ts**: 歌词解析和处理
- **unblockMusic.ts**: 网易云音乐解锁功能
- **server.ts**: 本地服务器
### 预加载脚本 (src/preload)
预加载脚本在渲染进程加载前执行,提供了渲染进程和主进程之间的桥接功能。
### 渲染进程 (src/renderer)
渲染进程是基于 Vue 3 的前端应用,负责 UI 渲染和用户交互。
- **components/**: 包含各种 UI 组件
- **common/**: 通用组件
- **home/**: 首页相关组件
- **lyric/**: 歌词显示组件
- **settings/**: 设置界面组件
- **MusicList.vue**: 音乐列表组件
- **MvPlayer.vue**: MV 播放器
- **EQControl.vue**: 均衡器控制
- **...**: 其他组件
- **store/**: Pinia 状态管理
- **modules/**: 各功能模块的状态管理
- **index.ts**: 状态管理入口
- **views/**: 页面视图组件
- **router/**: 路由配置
- **api/**: API 请求封装
- **utils/**: 工具函数
## 开发指南
### 命名约定
- 目录使用 kebab-case (如: components/auth-wizard)
- 组件文件名使用 PascalCase (如: AuthWizard.vue)
- 可组合式函数使用 camelCase (如: useAuthState.ts)
### 代码风格
- 使用 Composition API 和 `<script setup>` 语法
- 使用 TypeScript 类型系统
- 优先使用类型而非接口
- 避免使用枚举,使用 const 对象代替
- 使用 tailwind 实现响应式设计

View File

@@ -1,12 +1,4 @@
# 你的接口地址 (必填) # 你的接口地址 (必填)
VITE_API_LOCAL = *** VITE_API = http://127.0.0.1:30488
# 音乐破解接口地址 # 音乐破解接口地址 web端
VITE_API_MUSIC = *** VITE_API_MUSIC = ***
# 代理地址
VITE_API_PROXY = ***
# 本地运行代理地址
VITE_API_PROXY = /api
VITE_API_MUSIC_PROXY = /music
VITE_API_PROXY_MUSIC = /music_proxy

View File

@@ -4,8 +4,7 @@ require('@rushstack/eslint-patch/modern-module-resolution');
module.exports = { module.exports = {
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'eslint-config-airbnb-base',
'@vue/typescript/recommended', '@vue/typescript/recommended',
'plugin:vue/vue3-recommended', 'plugin:vue/vue3-recommended',
'plugin:vue-scoped-css/base', 'plugin:vue-scoped-css/base',

View File

@@ -77,6 +77,7 @@ jobs:
dist/*.dmg dist/*.dmg
dist/*.exe dist/*.exe
dist/*.deb dist/*.deb
dist/*.rpm
dist/*.AppImage dist/*.AppImage
dist/latest*.yml dist/latest*.yml
dist/*.blockmap dist/*.blockmap

View File

@@ -3,7 +3,7 @@ name: Deploy Web
on: on:
push: push:
branches: branches:
- dev_electron # 或者您的主分支名称 - main # 或者您的主分支名称
workflow_dispatch: # 允许手动触发 workflow_dispatch: # 允许手动触发
jobs: jobs:

11
.gitignore vendored
View File

@@ -25,3 +25,14 @@ out
.cursorrules .cursorrules
.github/deploy_keys .github/deploy_keys
resources/android/**/*
android/app/release
.cursor
.auto-imports.d.ts
.components.d.ts
src/renderer/auto-imports.d.ts
src/renderer/components.d.ts

2
.husky/pre-commit Normal file
View File

@@ -0,0 +1,2 @@
echo "运行类型检查..."
npm run typecheck

2
.husky/pre-push Executable file
View File

@@ -0,0 +1,2 @@
echo "运行类型检查..."
npm run typecheck

View File

@@ -1,17 +1,62 @@
# 更新日志 # 更新日志
## v4.3.0 ## v4.8.0
> 如果更新遇到问题,请前往 <a href="http://donate.alger.fun/download" target="_blank">下载 AlgerMusicPlayer</a>
> 请我喝咖啡(支持作者) ☕️ <a href="http://donate.alger.fun/donate" target="_blank" style="color: red; font-weight: bold;">赏你</a>
> 帮我点个 star <a href="https://github.com/algerkong/AlgerMusicPlayer" target="_blank">github star</a>
> 微信公众号 微信搜索 <span style="font-weight: bold;">AlgerMusic</span>
> QQ频道 AlgerMusic <a href="https://pd.qq.com/s/cs056n33q?b=5" target="_blank">加入频道</a>
### ✨ 新功能 ### ✨ 新功能
- 歌曲下载内置封面歌词歌曲信息,添加无限制下载功能,优化下载页面添加下载记录清除功能 ([3b1488f](https://github.com/algerkong/AlgerMusicPlayer/commit/3b1488f)) (#123) ([988418e](https://github.com/algerkong/AlgerMusicPlayer/commit/988418e)) - 增强移动端播放页面效果,添加播放模式选择,添加横屏模式,添加播放列表功能 ([81b61e4](https://github.com/algerkong/AlgerMusicPlayer/commit/81b61e4))([0d89e15](https://github.com/algerkong/AlgerMusicPlayer/commit/0d89e15))([9345805](https://github.com/algerkong/AlgerMusicPlayer/commit/9345805))
- 添加搜索功能至歌曲列表,支持名称、歌手、专辑搜索,支持拼音匹配 ([b593ca3](https://github.com/algerkong/AlgerMusicPlayer/commit/b593ca3)) (#126) - 优化移动端界面动画效果,播放栏,返回效果等一系列功能
- 添加快捷键管理功能,支持全局和应用内快捷键的启用/禁用 ([c2983ba](https://github.com/algerkong/AlgerMusicPlayer/commit/c2983ba)) (#119) - 添加下载管理页面, 引入文件类型检测库以支持多种音频格式,支持自定义文件名格式和下载路径配置 ([3ac3159](https://github.com/algerkong/AlgerMusicPlayer/commit/3ac3159)),([b203077](https://github.com/algerkong/AlgerMusicPlayer/commit/b203077))
- 优化歌单加载、播放逻辑,提升大型歌单加载性能 ([7bc8405](https://github.com/algerkong/AlgerMusicPlayer/commit/7bc8405))、([d7fea7f](https://github.com/algerkong/AlgerMusicPlayer/commit/d7fea7f)) - 新增歌单导入功能 ([edd393c](https://github.com/algerkong/AlgerMusicPlayer/commit/edd393c))
- 添加直接播放首页歌单功能 ([5f4b53c](https://github.com/algerkong/AlgerMusicPlayer/commit/5f4b53c)) - 列表添加多选下载功能,支持批量选择和下载音乐 ([1221101](https://github.com/algerkong/AlgerMusicPlayer/commit/1221101)),([8988cdb](https://github.com/algerkong/AlgerMusicPlayer/commit/8988cdb))([21b2fc0](https://github.com/algerkong/AlgerMusicPlayer/commit/21b2fc0))
- 添加统计服务([a7f2045](https://github.com/algerkong/AlgerMusicPlayer/commit/a7f2045)) - Windows添加任务栏缩略图播放控制按钮 ([9bec67e](https://github.com/algerkong/AlgerMusicPlayer/commit/9bec67e))([58ab990](https://github.com/algerkong/AlgerMusicPlayer/commit/58ab990)) 感谢[HE Cai](https://github.com/hecai84)的pr
- 优化历史和收藏视图的加载体验 ([09f8837](https://github.com/algerkong/AlgerMusicPlayer/commit/09f8837)) - 添加主窗口自适应大小功能,页面缩放功能,支持缩放因子的调整和重置 ([6170047](https://github.com/algerkong/AlgerMusicPlayer/commit/6170047)), ([e46df8a](https://github.com/algerkong/AlgerMusicPlayer/commit/e46df8a))
- 优化歌词界面配置,提供更好的用户体验 ([55b50d7](https://github.com/algerkong/AlgerMusicPlayer/commit/55b50d7)) - 添加歌词时间矫正功能,支持增加和减少矫正时间 ([c975344](https://github.com/algerkong/AlgerMusicPlayer/commit/c975344))
### 🐛 Bug 修复 ### 🐛 Bug 修复
- 优化音乐封面显示逻辑,确保在缺失封面时使用默认图片 ([bb7d1e3](https://github.com/algerkong/AlgerMusicPlayer/commit/bb7d1e3)) - 修复音频初始化音量问题,完善翻译 ([#320](https://github.com/algerkong/AlgerMusicPlayer/pull/320)) 感谢[Qumo](https://github.com/Hellodwadawd12312312)的pr
- 优化桌面歌词行动态样式计算,提升歌词显示效果 ([541ff2b](https://github.com/algerkong/AlgerMusicPlayer/commit/541ff2b)) - 重构每日推荐数据加载逻辑,提取为独立函数并优化用户状态判断 ([5e704a1](https://github.com/algerkong/AlgerMusicPlayer/commit/5e704a1))
- 修复刷新后第一次播放出现的无法播放问题 ([6f1909a](https://github.com/algerkong/AlgerMusicPlayer/commit/6f1909a))
- 修复更多设置弹窗被歌词窗口遮挡问题,并优化为互斥弹窗,优化样式 ([62e5166](https://github.com/algerkong/AlgerMusicPlayer/commit/62e5166))
- 修复设置页面动画速度滑块样式和文本错误 ([e5adb8a](https://github.com/algerkong/AlgerMusicPlayer/commit/e5adb8a))
- 修复音频服务相关问题 ([090103b](https://github.com/algerkong/AlgerMusicPlayer/commit/090103b)),([5ee60d7](https://github.com/algerkong/AlgerMusicPlayer/commit/5ee60d7))
- 修复播放栏无法控制隐藏问题 ([d227ac8](https://github.com/algerkong/AlgerMusicPlayer/commit/d227ac8))
### 🎨 优化
- 优化歌曲列表组件布局([fabcf28](https://github.com/algerkong/AlgerMusicPlayer/commit/fabcf28))
- 重构播放控制逻辑,添加播放进度恢复功能并清理无用代码 ([b9c38d2](https://github.com/algerkong/AlgerMusicPlayer/commit/b9c38d2))
- 优化提示组件,支持位置和图标显示选项 ([155bdf2](https://github.com/algerkong/AlgerMusicPlayer/commit/155bdf2))
- 添加mini播放栏鼠标滚轮调整音量并优化音量滑块数字不展示问题 ([5c72785](https://github.com/algerkong/AlgerMusicPlayer/commit/5c72785))
- 优化收藏和历史列表组件,添加加载状态管理和动画效果 ([5070a08](https://github.com/algerkong/AlgerMusicPlayer/commit/5070a08))
- 翻译优化
- 代码优化
## 赞赏支持☕️
[赞赏列表](http://donate.alger.fun/)
<table>
<tr>
<th style="text-align:center">微信赞赏</th>
<th style="width:100px"></th>
<th style="text-align:center">支付宝赞赏</th>
</tr>
<tr>
<td align="center">
<img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width="200"><br>
<h6>☕️喝点咖啡继续干</h6>
</td>
<td></td>
<td align="center">
<img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Alipay QRcode" width="200"><br>
<h6>🍔来个汉堡</h6>
</td>
</tr>
</table>

192
DEV.md Normal file
View File

@@ -0,0 +1,192 @@
# Alger Music Player 开发文档
## 项目结构
### 技术栈
- **前端框架**Vue 3 + TypeScript
- **UI 组件库**naive-ui
- **样式框架**Tailwind CSS
- **图标库**remixicon
- **状态管理**Pinia
- **工具库**VueUse
- **构建工具**Vite, electron-vite
- **打包工具**electron-builder
- **国际化**vue-i18n
- **HTTP 客户端**axios
- **本地存储**electron-store localstorage
- **网易云音乐 API**netease-cloud-music-api
- **音乐解锁**@unblockneteasemusic/server
### 项目结构
```
AlgerMusicPlayer/
├── build/                  # 构建相关文件
├── docs/                   # 项目文档
├── node_modules/           # 依赖包
├── out/                    # 构建输出目录
├── resources/              # 资源文件
├── src/                    # 源代码
  ├── i18n/               # 国际化配置
   ├── lang/           # 语言包
   ├── main.ts         # 主进程国际化入口
   └── renderer.ts     # 渲染进程国际化入口
  ├── main/               # Electron 主进程
   ├── modules/        # 主进程模块
   ├── index.ts        # 主进程入口
   ├── lyric.ts        # 歌词处理
   ├── server.ts       # 服务器
   ├── set.json        # 设置
   └── unblockMusic.ts # 音乐解锁
  ├── preload/            # 预加载脚本
   ├── index.ts        # 预加载脚本入口
   └── index.d.ts      # 预加载脚本类型声明
  └── renderer/           # Vue 渲染进程
      ├── api/            # API 请求
      ├── assets/         # 静态资源
      ├── components/     # 组件
       ├── common/     # 通用组件
       ├── home/       # 首页组件
       ├── lyric/      # 歌词组件
       ├── settings/   # 设置组件
       └── ...         # 其他组件
      ├── const/          # 常量定义
      ├── directive/      # 自定义指令
      ├── hooks/          # 自定义 Hooks
      ├── layout/         # 布局组件
      ├── router/         # 路由配置
      ├── services/       # 服务
      ├── store/          # Pinia 状态管理
       ├── modules/    # Pinia 模块
       └── index.ts    # Pinia 入口
      ├── type/           # 类型定义
      ├── types/          # 更多类型定义
      ├── utils/          # 工具函数
      ├── views/          # 页面视图
      ├── App.vue         # 根组件
      ├── index.css       # 全局样式
      ├── index.html      # HTML 模板
      ├── main.ts         # 渲染进程入口
      └── ...             # 其他文件
├── .env.development        # 开发环境变量
├── .env.development.local  # 本地开发环境变量
├── .env.production.local   # 本地生产环境变量
├── .eslintrc.cjs           # ESLint 配置
├── .gitignore              # Git 忽略文件
├── .prettierrc.yaml        # Prettier 配置
├── electron-builder.yml    # electron-builder 配置
├── electron.vite.config.ts # electron-vite 配置
├── package.json            # 项目配置
├── postcss.config.js       # PostCSS 配置
├── tailwind.config.js      # Tailwind 配置
├── tsconfig.json           # TypeScript 配置
├── tsconfig.node.json      # 节点 TypeScript 配置
└── tsconfig.web.json       # Web TypeScript 配置
```
### 主要组件说明
#### 主进程 (src/main)
主进程负责创建窗口、处理系统层面的交互以及与渲染进程的通信。
- **index.ts**: 应用主入口,负责创建窗口和应用生命周期管理
- **lyric.ts**: 歌词解析和处理
- **unblockMusic.ts**: 网易云音乐解锁功能
- **server.ts**: 本地服务器
#### 预加载脚本 (src/preload)
预加载脚本在渲染进程加载前执行,提供了渲染进程和主进程之间的桥接功能。
#### 渲染进程 (src/renderer)
渲染进程是基于 Vue 3 的前端应用,负责 UI 渲染和用户交互。
- **components/**: 包含各种 UI 组件
  - **common/**: 通用组件
  - **home/**: 首页相关组件
  - **lyric/**: 歌词显示组件
  - **settings/**: 设置界面组件
  - **MusicList.vue**: 音乐列表组件
  - **MvPlayer.vue**: MV 播放器
  - **EQControl.vue**: 均衡器控制
  - **...**: 其他组件
- **store/**: Pinia 状态管理
  - **modules/**: 各功能模块的状态管理
  - **index.ts**: 状态管理入口
- **views/**: 页面视图组件
- **router/**: 路由配置
- **api/**: API 请求封装
- **utils/**: 工具函数
### 开发指南
#### 命名约定
- 目录使用 kebab-case (如: components/auth-wizard)
- 组件文件名使用 PascalCase (如: AuthWizard.vue)
- 可组合式函数使用 camelCase (如: useAuthState.ts)
#### 代码风格
- 使用 Composition API 和 `<script setup>` 语法
- 使用 TypeScript 类型系统
- 优先使用类型而非接口
- 避免使用枚举,使用 const 对象代替
- 使用 tailwind 实现响应式设计
### 如何启动?
安装依赖最好使用node18+
```
npm install
```
#### 桌面端开发
启动桌面端开发:
```
npm run dev
```
#### 网页端开发
如果只启动网页端开发,需要自己部署服务 netease-cloud-music-api
需要复制一份 `.env.development.local``src/renderer`
```
# .env.development.local
# 你的接口地址 (必填)
VITE_API = ***
# 音乐破解接口地址
VITE_API_MUSIC = ***
```
启动web端开发
```
npm run dev:web
```
### 打包
打包桌面端:
```
npm run build:win
```
打包后的文件在 /dist 下
打包网页端:
```
npm run build
```
打包后的文件在 /out/renderer 下

View File

@@ -1,48 +1,84 @@
# Alger Music Player
<h2 align="center">🎵 Alger Music Player</h2>
<div align="center">
<div align="center">
<a href="https://github.com/algerkong/AlgerMusicPlayer/stargazers">
<img src="https://img.shields.io/github/stars/algerkong/AlgerMusicPlayer?style=for-the-badge&logo=github&label=Stars&logoColor=white&color=22c55e" alt="GitHub stars">
</a>
<a href="https://github.com/algerkong/AlgerMusicPlayer/releases">
<img src="https://img.shields.io/github/v/release/algerkong/AlgerMusicPlayer?style=for-the-badge&logo=github&label=Release&logoColor=white&color=1a67af" alt="GitHub release">
</a>
<a href="https://pd.qq.com/s/cs056n33q?b=5">
<img src="https://img.shields.io/badge/QQ频道-algermusic-blue?style=for-the-badge&color=yellow" alt="加入频道">
</a>
<a href="https://t.me/+9efsKRuvKBk2NWVl">
<img src="https://img.shields.io/badge/AlgerMusic-blue?style=for-the-badge&logo=telegram&logoColor=white&label=Telegram" alt="Telegram">
</a>
<a href="https://donate.alger.fun/">
<img src="https://img.shields.io/badge/%E9%A1%B9%E7%9B%AE%E6%8D%90%E8%B5%A0-blue?style=for-the-badge&logo=telegram&logoColor=pink&color=pink&label=%E8%B5%9E%E5%8A%A9" alt="赞助">
</a>
</div>
</div>
<div align="center">
<a href="https://hellogithub.com/repository/607b849c598d48e08fe38789d156ebdc" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=607b849c598d48e08fe38789d156ebdc&claim_uid=ObuMXUfeHBmk9TI&theme=neutral" alt="FeaturedHelloGitHub" width="160" height="32" /></a>
</div>
[项目下安装以及常用问题文档](https://www.yuque.com/alger-pfg5q/ip4f1a/bmgmfmghnhgwghkm?singleDoc#)
主要功能如下 主要功能如下
- 🎵 音乐推荐 - 🎵 音乐推荐
- 🔐 网易云账号登录与同步 - 🔐 网易云账号登录与同步
- 📝 功能 - 📝 功能
- 播放历史记录 - 播放历史记录
- 歌曲收藏管理 - 歌曲收藏管理
- 自定义快捷键配置 - 歌单 MV 排行榜 每日推荐
- 自定义快捷键配置(全局或应用内)
- 🎨 界面与交互 - 🎨 界面与交互
- 沉浸式歌词显示(点击左下角封面进入) - 沉浸式歌词显示(点击左下角封面进入)
- 独立桌面歌词窗口 - 独立桌面歌词窗口
- 明暗主题切换 - 明暗主题切换
- 迷你模式
- 状态栏控制
- 多语言支持
- 🎼 音乐功能 - 🎼 音乐功能
- 支持歌单、MV、专辑等完整音乐服务 - 支持歌单、MV、专辑等完整音乐服务
- 灰色音乐资源解析(基于 @unblockneteasemusic/server - 灰色音乐资源解析(基于 @unblockneteasemusic/server
- 高品质音乐试听需网易云VIP - 音乐单独解析
- 音乐文件下载(支持右键下载和批量下载) - EQ均衡器
- 定时播放 远程控制播放 倍速播放
- 高品质音乐
- 音乐文件下载(支持右键下载和批量下载, 附带歌词封面等信息)
- 搜索 MV 音乐 专辑 歌单 bilibili
- 音乐单独选择音源解析
- 🚀 技术特性 - 🚀 技术特性
- 本地化服务无需依赖在线API (基于 netease-cloud-music-api) - 本地化服务无需依赖在线API (基于 netease-cloud-music-api)
- 自动更新检测 - 全平台适配Desktop & Web & Mobile Web & Android<测试> & ios<后续>
- 全平台适配Desktop & Web & Mobile Web
## 项目简介 ## 项目简介
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端 一个第三方音乐播放器、本地服务、桌面歌词、音乐下载、最高音质
## 预览地址 ## 预览地址
[http://mc.alger.fun/](http://mc.alger.fun/) [http://music.alger.fun/](http://music.alger.fun/)
QQ群:789288579
## 软件截图 ## 软件截图
![首页白](./docs/image.png) ![首页白](./docs/image.png)
![首页黑](./docs/image3.png) ![首页黑](./docs/image3.png)
![歌词](./docs/image1.png) ![歌词](./docs/image6.png)
![桌面歌词](./docs/image2.png) ![桌面歌词](./docs/image2.png)
![设置页面](./docs/image4.png) ![设置页面](./docs/image4.png)
![音乐远程控制](./docs/image5.png)
## 项目启动
```bash
npm install
npm run dev
```
## 开发文档
点击这里[开发文档](./DEV.md)
## 技术栈
### 主要框架
- Vue 3 - 渐进式 JavaScript 框架
- TypeScript - JavaScript 的超集,添加了类型系统
- Electron - 跨平台桌面应用开发框架
- Vite - 下一代前端构建工具
- Naive UI - 基于 Vue 3 的组件库
## 赞赏☕️ ## 赞赏☕️
@@ -60,5 +96,6 @@ QQ群:789288579
## 欢迎提Issues ## 欢迎提Issues
## 免责声明 ## 声明
本软件仅用于学习交流,禁止用于商业用途,否则后果自负。 本软件仅用于学习交流,禁止用于商业用途,否则后果自负。
希望大家还是要多多支持官方正版,此软件仅用作开发教学。

101
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

90
auto-imports.d.ts vendored
View File

@@ -1,90 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: (typeof import('vue'))['EffectScope'];
const computed: (typeof import('vue'))['computed'];
const createApp: (typeof import('vue'))['createApp'];
const customRef: (typeof import('vue'))['customRef'];
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent'];
const defineComponent: (typeof import('vue'))['defineComponent'];
const effectScope: (typeof import('vue'))['effectScope'];
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance'];
const getCurrentScope: (typeof import('vue'))['getCurrentScope'];
const h: (typeof import('vue'))['h'];
const inject: (typeof import('vue'))['inject'];
const isProxy: (typeof import('vue'))['isProxy'];
const isReactive: (typeof import('vue'))['isReactive'];
const isReadonly: (typeof import('vue'))['isReadonly'];
const isRef: (typeof import('vue'))['isRef'];
const markRaw: (typeof import('vue'))['markRaw'];
const nextTick: (typeof import('vue'))['nextTick'];
const onActivated: (typeof import('vue'))['onActivated'];
const onBeforeMount: (typeof import('vue'))['onBeforeMount'];
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount'];
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate'];
const onDeactivated: (typeof import('vue'))['onDeactivated'];
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured'];
const onMounted: (typeof import('vue'))['onMounted'];
const onRenderTracked: (typeof import('vue'))['onRenderTracked'];
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered'];
const onScopeDispose: (typeof import('vue'))['onScopeDispose'];
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch'];
const onUnmounted: (typeof import('vue'))['onUnmounted'];
const onUpdated: (typeof import('vue'))['onUpdated'];
const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup'];
const provide: (typeof import('vue'))['provide'];
const reactive: (typeof import('vue'))['reactive'];
const readonly: (typeof import('vue'))['readonly'];
const ref: (typeof import('vue'))['ref'];
const resolveComponent: (typeof import('vue'))['resolveComponent'];
const shallowReactive: (typeof import('vue'))['shallowReactive'];
const shallowReadonly: (typeof import('vue'))['shallowReadonly'];
const shallowRef: (typeof import('vue'))['shallowRef'];
const toRaw: (typeof import('vue'))['toRaw'];
const toRef: (typeof import('vue'))['toRef'];
const toRefs: (typeof import('vue'))['toRefs'];
const toValue: (typeof import('vue'))['toValue'];
const triggerRef: (typeof import('vue'))['triggerRef'];
const unref: (typeof import('vue'))['unref'];
const useAttrs: (typeof import('vue'))['useAttrs'];
const useCssModule: (typeof import('vue'))['useCssModule'];
const useCssVars: (typeof import('vue'))['useCssVars'];
const useDialog: (typeof import('naive-ui'))['useDialog'];
const useId: (typeof import('vue'))['useId'];
const useLoadingBar: (typeof import('naive-ui'))['useLoadingBar'];
const useMessage: (typeof import('naive-ui'))['useMessage'];
const useModel: (typeof import('vue'))['useModel'];
const useNotification: (typeof import('naive-ui'))['useNotification'];
const useSlots: (typeof import('vue'))['useSlots'];
const useTemplateRef: (typeof import('vue'))['useTemplateRef'];
const watch: (typeof import('vue'))['watch'];
const watchEffect: (typeof import('vue'))['watchEffect'];
const watchPostEffect: (typeof import('vue'))['watchPostEffect'];
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect'];
}
// for type re-export
declare global {
// @ts-ignore
export type {
Component,
ComponentPublicInstance,
ComputedRef,
DirectiveBinding,
ExtractDefaultPropTypes,
ExtractPropTypes,
ExtractPublicPropTypes,
InjectionKey,
PropType,
Ref,
MaybeRef,
MaybeRefOrGetter,
VNode,
WritableComputedRef
} from 'vue';
import('vue');
}

33
components.d.ts vendored
View File

@@ -1,33 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {};
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 406 KiB

BIN
docs/image5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
docs/image6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -37,25 +37,9 @@ export default defineConfig({
resolvers: [NaiveUiResolver()] resolvers: [NaiveUiResolver()]
}) })
], ],
publicDir: resolve('resources'),
server: { server: {
proxy: { host: '0.0.0.0',
// with options
[process.env.VITE_API_LOCAL as string]: {
target: process.env.VITE_API,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_LOCAL}`), '')
},
[process.env.VITE_API_MUSIC_PROXY as string]: {
target: process.env.VITE_API_MUSIC,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_MUSIC_PROXY}`), '')
},
[process.env.VITE_API_PROXY_MUSIC as string]: {
target: process.env.VITE_API_PROXY,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_PROXY_MUSIC}`), '')
}
}
} }
} }
}); });

View File

@@ -1,11 +1,12 @@
{ {
"name": "AlgerMusicPlayer", "name": "AlgerMusicPlayer",
"version": "4.3.0", "version": "4.8.0",
"description": "Alger Music Player", "description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>", "author": "Alger <algerkc@qq.com>",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"homepage": "https://github.com/algerkong/AlgerMusicPlayer", "homepage": "https://github.com/algerkong/AlgerMusicPlayer",
"scripts": { "scripts": {
"prepare": "husky",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -13,7 +14,8 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build", "dev:web": "vite dev",
"build": "electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win", "build:win": "npm run build && electron-builder --win",
@@ -21,50 +23,55 @@
"build:linux": "npm run build && electron-builder --linux" "build:linux": "npm run build && electron-builder --linux"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^4.0.0",
"@unblockneteasemusic/server": "^0.27.8-patch.1", "@unblockneteasemusic/server": "^0.27.8-patch.1",
"cors": "^2.8.5",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.1.7", "electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"express": "^4.18.2",
"file-type": "^21.0.0",
"font-list": "^1.5.1", "font-list": "^1.5.1",
"husky": "^9.1.7",
"music-metadata": "^11.2.3",
"netease-cloud-music-api-alger": "^4.26.1", "netease-cloud-music-api-alger": "^4.26.1",
"node-id3": "^0.2.9", "node-id3": "^0.2.9",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"vue-i18n": "9" "vue-i18n": "^11.1.3"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2", "@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
"@rushstack/eslint-patch": "^1.10.3", "@rushstack/eslint-patch": "^1.10.3",
"@tailwindcss/postcss7-compat": "^2.2.4", "@tailwindcss/postcss7-compat": "^2.2.4",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^20.14.8", "@types/node": "^20.14.8",
"@types/tinycolor2": "^1.4.6", "@types/tinycolor2": "^1.4.6",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^8.30.1",
"@typescript-eslint/parser": "^7.0.0", "@typescript-eslint/parser": "^8.30.1",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"@vue/compiler-sfc": "^3.5.0", "@vue/compiler-sfc": "^3.5.0",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^13.0.0", "@vue/eslint-config-typescript": "^14.5.0",
"@vue/runtime-core": "^3.5.0", "@vue/runtime-core": "^3.5.0",
"@vueuse/core": "^11.0.3", "@vueuse/core": "^11.3.0",
"@vueuse/electron": "^11.0.3", "@vueuse/electron": "^11.3.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.7.7", "axios": "^1.7.7",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"electron": "^35.0.2", "electron": "^35.2.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"electron-vite": "^3.0.0", "electron-vite": "^3.1.0",
"eslint": "^8.57.0", "eslint": "^9.0.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^10.1.2",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-simple-import-sort": "^12.0.0", "eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-vue": "^9.26.0", "eslint-plugin-vue": "^10.0.0",
"eslint-plugin-vue-scoped-css": "^2.7.2", "eslint-plugin-vue-scoped-css": "^2.9.0",
"howler": "^2.2.4", "howler": "^2.2.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^15.0.4", "marked": "^15.0.4",
@@ -98,6 +105,15 @@
"repo": "AlgerMusicPlayer" "repo": "AlgerMusicPlayer"
} }
], ],
"extraResources": [
{
"from": "resources/html",
"to": "html",
"filter": [
"**/*"
]
}
],
"mac": { "mac": {
"icon": "resources/icon.icns", "icon": "resources/icon.icns",
"target": [ "target": [
@@ -122,13 +138,14 @@
] ]
}, },
"win": { "win": {
"icon": "resources/favicon.ico", "icon": "resources/icon.ico",
"target": [ "target": [
{ {
"target": "nsis", "target": "nsis",
"arch": [ "arch": [
"x64", "x64",
"ia32" "ia32",
"arm64"
] ]
} }
], ],
@@ -141,13 +158,22 @@
{ {
"target": "AppImage", "target": "AppImage",
"arch": [ "arch": [
"x64" "x64",
"arm64"
] ]
}, },
{ {
"target": "deb", "target": "deb",
"arch": [ "arch": [
"x64" "x64",
"arm64"
]
},
{
"target": "rpm",
"arch": [
"x64",
"arm64"
] ]
} }
], ],
@@ -158,8 +184,8 @@
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"installerIcon": "resources/favicon.ico", "installerIcon": "resources/icon.ico",
"uninstallerIcon": "resources/favicon.ico", "uninstallerIcon": "resources/icon.ico",
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "AlgerMusicPlayer", "shortcutName": "AlgerMusicPlayer",

View File

@@ -0,0 +1,486 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>AlgerMusicPlayer 远程控制</title>
<style>
:root {
--primary-color: #007AFF;
--secondary-color: #5AC8FA;
--success-color: #4CD964;
--danger-color: #FF3B30;
--warning-color: #FF9500;
--light-gray: #F2F2F7;
--medium-gray: #E5E5EA;
--dark-gray: #8E8E93;
--text-color: #000000;
--text-secondary: #6C6C6C;
--background-color: #FFFFFF;
}
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #0A84FF;
--secondary-color: #64D2FF;
--success-color: #30D158;
--danger-color: #FF453A;
--warning-color: #FF9F0A;
--light-gray: #1C1C1E;
--medium-gray: #2C2C2E;
--dark-gray: #8E8E93;
--text-color: #FFFFFF;
--text-secondary: #AEAEB2;
--background-color: #000000;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--light-gray);
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0;
margin: 0;
}
.header {
position: sticky;
top: 0;
z-index: 100;
background-color: var(--background-color);
padding: 12px 16px;
text-align: center;
border-bottom: 1px solid var(--medium-gray);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.header h1 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.container {
max-width: 540px;
margin: 0 auto;
padding: 16px;
width: 100%;
flex: 1;
}
.card {
background-color: var(--background-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.song-info {
display: flex;
align-items: center;
padding-bottom: 16px;
}
.song-cover {
width: 72px;
height: 72px;
border-radius: 8px;
object-fit: cover;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
background-color: var(--medium-gray);
}
.song-details {
margin-left: 16px;
flex: 1;
}
.song-details h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-details p {
margin: 4px 0 0;
font-size: 15px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.play-state {
font-size: 14px;
color: var(--primary-color);
margin-top: 4px;
display: flex;
align-items: center;
}
.play-state::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.playing .play-state::before {
background-color: var(--success-color);
}
.paused .play-state::before {
background-color: var(--warning-color);
}
.controls {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.extra-controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--background-color);
color: var(--primary-color);
border: 1px solid var(--medium-gray);
padding: 16px 0;
border-radius: 12px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
user-select: none;
position: relative;
overflow: hidden;
}
.btn:active {
transform: scale(0.97);
opacity: 0.7;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--primary-color);
opacity: 0;
transition: opacity 0.2s ease;
z-index: -1;
}
.btn:active::before {
opacity: 0.1;
}
.btn svg {
margin-bottom: 8px;
width: 24px;
height: 24px;
fill: var(--primary-color);
}
.btn-play svg {
width: 28px;
height: 28px;
margin-bottom: 6px;
}
.status-bar {
text-align: center;
padding: 8px 16px;
font-size: 14px;
color: var(--text-secondary);
background-color: var(--background-color);
border-top: 1px solid var(--medium-gray);
position: sticky;
bottom: 0;
}
.status-message {
display: inline-block;
transition: opacity 0.3s ease;
}
.status-message.fade {
opacity: 0;
}
.refresh-button {
color: var(--primary-color);
background: none;
border: none;
font-size: 14px;
cursor: pointer;
padding: 0;
margin-left: 8px;
}
@media (max-width: 350px) {
.controls, .extra-controls {
gap: 8px;
}
.btn {
padding: 12px 0;
font-size: 12px;
}
.btn svg {
width: 20px;
height: 20px;
margin-bottom: 6px;
}
}
</style>
</head>
<body>
<div class="header">
<h1>AlgerMusicPlayer 远程控制</h1>
</div>
<div class="container">
<div class="card" id="songInfoCard">
<div class="song-info">
<img id="songCover" class="song-cover" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%238E8E93'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z'/%3E%3C/svg%3E" alt="封面">
<div class="song-details">
<h2 id="songTitle">未在播放</h2>
<p id="songArtist">--</p>
<div class="play-state" id="playState">未播放</div>
</div>
</div>
</div>
<div class="card">
<div class="controls">
<button id="prevBtn" class="btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
上一首
</button>
<button id="playBtn" class="btn btn-play">
<svg id="playIcon" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
播放/暂停
</button>
<button id="nextBtn" class="btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
下一首
</button>
</div>
<div class="extra-controls">
<button id="volumeDownBtn" class="btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>
</svg>
音量-
</button>
<button id="volumeUpBtn" class="btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
音量+
</button>
</div>
</div>
<div class="card">
<div class="extra-controls">
<button id="favoriteBtn" class="btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
收藏
</button>
<button id="refreshBtn" class="btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
刷新
</button>
</div>
</div>
</div>
<div class="status-bar">
<span id="status" class="status-message">准备就绪</span>
</div>
<script>
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', () => {
// 获取DOM元素
const songInfoCard = document.getElementById('songInfoCard');
const songTitle = document.getElementById('songTitle');
const songArtist = document.getElementById('songArtist');
const songCover = document.getElementById('songCover');
const playState = document.getElementById('playState');
const playBtn = document.getElementById('playBtn');
const playIcon = document.getElementById('playIcon');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const favoriteBtn = document.getElementById('favoriteBtn');
const volumeUpBtn = document.getElementById('volumeUpBtn');
const volumeDownBtn = document.getElementById('volumeDownBtn');
const refreshBtn = document.getElementById('refreshBtn');
const status = document.getElementById('status');
let isPlaying = false;
// 显示状态消息并淡出
function showStatus(message, autoClear = true) {
status.textContent = message;
status.classList.remove('fade');
if (autoClear) {
setTimeout(() => {
status.classList.add('fade');
}, 2000);
}
}
// 更新播放/暂停图标
function updatePlayIcon() {
if (isPlaying) {
playIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
} else {
playIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
}
}
// 更新状态的函数
async function updateStatus() {
try {
showStatus('获取播放状态...', false);
const response = await fetch('/api/status');
const data = await response.json();
// 更新播放状态
isPlaying = data.isPlaying;
updatePlayIcon();
// 更新UI
if (data.currentSong) {
songTitle.textContent = data.currentSong.name || '未知歌曲';
if (data.currentSong.ar && data.currentSong.ar.length) {
songArtist.textContent = data.currentSong.ar.map(a => a.name).join(', ');
} else if (data.currentSong.artists && data.currentSong.artists.length) {
songArtist.textContent = data.currentSong.artists.map(a => a.name).join(', ');
} else {
songArtist.textContent = '未知艺术家';
}
if (data.currentSong.picUrl) {
songCover.src = data.currentSong.picUrl;
}
} else {
songTitle.textContent = '未在播放';
songArtist.textContent = '--';
songCover.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%238E8E93'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z'/%3E%3C/svg%3E";
}
// 更新播放状态
playState.textContent = isPlaying ? '正在播放' : '已暂停';
songInfoCard.className = isPlaying ? 'card playing' : 'card paused';
showStatus('已更新', true);
} catch (error) {
console.error('获取状态失败:', error);
showStatus('获取状态失败');
}
}
// 发送命令的函数
async function sendCommand(endpoint) {
try {
showStatus('发送命令中...', false);
const response = await fetch('/api/' + endpoint, { method: 'POST' });
const data = await response.json();
showStatus(data.message || '命令已发送');
// 稍等后更新状态
setTimeout(updateStatus, 500);
} catch (error) {
console.error('发送命令失败:', error);
showStatus('发送命令失败');
}
}
// 绑定按钮事件
playBtn.addEventListener('click', () => sendCommand('toggle-play'));
prevBtn.addEventListener('click', () => sendCommand('prev'));
nextBtn.addEventListener('click', () => sendCommand('next'));
favoriteBtn.addEventListener('click', () => sendCommand('toggle-favorite'));
volumeUpBtn.addEventListener('click', () => sendCommand('volume-up'));
volumeDownBtn.addEventListener('click', () => sendCommand('volume-down'));
refreshBtn.addEventListener('click', updateStatus);
// 初始加载状态
updateStatus();
// 每1秒更新一次状态
setInterval(updateStatus, 1000);
// 添加触摸反馈
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.addEventListener('touchstart', function() {
this.style.transform = 'scale(0.97)';
this.style.opacity = '0.7';
});
btn.addEventListener('touchend', function() {
this.style.transform = 'scale(1)';
this.style.opacity = '1';
});
});
// 检测深色模式变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
updateStatus();
});
});
</script>
</body>
</html>

Binary file not shown.

BIN
resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -26,6 +26,10 @@ export default {
delete: 'Delete', delete: 'Delete',
refresh: 'Refresh', refresh: 'Refresh',
retry: 'Retry', retry: 'Retry',
reset: 'Reset',
back: 'Back',
copySuccess: 'Copied to clipboard',
copyFailed: 'Copy failed',
validation: { validation: {
required: 'This field is required', required: 'This field is required',
invalidInput: 'Invalid input', invalidInput: 'Invalid input',
@@ -46,7 +50,8 @@ export default {
prev: 'Previous', prev: 'Previous',
next: 'Next', next: 'Next',
pause: 'Pause', pause: 'Pause',
play: 'Play' play: 'Play',
favorite: 'Favorite'
}, },
language: 'Language' language: 'Language'
}; };

View File

@@ -1,6 +1,6 @@
export default { export default {
installApp: { installApp: {
description: 'Install the application on the desktop for a better experience', description: 'Install the application for a better experience',
noPrompt: 'Do not prompt again', noPrompt: 'Do not prompt again',
install: 'Install now', install: 'Install now',
cancel: 'Cancel', cancel: 'Cancel',
@@ -60,7 +60,7 @@ export default {
wechatQR: 'Wechat QR code', wechatQR: 'Wechat QR code',
coffeeDesc: 'A cup of coffee, a support', coffeeDesc: 'A cup of coffee, a support',
coffeeDescLinkText: 'View more', coffeeDescLinkText: 'View more',
qqGroup: 'QQ group: 789288579', qqGroup: 'QQ group: algermusic',
messages: { messages: {
copySuccess: 'Copied to clipboard' copySuccess: 'Copied to clipboard'
}, },
@@ -85,12 +85,16 @@ export default {
login: 'Login', login: 'Login',
toLogin: 'To Login', toLogin: 'To Login',
logout: 'Logout', logout: 'Logout',
set: 'Set', set: 'Settings',
theme: 'Theme', theme: 'Theme',
restart: 'Restart', restart: 'Restart',
refresh: 'Refresh', refresh: 'Refresh',
currentVersion: 'Current Version', currentVersion: 'Current Version',
searchPlaceholder: 'Search for something...' searchPlaceholder: 'Search for something...',
zoom: 'Zoom',
zoom100: 'Zoom 100%',
resetZoom: 'Reset Zoom',
zoomDefault: 'Default Zoom'
}, },
titleBar: { titleBar: {
closeTitle: 'Choose how to close', closeTitle: 'Choose how to close',
@@ -104,6 +108,85 @@ export default {
}, },
musicList: { musicList: {
searchSongs: 'Search Songs', searchSongs: 'Search Songs',
noSearchResults: 'No search results' noSearchResults: 'No search results',
} switchToNormal: 'Switch to normal layout',
switchToCompact: 'Switch to compact layout',
playAll: 'Play All',
collect: 'Collect',
collectSuccess: 'Collect Success',
cancelCollectSuccess: 'Cancel Collect Success',
cancelCollect: 'Cancel Collect',
addToPlaylist: 'Add to Playlist',
addToPlaylistSuccess: 'Add to Playlist Success',
operationFailed: 'Operation Failed',
songsAlreadyInPlaylist: 'Songs already in playlist'
},
playlist: {
import: {
button: 'Import Playlist',
title: 'Import Playlist',
description: 'Import playlists via metadata, text, or links',
linkTab: 'Import by Link',
textTab: 'Import by Text',
localTab: 'Import by Metadata',
linkPlaceholder: 'Enter playlist links, one per line',
textPlaceholder: 'Enter song information in format: Song Name Artist Name',
localPlaceholder: 'Enter song metadata in JSON format',
linkTips: 'Supported link sources:',
linkTip1: 'Copy links after sharing playlists to WeChat/Weibo/QQ',
linkTip2: 'Directly copy playlist/profile links',
linkTip3: 'Directly copy article links',
textTips: 'Enter song information, one song per line',
textFormat: 'Format: Song Name Artist Name',
localTips: 'Add song metadata',
localFormat: 'Format example:',
songNamePlaceholder: 'Song Name',
artistNamePlaceholder: 'Artist Name',
albumNamePlaceholder: 'Album Name',
addSongButton: 'Add Song',
addLinkButton: 'Add Link',
importToStarPlaylist: 'Import to My Favorite Music',
playlistNamePlaceholder: 'Enter playlist name',
importButton: 'Start Import',
emptyLinkWarning: 'Please enter playlist links',
emptyTextWarning: 'Please enter song information',
emptyLocalWarning: 'Please enter song metadata',
invalidJsonFormat: 'Invalid JSON format',
importSuccess: 'Import task created successfully',
importFailed: 'Import failed',
importStatus: 'Import Status',
refresh: 'Refresh',
taskId: 'Task ID',
status: 'Status',
successCount: 'Success Count',
failReason: 'Failure Reason',
unknownError: 'Unknown error',
statusPending: 'Pending',
statusProcessing: 'Processing',
statusSuccess: 'Success',
statusFailed: 'Failed',
statusUnknown: 'Unknown',
taskList: 'Task List',
taskListTitle: 'Import Task List',
action: 'Action',
select: 'Select',
fetchTaskListFailed: 'Failed to fetch task list',
noTasks: 'No import tasks',
clearTasks: 'Clear Tasks',
clearTasksConfirmTitle: 'Confirm Clear',
clearTasksConfirmContent: 'Are you sure you want to clear all import task records? This action cannot be undone.',
confirm: 'Confirm',
cancel: 'Cancel',
clearTasksSuccess: 'Task list cleared',
clearTasksFailed: 'Failed to clear task list'
}
},
settings: 'Settings',
user: 'User',
toplist: 'Toplist',
history: 'History',
list: 'Playlist',
mv: 'MV',
home: 'Home',
search: 'Search'
}; };

View File

@@ -3,5 +3,7 @@ export default {
'Your donation will be used to support development and maintenance work, including but not limited to server maintenance, domain name renewal, etc.', 'Your donation will be used to support development and maintenance work, including but not limited to server maintenance, domain name renewal, etc.',
message: 'You can leave your email or github name when leaving a message.', message: 'You can leave your email or github name when leaving a message.',
refresh: 'Refresh List', refresh: 'Refresh List',
toDonateList: 'Buy me a coffee' toDonateList: 'Buy me a coffee',
title: 'Donation List',
noMessage: 'No Message'
}; };

View File

@@ -3,17 +3,20 @@ export default {
localMusic: 'Local Music', localMusic: 'Local Music',
count: '{count} songs in total', count: '{count} songs in total',
clearAll: 'Clear All', clearAll: 'Clear All',
settings: 'Settings',
tabs: { tabs: {
downloading: 'Downloading', downloading: 'Downloading',
downloaded: 'Downloaded' downloaded: 'Downloaded'
}, },
empty: { empty: {
noTasks: 'No download tasks', noTasks: 'No download tasks',
noDownloaded: 'No downloaded songs' noDownloaded: 'No downloaded songs',
noDownloadedHint: 'Download your favorite songs to listen offline'
}, },
progress: { progress: {
total: 'Total Progress: {progress}%' total: 'Total Progress: {progress}%'
}, },
items: 'items',
status: { status: {
downloading: 'Downloading', downloading: 'Downloading',
completed: 'Completed', completed: 'Completed',
@@ -43,6 +46,46 @@ export default {
}, },
message: { message: {
downloadComplete: '{filename} download completed', downloadComplete: '{filename} download completed',
downloadFailed: '{filename} download failed: {error}' downloadFailed: '{filename} download failed: {error}',
alreadyDownloading: '{filename} is already downloading'
},
loading: 'Loading...',
playStarted: 'Play started: {name}',
playFailed: 'Play failed: {name}',
path: {
copied: 'Path copied to clipboard',
copyFailed: 'Failed to copy path'
},
settingsPanel: {
title: 'Download Settings',
path: 'Download Location',
pathDesc: 'Set where your music files will be saved',
pathPlaceholder: 'Please select download path',
noPathSelected: 'Please select download path first',
select: 'Select Folder',
open: 'Open Folder',
fileFormat: 'Filename Format',
fileFormatDesc: 'Set how downloaded music files will be named',
customFormat: 'Custom Format',
separator: 'Separator',
separators: {
dash: 'Space-dash-space',
underscore: 'Underscore',
space: 'Space'
},
dragToArrange: 'Sort or use arrow buttons to arrange:',
formatVariables: 'Available variables',
preview: 'Preview:',
saveSuccess: 'Download settings saved',
presets: {
songArtist: 'Song - Artist',
artistSong: 'Artist - Song',
songOnly: 'Song only'
},
components: {
songName: 'Song name',
artistName: 'Artist name',
albumName: 'Album name'
}
} }
}; };

View File

@@ -11,5 +11,7 @@ export default {
downloadSuccess: 'Download completed', downloadSuccess: 'Download completed',
downloadFailed: 'Download failed', downloadFailed: 'Download failed',
downloading: 'Downloading, please wait...', downloading: 'Downloading, please wait...',
selectSongsFirst: 'Please select songs to download first' selectSongsFirst: 'Please select songs to download first',
descending: 'Descending',
ascending: 'Ascending'
}; };

View File

@@ -11,6 +11,8 @@ export default {
mute: 'Mute', mute: 'Mute',
unmute: 'Unmute', unmute: 'Unmute',
songNum: 'Song Number: {num}', songNum: 'Song Number: {num}',
addCorrection: 'Add {num} seconds',
subtractCorrection: 'Subtract {num} seconds',
playFailed: 'Play Failed, Play Next Song', playFailed: 'Play Failed, Play Next Song',
playMode: { playMode: {
sequence: 'Sequence', sequence: 'Sequence',
@@ -29,6 +31,15 @@ export default {
lrc: { lrc: {
noLrc: 'No lyrics, please enjoy' noLrc: 'No lyrics, please enjoy'
}, },
reparse: {
title: 'Select Music Source',
desc: 'Click a source to directly reparse the current song. This source will be used next time this song plays.',
success: 'Reparse successful',
failed: 'Reparse failed',
warning: 'Please select a music source',
bilibiliNotSupported: 'Bilibili videos do not support reparsing',
processing: 'Processing...'
},
playBar: { playBar: {
expand: 'Expand Lyrics', expand: 'Expand Lyrics',
collapse: 'Collapse Lyrics', collapse: 'Collapse Lyrics',
@@ -37,6 +48,7 @@ export default {
noSongPlaying: 'No song playing', noSongPlaying: 'No song playing',
eq: 'Equalizer', eq: 'Equalizer',
playList: 'Play List', playList: 'Play List',
reparse: 'Reparse',
playMode: { playMode: {
sequence: 'Sequence', sequence: 'Sequence',
loop: 'Loop', loop: 'Loop',
@@ -48,7 +60,9 @@ export default {
next: 'Next', next: 'Next',
volume: 'Volume', volume: 'Volume',
favorite: 'Favorite {name}', favorite: 'Favorite {name}',
unFavorite: 'Unfavorite {name}' unFavorite: 'Unfavorite {name}',
playbackSpeed: 'Playback Speed',
advancedControls: 'Advanced Controls',
}, },
eq: { eq: {
title: 'Equalizer', title: 'Equalizer',
@@ -73,5 +87,35 @@ export default {
acoustic: 'Acoustic', acoustic: 'Acoustic',
custom: 'Custom' custom: 'Custom'
} }
},
// Sleep timer related
sleepTimer: {
title: 'Sleep Timer',
cancel: 'Cancel Timer',
timeMode: 'By Time',
songsMode: 'By Songs',
playlistEnd: 'After Playlist',
afterPlaylist: 'After Playlist Ends',
activeUntilEnd: 'Active until end of playlist',
minutes: 'min',
hours: 'hr',
songs: 'songs',
set: 'Set',
timerSetSuccess: 'Timer set for {minutes} minutes',
songsSetSuccess: 'Timer set for {songs} songs',
playlistEndSetSuccess: 'Timer set to end after playlist',
timerCancelled: 'Sleep timer cancelled',
timerEnded: 'Sleep timer ended',
playbackStopped: 'Music playback stopped',
minutesRemaining: '{minutes} min remaining',
songsRemaining: '{count} songs remaining'
},
playList: {
clearAll: 'Clear Playlist',
alreadyEmpty: 'Playlist is already empty',
cleared: 'Playlist cleared',
empty: 'Playlist is empty',
clearConfirmTitle: 'Clear Playlist',
clearConfirmContent: 'This will clear all songs in the playlist and stop the current playback. Continue?'
} }
}; };

View File

@@ -6,7 +6,8 @@ export default {
}, },
button: { button: {
clear: 'Clear', clear: 'Clear',
back: 'Back' back: 'Back',
playAll: 'Play All'
}, },
loading: { loading: {
more: 'Loading...', more: 'Loading...',
@@ -15,5 +16,12 @@ export default {
noMore: 'No more results', noMore: 'No more results',
error: { error: {
searchFailed: 'Search failed' searchFailed: 'Search failed'
},
search: {
single: 'Single',
album: 'Album',
playlist: 'Playlist',
mv: 'MV',
bilibili: 'Bilibili'
} }
}; };

View File

@@ -56,8 +56,19 @@ export default {
dolby: 'Dolby Atmos', dolby: 'Dolby Atmos',
jymaster: 'Master' jymaster: 'Master'
}, },
musicSources: 'Music Sources',
musicSourcesDesc: 'Select music sources for song resolution',
musicSourcesWarning: 'At least one music source must be selected',
musicUnblockEnable: 'Enable Music Unblocking',
musicUnblockEnableDesc: 'When enabled, attempts to resolve unplayable songs',
configureMusicSources: 'Configure Sources',
selectedMusicSources: 'Selected sources:',
noMusicSources: 'No sources selected',
gdmusicInfo: 'GD Music Station intelligently resolves music from multiple platforms automatically',
autoPlay: 'Auto Play', autoPlay: 'Auto Play',
autoPlayDesc: 'Auto resume playback when reopening the app' autoPlayDesc: 'Auto resume playback when reopening the app',
showStatusBar: "Show Status Bar",
showStatusBarContent: "You can display the music control function in your mac status bar (effective after a restart)"
}, },
application: { application: {
closeAction: 'Close Action', closeAction: 'Close Action',
@@ -74,7 +85,9 @@ export default {
unlimitedDownload: 'Unlimited Download', unlimitedDownload: 'Unlimited Download',
unlimitedDownloadDesc: 'Enable unlimited download mode for music , default limit 300 songs', unlimitedDownloadDesc: 'Enable unlimited download mode for music , default limit 300 songs',
downloadPath: 'Download Directory', downloadPath: 'Download Directory',
downloadPathDesc: 'Choose download location for music files' downloadPathDesc: 'Choose download location for music files',
remoteControl: 'Remote Control',
remoteControlDesc: 'Set remote control function'
}, },
network: { network: {
apiPort: 'Music API Port', apiPort: 'Music API Port',
@@ -155,42 +168,57 @@ export default {
}, },
lyricSettings: { lyricSettings: {
title: 'Lyric Settings', title: 'Lyric Settings',
tabs: {
display: 'Display',
interface: 'Interface',
typography: 'Typography',
mobile: 'Mobile'
},
pureMode: 'Pure Mode', pureMode: 'Pure Mode',
hideCover: 'Hide Cover', hideCover: 'Hide Cover',
centerDisplay: 'Center Display', centerDisplay: 'Center Display',
showTranslation: 'Show Translation', showTranslation: 'Show Translation',
hideLyrics: 'Hide Lyrics',
hidePlayBar: 'Hide Play Bar', hidePlayBar: 'Hide Play Bar',
fontSize: 'Font Size', hideMiniPlayBar: 'Hide Mini Play Bar',
letterSpacing: 'Letter Spacing',
lineHeight: 'Line Height',
backgroundTheme: 'Background Theme', backgroundTheme: 'Background Theme',
fontSizeMarks: {
small: 'Small',
medium: 'Medium',
large: 'Large'
},
letterSpacingMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
lineHeightMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
themeOptions: { themeOptions: {
default: 'Default', default: 'Default',
light: 'Light', light: 'Light',
dark: 'Dark' dark: 'Dark'
}, },
hideMiniPlayBar: 'Hide Mini Play Bar', fontSize: 'Font Size',
hideLyrics: 'Hide Lyrics', fontSizeMarks: {
tabs: { small: 'Small',
interface: 'Interface', medium: 'Medium',
display: 'Display', large: 'Large'
typography: 'Typography' },
} letterSpacing: 'Letter Spacing',
letterSpacingMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
lineHeight: 'Line Height',
lineHeightMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
mobileLayout: 'Mobile Layout',
layoutOptions: {
default: 'Default',
ios: 'iOS Style',
android: 'Android Style'
},
mobileCoverStyle: 'Cover Style',
coverOptions: {
record: 'Record',
square: 'Square',
full: 'Full Screen'
},
lyricLines: 'Lyric Lines',
mobileUnavailable: 'This setting is only available on mobile devices'
}, },
shortcutSettings: { shortcutSettings: {
title: 'Shortcut Settings', title: 'Shortcut Settings',
@@ -221,5 +249,15 @@ export default {
disableAll: 'All shortcuts disabled, please save to apply', disableAll: 'All shortcuts disabled, please save to apply',
enableAll: 'All shortcuts enabled, please save to apply' enableAll: 'All shortcuts enabled, please save to apply'
} }
},
remoteControl: {
title: 'Remote Control',
enable: 'Enable Remote Control',
port: 'Port',
allowedIps: 'Allowed IPs',
addIp: 'Add IP',
emptyListHint: 'Empty list means allow all IPs',
saveSuccess: 'Remote control settings saved',
accessInfo: 'Remote control access address:',
} }
}; };

View File

@@ -6,7 +6,9 @@ export default {
addToPlaylist: 'Add to Playlist', addToPlaylist: 'Add to Playlist',
favorite: 'Like', favorite: 'Like',
unfavorite: 'Unlike', unfavorite: 'Unlike',
removeFromPlaylist: 'Remove from Playlist' removeFromPlaylist: 'Remove from Playlist',
dislike: 'Dislike',
undislike: 'Undislike',
}, },
message: { message: {
downloading: 'Downloading, please wait...', downloading: 'Downloading, please wait...',
@@ -14,5 +16,13 @@ export default {
downloadQueued: 'Added to download queue', downloadQueued: 'Added to download queue',
addedToNextPlay: 'Added to play next', 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'
},
dialog: {
dislike:{
title: 'Dislike',
content: 'Are you sure you want to dislike this song?',
positiveText: 'Dislike',
negativeText: 'Cancel'
}
} }
}; };

View File

@@ -6,6 +6,7 @@ export default {
}, },
playlist: { playlist: {
created: 'Created Playlists', created: 'Created Playlists',
mine: 'Mine',
trackCount: '{count} tracks', trackCount: '{count} tracks',
playCount: 'Played {count} times' playCount: 'Played {count} times'
}, },
@@ -18,12 +19,16 @@ export default {
viewPlaylist: 'View Playlist', viewPlaylist: 'View Playlist',
noFollowings: 'No Followings', noFollowings: 'No Followings',
loadMore: 'Load More', loadMore: 'Load More',
noSignature: 'This guy is lazy, nothing left' noSignature: 'This guy is lazy, nothing left',
userFollowsTitle: '\'s Followings',
myFollowsTitle: 'My Followings'
}, },
follower: { follower: {
title: 'Follower List', title: 'Follower List',
noFollowers: 'No Followers', noFollowers: 'No Followers',
loadMore: 'Load More' loadMore: 'Load More',
userFollowersTitle: '\'s Followers',
myFollowersTitle: 'My Followers'
}, },
detail: { detail: {
playlists: 'Playlists', playlists: 'Playlists',
@@ -32,7 +37,8 @@ export default {
noRecords: 'No Listening History', noRecords: 'No Listening History',
artist: 'Artist', artist: 'Artist',
noSignature: 'This guy is lazy, nothing left', noSignature: 'This guy is lazy, nothing left',
invalidUserId: 'Invalid User ID' invalidUserId: 'Invalid User ID',
noRecordPermission: '{name} doesn\'t let you see your listening history'
}, },
message: { message: {
loadFailed: 'Failed to load user page', loadFailed: 'Failed to load user page',

View File

@@ -26,6 +26,10 @@ export default {
delete: '删除', delete: '删除',
refresh: '刷新', refresh: '刷新',
retry: '重试', retry: '重试',
reset: '重置',
back: '返回',
copySuccess: '已复制到剪贴板',
copyFailed: '复制失败',
validation: { validation: {
required: '此项是必填的', required: '此项是必填的',
invalidInput: '输入无效', invalidInput: '输入无效',
@@ -46,6 +50,7 @@ export default {
prev: '上一首', prev: '上一首',
next: '下一首', next: '下一首',
pause: '暂停', pause: '暂停',
play: '播放' play: '播放',
favorite: '收藏'
} }
}; };

View File

@@ -1,6 +1,6 @@
export default { export default {
installApp: { installApp: {
description: '在桌面安装应用,获得更好的体验', description: '安装应用程序,获得更好的体验',
noPrompt: '不再提示', noPrompt: '不再提示',
install: '立即安装', install: '立即安装',
cancel: '暂不安装', cancel: '暂不安装',
@@ -58,7 +58,7 @@ export default {
wechatQR: '微信收款码', wechatQR: '微信收款码',
coffeeDesc: '一杯咖啡,一份支持', coffeeDesc: '一杯咖啡,一份支持',
coffeeDescLinkText: '查看更多', coffeeDescLinkText: '查看更多',
qqGroup: 'QQ789288579', qqGroup: 'QQ频道algermusic',
messages: { messages: {
copySuccess: '已复制到剪贴板' copySuccess: '已复制到剪贴板'
}, },
@@ -88,7 +88,11 @@ export default {
restart: '重启', restart: '重启',
refresh: '刷新', refresh: '刷新',
currentVersion: '当前版本', currentVersion: '当前版本',
searchPlaceholder: '搜索点什么吧...' searchPlaceholder: '搜索点什么吧...',
zoom: '页面缩放',
zoom100: '标准缩放100%',
resetZoom: '点击重置缩放',
zoomDefault: '标准缩放'
}, },
titleBar: { titleBar: {
closeTitle: '请选择关闭方式', closeTitle: '请选择关闭方式',
@@ -102,6 +106,85 @@ export default {
}, },
musicList: { musicList: {
searchSongs: '搜索歌曲', searchSongs: '搜索歌曲',
noSearchResults: '没有找到相关歌曲' noSearchResults: '没有找到相关歌曲',
} switchToNormal: '切换到默认布局',
switchToCompact: '切换到紧凑布局',
playAll: '播放全部',
collect: '收藏',
collectSuccess: '收藏成功',
cancelCollectSuccess: '取消收藏成功',
operationFailed: '操作失败',
cancelCollect: '取消收藏',
addToPlaylist: '添加到播放列表',
addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中'
},
playlist: {
import: {
button: '歌单导入',
title: '歌单导入',
description: '支持通过元数据/文字/链接三种方式导入歌单',
linkTab: '链接导入',
textTab: '文字导入',
localTab: '元数据导入',
linkPlaceholder: '请输入歌单链接,每行一个',
textPlaceholder: '请输入歌曲信息,格式为:歌曲名 歌手名',
localPlaceholder: '请输入JSON格式的歌曲元数据',
linkTips: '支持的链接来源:',
linkTip1: '将歌单分享到微信/微博/QQ后复制链接',
linkTip2: '直接复制歌单/个人主页链接',
linkTip3: '直接复制文章链接',
textTips: '请输入歌曲信息,每行一首歌',
textFormat: '格式:歌曲名 歌手名',
localTips: '请添加歌曲元数据',
localFormat: '格式示例:',
songNamePlaceholder: '歌曲名称',
artistNamePlaceholder: '艺术家名称',
albumNamePlaceholder: '专辑名称',
addSongButton: '添加歌曲',
addLinkButton: '添加链接',
importToStarPlaylist: '导入到我喜欢的音乐',
playlistNamePlaceholder: '请输入歌单名称',
importButton: '开始导入',
emptyLinkWarning: '请输入歌单链接',
emptyTextWarning: '请输入歌曲信息',
emptyLocalWarning: '请输入歌曲元数据',
invalidJsonFormat: 'JSON格式不正确',
importSuccess: '导入任务创建成功',
importFailed: '导入失败',
importStatus: '导入状态',
refresh: '刷新',
taskId: '任务ID',
status: '状态',
successCount: '成功数量',
failReason: '失败原因',
unknownError: '未知错误',
statusPending: '等待处理',
statusProcessing: '处理中',
statusSuccess: '导入成功',
statusFailed: '导入失败',
statusUnknown: '未知状态',
taskList: '任务列表',
taskListTitle: '导入任务列表',
action: '操作',
select: '选择',
fetchTaskListFailed: '获取任务列表失败',
noTasks: '暂无导入任务',
clearTasks: '清除任务',
clearTasksConfirmTitle: '确认清除',
clearTasksConfirmContent: '确定要清除所有导入任务记录吗?此操作不可恢复。',
confirm: '确认',
cancel: '取消',
clearTasksSuccess: '任务列表已清除',
clearTasksFailed: '清除任务列表失败'
}
},
settings: '设置',
user: '用户',
toplist: '排行榜',
history: '收藏历史',
list: '歌单',
mv: 'MV',
home: '首页',
search: '搜索'
}; };

View File

@@ -2,5 +2,7 @@ export default {
description: '您的捐赠将用于支持开发和维护工作,包括但不限于服务器维护、域名续费等。', description: '您的捐赠将用于支持开发和维护工作,包括但不限于服务器维护、域名续费等。',
message: '留言时可留下您的邮箱或 github名称。', message: '留言时可留下您的邮箱或 github名称。',
refresh: '刷新列表', refresh: '刷新列表',
toDonateList: '请我喝咖啡' toDonateList: '请我喝咖啡',
noMessage: '暂无留言',
title: '捐赠列表'
}; };

View File

@@ -3,6 +3,7 @@ export default {
localMusic: '本地音乐', localMusic: '本地音乐',
count: '共 {count} 首歌曲', count: '共 {count} 首歌曲',
clearAll: '清空记录', clearAll: '清空记录',
settings: '设置',
tabs: { tabs: {
downloading: '下载中', downloading: '下载中',
downloaded: '已下载' downloaded: '已下载'
@@ -43,5 +44,44 @@ export default {
message: { message: {
downloadComplete: '{filename} 下载完成', downloadComplete: '{filename} 下载完成',
downloadFailed: '{filename} 下载失败: {error}' downloadFailed: '{filename} 下载失败: {error}'
},
loading: '加载中...',
playStarted: '开始播放: {name}',
playFailed: '播放失败: {name}',
path: {
copied: '路径已复制到剪贴板',
copyFailed: '复制路径失败'
},
settingsPanel: {
title: '下载设置',
path: '下载位置',
pathDesc: '设置音乐文件下载保存的位置',
pathPlaceholder: '请选择下载路径',
noPathSelected: '请先选择下载路径',
select: '选择文件夹',
open: '打开文件夹',
fileFormat: '文件名格式',
fileFormatDesc: '设置下载音乐时的文件命名格式',
customFormat: '自定义格式',
separator: '分隔符',
separators: {
dash: '空格-空格',
underscore: '下划线',
space: '空格'
},
dragToArrange: '拖动排序或使用箭头按钮调整顺序:',
formatVariables: '可用变量',
preview: '预览效果:',
saveSuccess: '下载设置已保存',
presets: {
songArtist: '歌曲名 - 歌手名',
artistSong: '歌手名 - 歌曲名',
songOnly: '仅歌曲名'
},
components: {
songName: '歌曲名',
artistName: '歌手名',
albumName: '专辑名'
}
} }
}; };

View File

@@ -7,5 +7,7 @@ export default {
downloadSuccess: '下载完成', downloadSuccess: '下载完成',
downloadFailed: '下载失败', downloadFailed: '下载失败',
downloading: '正在下载中,请稍候...', downloading: '正在下载中,请稍候...',
selectSongsFirst: '请先选择要下载的歌曲' selectSongsFirst: '请先选择要下载的歌曲',
descending: '降',
ascending: '升'
}; };

View File

@@ -11,10 +11,12 @@ export default {
mute: '静音', mute: '静音',
unmute: '取消静音', unmute: '取消静音',
songNum: '歌曲总数:{num}', songNum: '歌曲总数:{num}',
addCorrection: '提前 {num} 秒',
subtractCorrection: '延迟 {num} 秒',
playFailed: '当前歌曲播放失败,播放下一首', playFailed: '当前歌曲播放失败,播放下一首',
playMode: { playMode: {
sequence: '顺序播放', sequence: '顺序播放',
loop: '循环播放', loop: '单曲循环',
random: '随机播放' random: '随机播放'
}, },
fullscreen: { fullscreen: {
@@ -29,6 +31,15 @@ export default {
lrc: { lrc: {
noLrc: '暂无歌词, 请欣赏' noLrc: '暂无歌词, 请欣赏'
}, },
reparse: {
title: '选择解析音源',
desc: '点击音源直接进行解析,下次播放此歌曲时将使用所选音源',
success: '重新解析成功',
failed: '重新解析失败',
warning: '请选择一个音源',
bilibiliNotSupported: 'B站视频不支持重新解析',
processing: '解析中...'
},
playBar: { playBar: {
expand: '展开歌词', expand: '展开歌词',
collapse: '收起歌词', collapse: '收起歌词',
@@ -37,6 +48,7 @@ export default {
noSongPlaying: '没有正在播放的歌曲', noSongPlaying: '没有正在播放的歌曲',
eq: '均衡器', eq: '均衡器',
playList: '播放列表', playList: '播放列表',
reparse: '重新解析',
playMode: { playMode: {
sequence: '顺序播放', sequence: '顺序播放',
loop: '循环播放', loop: '循环播放',
@@ -49,7 +61,9 @@ export default {
volume: '音量', volume: '音量',
favorite: '已收藏{name}', favorite: '已收藏{name}',
unFavorite: '已取消收藏{name}', unFavorite: '已取消收藏{name}',
miniPlayBar: '迷你播放栏' miniPlayBar: '迷你播放栏',
playbackSpeed: '播放速度',
advancedControls: '更多设置s',
}, },
eq: { eq: {
title: '均衡器', title: '均衡器',
@@ -74,5 +88,35 @@ export default {
acoustic: '原声', acoustic: '原声',
custom: '自定义' custom: '自定义'
} }
},
// 定时关闭功能相关
sleepTimer: {
title: '定时关闭',
cancel: '取消定时',
timeMode: '按时间关闭',
songsMode: '按歌曲数关闭',
playlistEnd: '播放完列表后关闭',
afterPlaylist: '播放完列表后关闭',
activeUntilEnd: '播放至列表结束',
minutes: '分钟',
hours: '小时',
songs: '首歌',
set: '设置',
timerSetSuccess: '已设置{minutes}分钟后关闭',
songsSetSuccess: '已设置播放{songs}首歌后关闭',
playlistEndSetSuccess: '已设置播放完列表后关闭',
timerCancelled: '已取消定时关闭',
timerEnded: '定时关闭已触发',
playbackStopped: '音乐播放已停止',
minutesRemaining: '剩余{minutes}分钟',
songsRemaining: '剩余{count}首歌'
},
playList: {
clearAll: '清空播放列表',
alreadyEmpty: '播放列表已经为空',
cleared: '已清空播放列表',
empty: '播放列表为空',
clearConfirmTitle: '清空播放列表',
clearConfirmContent: '这将清空所有播放列表中的歌曲并停止当前播放。是否继续?'
} }
}; };

View File

@@ -6,7 +6,8 @@ export default {
}, },
button: { button: {
clear: '清空', clear: '清空',
back: '返回' back: '返回',
playAll: '播放列表'
}, },
loading: { loading: {
more: '加载中...', more: '加载中...',
@@ -15,5 +16,12 @@ export default {
noMore: '没有更多了', noMore: '没有更多了',
error: { error: {
searchFailed: '搜索失败' searchFailed: '搜索失败'
},
search: {
single: '单曲',
album: '专辑',
playlist: '歌单',
mv: 'MV',
bilibili: 'B站'
} }
}; };

View File

@@ -44,7 +44,7 @@ export default {
}, },
playback: { playback: {
quality: '音质设置', quality: '音质设置',
qualityDesc: '选择音乐播放音质VIP', qualityDesc: '选择音乐播放音质(网易云VIP',
qualityOptions: { qualityOptions: {
standard: '标准', standard: '标准',
higher: '较高', higher: '较高',
@@ -56,8 +56,19 @@ export default {
dolby: '杜比全景声', dolby: '杜比全景声',
jymaster: '超清母带' jymaster: '超清母带'
}, },
musicSources: '音源设置',
musicSourcesDesc: '选择音乐解析使用的音源平台',
musicSourcesWarning: '至少需要选择一个音源平台',
musicUnblockEnable: '启用音乐解析',
musicUnblockEnableDesc: '开启后将尝试解析无法播放的音乐',
configureMusicSources: '配置音源',
selectedMusicSources: '已选音源:',
noMusicSources: '未选择音源',
gdmusicInfo: 'GD音乐台可自动解析多个平台音源自动选择最佳结果',
autoPlay: '自动播放', autoPlay: '自动播放',
autoPlayDesc: '重新打开应用时是否自动继续播放' autoPlayDesc: '重新打开应用时是否自动继续播放',
showStatusBar: '是否显示状态栏控制功能',
showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)',
}, },
application: { application: {
closeAction: '关闭行为', closeAction: '关闭行为',
@@ -74,7 +85,9 @@ export default {
unlimitedDownload: '无限制下载', unlimitedDownload: '无限制下载',
unlimitedDownloadDesc: '开启后将无限制下载音乐(可能出现下载失败的情况), 默认限制 300 首', unlimitedDownloadDesc: '开启后将无限制下载音乐(可能出现下载失败的情况), 默认限制 300 首',
downloadPath: '下载目录', downloadPath: '下载目录',
downloadPathDesc: '选择音乐文件的下载位置' downloadPathDesc: '选择音乐文件的下载位置',
remoteControl: '远程控制',
remoteControlDesc: '设置远程控制功能'
}, },
network: { network: {
apiPort: '音乐API端口', apiPort: '音乐API端口',
@@ -154,19 +167,33 @@ export default {
portNumber: '请输入有效的端口号(1-65535)' portNumber: '请输入有效的端口号(1-65535)'
}, },
lyricSettings: { lyricSettings: {
title: '页面设置', title: '歌词设置',
tabs: {
display: '显示',
interface: '界面',
typography: '文字',
mobile: '移动端'
},
pureMode: '纯净模式', pureMode: '纯净模式',
hideCover: '隐藏封面', hideCover: '隐藏封面',
centerDisplay: '居中显示', centerDisplay: '居中显示',
showTranslation: '显示翻译', showTranslation: '显示翻译',
hideLyrics: '隐藏歌词',
hidePlayBar: '隐藏播放栏', hidePlayBar: '隐藏播放栏',
hideMiniPlayBar: '隐藏迷你播放栏',
backgroundTheme: '背景主题',
themeOptions: {
default: '默认',
light: '亮色',
dark: '暗色'
},
fontSize: '字体大小', fontSize: '字体大小',
fontSizeMarks: { fontSizeMarks: {
small: '小', small: '小',
medium: '中', medium: '中',
large: '大' large: '大'
}, },
letterSpacing: '字间距', letterSpacing: '字间距',
letterSpacingMarks: { letterSpacingMarks: {
compact: '紧凑', compact: '紧凑',
default: '默认', default: '默认',
@@ -178,19 +205,20 @@ export default {
default: '默认', default: '默认',
loose: '宽松' loose: '宽松'
}, },
backgroundTheme: '背景主题', mobileLayout: '移动端布局',
themeOptions: { layoutOptions: {
default: '默认', default: '默认',
light: '亮色', ios: 'iOS风格',
dark: '暗色' android: '安卓风格'
}, },
hideMiniPlayBar: '隐藏迷你播放栏', mobileCoverStyle: '封面样式',
hideLyrics: '隐藏歌词', coverOptions: {
tabs: { record: '唱片',
interface: '界面', square: '方形',
typography: '文字', full: '全屏'
display: '显示' },
} lyricLines: '歌词行数',
mobileUnavailable: '此设置仅在移动端可用'
}, },
shortcutSettings: { shortcutSettings: {
title: '快捷键设置', title: '快捷键设置',
@@ -221,5 +249,15 @@ export default {
disableAll: '已禁用所有快捷键,请记得保存', disableAll: '已禁用所有快捷键,请记得保存',
enableAll: '已启用所有快捷键,请记得保存' enableAll: '已启用所有快捷键,请记得保存'
} }
},
remoteControl: {
title: '远程控制',
enable: '启用远程控制',
port: '服务端口',
allowedIps: '允许的IP地址',
addIp: '添加IP',
emptyListHint: '空列表表示允许所有IP访问',
saveSuccess: '远程控制设置已保存',
accessInfo: '远程控制访问地址:',
} }
}; };

View File

@@ -6,7 +6,9 @@ export default {
addToPlaylist: '添加到歌单', addToPlaylist: '添加到歌单',
favorite: '喜欢', favorite: '喜欢',
unfavorite: '取消喜欢', unfavorite: '取消喜欢',
removeFromPlaylist: '从歌单中删除' removeFromPlaylist: '从歌单中删除',
dislike: '不喜欢',
undislike: '取消不喜欢',
}, },
message: { message: {
downloading: '正在下载中,请稍候...', downloading: '正在下载中,请稍候...',
@@ -14,5 +16,13 @@ export default {
downloadQueued: '已加入下载队列', downloadQueued: '已加入下载队列',
addedToNextPlay: '已添加到下一首播放', addedToNextPlay: '已添加到下一首播放',
getUrlFailed: '获取音乐下载地址失败,请检查是否登录' getUrlFailed: '获取音乐下载地址失败,请检查是否登录'
},
dialog: {
dislike: {
title: '提示!',
content: '确认不喜欢这首歌吗?再次进入将从每日推荐中排除。',
positiveText: '不喜欢',
negativeText: '取消'
}
} }
}; };

View File

@@ -6,6 +6,7 @@ export default {
}, },
playlist: { playlist: {
created: '创建的歌单', created: '创建的歌单',
mine: '我创建的',
trackCount: '{count}首', trackCount: '{count}首',
playCount: '播放{count}次' playCount: '播放{count}次'
}, },
@@ -18,12 +19,16 @@ export default {
viewPlaylist: '查看歌单', viewPlaylist: '查看歌单',
noFollowings: '暂无关注', noFollowings: '暂无关注',
loadMore: '加载更多', loadMore: '加载更多',
noSignature: '这个家伙很懒,什么都没留下' noSignature: '这个家伙很懒,什么都没留下',
userFollowsTitle: '的关注',
myFollowsTitle: '我的关注'
}, },
follower: { follower: {
title: '粉丝列表', title: '粉丝列表',
noFollowers: '暂无粉丝', noFollowers: '暂无粉丝',
loadMore: '加载更多' loadMore: '加载更多',
userFollowersTitle: '的粉丝',
myFollowersTitle: '我的粉丝'
}, },
detail: { detail: {
playlists: '歌单', playlists: '歌单',
@@ -32,7 +37,8 @@ export default {
noRecords: '暂无听歌记录', noRecords: '暂无听歌记录',
artist: '歌手', artist: '歌手',
noSignature: '这个人很懒,什么都没留下', noSignature: '这个人很懒,什么都没留下',
invalidUserId: '用户ID无效' invalidUserId: '用户ID无效',
noRecordPermission: '{name}不让你看听歌排行'
}, },
message: { message: {
loadFailed: '加载用户页面失败', loadFailed: '加载用户页面失败',

58
src/locale/zh-CN.json Normal file
View File

@@ -0,0 +1,58 @@
{
"settings": {
"lyricSettings": {
"title": "歌词设置",
"tabs": {
"display": "显示",
"interface": "界面",
"typography": "文字",
"mobile": "移动端"
},
"pureMode": "纯净模式",
"hideCover": "隐藏封面",
"centerDisplay": "居中显示",
"showTranslation": "显示翻译",
"hideLyrics": "隐藏歌词",
"hidePlayBar": "隐藏播放栏",
"hideMiniPlayBar": "隐藏迷你播放栏",
"backgroundTheme": "背景主题",
"themeOptions": {
"default": "默认",
"light": "亮色",
"dark": "暗色"
},
"fontSize": "字体大小",
"fontSizeMarks": {
"small": "小",
"medium": "中",
"large": "大"
},
"letterSpacing": "字间距",
"letterSpacingMarks": {
"compact": "紧凑",
"default": "默认",
"loose": "宽松"
},
"lineHeight": "行高",
"lineHeightMarks": {
"compact": "紧凑",
"default": "默认",
"loose": "宽松"
},
"mobileLayout": "移动端布局",
"layoutOptions": {
"default": "默认",
"ios": "iOS风格",
"android": "安卓风格"
},
"mobileCoverStyle": "封面样式",
"coverOptions": {
"record": "唱片",
"square": "方形",
"full": "全屏"
},
"lyricLines": "歌词行数",
"mobileUnavailable": "此设置仅在移动端可用"
}
}
}

View File

@@ -8,21 +8,21 @@ import { loadLyricWindow } from './lyric';
import { initializeConfig } from './modules/config'; import { initializeConfig } from './modules/config';
import { initializeFileManager } from './modules/fileManager'; import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts'; import { initializeFonts } from './modules/fonts';
import { initializeRemoteControl } from './modules/remoteControl';
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts'; import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeStats, setupStatsHandlers } from './modules/statsService'; import { initializeStats, setupStatsHandlers } from './modules/statsService';
import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray'; import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
import { setupUpdateHandlers } from './modules/update'; import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager } from './modules/window'; import { createMainWindow, initializeWindowManager } from './modules/window';
import { startMusicApi } from './server'; import { startMusicApi } from './server';
import { initWindowSizeManager } from './modules/window-size';
// 导入所有图标 // 导入所有图标
const iconPath = join(__dirname, '../../resources'); const iconPath = join(__dirname, '../../resources');
const icon = nativeImage.createFromPath( const icon = nativeImage.createFromPath(
process.platform === 'darwin' process.platform === 'darwin'
? join(iconPath, 'icon.icns') ? join(iconPath, 'icon.icns')
: process.platform === 'win32' : join(iconPath, 'icon.png')
? join(iconPath, 'favicon.ico')
: join(iconPath, 'icon.png')
); );
let mainWindow: Electron.BrowserWindow; let mainWindow: Electron.BrowserWindow;
@@ -66,6 +66,9 @@ function initialize() {
// 初始化快捷键 // 初始化快捷键
initializeShortcuts(mainWindow); initializeShortcuts(mainWindow);
// 初始化远程控制服务
initializeRemoteControl(mainWindow);
// 初始化更新处理程序 // 初始化更新处理程序
setupUpdateHandlers(mainWindow); setupUpdateHandlers(mainWindow);
} }
@@ -97,6 +100,9 @@ if (!isSingleInstance) {
optimizer.watchWindowShortcuts(window); optimizer.watchWindowShortcuts(window);
}); });
// 初始化窗口大小管理器
initWindowSizeManager();
// 初始化应用 // 初始化应用
initialize(); initialize();

View File

@@ -84,15 +84,20 @@ const createWin = () => {
frame: false, frame: false,
show: false, show: false,
transparent: true, transparent: true,
opacity: 1,
hasShadow: false, hasShadow: false,
alwaysOnTop: true, alwaysOnTop: true,
resizable: true, resizable: true,
roundedCorners: false,
titleBarStyle: 'hidden',
titleBarOverlay: false,
// 添加跨屏幕支持选项 // 添加跨屏幕支持选项
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false, sandbox: false,
contextIsolation: true contextIsolation: true
} },
backgroundColor: '#00000000'
}); });
// 监听窗口关闭事件 // 监听窗口关闭事件
@@ -117,6 +122,8 @@ const createWin = () => {
} }
}); });
lyricWindow.on('blur', () => lyricWindow && lyricWindow.setMaximizable(false))
return lyricWindow; return lyricWindow;
}; };

View File

@@ -24,6 +24,7 @@ type SetConfig = {
fontFamily: string; fontFamily: string;
fontScope: 'global' | 'lyric'; fontScope: 'global' | 'lyric';
language: string; language: string;
showTopAction: boolean;
}; };
interface StoreType { interface StoreType {
set: SetConfig; set: SetConfig;

View File

@@ -6,6 +6,9 @@ import * as http from 'http';
import * as https from 'https'; import * as https from 'https';
import * as NodeID3 from 'node-id3'; import * as NodeID3 from 'node-id3';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import * as mm from 'music-metadata';
import { fileTypeFromFile } from 'file-type';
import { getStore } from './config'; import { getStore } from './config';
@@ -29,6 +32,9 @@ const audioCacheStore = new Store({
} }
}); });
// 保存已发送通知的文件,避免重复通知
const sentNotifications = new Map();
/** /**
* 初始化文件管理相关的IPC监听 * 初始化文件管理相关的IPC监听
*/ */
@@ -36,9 +42,18 @@ export function initializeFileManager() {
// 注册本地文件协议 // 注册本地文件协议
protocol.registerFileProtocol('local', (request, callback) => { protocol.registerFileProtocol('local', (request, callback) => {
try { try {
const decodedUrl = decodeURIComponent(request.url); let url = request.url;
const filePath = decodedUrl.replace('local://', ''); // local://C:/Users/xxx.mp3
let filePath = decodeURIComponent(url.replace('local:///', ''));
// 兼容 local:///C:/Users/xxx.mp3 这种情况
if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
// 还原为系统路径格式
filePath = path.normalize(filePath);
// 检查文件是否存在 // 检查文件是否存在
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
console.error('File not found:', filePath); console.error('File not found:', filePath);
@@ -53,6 +68,31 @@ export function initializeFileManager() {
} }
}); });
// 检查文件是否存在
ipcMain.handle('check-file-exists', (_, filePath) => {
try {
return fs.existsSync(filePath);
} catch (error) {
console.error('Error checking if file exists:', error);
return false;
}
});
// 获取支持的音频格式列表
ipcMain.handle('get-supported-audio-formats', () => {
return {
formats: [
{ ext: 'mp3', name: 'MP3' },
{ ext: 'm4a', name: 'M4A/AAC' },
{ ext: 'flac', name: 'FLAC' },
{ ext: 'wav', name: 'WAV' },
{ ext: 'ogg', name: 'OGG Vorbis' },
{ ext: 'aac', name: 'AAC' }
],
default: 'mp3'
};
});
// 通用的选择目录处理 // 通用的选择目录处理
ipcMain.handle('select-directory', async () => { ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
@@ -84,6 +124,23 @@ export function initializeFileManager() {
} }
}); });
// 获取默认下载路径
ipcMain.handle('get-downloads-path', () => {
return app.getPath('downloads');
});
// 获取存储的配置值
ipcMain.handle('get-store-value', (_, key) => {
const store = new Store();
return store.get(key);
});
// 设置存储的配置值
ipcMain.on('set-store-value', (_, key, value) => {
const store = new Store();
store.set(key, value);
});
// 下载音乐处理 // 下载音乐处理
ipcMain.on('download-music', handleDownloadRequest); ipcMain.on('download-music', handleDownloadRequest);
@@ -122,20 +179,37 @@ export function initializeFileManager() {
}); });
// 获取已下载音乐列表 // 获取已下载音乐列表
ipcMain.handle('get-downloaded-music', () => { ipcMain.handle('get-downloaded-music', async () => {
try { try {
const store = new Store(); const store = new Store();
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>; const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
// 过滤出实际存在的文件 // 异步处理文件存在性检查
const validSongs = Object.entries(songInfos) const entriesArray = Object.entries(songInfos);
.filter(([path]) => fs.existsSync(path)) const validEntriesPromises = await Promise.all(
.map(([_, info]) => info) entriesArray.map(async ([path, info]) => {
try {
const exists = await fs.promises.access(path)
.then(() => true)
.catch(() => false);
return exists ? info : null;
} catch (error) {
console.error('Error checking file existence:', error);
return null;
}
})
);
// 过滤有效的歌曲并排序
const validSongs = validEntriesPromises
.filter(song => song !== null)
.sort((a, b) => (b.downloadTime || 0) - (a.downloadTime || 0)); .sort((a, b) => (b.downloadTime || 0) - (a.downloadTime || 0));
// 更新存储,移除不存在的文件记录 // 更新存储,移除不存在的文件记录
const newSongInfos = validSongs.reduce((acc, song) => { const newSongInfos = validSongs.reduce((acc, song) => {
acc[song.path] = song; if (song && song.path) {
acc[song.path] = song;
}
return acc; return acc;
}, {}); }, {});
store.set('downloadedSongs', newSongInfos); store.set('downloadedSongs', newSongInfos);
@@ -175,6 +249,13 @@ export function initializeFileManager() {
downloadStore.set('history', []); downloadStore.set('history', []);
}); });
// 添加清除已下载音乐记录的处理函数
ipcMain.handle('clear-downloaded-music', () => {
const store = new Store();
store.set('downloadedSongs', {});
return true;
});
// 添加清除音频缓存的处理函数 // 添加清除音频缓存的处理函数
ipcMain.on('clear-audio-cache', () => { ipcMain.on('clear-audio-cache', () => {
audioCacheStore.set('cache', {}); audioCacheStore.set('cache', {});
@@ -287,6 +368,7 @@ async function downloadMusic(
) { ) {
let finalFilePath = ''; let finalFilePath = '';
let writer: fs.WriteStream | null = null; let writer: fs.WriteStream | null = null;
let tempFilePath = '';
try { try {
// 使用配置Store来获取设置 // 使用配置Store来获取设置
@@ -294,29 +376,44 @@ async function downloadMusic(
const downloadPath = const downloadPath =
(configStore.get('set.downloadPath') as string) || app.getPath('downloads'); (configStore.get('set.downloadPath') as string) || app.getPath('downloads');
const apiPort = configStore.get('set.musicApiPort') || 30488; const apiPort = configStore.get('set.musicApiPort') || 30488;
// 获取文件名格式设置
const nameFormat =
(configStore.get('set.downloadNameFormat') as string) || '{songName} - {artistName}';
// 根据格式创建文件名
let formattedFilename = filename;
if (songInfo) {
// 准备替换变量
const artistName = songInfo.ar?.map((a: any) => a.name).join('/') || '未知艺术家';
const songName = songInfo.name || filename;
const albumName = songInfo.al?.name || '未知专辑';
// 应用自定义格式
formattedFilename = nameFormat
.replace(/\{songName\}/g, songName)
.replace(/\{artistName\}/g, artistName)
.replace(/\{albumName\}/g, albumName);
}
// 清理文件名中的非法字符 // 清理文件名中的非法字符
const sanitizedFilename = sanitizeFilename(filename); const sanitizedFilename = sanitizeFilename(formattedFilename);
// 从URL中获取文件扩展名如果没有则使用传入的type或默认mp3 // 创建临时文件路径 (在系统临时目录中创建)
const urlExt = type ? `.${type}` : '.mp3'; const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp');
const filePath = path.join(downloadPath, `${sanitizedFilename}${urlExt}`);
// 确保临时目录存在
// 检查文件是否已存在,如果存在则添加序号 if (!fs.existsSync(tempDir)) {
finalFilePath = filePath; fs.mkdirSync(tempDir, { recursive: true });
let counter = 1;
while (fs.existsSync(finalFilePath)) {
const ext = path.extname(filePath);
const nameWithoutExt = filePath.slice(0, -ext.length);
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
counter++;
} }
tempFilePath = path.join(tempDir, `${Date.now()}_${sanitizedFilename}.tmp`);
// 先获取文件大小 // 先获取文件大小
const headResponse = await axios.head(url); const headResponse = await axios.head(url);
const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10); const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10);
// 开始下载 // 开始下载到临时文件
const response = await axios({ const response = await axios({
url, url,
method: 'GET', method: 'GET',
@@ -326,7 +423,7 @@ async function downloadMusic(
httpsAgent: new https.Agent({ keepAlive: true }) httpsAgent: new https.Agent({ keepAlive: true })
}); });
writer = fs.createWriteStream(finalFilePath); writer = fs.createWriteStream(tempFilePath);
let downloadedSize = 0; let downloadedSize = 0;
// 使用 data 事件来跟踪下载进度 // 使用 data 事件来跟踪下载进度
@@ -338,7 +435,7 @@ async function downloadMusic(
progress, progress,
loaded: downloadedSize, loaded: downloadedSize,
total: totalSize, total: totalSize,
path: finalFilePath, path: tempFilePath,
status: progress === 100 ? 'completed' : 'downloading', status: progress === 100 ? 'completed' : 'downloading',
songInfo: songInfo || { songInfo: songInfo || {
name: filename, name: filename,
@@ -356,11 +453,77 @@ async function downloadMusic(
}); });
// 验证文件是否完整下载 // 验证文件是否完整下载
const stats = fs.statSync(finalFilePath); const stats = fs.statSync(tempFilePath);
if (stats.size !== totalSize) { if (stats.size !== totalSize) {
throw new Error('文件下载不完整'); throw new Error('文件下载不完整');
} }
// 检测文件类型
let fileExtension = '';
try {
// 首先尝试使用file-type库检测
const fileType = await fileTypeFromFile(tempFilePath);
if (fileType && fileType.ext) {
fileExtension = `.${fileType.ext}`;
console.log(`文件类型检测结果: ${fileType.mime}, 扩展名: ${fileExtension}`);
} else {
// 如果file-type无法识别尝试使用music-metadata
const metadata = await mm.parseFile(tempFilePath);
if (metadata && metadata.format) {
// 根据format.container或codec判断扩展名
const formatInfo = metadata.format;
const container = formatInfo.container || '';
const codec = formatInfo.codec || '';
// 音频格式映射表
const formatMap = {
'mp3': ['MPEG', 'MP3', 'mp3'],
'aac': ['AAC'],
'flac': ['FLAC'],
'ogg': ['Ogg', 'Vorbis'],
'wav': ['WAV', 'PCM'],
'm4a': ['M4A', 'MP4']
};
// 查找匹配的格式
const format = Object.entries(formatMap).find(([_, keywords]) =>
keywords.some(keyword => container.includes(keyword) || codec.includes(keyword))
);
// 设置文件扩展名如果没找到则默认为mp3
fileExtension = format ? `.${format[0]}` : '.mp3';
console.log(`music-metadata检测结果: 容器:${container}, 编码:${codec}, 扩展名: ${fileExtension}`);
} else {
// 两种方法都失败使用传入的type或默认mp3
fileExtension = type ? `.${type}` : '.mp3';
console.log(`无法检测文件类型,使用默认扩展名: ${fileExtension}`);
}
}
} catch (err) {
console.error('检测文件类型失败:', err);
// 检测失败使用传入的type或默认mp3
fileExtension = type ? `.${type}` : '.mp3';
}
// 使用检测到的文件扩展名创建最终文件路径
const filePath = path.join(downloadPath, `${sanitizedFilename}${fileExtension}`);
// 检查文件是否已存在,如果存在则添加序号
finalFilePath = filePath;
let counter = 1;
while (fs.existsSync(finalFilePath)) {
const ext = path.extname(filePath);
const nameWithoutExt = filePath.slice(0, -ext.length);
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
counter++;
}
// 将临时文件移动到最终位置
fs.copyFileSync(tempFilePath, finalFilePath);
fs.unlinkSync(tempFilePath); // 删除临时文件
// 下载歌词 // 下载歌词
let lyricData = null; let lyricData = null;
let lyricsContent = ''; let lyricsContent = '';
@@ -389,8 +552,7 @@ async function downloadMusic(
} }
} }
// 不再单独写入歌词文件只保存在ID3标签中 console.log('歌词已准备好,将写入元数据');
console.log('歌词已准备好将写入ID3标签');
} }
} }
} catch (lyricError) { } catch (lyricError) {
@@ -413,9 +575,7 @@ async function downloadMusic(
// 获取封面图片的buffer // 获取封面图片的buffer
coverImageBuffer = Buffer.from(coverResponse.data); coverImageBuffer = Buffer.from(coverResponse.data);
console.log('封面已准备好,将写入元数据');
// 不再单独保存封面文件只保存在ID3标签中
console.log('封面已准备好将写入ID3标签');
} }
} }
} catch (coverError) { } catch (coverError) {
@@ -423,54 +583,58 @@ async function downloadMusic(
// 继续处理,不影响音乐下载 // 继续处理,不影响音乐下载
} }
// 在写入ID3标签前先移除可能存在的旧标签 const fileFormat = fileExtension.toLowerCase();
try {
NodeID3.removeTags(finalFilePath);
} catch (err) {
console.error('Error removing existing ID3 tags:', err);
}
// 强化ID3标签的写入格式
const artistNames = const artistNames =
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家'; (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 (['.mp3'].includes(fileFormat)) {
if (!success) { // 对MP3文件使用NodeID3处理ID3标签
console.error('Failed to write ID3 tags'); try {
} else { // 在写入ID3标签前先移除可能存在的旧标签
console.log('ID3 tags written successfully'); NodeID3.removeTags(finalFilePath);
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
};
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);
} }
} catch (err) { } else {
console.error('Error writing ID3 tags:', err); // 对于非MP3文件使用music-metadata来写入元数据可能需要专门的库
// 或者根据不同文件类型使用专用工具,暂时只记录但不处理
console.log(`文件类型 ${fileFormat} 不支持使用NodeID3写入标签跳过元数据写入`);
} }
// 保存下载信息 // 保存下载信息
@@ -495,7 +659,7 @@ async function downloadMusic(
size: totalSize, size: totalSize,
path: finalFilePath, path: finalFilePath,
downloadTime: Date.now(), downloadTime: Date.now(),
type: type || 'mp3', type: fileExtension.substring(1), // 去掉前面的点号,只保留扩展名
lyric: lyricData lyric: lyricData
}; };
@@ -508,27 +672,38 @@ async function downloadMusic(
history.unshift(newSongInfo); history.unshift(newSongInfo);
downloadStore.set('history', history); downloadStore.set('history', history);
// 发送桌面通知 // 避免重复发送通知
try { const notificationId = `download-${finalFilePath}`;
const artistNames = if (!sentNotifications.has(notificationId)) {
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/') || sentNotifications.set(notificationId, true);
'未知艺术家';
const notification = new Notification({ // 发送桌面通知
title: '下载完成', try {
body: `${songInfo?.name || filename} - ${artistNames}`, const artistNames =
silent: false (songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/') ||
}); '未知艺术家';
const notification = new Notification({
notification.on('click', () => { title: '下载完成',
shell.showItemInFolder(finalFilePath); body: `${songInfo?.name || filename} - ${artistNames}`,
}); silent: false
});
notification.show();
} catch (notifyError) { notification.on('click', () => {
console.error('发送通知失败:', notifyError); shell.showItemInFolder(finalFilePath);
});
notification.show();
// 60秒后清理通知记录释放内存
setTimeout(() => {
sentNotifications.delete(notificationId);
}, 60000);
} catch (notifyError) {
console.error('发送通知失败:', notifyError);
}
} }
// 发送下载完成事件 // 发送下载完成事件,确保只发送一次
event.reply('music-download-complete', { event.reply('music-download-complete', {
success: true, success: true,
path: finalFilePath, path: finalFilePath,
@@ -547,6 +722,17 @@ async function downloadMusic(
if (writer) { if (writer) {
writer.end(); writer.end();
} }
// 清理临时文件
if (tempFilePath && fs.existsSync(tempFilePath)) {
try {
fs.unlinkSync(tempFilePath);
} catch (e) {
console.error('Failed to delete temporary file:', e);
}
}
// 清理未完成的最终文件
if (finalFilePath && fs.existsSync(finalFilePath)) { if (finalFilePath && fs.existsSync(finalFilePath)) {
try { try {
fs.unlinkSync(finalFilePath); fs.unlinkSync(finalFilePath);

View File

@@ -0,0 +1,231 @@
import { ipcMain } from 'electron';
import express from 'express';
import cors from 'cors';
import os from 'os';
import { getStore } from './config';
import path from 'path';
import fs from 'fs';
// 定义远程控制相关接口
export interface RemoteControlConfig {
enabled: boolean;
port: number;
allowedIps: string[];
}
// 默认配置
export const defaultRemoteControlConfig: RemoteControlConfig = {
enabled: false,
port: 31888,
allowedIps: []
};
let app: express.Application | null = null;
let server: any = null;
let mainWindowRef: Electron.BrowserWindow | null = null;
let currentSong: any = null;
let isPlaying: boolean = false;
// 获取本地IP地址
function getLocalIpAddresses(): string[] {
const interfaces = os.networkInterfaces();
const addresses: string[] = [];
for (const key in interfaces) {
const iface = interfaces[key];
if (iface) {
for (const alias of iface) {
if (alias.family === 'IPv4' && !alias.internal) {
addresses.push(alias.address);
}
}
}
}
return addresses;
}
// 初始化远程控制服务
export function initializeRemoteControl(mainWindow: Electron.BrowserWindow) {
mainWindowRef = mainWindow;
const store = getStore() as any;
let config = store.get('remoteControl') as RemoteControlConfig;
// 如果配置不存在,使用默认配置
if (!config) {
config = defaultRemoteControlConfig;
store.set('remoteControl', config);
}
// 监听当前歌曲变化
ipcMain.on('update-current-song', (_, song: any) => {
currentSong = song;
});
// 监听播放状态变化
ipcMain.on('update-play-state', (_, playing: boolean) => {
isPlaying = playing;
});
// 监听远程控制配置变化
ipcMain.on('update-remote-control-config', (_, newConfig: RemoteControlConfig) => {
if (server) {
stopServer();
}
store.set('remoteControl', newConfig);
if (newConfig.enabled) {
startServer(newConfig);
}
});
// 获取远程控制配置
ipcMain.handle('get-remote-control-config', () => {
const config = store.get('remoteControl') as RemoteControlConfig;
return config || defaultRemoteControlConfig;
});
// 获取本地IP地址
ipcMain.handle('get-local-ip-addresses', () => {
return getLocalIpAddresses();
});
// 如果启用了远程控制,启动服务器
if (config.enabled) {
startServer(config);
}
}
// 启动远程控制服务器
function startServer(config: RemoteControlConfig) {
if (!mainWindowRef) {
console.error('主窗口未初始化,无法启动远程控制服务');
return;
}
app = express();
// 跨域配置
app.use(cors());
app.use(express.json());
// IP 过滤中间件
app.use((req, res, next) => {
const clientIp = req.ip || req.socket.remoteAddress || '';
const cleanIp = clientIp.replace(/^::ffff:/, ''); // 移除IPv6前缀
console.log('config',config)
if (config.allowedIps.length === 0 || config.allowedIps.includes(cleanIp)) {
next();
} else {
res.status(403).json({ error: '未授权的IP地址' });
}
});
// 路由配置
setupRoutes(app);
// 启动服务器
try {
server = app.listen(config.port, () => {
console.log(`远程控制服务已启动,监听端口: ${config.port}`);
});
} catch (error) {
console.error('启动远程控制服务失败:', error);
}
}
// 停止远程控制服务器
function stopServer() {
if (server) {
server.close();
server = null;
app = null;
console.log('远程控制服务已停止');
}
}
// 设置路由
function setupRoutes(app: express.Application) {
// 获取当前播放状态
app.get('/api/status', (_, res) => {
res.json({
isPlaying,
currentSong
});
});
// 播放/暂停
app.post('/api/toggle-play', (_, res) => {
if (!mainWindowRef) {
return res.status(500).json({ error: '主窗口未初始化' });
}
mainWindowRef.webContents.send('global-shortcut', 'togglePlay');
res.json({ success: true, message: '已发送播放/暂停指令' });
});
// 上一首
app.post('/api/prev', (_, res) => {
if (!mainWindowRef) {
return res.status(500).json({ error: '主窗口未初始化' });
}
mainWindowRef.webContents.send('global-shortcut', 'prevPlay');
res.json({ success: true, message: '已发送上一首指令' });
});
// 下一首
app.post('/api/next', (_, res) => {
if (!mainWindowRef) {
return res.status(500).json({ error: '主窗口未初始化' });
}
mainWindowRef.webContents.send('global-shortcut', 'nextPlay');
res.json({ success: true, message: '已发送下一首指令' });
});
// 音量加
app.post('/api/volume-up', (_, res) => {
if (!mainWindowRef) {
return res.status(500).json({ error: '主窗口未初始化' });
}
mainWindowRef.webContents.send('global-shortcut', 'volumeUp');
res.json({ success: true, message: '已发送音量加指令' });
});
// 音量减
app.post('/api/volume-down', (_, res) => {
if (!mainWindowRef) {
return res.status(500).json({ error: '主窗口未初始化' });
}
mainWindowRef.webContents.send('global-shortcut', 'volumeDown');
res.json({ success: true, message: '已发送音量减指令' });
});
// 收藏/取消收藏
app.post('/api/toggle-favorite', (_, res) => {
if (!mainWindowRef) {
return res.status(500).json({ error: '主窗口未初始化' });
}
mainWindowRef.webContents.send('global-shortcut', 'toggleFavorite');
res.json({ success: true, message: '已发送收藏/取消收藏指令' });
});
// 提供远程控制界面HTML
app.get('/', (_, res) => {
try {
const resourcesPath = process.resourcesPath || '';
const isDev = process.env.NODE_ENV === 'development';
const htmlPath = path.join(process.cwd(), 'resources', 'html', 'remote-control.html');
const finalPath = isDev ? htmlPath : path.join(resourcesPath, 'html', 'remote-control.html');
if (fs.existsSync(finalPath)) {
res.sendFile(finalPath);
} else {
res.status(404).send('远程控制界面文件未找到');
console.error('远程控制界面文件不存在:', finalPath);
}
} catch (error) {
console.error('加载远程控制界面失败:', error);
res.status(500).send('加载远程控制界面失败');
}
});
}

View File

@@ -11,6 +11,7 @@ import { join } from 'path';
import type { Language } from '../../i18n/main'; import type { Language } from '../../i18n/main';
import i18n from '../../i18n/main'; import i18n from '../../i18n/main';
import { getStore } from './config';
// 歌曲信息接口定义 // 歌曲信息接口定义
interface SongInfo { interface SongInfo {
@@ -174,6 +175,18 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
}) })
); );
// 收藏
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.favorite'),
type: 'normal',
click: () => {
console.log('[Tray] 发送收藏命令 - macOS菜单');
mainWindow.webContents.send('global-shortcut', 'toggleFavorite');
}
})
);
menu.append( menu.append(
new MenuItem({ new MenuItem({
label: i18n.global.t('common.tray.next'), label: i18n.global.t('common.tray.next'),
@@ -253,6 +266,14 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
mainWindow.show(); mainWindow.show();
} }
}, },
{
label: i18n.global.t('common.tray.favorite'),
type: 'normal',
click: () => {
console.log('[Tray] 发送收藏命令 - Windows/Linux菜单');
mainWindow.webContents.send('global-shortcut', 'toggleFavorite');
}
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: i18n.global.t('common.tray.prev'), label: i18n.global.t('common.tray.prev'),
@@ -307,7 +328,8 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
// 初始化状态栏Tray // 初始化状态栏Tray
function initializeStatusBarTray(mainWindow: BrowserWindow) { function initializeStatusBarTray(mainWindow: BrowserWindow) {
if (process.platform !== 'darwin') return; const store = getStore()
if (process.platform !== 'darwin' || !store.get('set.showTopAction')) return;
const iconSize = getProperIconSize(); const iconSize = getProperIconSize();

View File

@@ -0,0 +1,700 @@
import { app, BrowserWindow, ipcMain, screen } from 'electron';
import Store from 'electron-store';
const store = new Store();
// 默认窗口尺寸
export const DEFAULT_MAIN_WIDTH = 1200;
export const DEFAULT_MAIN_HEIGHT = 780;
export const DEFAULT_MINI_WIDTH = 340;
export const DEFAULT_MINI_HEIGHT = 64;
export const DEFAULT_MINI_EXPANDED_HEIGHT = 400;
// 用于存储窗口状态的键名
export const WINDOW_STATE_KEY = 'windowState';
// 最小窗口尺寸
let MIN_WIDTH = Math.round(DEFAULT_MAIN_WIDTH * 0.5);
let MIN_HEIGHT = Math.round(DEFAULT_MAIN_HEIGHT * 0.5);
// 标记IPC处理程序是否已注册
let ipcHandlersRegistered = false;
/**
* 窗口状态类型定义
*/
export interface WindowState {
width: number;
height: number;
x?: number;
y?: number;
isMaximized: boolean;
}
/**
* 窗口大小管理器
* 负责保存、恢复和维护窗口大小状态
*/
class WindowSizeManager {
private store: Store;
private mainWindow: BrowserWindow | null = null;
private savedState: WindowState | null = null;
private isInitialized: boolean = false;
constructor() {
this.store = store;
// 初始化时不做与screen相关的操作等app ready后再初始化
}
/**
* 初始化窗口大小管理器
* 必须在app ready后调用
*/
initialize(): void {
if (!app.isReady()) {
console.warn('WindowSizeManager.initialize() 必须在 app ready 之后调用!');
return;
}
if (this.isInitialized) {
return;
}
this.initMinimumWindowSize();
this.setupIPCHandlers();
this.isInitialized = true;
console.log('窗口大小管理器初始化完成');
}
/**
* 设置主窗口引用
*/
setMainWindow(win: BrowserWindow): void {
if (!this.isInitialized) {
this.initialize();
}
this.mainWindow = win;
// 读取保存的状态
this.savedState = this.getWindowState();
// 监听重要事件
this.setupEventListeners(win);
// 立即保存初始状态
this.saveWindowState(win);
}
/**
* 初始化最小窗口尺寸
*/
private initMinimumWindowSize(): void {
if (!app.isReady()) {
console.warn('不能在 app ready 之前访问 screen 模块');
return;
}
try {
const { width: workAreaWidth, height: workAreaHeight } = screen.getPrimaryDisplay().workArea;
// 根据工作区大小设置合理的最小尺寸
MIN_WIDTH = Math.min(Math.round(DEFAULT_MAIN_WIDTH * 0.5), Math.round(workAreaWidth * 0.3));
MIN_HEIGHT = Math.min(Math.round(DEFAULT_MAIN_HEIGHT * 0.5), Math.round(workAreaHeight * 0.3));
console.log(`设置最小窗口尺寸: ${MIN_WIDTH}x${MIN_HEIGHT}`);
} catch (error) {
console.error('初始化最小窗口尺寸失败:', error);
// 使用默认值
MIN_WIDTH = Math.round(DEFAULT_MAIN_WIDTH * 0.5);
MIN_HEIGHT = Math.round(DEFAULT_MAIN_HEIGHT * 0.5);
}
}
/**
* 设置事件监听器
*/
private setupEventListeners(win: BrowserWindow): void {
// 监听窗口大小调整事件
win.on('resize', () => {
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
}
});
// 监听窗口移动事件
win.on('move', () => {
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
}
});
// 监听窗口最大化事件
win.on('maximize', () => {
if (!win.isDestroyed()) {
this.saveWindowState(win);
}
});
// 监听窗口从最大化恢复事件
win.on('unmaximize', () => {
if (!win.isDestroyed()) {
this.saveWindowState(win);
}
});
// 监听窗口关闭事件,确保保存最终状态
win.on('close', () => {
if (!win.isDestroyed()) {
this.saveWindowState(win);
}
});
// 在页面加载完成后确保窗口大小正确
win.webContents.on('did-finish-load', () => {
this.enforceCorrectSize(win);
});
// 在窗口准备好显示时确保尺寸正确
win.on('ready-to-show', () => {
this.enforceCorrectSize(win);
});
}
/**
* 强制应用正确的窗口大小
*/
private enforceCorrectSize(win: BrowserWindow): void {
if (!this.savedState || win.isMaximized() || win.isMinimized() || win.isDestroyed()) {
return;
}
const [currentWidth, currentHeight] = win.getSize();
if (Math.abs(currentWidth - this.savedState.width) > 2 ||
Math.abs(currentHeight - this.savedState.height) > 2) {
console.log(`强制调整窗口大小: 当前=${currentWidth}x${currentHeight}, 目标=${this.savedState.width}x${this.savedState.height}`);
// 临时禁用minimum size限制
const [minWidth, minHeight] = win.getMinimumSize();
win.setMinimumSize(1, 1);
// 强制设置正确大小
win.setSize(this.savedState.width, this.savedState.height, false);
// 恢复原始minimum size
win.setMinimumSize(minWidth, minHeight);
// 验证尺寸设置是否成功
const [newWidth, newHeight] = win.getSize();
console.log(`调整后窗口大小: ${newWidth}x${newHeight}`);
// 如果调整后的大小仍然与目标不一致,尝试再次调整
if (Math.abs(newWidth - this.savedState.width) > 1 ||
Math.abs(newHeight - this.savedState.height) > 1) {
console.log(`窗口大小调整后仍不一致,将再次尝试调整`);
setTimeout(() => {
if (!win.isDestroyed() && !win.isMaximized() && !win.isMinimized()) {
win.setSize(this.savedState!.width, this.savedState!.height, false);
}
}, 50);
}
// // 开始尺寸强制执行
// this.startSizeEnforcement(win);
}
}
/**
* 开启尺寸强制执行定时器
*/
// private startSizeEnforcement(win: BrowserWindow): void {
// // 清除之前的定时器
// if (this.enforceTimer) {
// clearInterval(this.enforceTimer);
// this.enforceTimer = null;
// }
// this.enforceCount = 0;
// // 创建新的定时器每50ms检查一次窗口大小
// this.enforceTimer = setInterval(() => {
// if (this.enforceCount >= this.MAX_ENFORCE_COUNT ||
// !this.savedState ||
// win.isDestroyed() ||
// win.isMaximized() ||
// win.isMinimized()) {
// // 达到最大检查次数或不需要检查,清除定时器
// if (this.enforceTimer) {
// clearInterval(this.enforceTimer);
// this.enforceTimer = null;
// }
// return;
// }
// const [currentWidth, currentHeight] = win.getSize();
// if (Math.abs(currentWidth - this.savedState.width) > 2 ||
// Math.abs(currentHeight - this.savedState.height) > 2) {
// console.log(`[定时检查] 强制调整窗口大小: 当前=${currentWidth}x${currentHeight}, 目标=${this.savedState.width}x${this.savedState.height}`);
// // 临时禁用minimum size限制
// const [minWidth, minHeight] = win.getMinimumSize();
// win.setMinimumSize(1, 1);
// // 强制设置正确大小
// win.setSize(this.savedState.width, this.savedState.height, false);
// // 恢复原始minimum size
// win.setMinimumSize(minWidth, minHeight);
// // 验证尺寸设置是否成功
// const [newWidth, newHeight] = win.getSize();
// if (Math.abs(newWidth - this.savedState.width) <= 1 &&
// Math.abs(newHeight - this.savedState.height) <= 1) {
// console.log(`窗口大小已成功调整为目标尺寸: ${newWidth}x${newHeight}`);
// }
// }
// this.enforceCount++;
// }, 50);
// }
/**
* 获取窗口创建选项
*/
getWindowOptions(): Electron.BrowserWindowConstructorOptions {
// 确保初始化
if (!this.isInitialized && app.isReady()) {
this.initialize();
}
// 读取保存的状态
const savedState = this.getWindowState();
// 准备选项
const options: Electron.BrowserWindowConstructorOptions = {
width: savedState?.width || DEFAULT_MAIN_WIDTH,
height: savedState?.height || DEFAULT_MAIN_HEIGHT,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
show: false,
frame: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true
}
};
// 如果有保存的位置,且位置有效,则使用该位置
if (savedState?.x !== undefined && savedState?.y !== undefined && app.isReady()) {
if (this.isPositionVisible(savedState.x, savedState.y)) {
options.x = savedState.x;
options.y = savedState.y;
}
}
console.log(`窗口创建选项: 大小=${options.width}x${options.height}, 位置=(${options.x}, ${options.y})`);
return options;
}
/**
* 应用窗口初始状态
* 在窗口创建后调用
*/
applyInitialState(win: BrowserWindow): void {
const savedState = this.getWindowState();
if (!savedState) {
win.center();
return;
}
// 如果需要最大化,直接最大化
if (savedState.isMaximized) {
console.log('应用已保存的最大化状态');
win.maximize();
}
// 如果位置无效,则居中显示
else if (!app.isReady() || savedState.x === undefined || savedState.y === undefined ||
!this.isPositionVisible(savedState.x, savedState.y)) {
console.log('保存的位置无效,窗口居中显示');
win.center();
}
}
/**
* 保存窗口状态
*/
saveWindowState(win: BrowserWindow): WindowState {
// 如果窗口已销毁,则返回之前的状态或默认状态
console.log('win.isDestroyed()',win.isDestroyed())
if (win.isDestroyed()) {
return this.savedState || {
width: DEFAULT_MAIN_WIDTH,
height: DEFAULT_MAIN_HEIGHT,
isMaximized: false
};
}
const isMaximized = win.isMaximized();
let state: WindowState;
if (isMaximized) {
// 如果窗口处于最大化状态,保存最大化标志
// 由于 Electron 的限制,最大化状态下 getBounds() 可能不准确
// 所以我们尽量保留之前保存的非最大化时的大小
const currentBounds = win.getBounds();
const previousSize = this.savedState && !this.savedState.isMaximized
? { width: this.savedState.width, height: this.savedState.height }
: { width: currentBounds.width, height: currentBounds.height };
state = {
width: previousSize.width,
height: previousSize.height,
x: currentBounds.x,
y: currentBounds.y,
isMaximized: true
};
console.log('state IsMaximized',state)
}
else if (win.isMinimized()) {
// 最小化状态下不保存窗口大小,因为可能不准确
console.log('state IsMinimized',this.savedState)
return this.savedState || {
width: DEFAULT_MAIN_WIDTH,
height: DEFAULT_MAIN_HEIGHT,
isMaximized: false
};
}
else {
// 正常状态下保存当前大小和位置
const [width, height] = win.getSize();
const [x, y] = win.getPosition();
state = {
width,
height,
x,
y,
isMaximized: false
};
console.log('state IsNormal',state)
}
// 保存状态到存储
this.store.set(WINDOW_STATE_KEY, state);
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
// 更新内部状态
this.savedState = state;
console.log('state',state)
return state;
}
/**
* 获取保存的窗口状态
*/
getWindowState(): WindowState | null {
const state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
if (!state) {
console.log('未找到保存的窗口状态,将使用默认值');
return null;
}
// 验证尺寸,确保不小于最小值
const validatedState: WindowState = {
width: Math.max(MIN_WIDTH, state.width || DEFAULT_MAIN_WIDTH),
height: Math.max(MIN_HEIGHT, state.height || DEFAULT_MAIN_HEIGHT),
x: state.x,
y: state.y,
isMaximized: !!state.isMaximized
};
console.log(`读取保存的窗口状态: ${JSON.stringify(validatedState)}`);
return validatedState;
}
/**
* 检查位置是否在可见屏幕范围内
*/
isPositionVisible(x: number, y: number): boolean {
if (!app.isReady()) {
return false;
}
try {
const displays = screen.getAllDisplays();
for (const display of displays) {
const { x: screenX, y: screenY, width, height } = display.workArea;
if (
x >= screenX &&
x < screenX + width &&
y >= screenY &&
y < screenY + height
) {
return true;
}
}
} catch (error) {
console.error('检查位置可见性失败:', error);
return false;
}
return false;
}
/**
* 计算适合当前缩放比的缩放因子
*/
calculateContentZoomFactor(): number {
// 只有在 app 准备好后才能使用screen
if (!app.isReady()) {
return 1;
}
try {
// 获取系统的缩放因子
const { scaleFactor } = screen.getPrimaryDisplay();
// 缩放因子默认为1
let zoomFactor = 1;
// 只在高DPI情况下调整
if (scaleFactor > 1) {
// 自定义逻辑来根据不同的缩放比例进行调整
if (scaleFactor >= 2.5) {
// 极高缩放比例如4K屏幕用200%+缩放
zoomFactor = 0.7;
} else if (scaleFactor >= 2) {
// 高缩放比例如200%
zoomFactor = 0.8;
} else if (scaleFactor >= 1.5) {
// 中等缩放比例如150%
zoomFactor = 0.85;
} else if (scaleFactor > 1.25) {
// 略高缩放比例如125%-149%
zoomFactor = 0.9;
} else {
// 低缩放比,不做调整
zoomFactor = 1;
}
}
// 获取用户的自定义缩放设置(如果有)
const userZoomFactor = this.store.get('set.contentZoomFactor') as number | undefined;
if (userZoomFactor) {
zoomFactor = userZoomFactor;
}
return zoomFactor;
} catch (error) {
console.error('计算内容缩放因子失败:', error);
return 1;
}
}
/**
* 应用页面内容缩放
*/
applyContentZoom(win: BrowserWindow): void {
const zoomFactor = this.calculateContentZoomFactor();
win.webContents.setZoomFactor(zoomFactor);
if (app.isReady()) {
try {
console.log(`应用页面缩放因子: ${zoomFactor}, 系统缩放比: ${screen.getPrimaryDisplay().scaleFactor}`);
} catch (error) {
console.log(`应用页面缩放因子: ${zoomFactor}`);
}
} else {
console.log(`应用页面缩放因子: ${zoomFactor}`);
}
}
/**
* 初始化IPC消息处理程序
*/
setupIPCHandlers(): void {
// 防止重复注册IPC处理程序
if (ipcHandlersRegistered) {
console.log('IPC处理程序已注册跳过重复注册');
return;
}
console.log('注册窗口大小相关的IPC处理程序');
// 标记为已注册
ipcHandlersRegistered = true;
// 安全地移除已存在的处理程序(如果有)
const removeHandlerSafely = (channel: string) => {
try {
ipcMain.removeHandler(channel);
} catch (error) {
// 忽略错误,处理程序可能不存在
}
};
// 为需要使用handle方法的通道先移除已有处理程序
removeHandlerSafely('get-content-zoom');
removeHandlerSafely('get-system-scale-factor');
// 注册新的处理程序
ipcMain.on('set-content-zoom', (event, zoomFactor) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) {
win.webContents.setZoomFactor(zoomFactor);
this.store.set('set.contentZoomFactor', zoomFactor);
}
});
ipcMain.handle('get-content-zoom', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) {
return win.webContents.getZoomFactor();
}
return 1;
});
ipcMain.handle('get-system-scale-factor', () => {
if (!app.isReady()) {
return 1;
}
try {
return screen.getPrimaryDisplay().scaleFactor;
} catch (error) {
console.error('获取系统缩放因子失败:', error);
return 1;
}
});
ipcMain.on('reset-content-zoom', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) {
this.store.delete('set.contentZoomFactor');
this.applyContentZoom(win);
}
});
ipcMain.on('resize-window', (event, width, height) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) {
console.log(`接收到调整窗口大小请求: ${width}x${height}`);
// 确保尺寸不小于最小值
const adjustedWidth = Math.max(width, MIN_WIDTH);
const adjustedHeight = Math.max(height, MIN_HEIGHT);
// 设置窗口的大小
win.setSize(adjustedWidth, adjustedHeight);
console.log(`窗口大小已调整为: ${adjustedWidth}x${adjustedHeight}`);
// 保存窗口状态
this.saveWindowState(win);
}
});
ipcMain.on('resize-mini-window', (event, showPlaylist) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) {
if (showPlaylist) {
console.log(`扩大迷你窗口至 ${DEFAULT_MINI_WIDTH} x ${DEFAULT_MINI_EXPANDED_HEIGHT}`);
win.setMinimumSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_HEIGHT);
win.setMaximumSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_EXPANDED_HEIGHT);
win.setSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_EXPANDED_HEIGHT, false);
} else {
console.log(`缩小迷你窗口至 ${DEFAULT_MINI_WIDTH} x ${DEFAULT_MINI_HEIGHT}`);
win.setMaximumSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_HEIGHT);
win.setMinimumSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_HEIGHT);
win.setSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_HEIGHT, false);
}
}
});
// 只在app ready后设置显示器变化监听
if (app.isReady()) {
// 监听显示器变化事件
screen.on('display-metrics-changed', (_event, _display, changedMetrics) => {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
// 当缩放因子变化时,重新应用页面缩放
if (changedMetrics.includes('scaleFactor')) {
this.applyContentZoom(this.mainWindow);
}
// 重新初始化最小尺寸
this.initMinimumWindowSize();
}
});
}
// 监听 store 中的缩放设置变化
this.store.onDidChange('set.contentZoomFactor', () => {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.applyContentZoom(this.mainWindow);
}
});
}
}
// 创建窗口大小管理器实例
const windowSizeManager = new WindowSizeManager();
// 导出初始化函数
export const initWindowSizeManager = (): void => {
// 等待app ready后再初始化
if (app.isReady()) {
windowSizeManager.initialize();
} else {
app.on('ready', () => {
windowSizeManager.initialize();
});
}
};
// 导出实例方法
export const getWindowOptions = (): Electron.BrowserWindowConstructorOptions => {
return windowSizeManager.getWindowOptions();
};
export const applyInitialState = (win: BrowserWindow): void => {
windowSizeManager.applyInitialState(win);
};
export const saveWindowState = (win: BrowserWindow): WindowState => {
return windowSizeManager.saveWindowState(win);
};
export const getWindowState = (): WindowState | null => {
return windowSizeManager.getWindowState();
};
export const applyContentZoom = (win: BrowserWindow): void => {
windowSizeManager.applyContentZoom(win);
};
export const initWindowSizeHandlers = (mainWindow: BrowserWindow | null): void => {
// 确保app ready后再初始化
if (!app.isReady()) {
app.on('ready', () => {
if (mainWindow) {
windowSizeManager.setMainWindow(mainWindow);
}
});
} else {
if (mainWindow) {
windowSizeManager.setMainWindow(mainWindow);
}
}
};
export const calculateMinimumWindowSize = (): { minWidth: number; minHeight: number } => {
return { minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT };
};

View File

@@ -1,16 +1,32 @@
import { is } from '@electron-toolkit/utils'; import { is } from '@electron-toolkit/utils';
import { app, BrowserWindow, globalShortcut, ipcMain, screen, session, shell } from 'electron'; import { app, BrowserWindow, nativeImage, globalShortcut, ipcMain, screen, session, shell } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
import { join } from 'path'; import { join } from 'path';
import {
DEFAULT_MAIN_WIDTH,
DEFAULT_MAIN_HEIGHT,
DEFAULT_MINI_WIDTH,
DEFAULT_MINI_HEIGHT,
applyContentZoom,
saveWindowState,
applyInitialState,
initWindowSizeHandlers,
getWindowOptions,
getWindowState,
WindowState
} from './window-size';
const store = new Store(); const store = new Store();
// 保存主窗口的大小和位置 // 保存主窗口引用,以便在 activate 事件中使用
let mainWindowState = { let mainWindowInstance: BrowserWindow | null = null;
width: 1200, let isPlaying = false;
height: 780, // 保存迷你模式前的窗口状态
x: undefined as number | undefined, let preMiniModeState: WindowState = {
y: undefined as number | undefined, width: DEFAULT_MAIN_WIDTH,
height: DEFAULT_MAIN_HEIGHT,
x: undefined,
y: undefined,
isMaximized: false isMaximized: false
}; };
@@ -40,6 +56,38 @@ function initializeProxy() {
} }
} }
function setThumbarButtons(window: BrowserWindow) {
window.setThumbarButtons([
{
tooltip: 'prev',
icon: nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'prev.png')),
click() {
window.webContents.send('global-shortcut', 'prevPlay');
},
},
{
tooltip: isPlaying ? 'pause' : 'play',
icon: nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', isPlaying ? 'pause.png' : 'play.png')),
click() {
window.webContents.send('global-shortcut', 'togglePlay');
},
},
{
tooltip: 'next',
icon: nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'next.png')),
click() {
window.webContents.send('global-shortcut', 'nextPlay');
},
}
]);
}
/** /**
* 初始化窗口管理相关的IPC监听 * 初始化窗口管理相关的IPC监听
*/ */
@@ -62,6 +110,7 @@ export function initializeWindowManager() {
} else { } else {
win.maximize(); win.maximize();
} }
// 状态保存在事件监听器中处理
} }
}); });
@@ -83,26 +132,21 @@ export function initializeWindowManager() {
ipcMain.on('mini-window', (event) => { ipcMain.on('mini-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender); const win = BrowserWindow.fromWebContents(event.sender);
if (win) { if (win) {
// 保存当前窗口状态 // 保存当前窗口状态,以便之后恢复
const [width, height] = win.getSize(); preMiniModeState = saveWindowState(win);
const [x, y] = win.getPosition(); console.log('保存正常模式状态用于恢复:', JSON.stringify(preMiniModeState));
mainWindowState = {
width,
height,
x,
y,
isMaximized: win.isMaximized()
};
// 获取屏幕尺寸 // 获取屏幕工作区尺寸
const { width: screenWidth } = screen.getPrimaryDisplay().workAreaSize; const display = screen.getDisplayMatching(win.getBounds());
const { width: screenWidth, x: screenX } = display.workArea;
// 设置迷你窗口的大小和位置 // 设置迷你窗口的大小和位置
win.unmaximize(); win.unmaximize();
win.setMinimumSize(340, 64); win.setMinimumSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_HEIGHT);
win.setMaximumSize(340, 64); win.setMaximumSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_HEIGHT);
win.setSize(340, 64); win.setSize(DEFAULT_MINI_WIDTH, DEFAULT_MINI_HEIGHT, false); // 禁用动画
win.setPosition(screenWidth - 340, 20); // 将迷你窗口放在工作区的右上角,留出一些边距
win.setPosition(screenX + screenWidth - DEFAULT_MINI_WIDTH - 20, display.workArea.y + 20, false);
win.setAlwaysOnTop(true); win.setAlwaysOnTop(true);
win.setSkipTaskbar(false); win.setSkipTaskbar(false);
win.setResizable(false); win.setResizable(false);
@@ -112,6 +156,9 @@ export function initializeWindowManager() {
// 发送事件到渲染进程,通知切换到迷你模式 // 发送事件到渲染进程,通知切换到迷你模式
win.webContents.send('mini-mode', true); win.webContents.send('mini-mode', true);
// 迷你窗口使用默认的缩放比
win.webContents.setZoomFactor(1);
} }
}); });
@@ -121,21 +168,14 @@ export function initializeWindowManager() {
if (win) { if (win) {
// 恢复窗口的大小调整功能 // 恢复窗口的大小调整功能
win.setResizable(true); win.setResizable(true);
win.setMaximumSize(0, 0); win.setMaximumSize(0, 0); // 取消最大尺寸限制
// 恢复窗口的最小尺寸限制 console.log('从迷你模式恢复,使用保存的状态:', JSON.stringify(preMiniModeState));
win.setMinimumSize(1200, 780);
// 设置适当的最小尺寸
win.setMinimumSize(Math.max(DEFAULT_MAIN_WIDTH * 0.5, 600), Math.max(DEFAULT_MAIN_HEIGHT * 0.5, 400));
// 恢复窗口状态 // 恢复窗口状态
if (mainWindowState.isMaximized) {
win.maximize();
} else {
win.setSize(mainWindowState.width, mainWindowState.height);
if (mainWindowState.x !== undefined && mainWindowState.y !== undefined) {
win.setPosition(mainWindowState.x, mainWindowState.y);
}
}
win.setAlwaysOnTop(false); win.setAlwaysOnTop(false);
win.setSkipTaskbar(false); win.setSkipTaskbar(false);
@@ -144,6 +184,48 @@ export function initializeWindowManager() {
// 发送事件到渲染进程,通知退出迷你模式 // 发送事件到渲染进程,通知退出迷你模式
win.webContents.send('mini-mode', false); win.webContents.send('mini-mode', false);
// 应用保存的状态
setTimeout(() => {
// 如果有保存的位置,则应用
if (preMiniModeState.x !== undefined && preMiniModeState.y !== undefined) {
win.setPosition(preMiniModeState.x, preMiniModeState.y, false);
} else {
win.center();
}
// 使用存储的迷你模式前的状态
if (preMiniModeState.isMaximized) {
win.maximize();
} else {
// 设置正确的窗口大小
win.setSize(preMiniModeState.width, preMiniModeState.height, false);
}
// 应用页面缩放
applyContentZoom(win);
// 确保窗口大小被正确应用
setTimeout(() => {
if (!win.isDestroyed() && !win.isMaximized() && !win.isMinimized()) {
// 再次验证窗口大小
const [width, height] = win.getSize();
if (Math.abs(width - preMiniModeState.width) > 2 ||
Math.abs(height - preMiniModeState.height) > 2) {
console.log(`恢复后窗口大小不一致,再次调整: 当前=${width}x${height}, 目标=${preMiniModeState.width}x${preMiniModeState.height}`);
win.setSize(preMiniModeState.width, preMiniModeState.height, false);
}
}
}, 150);
}, 50);
}
});
ipcMain.on('update-play-state', (_, playing: boolean) => {
isPlaying = playing;
if (mainWindowInstance) {
setThumbarButtons(mainWindowInstance);
} }
}); });
@@ -152,34 +234,15 @@ export function initializeWindowManager() {
initializeProxy(); initializeProxy();
}); });
// 监听窗口大小调整事件 // 初始化窗口大小和缩放相关的IPC处理程序
ipcMain.on('resize-window', (event, width, height) => { initWindowSizeHandlers(mainWindowInstance);
const win = BrowserWindow.fromWebContents(event.sender); // 监听 macOS 下点击 Dock 图标的事件
if (win) { app.on('activate', () => {
// 设置窗口的大小 // 当应用被激活时,检查主窗口是否存在
console.log(`调整窗口大小: ${width} x ${height}`); if (mainWindowInstance && !mainWindowInstance.isDestroyed()) {
win.setSize(width, height); // 如果窗口存在但被隐藏,则显示窗口
} if (!mainWindowInstance.isVisible()) {
}); mainWindowInstance.show();
// 专门用于迷你模式下调整窗口大小的事件
ipcMain.on('resize-mini-window', (event, showPlaylist) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
if (showPlaylist) {
console.log('主进程: 扩大迷你窗口至 340 x 400');
// 调整最大尺寸限制,允许窗口变大
win.setMinimumSize(340, 64);
win.setMaximumSize(340, 400);
// 调整窗口尺寸
win.setSize(340, 400);
} else {
console.log('主进程: 缩小迷你窗口至 340 x 64');
// 强制重置尺寸限制,确保窗口可以缩小
win.setMaximumSize(340, 64);
win.setMinimumSize(340, 64);
// 调整窗口尺寸
win.setSize(340, 64);
} }
} }
}); });
@@ -189,25 +252,75 @@ export function initializeWindowManager() {
* 创建主窗口 * 创建主窗口
*/ */
export function createMainWindow(icon: Electron.NativeImage): BrowserWindow { export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
const mainWindow = new BrowserWindow({ console.log('开始创建主窗口...');
width: 1200,
height: 780, // 获取窗口创建选项
show: false, const options = getWindowOptions();
frame: false,
autoHideMenuBar: true, // 添加图标和预加载脚本
icon, options.icon = icon;
webPreferences: { options.webPreferences = {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false, sandbox: false,
contextIsolation: true, contextIsolation: true,
webSecurity: false webSecurity: false
} };
console.log(`创建窗口,使用选项: ${JSON.stringify({
width: options.width,
height: options.height,
x: options.x,
y: options.y,
minWidth: options.minWidth,
minHeight: options.minHeight
})}`);
// 创建窗口
const mainWindow = new BrowserWindow(options);
// 移除菜单
mainWindow.removeMenu();
// 应用初始状态 (例如最大化状态)
applyInitialState(mainWindow);
// 更新 preMiniModeState以便迷你模式可以正确恢复
const savedState = getWindowState();
if (savedState) {
preMiniModeState = { ...savedState };
}
mainWindow.on('show', () => {
setThumbarButtons(mainWindow);
}); });
mainWindow.setMinimumSize(1200, 780);
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
const [width, height] = mainWindow.getSize();
console.log(`窗口显示前的大小: ${width}x${height}`);
// 强制确保窗口使用正确的大小
if (savedState && !savedState.isMaximized) {
mainWindow.setSize(savedState.width, savedState.height, false);
}
// 显示窗口
mainWindow.show(); mainWindow.show();
// 应用页面内容缩放
applyContentZoom(mainWindow);
// 再次检查窗口大小是否正确应用
setTimeout(() => {
if (!mainWindow.isDestroyed() && !mainWindow.isMaximized()) {
const [currentWidth, currentHeight] = mainWindow.getSize();
if (savedState && !savedState.isMaximized) {
if (Math.abs(currentWidth - savedState.width) > 2 ||
Math.abs(currentHeight - savedState.height) > 2) {
console.log(`窗口大小不匹配,再次调整: 当前=${currentWidth}x${currentHeight}, 目标=${savedState.width}x${savedState.height}`);
mainWindow.setSize(savedState.width, savedState.height, false);
}
}
}
}, 100);
}); });
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
@@ -229,5 +342,11 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')); mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
} }
initWindowSizeHandlers(mainWindow);
// 保存主窗口引用
mainWindowInstance = mainWindow;
return mainWindow; return mainWindow;
} }

View File

@@ -5,16 +5,22 @@ import server from 'netease-cloud-music-api-alger/server';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { unblockMusic } from './unblockMusic'; import { unblockMusic, type Platform } from './unblockMusic';
const store = new Store(); const store = new Store();
if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) { if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8'); fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
} }
// 处理解锁音乐请求 // 设置音乐解析的处理程序
ipcMain.handle('unblock-music', async (_, id, data) => { ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {
return unblockMusic(id, data); try {
const result = await unblockMusic(id, songData, 1, enabledSources as Platform[]);
return result;
} catch (error) {
console.error('音乐解析失败:', error);
return { error: (error as Error).message || '未知错误' };
}
}); });
async function startMusicApi(): Promise<void> { async function startMusicApi(): Promise<void> {

View File

@@ -21,5 +21,9 @@
"downloadPath": "", "downloadPath": "",
"language": "zh-CN", "language": "zh-CN",
"alwaysShowDownloadButton": false, "alwaysShowDownloadButton": false,
"unlimitedDownload": false "unlimitedDownload": false,
"enableMusicUnblock": true,
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili", "kuwo"],
"showTopAction": false,
"contentZoomFactor": 1
} }

View File

@@ -1,11 +1,13 @@
import match from '@unblockneteasemusic/server'; import match from '@unblockneteasemusic/server';
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'youtube'; type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili';
interface SongData { interface SongData {
name: string; name: string;
artists: Array<{ name: string }>; artists: Array<{ name: string }>;
album?: { name: string }; album?: { name: string };
ar?: Array<{ name: string }>;
al?: { name: string };
} }
interface ResponseData { interface ResponseData {
@@ -27,24 +29,33 @@ interface UnblockResult {
}; };
} }
// 所有可用平台
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'kuwo', 'bilibili'];
/** /**
* 音乐解析函数 * 音乐解析函数
* @param id 歌曲ID * @param id 歌曲ID
* @param songData 歌曲信息 * @param songData 歌曲信息
* @param retryCount 重试次数 * @param retryCount 重试次数
* @param enabledPlatforms 启用的平台列表,默认为所有平台
* @returns Promise<UnblockResult> * @returns Promise<UnblockResult>
*/ */
const unblockMusic = async ( const unblockMusic = async (
id: number | string, id: number | string,
songData: SongData, songData: SongData,
retryCount = 3 retryCount = 1,
enabledPlatforms?: Platform[]
): Promise<UnblockResult> => { ): Promise<UnblockResult> => {
// 所有可用平台 // 过滤 enabledPlatforms确保只包含 ALL_PLATFORMS 中存在的平台
const platforms: Platform[] = ['migu', 'kugou', 'pyncmd', 'joox', 'kuwo', 'bilibili', 'youtube']; const filteredPlatforms = enabledPlatforms
? enabledPlatforms.filter(platform => ALL_PLATFORMS.includes(platform))
: ALL_PLATFORMS;
songData.album = songData.album || songData.al;
songData.artists = songData.artists || songData.ar;
const retry = async (attempt: number): Promise<UnblockResult> => { const retry = async (attempt: number): Promise<UnblockResult> => {
try { try {
const data = await match(parseInt(String(id), 10), platforms, songData); const data = await match(parseInt(String(id), 10), filteredPlatforms, songData);
const result: UnblockResult = { const result: UnblockResult = {
data: { data: {
data, data,
@@ -58,7 +69,7 @@ const unblockMusic = async (
} catch (err) { } catch (err) {
if (attempt < retryCount) { if (attempt < retryCount) {
// 延迟重试,每次重试增加延迟时间 // 延迟重试,每次重试增加延迟时间
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
return retry(attempt + 1); return retry(attempt + 1);
} }

View File

@@ -14,7 +14,7 @@ interface API {
openLyric: () => void; openLyric: () => void;
sendLyric: (data: any) => void; sendLyric: (data: any) => void;
sendSong: (data: any) => void; sendSong: (data: any) => void;
unblockMusic: (id: number, data: any) => Promise<any>; unblockMusic: (id: number, data: any, enabledSources?: string[]) => Promise<any>;
onLyricWindowClosed: (callback: () => void) => void; onLyricWindowClosed: (callback: () => void) => void;
startDownload: (url: string) => void; startDownload: (url: string) => void;
onDownloadProgress: (callback: (progress: number, status: string) => void) => void; onDownloadProgress: (callback: (progress: number, status: string) => void) => void;

View File

@@ -16,7 +16,7 @@ const api = {
openLyric: () => ipcRenderer.send('open-lyric'), openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data), sendLyric: (data) => ipcRenderer.send('send-lyric', data),
sendSong: (data) => ipcRenderer.send('update-current-song', data), sendSong: (data) => ipcRenderer.send('update-current-song', data),
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id), unblockMusic: (id, data, enabledSources) => ipcRenderer.invoke('unblock-music', id, data, enabledSources),
// 歌词窗口关闭事件 // 歌词窗口关闭事件
onLyricWindowClosed: (callback: () => void) => { onLyricWindowClosed: (callback: () => void) => {
ipcRenderer.on('lyric-window-closed', () => callback()); ipcRenderer.on('lyric-window-closed', () => callback());

View File

@@ -26,7 +26,7 @@ import { isElectron, isLyricWindow } from '@/utils';
import { initAudioListeners } from './hooks/MusicHook'; import { initAudioListeners } from './hooks/MusicHook';
import { isMobile } from './utils'; import { isMobile } from './utils';
import { useAppShortcuts } from './utils/appShortcuts'; import { useAppShortcuts } from './utils/appShortcuts';
import { initShortcut } from './utils/shortcut'; import { audioService } from './services/audioService';
const { locale } = useI18n(); const { locale } = useI18n();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@@ -120,8 +120,8 @@ onMounted(async () => {
window.api.sendSong(cloneDeep(playerStore.playMusic)); window.api.sendSong(cloneDeep(playerStore.playMusic));
} }
} }
// 初始化快捷键
initShortcut(); audioService.releaseOperationLock();
}); });
</script> </script>

View File

@@ -152,3 +152,32 @@ export const getBilibiliAudioUrl = async (bvid: string, cid: number): Promise<st
throw error; throw error;
} }
}; };
// 根据音乐名称搜索并直接返回音频URL
export const searchAndGetBilibiliAudioUrl = async (
keyword: string
): Promise<string> => {
try {
// 搜索B站视频取第一页第一个结果
const res = await searchBilibili({ keyword, page: 1, pagesize: 1 });
const result = res.data?.data?.result;
if (!result || result.length === 0) {
throw new Error('未找到相关B站视频');
}
const first = result[0];
const bvid = first.bvid;
// 需要获取视频详情以获得cid
const detailRes = await getBilibiliVideoDetail(bvid);
const pages = detailRes.data.pages;
if (!pages || pages.length === 0) {
throw new Error('未找到视频分P信息');
}
const cid = pages[0].cid;
// 获取音频URL
return await getBilibiliAudioUrl(bvid, cid);
} catch (error) {
console.error('根据名称搜索B站音频URL失败:', error);
throw error;
}
}

187
src/renderer/api/gdmusic.ts Normal file
View File

@@ -0,0 +1,187 @@
import axios from 'axios';
import type { MusicSourceType } from '@/type/music';
/**
* GD音乐台解析服务
*/
export interface GDMusicResponse {
url: string;
br: number;
size: number;
md5: string;
platform: string;
gain: number;
}
export interface ParsedMusicResult {
data: {
data: GDMusicResponse;
params: {
id: number;
type: string;
}
}
}
/**
* 从GD音乐台解析音乐URL
* @param id 音乐ID
* @param data 音乐数据,包含名称和艺术家信息
* @param quality 音质设置
* @param timeout 超时时间(毫秒)默认15000ms
* @returns 解析后的音乐URL及相关信息
*/
export const parseFromGDMusic = async (
id: number,
data: any,
quality: string = '999',
timeout: number = 15000
): Promise<ParsedMusicResult | null> => {
// 创建一个超时Promise
const timeoutPromise = new Promise<null>((_, reject) => {
setTimeout(() => {
reject(new Error('GD音乐台解析超时'));
}, timeout);
});
try {
// 使用Promise.race竞争主解析流程和超时
return await Promise.race([
(async () => {
// 处理不同数据结构
if (!data) {
console.error('GD音乐台解析歌曲数据为空');
throw new Error('歌曲数据为空');
}
const songName = data.name || '';
let artistNames = '';
// 处理不同的艺术家字段结构
if (data.artists && Array.isArray(data.artists)) {
artistNames = data.artists.map(artist => artist.name).join(' ');
} else if (data.ar && Array.isArray(data.ar)) {
artistNames = data.ar.map(artist => artist.name).join(' ');
} else if (data.artist) {
artistNames = typeof data.artist === 'string' ? data.artist : '';
}
const searchQuery = `${songName} ${artistNames}`.trim();
if (!searchQuery || searchQuery.length < 2) {
console.error('GD音乐台解析搜索查询过短', { name: songName, artists: artistNames });
throw new Error('搜索查询过短');
}
// 所有可用的音乐源 netease、kuwo、joox、tidal
const allSources = [
'kuwo', 'joox', 'tidal', 'netease'
] as MusicSourceType[];
console.log('GD音乐台开始搜索:', searchQuery);
// 依次尝试所有音源
for (const source of allSources) {
try {
const result = await searchAndGetUrl(source, searchQuery, quality);
if (result) {
console.log(`GD音乐台成功通过 ${result.source} 解析音乐!`);
// 返回符合原API格式的数据
return {
data: {
data: {
url: result.url.replace(/\\/g, ''),
br: parseInt(result.br, 10) * 1000 || 320000,
size: result.size || 0,
md5: '',
platform: 'gdmusic',
gain: 0
},
params: {
id: parseInt(String(id), 10),
type: 'song'
}
}
};
}
} catch (error) {
console.error(`GD音乐台 ${source} 音源解析失败:`, error);
// 该音源失败,继续尝试下一个音源
continue;
}
}
console.log('GD音乐台所有音源均解析失败');
return null;
})(),
timeoutPromise
]);
} catch (error: any) {
if (error.message === 'GD音乐台解析超时') {
console.error('GD音乐台解析超时(15秒):', error);
} else {
console.error('GD音乐台解析完全失败:', error);
}
return null;
}
};
interface GDMusicUrlResult {
url: string;
br: string;
size: number;
source: string;
}
const baseUrl = 'https://music-api.gdstudio.xyz/api.php';
/**
* 在指定音源搜索歌曲并获取URL
* @param source 音源
* @param searchQuery 搜索关键词
* @param quality 音质
* @returns 音乐URL结果
*/
async function searchAndGetUrl(
source: MusicSourceType,
searchQuery: string,
quality: string
): Promise<GDMusicUrlResult | null> {
// 1. 搜索歌曲
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=1&pages=1`;
console.log(`GD音乐台尝试音源 ${source} 搜索:`, searchUrl);
const searchResponse = await axios.get(searchUrl, { timeout: 5000 });
if (searchResponse.data && Array.isArray(searchResponse.data) && searchResponse.data.length > 0) {
const firstResult = searchResponse.data[0];
if (!firstResult || !firstResult.id) {
console.log(`GD音乐台 ${source} 搜索结果无效`);
return null;
}
const trackId = firstResult.id;
const trackSource = firstResult.source || source;
// 2. 获取歌曲URL
const songUrl = `${baseUrl}?types=url&source=${trackSource}&id=${trackId}&br=${quality}`;
console.log(`GD音乐台尝试获取 ${trackSource} 歌曲URL:`, songUrl);
const songResponse = await axios.get(songUrl, { timeout: 5000 });
if (songResponse.data && songResponse.data.url) {
return {
url: songResponse.data.url,
br: songResponse.data.br,
size: songResponse.data.size || 0,
source: trackSource
};
} else {
console.log(`GD音乐台 ${trackSource} 未返回有效URL`);
return null;
}
} else {
console.log(`GD音乐台 ${source} 搜索结果为空`);
return null;
}
}

View File

@@ -40,3 +40,8 @@ export function getListDetail(id: number | string) {
export function getAlbum(id: number | string) { export function getAlbum(id: number | string) {
return request.get('/album', { params: { id } }); return request.get('/album', { params: { id } });
} }
// 获取排行榜列表
export function getToplist() {
return request.get('/toplist');
}

View File

@@ -15,7 +15,7 @@ export function createQr(key: any) {
// 获取二维码状态 // 获取二维码状态
// /login/qr/check // /login/qr/check
export function checkQr(key: any) { export function checkQr(key: any) {
return request.get('/login/qr/check', { params: { key } }); return request.get('/login/qr/check', { params: { key, noCookie: true } });
} }
// 获取登录状态 // 获取登录状态

View File

@@ -4,6 +4,10 @@ import type { ILyric } from '@/type/lyric';
import { isElectron } from '@/utils'; import { isElectron } from '@/utils';
import request from '@/utils/request'; import request from '@/utils/request';
import requestMusic from '@/utils/request_music'; import requestMusic from '@/utils/request_music';
import { cloneDeep } from 'lodash';
import { parseFromGDMusic } from './gdmusic';
import type { SongResult } from '@/type/music';
import { searchAndGetBilibiliAudioUrl } from './bilibili';
const { addData, getData, deleteData } = musicDB; const { addData, getData, deleteData } = musicDB;
@@ -78,10 +82,73 @@ export const getMusicLrc = async (id: number) => {
} }
}; };
export const getParsingMusicUrl = (id: number, data: any) => { export const getParsingMusicUrl = async (id: number, data: SongResult) => {
if (isElectron) { const settingStore = useSettingsStore();
return window.api.unblockMusic(id, data);
// 如果禁用了音乐解析功能,则直接返回空结果
if (!settingStore.setData.enableMusicUnblock) {
return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } });
} }
// 获取音源设置,优先使用歌曲自定义音源
const songId = String(id);
const savedSource = localStorage.getItem(`song_source_${songId}`);
let enabledSources: any[] = [];
// 如果有歌曲自定义音源,使用自定义音源
if (savedSource) {
try {
enabledSources = JSON.parse(savedSource);
console.log(`使用歌曲 ${id} 自定义音源:`, enabledSources);
if(enabledSources.includes('bilibili')){
// 构建搜索关键词,依次判断歌曲名称、歌手名称和专辑名称是否存在
const songName = data?.name || '';
const artistName = Array.isArray(data?.ar) && data.ar.length > 0 && data.ar[0]?.name ? data.ar[0].name : '';
const albumName = data?.al && typeof data.al === 'object' && data.al?.name ? data.al.name : '';
const name = [songName, artistName, albumName].filter(Boolean).join(' ').trim();
console.log('开始搜索bilibili音频', name);
return {
data: {
code: 200,
message: 'success',
data: {
url: await searchAndGetBilibiliAudioUrl(name)
}
}
}
}
} catch (e) {
console.error('e',e)
console.error('解析自定义音源失败, 使用全局设置', e);
enabledSources = settingStore.setData.enabledMusicSources || [];
}
} else {
// 没有自定义音源,使用全局音源设置
enabledSources = settingStore.setData.enabledMusicSources || [];
}
// 检查是否选择了GD音乐台解析
if (enabledSources.includes('gdmusic')) {
// 获取音质设置并转换为GD音乐台格式
try {
const gdResult = await parseFromGDMusic(id, data, '999');
if (gdResult) {
return gdResult;
}
} catch (error) {
console.error('GD音乐台解析失败:', error);
}
console.log('GD音乐台所有音源均解析失败尝试使用unblockMusic');
}
// 如果GD音乐台解析失败或者未启用尝试使用unblockMusic
if (isElectron) {
const filteredSources = enabledSources.filter(source => source !== 'gdmusic');
return window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources));
}
return requestMusic.get<any>('/music', { params: { id } }); return requestMusic.get<any>('/music', { params: { id } });
}; };
@@ -108,5 +175,55 @@ export const updatePlaylistTracks = (params: {
pid: number; pid: number;
tracks: string; tracks: string;
}) => { }) => {
return request.get('/playlist/tracks', { params }); return request.post('/playlist/tracks', params);
}; };
/**
* 根据类型获取列表数据
* @param type 列表类型 album/playlist
* @param id 列表ID
*/
export function getMusicListByType(type: string, id: string) {
if (type === 'album') {
return getAlbumDetail(id);
} else if (type === 'playlist') {
return getPlaylistDetail(id);
}
return Promise.reject(new Error('未知列表类型'));
}
/**
* 获取专辑详情
* @param id 专辑ID
*/
export function getAlbumDetail(id: string) {
return request({
url: '/album',
method: 'get',
params: {
id
}
});
}
/**
* 获取歌单详情
* @param id 歌单ID
*/
export function getPlaylistDetail(id: string) {
return request({
url: '/playlist/detail',
method: 'get',
params: {
id
}
});
}
export function subscribePlaylist(params: { t: number; id: number }) {
return request({
url: '/playlist/subscribe',
method: 'post',
params
});
}

View File

@@ -0,0 +1,27 @@
import request from '@/utils/request';
/**
* 歌单导入 - 元数据/文字/链接导入
* @param params 导入参数
*/
export function importPlaylist(params: {
local?: string;
text?: string;
link?: string;
importStarPlaylist?: boolean;
playlistName?: string;
}) {
return request.post('/playlist/import/name/task/create', params);
}
/**
* 歌单导入 - 任务状态
* @param id 任务ID
*/
export function getImportTaskStatus(id: string | number) {
return request({
url: '/playlist/import/task/status',
method: 'get',
params: { id }
});
}

View File

@@ -14,7 +14,11 @@ export function getUserPlaylist(uid: number, limit: number = 30, offset: number
// 播放历史 // 播放历史
// /user/record?uid=32953014&type=1 // /user/record?uid=32953014&type=1
export function getUserRecord(uid: number, type: number = 0) { export function getUserRecord(uid: number, type: number = 0) {
return request.get('/user/record', { params: { uid, type } });
return request.get('/user/record', {
params: { uid, type },
noRetry: true
} as any);
} }
// 获取用户关注列表 // 获取用户关注列表

View File

@@ -1,75 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -1,56 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCarousel: typeof import('naive-ui')['NCarousel']
NCarouselItem: typeof import('naive-ui')['NCarouselItem']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -71,12 +71,12 @@ const { t } = useI18n();
const message = useMessage(); const message = useMessage();
const copyQQ = () => { const copyQQ = () => {
navigator.clipboard.writeText('789288579'); navigator.clipboard.writeText('algermusic');
message.success('已复制到剪贴板'); message.success(t('common.copySuccess'));
}; };
const toDonateList = () => { const toDonateList = () => {
window.open('http://donate.alger.fun', '_blank'); window.open('http://donate.alger.fun/download', '_blank');
}; };
defineProps({ defineProps({

View File

@@ -1,7 +1,11 @@
<template> <template>
<div class="eq-control"> <div class="eq-control">
<div class="eq-header"> <div class="eq-header">
<h3>{{ t('player.eq.title') }}</h3> <h3>{{ t('player.eq.title') }}
<n-tag type="warning" size="small" round v-if="!isElectron">
桌面版可用网页端不支持
</n-tag>
</h3>
<div class="eq-controls"> <div class="eq-controls">
<n-switch v-model:value="isEnabled" @update:value="toggleEQ"> <n-switch v-model:value="isEnabled" @update:value="toggleEQ">
<template #checked>{{ t('player.eq.on') }}</template> <template #checked>{{ t('player.eq.on') }}</template>
@@ -52,6 +56,7 @@ import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { isElectron } from '@/utils';
const { t } = useI18n(); const { t } = useI18n();
@@ -264,7 +269,6 @@ const formatFreq = (freq: number) => {
.eq-control { .eq-control {
@apply p-6 rounded-lg; @apply p-6 rounded-lg;
@apply bg-light dark:bg-dark; @apply bg-light dark:bg-dark;
@apply shadow-lg dark:shadow-none;
width: 100%; width: 100%;
max-width: 700px; max-width: 700px;

View File

@@ -193,7 +193,6 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { getMvUrl } from '@/api/mv'; import { getMvUrl } from '@/api/mv';
import { usePlayerStore } from '@/store/modules/player';
import { IMvItem } from '@/type/mv'; import { IMvItem } from '@/type/mv';
const { t } = useI18n(); const { t } = useI18n();
@@ -222,7 +221,6 @@ const emit = defineEmits<{
(e: 'prev', loading: (value: boolean) => void): void; (e: 'prev', loading: (value: boolean) => void): void;
}>(); }>();
const playerStore = usePlayerStore();
const mvUrl = ref<string>(); const mvUrl = ref<string>();
const playMode = ref<PlayMode>(PLAY_MODE.Auto); const playMode = ref<PlayMode>(PLAY_MODE.Auto);
@@ -359,9 +357,6 @@ const loadMvUrl = async (mv: IMvItem) => {
const handleClose = () => { const handleClose = () => {
emit('update:show', false); emit('update:show', false);
if (playerStore.playMusicUrl) {
playerStore.setIsPlay(true);
}
}; };
const handleEnded = () => { const handleEnded = () => {

View File

@@ -1,8 +1,8 @@
<template> <template>
<transition name="shortcut-toast"> <transition name="shortcut-toast">
<div v-if="visible" class="shortcut-toast"> <div v-if="visible" class="shortcut-toast" :class="`shortcut-toast-${position}`">
<div class="shortcut-toast-content"> <div class="shortcut-toast-content">
<div class="shortcut-toast-icon"> <div v-if="showIcon" class="shortcut-toast-icon">
<i :class="icon"></i> <i :class="icon"></i>
</div> </div>
<div class="shortcut-toast-text">{{ text }}</div> <div class="shortcut-toast-text">{{ text }}</div>
@@ -14,12 +14,24 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, ref } from 'vue'; import { onBeforeUnmount, ref } from 'vue';
defineProps({
position: {
type: String,
default: 'center',
validator: (val: string) => ['top', 'center', 'bottom'].includes(val)
},
showIcon: {
type: Boolean,
default: true
}
});
const visible = ref(false); const visible = ref(false);
const text = ref(''); const text = ref('');
const icon = ref(''); const icon = ref('');
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
const show = (message: string, iconName: string) => { const show = (message: string, iconName = '') => {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
} }
@@ -54,9 +66,28 @@ defineExpose({
<style lang="scss" scoped> <style lang="scss" scoped>
.shortcut-toast { .shortcut-toast {
@apply fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[9999]; @apply fixed left-1/2 z-[9999];
@apply flex items-center justify-center; @apply flex items-center justify-center;
// 位置变体
&-center {
@apply top-1/2 -translate-y-1/2;
.shortcut-toast-content {
@apply p-2;
}
}
&-top {
@apply top-20;
}
&-bottom {
@apply bottom-40;
}
// 水平居中
@apply -translate-x-1/2;
&-content { &-content {
@apply flex flex-col items-center gap-2 p-4 rounded-lg; @apply flex flex-col items-center gap-2 p-4 rounded-lg;
@apply bg-light-200 bg-opacity-70 dark:bg-dark-200 dark:bg-opacity-90; @apply bg-light-200 bg-opacity-70 dark:bg-dark-200 dark:bg-opacity-90;

View File

@@ -1,6 +1,41 @@
<template> <template>
<div class="donation-container"> <div class="donation-container">
<div class="refresh-container"> <div class="qrcode-container">
<div class="description">
<p>{{ t('donation.description') }}</p>
<p>{{ t('donation.message') }}</p>
<n-button type="primary" @click="toDonateList">
<template #icon>
<i class="ri-cup-line"></i>
</template>
{{ t('donation.toDonateList') }}
</n-button>
</div>
<div class="qrcode-grid">
<div class="qrcode-item">
<n-image
:src="alipay"
:alt="t('common.alipay')"
class="qrcode-image"
preview-disabled
/>
<span class="qrcode-label">{{ t('common.alipay') }}</span>
</div>
<div class="qrcode-item">
<n-image
:src="wechat"
:alt="t('common.wechat')"
class="qrcode-image"
preview-disabled
/>
<span class="qrcode-label">{{ t('common.wechat') }}</span>
</div>
</div>
</div>
<div class="header-container">
<h3 class="section-title">{{ t('donation.title') }}</h3>
<n-button secondary round size="small" :loading="isLoading" @click="fetchDonors"> <n-button secondary round size="small" :loading="isLoading" @click="fetchDonors">
<template #icon> <template #icon>
<i class="ri-refresh-line"></i> <i class="ri-refresh-line"></i>
@@ -8,15 +43,13 @@
{{ t('donation.refresh') }} {{ t('donation.refresh') }}
</n-button> </n-button>
</div> </div>
<div class="donation-grid" :class="{ 'grid-expanded': isExpanded }"> <div class="donation-grid" :class="{ 'grid-expanded': isExpanded }">
<div <div
v-for="(donor, index) in displayDonors" v-for="donor in displayDonors"
:key="donor.id" :key="donor.id"
class="donation-card animate__animated" class="donation-card"
:class="getAnimationClass(index)" :class="{ 'no-message': !donor.message }"
:style="{ animationDelay: `${index * 0.1}s` }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
> >
<div class="card-content"> <div class="card-content">
<div class="donor-avatar"> <div class="donor-avatar">
@@ -24,50 +57,49 @@
:src="donor.avatar" :src="donor.avatar"
:fallback-src="defaultAvatar" :fallback-src="defaultAvatar"
round round
size="large" class="avatar-img"
class="animate__animated animate__pulse animate__infinite avatar-img"
/> />
<div class="donor-badge" :class="getBadgeClass(donor.badge)">
{{ donor.badge }}
</div>
</div> </div>
<div class="donor-info"> <div class="donor-info">
<div class="donor-name">{{ donor.name }}</div> <div class="donor-meta">
<div class="donation-meta"> <div class="donor-name">{{ donor.name }}</div>
<n-tag <!-- <div class="price-tag">{{ donor.amount }}</div> -->
:type="getAmountTagType(donor.amount)"
size="small"
class="donation-amount animate__animated"
round
bordered
>
{{ donor.amount }}
</n-tag>
<span class="donation-date">{{ donor.date }}</span>
</div> </div>
<div class="donation-date">{{ donor.date }}</div>
</div> </div>
</div> </div>
<div v-if="donor.message" class="donation-message">
<n-popover trigger="hover" placement="bottom"> <!-- 有留言的情况 -->
<template #trigger> <n-popover
<div class="message-content"> v-if="donor.message"
<i class="ri-double-quotes-l quote-icon"></i> trigger="hover"
<div class="message-text">{{ donor.message }}</div> placement="bottom"
<i class="ri-double-quotes-r quote-icon"></i> :show-arrow="true"
</div> :width="240"
</template> >
<div class="message-popup"> <template #trigger>
<div class="donation-message">
<i class="ri-double-quotes-l quote-icon"></i> <i class="ri-double-quotes-l quote-icon"></i>
{{ donor.message }} <span class="message-text">{{ donor.message }}</span>
<i class="ri-double-quotes-r quote-icon"></i> <i class="ri-double-quotes-r quote-icon"></i>
</div> </div>
</n-popover> </template>
<div class="message-popover">
<i class="ri-double-quotes-l quote-icon"></i>
<span>{{ donor.message }}</span>
<i class="ri-double-quotes-r quote-icon"></i>
</div>
</n-popover>
<!-- 没有留言的情况显示占位符 -->
<div v-else class="donation-message-placeholder">
<i class="ri-emotion-line"></i>
<span>{{ t('donation.noMessage') }}</span>
</div> </div>
<div class="card-sparkles"></div>
</div> </div>
</div> </div>
<div v-if="sortedDonors.length > 8" class="expand-button"> <div v-if="donors.length > 8" class="expand-button">
<n-button text @click="toggleExpand"> <n-button text @click="toggleExpand">
<template #icon> <template #icon>
<i :class="isExpanded ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i> <i :class="isExpanded ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i>
@@ -75,40 +107,6 @@
{{ isExpanded ? t('common.collapse') : t('common.expand') }} {{ isExpanded ? t('common.collapse') : t('common.expand') }}
</n-button> </n-button>
</div> </div>
<div class="p-6 rounded-lg shadow-lg">
<div class="description text-center text-sm text-gray-700 dark:text-gray-200">
<p>{{ t('donation.description') }}</p>
<p>{{ t('donation.message') }}</p>
</div>
<div class="flex justify-between mt-6">
<div class="flex flex-col items-center gap-2">
<n-image
:src="alipay"
:alt="t('common.alipay')"
class="w-60 h-60 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.alipay') }}</span>
</div>
<n-button type="primary" @click="toDonateList">
<template #icon>
<i class="ri-cup-line"></i>
</template>
{{ t('donation.toDonateList') }}
<i class="ri-arrow-right-s-line"></i>
</n-button>
<div class="flex flex-col items-center gap-2">
<n-image
:src="wechat"
:alt="t('common.wechat')"
class="w-60 h-60 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.wechat') }}</span>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -152,81 +150,13 @@ onActivated(() => {
fetchDonors(); fetchDonors();
}); });
// 动画类名列表
const animationClasses = [
'animate__fadeInUp',
'animate__fadeInLeft',
'animate__fadeInRight',
'animate__zoomIn'
];
// 获取随机动画类名
const getAnimationClass = (index: number) => {
return animationClasses[index % animationClasses.length];
};
// 根据金额获取标签类型
const getAmountTagType = (amount: number): 'success' | 'warning' | 'error' | 'info' => {
if (amount >= 5) return 'warning';
if (amount >= 2) return 'success';
return 'info';
};
// 获取徽章样式类名
const getBadgeClass = (badge: string): string => {
if (badge.includes('金牌')) return 'badge-gold';
if (badge.includes('银牌')) return 'badge-silver';
return 'badge-bronze';
};
// 鼠标悬停效果
const handleMouseEnter = (event: MouseEvent) => {
const card = event.currentTarget as HTMLElement;
card.style.transform = 'translateY(-2px)';
card.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.12)';
// 添加金额标签动画
const amountTag = card.querySelector('.donation-amount');
if (amountTag) {
amountTag.classList.add('animate__tada');
}
};
const handleMouseLeave = (event: MouseEvent) => {
const card = event.currentTarget as HTMLElement;
card.style.transform = 'translateY(0)';
card.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.08)';
// 移除金额标签动画
const amountTag = card.querySelector('.donation-amount');
if (amountTag) {
amountTag.classList.remove('animate__tada');
}
};
// 按金额和留言排序的捐赠列表
const sortedDonors = computed(() => {
return [...donors.value].sort((a, b) => {
// 如果一个有留言一个没有,有留言的排在前面
if (a.message && !b.message) return -1;
if (!a.message && b.message) return 1;
// 都有留言或都没有留言时,按金额从大到小排序
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) return amountDiff;
// 金额相同时,按日期从新到旧排序
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
});
const isExpanded = ref(false); const isExpanded = ref(false);
const displayDonors = computed(() => { const displayDonors = computed(() => {
if (isExpanded.value) { if (isExpanded.value) {
return sortedDonors.value; return donors.value;
} }
return sortedDonors.value.slice(0, 8); return donors.value.slice(0, 8);
}); });
const toggleExpand = () => { const toggleExpand = () => {
@@ -234,19 +164,27 @@ const toggleExpand = () => {
}; };
const toDonateList = () => { const toDonateList = () => {
window.open('http://donate.alger.fun', '_blank'); window.open('http://donate.alger.fun/download', '_blank');
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.donation-container { .donation-container {
@apply w-full overflow-hidden; @apply w-full overflow-hidden flex flex-col gap-4;
}
.header-container {
@apply flex justify-between items-center px-4 py-2;
.section-title {
@apply text-lg font-medium text-gray-700 dark:text-gray-200;
}
} }
.donation-grid { .donation-grid {
@apply grid gap-3 px-2 py-3 transition-all duration-300 overflow-hidden; @apply grid gap-3 transition-all duration-300 overflow-hidden;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
max-height: 280px; max-height: 320px;
@media (min-width: 768px) { @media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -262,127 +200,138 @@ const toDonateList = () => {
} }
.donation-card { .donation-card {
@apply relative rounded-lg p-3 min-w-0 w-full transition-all duration-500 shadow-md backdrop-blur-md; @apply rounded-lg p-2.5 transition-all duration-200 hover:shadow-md;
@apply bg-gradient-to-br from-white/[0.03] to-white/[0.08] border border-white/[0.08]; @apply bg-light-100 dark:bg-gray-800/5 backdrop-blur-sm;
@apply hover:shadow-lg; @apply border border-gray-200 dark:border-gray-700/10;
@apply flex flex-col;
min-height: 100px;
.card-content { .card-content {
@apply relative z-10 flex items-start gap-3; @apply flex items-start gap-2 mb-2;
} }
}
.donor-avatar { .donor-avatar {
@apply relative flex-shrink-0 w-10 h-9 transition-transform duration-300; @apply relative flex-shrink-0;
.avatar-img { .avatar-img {
@apply border border-white/20 dark:border-gray-800/50 shadow-sm; @apply border border-gray-200 dark:border-gray-700/10 shadow-sm;
@apply w-10 h-9; @apply w-9 h-9;
}
} }
}
.donor-badge { .donor-info {
@apply absolute -bottom-2 -right-1 px-1.5 py-0.5 text-xs font-medium text-white/90 rounded-full whitespace-nowrap; @apply flex-1 min-w-0 flex flex-col justify-center;
@apply bg-gradient-to-r from-pink-400 to-pink-500 shadow-sm opacity-90 scale-90;
@apply transition-all duration-300;
}
.donor-info {
@apply flex-1 min-w-0;
.donor-meta {
@apply flex justify-between items-center mb-0.5;
.donor-name { .donor-name {
@apply text-sm font-medium mb-0.5 truncate; @apply text-sm font-medium truncate flex-1 mr-1;
} }
.donation-meta { .price-tag {
@apply flex items-center gap-2 mb-1; @apply text-xs text-gray-400/80 dark:text-gray-500/80 whitespace-nowrap;
.donation-date {
@apply text-xs text-gray-400/80 dark:text-gray-500/80;
}
} }
} }
.donation-message { .donation-date {
@apply text-sm text-gray-600 dark:text-gray-300 leading-relaxed mt-3 w-full; @apply text-xs text-gray-400/60 dark:text-gray-500/60;
}
}
.message-content { .donation-message {
@apply relative p-2 rounded-lg transition-all duration-300 cursor-pointer; @apply text-xs text-gray-500 dark:text-gray-400 italic mt-1 px-2 py-1.5;
@apply bg-white/[0.02] hover:bg-[var(--n-color)]; @apply bg-gray-100/10 dark:bg-dark-300 rounded;
@apply flex items-start;
.message-text { @apply cursor-pointer transition-all duration-200;
@apply px-6 italic line-clamp-2;
} .quote-icon {
@apply text-gray-300 dark:text-gray-600 flex-shrink-0 opacity-60;
.quote-icon {
@apply absolute text-gray-400/60 dark:text-gray-500/60 text-sm; &:first-child {
@apply mr-1 self-start;
&:first-child {
@apply left-0.5 top-2;
}
&:last-child {
@apply right-0.5 bottom-2;
}
}
} }
&:last-child {
@apply ml-1 self-end;
}
}
.message-text {
@apply flex-1 line-clamp-2;
} }
&:hover { &:hover {
.donor-avatar { @apply bg-gray-100/40 dark:bg-dark-200;
@apply scale-105 rotate-3;
}
.donor-badge {
@apply scale-95 -translate-y-0.5;
}
.card-sparkles {
@apply opacity-60 scale-110;
}
} }
} }
.card-sparkles { .donation-message-placeholder {
@apply absolute inset-0 pointer-events-none opacity-0 transition-all duration-500; @apply text-xs text-gray-400 dark:text-gray-500 mt-1 px-2 py-1.5;
background-image: radial-gradient(2px 2px at 20px 30px, rgba(255, 255, 255, 0.95), transparent), @apply bg-gray-100/5 dark:bg-dark-300 rounded;
radial-gradient(2px 2px at 40px 70px, rgba(255, 255, 255, 0.95), transparent), @apply flex items-center justify-center gap-1 italic;
radial-gradient(2.5px 2.5px at 50px 160px, rgba(255, 255, 255, 0.95), transparent), @apply border border-transparent;
radial-gradient(2px 2px at 90px 40px, rgba(255, 255, 255, 0.95), transparent),
radial-gradient(2.5px 2.5px at 130px 80px, rgba(255, 255, 255, 0.95), transparent); i {
background-size: 200% 200%; @apply text-gray-300 dark:text-gray-600;
animation: sparkle 8s ease infinite;
}
@keyframes sparkle {
0%,
100% {
@apply bg-[0%_0%] opacity-40 scale-100;
}
50% {
@apply bg-[100%_100%] opacity-80 scale-110;
} }
} }
.refresh-container { .message-popover {
@apply flex justify-end px-2 py-2; @apply text-sm text-gray-700 dark:text-gray-200 italic p-2;
@apply flex items-start;
.quote-icon {
@apply text-gray-400 dark:text-gray-500 flex-shrink-0;
&:first-child {
@apply mr-1.5 self-start;
}
&:last-child {
@apply ml-1.5 self-end;
}
}
} }
.expand-button { .expand-button {
@apply flex justify-center items-center py-2; @apply flex justify-center items-center py-2;
:deep(.n-button) { :deep(.n-button) {
@apply transition-all duration-300 hover:-translate-y-0.5; @apply transition-all duration-200 hover:-translate-y-0.5;
} }
} }
.message-popup { .qrcode-container {
@apply relative px-4 py-2 text-sm; @apply p-5 rounded-lg shadow-sm bg-light-100 dark:bg-gray-800/5 backdrop-blur-sm border border-gray-200 dark:border-gray-700/10;
max-width: 300px;
line-height: 1.6; .description {
font-style: italic; @apply text-center text-sm text-gray-600 dark:text-gray-300 mb-4;
.quote-icon { p {
@apply text-gray-400/60 dark:text-gray-500/60; @apply mb-2;
font-size: 0.9rem; }
}
.qrcode-grid {
@apply flex justify-between items-center gap-4 flex-wrap;
.qrcode-item {
@apply flex flex-col items-center gap-2;
.qrcode-image {
@apply w-36 h-36 rounded-lg border border-gray-200 dark:border-gray-700/10 shadow-sm transition-transform duration-200 hover:scale-105;
}
.qrcode-label {
@apply text-sm text-gray-600 dark:text-gray-300;
}
}
.donate-button {
@apply flex flex-col items-center justify-center;
}
} }
} }
</style> </style>

View File

@@ -1,480 +1,34 @@
<template> <template>
<div class="download-drawer-trigger"> <div class="download-drawer-trigger">
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0"> <n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
<n-button circle @click="settingsStore.showDownloadDrawer = true"> <n-button circle @click="navigateToDownloads">
<template #icon> <template #icon>
<i class="iconfont ri-download-cloud-2-line"></i> <i class="iconfont ri-download-cloud-2-line"></i>
</template> </template>
</n-button> </n-button>
</n-badge> </n-badge>
</div> </div>
<n-drawer
v-model:show="showDrawer"
:height="'80%'"
placement="bottom"
@after-leave="handleDrawerClose"
>
<n-drawer-content :title="t('download.title')" closable :native-scrollbar="false">
<div class="drawer-container">
<n-tabs type="line" animated class="h-full">
<!-- 下载列表 -->
<n-tab-pane name="downloading" :tab="t('download.tabs.downloading')" class="h-full">
<div class="download-list">
<div v-if="downloadList.length === 0" class="empty-tip">
<n-empty :description="t('download.empty.noTasks')" />
</div>
<template v-else>
<div class="total-progress">
<div class="total-progress-text">
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
</div>
<n-progress
type="line"
:percentage="Number(totalProgress.toFixed(1))"
:height="12"
:border-radius="6"
:indicator-placement="'inside'"
/>
</div>
<div class="download-content">
<div class="download-items">
<div v-for="item in downloadList" :key="item.path" class="download-item">
<div class="download-item-content">
<div class="download-item-cover">
<n-image
:src="getImgUrl(item.songInfo?.picUrl, '200y200')"
preview-disabled
:object-fit="'cover'"
class="cover-image"
/>
</div>
<div class="download-item-info">
<div class="download-item-name" :title="item.filename">
{{ item.filename }}
</div>
<div class="download-item-artist">
{{
item.songInfo?.ar?.map((a) => a.name).join(', ') ||
t('download.artist.unknown')
}}
</div>
<div class="download-item-progress">
<n-progress
type="line"
:percentage="item.progress"
:processing="item.status === 'downloading'"
:status="getProgressStatus(item)"
:height="8"
/>
</div>
<div class="download-item-size">
<span
>{{ formatSize(item.loaded) }} / {{ formatSize(item.total) }}</span
>
</div>
</div>
<div class="download-item-status">
<n-tag :type="getStatusType(item)" size="small">
{{ getStatusText(item) }}
</n-tag>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</n-tab-pane>
<!-- 已下载列表 -->
<n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full">
<div class="downloaded-list">
<div v-if="downloadedList.length === 0" class="empty-tip">
<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">
<div class="downloaded-item-cover">
<n-image
:src="getImgUrl(item.picUrl, '200y200')"
preview-disabled
:object-fit="'cover'"
class="cover-image"
/>
</div>
<div class="downloaded-item-info">
<div class="downloaded-item-name" :title="item.filename">
{{ item.filename }}
</div>
<div class="downloaded-item-artist">
{{ item.ar?.map((a) => a.name).join(', ') }}
</div>
<div class="downloaded-item-size">{{ formatSize(item.size) }}</div>
</div>
<div class="downloaded-item-actions">
<!-- <n-button text type="primary" size="large" @click="handlePlayMusic(item)">
<template #icon>
<i class="iconfont ri-play-circle-line text-xl"></i>
</template>
</n-button> -->
<n-button
text
type="primary"
size="large"
@click="openDirectory(item.path)"
>
<template #icon>
<i class="iconfont ri-folder-open-line text-xl"></i>
</template>
</n-button>
<n-button text type="error" size="large" @click="handleDelete(item)">
<template #icon>
<i class="iconfont ri-delete-bin-line text-xl"></i>
</template>
</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>
</n-drawer-content>
</n-drawer>
<!-- 删除确认对话框 -->
<n-modal
v-model:show="showDeleteConfirm"
preset="dialog"
type="warning"
:title="t('download.delete.title')"
>
<template #header>
<div class="flex items-center">
<i class="iconfont ri-error-warning-line mr-2 text-xl"></i>
<span>{{ t('download.delete.title') }}</span>
</div>
</template>
<div class="delete-confirm-content">
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
</div>
<template #action>
<n-button size="small" @click="showDeleteConfirm = false">{{
t('download.delete.cancel')
}}</n-button>
<n-button size="small" type="warning" @click="confirmDelete">{{
t('download.delete.confirm')
}}</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> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ProgressStatus } from 'naive-ui'; import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui'; import { useRouter } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { getMusicDetail } from '@/api/music'; const router = useRouter();
// import { usePlayerStore } from '@/store/modules/player'; const downloadList = ref<any[]>([]);
import { useSettingsStore } from '@/store/modules/settings';
// import { audioService } from '@/services/audioService';
import { getImgUrl } from '@/utils';
// import { SongResult } from '@/type/music';
const { t } = useI18n();
interface DownloadItem {
filename: string;
progress: number;
loaded: number;
total: number;
path: string;
status: 'downloading' | 'completed' | 'error';
error?: string;
songInfo?: any;
}
interface DownloadedItem {
filename: string;
path: string;
size: number;
id: number;
picUrl: string;
ar: { name: string }[];
}
const message = useMessage();
// const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const showDrawer = computed({
get: () => settingsStore.showDownloadDrawer,
set: (val) => {
settingsStore.showDownloadDrawer = val;
}
});
const downloadList = ref<DownloadItem[]>([]);
const downloadedList = ref<DownloadedItem[]>(
JSON.parse(localStorage.getItem('downloadedList') || '[]')
);
const downList = computed(() => {
return (downloadedList.value as DownloadedItem[]).reverse();
});
// 计算下载中的任务数量 // 计算下载中的任务数量
const downloadingCount = computed(() => { const downloadingCount = computed(() => {
return downloadList.value.filter((item) => item.status === 'downloading').length; return downloadList.value.filter((item) => item.status === 'downloading').length;
}); });
// 计算总进度 // 导航到下载页面
const totalProgress = computed(() => { const navigateToDownloads = () => {
if (downloadList.value.length === 0) return 0; router.push('/downloads');
const total = downloadList.value.reduce((sum, item) => sum + item.progress, 0);
return total / downloadList.value.length;
});
watch(totalProgress, (newVal) => {
if (newVal === 100) {
refreshDownloadedList();
}
});
// 获取状态类型
const getStatusType = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return 'info';
case 'completed':
return 'success';
case 'error':
return 'error';
default:
return 'default';
}
}; };
// 获取状态文本
const getStatusText = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return t('download.status.downloading');
case 'completed':
return t('download.status.completed');
case 'error':
return t('download.status.failed');
default:
return t('download.status.unknown');
}
};
// 获取进度条状态
const getProgressStatus = (item: DownloadItem): ProgressStatus => {
switch (item.status) {
case 'completed':
return 'success';
case 'error':
return 'error';
default:
return 'info';
}
};
// 格式化文件大小
const formatSize = (bytes: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
// 打开目录
const openDirectory = (path: string) => {
window.electron.ipcRenderer.send('open-directory', path);
};
// 删除相关
const showDeleteConfirm = ref(false);
const itemToDelete = ref<DownloadedItem | null>(null);
// 处理删除点击
const handleDelete = (item: DownloadedItem) => {
itemToDelete.value = item;
showDeleteConfirm.value = true;
};
// 确认删除
const confirmDelete = async () => {
if (!itemToDelete.value) return;
try {
const success = await window.electron.ipcRenderer.invoke(
'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) {
message.success(t('download.delete.success'));
} else {
message.warning(t('download.delete.fileNotFound'));
}
} catch (error) {
console.error('Failed to delete music:', error);
// 即使删除文件出错,也从记录中移除
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);
// playerStore.setPlayMusic(true);
// playerStore.setIsPlay(true);
// };
// 获取已下载音乐列表
const refreshDownloadedList = async () => {
try {
let saveList: any = [];
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
if (!Array.isArray(list) || list.length === 0) {
saveList = [];
return;
}
const songIds = list.filter((item) => item.id).map((item) => item.id);
// 如果有歌曲ID获取详细信息
if (songIds.length > 0) {
try {
const detailRes = await getMusicDetail(songIds);
const songDetails = detailRes.data.songs.reduce((acc, song) => {
acc[song.id] = song;
return acc;
}, {});
saveList = list.map((item) => {
const songDetail = songDetails[item.id];
return {
...item,
picUrl: songDetail?.al?.picUrl || item.picUrl || '/images/default_cover.png',
ar: songDetail?.ar || item.ar || [{ name: t('download.localMusic') }]
};
});
} catch (detailError) {
console.error('Failed to get music details:', detailError);
saveList = list;
}
} else {
saveList = list;
}
setLocalDownloadedList(saveList);
} catch (error) {
console.error('Failed to get downloaded music list:', error);
downloadedList.value = [];
}
};
const setLocalDownloadedList = (list: DownloadedItem[]) => {
const localList = localStorage.getItem('downloadedList');
// 合并 去重
const saveList = [...(localList ? JSON.parse(localList) : []), ...list];
const uniqueList = saveList.filter(
(item, index, self) => index === self.findIndex((t) => t.id === item.id)
);
localStorage.setItem('downloadedList', JSON.stringify(uniqueList));
downloadedList.value = uniqueList;
};
// 监听抽屉显示状态
watch(
() => showDrawer.value,
(newVal) => {
if (newVal) {
refreshDownloadedList();
}
}
);
// 监听下载进度 // 监听下载进度
onMounted(() => { onMounted(() => {
refreshDownloadedList();
// 监听下载进度 // 监听下载进度
window.electron.ipcRenderer.on('music-download-progress', (_, data) => { window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename); const existingItem = downloadList.value.find((item) => item.filename === data.filename);
@@ -503,15 +57,11 @@ onMounted(() => {
}); });
// 监听下载完成 // 监听下载完成
window.electron.ipcRenderer.on('music-download-complete', (_, data) => { window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
if (data.success) { if (data.success) {
// 从下载列表中移除 downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
// 刷新已下载列表
refreshDownloadedList();
message.success(t('download.message.downloadComplete', { filename: data.filename }));
} else { } else {
const existingItem = downloadList.value.find((item) => item.filename === data.filename); const existingItem = downloadList.value.find(item => item.filename === data.filename);
if (existingItem) { if (existingItem) {
Object.assign(existingItem, { Object.assign(existingItem, {
status: 'error', status: 'error',
@@ -519,12 +69,9 @@ onMounted(() => {
progress: 0 progress: 0
}); });
setTimeout(() => { setTimeout(() => {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename); downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
}, 3000); }, 3000);
} }
message.error(
t('download.message.downloadFailed', { filename: data.filename, error: data.error })
);
} }
}); });
@@ -544,10 +91,6 @@ onMounted(() => {
} }
}); });
}); });
const handleDrawerClose = () => {
settingsStore.showDownloadDrawer = false;
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -566,117 +109,4 @@ const handleDrawerClose = () => {
} }
} }
} }
.drawer-container {
@apply h-full;
}
.download-list,
.downloaded-list {
@apply flex flex-col h-full;
.empty-tip {
@apply flex-1 flex items-center justify-center;
@apply text-gray-400 dark:text-gray-600;
}
}
.download-content,
.downloaded-content {
@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 p-4;
}
.total-progress {
@apply px-4 py-3 bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
@apply border-b border-gray-100 dark:border-gray-800;
@apply sticky top-0 z-10;
&-text {
@apply mb-2 text-sm font-medium text-gray-600 dark:text-gray-400;
}
}
.download-item,
.downloaded-item {
@apply p-3 rounded-lg;
@apply bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
@apply border border-gray-100 dark:border-gray-700;
@apply transition-all duration-300;
@apply hover:bg-light-300 dark:hover:bg-dark-300;
@apply hover:shadow-md;
&-content {
@apply flex items-center gap-3;
}
&-cover {
@apply w-10 h-10 flex-shrink-0 rounded-lg overflow-hidden;
@apply shadow-md;
.cover-image {
@apply w-full h-full object-cover;
}
}
&-info {
@apply flex-1 min-w-0;
}
&-name {
@apply text-sm font-medium truncate;
@apply text-gray-900 dark:text-gray-100;
}
&-artist {
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
}
&-progress {
@apply mt-1;
}
&-size {
@apply text-xs text-gray-500 dark:text-gray-400 mt-1;
}
&-status {
@apply flex-shrink-0;
}
}
.downloaded-item {
&-actions {
@apply flex items-center gap-1;
.n-button {
@apply p-2;
@apply hover:bg-gray-200/80 dark:hover:bg-gray-600/80;
@apply rounded-lg;
@apply transition-colors duration-300;
.iconfont {
@apply text-xl;
}
}
}
}
.delete-confirm-content {
@apply py-6 px-4;
@apply text-base text-gray-600 dark:text-gray-400;
}
</style> </style>

View File

@@ -45,8 +45,7 @@
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { isElectron, isMobile } from '@/utils'; import { isElectron } from '@/utils';
import { getLatestReleaseInfo, getProxyNodes } from '@/utils/update';
import config from '../../../../package.json'; import config from '../../../../package.json';
@@ -54,7 +53,6 @@ const { t } = useI18n();
const showModal = ref(false); const showModal = ref(false);
const noPrompt = ref(false); const noPrompt = ref(false);
const releaseInfo = ref<any>(null);
const closeModal = () => { const closeModal = () => {
showModal.value = false; showModal.value = false;
@@ -63,11 +61,9 @@ const closeModal = () => {
} }
}; };
const proxyHosts = ref<string[]>([]);
onMounted(async () => { onMounted(async () => {
// 如果是 electron 环境,不显示安装提示 // 如果是 electron 环境,不显示安装提示
if (isElectron || isMobile.value) { if (isElectron) {
return; return;
} }
@@ -76,58 +72,11 @@ onMounted(async () => {
if (isDismissed) { if (isDismissed) {
return; return;
} }
// 获取最新版本信息
releaseInfo.value = await getLatestReleaseInfo();
showModal.value = true; showModal.value = true;
proxyHosts.value = await getProxyNodes();
}); });
const handleInstall = async (): Promise<void> => { const handleInstall = async (): Promise<void> => {
const assets = releaseInfo.value?.assets || []; window.open('http://donate.alger.fun/download', '_blank');
const { userAgent } = navigator;
const isMac = userAgent.toLowerCase().includes('mac');
const isWindows = userAgent.toLowerCase().includes('win');
const isLinux = userAgent.toLowerCase().includes('linux');
const isX64 =
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
let downloadUrl = '';
// 根据平台和架构选择对应的安装包
if (isMac) {
// macOS
const macAsset = assets.find((asset) => asset.name.includes('mac'));
downloadUrl = macAsset?.browser_download_url || '';
} else if (isWindows) {
// Windows
let winAsset = assets.find(
(asset) =>
asset.name.includes('win') &&
(isX64 ? asset.name.includes('x64') : asset.name.includes('ia32'))
);
if (!winAsset) {
winAsset = assets.find((asset) => asset.name.includes('win.exe'));
}
downloadUrl = winAsset?.browser_download_url || '';
} else if (isLinux) {
// Linux
const linuxAsset = assets.find(
(asset) =>
(asset.name.endsWith('.AppImage') || asset.name.endsWith('.deb')) &&
asset.name.includes('x64')
);
downloadUrl = linuxAsset?.browser_download_url || '';
}
if (downloadUrl) {
const proxyDownloadUrl = `${proxyHosts.value[0]}/${downloadUrl}`;
window.open(proxyDownloadUrl, '_blank');
} else {
// 如果没有找到对应的安装包,跳转到 release 页面
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
}
closeModal();
}; };
</script> </script>

View File

@@ -0,0 +1,38 @@
import { Router } from 'vue-router';
import { useMusicStore } from '@/store/modules/music';
/**
* 导航到音乐列表页面的通用方法
* @param router Vue路由实例
* @param options 导航选项
*/
export function navigateToMusicList(
router: Router,
options: {
id?: string | number;
type?: 'album' | 'playlist' | 'dailyRecommend' | string;
name: string;
songList: any[];
listInfo?: any;
canRemove?: boolean;
}
) {
const musicStore = useMusicStore();
const { id, type, name, songList, listInfo, canRemove = false } = options;
// 保存数据到状态管理
musicStore.setCurrentMusicList(songList, name, listInfo, canRemove);
// 路由跳转
if (id) {
router.push({
name: 'musicList',
params: { id },
query: { type }
});
} else {
router.push({
name: 'musicList'
});
}
}

View File

@@ -1,11 +1,12 @@
<template> <template>
<div v-if="isPlay" class="bottom" :style="{ height }"></div> <div v-if="isPlay && !isMobile" class="bottom" :style="{ height }"></div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { isMobile } from '@/utils';
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const isPlay = computed(() => playerStore.playMusicUrl); const isPlay = computed(() => playerStore.playMusicUrl);

View File

@@ -4,6 +4,7 @@
:width="400" :width="400"
placement="right" placement="right"
@update:show="$emit('update:modelValue', $event)" @update:show="$emit('update:modelValue', $event)"
:unstable-show-mask="false"
> >
<n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer"> <n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer">
<n-scrollbar class="h-full"> <n-scrollbar class="h-full">
@@ -158,9 +159,9 @@ const fetchUserPlaylists = async () => {
return; return;
} }
const res = await getUserPlaylist(user.userId); const res = await getUserPlaylist(user.userId, 999);
if (res.data?.playlist) { if (res.data?.playlist) {
playlists.value = res.data.playlist; playlists.value = res.data.playlist.filter((item: any) => item.userId === user.userId);
} }
} catch (error) { } catch (error) {
console.error('获取歌单失败:', error); console.error('获取歌单失败:', error);
@@ -254,7 +255,7 @@ watch(
} }
.playlist-drawer { .playlist-drawer {
@apply flex flex-col gap-6; @apply flex flex-col gap-6 py-6;
} }
.create-playlist-section { .create-playlist-section {
@@ -335,7 +336,7 @@ watch(
} }
.playlist-list { .playlist-list {
@apply flex flex-col gap-2; @apply flex flex-col gap-2 pb-40;
} }
.playlist-item { .playlist-item {
@@ -367,4 +368,9 @@ watch(
} }
} }
} }
:deep(.n-drawer-body-content-wrapper) {
padding-bottom: 0 !important;
padding-top: 0 !important;
}
</style> </style>

View File

@@ -21,15 +21,6 @@
<span>{{ item.size }}</span> <span>{{ item.size }}</span>
</div> </div>
<music-list
v-if="['专辑', 'playlist'].includes(item.type)"
v-model:show="showPop"
:name="item.name"
:song-list="songList"
:list-info="listInfo"
:cover="false"
:z-index="zIndex"
/>
<mv-player <mv-player
v-if="item.type === 'mv'" v-if="item.type === 'mv'"
v-model:show="showPop" v-model:show="showPop"
@@ -42,12 +33,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { getAlbum, getListDetail } from '@/api/list'; import { getAlbum, getListDetail } from '@/api/list';
import MvPlayer from '@/components/MvPlayer.vue'; import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { IMvItem } from '@/type/mv'; import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
import { useRouter } from 'vue-router';
import MusicList from '../MusicList.vue'; import { useMusicStore } from '@/store/modules/music';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -72,6 +62,8 @@ const showPop = ref(false);
const listInfo = ref<any>(null); const listInfo = ref<any>(null);
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const router = useRouter();
const musicStore = useMusicStore();
const getCurrentMv = () => { const getCurrentMv = () => {
return { return {
@@ -83,7 +75,6 @@ const getCurrentMv = () => {
const handleClick = async () => { const handleClick = async () => {
listInfo.value = null; listInfo.value = null;
if (props.item.type === '专辑') { if (props.item.type === '专辑') {
showPop.value = true;
const res = await getAlbum(props.item.id); const res = await getAlbum(props.item.id);
songList.value = res.data.songs.map((song: any) => { songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || props.item.picUrl; song.al.picUrl = song.al.picUrl || props.item.picUrl;
@@ -97,24 +88,47 @@ const handleClick = async () => {
}, },
description: res.data.album.description description: res.data.album.description
}; };
}
// 保存数据到store
if (props.item.type === 'playlist') { musicStore.setCurrentMusicList(
showPop.value = true; songList.value,
props.item.name,
listInfo.value,
false
);
// 使用路由跳转
router.push({
name: 'musicList',
params: { id: props.item.id },
query: { type: 'album' }
});
} else if (props.item.type === 'playlist') {
const res = await getListDetail(props.item.id); const res = await getListDetail(props.item.id);
songList.value = res.data.playlist.tracks; songList.value = res.data.playlist.tracks;
listInfo.value = res.data.playlist; listInfo.value = res.data.playlist;
}
// 保存数据到store
if (props.item.type === 'mv') { musicStore.setCurrentMusicList(
songList.value,
props.item.name,
listInfo.value,
false
);
// 使用路由跳转
router.push({
name: 'musicList',
params: { id: props.item.id },
query: { type: 'playlist' }
});
} else if (props.item.type === 'mv') {
handleShowMv(); handleShowMv();
} }
}; };
const handleShowMv = async () => { const handleShowMv = async () => {
playerStore.setIsPlay(false); playerStore.handlePause();
playerStore.setPlayMusic(false);
audioService.getCurrentSound()?.pause();
showPop.value = true; showPop.value = true;
}; };
</script> </script>

View File

@@ -1,649 +1,61 @@
<template> <template>
<div <component
class="song-item" :is="renderComponent"
:class="{ 'song-mini': mini, 'song-list': list }" :item="item"
@contextmenu.prevent="handleContextMenu" :favorite="favorite"
> :selectable="selectable"
<div v-if="selectable" class="song-item-select" @click.stop="toggleSelect"> :selected="selected"
<n-checkbox :checked="selected" /> :can-remove="canRemove"
</div> :is-next="isNext"
<n-image :index="index"
v-if="item.picUrl" @play="(...args) => $emit('play', ...args)"
ref="songImg" @select="(...args) => $emit('select', ...args)"
:src="getImgUrl(item.picUrl, '100y100')" @remove-song="(...args) => $emit('remove-song', ...args)"
class="song-item-img" />
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="imageLoad"
/>
<div class="song-item-content">
<div v-if="list" class="song-item-content-wrapper">
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{
item.name
}}</n-ellipsis>
<div class="song-item-content-divider">-</div>
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="handleArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
<template v-else>
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="handleArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</template>
</div>
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="toggleFavorite"
></i>
</div>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
@click="playMusicEvent(item)"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
<n-dropdown
v-if="isElectron"
:show="showDropdown"
:x="dropdownX"
:y="dropdownY"
:options="dropdownOptions"
:z-index="99999"
placement="bottom-start"
@clickoutside="showDropdown = false"
@select="handleSelect"
/>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { cloneDeep } from 'lodash'; import { computed } from 'vue';
import type { MenuOption } from 'naive-ui';
import { NImage, NText, useMessage } from 'naive-ui';
import { computed, h, inject, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { getSongUrl } from '@/hooks/MusicListHook';
import { useArtist } from '@/hooks/useArtist';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
const { t } = useI18n(); import StandardSongItem from './songItemCom/StandardSongItem.vue';
import MiniSongItem from './songItemCom/MiniSongItem.vue';
import ListSongItem from './songItemCom/ListSongItem.vue';
import CompactSongItem from './songItemCom/CompactSongItem.vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
item: SongResult; item: SongResult;
mini?: boolean; mini?: boolean;
list?: boolean; list?: boolean;
compact?: boolean;
favorite?: boolean; favorite?: boolean;
selectable?: boolean; selectable?: boolean;
selected?: boolean; selected?: boolean;
canRemove?: boolean; canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(), }>(),
{ {
mini: false, mini: false,
list: false, list: false,
compact: false,
favorite: true, favorite: true,
selectable: false, selectable: false,
selected: false, selected: false,
canRemove: false canRemove: false,
isNext: false,
index: undefined
} }
); );
const playerStore = usePlayerStore(); defineEmits(['play', 'select', 'remove-song']);
const message = useMessage(); // 根据属性决定渲染哪个组件
const renderComponent = computed(() => {
const play = computed(() => playerStore.isPlay); if (props.mini) return MiniSongItem;
const playMusic = computed(() => playerStore.playMusic); if (props.list) return ListSongItem;
const playLoading = computed( if (props.compact) return CompactSongItem;
() => playMusic.value.id === props.item.id && playMusic.value.playLoading return StandardSongItem;
);
const isPlaying = computed(() => {
return playMusic.value.id === props.item.id;
}); });
const showDropdown = ref(false);
const dropdownX = ref(0);
const dropdownY = ref(0);
const isDownloading = ref(false);
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
const { navigateToArtist } = useArtist();
const renderSongPreview = () => {
return h(
'div',
{
class: 'flex items-center gap-3 px-2 py-1 dark:border-gray-800'
},
[
h(NImage, {
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
class: 'w-10 h-10 rounded-lg flex-shrink-0',
previewDisabled: true,
imgProps: {
crossorigin: 'anonymous'
}
}),
h(
'div',
{
class: 'flex-1 min-w-0 py-1'
},
[
h(
'div',
{
class: 'mb-1'
},
[
h(
NText,
{
depth: 1,
class: 'text-sm font-medium'
},
{
default: () => props.item.name
}
)
]
)
]
)
]
);
};
const dropdownOptions = computed<MenuOption[]>(() => {
const options: MenuOption[] = [
{
key: 'header',
type: 'render',
render: renderSongPreview
},
{
key: 'divider1',
type: 'divider'
},
{
label: t('songItem.menu.play'),
key: 'play',
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
},
{
label: t('songItem.menu.playNext'),
key: 'playNext',
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
},
{
type: 'divider',
key: 'd1'
},
{
label: t('songItem.menu.download'),
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' })
},
{
label: t('songItem.menu.addToPlaylist'),
key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
},
{
label: isFavorite.value ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
key: 'favorite',
icon: () =>
h('i', {
class: `iconfont ${isFavorite.value ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
})
}
];
if (props.canRemove) {
options.push(
{
type: 'divider',
key: 'd2'
},
{
label: t('songItem.menu.removeFromPlaylist'),
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
);
}
return options;
});
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
};
const handleSelect = (key: string | number) => {
showDropdown.value = false;
if (key === 'download') {
downloadMusic();
} else if (key === 'playNext') {
handlePlayNext();
} else if (key === 'addToPlaylist') {
openPlaylistDrawer?.(props.item.id);
} else if (key === 'favorite') {
toggleFavorite(new Event('click'));
} else if (key === 'play') {
playMusicEvent(props.item);
} else if (key === 'remove') {
emits('remove-song', props.item.id);
}
};
// 下载音乐
const downloadMusic = async () => {
if (isDownloading.value) {
message.warning(t('songItem.message.downloading'));
return;
}
try {
isDownloading.value = true;
const data = (await getSongUrl(props.item.id as number, cloneDeep(props.item), true)) as any;
if (!data || !data.url) {
throw new Error(t('songItem.message.getUrlFailed'));
}
// 构建文件名
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: {
...songData,
downloadTime: Date.now()
}
});
message.success(t('songItem.message.downloadQueued'));
// 监听下载完成事件
const handleDownloadComplete = (_, result) => {
if (result.filename === filename) {
isDownloading.value = false;
removeListeners();
}
};
// 监听下载错误事件
const handleDownloadError = (_, result) => {
if (result.filename === filename) {
isDownloading.value = false;
removeListeners();
}
};
// 移除监听器函数
const removeListeners = () => {
window.electron.ipcRenderer.removeListener('music-download-complete', handleDownloadComplete);
window.electron.ipcRenderer.removeListener('music-download-error', handleDownloadError);
};
// 添加事件监听器
window.electron.ipcRenderer.once('music-download-complete', handleDownloadComplete);
window.electron.ipcRenderer.once('music-download-error', handleDownloadError);
// 30秒后自动清理监听器以防下载过程中出现未知错误
setTimeout(removeListeners, 30000);
} catch (error: any) {
console.error('Download error:', error);
isDownloading.value = false;
message.error(error.message || t('songItem.message.downloadFailed'));
}
};
const emits = defineEmits(['play', 'select', 'remove-song']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
if (!songImageRef.value) {
return;
}
const { backgroundColor } = await getImageBackground(
(songImageRef.value as any).imageRef as unknown as HTMLImageElement
);
// eslint-disable-next-line vue/no-mutating-props
props.item.backgroundColor = backgroundColor;
};
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => {
// 如果是当前正在播放的音乐,则切换播放/暂停状态
if (playMusic.value.id === item.id) {
if (play.value) {
playerStore.setPlayMusic(false);
audioService.getCurrentSound()?.pause();
} else {
playerStore.setPlayMusic(true);
audioService.getCurrentSound()?.play();
}
return;
}
try {
// 使用store的setPlay方法该方法已经包含了B站视频URL处理逻辑
const result = await playerStore.setPlay(item);
if (!result) {
throw new Error('播放失败');
}
playerStore.isPlay = true;
emits('play', item);
} catch (error) {
console.error('播放出错:', error);
}
};
// 判断是否已收藏
const isFavorite = computed(() => {
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.favoriteList.includes(numericId);
});
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(numericId);
}
};
// 切换选择状态
const toggleSelect = () => {
emits('select', props.item.id, !props.selected);
};
const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
// 获取歌手列表最多显示5个
const artists = computed(() => {
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
});
// 添加到下一首播放
const handlePlayNext = () => {
playerStore.addToNextPlay(props.item);
message.success(t('songItem.message.addedToNextPlay'));
};
</script> </script>
<style lang="scss" scoped>
// 配置文字不可选中
.text-ellipsis {
width: 100%;
}
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
&-img {
@apply w-12 h-12 rounded-2xl mr-4;
}
&-content {
@apply flex-1;
&-title {
@apply text-base text-gray-900 dark:text-white;
}
&-name {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
&-operating {
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-xl;
}
.icon-likefill {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
&-like {
@apply mr-2 cursor-pointer ml-4 transition-all;
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
&-play {
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
&-download {
@apply mr-2 cursor-pointer;
.iconfont {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
}
}
}
&-select {
@apply mr-3 cursor-pointer;
}
}
.song-mini {
@apply p-2 rounded-2xl;
.song-item {
@apply p-0;
&-img {
@apply w-10 h-10 mr-2;
}
&-content {
@apply flex-1;
&-title {
@apply text-sm;
}
&-name {
@apply text-xs;
}
}
&-operating {
@apply pl-2;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1 ml-1;
}
&-play {
@apply w-8 h-8;
}
}
}
}
.song-list {
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.song-item-img {
@apply w-10 h-10 rounded-lg mr-3;
}
.song-item-content {
@apply flex items-center flex-1;
&-wrapper {
@apply flex items-center flex-1 text-sm;
}
&-title {
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
}
&-divider {
@apply mx-2 text-gray-500 dark:text-gray-400;
}
&-name {
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center gap-2;
&-like {
@apply cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
}
}
&-play {
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base;
}
}
}
}
:deep(.n-dropdown-menu) {
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
.n-dropdown-option {
@apply h-9 text-sm;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
.n-dropdown-option-body {
@apply h-full;
.n-dropdown-option-body__prefix {
@apply w-8 flex justify-center items-center;
.iconfont {
@apply text-base;
}
}
}
}
.n-dropdown-divider {
@apply my-1;
}
}
:deep(.song-preview) {
@apply flex items-center gap-3 p-3 border-b dark:border-gray-800;
.n-image {
@apply w-12 h-12 rounded-lg flex-shrink-0;
}
.song-preview-info {
@apply flex-1 min-w-0 py-1;
.song-preview-name {
@apply text-sm font-medium truncate mb-1;
}
.song-preview-artist {
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
}
}
}
:deep(.n-dropdown-option-body--render) {
@apply p-0;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div
class="song-item"
@contextmenu.prevent="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dblclick.stop="playMusicEvent(item)"
>
<slot name="index"></slot>
<slot name="select" v-if="selectable"></slot>
<slot name="image"></slot>
<slot name="content"></slot>
<slot name="operating"></slot>
<song-item-dropdown
v-if="isElectron"
:item="item"
:show="showDropdown"
:x="dropdownX"
:y="dropdownY"
:is-favorite="isFavorite"
:is-dislike="isDislike"
:can-remove="canRemove"
@update:show="showDropdown = $event"
@play="playMusicEvent(item)"
@play-next="handlePlayNext"
@download="downloadMusic(item)"
@toggle-favorite="toggleFavorite"
@toggle-dislike="toggleDislike"
@remove="$emit('remove-song', $event)"
/>
</div>
</template>
<script lang="ts" setup>
import SongItemDropdown from './SongItemDropdown.vue';
import { useSongItem } from '@/hooks/useSongItem';
import { isElectron } from '@/utils';
import type { SongResult } from '@/type/music';
const props = defineProps<{
item: SongResult;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>();
const emits = defineEmits(['play', 'select', 'remove-song']);
// 使用公共逻辑
const {
playLoading,
isPlaying,
isFavorite,
isDislike,
artists,
showDropdown,
dropdownX,
dropdownY,
isHovering,
handleImageLoad,
playMusicEvent,
toggleFavorite,
toggleDislike,
handlePlayNext,
handleContextMenu,
handleMenuClick,
handleArtistClick,
handleMouseEnter,
handleMouseLeave,
downloadMusic
} = useSongItem(props);
// 处理图片加载
const imageLoad = async (event: Event) => {
const target = event.target as HTMLImageElement;
if (!target) return;
await handleImageLoad(target);
};
// 切换选择状态
const toggleSelect = () => {
emits('select', props.item.id, !props.selected);
};
// 把图片处理、艺术家处理等公共方法暴露给子组件
defineExpose({
imageLoad,
toggleSelect,
handleArtistClick,
handleMenuClick,
playMusicEvent,
toggleFavorite,
handlePlayNext,
playLoading,
isPlaying,
isFavorite,
isDislike,
artists,
isHovering
});
</script>
<style lang="scss" scoped>
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
}
.text-ellipsis {
width: 100%;
}
</style>

View File

@@ -0,0 +1,252 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="(...args) => $emit('play', ...args)"
@select="(...args) => $emit('select', ...args)"
@remove-song="(...args) => $emit('remove-song', ...args)"
class="compact-song-item"
ref="baseItem"
>
<!-- 索引插槽 -->
<template #index>
<div v-if="index !== undefined" class="song-item-index" :class="{ 'text-green-500': isPlaying }">
{{ index + 1 }}
</div>
</template>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content-compact">
<div class="song-item-content-compact-wrapper">
<div class="song-item-content-compact-title w-60 flex-shrink-0">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
{{ item.name }}
</n-ellipsis>
</div>
<div class="song-item-content-compact-artist">
<n-ellipsis line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
<div class="song-item-content-compact-album">
<n-ellipsis line-clamp="1">{{ item.al?.name || '-' }}</n-ellipsis>
</div>
<div class="song-item-content-compact-duration">
{{ formatDuration(getDuration(item)) }}
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating-compact">
<div v-if="favorite" class="song-item-operating-like" :class="{ 'opacity-0': !isHovering && !isFavorite }">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<div
class="song-item-operating-play animate__animated"
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading, 'opacity-0': !isHovering && !isPlaying }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
<div class="song-item-operating-menu" @click.stop="onMenuClick" :class="{ 'opacity-0': !isHovering && !isPlaying }">
<i class="iconfont ri-more-fill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis } from 'naive-ui';
import { computed, ref } from 'vue';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
const emit = defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从基础组件获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const isHovering = computed(() => baseItem.value?.isHovering || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => {
baseItem.value?.toggleSelect();
};
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => {
baseItem.value?.toggleFavorite(event);
// 可选emit 收藏事件
};
const onPlayMusic = () => {
baseItem.value?.playMusicEvent(props.item);
emit('play', props.item);
};
const onMenuClick = (event: MouseEvent) => baseItem.value?.handleMenuClick(event);
// 从useSongItem.ts导入格式化时长和获取时长方法
const getDuration = (item: SongResult): number => {
if (item.duration) return item.duration;
if (typeof item.dt === 'number') return item.dt;
return 0;
};
const formatDuration = (ms: number): string => {
if (!ms) return '--:--';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
</script>
<style lang="scss" scoped>
.compact-song-item {
@apply rounded-lg p-2 h-12 mb-1 border-b dark:border-gray-800 border-gray-100;
&:hover {
@apply bg-gray-50 dark:bg-gray-700;
.opacity-0 {
opacity: 1;
}
}
.song-item-index {
@apply w-8 text-center text-gray-500 dark:text-gray-400 text-sm;
}
.song-item-select {
@apply mr-3 cursor-pointer;
}
.song-item-content-compact {
@apply flex-1 flex items-center gap-4;
&-wrapper {
@apply flex-1 min-w-0 flex items-center;
}
&-title {
@apply text-sm cursor-pointer text-gray-900 dark:text-white flex items-center;
}
&-artist {
@apply w-40 text-sm text-gray-500 dark:text-gray-400 ml-2 flex items-center;
}
&-album {
@apply w-32 flex items-center text-sm text-gray-500 dark:text-gray-400;
}
&-duration {
@apply w-16 flex items-center text-sm text-gray-500 dark:text-gray-400 text-right;
}
}
.song-item-operating-compact {
@apply border-none bg-transparent gap-2 flex items-center;
.song-item-operating-like,
.song-item-operating-play,
.song-item-operating-menu {
@apply transition-opacity duration-200;
}
.song-item-operating-play {
@apply w-7 h-7 flex items-center justify-center cursor-pointer rounded-full bg-gray-300 dark:bg-gray-800 border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
.iconfont {
@apply text-base;
}
}
.song-item-operating-like {
@apply mr-1 ml-0 cursor-pointer;
.iconfont {
@apply text-base transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
}
.song-item-operating-menu {
@apply cursor-pointer flex items-center justify-center px-2;
.iconfont {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
}
}
.opacity-0 {
opacity: 0;
}
}
}
// 全局应用
:deep(.text-ellipsis) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="(...args) => $emit('play', ...args)"
@select="(...args) => $emit('select', ...args)"
@remove-song="(...args) => $emit('remove-song', ...args)"
class="list-song-item"
ref="baseItem"
>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 图片插槽 -->
<template #image>
<n-image
v-if="item.picUrl"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="onImageLoad"
/>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content">
<div class="song-item-content-wrapper">
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
{{ item.name }}
</n-ellipsis>
<div class="song-item-content-divider">-</div>
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating song-item-operating-list">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis, NImage } from 'naive-ui';
import { computed, ref } from 'vue';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
const emit = defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从基础组件获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => {
baseItem.value?.toggleSelect();
emit('select', props.item.id, !props.selected);
};
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => {
baseItem.value?.toggleFavorite(event);
// 可选emit 收藏事件
};
const onPlayMusic = () => {
baseItem.value?.playMusicEvent(props.item);
emit('play', props.item);
};
</script>
<style lang="scss" scoped>
.list-song-item {
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.song-item-img {
@apply w-10 h-10 rounded-lg mr-3;
}
.song-item-content {
@apply flex items-center flex-1;
&-wrapper {
@apply flex items-center flex-1 text-sm;
}
&-title {
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
}
&-divider {
@apply mx-2 text-gray-500 dark:text-gray-400;
}
&-name {
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
}
}
.song-item-operating-list {
@apply flex items-center gap-2;
&-like {
@apply cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
}
}
&-play {
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base;
}
}
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="(...args) => $emit('play', ...args)"
@select="(...args) => $emit('select', ...args)"
@remove-song="(...args) => $emit('remove-song', ...args)"
class="mini-song-item"
ref="baseItem"
>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 图片插槽 -->
<template #image>
<n-image
v-if="item.picUrl"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="onImageLoad"
/>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content">
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
{{ item.name }}
</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis, NImage } from 'naive-ui';
import { computed, ref } from 'vue';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
const emit = defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从基础组件获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => {
baseItem.value?.toggleSelect();
emit('select', props.item.id, !props.selected);
};
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => {
baseItem.value?.toggleFavorite(event);
// 可选emit 收藏事件
};
const onPlayMusic = () => {
baseItem.value?.playMusicEvent(props.item);
emit('play', props.item);
};
</script>
<style lang="scss" scoped>
.mini-song-item {
@apply p-2 rounded-2xl;
&:hover {
@apply bg-light-100 dark:bg-dark-100;
}
.song-item-img {
@apply w-10 h-10 mr-2 rounded-xl;
}
.song-item-content {
@apply flex-1;
&-title {
@apply text-sm text-gray-900 dark:text-white;
}
&-name {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center rounded-full ml-4 pl-2 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1 ml-1 cursor-pointer;
.icon-likefill {
@apply text-base transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
}
&-play {
@apply cursor-pointer rounded-full w-8 h-8 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
}
}
</style>

View File

@@ -0,0 +1,252 @@
<template>
<n-dropdown
v-if="isElectron"
:show="show"
:x="x"
:y="y"
:options="dropdownOptions"
:z-index="99999999"
placement="bottom-start"
@clickoutside="$emit('update:show', false)"
@select="handleSelect"
class="rounded-xl"
/>
</template>
<script lang="ts" setup>
import type { MenuOption } from 'naive-ui';
import { NEllipsis, NImage, NDropdown } from 'naive-ui';
import { computed, h, inject } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron } from '@/utils';
const { t } = useI18n();
const props = defineProps<{
item: SongResult;
show: boolean;
x: number;
y: number;
isFavorite: boolean;
isDislike: boolean;
canRemove?: boolean;
}>();
const emits = defineEmits([
'update:show',
'select',
'play',
'play-next',
'download',
'add-to-playlist',
'toggle-favorite',
'toggle-dislike',
'remove'
]);
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
// 渲染歌曲预览
const renderSongPreview = () => {
return h(
'div',
{
class: 'flex items-center gap-3 px-2 dark:border-gray-800 dark:text-white'
},
[
h(NImage, {
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
class: 'w-10 h-10 rounded-lg flex-shrink-0',
previewDisabled: true,
imgProps: {
crossorigin: 'anonymous'
}
}),
h(
'div',
{
class: 'flex-1 min-w-0 py-1 overflow-hidden'
},
[
h(
'div',
{
class: 'mb-1 overflow-hidden'
},
[
h(
NEllipsis,
{
lineClamp: 1,
depth: 1,
class: 'text-sm font-medium w-full',
style: 'max-width: 150px; min-width: 120px;'
},
{
default: () => props.item.name
}
)
]
),
h(
'div',
{
class: 'text-xs text-gray-500 dark:text-gray-400 overflow-hidden'
},
[
h(
NEllipsis,
{
lineClamp: 1,
style: 'max-width: 150px;'
},
{
default: () => {
const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(' / ');
return artistNames || '未知艺术家';
}
}
)
]
)
]
)
]
);
};
// 下拉菜单选项
const dropdownOptions = computed<MenuOption[]>(() => {
const options: MenuOption[] = [
{
key: 'header',
type: 'render',
render: renderSongPreview
},
{
key: 'divider1',
type: 'divider'
},
{
label: t('songItem.menu.play'),
key: 'play',
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
},
{
label: t('songItem.menu.playNext'),
key: 'playNext',
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
},
{
type: 'divider',
key: 'd1'
},
{
label: t('songItem.menu.download'),
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' })
},
{
label: t('songItem.menu.addToPlaylist'),
key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
},
{
label: props.isFavorite ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
key: 'favorite',
icon: () =>
h('i', {
class: `iconfont ${props.isFavorite ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
})
},
{
label: props.isDislike ? t('songItem.menu.undislike') : t('songItem.menu.dislike'),
key: 'dislike',
icon: () => h('i', { class: `iconfont ${props.isDislike ? 'ri-dislike-fill text-green-500': 'ri-dislike-line'}` })
},
];
if (props.canRemove) {
options.push(
{
type: 'divider',
key: 'd2'
},
{
label: t('songItem.menu.removeFromPlaylist'),
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
);
}
return options;
});
// 处理选择
const handleSelect = (key: string | number) => {
emits('update:show', false);
switch (key) {
case 'download':
emits('download');
break;
case 'playNext':
emits('play-next');
break;
case 'addToPlaylist':
openPlaylistDrawer?.(props.item.id);
break;
case 'favorite':
emits('toggle-favorite');
break;
case 'play':
emits('play');
break;
case 'remove':
emits('remove', props.item.id);
break;
case 'dislike':
emits('toggle-dislike');
break;
default:
break;
}
};
</script>
<style lang="scss" scoped>
:deep(.n-dropdown-menu) {
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
.n-dropdown-option {
@apply h-9 text-sm;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
.n-dropdown-option-body {
@apply h-full;
.n-dropdown-option-body__prefix {
@apply w-8 flex justify-center items-center;
.iconfont {
@apply text-base;
}
}
}
}
.n-dropdown-divider {
@apply my-1;
}
}
:deep(.n-dropdown-option-body--render) {
@apply p-0;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="(...args) => $emit('play', ...args)"
@select="(...args) => $emit('select', ...args)"
@remove-song="(...args) => $emit('remove-song', ...args)"
class="standard-song-item"
ref="baseItem"
>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 图片插槽 -->
<template #image>
<n-image
v-if="item.picUrl"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="onImageLoad"
/>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content">
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<n-tooltip v-if="isNext" trigger="hover" :z-index="9999999" :delay="400">
<template #trigger>
<div class="song-item-operating-next" @click.stop="onPlayNext">
<i class="iconfont ri-skip-forward-fill"></i>
</div>
</template>
{{ t('songItem.menu.playNext') }}
</n-tooltip>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis, NImage, NTooltip } from 'naive-ui';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const { t } = useI18n();
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
const emit = defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从playerStore和baseItem获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => {
baseItem.value?.toggleSelect();
};
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => {
baseItem.value?.toggleFavorite(event);
};
const onPlayMusic = () => {
baseItem.value?.playMusicEvent(props.item);
emit('play', props.item);
};
const onPlayNext = () => {
baseItem.value?.handlePlayNext();
};
</script>
<style lang="scss" scoped>
.standard-song-item {
&:hover {
@apply bg-light-100 dark:bg-dark-100;
}
.song-item-img {
@apply w-12 h-12 rounded-2xl mr-4;
}
.song-item-content {
@apply flex-1;
&-title {
@apply text-base text-gray-900 dark:text-white;
}
&-name {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-xl;
}
.icon-likefill {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
&-like {
@apply mr-2 cursor-pointer ml-4 transition-all;
}
&-next {
@apply mr-2 cursor-pointer transition-all;
.iconfont {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
}
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
&-play {
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
}
.song-item-select {
@apply mr-3 cursor-pointer;
}
}
</style>

View File

@@ -22,26 +22,19 @@
</div> </div>
</template> </template>
</div> </div>
<music-list
v-model:show="showMusic"
:name="albumName"
:song-list="songList"
:cover="true"
:loading="loadingList"
:list-info="albumInfo"
/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getNewAlbum } from '@/api/home'; import { getNewAlbum } from '@/api/home';
import { getAlbum } from '@/api/list'; import { getAlbum } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import type { IAlbumNew } from '@/type/album';
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils'; import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import type { IAlbumNew } from '@/type/album';
const { t } = useI18n(); const { t } = useI18n();
const albumData = ref<IAlbumNew>(); const albumData = ref<IAlbumNew>();
@@ -50,33 +43,42 @@ const loadAlbumList = async () => {
albumData.value = data; albumData.value = data;
}; };
const showMusic = ref(false); const router = useRouter();
const songList = ref([]);
const albumName = ref('');
const loadingList = ref(false);
const albumInfo = ref<any>({});
const handleClick = async (item: any) => { const handleClick = async (item: any) => {
songList.value = []; openAlbum(item);
albumInfo.value = {}; };
albumName.value = item.name;
loadingList.value = true; const openAlbum = async (album: any) => {
showMusic.value = true; if (!album) return;
const res = await getAlbum(item.id);
const { songs, album } = res.data; try {
songList.value = songs.map((song: any) => { const res = await getAlbum(album.id);
song.al.picUrl = song.al.picUrl || album.picUrl; const { songs, album: albumInfo } = res.data;
song.picUrl = song.al.picUrl || album.picUrl || song.picUrl;
return song; const formattedSongs = songs.map((song: any) => {
}); song.al.picUrl = song.al.picUrl || albumInfo.picUrl;
albumInfo.value = { song.picUrl = song.al.picUrl || albumInfo.picUrl || song.picUrl;
...album, return song;
creator: { });
avatarUrl: album.artist.img1v1Url,
nickname: `${album.artist.name} - ${album.company}` navigateToMusicList(router, {
}, id: album.id,
description: album.description type: 'album',
}; name: album.name,
loadingList.value = false; songList: formattedSongs,
listInfo: {
...albumInfo,
creator: {
avatarUrl: albumInfo.artist.img1v1Url,
nickname: `${albumInfo.artist.name} - ${albumInfo.company}`
},
description: albumInfo.description
}
});
} catch (error) {
console.error('获取专辑详情失败:', error);
}
}; };
onMounted(() => { onMounted(() => {

View File

@@ -23,7 +23,7 @@
></div> ></div>
<div <div
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer" class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
@click="showMusic = true" @click="showDayRecommend"
> >
<div class="font-bold text-lg"> <div class="font-bold text-lg">
{{ t('comp.recommendSinger.title') }} {{ t('comp.recommendSinger.title') }}
@@ -31,7 +31,7 @@
<div class="mt-2"> <div class="mt-2">
<p <p
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)" v-for="item in getDisplayDaySongs.slice(0, 5)"
:key="item.id" :key="item.id"
class="text-el" class="text-el"
> >
@@ -57,7 +57,7 @@
v-for="item in userPlaylist" v-for="item in userPlaylist"
:key="item.id" :key="item.id"
class="user-play-item" class="user-play-item"
@click="toPlaylist(item.id)" @click="openPlaylist(item)"
> >
<div class="user-play-item-img"> <div class="user-play-item-img">
<img :src="getImgUrl(item.coverImgUrl, '200y200')" alt="" /> <img :src="getImgUrl(item.coverImgUrl, '200y200')" alt="" />
@@ -100,7 +100,7 @@
@click="handleArtistClick(item.id)" @click="handleArtistClick(item.id)"
> >
<div <div
:style="setBackgroundImg(getImgUrl(item.picUrl, '500y500'))" :style="setBackgroundImg(getImgUrl(item.picUrl || item.avatar || item.cover, '500y500'))"
class="recommend-singer-item-bg" class="recommend-singer-item-bg"
></div> ></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10"> <div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
@@ -124,42 +124,25 @@
</n-carousel-item> </n-carousel-item>
</n-carousel> </n-carousel>
</div> </div>
<music-list
v-if="dayRecommendData?.dailySongs.length"
v-model:show="showMusic"
:name="t('comp.recommendSinger.songlist')"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
<!-- 添加用户歌单弹窗 -->
<music-list
v-model:show="showPlaylist"
v-model:loading="playlistLoading"
:name="playlistItem?.name || ''"
:song-list="playlistDetail?.playlist?.tracks || []"
:list-info="playlistDetail?.playlist"
/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, watchEffect } from 'vue'; import { onMounted, ref, watchEffect, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getDayRecommend, getHotSinger } from '@/api/home'; import { getDayRecommend, getHotSinger } from '@/api/home';
import { getListDetail } from '@/api/list'; import { getListDetail } from '@/api/list';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
import { getUserPlaylist } from '@/api/user'; import { getUserPlaylist } from '@/api/user';
import MusicList from '@/components/MusicList.vue';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { usePlayerStore, useUserStore } from '@/store'; import { usePlayerStore, useUserStore } from '@/store';
import { IDayRecommend } from '@/type/day_recommend'; import { IDayRecommend } from '@/type/day_recommend';
import { Playlist } from '@/type/list'; import { Playlist } from '@/type/list';
import type { IListDetail } from '@/type/listDetail'; import type { IListDetail } from '@/type/listDetail';
import { SongResult } from '@/type/music'; import { SongResult } from '@/type/music';
import type { IHotSinger } from '@/type/singer'; import type { Artist, IHotSinger } from '@/type/singer';
import { import {
getImgUrl, getImgUrl,
isMobile, isMobile,
@@ -167,20 +150,21 @@ import {
setAnimationDelay, setAnimationDelay,
setBackgroundImg setBackgroundImg
} from '@/utils'; } from '@/utils';
import { getArtistDetail } from '@/api/artist';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
const userStore = useUserStore(); const userStore = useUserStore();
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
// 歌手信息 // 歌手信息
const hotSingerData = ref<IHotSinger>(); const hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>(); const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
const userPlaylist = ref<Playlist[]>([]); const userPlaylist = ref<Playlist[]>([]);
// 为歌单弹窗添加的状态 // 为歌单弹窗添加的状态
const showPlaylist = ref(false);
const playlistLoading = ref(false); const playlistLoading = ref(false);
const playlistItem = ref<Playlist | null>(null); const playlistItem = ref<Playlist | null>(null);
const playlistDetail = ref<IListDetail | null>(null); const playlistDetail = ref<IListDetail | null>(null);
@@ -246,21 +230,61 @@ const getCarouselItemStyleForPlaylist = (playlistCount: number) => {
}; };
onMounted(async () => { onMounted(async () => {
await loadData(); loadNonUserData();
}); });
const loadData = async () => { const JayChouId = 6452;
const loadArtistData = async () => {
try { try {
// 获取每日推荐 const { data: artistData }: { data: { data: { artist: Artist } } } = await getArtistDetail(JayChouId);
try { console.log('artistData', artistData);
const { if (hotSingerData.value) {
data: { data: dayRecommend } // 将周杰伦数据放在第一位
} = await getDayRecommend(); hotSingerData.value.artists = [artistData.data.artist, ...hotSingerData.value.artists];
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
} catch (error) {
console.error('error', error);
} }
} catch (error) {
console.error('获取周杰伦数据失败:', error);
}
}
// 提取每日推荐加载逻辑到单独的函数
const loadDayRecommendData = async () => {
try {
const {
data: { data: dayRecommend }
} = await getDayRecommend();
const dayRecommendSource = dayRecommend as unknown as IDayRecommend;
dayRecommendData.value = {
...dayRecommendSource,
dailySongs: dayRecommendSource.dailySongs.filter((song: any) => !playerStore.dislikeList.includes(song.id))
};
} catch (error) {
console.error('获取每日推荐失败:', error);
}
};
// 加载不需要登录的数据
const loadNonUserData = async () => {
try {
// 获取每日推荐仅在用户未登录时加载已登录用户会通过watchEffect触发loadDayRecommendData
if (!userStore.user) {
await loadDayRecommendData();
}
// 获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
hotSingerData.value = singerData;
await loadArtistData();
} catch (error) {
console.error('加载热门歌手数据失败:', error);
}
};
// 加载需要登录的数据
const loadUserData = async () => {
try {
if (userStore.user) { if (userStore.user) {
const { data: playlistData } = await getUserPlaylist(userStore.user?.userId); const { data: playlistData } = await getUserPlaylist(userStore.user?.userId);
// 确保最多只显示4个歌单并按播放次数排序 // 确保最多只显示4个歌单并按播放次数排序
@@ -268,40 +292,49 @@ const loadData = async () => {
.sort((a, b) => b.playCount - a.playCount) .sort((a, b) => b.playCount - a.playCount)
.slice(0, 4); .slice(0, 4);
} }
// 获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
hotSingerData.value = singerData;
} catch (error) { } catch (error) {
console.error('error', error); console.error('加载用户数据失败:', error);
} }
}; };
const handleArtistClick = (id: number) => { const handleArtistClick = (id: number) => {
navigateToArtist(id); navigateToArtist(id);
}; };
const getDisplayDaySongs = computed(() => {
if(!dayRecommendData.value){
return [];
}
return dayRecommendData.value.dailySongs.filter((song) => !playerStore.dislikeList.includes(song.id));
})
const toPlaylist = async (id: number) => { const showDayRecommend = () => {
if (!dayRecommendData.value?.dailySongs) return;
navigateToMusicList(router, {
type: 'dailyRecommend',
name: t('comp.recommendSinger.songlist'),
songList: getDisplayDaySongs.value,
canRemove: false
});
};
const openPlaylist = (item: any) => {
playlistItem.value = item;
playlistLoading.value = true; playlistLoading.value = true;
playlistItem.value = null;
playlistDetail.value = null; getListDetail(item.id).then(res => {
showPlaylist.value = true; playlistDetail.value = res.data;
// 设置当前点击的歌单信息
const selectedPlaylist = userPlaylist.value.find((item) => item.id === id);
if (selectedPlaylist) {
playlistItem.value = selectedPlaylist;
}
try {
// 获取歌单详情
const { data } = await getListDetail(id);
playlistDetail.value = data;
} catch (error) {
console.error('获取歌单详情失败:', error);
} finally {
playlistLoading.value = false; playlistLoading.value = false;
}
navigateToMusicList(router, {
id: item.id,
type: 'playlist',
name: item.name,
songList: res.data.playlist.tracks || [],
listInfo: res.data.playlist,
canRemove: false
});
});
}; };
// 添加直接播放歌单的方法 // 添加直接播放歌单的方法
@@ -394,10 +427,12 @@ const loadFullPlaylist = async (trackIds: { id: number }[], initialSongs: SongRe
// 监听登录状态 // 监听登录状态
watchEffect(() => { watchEffect(() => {
if (userStore.user) { if (userStore.user) {
loadData(); loadUserData();
loadDayRecommendData();
} }
}); });
const getPlaylistGridClass = (length: number) => { const getPlaylistGridClass = (length: number) => {
switch (length) { switch (length) {
case 1: case 1:

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
correctionTime: number
}>();
const emit = defineEmits<{
(e: 'adjust', delta: number): void
}>();
const { t } = useI18n();
</script>
<template>
<div
class="lyric-correction"
>
<n-tooltip placement="right">
<template #trigger>
<div
class="lyric-correction-btn"
@click="emit('adjust', -0.2)"
:title="t('player.subtractCorrection', { num: 0.2 })"
>
<i class="ri-subtract-line text-base"></i>
</div>
</template>
<span>{{ t('player.subtractCorrection', { num: 0.2 }) }}</span>
</n-tooltip>
<span class="text-xs py-0.5 px-1 rounded bg-white/70 dark:bg-neutral-800/70 shadow font-mono tracking-wider text-gray-700 dark:text-gray-200 bg-opacity-40 backdrop-blur-2xl">
{{ props.correctionTime > 0 ? '+' : '' }}{{ props.correctionTime.toFixed(1) }}s
</span>
<n-tooltip placement="right">
<template #trigger>
<div
class="lyric-correction-btn"
@click="emit('adjust', 0.2)"
:title="t('player.addCorrection', { num: 0.2 })"
>
<i class="ri-add-line text-base"></i>
</div>
</template>
<span>{{ t('player.addCorrection', { num: 0.2 }) }}</span>
</n-tooltip>
</div>
</template>
<style scoped lang="scss">
.lyric-correction {
@apply absolute right-0 bottom-4 flex flex-col items-center space-y-1 z-50 select-none transition-opacity duration-200 opacity-0 pointer-events-none;
}
.lyric-correction-btn {
@apply w-7 h-7 flex items-center justify-center rounded-lg bg-white dark:bg-neutral-800 border border-white/20 dark:border-neutral-700/40 shadow-md backdrop-blur-2xl cursor-pointer transition-all duration-150 text-gray-700 dark:text-gray-200 hover:bg-green-500/80 hover:text-white hover:border-green-400/60 active:scale-95 bg-opacity-40 dark:hover:bg-green-500/80 dark:hover:text-white dark:hover:border-green-400/60 dark:hover:bg-opacity-40;
}
.mobile{
.lyric-correction {
@apply opacity-100;
}
}
</style>

View File

@@ -253,4 +253,8 @@ defineExpose({
color: var(--text-color-active) !important; color: var(--text-color-active) !important;
@apply text-xs; @apply text-xs;
} }
.mobile-unavailable {
@apply text-center py-4 text-gray-500 text-sm;
}
</style> </style>

View File

@@ -34,13 +34,18 @@
:class="{ 'only-cover': config.hideLyrics }" :class="{ 'only-cover': config.hideLyrics }"
:style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }" :style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }"
> >
<n-image <div class="img-container relative">
ref="PicImgRef" <n-image
:src="getImgUrl(playMusic?.picUrl, '500y500')" ref="PicImgRef"
class="img" :src="getImgUrl(playMusic?.picUrl, '500y500')"
lazy class="img"
preview-disabled lazy
/> preview-disabled
/>
<div v-if="playMusic?.playLoading" class="loading-overlay">
<i class="ri-loader-4-line loading-icon"></i>
</div>
</div>
<div class="music-info"> <div class="music-info">
<div class="music-content-name">{{ playMusic.name }}</div> <div class="music-content-name">{{ playMusic.name }}</div>
<div class="music-content-singer"> <div class="music-content-singer">
@@ -130,12 +135,13 @@
<span>{{ t('player.lrc.noLrc') }}</span> <span>{{ t('player.lrc.noLrc') }}</span>
</div> </div>
</div> </div>
<!-- 歌词右下角矫正按钮组件 -->
<LyricCorrectionControl
v-if="!isMobile"
:correction-time="correctionTime"
@adjust="adjustCorrectionTime"
/>
</n-layout> </n-layout>
<!-- 时间矫正 -->
<!-- <div class="music-content-time">
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
<n-button @click="addCorrectionTime(0.2)">+</n-button>
</div> -->
</div> </div>
</div> </div>
</n-drawer> </n-drawer>
@@ -148,6 +154,7 @@ import { useI18n } from 'vue-i18n';
import LyricSettings from '@/components/lyric/LyricSettings.vue'; import LyricSettings from '@/components/lyric/LyricSettings.vue';
import MiniPlayBar from '@/components/player/MiniPlayBar.vue'; import MiniPlayBar from '@/components/player/MiniPlayBar.vue';
import LyricCorrectionControl from '@/components/lyric/LyricCorrectionControl.vue';
import { import {
artistList, artistList,
lrcArray, lrcArray,
@@ -155,7 +162,9 @@ import {
playMusic, playMusic,
setAudioTime, setAudioTime,
textColors, textColors,
useLyricProgress useLyricProgress,
correctionTime,
adjustCorrectionTime
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
@@ -549,10 +558,14 @@ defineExpose({
max-width: none; max-width: none;
max-height: none; max-height: none;
.img { .img-container {
@apply w-[50vh] h-[50vh] mb-8; @apply w-[50vh] h-[50vh] mb-8;
} }
.img {
@apply w-full h-full;
}
.music-info { .music-info {
@apply text-center w-[600px]; @apply text-center w-[600px];
@@ -568,6 +581,10 @@ defineExpose({
} }
} }
.img-container {
@apply relative w-full h-full;
}
.img { .img {
@apply rounded-xl w-full h-full shadow-2xl transition-all duration-300; @apply rounded-xl w-full h-full shadow-2xl transition-all duration-300;
} }
@@ -763,4 +780,33 @@ defineExpose({
} }
} }
} }
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-overlay {
@apply absolute inset-0 flex items-center justify-center rounded-xl;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2;
}
.loading-icon {
font-size: 48px;
color: white;
animation: spin 1s linear infinite;
}
.lyric-correction {
/* 仅在 hover 歌词区域时显示 */
.music-lrc:hover & {
opacity: 1 !important;
pointer-events: auto !important;
}
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
<template>
<component :is="componentToUse" v-bind="$attrs" ref="musicFullRef" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { isMobile } from '@/utils';
import MusicFull from '@/components/lyric/MusicFull.vue';
import MusicFullMobile from '@/components/lyric/MusicFullMobile.vue';
// 根据当前设备类型选择需要显示的组件
const componentToUse = computed(() => {
return isMobile.value ? MusicFullMobile : MusicFull;
});
const musicFullRef = ref<InstanceType<typeof MusicFull>>();
defineExpose({
musicFullRef
});
</script>

View File

@@ -0,0 +1,285 @@
<template>
<n-dropdown
:show="showDropdown"
:options="dropdownOptions"
trigger="hover"
:z-index="9999999"
@select="handleSelect"
placement="top"
@update:show="(show) => showDropdown = show"
>
<n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<div class="advanced-controls-btn">
<i class="iconfont ri-settings-3-line"></i>
<!-- 激活状态的小标记 -->
<div v-if="hasActiveSettings" class="active-indicator">
<span v-if="hasActiveSleepTimer" class="timer-badge">
<i class="ri-time-line"></i>
</span>
</div>
</div>
</template>
{{ t('player.playBar.advancedControls') }}
</n-tooltip>
</n-dropdown>
<!-- EQ 均衡器弹窗 -->
<n-modal v-model:show="showEQModal" :mask-closable="true" :unstable-show-mask="false" :z-index="9999999">
<div class="eq-modal-content">
<div class="modal-close" @click="showEQModal = false">
<i class="ri-close-line"></i>
</div>
<eq-control />
</div>
</n-modal>
<!-- 定时关闭弹窗 -->
<n-modal v-model:show="playerStore.showSleepTimer" :mask-closable="true" :unstable-show-mask="false" :z-index="9999999">
<div class="timer-modal-content">
<div class="modal-close" @click="playerStore.showSleepTimer = false">
<i class="ri-close-line"></i>
</div>
<sleep-timer />
</div>
</n-modal>
<!-- 播放速度设置弹窗 -->
<n-modal v-model:show="showSpeedModal" :mask-closable="true" :unstable-show-mask="false" :z-index="9999999">
<div class="speed-modal-content">
<div class="modal-close" @click="showSpeedModal = false">
<i class="ri-close-line"></i>
</div>
<h3>{{ t('player.playBar.playbackSpeed') }}</h3>
<div class="speed-options">
<div
v-for="option in playbackRateOptions"
:key="option.key"
class="speed-option"
:class="{ 'active': playbackRate === option.key }"
@click="selectSpeed(option.key)"
>
{{ option.label }}
</div>
</div>
</div>
</n-modal>
</template>
<script lang="ts" setup>
import { ref, computed, h, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { DropdownOption } from 'naive-ui';
import { usePlayerStore } from '@/store/modules/player';
import EqControl from '@/components/EQControl.vue';
import SleepTimer from '@/components/player/SleepTimer.vue';
const { t } = useI18n();
const playerStore = usePlayerStore();
// 下拉菜单状态
const showDropdown = ref(false);
const showEQModal = ref(false);
const showSpeedModal = ref(false);
const isEQVisible = ref(false);
// 监听弹窗状态,确保互斥
watch(showEQModal, (newValue) => {
if (newValue) {
// 如果EQ弹窗打开关闭其他弹窗
playerStore.showSleepTimer = false;
showSpeedModal.value = false;
}
});
watch(() => playerStore.showSleepTimer, (newValue) => {
if (newValue) {
// 如果睡眠定时器弹窗打开,关闭其他弹窗
showEQModal.value = false;
showSpeedModal.value = false;
}
});
watch(showSpeedModal, (newValue) => {
if (newValue) {
// 如果播放速度弹窗打开,关闭其他弹窗
showEQModal.value = false;
playerStore.showSleepTimer = false;
}
});
// 播放速度状态
const playbackRate = computed(() => playerStore.playbackRate);
// 播放速度选项
const playbackRateOptions = [
{ label: '0.5x', key: 0.5 },
{ label: '0.75x', key: 0.75 },
{ label: '1.0x', key: 1.0 },
{ label: '1.25x', key: 1.25 },
{ label: '1.5x', key: 1.5 },
{ label: '2.0x', key: 2.0 }
];
// 是否有激活的睡眠定时器
const hasActiveSleepTimer = computed(() => playerStore.hasSleepTimerActive);
// 检查是否有任何高级设置是激活状态
const hasActiveSettings = computed(() => {
return playbackRate.value !== 1.0 || hasActiveSleepTimer.value || isEQVisible.value;
});
// 下拉菜单选项
const dropdownOptions = computed<DropdownOption[]>(() => [
{
label: t('player.playBar.eq'),
key: 'eq',
icon: () => h('i', { class: 'ri-equalizer-line' })
},
{
label: t('player.sleepTimer.title'),
key: 'timer',
icon: () => h('i', { class: 'ri-timer-line' }),
// 如果有激活的定时器,添加标记
suffix: () => hasActiveSleepTimer.value ? h('span', { class: 'active-option-mark' }) : null
},
{
label: t('player.playBar.playbackSpeed') + `(${playbackRate.value}x)`,
key: 'speed',
icon: () => h('i', { class: 'ri-speed-line' }),
// 如果播放速度不是1.0,添加标记
suffix: () => playbackRate.value !== 1.0 ? h('span', { class: 'active-option-mark' }, `${playbackRate.value}x`) : null
}
]);
// 处理菜单选择
const handleSelect = (key: string) => {
// 先关闭所有弹窗
showEQModal.value = false;
playerStore.showSleepTimer = false;
showSpeedModal.value = false;
// 然后仅打开所选弹窗
switch (key) {
case 'eq':
showEQModal.value = true;
break;
case 'timer':
playerStore.showSleepTimer = true;
break;
case 'speed':
showSpeedModal.value = true;
break;
}
};
// 选择播放速度
const selectSpeed = (speed: number) => {
playerStore.setPlaybackRate(speed);
showSpeedModal.value = false;
};
</script>
<style lang="scss" scoped>
.sleep-timer-countdown {
@apply fixed top-0 left-1/2 transform -translate-x-1/2 py-1 px-3 rounded-b-lg bg-green-500 text-white text-sm flex items-center;
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
z-index: 9998;
min-width: 80px;
text-align: center;
animation: fadeInDown 0.3s ease-out;
@keyframes fadeInDown {
from {
transform: translate(-50%, -100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
span {
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
font-weight: 500;
}
}
.advanced-controls-btn {
@apply cursor-pointer mx-3 relative;
.iconfont {
@apply text-2xl transition;
@apply hover:text-green-500;
}
.active-indicator {
@apply absolute -top-1 -right-1 flex;
.timer-badge, .speed-badge {
@apply flex items-center justify-center text-xs bg-green-500 text-white rounded-full;
height: 16px;
min-width: 16px;
padding: 0 3px;
font-weight: 600;
font-size: 10px;
i {
font-size: 10px;
}
}
.timer-badge + .speed-badge {
@apply -ml-2 z-10;
}
}
}
.eq-modal-content,
.timer-modal-content,
.speed-modal-content {
@apply p-6 rounded-3xl bg-light-100 dark:bg-dark-100 bg-opacity-80 filter backdrop-blur-sm;
max-width: 600px;
margin: 0 auto;
}
.eq-modal-content {
@apply p-10 max-w-[800px];
}
.speed-modal-content {
h3 {
@apply text-lg font-medium mb-4 text-center;
}
.speed-options {
@apply flex flex-wrap justify-center gap-4 my-8 mx-4;
.speed-option {
@apply py-2 px-4 rounded-full cursor-pointer transition-all;
@apply bg-gray-100 dark:bg-gray-800;
@apply hover:bg-green-100 dark:hover:bg-green-900;
&.active {
@apply bg-green-500 text-white;
}
}
}
}
.active-option-mark {
@apply ml-2 text-xs bg-green-500 text-white py-0.5 px-1.5 rounded-full;
font-weight: 500;
}
.modal-close {
@apply absolute top-4 right-4 cursor-pointer hover:text-green-500;
i {
@apply text-2xl;
}
}
</style>

View File

@@ -31,53 +31,54 @@
<!-- 控制按钮区域 --> <!-- 控制按钮区域 -->
<div class="control-buttons"> <div class="control-buttons">
<button class="control-button previous" @click="handlePrev"> <div class="control-button previous" @click="handlePrev">
<i class="iconfont icon-prev"></i> <i class="iconfont icon-prev"></i>
</button> </div>
<button class="control-button play" @click="playMusicEvent"> <div class="control-button play" @click="playMusicEvent">
<i class="iconfont" :class="play ? 'icon-stop' : 'icon-play'"></i> <i class="iconfont" :class="play ? 'icon-stop' : 'icon-play'"></i>
</button> </div>
<button class="control-button next" @click="handleNext"> <div class="control-button next" @click="handleNext">
<i class="iconfont icon-next"></i> <i class="iconfont icon-next"></i>
</button> </div>
</div> </div>
<!-- 右侧功能按钮 --> <!-- 右侧功能按钮 -->
<div class="function-buttons"> <div class="function-buttons">
<button class="function-button"> <div class="function-button">
<i <i
class="iconfont icon-likefill" class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }" :class="{ 'like-active': isFavorite }"
@click="toggleFavorite" @click="toggleFavorite"
></i> ></i>
</button> </div>
<n-popover trigger="click" :z-index="99999999" placement="top" :show-arrow="false"> <n-popover v-if="component" trigger="hover" :z-index="99999999" placement="top" :show-arrow="false">
<template #trigger> <template #trigger>
<button class="function-button" @click="mute"> <div class="function-button" @click="mute" @wheel.prevent="handleVolumeWheel">
<i class="iconfont" :class="getVolumeIcon"></i> <i class="iconfont" :class="getVolumeIcon"></i>
</button> </div>
</template> </template>
<div class="volume-slider-wrapper"> <div class="volume-slider-wrapper transparent-popover">
<n-slider <n-slider
v-model:value="volumeSlider" v-model:value="volumeSlider"
:step="0.01" :step="0.01"
:tooltip="false" :tooltip="false"
vertical vertical
@wheel.prevent="handleVolumeWheel"
></n-slider> ></n-slider>
</div> </div>
</n-popover> </n-popover>
<!-- 播放列表按钮 --> <!-- 播放列表按钮 -->
<button v-if="!component" class="function-button" @click="togglePlaylist"> <div v-if="!component" class="function-button" @click="togglePlaylist">
<i class="iconfont icon-list"></i> <i class="iconfont icon-list"></i>
</button> </div>
</div> </div>
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<button v-if="!component" class="close-button" @click="handleClose"> <div v-if="!component" class="close-button" @click="handleClose">
<i class="iconfont ri-close-line"></i> <i class="iconfont ri-close-line"></i>
</button> </div>
</div> </div>
<!-- 进度条 --> <!-- 进度条 -->
@@ -123,7 +124,7 @@ import SongItem from '@/components/common/SongItem.vue';
import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook'; import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { usePlayerStore, useSettingsStore } from '@/store'; import { isBilibiliIdMatch, usePlayerStore, useSettingsStore } from '@/store';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
@@ -183,22 +184,41 @@ const mute = () => {
} }
}; };
// 鼠标滚轮调整音量
const handleVolumeWheel = (e: WheelEvent) => {
// 向上滚动增加音量,向下滚动减少音量
const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
volumeSlider.value = newValue;
};
// 收藏相关 // 收藏相关
const isFavorite = computed(() => { const isFavorite = computed(() => {
const numericId = // 对于B站视频使用ID匹配函数
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id; if (playMusic.value.source === 'bilibili' && playMusic.value.bilibiliData?.bvid) {
return playerStore.favoriteList.includes(numericId); return playerStore.favoriteList.some(id => isBilibiliIdMatch(id, playMusic.value.id));
}
// 非B站视频直接比较ID
return playerStore.favoriteList.includes(playMusic.value.id);
}); });
const toggleFavorite = async (e: Event) => { const toggleFavorite = async (e: Event) => {
e.stopPropagation(); e.stopPropagation();
const numericId =
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id; // 处理B站视频的收藏ID
let favoriteId = playMusic.value.id;
if (playMusic.value.source === 'bilibili' && playMusic.value.bilibiliData?.bvid) {
// 如果当前播放的是B站视频且已有ID不包含--格式则需要构造完整ID
if (!String(favoriteId).includes('--')) {
favoriteId = `${playMusic.value.bilibiliData.bvid}--${playMusic.value.song?.ar?.[0]?.id || 0}--${playMusic.value.bilibiliData.cid}`;
}
}
if (isFavorite.value) { if (isFavorite.value) {
playerStore.removeFromFavorite(numericId); playerStore.removeFromFavorite(favoriteId);
} else { } else {
playerStore.addToFavorite(numericId); playerStore.addToFavorite(favoriteId);
} }
}; };
@@ -301,25 +321,7 @@ const handleNext = () => playerStore.nextPlay();
const playMusicEvent = async () => { const playMusicEvent = async () => {
try { try {
if (!playerStore.playMusic?.id || !playerStore.playMusicUrl) { playerStore.setPlay(playerStore.playMusic);
console.warn('No valid music or URL available');
playerStore.setPlay(playerStore.playMusic);
return;
}
if (play.value) {
if (audioService.getCurrentSound()) {
audioService.pause();
playerStore.setPlayMusic(false);
}
} else {
if (audioService.getCurrentSound()) {
audioService.play();
} else {
await audioService.play(playerStore.playMusicUrl, playerStore.playMusic);
}
playerStore.setPlayMusic(true);
}
} catch (error) { } catch (error) {
console.error('播放出错:', error); console.error('播放出错:', error);
playerStore.nextPlay(); playerStore.nextPlay();
@@ -448,7 +450,7 @@ const setMusicFull = () => {
} }
.control-button { .control-button {
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer text-gray-600; @apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200;
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -473,10 +475,9 @@ const setMusicFull = () => {
} }
.function-button { .function-button {
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer; @apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200;
width: 32px; width: 32px;
height: 32px; height: 32px;
color: var(--text-color-2, #666);
&:hover { &:hover {
@apply bg-gray-100 dark:bg-dark-300; @apply bg-gray-100 dark:bg-dark-300;
@@ -535,9 +536,52 @@ const setMusicFull = () => {
} }
.volume-slider-wrapper { .volume-slider-wrapper {
@apply p-4 rounded-xl bg-white dark:bg-dark-100 shadow-lg; @apply p-2 py-4 rounded-xl bg-white dark:bg-dark-100 shadow-lg bg-opacity-90 backdrop-blur;
width: 40px;
height: 160px; height: 160px;
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: theme('colors.gray.200');
--n-rail-color-dark: theme('colors.gray.700');
--n-fill-color: theme('colors.green.500');
--n-handle-size: 12px;
--n-handle-color: theme('colors.green.500');
&.n-slider--vertical {
height: 100%;
.n-slider-rail {
width: 4px;
}
&:hover {
.n-slider-rail {
width: 6px;
}
.n-slider-handle {
width: 14px;
height: 14px;
}
}
}
.n-slider-rail {
@apply overflow-hidden transition-all duration-200;
@apply bg-gray-500 dark:bg-dark-300 bg-opacity-10 !important;
}
.n-slider-handle {
@apply transition-all duration-200;
opacity: 0;
}
&:hover {
.n-slider-handle {
opacity: 1;
}
}
}
} }
// 播放列表样式 // 播放列表样式
@@ -597,4 +641,8 @@ const setMusicFull = () => {
} }
} }
} }
:deep(.n-popover){
background-color: transparent !important;
}
</style> </style>

View File

@@ -3,7 +3,8 @@
class="mobile-play-bar" class="mobile-play-bar"
:class="[ :class="[
setAnimationClass('animate__fadeInUp'), setAnimationClass('animate__fadeInUp'),
musicFullVisible ? 'play-bar-expanded' : 'play-bar-mini' musicFullVisible ? 'play-bar-expanded' : 'play-bar-mini',
!shouldShowMobileMenu ? 'mobile-play-bar-no-menu' : ''
]" ]"
:style="{ :style="{
color: musicFullVisible color: musicFullVisible
@@ -16,7 +17,7 @@
}" }"
> >
<!-- 完整模式 - 在musicFullVisible为true时显示 --> <!-- 完整模式 - 在musicFullVisible为true时显示 -->
<template v-if="musicFullVisible"> <template v-if="false">
<!-- 顶部信息区域 --> <!-- 顶部信息区域 -->
<div class="music-info-header"> <div class="music-info-header">
<div class="music-info-main"> <div class="music-info-main">
@@ -61,36 +62,17 @@
<div class="control-btn next" @click="handleNext"> <div class="control-btn next" @click="handleNext">
<i class="iconfont ri-skip-forward-fill"></i> <i class="iconfont ri-skip-forward-fill"></i>
</div> </div>
<n-popover <div class="control-btn list" @click="openPlayListDrawer">
trigger="click" <i class="iconfont ri-menu-line"></i>
:z-index="99999999" </div>
content-class="mobile-play-list"
raw
:show-arrow="false"
placement="top"
@update-show="scrollToPlayList"
>
<template #trigger>
<div class="control-btn list">
<i class="iconfont ri-menu-line"></i>
</div>
</template>
<div class="mobile-play-list-container">
<div class="mobile-play-list-back"></div>
<n-virtual-list ref="playListRef" :item-size="56" item-resizable :items="playList">
<template #default="{ item }">
<div class="mobile-play-list-item">
<song-item :key="item.id" :item="item" mini></song-item>
</div>
</template>
</n-virtual-list>
</div>
</n-popover>
</div> </div>
<!-- 定时关闭按钮 -->
<!-- <SleepTimerPopover mode="mobile" /> -->
</template> </template>
<!-- Mini模式 - 在musicFullVisible为false时显示 --> <!-- Mini模式 - 在musicFullVisible为false时显示 -->
<div v-else class="mobile-mini-controls"> <div v-if="!musicFullVisible" class="mobile-mini-controls">
<!-- 歌曲信息 --> <!-- 歌曲信息 -->
<div class="mini-song-info" @click="setMusicFull"> <div class="mini-song-info" @click="setMusicFull">
<n-image <n-image
@@ -100,12 +82,13 @@
preview-disabled preview-disabled
/> />
<div class="mini-song-text"> <div class="mini-song-text">
<n-ellipsis class="mini-song-title" line-clamp="1"> <n-ellipsis line-clamp="1">
{{ playMusic.name }} <span class="mini-song-title">{{ playMusic.name }}</span>
</n-ellipsis> <span class="mx-2 text-gray-500 dark:text-gray-400">-</span>
<n-ellipsis class="mini-song-artist" line-clamp="1"> <span class="mini-song-artist">
<span v-for="(artists, artistsindex) in artistList" :key="artistsindex"> <span v-for="(artists, artistsindex) in artistList" :key="artistsindex">
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }} {{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
</span>
</span> </span>
</n-ellipsis> </n-ellipsis>
</div> </div>
@@ -116,34 +99,12 @@
<div class="mini-control-btn play" @click="playMusicEvent"> <div class="mini-control-btn play" @click="playMusicEvent">
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i> <i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
</div> </div>
<n-popover <i class="iconfont icon-list mini-list-icon" @click="openPlayListDrawer"></i>
trigger="click"
:z-index="99999999"
content-class="mobile-play-list"
raw
:show-arrow="false"
placement="top"
@update-show="scrollToPlayList"
>
<template #trigger>
<i class="iconfont icon-list mini-list-icon"></i>
</template>
<div class="mobile-play-list-container">
<div class="mobile-play-list-back"></div>
<n-virtual-list ref="playListRef" :item-size="56" item-resizable :items="playList">
<template #default="{ item }">
<div class="mobile-play-list-item">
<song-item :key="item.id" :item="item" mini></song-item>
</div>
</template>
</n-virtual-list>
</div>
</n-popover>
</div> </div>
</div> </div>
<!-- 全屏播放器 --> <!-- 全屏播放器 -->
<music-full ref="MusicFullRef" v-model="musicFullVisible" :background="background" /> <music-full-wrapper ref="MusicFullRef" v-model="musicFullVisible" :background="background" />
</div> </div>
</template> </template>
@@ -151,22 +112,19 @@
import { useThrottleFn } from '@vueuse/core'; import { useThrottleFn } from '@vueuse/core';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, artistList, nowTime, playMusic, sound, textColors } from '@/hooks/MusicHook'; import { allTime, artistList, nowTime, playMusic, sound, textColors } from '@/hooks/MusicHook';
import MusicFull from '@/layout/components/MusicFull.vue'; import MusicFullWrapper from '@/components/lyric/MusicFullWrapper.vue';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils'; import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
const shouldShowMobileMenu = inject('shouldShowMobileMenu');
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
// 是否播放 // 是否播放
const play = computed(() => playerStore.isPlay); const play = computed(() => playerStore.isPlay);
// 播放列表
const playList = computed(() => playerStore.playList as SongResult[]);
// 背景颜色 // 背景颜色
const background = ref('#000'); const background = ref('#000');
@@ -204,14 +162,9 @@ const setMusicFull = () => {
} }
}; };
// 播放列表引用 // 打开播放列表抽屉
const playListRef = ref<any>(null); const openPlayListDrawer = () => {
playerStore.setPlayListDrawerVisible(true);
const scrollToPlayList = (val: boolean) => {
if (!val) return;
setTimeout(() => {
playListRef.value?.scrollTo({ top: playerStore.playListIndex * 56 });
}, 50);
}; };
// 收藏功能 // 收藏功能
@@ -231,25 +184,7 @@ const toggleFavorite = () => {
// 播放暂停按钮事件 // 播放暂停按钮事件
const playMusicEvent = async () => { const playMusicEvent = async () => {
try { try {
if (!playMusic.value?.id || !playerStore.playMusicUrl) { playerStore.setPlay(playMusic.value);
console.warn('No valid music or URL available');
playerStore.setPlay(playMusic.value);
return;
}
if (play.value) {
if (audioService.getCurrentSound()) {
audioService.pause();
playerStore.setPlayMusic(false);
}
} else {
if (audioService.getCurrentSound()) {
audioService.play();
} else {
await audioService.play(playerStore.playMusicUrl, playMusic.value);
}
playerStore.setPlayMusic(true);
}
} catch (error) { } catch (error) {
console.error('播放出错:', error); console.error('播放出错:', error);
playerStore.nextPlay(); playerStore.nextPlay();
@@ -267,11 +202,15 @@ watch(
<style lang="scss" scoped> <style lang="scss" scoped>
.mobile-play-bar { .mobile-play-bar {
@apply fixed bottom-[56px] left-0 w-full flex flex-col shadow-lg; @apply fixed bottom-[56px] left-0 w-full flex flex-col;
z-index: 10000; z-index: 10000;
animation-duration: 0.3s !important; animation-duration: 0.3s !important;
transition: all 0.3s ease; transition: all 0.3s ease;
&.mobile-play-bar-no-menu {
@apply bottom-[10px];
}
&.play-bar-expanded { &.play-bar-expanded {
@apply bg-transparent; @apply bg-transparent;
height: auto; /* 自动适应内容高度 */ height: auto; /* 自动适应内容高度 */
@@ -301,7 +240,7 @@ watch(
} }
&.play-bar-mini { &.play-bar-mini {
@apply h-14 py-0 bg-light dark:bg-dark; @apply h-14 py-0;
} }
// 顶部信息区域 // 顶部信息区域
@@ -439,13 +378,13 @@ watch(
// Mini模式样式 // Mini模式样式
.mobile-mini-controls { .mobile-mini-controls {
@apply flex items-center justify-between px-4 h-14; @apply flex items-center justify-between pr-4 mx-3 h-12 rounded-full bg-light-100 dark:bg-dark-100 shadow-lg;
.mini-song-info { .mini-song-info {
@apply flex items-center flex-1 min-w-0 cursor-pointer; @apply flex items-center flex-1 min-w-0 cursor-pointer;
.mini-song-cover { .mini-song-cover {
@apply w-8 h-8 rounded-md; @apply w-12 h-12 rounded-full border-8 border-dark-300 dark:border-light-300;
} }
.mini-song-text { .mini-song-text {
@@ -456,7 +395,7 @@ watch(
} }
.mini-song-artist { .mini-song-artist {
@apply text-xs text-gray-500 dark:text-gray-400 mt-0.5; @apply text-xs text-gray-500 dark:text-gray-400;
} }
} }
} }

View File

@@ -4,7 +4,7 @@
:class="[ :class="[
setAnimationClass('animate__bounceInUp'), setAnimationClass('animate__bounceInUp'),
musicFullVisible ? 'play-bar-opcity' : '', musicFullVisible ? 'play-bar-opcity' : '',
musicFullVisible && MusicFullRef?.config?.hidePlayBar musicFullVisible && MusicFullRef?.musicFullRef?.config?.hidePlayBar
? 'animate__animated animate__slideOutDown' ? 'animate__animated animate__slideOutDown'
: '' : ''
]" ]"
@@ -39,6 +39,9 @@
lazy lazy
preview-disabled preview-disabled
/> />
<div v-if="playMusic?.playLoading" class="loading-overlay">
<i class="ri-loader-4-line loading-icon"></i>
</div>
<div class="hover-arrow"> <div class="hover-arrow">
<div class="hover-content"> <div class="hover-content">
<!-- <i class="ri-arrow-up-s-line text-3xl" :class="{ 'ri-arrow-down-s-line': musicFullVisible }"></i> --> <!-- <i class="ri-arrow-up-s-line text-3xl" :class="{ 'ri-arrow-down-s-line': musicFullVisible }"></i> -->
@@ -53,10 +56,13 @@
</div> </div>
</div> </div>
<div class="music-content"> <div class="music-content">
<div class="music-content-title"> <div class="music-content-title flex items-center">
<n-ellipsis class="text-ellipsis" line-clamp="1"> <n-ellipsis class="text-ellipsis" line-clamp="1">
{{ playMusic.name }} {{ playMusic.name }}
</n-ellipsis> </n-ellipsis>
<span v-if="playbackRate !== 1.0" class="playback-rate-badge">
{{ playbackRate }}x
</span>
</div> </div>
<div class="music-content-name"> <div class="music-content-name">
<n-ellipsis <n-ellipsis
@@ -90,11 +96,12 @@
</div> </div>
</div> </div>
<div class="audio-button"> <div class="audio-button">
<div class="audio-volume custom-slider"> <div class="audio-volume custom-slider" @wheel.prevent="handleVolumeWheel">
<div class="volume-icon" @click="mute"> <div class="volume-icon" @click="mute">
<i class="iconfont" :class="getVolumeIcon"></i> <i class="iconfont" :class="getVolumeIcon"></i>
</div> </div>
<div class="volume-slider"> <div class="volume-slider">
<div class="volume-percentage">{{ Math.round(volumeSlider) }}%</div>
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider> <n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider>
</div> </div>
</div> </div>
@@ -124,76 +131,34 @@
</template> </template>
{{ playMusic.id ? t('player.playBar.lyric') : t('player.playBar.noSongPlaying') }} {{ playMusic.id ? t('player.playBar.lyric') : t('player.playBar.noSongPlaying') }}
</n-tooltip> </n-tooltip>
<n-popover <n-tooltip v-if="playMusic.id && isElectron" trigger="hover" :z-index="9999999">
v-if="isElectron"
trigger="click"
:z-index="99999999"
content-class="music-eq"
raw
:show-arrow="false"
:delay="200"
placement="top"
>
<template #trigger> <template #trigger>
<n-tooltip trigger="hover" :z-index="9999999"> <reparse-popover v-if="playMusic.id" />
<template #trigger>
<i class="iconfont ri-equalizer-line" :class="{ 'text-green-500': isEQVisible }"></i>
</template>
{{ t('player.playBar.eq') }}
</n-tooltip>
</template> </template>
<eq-control /> {{ t('player.playBar.reparse') }}
</n-popover> </n-tooltip>
<n-popover
trigger="click" <!-- 高级控制菜单按钮整合了 EQ定时关闭播放速度 -->
:z-index="99999999" <advanced-controls-popover />
content-class="music-play"
raw <n-tooltip trigger="hover" :z-index="9999999">
:show-arrow="false"
:delay="200"
arrow-wrapper-style=" border-radius:1.5rem"
@update-show="scrollToPlayList"
>
<template #trigger> <template #trigger>
<n-tooltip trigger="manual" :z-index="9999999"> <i class="iconfont icon-list text-2xl hover:text-green-500 transition-colors cursor-pointer" @click="openPlayListDrawer"></i>
<template #trigger>
<i class="iconfont icon-list"></i>
</template>
{{ t('player.playBar.playList') }}
</n-tooltip>
</template> </template>
<div class="music-play-list"> {{ t('player.playBar.playList') }}
<div class="music-play-list-back"></div> </n-tooltip>
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
<template #default="{ item }">
<div class="music-play-list-content">
<div class="flex items-center justify-between">
<song-item :key="item.id" class="flex-1" :item="item" mini></song-item>
<div class="delete-btn" @click.stop="handleDeleteSong(item)">
<i
class="iconfont ri-delete-bin-line text-gray-400 hover:text-red-500 transition-colors"
></i>
</div>
</div>
</div>
</template>
</n-virtual-list>
</div>
</n-popover>
</div> </div>
<!-- 播放音乐 --> <!-- 全屏播放器 -->
<music-full ref="MusicFullRef" v-model="musicFullVisible" :background="background" /> <music-full-wrapper ref="MusicFullRef" v-model="musicFullVisible" :background="background" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core'; import { useThrottleFn } from '@vueuse/core';
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui';
import { computed, ref, useTemplateRef, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ReparsePopover from '@/components/player/ReparsePopover.vue';
import SongItem from '@/components/common/SongItem.vue';
import EqControl from '@/components/EQControl.vue';
import { import {
allTime, allTime,
artistList, artistList,
@@ -204,12 +169,16 @@ import {
textColors textColors
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import MusicFull from '@/layout/components/MusicFull.vue'; import MusicFullWrapper from '@/components/lyric/MusicFullWrapper.vue';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player'; import {
isBilibiliIdMatch,
usePlayerStore
} from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils'; import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils';
import AdvancedControlsPopover from '@/components/player/AdvancedControlsPopover.vue';
import { storeToRefs } from 'pinia';
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@@ -217,8 +186,6 @@ const { t } = useI18n();
const message = useMessage(); const message = useMessage();
// 是否播放 // 是否播放
const play = computed(() => playerStore.isPlay); const play = computed(() => playerStore.isPlay);
// 播放列表
const playList = computed(() => playerStore.playList as SongResult[]);
// 背景颜色 // 背景颜色
const background = ref('#000'); const background = ref('#000');
@@ -310,6 +277,14 @@ const mute = () => {
} }
}; };
// 鼠标滚轮调整音量
const handleVolumeWheel = (e: WheelEvent) => {
// 向上滚动增加音量,向下滚动减少音量
const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
volumeSlider.value = newValue;
};
// 播放模式 // 播放模式
const playMode = computed(() => playerStore.playMode); const playMode = computed(() => playerStore.playMode);
const playModeIcon = computed(() => { const playModeIcon = computed(() => {
@@ -337,6 +312,8 @@ const playModeText = computed(() => {
} }
}); });
// 播放速度控制
const {playbackRate} = storeToRefs(playerStore);
// 切换播放模式 // 切换播放模式
const togglePlayMode = () => { const togglePlayMode = () => {
playerStore.togglePlayMode(); playerStore.togglePlayMode();
@@ -356,42 +333,12 @@ const showSliderTooltip = ref(false);
// 播放暂停按钮事件 // 播放暂停按钮事件
const playMusicEvent = async () => { const playMusicEvent = async () => {
try { try {
// 检查是否有有效的音乐对象 const result = await playerStore.setPlay({ ...playMusic.value});
if (!playMusic.value?.id) { if (result) {
console.warn('没有有效的播放对象');
return;
}
// 当前处于播放状态 -> 暂停
if (play.value) {
if (audioService.getCurrentSound()) {
audioService.pause();
playerStore.setPlayMusic(false);
}
return;
}
// 当前处于暂停状态 -> 播放
// 有音频实例,直接播放
if (audioService.getCurrentSound()) {
audioService.play();
playerStore.setPlayMusic(true); playerStore.setPlayMusic(true);
return;
}
// 没有音频实例重新获取并播放包括重新获取B站视频URL
try {
// 复用当前播放对象但强制重新获取URL
const result = await playerStore.setPlay({ ...playMusic.value, playMusicUrl: undefined });
if (result) {
playerStore.setPlayMusic(true);
}
} catch (error) {
console.error('重新获取播放链接失败:', error);
message.error(t('player.playFailed'));
} }
} catch (error) { } catch (error) {
console.error('播放出错:', error); console.error('重新获取播放链接失败:', error);
message.error(t('player.playFailed')); message.error(t('player.playFailed'));
} }
}; };
@@ -407,32 +354,33 @@ const setMusicFull = () => {
} }
}; };
const palyListRef = useTemplateRef('palyListRef') as any;
const scrollToPlayList = (val: boolean) => {
if (!val) return;
setTimeout(() => {
palyListRef.value?.scrollTo({ top: playerStore.playListIndex * 62 });
}, 50);
};
const isFavorite = computed(() => { const isFavorite = computed(() => {
// 将id转换为number兼容B站视频ID // 对于B站视频使用ID匹配函数
const numericId = if (playMusic.value.source === 'bilibili' && playMusic.value.bilibiliData?.bvid) {
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id; return playerStore.favoriteList.some(id => isBilibiliIdMatch(id, playMusic.value.id));
return playerStore.favoriteList.includes(numericId); }
// 非B站视频直接比较ID
return playerStore.favoriteList.includes(playMusic.value.id);
}); });
const toggleFavorite = async (e: Event) => { const toggleFavorite = async (e: Event) => {
console.log('playMusic.value', playMusic.value);
e.stopPropagation(); e.stopPropagation();
// 将id转换为number兼容B站视频ID
const numericId = // 处理B站视频的收藏ID
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id; let favoriteId = playMusic.value.id;
if (playMusic.value.source === 'bilibili' && playMusic.value.bilibiliData?.bvid) {
// 如果当前播放的是B站视频且已有ID不包含--格式则需要构造完整ID
if (!String(favoriteId).includes('--')) {
favoriteId = `${playMusic.value.bilibiliData.bvid}--${playMusic.value.song?.ar?.[0]?.id || 0}--${playMusic.value.bilibiliData.cid}`;
}
}
if (isFavorite.value) { if (isFavorite.value) {
playerStore.removeFromFavorite(numericId); playerStore.removeFromFavorite(favoriteId);
} else { } else {
playerStore.addToFavorite(numericId); playerStore.addToFavorite(favoriteId);
} }
}; };
@@ -447,25 +395,9 @@ const handleArtistClick = (id: number) => {
navigateToArtist(id); navigateToArtist(id);
}; };
// 监听播放栏显示状态 // 打开播放列表抽屉
watch( const openPlayListDrawer = () => {
() => MusicFullRef.value?.config?.hidePlayBar, playerStore.setPlayListDrawerVisible(true);
(newVal) => {
if (newVal && musicFullVisible.value) {
// 使用 animate.css 动画,不需要手动设置样式
}
}
);
const isEQVisible = ref(false);
// 在 script setup 部分添加删除歌曲的处理函数
const handleDeleteSong = (song: SongResult) => {
// 如果删除的是当前播放的歌曲,先切换到下一首
if (song.id === playMusic.value.id) {
playerStore.nextPlay();
}
playerStore.removeFromPlayList(song.id as number);
}; };
</script> </script>
@@ -491,7 +423,7 @@ const handleDeleteSong = (song: SongResult) => {
} }
.music-content { .music-content {
width: 160px; width: 200px;
@apply ml-4; @apply ml-4;
&-title { &-title {
@@ -551,16 +483,23 @@ const handleDeleteSong = (song: SongResult) => {
.volume-slider { .volume-slider {
@apply absolute opacity-0 invisible transition-all duration-300 bottom-[30px] left-1/2 -translate-x-1/2 h-[180px] px-2 py-4 rounded-xl; @apply absolute opacity-0 invisible transition-all duration-300 bottom-[30px] left-1/2 -translate-x-1/2 h-[180px] px-2 py-4 rounded-xl;
@apply bg-light dark:bg-gray-800; @apply bg-light dark:bg-dark-200;
@apply border border-gray-200 dark:border-gray-700; @apply border border-gray-200 dark:border-gray-700;
.volume-percentage {
@apply absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-medium bg-light dark:bg-dark-200 px-2 py-1 rounded-md;
@apply border border-gray-200 dark:border-gray-700;
@apply text-gray-800 dark:text-white;
white-space: nowrap;
}
} }
} }
.audio-button { .audio-button {
@apply flex items-center mx-4; @apply flex items-center;
.iconfont { .iconfont {
@apply text-2xl transition cursor-pointer m-4; @apply text-2xl transition cursor-pointer mx-3;
@apply hover:text-green-500; @apply hover:text-green-500;
} }
} }
@@ -659,7 +598,7 @@ const handleDeleteSong = (song: SongResult) => {
// 确保悬停时提示样式正确 // 确保悬停时提示样式正确
.n-slider-tooltip { .n-slider-tooltip {
@apply bg-gray-800 text-white text-xs py-1 px-2 rounded; @apply bg-dark-200 text-white text-xs py-1 px-2 rounded;
z-index: 999999; z-index: 999999;
} }
} }
@@ -742,4 +681,52 @@ const handleDeleteSong = (song: SongResult) => {
} }
} }
} }
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-overlay {
@apply absolute inset-0 flex items-center justify-center rounded-2xl;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2;
}
.loading-icon {
font-size: 24px;
color: white;
animation: spin 1s linear infinite;
}
.play-speed {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0 8px;
}
.speed-button {
font-size: 14px;
color: var(--text-color);
padding: 4px 8px;
border-radius: 4px;
background: var(--hover-color);
}
.speed-button:hover {
background: var(--hover-color-dark);
}
.playback-rate-badge {
@apply ml-2 px-1.5 h-4 flex items-center text-xs rounded bg-green-500 bg-opacity-15 text-green-600 dark:text-green-400;
font-weight: 500;
vertical-align: 1px;
}
</style> </style>

View File

@@ -0,0 +1,296 @@
<template>
<!-- 透明遮罩层点击任意位置关闭 -->
<div v-if="internalVisible" class="fixed-overlay" @click="closePanel"></div>
<!-- 使用animate.css进行动画效果 -->
<div
v-if="internalVisible"
class="playlist-panel"
:class="[
'animate__animated',
closing ? (isMobile ? 'animate__slideOutDown' : 'animate__slideOutRight') :
(isMobile ? 'animate__slideInUp' : 'animate__slideInRight')
]"
>
<div class="playlist-panel-header">
<div class="title">{{ t('player.playBar.playList') }}</div>
<div class="header-actions">
<n-tooltip trigger="hover">
<template #trigger>
<div class="action-btn" @click="handleClearPlaylist">
<i class="iconfont ri-delete-bin-line"></i>
</div>
</template>
{{ t('player.playList.clearAll')}}
</n-tooltip>
<div class="close-btn" @click="closePanel">
<i class="iconfont ri-close-line"></i>
</div>
</div>
</div>
<div class="playlist-panel-content">
<div v-if="playList.length === 0" class="empty-playlist">
<i class="iconfont ri-music-2-line"></i>
<p>{{ t('player.playList.empty')}}</p>
</div>
<n-virtual-list v-else ref="playListRef" :item-size="62" item-resizable :items="playList">
<template #default="{ item }">
<div class="music-play-list-content">
<div class="flex items-center justify-between">
<song-item :key="item.id" class="flex-1" :item="item" mini></song-item>
<div class="delete-btn" @click.stop="handleDeleteSong(item)">
<i
class="iconfont ri-delete-bin-line text-gray-400 hover:text-red-500 transition-colors"
></i>
</div>
</div>
</div>
</template>
</n-virtual-list>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessage, useDialog } from 'naive-ui';
import SongItem from '@/components/common/SongItem.vue';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import { isMobile } from '@/utils';
const { t } = useI18n();
const message = useMessage();
const dialog = useDialog();
const playerStore = usePlayerStore();
// 内部状态控制组件的可见性
const internalVisible = ref(false);
const closing = ref(false);
// 当前是否显示播放列表面板
const show = computed({
get: () => playerStore.playListDrawerVisible,
set: (value) => {
playerStore.setPlayListDrawerVisible(value);
}
});
// 监听外部可见性变化
watch(show, (newValue) => {
if (newValue) {
// 打开面板
internalVisible.value = true;
closing.value = false;
// 在下一个渲染周期后滚动到当前歌曲
nextTick(() => {
scrollToCurrentSong();
});
} else {
// 如果已经是关闭状态,不需要处理
if (!internalVisible.value) return;
// 开始关闭动画
closing.value = true;
// 等待动画完成后再隐藏组件
setTimeout(() => {
internalVisible.value = false;
}, 400); // 动画持续时间
}
}, { immediate: true });
// 播放列表
const playList = computed(() => playerStore.playList as SongResult[]);
// 播放列表引用
const playListRef = ref<any>(null);
// 关闭面板
const closePanel = () => {
show.value = false;
};
// 清空播放列表
const handleClearPlaylist = () => {
if (playList.value.length === 0) {
message.info(t('player.playList.alreadyEmpty'));
return;
}
if(isMobile.value){
closePanel();
}
dialog.warning({
title: t('player.playList.clearConfirmTitle'),
content: t('player.playList.clearConfirmContent'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
style: { zIndex: 999999999 }, // 确保对话框显示在遮罩之上
onPositiveClick: () => {
// 清空播放列表
playerStore.clearPlayAll();
message.success(t('player.playList.cleared'));
}
});
};
// 处理键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && internalVisible.value) {
closePanel();
}
};
// 添加和移除键盘事件监听
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
// 滚动到当前播放歌曲
const scrollToCurrentSong = () => {
// 延长等待时间,确保列表已渲染完成
setTimeout(() => {
if (playListRef.value && playList.value.length > 0) {
const index = playerStore.playListIndex;
console.log('滚动到歌曲索引:', index);
playListRef.value.scrollTo({
top: (index > 3 ? (index - 3) : 0) * 62,
});
}
}, 100);
};
// 删除歌曲
const handleDeleteSong = (song: SongResult) => {
playerStore.removeFromPlayList(song.id as number);
};
</script>
<style lang="scss" scoped>
.fixed-overlay {
@apply fixed inset-0 z-[999999];
pointer-events: auto; // 允许点击关闭
cursor: default;
}
.playlist-panel {
@apply fixed right-0 z-[9999999] rounded-l-xl overflow-hidden;
width: 350px;
height: 70vh;
top: 15vh; // 距离顶部15%
animation-duration: 0.4s !important; // 动画持续时间
@apply bg-light dark:bg-dark shadow-2xl dark:border dark:border-gray-700;
&-header {
@apply flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-900;
backdrop-filter: blur(10px);
background-color: rgba(255, 255, 255, 0.7);
.dark & {
background-color: rgba(18, 18, 18, 0.7);
}
.title {
@apply text-base font-medium text-gray-800 dark:text-gray-200;
}
.header-actions {
@apply flex items-center;
}
.action-btn,
.close-btn {
@apply w-8 h-8 flex items-center justify-center rounded-full cursor-pointer mx-1 text-gray-800 dark:text-gray-200;
@apply hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors;
.iconfont {
@apply text-xl;
}
}
.action-btn {
@apply text-gray-500 dark:text-gray-400;
&:hover {
@apply text-red-500 dark:text-red-400;
}
}
}
&-content {
@apply h-[calc(70vh-60px)] overflow-hidden;
}
}
.empty-playlist {
@apply flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500;
.iconfont {
@apply text-5xl mb-4;
}
p {
@apply text-sm;
}
}
.music-play-list-content {
@apply pr-2 hover:bg-light-100 dark:hover:bg-dark-100;
&:hover {
.delete-btn {
@apply visible;
}
}
.delete-btn {
@apply pr-2 cursor-pointer invisible;
.iconfont {
@apply text-lg;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.playlist-panel {
position: fixed;
width: 100%;
height: 80vh;
top: auto;
bottom: 0; // 移动端底部留出导航栏高度
border-radius: 30px 30px 0 0;
border-left: none;
border-top: 1px solid theme('colors.gray.200');
box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.1);
&-header {
@apply text-center relative px-4;
&::before {
content: '';
position: absolute;
top: -15px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 5px;
border-radius: 5px;
background-color: rgba(150, 150, 150, 0.3);
}
}
&-content {
height: calc(80vh - 60px);
@apply px-4;
.delete-btn{
@apply visible;
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More