101 Commits

Author SHA1 Message Date
algerkong
e2cdd1d8d7 feat: 优化音源选择逻辑以去重 2025-04-23 10:46:29 +08:00
algerkong
c90cfbf3cd feat: 优化设置模块,合并默认设置与存储设置,初始化时读取设置 2025-04-23 09:52:07 +08:00
algerkong
2c5bfac439 🔧 chore: 移除不再使用的快捷键初始化功能 2025-04-23 09:05:24 +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
alger
b7de5fc173 🌈 style: v4.3.0 2025-04-13 00:04:48 +08:00
alger
7bc8405df0 feat: 优化歌单加载逻辑 2025-04-12 23:51:37 +08:00
alger
a7f2045c7b feat: 添加统计服务 2025-04-12 23:16:12 +08:00
alger
b9b52f4d9f feat:确保仅在 Electron 中调用 API 发送歌曲数据 2025-04-12 13:05:08 +08:00
alger
09f8837fe4 feat: 增加无限滚动页面大小至100,以优化历史和收藏视图的加载体验 2025-04-12 12:37:08 +08:00
alger
d7fea7f888 feat: 优化歌单歌曲播放处理 2025-04-12 12:32:19 +08:00
alger
bb7d1e332f feat: 优化音乐封面显示逻辑,确保在缺失封面时使用默认图片,并更新推荐专辑组件以显示封面 2025-04-11 20:07:51 +08:00
alger
2dc907a20f 🌈 style: 修改依赖 2025-04-11 19:37:45 +08:00
Alger
ad3e52f6e1 Merge pull request #126 from algerkong/feat/music-list-search
 feat: 添加搜索功能至歌曲列表(可搜索名称 歌手 专辑),支持拼音匹配,优化播放列表加载逻辑,更好适配超大歌单
2025-04-11 19:36:10 +08:00
alger
b593ca3011 feat: 添加搜索功能至歌曲列表(可搜索名称 歌手 专辑),支持拼音匹配,优化播放列表加载逻辑,更好适配超大歌单 2025-04-11 19:35:21 +08:00
alger
988418e8d1 feat: 优化歌曲下载逻辑 2025-04-10 22:15:58 +08:00
Alger
1922311238 Merge pull request #123 from algerkong/feat/down-new
 feat: 歌曲下载内置封面歌词歌曲信息等,添加无限制下载功能,优化下载管理,支持清空下载记录
2025-04-10 00:27:48 +08:00
alger
3b1488f147 feat: 歌曲下载内置封面歌词歌曲信息等,添加无限制下载功能,优化下载管理,支持清空下载记录 2025-04-10 00:26:58 +08:00
alger
5f4b53c167 feat: 添加直接播放歌单功能,优化播放列表加载逻辑,支持异步加载完整歌单 2025-04-09 22:27:52 +08:00
alger
e17941dfb0 feat: 调整迷你播放栏列表项高度,从52px更改为69px,以优化滚动体验 2025-04-06 21:11:17 +08:00
Alger
9aff694116 Merge pull request #119 from algerkong/feat/shortcut
 feat: 添加快捷键管理功能,支持全局和应用内快捷键的启用/禁用,优化快捷键配置界面
2025-04-05 20:36:13 +08:00
algerkong
c2983ba079 feat: 添加快捷键管理功能,支持全局和应用内快捷键的启用/禁用,优化快捷键配置界面
feat: #117
2025-04-05 20:33:34 +08:00
algerkong
541ff2b76c feat: 优化桌面歌词 歌词行动态样式计算,提升歌词显示效果
feat: #115
2025-04-04 22:46:23 +08:00
algerkong
55b50d764b feat: 优化歌词界面配置 2025-04-04 22:19:35 +08:00
algerkong
30ff7b2930 feat: 优化历史歌曲获取功能, 分离网易云音乐和B站视频处理逻辑 2025-04-04 21:16:27 +08:00
algerkong
a24f901d1d feat: 优化B站视频代理URL获取逻辑 2025-04-04 21:16:09 +08:00
alger
7f7d41f883 feat: 更新迷你播放栏在页面显示逻辑 2025-04-02 00:07:37 +08:00
alger
7b27cf5bc6 fix: 修复类型问题 2025-04-01 23:34:17 +08:00
alger
ad8f7af3a9 feat: 更新至 v4.2.0
## v4.2.0

###  新功能
- 添加迷你播放器模式 ([0f55795](https://github.com/algerkong/AlgerMusicPlayer/commit/0f55795))
- 更新网易云音乐API版本,添加B站视频搜索功能和播放器组件 ([280fec1](https://github.com/algerkong/AlgerMusicPlayer/commit/280fec1))
- mac端添加状态栏 显示当前播放歌曲和操作按钮 ([374a7a8](https://github.com/algerkong/AlgerMusicPlayer/commit/374a7a8))
- 添加音频URL过期事件监听,自动重新获取B站和网易云音乐音频URL并恢复播放 ([ee6e9d4](https://github.com/algerkong/AlgerMusicPlayer/commit/ee6e9d4))
- 优化搜索功能,改进搜索历史管理和路由处理逻辑 ([477f8bb](https://github.com/algerkong/AlgerMusicPlayer/commit/477f8bb))
- 在播放列表中添加歌曲删除功能,优化播放列表管理逻辑 ([a5f694e](https://github.com/algerkong/AlgerMusicPlayer/commit/a5f694e)) (#94)
- 优化歌词窗口字体控制按钮样式 ([c5e50c9](https://github.com/algerkong/AlgerMusicPlayer/commit/c5e50c9))
- 优化首页banner加载逻辑 ([01ccad4](https://github.com/algerkong/AlgerMusicPlayer/commit/01ccad4))
- 优化歌手详情页面 由抽屉改为页面 ([dfb8f55](https://github.com/algerkong/AlgerMusicPlayer/commit/dfb8f55))
- 增加用户关注列表和关注用户详情页 可查看听歌排行和用户歌单 ([2924ad6](https://github.com/algerkong/AlgerMusicPlayer/commit/2924ad6))
- 优化进度条 鼠标悬停直接显示进度信息 ([9ce872e](https://github.com/algerkong/AlgerMusicPlayer/commit/9ce872e))
- 优化应用更新下载功能 可后台下载 弹出下载完成提示 不再自动关闭应用 ([23b2340](https://github.com/algerkong/AlgerMusicPlayer/commit/23b2340))

### 🐛 Bug 修复
- 修复进度条多次拖动和多次暂停播放引发的歌曲重复播放bug ([cfe197c](https://github.com/algerkong/AlgerMusicPlayer/commit/cfe197c)) (#104)
- 修复关闭按钮最小化 还在任务栏显示的bug ([e0d1305](https://github.com/algerkong/AlgerMusicPlayer/commit/e0d1305))
- 修复播放列表中歌曲删除时类型不匹配的问题 ([8d6d052](https://github.com/algerkong/AlgerMusicPlayer/commit/8d6d052))
2025-04-01 23:25:52 +08:00
alger
2599766e3e feat: cursor rule 2025-04-01 23:25:19 +08:00
alger
0f55795ca9 feat: 添加迷你模式功能,支持迷你窗口的显示与隐藏,更新设置项以控制迷你播放栏和歌词显示,优化路由管理以适应迷你模式 2025-04-01 23:22:26 +08:00
alger
8d6d0527db 🐛fix: 修复播放列表中歌曲删除时类型不匹配的问题,确保正确移除歌曲 2025-03-31 23:07:31 +08:00
alger
374a7a837d feat: mac添加音乐控制图标 , 托盘菜单项,更新播放状态和当前歌曲信息的逻辑
feat #105
2025-03-31 23:05:19 +08:00
alger
e0d13057c3 🐛fix: 修改标题栏行为,将最小化功能更改为托盘显示,优化窗口管理逻辑
fix #98
2025-03-31 23:04:35 +08:00
alger
23b2340169 feat: 优化更新提示对话框,支持文件路径复制和后台下载功能,优化安装流程
feat: #100
2025-03-31 23:01:03 +08:00
Alger
7e826311fe Merge pull request #104 from algerkong/fix/play-error
 feat: 优化音频播放进度更新逻辑,添加拖动滑块时的状态管理和节流处理
2025-03-31 22:58:36 +08:00
alger
cfe197c805 feat: 优化音频播放进度更新逻辑,添加拖动滑块时的状态管理和节流处理 2025-03-31 22:57:00 +08:00
Alger
230132904e Merge pull request #102 from algerkong/feat/bilibili-play
添加 bilibili资源搜索播放
2025-03-31 22:53:35 +08:00
alger
fb44ae45cc Merge branch 'dev_electron' into feat/bilibili-play 2025-03-31 22:48:59 +08:00
alger
9ce872eebe feat: 优化播放条滑块提示样式,添加滑块悬停提示功能 2025-03-30 12:56:42 +08:00
alger
ee6e9d43fd feat: 添加音频URL过期事件监听,自动重新获取B站和网易云音乐音频URL并恢复播放 2025-03-30 12:40:39 +08:00
alger
1a440fad09 feat: 添加B站音频URL获取功能,优化播放器逻辑,删除不再使用的BilibiliPlayer和MusicBar组件 2025-03-30 01:20:28 +08:00
alger
477f8bb99b feat: 优化搜索功能,改进搜索历史管理和路由处理逻辑 2025-03-30 00:18:44 +08:00
Alger
56c3ca1cce Merge pull request #94 from algerkong/feat/del-playlist
 feat: 在播放列表中添加歌曲删除功能,优化播放列表管理逻辑
2025-03-29 23:28:47 +08:00
alger
a5f694ea72 feat: 在播放列表中添加歌曲删除功能,优化播放列表管理逻辑
feat: #92
2025-03-29 23:26:26 +08:00
alger
280fec1990 feat: 更新网易云音乐 API 版本,添加 B站视频搜索功能和播放器组件 2025-03-29 23:19:51 +08:00
alger
c5e50c9fd5 feat: 优化歌词窗口字体控制按钮样式 2025-03-29 20:53:47 +08:00
alger
01ccad4df7 feat: 优化首页banner加载逻辑 2025-03-29 20:53:10 +08:00
alger
dfb8f55fba feat: 添加新的歌手详情页面 2025-03-29 20:52:50 +08:00
alger
2924ad6c18 feat: 增加用户关注列表 和 用户详情页 2025-03-24 22:54:04 +08:00
alger
9f5bac29a0 🚀new: v4.1.0 更新
### 🐛 Bug 修复
- 修复歌词窗口处理逻辑,解决 Windows 系统下桌面歌词窗口拖动问题
- 解决歌词初始化重复播放问题

###  新功能
- 优化移动端和网页端效果和体验
- 增加系统控制的音频服务的上一曲和下一曲功能
- 优化用户数据加载逻辑和错误处理
- 增强语言切换功能
- 首页添加用户歌单推荐
- 优化音频监听器初始化和设置保存逻辑

### 🔄 重构
- 将 Vuex 替换为 Pinia 状态管理
- 更新依赖版本
2025-03-23 12:33:13 +08:00
Alger
2fe1f0c04c Merge pull request #83 from algerkong/fix/duplicate-playback
 feat: 增强歌词窗口处理逻辑,修复可能引起的歌词初始化重复播放问题
2025-03-23 00:49:08 +08:00
alger
2a12f57cb2 feat: 增强歌词窗口处理逻辑,修复可能引起的歌词初始化重复播放问题 2025-03-23 00:47:01 +08:00
alger
4c10533a3d 🛠️ lint: 修复格式问题 2025-03-23 00:33:49 +08:00
alger
cda440b01a 🛠️ feat: 移除不必要的字体设置 2025-03-23 00:28:42 +08:00
alger
7b9e23743b lint: 修复格式问题 2025-03-22 15:01:38 +08:00
alger
e43270f35d lint: 修复格式问题 2025-03-22 14:54:24 +08:00
alger
9431faf932 feat: 优化移动端和网页端效果和体验
feat: #82
2025-03-22 13:45:23 +08:00
Alger
be03b5f8fc Merge pull request #82 from algerkong/feat/next-play
 feat: 增加音频服务的上一曲和下一曲功能
2025-03-22 10:40:19 +08:00
alger
8a414d0c25 feat: 增加音频服务的上一曲和下一曲功能 2025-03-22 10:37:57 +08:00
alger
f9fd9afcdd feat: 优化用户数据加载逻辑和错误处理 2025-03-22 10:31:05 +08:00
alger
b114cf4a33 feat: 增强语言切换功能和用户播放列表显示 2025-03-22 10:30:57 +08:00
alger
fa39d4ca55 feat: 优化音频监听器初始化和设置保存逻辑
- 在 App.vue 中引入 initAudioListeners 函数,确保在播放音乐时初始化音频监听器。
- 在 MusicHook.ts 中重构音频监听器的初始化逻辑,增加音频加载的超时处理。
- 在设置页面中实现防抖保存功能,避免频繁更新设置,提高性能和用户体验。

这些更改旨在提升音频播放的稳定性和设置管理的效率。
2025-03-21 00:19:15 +08:00
alger
650e4ff786 🔧 feat: 更新依赖版本 修复类型错误 优化首页推荐样式 2025-03-20 01:07:39 +08:00
alger
e355341596 🦄 refactor: 重构代码将 Vuex替换为 Pinia
集成 Pinia 状态管理
2025-03-19 22:48:28 +08:00
algerkong
4fa5ed0ca6 feat: 更新依赖和配置,增强开发体验
- 在 electron.vite.config.ts 中启用 Vue DevTools 插件
- 更新 package.json 中多个依赖版本,确保兼容性和性能
- 调整 tsconfig.node.json 的配置,优化模块解析
- 删除不再使用的组件 PlaylistType.vue、RecommendAlbum.vue、RecommendSinger.vue 和 RecommendSonglist.vue
- 在请求处理逻辑中改进错误日志输出,使用 console.error 替代 console.log
- 在首页视图中替换推荐歌手组件为顶部横幅组件

这些更改旨在提升开发效率和用户体验,确保项目的稳定性和可维护性。
2025-03-19 21:25:32 +08:00
alger
df9a1370c3 🐞 fix: 添加文件名清理功能以处理非法字符
- 新增 sanitizeFilename 函数,清理文件名中的非法字符
- 在下载音乐功能中应用清理后的文件名,确保文件名有效性

fixed: #78
2025-03-14 21:19:23 +08:00
alger
6a8813531f 🐞 fix: 修复歌词窗口拖动变大问题和多屏幕支持,优化字体样式
fixed: #77
2025-03-11 23:28:04 +08:00
alger
e5e45148c3 feat: 优化标题栏交互和下载按钮
- 为非 Electron 环境添加下载桌面版按钮
- 调整标题栏按钮显示逻辑,支持 Web 和桌面端不同交互
- 新增打开下载页面的方法,增强用户引导体验
2025-03-09 19:34:05 +08:00
alger
4a66796747 🚀 chore: 优化 GitHub Actions Web 部署工作 2025-03-09 12:13:19 +08:00
alger
7f8ab8be7c 🔒 chore: 添加 GitHub 部署密钥到 .gitignore 2025-03-08 23:52:32 +08:00
alger
ce276df55c feat: 优化赞赏支持 2025-03-08 23:22:56 +08:00
alger
ccc59ea893 🔧 fix: 优化音频服务和EQ设置的跨平台兼容性 2025-03-08 21:27:05 +08:00
alger
0b409f38d6 🚀 release: v4.0.0 2025-03-08 20:58:53 +08:00
alger
f9878ed88a feat: 优化歌词窗口交互和同步机制
- 增强歌词窗口数据同步逻辑,支持实时更新和状态管理
- 添加歌词窗口关闭事件监听和状态处理
- 优化无歌词时的默认提示和窗口行为
- 实现歌词窗口定时同步机制,提升用户体验
- 修复歌词窗口打开和关闭时的状态控制
- 国际化支持无歌曲播放时的提示文案
2025-03-08 19:00:50 +08:00
alger
e43e85480d feat: 增强音频播放状态管理和进度恢复功能
- 实现全局进度动画管理,优化歌词进度更新机制
- 新增音频播放进度本地存储和恢复功能
- 优化音频服务初始化和播放状态控制
- 改进音频上下文和 Howler 初始化逻辑
- 增加播放状态和进度的本地持久化支持
2025-03-08 18:31:46 +08:00
Alger
b97170d1b2 Update README.md 2025-03-08 17:07:32 +08:00
Alger
b9aa1d574a Merge pull request #75 from algerkong/feat/music-eq
 feat: 添加EQ音效调节功能 实时调节以及多个预设提供
2025-03-07 22:50:07 +08:00
alger
dd7b06d7e5 feat: 添加EQ音效调节功能 实时调节以及多个预设提供 2025-03-07 01:14:35 +08:00
alger
ddafcfba10 🔧 chore: 移除网站访问统计脚本和无用的统计显示元素 2025-03-05 23:03:05 +08:00
Alger
da5b8c408a Merge pull request #72 from algerkong/fix/random-music
fix: 修复随机播放模式 手动下一首不是随机的问题
2025-03-04 19:32:29 +08:00
alger
fb35d42fc4 fix: 修复随机播放模式 手动下一首不是随机的问题 2025-03-04 19:29:46 +08:00
Alger
dfd5d4c8b7 Merge pull request #71 from algerkong/fix/music-list-play
Fix/music list play
2025-03-02 22:49:00 +08:00
alger
e5309cedee feat: 音乐列表加载优化
- 重构音乐列表加载逻辑,提升数据加载性能和用户体验
- 新增歌曲总数显示,优化滚动加载和状态管理
- 改进歌曲数据格式化和异步加载处理
2025-03-02 08:27:07 +08:00
alger
d335f57a1a feat: 优化音乐列表加载策略,提升异步加载稳定性和错误处理 2025-03-01 10:57:06 +08:00
alger
c703d9c197 feat: 优化音乐列表加载和播放逻辑,增强性能和用户体验 2025-02-28 19:52:00 +08:00
alger
87a0ceb5b0 feat: 优化WEB下载应用程序代理 2025-02-28 19:50:53 +08:00
129 changed files with 11906 additions and 2203 deletions

View File

@@ -0,0 +1,92 @@
---
description: 这个规则是项目描述
globs:
alwaysApply: false
---
您是 TypeScript、Node.js、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 个最可能的来源,将其与历史错误日志、相关系统状态和预期行为进行交叉验证。如果发现不一致,调整你的假设。
- 在添加日志时,确保它们被策略性地放置,以便同时确认或排除多个潜在原因。如果日志不支持你的假设,请先提出替代的调试策略,再继续深入分析。
- 在实施修复之前,先总结问题现象、经过验证的假设,以及预期的日志输出,以确认问题是否真正得到解决。

148
.cursor/rules/project.mdc Normal file
View File

@@ -0,0 +1,148 @@
---
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

@@ -5,7 +5,6 @@ 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',
@@ -38,6 +37,7 @@ module.exports = {
rules: { rules: {
'vue/require-default-prop': 'off', 'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'no-underscore-dangle': 'off',
'no-nested-ternary': 'off', 'no-nested-ternary': 'off',
'no-console': 'off', 'no-console': 'off',
'no-await-in-loop': 'off', 'no-await-in-loop': 'off',

51
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Deploy Web
on:
push:
branches:
- dev_electron # 或者您的主分支名称
workflow_dispatch: # 允许手动触发
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: 创建环境变量文件
run: |
echo "VITE_API=${{ secrets.VITE_API }}" > .env.production.local
echo "VITE_API_MUSIC=${{ secrets.VITE_API_MUSIC }}" >> .env.production.local
# 添加其他需要的环境变量
cat .env.production.local # 查看创建的文件内容,调试用
- name: Install Dependencies
run: npm install
- name: Build
run: npm run build
- name: Deploy to Server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.DEPLOY_KEY }}
source: "out/renderer/*"
target: ${{ secrets.DEPLOY_PATH }}
strip_components: 2
- name: Execute Remote Commands
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd ${{ secrets.DEPLOY_PATH }}
echo "部署完成于 $(date)"

5
.gitignore vendored
View File

@@ -16,9 +16,14 @@ dist.zip
.vscode .vscode
bun.lockb bun.lockb
bun.lock
.env.*.local .env.*.local
out out
.cursorrules .cursorrules
.github/deploy_keys
resources/android/**/*

View File

@@ -1,9 +1,23 @@
# 更新日志 # 更新日志
## v3.9.3 ## v4.4.0
## 更新时间 2025 年 4 月 23 日 00:16
> 如果更新遇到问题,请前往 <a href="http://donate.alger.fun/download" target="_blank">下载 AlgerMusicPlayer</a>
> 帮我点个 star <a href="https://github.com/algerkong/AlgerMusicPlayer" target="_blank">github star</a>
> 请我喝咖啡 ☕️ <a href="http://donate.alger.fun/donate" target="_blank">赏你</a>
> QQ群 976962720
### ✨ 新功能 ### ✨ 新功能
- 实现国际化i18n功能 - 优化音源解析功能添加音源配置添加GD音乐台解析支持 ([ed9cf9c](https://github.com/algerkong/AlgerMusicPlayer/commit/ed9cf9c))
- 增加动态代理节点获取和缓存机制
- 优化更新检查逻辑,增加多个代理源支持 ### 🐛 Bug 修复
- 修改捐赠列表 API - 修复下载管理切换 tab 程序卡死问题 ([27d5bd8](https://github.com/algerkong/AlgerMusicPlayer/commit/27d5bd8)) (#153)
- 修复歌曲播放地址缓存导致播放失败问题,添加过期时间 ([76e55d4](https://github.com/algerkong/AlgerMusicPlayer/commit/76e55d4))
- 修复 Electron 版本更新导致的桌面歌词窗口出现边框的问题 ([a676136](https://github.com/algerkong/AlgerMusicPlayer/commit/a676136))
- 优化底部菜单位置和间距 修复移动端菜单不显示问题 ([25b90fa](https://github.com/algerkong/AlgerMusicPlayer/commit/25b90fa))
### 🔧 其他变更
- 更新项目依赖 ([35b9cbf](https://github.com/algerkong/AlgerMusicPlayer/commit/35b9cbf))([7624a1a](https://github.com/algerkong/AlgerMusicPlayer/commit/7624a1a))

View File

@@ -45,16 +45,16 @@ QQ群:789288579
- Naive UI - 基于 Vue 3 的组件库 - Naive UI - 基于 Vue 3 的组件库
## 咖啡☕️ ## 赞赏☕️
[赞赏列表](http://donate.alger.fun/)
| 微信 | 支付宝 | | 微信赞赏 | 支付宝赞赏 |
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: | | :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
| <img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width=200> | <img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Wechat QRcode" width=200> | | <img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width=200> <br><small>喝点咖啡继续干</small> | <img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Wechat QRcode" width=200> <br><small>来包辣条吧~</small> |
## Stargazers over time ## 项目统计
[![Stargazers over time](https://starchart.cc/algerkong/AlgerMusicPlayer.svg?variant=adaptive)](https://starchart.cc/algerkong/AlgerMusicPlayer) [![Stargazers over time](https://starchart.cc/algerkong/AlgerMusicPlayer.svg?variant=adaptive)](https://starchart.cc/algerkong/AlgerMusicPlayer)
![Alt](https://repobeats.axiom.co/api/embed/c4d01b3632e241c90cdec9508dfde86a7f54c9f5.svg "Repobeats analytics image")

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

View File

@@ -5,6 +5,7 @@ import AutoImport from 'unplugin-auto-import/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'; import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite'; import Components from 'unplugin-vue-components/vite';
import viteCompression from 'vite-plugin-compression'; import viteCompression from 'vite-plugin-compression';
import VueDevTools from 'vite-plugin-vue-devtools';
export default defineConfig({ export default defineConfig({
main: { main: {
@@ -23,7 +24,7 @@ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
viteCompression(), viteCompression(),
// VueDevTools(), VueDevTools(),
AutoImport({ AutoImport({
imports: [ imports: [
'vue', 'vue',

View File

@@ -1,6 +1,6 @@
{ {
"name": "AlgerMusicPlayer", "name": "AlgerMusicPlayer",
"version": "3.9.3", "version": "4.4.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",
@@ -21,69 +21,71 @@
"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",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.1.7", "electron-updater": "^6.6.2",
"font-list": "^1.5.1", "font-list": "^1.5.1",
"netease-cloud-music-api-alger": "^4.25.0", "netease-cloud-music-api-alger": "^4.26.1",
"vue-i18n": "9" "node-id3": "^0.2.9",
"node-machine-id": "^1.1.12",
"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": "^34.0.0", "electron": "^35.2.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"electron-vite": "^2.3.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",
"naive-ui": "^2.41.0", "naive-ui": "^2.41.0",
"postcss": "^8.4.49", "pinia": "^3.0.1",
"pinyin-match": "^1.2.6",
"postcss": "^8.5.3",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"remixicon": "^4.2.0", "remixicon": "^4.6.0",
"sass": "^1.83.4", "sass": "^1.86.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",
"tunajs": "^1.0.15",
"typescript": "^5.5.2", "typescript": "^5.5.2",
"unplugin-auto-import": "^0.18.2", "unplugin-auto-import": "^19.1.1",
"unplugin-vue-components": "^0.27.4", "unplugin-vue-components": "^28.4.1",
"vfonts": "^0.1.0", "vite": "^6.2.2",
"vite": "^5.3.1",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "7.4.0", "vite-plugin-vue-devtools": "7.7.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vue-tsc": "^2.0.22", "vue-tsc": "^2.0.22"
"vuex": "^4.1.0"
}, },
"build": { "build": {
"appId": "com.alger.music", "appId": "com.alger.music",

BIN
resources/icons/next.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

BIN
resources/icons/pause.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

BIN
resources/icons/play.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

BIN
resources/icons/prev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -41,7 +41,12 @@ export default {
songCount: '{count} songs', songCount: '{count} songs',
tray: { tray: {
show: 'Show', show: 'Show',
quit: 'Quit' quit: 'Quit',
playPause: 'Play/Pause',
prev: 'Previous',
next: 'Next',
pause: 'Pause',
play: 'Play'
}, },
language: 'Language' language: 'Language'
}; };

View File

@@ -39,7 +39,18 @@ export default {
downloadFailed: 'Download failed, please try again or download manually', downloadFailed: 'Download failed, please try again or download manually',
startFailed: 'Start download failed, please try again or download manually', startFailed: 'Start download failed, please try again or download manually',
noDownloadUrl: noDownloadUrl:
'No suitable installation package found for the current system, please download manually' 'No suitable installation package found for the current system, please download manually',
installConfirmTitle: 'Install Update',
installConfirmContent: 'Do you want to close the application and install the update?',
manualInstallTip:
'If the installer does not open automatically after closing the application, please find the file in your download folder and open it manually.',
yesInstall: 'Install Now',
noThanks: 'Later',
fileLocation: 'File Location',
copy: 'Copy Path',
copySuccess: 'Path copied to clipboard',
copyFailed: 'Copy failed',
backgroundDownload: 'Background Download'
}, },
coffee: { coffee: {
title: 'Buy me a coffee', title: 'Buy me a coffee',
@@ -52,7 +63,8 @@ export default {
qqGroup: 'QQ group: 789288579', qqGroup: 'QQ group: 789288579',
messages: { messages: {
copySuccess: 'Copied to clipboard' copySuccess: 'Copied to clipboard'
} },
donateList: 'Buy me a coffee'
}, },
playlistType: { playlistType: {
title: 'Playlist Category', title: 'Playlist Category',
@@ -84,6 +96,14 @@ export default {
closeTitle: 'Choose how to close', closeTitle: 'Choose how to close',
minimizeToTray: 'Minimize to Tray', minimizeToTray: 'Minimize to Tray',
exitApp: 'Exit App', exitApp: 'Exit App',
rememberChoice: 'Remember my choice' rememberChoice: 'Remember my choice',
closeApp: 'Close App'
},
userPlayList: {
title: "{name}'s Playlist"
},
musicList: {
searchSongs: 'Search Songs',
noSearchResults: 'No search results'
} }
}; };

View File

@@ -2,5 +2,6 @@ export default {
description: description:
'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'
}; };

View File

@@ -1,6 +1,8 @@
export default { export default {
title: 'Download Manager', title: 'Download Manager',
localMusic: 'Local Music', localMusic: 'Local Music',
count: '{count} songs in total',
clearAll: 'Clear All',
tabs: { tabs: {
downloading: 'Downloading', downloading: 'Downloading',
downloaded: 'Downloaded' downloaded: 'Downloaded'
@@ -27,10 +29,21 @@ export default {
confirm: 'Delete', confirm: 'Delete',
cancel: 'Cancel', cancel: 'Cancel',
success: 'Successfully deleted', success: 'Successfully deleted',
failed: 'Failed to delete' failed: 'Failed to delete',
fileNotFound: 'File not found or moved, removed from records',
recordRemoved: 'Failed to delete file, but removed from records'
},
clear: {
title: 'Clear Download Records',
message:
'Are you sure you want to clear all download records? This will not delete the actual music files, but will clear all records.',
confirm: 'Clear',
cancel: 'Cancel',
success: 'Download records cleared'
}, },
message: { message: {
downloadComplete: '{filename} download completed', downloadComplete: '{filename} download completed',
downloadFailed: '{filename} download failed: {error}' downloadFailed: '{filename} download failed: {error}'
} },
loading: 'Loading...'
}; };

View File

@@ -10,6 +10,8 @@ export default {
volumeDown: 'Volume Down', volumeDown: 'Volume Down',
mute: 'Mute', mute: 'Mute',
unmute: 'Unmute', unmute: 'Unmute',
songNum: 'Song Number: {num}',
playFailed: 'Play Failed, Play Next Song',
playMode: { playMode: {
sequence: 'Sequence', sequence: 'Sequence',
loop: 'Loop', loop: 'Loop',
@@ -32,6 +34,8 @@ export default {
collapse: 'Collapse Lyrics', collapse: 'Collapse Lyrics',
like: 'Like', like: 'Like',
lyric: 'Lyric', lyric: 'Lyric',
noSongPlaying: 'No song playing',
eq: 'Equalizer',
playList: 'Play List', playList: 'Play List',
playMode: { playMode: {
sequence: 'Sequence', sequence: 'Sequence',
@@ -45,5 +49,29 @@ export default {
volume: 'Volume', volume: 'Volume',
favorite: 'Favorite {name}', favorite: 'Favorite {name}',
unFavorite: 'Unfavorite {name}' unFavorite: 'Unfavorite {name}'
},
eq: {
title: 'Equalizer',
reset: 'Reset',
on: 'On',
off: 'Off',
bass: 'Bass',
midrange: 'Midrange',
treble: 'Treble',
presets: {
flat: 'Flat',
pop: 'Pop',
rock: 'Rock',
classical: 'Classical',
jazz: 'Jazz',
electronic: 'Electronic',
hiphop: 'Hip-Hop',
rb: 'R&B',
metal: 'Metal',
vocal: 'Vocal',
dance: 'Dance',
acoustic: 'Acoustic',
custom: 'Custom'
}
} }
}; };

View File

@@ -56,6 +56,15 @@ 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'
}, },
@@ -71,6 +80,8 @@ export default {
shortcutDesc: 'Customize global shortcuts', shortcutDesc: 'Customize global shortcuts',
download: 'Download Management', download: 'Download Management',
downloadDesc: 'Always show download list button', downloadDesc: 'Always show download list button',
unlimitedDownload: 'Unlimited Download',
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'
}, },
@@ -181,6 +192,13 @@ export default {
default: 'Default', default: 'Default',
light: 'Light', light: 'Light',
dark: 'Dark' dark: 'Dark'
},
hideMiniPlayBar: 'Hide Mini Play Bar',
hideLyrics: 'Hide Lyrics',
tabs: {
interface: 'Interface',
display: 'Display',
typography: 'Typography'
} }
}, },
shortcutSettings: { shortcutSettings: {
@@ -190,6 +208,8 @@ export default {
shortcutConflict: 'Shortcut Conflict', shortcutConflict: 'Shortcut Conflict',
inputPlaceholder: 'Click to input shortcut', inputPlaceholder: 'Click to input shortcut',
resetShortcuts: 'Reset', resetShortcuts: 'Reset',
disableAll: 'Disable All',
enableAll: 'Enable All',
togglePlay: 'Play/Pause', togglePlay: 'Play/Pause',
prevPlay: 'Previous', prevPlay: 'Previous',
nextPlay: 'Next', nextPlay: 'Next',
@@ -197,12 +217,18 @@ export default {
volumeDown: 'Volume Down', volumeDown: 'Volume Down',
toggleFavorite: 'Favorite/Unfavorite', toggleFavorite: 'Favorite/Unfavorite',
toggleWindow: 'Show/Hide Window', toggleWindow: 'Show/Hide Window',
scopeGlobal: 'Global',
scopeApp: 'App Only',
enabled: 'Enabled',
disabled: 'Disabled',
messages: { messages: {
resetSuccess: 'Shortcuts reset successfully, please save', resetSuccess: 'Shortcuts reset successfully, please save',
conflict: 'Shortcut conflict, please reset', conflict: 'Shortcut conflict, please reset',
saveSuccess: 'Shortcuts saved successfully', saveSuccess: 'Shortcuts saved successfully',
saveError: 'Failed to save shortcuts', saveError: 'Failed to save shortcuts',
cancelEdit: 'Edit cancelled' cancelEdit: 'Edit cancelled',
disableAll: 'All shortcuts disabled, please save to apply',
enableAll: 'All shortcuts enabled, please save to apply'
} }
} }
}; };

View File

@@ -13,6 +13,6 @@ export default {
downloadFailed: 'Download failed', downloadFailed: 'Download failed',
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' getUrlFailed: 'Failed to get music download URL, please check if logged in'
} }
}; };

View File

@@ -13,6 +13,27 @@ export default {
title: 'Listening History', title: 'Listening History',
playCount: '{count} times' playCount: '{count} times'
}, },
follow: {
title: 'Follow List',
viewPlaylist: 'View Playlist',
noFollowings: 'No Followings',
loadMore: 'Load More',
noSignature: 'This guy is lazy, nothing left'
},
follower: {
title: 'Follower List',
noFollowers: 'No Followers',
loadMore: 'Load More'
},
detail: {
playlists: 'Playlists',
records: 'Listening History',
noPlaylists: 'No Playlists',
noRecords: 'No Listening History',
artist: 'Artist',
noSignature: 'This guy is lazy, nothing left',
invalidUserId: 'Invalid User ID'
},
message: { message: {
loadFailed: 'Failed to load user page', loadFailed: 'Failed to load user page',
deleteSuccess: 'Successfully deleted', deleteSuccess: 'Successfully deleted',

View File

@@ -41,6 +41,11 @@ export default {
language: '语言', language: '语言',
tray: { tray: {
show: '显示', show: '显示',
quit: '退出' quit: '退出',
playPause: '播放/暂停',
prev: '上一首',
next: '下一首',
pause: '暂停',
play: '播放'
} }
}; };

View File

@@ -38,7 +38,17 @@ export default {
nowUpdate: '立即更新', nowUpdate: '立即更新',
downloadFailed: '下载失败,请重试或手动下载', downloadFailed: '下载失败,请重试或手动下载',
startFailed: '启动下载失败,请重试或手动下载', startFailed: '启动下载失败,请重试或手动下载',
noDownloadUrl: '未找到适合当前系统的安装包,请手动下载' noDownloadUrl: '未找到适合当前系统的安装包,请手动下载',
installConfirmTitle: '安装更新',
installConfirmContent: '是否关闭应用并安装更新?',
manualInstallTip: '如果关闭应用后没有正常弹出安装程序,请至下载文件夹查找文件并手动打开。',
yesInstall: '立即安装',
noThanks: '稍后安装',
fileLocation: '文件位置',
copy: '复制路径',
copySuccess: '路径已复制到剪贴板',
copyFailed: '复制失败',
backgroundDownload: '后台下载'
}, },
coffee: { coffee: {
title: '请我喝咖啡', title: '请我喝咖啡',
@@ -51,7 +61,8 @@ export default {
qqGroup: 'QQ群789288579', qqGroup: 'QQ群789288579',
messages: { messages: {
copySuccess: '已复制到剪贴板' copySuccess: '已复制到剪贴板'
} },
donateList: '请我喝咖啡'
}, },
playlistType: { playlistType: {
title: '歌单分类', title: '歌单分类',
@@ -83,6 +94,14 @@ export default {
closeTitle: '请选择关闭方式', closeTitle: '请选择关闭方式',
minimizeToTray: '最小化到托盘', minimizeToTray: '最小化到托盘',
exitApp: '退出应用', exitApp: '退出应用',
rememberChoice: '记住我的选择' rememberChoice: '记住我的选择',
closeApp: '关闭应用'
},
userPlayList: {
title: '{name}的常听'
},
musicList: {
searchSongs: '搜索歌曲',
noSearchResults: '没有找到相关歌曲'
} }
}; };

View File

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

View File

@@ -1,6 +1,8 @@
export default { export default {
title: '下载管理', title: '下载管理',
localMusic: '本地音乐', localMusic: '本地音乐',
count: '共 {count} 首歌曲',
clearAll: '清空记录',
tabs: { tabs: {
downloading: '下载中', downloading: '下载中',
downloaded: '已下载' downloaded: '已下载'
@@ -27,10 +29,20 @@ export default {
confirm: '确定删除', confirm: '确定删除',
cancel: '取消', cancel: '取消',
success: '删除成功', success: '删除成功',
failed: '删除失败' failed: '删除失败',
fileNotFound: '文件不存在或已被移动,已从记录中移除',
recordRemoved: '文件删除失败,但已从记录中移除'
},
clear: {
title: '清空下载记录',
message: '确定要清空所有下载记录吗?此操作不会删除已下载的音乐文件,但将清空所有记录。',
confirm: '确定清空',
cancel: '取消',
success: '下载记录已清空'
}, },
message: { message: {
downloadComplete: '{filename} 下载完成', downloadComplete: '{filename} 下载完成',
downloadFailed: '{filename} 下载失败: {error}' downloadFailed: '{filename} 下载失败: {error}'
} },
loading: '加载中...'
}; };

View File

@@ -10,6 +10,8 @@ export default {
volumeDown: '音量减少', volumeDown: '音量减少',
mute: '静音', mute: '静音',
unmute: '取消静音', unmute: '取消静音',
songNum: '歌曲总数:{num}',
playFailed: '当前歌曲播放失败,播放下一首',
playMode: { playMode: {
sequence: '顺序播放', sequence: '顺序播放',
loop: '循环播放', loop: '循环播放',
@@ -32,6 +34,8 @@ export default {
collapse: '收起歌词', collapse: '收起歌词',
like: '喜欢', like: '喜欢',
lyric: '歌词', lyric: '歌词',
noSongPlaying: '没有正在播放的歌曲',
eq: '均衡器',
playList: '播放列表', playList: '播放列表',
playMode: { playMode: {
sequence: '顺序播放', sequence: '顺序播放',
@@ -44,6 +48,31 @@ export default {
next: '下一首', next: '下一首',
volume: '音量', volume: '音量',
favorite: '已收藏{name}', favorite: '已收藏{name}',
unFavorite: '已取消收藏{name}' unFavorite: '已取消收藏{name}',
miniPlayBar: '迷你播放栏'
},
eq: {
title: '均衡器',
reset: '重置',
on: '开启',
off: '关闭',
bass: '低音',
midrange: '中音',
treble: '高音',
presets: {
flat: '平坦',
pop: '流行',
rock: '摇滚',
classical: '古典',
jazz: '爵士',
electronic: '电子',
hiphop: '嘻哈',
rb: 'R&B',
metal: '金属',
vocal: '人声',
dance: '舞曲',
acoustic: '原声',
custom: '自定义'
}
} }
}; };

View File

@@ -0,0 +1,5 @@
"playback": {
"musicSources": "音源设置",
"musicSourcesDesc": "选择音乐解析使用的音源平台",
"musicSourcesWarning": "至少需要选择一个音源平台"
}

View File

@@ -56,6 +56,15 @@ export default {
dolby: '杜比全景声', dolby: '杜比全景声',
jymaster: '超清母带' jymaster: '超清母带'
}, },
musicSources: '音源设置',
musicSourcesDesc: '选择音乐解析使用的音源平台',
musicSourcesWarning: '至少需要选择一个音源平台',
musicUnblockEnable: '启用音乐解析',
musicUnblockEnableDesc: '开启后将尝试解析无法播放的音乐',
configureMusicSources: '配置音源',
selectedMusicSources: '已选音源:',
noMusicSources: '未选择音源',
gdmusicInfo: 'GD音乐台可自动解析多个平台音源自动选择最佳结果',
autoPlay: '自动播放', autoPlay: '自动播放',
autoPlayDesc: '重新打开应用时是否自动继续播放' autoPlayDesc: '重新打开应用时是否自动继续播放'
}, },
@@ -71,6 +80,8 @@ export default {
shortcutDesc: '自定义全局快捷键', shortcutDesc: '自定义全局快捷键',
download: '下载管理', download: '下载管理',
downloadDesc: '是否始终显示下载列表按钮', downloadDesc: '是否始终显示下载列表按钮',
unlimitedDownload: '无限制下载',
unlimitedDownloadDesc: '开启后将无限制下载音乐(可能出现下载失败的情况), 默认限制 300 首',
downloadPath: '下载目录', downloadPath: '下载目录',
downloadPathDesc: '选择音乐文件的下载位置' downloadPathDesc: '选择音乐文件的下载位置'
}, },
@@ -181,6 +192,13 @@ export default {
default: '默认', default: '默认',
light: '亮色', light: '亮色',
dark: '暗色' dark: '暗色'
},
hideMiniPlayBar: '隐藏迷你播放栏',
hideLyrics: '隐藏歌词',
tabs: {
interface: '界面',
typography: '文字',
display: '显示'
} }
}, },
shortcutSettings: { shortcutSettings: {
@@ -190,6 +208,8 @@ export default {
shortcutConflict: '快捷键冲突', shortcutConflict: '快捷键冲突',
inputPlaceholder: '点击输入快捷键', inputPlaceholder: '点击输入快捷键',
resetShortcuts: '恢复默认', resetShortcuts: '恢复默认',
disableAll: '全部禁用',
enableAll: '全部启用',
togglePlay: '播放/暂停', togglePlay: '播放/暂停',
prevPlay: '上一首', prevPlay: '上一首',
nextPlay: '下一首', nextPlay: '下一首',
@@ -197,12 +217,18 @@ export default {
volumeDown: '音量减少', volumeDown: '音量减少',
toggleFavorite: '收藏/取消收藏', toggleFavorite: '收藏/取消收藏',
toggleWindow: '显示/隐藏窗口', toggleWindow: '显示/隐藏窗口',
scopeGlobal: '全局',
scopeApp: '应用内',
enabled: '启用',
disabled: '禁用',
messages: { messages: {
resetSuccess: '已恢复默认快捷键,请记得保存', resetSuccess: '已恢复默认快捷键,请记得保存',
conflict: '存在冲突的快捷键,请重新设置', conflict: '存在冲突的快捷键,请重新设置',
saveSuccess: '快捷键设置已保存', saveSuccess: '快捷键设置已保存',
saveError: '保存快捷键失败,请重试', saveError: '保存快捷键失败,请重试',
cancelEdit: '已取消修改' cancelEdit: '已取消修改',
disableAll: '已禁用所有快捷键,请记得保存',
enableAll: '已启用所有快捷键,请记得保存'
} }
} }
}; };

View File

@@ -13,6 +13,6 @@ export default {
downloadFailed: '下载失败', downloadFailed: '下载失败',
downloadQueued: '已加入下载队列', downloadQueued: '已加入下载队列',
addedToNextPlay: '已添加到下一首播放', addedToNextPlay: '已添加到下一首播放',
getUrlFailed: '获取音乐下载地址失败' getUrlFailed: '获取音乐下载地址失败,请检查是否登录'
} }
}; };

View File

@@ -13,6 +13,27 @@ export default {
title: '听歌排行', title: '听歌排行',
playCount: '{count}次' playCount: '{count}次'
}, },
follow: {
title: '关注列表',
viewPlaylist: '查看歌单',
noFollowings: '暂无关注',
loadMore: '加载更多',
noSignature: '这个家伙很懒,什么都没留下'
},
follower: {
title: '粉丝列表',
noFollowers: '暂无粉丝',
loadMore: '加载更多'
},
detail: {
playlists: '歌单',
records: '听歌排行',
noPlaylists: '暂无歌单',
noRecords: '暂无听歌记录',
artist: '歌手',
noSignature: '这个人很懒,什么都没留下',
invalidUserId: '用户ID无效'
},
message: { message: {
loadFailed: '加载用户页面失败', loadFailed: '加载用户页面失败',
deleteSuccess: '删除成功', deleteSuccess: '删除成功',

View File

@@ -9,7 +9,8 @@ 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 { initializeShortcuts, registerShortcuts } from './modules/shortcuts'; import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeTray, updateTrayMenu } from './modules/tray'; import { initializeStats, setupStatsHandlers } from './modules/statsService';
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';
@@ -50,6 +51,12 @@ function initialize() {
// 初始化托盘 // 初始化托盘
initializeTray(iconPath, mainWindow); initializeTray(iconPath, mainWindow);
// 初始化统计服务
initializeStats();
// 设置统计相关的IPC处理程序
setupStatsHandlers(ipcMain);
// 启动音乐API // 启动音乐API
startMusicApi(); startMusicApi();
@@ -109,11 +116,21 @@ if (!isSingleInstance) {
// 更新主进程的语言设置 // 更新主进程的语言设置
i18n.global.locale = locale; i18n.global.locale = locale;
// 更新托盘菜单 // 更新托盘菜单
updateTrayMenu(); updateTrayMenu(mainWindow);
// 通知所有窗口语言已更改 // 通知所有窗口语言已更改
mainWindow?.webContents.send('language-changed', locale); mainWindow?.webContents.send('language-changed', locale);
}); });
// 监听播放状态变化
ipcMain.on('update-play-state', (_, playing: boolean) => {
updatePlayState(playing);
});
// 监听当前歌曲变化
ipcMain.on('update-current-song', (_, song: any) => {
updateCurrentSong(song);
});
// 所有窗口关闭时的处理 // 所有窗口关闭时的处理
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {

View File

@@ -5,6 +5,12 @@ import path, { join } from 'path';
const store = new Store(); const store = new Store();
let lyricWindow: BrowserWindow | null = null; let lyricWindow: BrowserWindow | null = null;
// 跟踪拖动状态
let isDragging = false;
// 添加窗口大小变化防护
let originalSize = { width: 0, height: 0 };
const createWin = () => { const createWin = () => {
console.log('Creating lyric window'); console.log('Creating lyric window');
@@ -15,31 +21,81 @@ const createWin = () => {
y?: number; y?: number;
width?: number; width?: number;
height?: number; height?: number;
displayId?: number;
}) || {}; }) || {};
const { x, y, width, height } = windowBounds;
// 获取屏幕尺寸 const { x, y, width, height, displayId } = windowBounds;
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
// 验证保存的位置是否有效 // 获取所有屏幕的信息
const validPosition = const displays = screen.getAllDisplays();
x !== undefined && y !== undefined && x >= 0 && y >= 0 && x < screenWidth && y < screenHeight; let isValidPosition = false;
let targetDisplay = displays[0]; // 默认使用主显示器
// 如果有显示器ID尝试按ID匹配
if (displayId) {
const matchedDisplay = displays.find((d) => d.id === displayId);
if (matchedDisplay) {
targetDisplay = matchedDisplay;
console.log('Found matching display by ID:', displayId);
}
}
// 验证位置是否在任何显示器的范围内
if (x !== undefined && y !== undefined) {
for (const display of displays) {
const { bounds } = display;
if (
x >= bounds.x - 50 && // 允许一点偏移,避免卡在边缘
x < bounds.x + bounds.width + 50 &&
y >= bounds.y - 50 &&
y < bounds.y + bounds.height + 50
) {
isValidPosition = true;
targetDisplay = display;
break;
}
}
}
// 确保宽高合理
const defaultWidth = 800;
const defaultHeight = 200;
const maxWidth = 1600; // 设置最大宽度限制
const maxHeight = 800; // 设置最大高度限制
const validWidth = width && width > 0 && width <= maxWidth ? width : defaultWidth;
const validHeight = height && height > 0 && height <= maxHeight ? height : defaultHeight;
// 确定窗口位置
let windowX = isValidPosition ? x : undefined;
let windowY = isValidPosition ? y : undefined;
// 如果位置无效,默认在当前显示器中居中
if (windowX === undefined || windowY === undefined) {
windowX = targetDisplay.bounds.x + (targetDisplay.bounds.width - validWidth) / 2;
windowY = targetDisplay.bounds.y + (targetDisplay.bounds.height - validHeight) / 2;
}
lyricWindow = new BrowserWindow({ lyricWindow = new BrowserWindow({
width: width || 800, width: validWidth,
height: height || 200, height: validHeight,
x: validPosition ? x : undefined, x: windowX,
y: validPosition ? y : undefined, y: windowY,
frame: false, frame: false,
show: false, show: false,
transparent: true, transparent: true,
opacity: 1,
hasShadow: false, hasShadow: false,
alwaysOnTop: true, alwaysOnTop: true,
resizable: true,
roundedCorners: 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'
}); });
// 监听窗口关闭事件 // 监听窗口关闭事件
@@ -50,6 +106,20 @@ const createWin = () => {
} }
}); });
// 监听窗口大小变化事件,保存新的尺寸
lyricWindow.on('resize', () => {
// 如果正在拖动,忽略大小调整事件
if (isDragging) return;
if (lyricWindow && !lyricWindow.isDestroyed()) {
const [width, height] = lyricWindow.getSize();
const [x, y] = lyricWindow.getPosition();
// 保存窗口位置和大小
store.set('lyricWindowBounds', { x, y, width, height });
}
});
return lyricWindow; return lyricWindow;
}; };
@@ -118,6 +188,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
if (lyricWindow && !lyricWindow.isDestroyed()) { if (lyricWindow && !lyricWindow.isDestroyed()) {
lyricWindow.webContents.send('lyric-window-close'); lyricWindow.webContents.send('lyric-window-close');
mainWin.webContents.send('lyric-control-back', 'close'); mainWin.webContents.send('lyric-control-back', 'close');
mainWin.webContents.send('lyric-window-closed');
lyricWindow.destroy(); lyricWindow.destroy();
lyricWindow = null; lyricWindow = null;
} }
@@ -136,26 +207,75 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
} }
}); });
// 开始拖动时设置标志
ipcMain.on('lyric-drag-start', () => {
isDragging = true;
if (lyricWindow && !lyricWindow.isDestroyed()) {
// 记录原始窗口大小
const [width, height] = lyricWindow.getSize();
originalSize = { width, height };
// 在拖动时暂时禁用大小调整
lyricWindow.setResizable(false);
}
});
// 结束拖动时清除标志
ipcMain.on('lyric-drag-end', () => {
isDragging = false;
if (lyricWindow && !lyricWindow.isDestroyed()) {
// 确保窗口大小恢复原样
lyricWindow.setSize(originalSize.width, originalSize.height);
// 拖动结束后恢复可调整大小
lyricWindow.setResizable(true);
}
});
// 处理拖动移动 // 处理拖动移动
ipcMain.on('lyric-drag-move', (_, { deltaX, deltaY }) => { ipcMain.on('lyric-drag-move', (_, { deltaX, deltaY }) => {
if (!lyricWindow || lyricWindow.isDestroyed()) return; if (!lyricWindow || lyricWindow.isDestroyed() || !isDragging) return;
const [currentX, currentY] = lyricWindow.getPosition(); const [currentX, currentY] = lyricWindow.getPosition();
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
const [windowWidth, windowHeight] = lyricWindow.getSize();
// 计算新位置,确保窗口不会移出屏幕 // 使用记录的原始大小,而不是当前大小
const newX = Math.max(0, Math.min(currentX + deltaX, screenWidth - windowWidth)); const windowWidth = originalSize.width;
const newY = Math.max(0, Math.min(currentY + deltaY, screenHeight - windowHeight)); const windowHeight = originalSize.height;
lyricWindow.setPosition(newX, newY); // 计算新位置
const newX = currentX + deltaX;
const newY = currentY + deltaY;
// 保存新位置 try {
store.set('lyricWindowBounds', { // 获取当前鼠标所在的显示器
...lyricWindow.getBounds(), const mousePoint = screen.getCursorScreenPoint();
x: newX, const currentDisplay = screen.getDisplayNearestPoint(mousePoint);
y: newY
}); // 拖动期间使用setBounds确保大小不变使用false避免动画卡顿
lyricWindow.setBounds(
{
x: newX,
y: newY,
width: windowWidth,
height: windowHeight
},
false
);
// 更新存储的位置
const windowBounds = {
x: newX,
y: newY,
width: windowWidth,
height: windowHeight,
displayId: currentDisplay.id // 记录当前显示器ID有助于多屏幕处理
};
store.set('lyricWindowBounds', windowBounds);
} catch (error) {
console.error('Error during window drag:', error);
// 出错时尝试使用更简单的方法
lyricWindow.setPosition(newX, newY);
}
}); });
// 添加鼠标穿透事件处理 // 添加鼠标穿透事件处理

View File

@@ -0,0 +1,63 @@
import { app } from 'electron';
import Store from 'electron-store';
import { machineIdSync } from 'node-machine-id';
import os from 'os';
const store = new Store();
/**
* 获取设备唯一标识符
* 优先使用存储的ID如果没有则获取机器ID并存储
*/
export function getDeviceId(): string {
let deviceId = store.get('deviceId') as string | undefined;
if (!deviceId) {
try {
// 使用node-machine-id获取设备唯一标识
deviceId = machineIdSync(true);
} catch (error) {
console.error('获取机器ID失败:', error);
// 如果获取失败使用主机名和MAC地址组合作为备选方案
const networkInterfaces = os.networkInterfaces();
let macAddress = '';
// 尝试获取第一个非内部网络接口的MAC地址
Object.values(networkInterfaces).forEach((interfaces) => {
if (interfaces) {
interfaces.forEach((iface) => {
if (!iface.internal && !macAddress && iface.mac !== '00:00:00:00:00:00') {
macAddress = iface.mac;
}
});
}
});
deviceId = `${os.hostname()}-${macAddress}`.replace(/:/g, '');
}
// 存储设备ID
if (deviceId) {
store.set('deviceId', deviceId);
} else {
// 如果所有方法都失败使用随机ID
deviceId = Math.random().toString(36).substring(2, 15);
store.set('deviceId', deviceId);
}
}
return deviceId;
}
/**
* 获取系统信息
*/
export function getSystemInfo() {
return {
osType: os.type(),
osVersion: os.release(),
osArch: os.arch(),
platform: process.platform,
appVersion: app.getVersion()
};
}

View File

@@ -1,9 +1,14 @@
import axios from 'axios'; import axios from 'axios';
import { app, dialog, ipcMain, protocol, shell } from 'electron'; import { app, dialog, ipcMain, Notification, protocol, shell } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
import * as fs from 'fs'; import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
import * as NodeID3 from 'node-id3';
import * as path from 'path'; import * as path from 'path';
import { getStore } from './config';
const MAX_CONCURRENT_DOWNLOADS = 3; const MAX_CONCURRENT_DOWNLOADS = 3;
const downloadQueue: { url: string; filename: string; songInfo: any; type?: string }[] = []; const downloadQueue: { url: string; filename: string; songInfo: any; type?: string }[] = [];
let activeDownloads = 0; let activeDownloads = 0;
@@ -117,20 +122,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);
@@ -170,6 +192,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', {});
@@ -258,7 +287,18 @@ async function processDownloadQueue(event: Electron.IpcMainEvent) {
} }
/** /**
* 下载音乐功能 * 清理文件名中的非法字符
*/
function sanitizeFilename(filename: string): string {
// 替换 Windows 和 Unix 系统中的非法字符
return filename
.replace(/[<>:"/\\|?*]/g, '_') // 替换特殊字符为下划线
.replace(/\s+/g, ' ') // 将多个空格替换为单个空格
.trim(); // 移除首尾空格
}
/**
* 下载音乐和歌词
*/ */
async function downloadMusic( async function downloadMusic(
event: Electron.IpcMainEvent, event: Electron.IpcMainEvent,
@@ -273,12 +313,18 @@ async function downloadMusic(
let writer: fs.WriteStream | null = null; let writer: fs.WriteStream | null = null;
try { try {
const store = new Store(); // 使用配置Store来获取设置
const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads'); const configStore = getStore();
const downloadPath =
(configStore.get('set.downloadPath') as string) || app.getPath('downloads');
const apiPort = configStore.get('set.musicApiPort') || 30488;
// 清理文件名中的非法字符
const sanitizedFilename = sanitizeFilename(filename);
// 从URL中获取文件扩展名如果没有则使用传入的type或默认mp3 // 从URL中获取文件扩展名如果没有则使用传入的type或默认mp3
const urlExt = type ? `.${type}` : '.mp3'; const urlExt = type ? `.${type}` : '.mp3';
const filePath = path.join(downloadPath, `${filename}${urlExt}`); const filePath = path.join(downloadPath, `${sanitizedFilename}${urlExt}`);
// 检查文件是否已存在,如果存在则添加序号 // 检查文件是否已存在,如果存在则添加序号
finalFilePath = filePath; finalFilePath = filePath;
@@ -299,7 +345,9 @@ async function downloadMusic(
url, url,
method: 'GET', method: 'GET',
responseType: 'stream', responseType: 'stream',
timeout: 30000 // 30秒超时 timeout: 30000, // 30秒超时
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true })
}); });
writer = fs.createWriteStream(finalFilePath); writer = fs.createWriteStream(finalFilePath);
@@ -337,9 +385,121 @@ async function downloadMusic(
throw new Error('文件下载不完整'); throw new Error('文件下载不完整');
} }
// 下载歌词
let lyricData = null;
let lyricsContent = '';
try {
if (songInfo?.id) {
// 下载歌词,使用配置的端口
const lyricsResponse = await axios.get(
`http://localhost:${apiPort}/lyric?id=${songInfo.id}`
);
if (lyricsResponse.data && (lyricsResponse.data.lrc || lyricsResponse.data.tlyric)) {
lyricData = lyricsResponse.data;
// 处理歌词内容
if (lyricsResponse.data.lrc && lyricsResponse.data.lrc.lyric) {
lyricsContent = lyricsResponse.data.lrc.lyric;
// 如果有翻译歌词,合并到主歌词中
if (lyricsResponse.data.tlyric && lyricsResponse.data.tlyric.lyric) {
// 解析原歌词和翻译
const originalLyrics = parseLyrics(lyricsResponse.data.lrc.lyric);
const translatedLyrics = parseLyrics(lyricsResponse.data.tlyric.lyric);
// 合并歌词
const mergedLyrics = mergeLyrics(originalLyrics, translatedLyrics);
lyricsContent = mergedLyrics;
}
}
// 不再单独写入歌词文件只保存在ID3标签中
console.log('歌词已准备好将写入ID3标签');
}
}
} catch (lyricError) {
console.error('下载歌词失败:', lyricError);
// 继续处理,不影响音乐下载
}
// 下载封面
let coverImageBuffer: Buffer | null = null;
try {
if (songInfo?.picUrl || songInfo?.al?.picUrl) {
const picUrl = songInfo.picUrl || songInfo.al?.picUrl;
if (picUrl && picUrl !== '/images/default_cover.png') {
const coverResponse = await axios({
url: picUrl.replace('http://', 'https://'),
method: 'GET',
responseType: 'arraybuffer',
timeout: 10000
});
// 获取封面图片的buffer
coverImageBuffer = Buffer.from(coverResponse.data);
// 不再单独保存封面文件只保存在ID3标签中
console.log('封面已准备好将写入ID3标签');
}
}
} catch (coverError) {
console.error('下载封面失败:', coverError);
// 继续处理,不影响音乐下载
}
// 在写入ID3标签前先移除可能存在的旧标签
try {
NodeID3.removeTags(finalFilePath);
} catch (err) {
console.error('Error removing existing ID3 tags:', err);
}
// 强化ID3标签的写入格式
const artistNames =
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家';
const tags = {
title: filename,
artist: artistNames,
TPE1: artistNames,
TPE2: artistNames,
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
APIC: {
// 专辑封面
imageBuffer: coverImageBuffer,
type: {
id: 3,
name: 'front cover'
},
description: 'Album cover',
mime: 'image/jpeg'
},
USLT: {
// 歌词
language: 'chi',
description: 'Lyrics',
text: lyricsContent || ''
},
trackNumber: songInfo?.no || undefined,
year: songInfo?.publishTime
? new Date(songInfo.publishTime).getFullYear().toString()
: undefined
};
try {
const success = NodeID3.write(tags, finalFilePath);
if (!success) {
console.error('Failed to write ID3 tags');
} else {
console.log('ID3 tags written successfully');
}
} catch (err) {
console.error('Error writing ID3 tags:', err);
}
// 保存下载信息 // 保存下载信息
try { try {
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>; const songInfos = configStore.get('downloadedSongs', {}) as Record<string, any>;
const defaultInfo = { const defaultInfo = {
name: filename, name: filename,
ar: [{ name: '本地音乐' }], ar: [{ name: '本地音乐' }],
@@ -350,24 +510,48 @@ async function downloadMusic(
id: songInfo?.id || 0, id: songInfo?.id || 0,
name: songInfo?.name || filename, name: songInfo?.name || filename,
filename, filename,
picUrl: songInfo?.picUrl || defaultInfo.picUrl, picUrl: songInfo?.picUrl || songInfo?.al?.picUrl || defaultInfo.picUrl,
ar: songInfo?.ar || defaultInfo.ar, ar: songInfo?.ar || defaultInfo.ar,
al: songInfo?.al || {
picUrl: songInfo?.picUrl || defaultInfo.picUrl,
name: songInfo?.name || filename
},
size: totalSize, size: totalSize,
path: finalFilePath, path: finalFilePath,
downloadTime: Date.now(), downloadTime: Date.now(),
al: songInfo?.al || { picUrl: songInfo?.picUrl || defaultInfo.picUrl }, type: type || 'mp3',
type: type || 'mp3' lyric: lyricData
}; };
// 保存到下载记录 // 保存到下载记录
songInfos[finalFilePath] = newSongInfo; songInfos[finalFilePath] = newSongInfo;
store.set('downloadedSongs', songInfos); configStore.set('downloadedSongs', songInfos);
// 添加到下载历史 // 添加到下载历史
const history = downloadStore.get('history', []) as any[]; const history = downloadStore.get('history', []) as any[];
history.unshift(newSongInfo); history.unshift(newSongInfo);
downloadStore.set('history', history); downloadStore.set('history', history);
// 发送桌面通知
try {
const artistNames =
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/') ||
'未知艺术家';
const notification = new Notification({
title: '下载完成',
body: `${songInfo?.name || filename} - ${artistNames}`,
silent: false
});
notification.on('click', () => {
shell.showItemInFolder(finalFilePath);
});
notification.show();
} catch (notifyError) {
console.error('发送通知失败:', notifyError);
}
// 发送下载完成事件 // 发送下载完成事件
event.reply('music-download-complete', { event.reply('music-download-complete', {
success: true, success: true,
@@ -402,3 +586,56 @@ async function downloadMusic(
}); });
} }
} }
// 辅助函数 - 解析歌词文本成时间戳和内容的映射
function parseLyrics(lyricsText: string): Map<string, string> {
const lyricMap = new Map<string, string>();
const lines = lyricsText.split('\n');
for (const line of lines) {
// 匹配时间标签,形如 [00:00.000]
const timeTagMatches = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g);
if (!timeTagMatches) continue;
// 提取歌词内容(去除时间标签)
const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim();
if (!content) continue;
// 将每个时间标签与歌词内容关联
for (const timeTag of timeTagMatches) {
lyricMap.set(timeTag, content);
}
}
return lyricMap;
}
// 辅助函数 - 合并原文歌词和翻译歌词
function mergeLyrics(
originalLyrics: Map<string, string>,
translatedLyrics: Map<string, string>
): string {
const mergedLines: string[] = [];
// 对每个时间戳,组合原始歌词和翻译
for (const [timeTag, originalContent] of originalLyrics.entries()) {
const translatedContent = translatedLyrics.get(timeTag);
// 添加原始歌词行
mergedLines.push(`${timeTag}${originalContent}`);
// 如果有翻译,添加翻译行(时间戳相同,这样可以和原歌词同步显示)
if (translatedContent) {
mergedLines.push(`${timeTag}${translatedContent}`);
}
}
// 按时间顺序排序
mergedLines.sort((a, b) => {
const timeA = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
const timeB = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
return timeA.localeCompare(timeB);
});
return mergedLines.join('\n');
}

View File

@@ -7,66 +7,93 @@ ipcMain.on('get-platform', (event) => {
event.returnValue = process.platform; event.returnValue = process.platform;
}); });
// 定义快捷键配置接口
export interface ShortcutConfig {
key: string;
enabled: boolean;
scope: 'global' | 'app';
}
export interface ShortcutsConfig {
[key: string]: ShortcutConfig;
}
// 定义默认快捷键 // 定义默认快捷键
export const defaultShortcuts = { export const defaultShortcuts: ShortcutsConfig = {
togglePlay: 'CommandOrControl+Alt+P', togglePlay: { key: 'CommandOrControl+Alt+P', enabled: true, scope: 'global' },
prevPlay: 'CommandOrControl+Alt+Left', prevPlay: { key: 'Alt+Left', enabled: true, scope: 'global' },
nextPlay: 'CommandOrControl+Alt+Right', nextPlay: { key: 'Alt+Right', enabled: true, scope: 'global' },
volumeUp: 'CommandOrControl+Alt+Up', volumeUp: { key: 'Alt+Up', enabled: true, scope: 'app' },
volumeDown: 'CommandOrControl+Alt+Down', volumeDown: { key: 'Alt+Down', enabled: true, scope: 'app' },
toggleFavorite: 'CommandOrControl+Alt+L', toggleFavorite: { key: 'CommandOrControl+Alt+L', enabled: true, scope: 'app' },
toggleWindow: 'CommandOrControl+Alt+Shift+M' toggleWindow: { key: 'CommandOrControl+Alt+Shift+M', enabled: true, scope: 'global' }
}; };
let mainWindowRef: Electron.BrowserWindow | null = null; let mainWindowRef: Electron.BrowserWindow | null = null;
// 注册快捷键 // 注册快捷键
export function registerShortcuts(mainWindow: Electron.BrowserWindow) { export function registerShortcuts(
mainWindow: Electron.BrowserWindow,
shortcutsConfig?: ShortcutsConfig
) {
mainWindowRef = mainWindow; mainWindowRef = mainWindow;
const store = getStore(); const store = getStore();
const shortcuts = store.get('shortcuts'); const shortcuts =
shortcutsConfig || (store.get('shortcuts') as ShortcutsConfig) || defaultShortcuts;
// 注销所有已注册的快捷键 // 注销所有已注册的快捷键
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
// 显示/隐藏主窗口 // 对旧格式数据进行兼容处理
globalShortcut.register(shortcuts.toggleWindow, () => { if (shortcuts && typeof shortcuts.togglePlay === 'string') {
if (mainWindow.isVisible()) { // 将 shortcuts 强制转换为 unknown再转为 Record<string, string>
mainWindow.hide(); const oldShortcuts = { ...shortcuts } as unknown as Record<string, string>;
} else { const newShortcuts: ShortcutsConfig = {};
mainWindow.show();
Object.entries(oldShortcuts).forEach(([key, value]) => {
newShortcuts[key] = {
key: value,
enabled: true,
scope: ['volumeUp', 'volumeDown', 'toggleFavorite'].includes(key) ? 'app' : 'global'
};
});
store.set('shortcuts', newShortcuts);
registerShortcuts(mainWindow, newShortcuts);
return;
}
// 注册全局快捷键
Object.entries(shortcuts).forEach(([action, config]) => {
const { key, enabled, scope } = config as ShortcutConfig;
// 只注册启用且作用域为全局的快捷键
if (!enabled || scope !== 'global') return;
try {
switch (action) {
case 'toggleWindow':
globalShortcut.register(key, () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
break;
default:
globalShortcut.register(key, () => {
mainWindow.webContents.send('global-shortcut', action);
});
break;
}
} catch (error) {
console.error(`注册快捷键 ${key} 失败:`, error);
} }
}); });
// 播放/暂停 // 通知渲染进程更新应用内快捷键
globalShortcut.register(shortcuts.togglePlay, () => { mainWindow.webContents.send('update-app-shortcuts', shortcuts);
mainWindow.webContents.send('global-shortcut', 'togglePlay');
});
// 上一首
globalShortcut.register(shortcuts.prevPlay, () => {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
});
// 下一首
globalShortcut.register(shortcuts.nextPlay, () => {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
});
// 音量增加
globalShortcut.register(shortcuts.volumeUp, () => {
mainWindow.webContents.send('global-shortcut', 'volumeUp');
});
// 音量减少
globalShortcut.register(shortcuts.volumeDown, () => {
mainWindow.webContents.send('global-shortcut', 'volumeDown');
});
// 收藏当前歌曲
globalShortcut.register(shortcuts.toggleFavorite, () => {
mainWindow.webContents.send('global-shortcut', 'toggleFavorite');
});
} }
// 初始化快捷键 // 初始化快捷键
@@ -85,4 +112,11 @@ export function initializeShortcuts(mainWindow: Electron.BrowserWindow) {
registerShortcuts(mainWindowRef); registerShortcuts(mainWindowRef);
} }
}); });
// 监听快捷键更新事件
ipcMain.on('update-shortcuts', (_, shortcutsConfig: ShortcutsConfig) => {
if (mainWindowRef) {
registerShortcuts(mainWindowRef, shortcutsConfig);
}
});
} }

View File

@@ -0,0 +1,122 @@
import axios from 'axios';
import { app } from 'electron';
import Store from 'electron-store';
import { getDeviceId, getSystemInfo } from './deviceInfo';
const store = new Store();
// 统计服务配置
const STATS_API_URL = 'http://donate.alger.fun/state/api/stats';
/**
* 记录应用安装/启动
*/
export async function recordInstallation(): Promise<void> {
try {
const deviceId = getDeviceId();
const systemInfo = getSystemInfo();
// 发送请求到统计服务器
await axios.post(`${STATS_API_URL}/installation`, {
deviceId,
osType: systemInfo.osType,
osVersion: systemInfo.osVersion,
appVersion: systemInfo.appVersion
});
console.log('应用启动统计已记录');
// 记录最后一次启动时间
store.set('lastStartTime', new Date().toISOString());
} catch (error) {
console.error('记录应用启动统计失败:', error);
}
}
/**
* 设置 IPC 处理程序以接收渲染进程的统计请求
* @param ipcMain Electron IPC主对象
*/
export function setupStatsHandlers(ipcMain: Electron.IpcMain): void {
// 处理页面访问统计
ipcMain.handle('record-visit', async (_, page: string, userId?: string) => {
try {
const deviceId = getDeviceId();
await axios.post(`${STATS_API_URL}/visit`, {
deviceId,
userId,
page
});
return { success: true };
} catch (error) {
console.error('记录页面访问统计失败:', error);
return { success: false, error: (error as Error).message };
}
});
// 处理播放统计
ipcMain.handle(
'record-play',
async (
_,
songData: {
userId: string | null;
songId: string | number;
songName: string;
artistName: string;
duration?: number;
completedPlay?: boolean;
}
) => {
try {
const { songId, songName, artistName, duration = 0, completedPlay = false } = songData;
const deviceId = getDeviceId();
await axios.post(`${STATS_API_URL}/play`, {
deviceId,
userId: songData.userId,
songId: songId.toString(),
songName,
artistName,
duration,
completedPlay
});
return { success: true };
} catch (error) {
console.error('记录播放统计失败:', error);
return { success: false, error: (error as Error).message };
}
}
);
// 处理获取统计摘要
ipcMain.handle('get-stats-summary', async () => {
try {
const response = await axios.get(`${STATS_API_URL}/summary`);
return response.data;
} catch (error) {
console.error('获取统计摘要失败:', error);
throw error;
}
});
}
/**
* 应用启动时初始化统计服务
*/
export function initializeStats(): void {
// 记录应用启动统计
recordInstallation().catch((error) => {
console.error('初始化统计服务失败:', error);
});
// 注册应用退出时的回调
app.on('will-quit', () => {
// 可以在这里添加应用退出时的统计逻辑
console.log('应用退出');
});
}

View File

@@ -1,79 +1,418 @@
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'; import {
app,
BrowserWindow,
Menu,
MenuItem,
MenuItemConstructorOptions,
nativeImage,
Tray
} from 'electron';
import { join } from 'path'; 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';
// 歌曲信息接口定义
interface SongInfo {
name: string;
song: {
artists: Array<{ name: string; [key: string]: any }>;
[key: string]: any;
};
[key: string]: any;
}
let tray: Tray | null = null; let tray: Tray | null = null;
// 为macOS状态栏添加控制图标
let playPauseTray: Tray | null = null;
let prevTray: Tray | null = null;
let nextTray: Tray | null = null;
let songTitleTray: Tray | null = null;
let isPlaying = false;
let currentSong: SongInfo | null = null;
const LANGUAGES: { label: string; value: Language }[] = [ const LANGUAGES: { label: string; value: Language }[] = [
{ label: '简体中文', value: 'zh-CN' }, { label: '简体中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' } { label: 'English', value: 'en-US' }
]; ];
// 更新播放状态
export function updatePlayState(playing: boolean) {
isPlaying = playing;
if (tray) {
updateTrayMenu(BrowserWindow.getAllWindows()[0]);
}
// 更新播放/暂停图标
updateStatusBarTray();
}
// 获取艺术家名称字符串
function getArtistString(song: SongInfo | null): string {
if (!song || !song.song || !song.song.artists) return '';
return song.song.artists.map((item) => item.name).join(' / ');
}
// 获取歌曲完整标题(歌曲名 - 艺术家)
function getSongTitle(song: SongInfo | null): string {
if (!song) return '未播放';
const artistStr = getArtistString(song);
return artistStr ? `${song.name} - ${artistStr}` : song.name;
}
// 更新当前播放的音乐信息
export function updateCurrentSong(song: SongInfo | null) {
currentSong = song;
if (tray) {
updateTrayMenu(BrowserWindow.getAllWindows()[0]);
}
// 更新状态栏歌曲信息
updateStatusBarTray();
}
// 确保 macOS 状态栏图标能正确显示
function getProperIconSize() {
// macOS 状态栏通常高度为22像素
const height = 18;
const width = 18;
return { width, height };
}
// 更新macOS状态栏图标
function updateStatusBarTray() {
if (process.platform !== 'darwin') return;
const iconSize = getProperIconSize();
// 更新歌曲标题显示
if (songTitleTray) {
if (currentSong) {
// 限制歌曲名显示长度,添加作者名
const songName = currentSong.name.slice(0, 10);
let title = songName;
const artistStr = getArtistString(currentSong);
// 如果有艺术家名称,添加到标题中
if (artistStr) {
title = `${songName} - ${artistStr.slice(0, 6)}${artistStr.length > 6 ? '..' : ''}`;
}
// 设置标题和提示
songTitleTray.setTitle(title, {
fontType: 'monospacedDigit' // 使用等宽字体以确保更好的可读性
});
// 完整信息放在tooltip中
const fullTitle = getSongTitle(currentSong);
songTitleTray.setToolTip(fullTitle);
console.log('更新状态栏歌曲显示:', title, '完整信息:', fullTitle);
} else {
songTitleTray.setTitle('未播放', {
fontType: 'monospacedDigit'
});
songTitleTray.setToolTip('未播放');
console.log('更新状态栏歌曲显示: 未播放');
}
}
// 更新播放/暂停图标
if (playPauseTray) {
// 使用PNG图标替代文本
const iconPath = join(
app.getAppPath(),
'resources/icons',
isPlaying ? 'pause.png' : 'play.png'
);
const icon = nativeImage.createFromPath(iconPath).resize(iconSize);
icon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
playPauseTray.setImage(icon);
playPauseTray.setToolTip(
isPlaying ? i18n.global.t('common.tray.pause') : i18n.global.t('common.tray.play')
);
}
}
// 导出更新菜单的函数 // 导出更新菜单的函数
export function updateTrayMenu() { export function updateTrayMenu(mainWindow: BrowserWindow) {
if (!tray) return; if (!tray) return;
// 创建一个上下文菜单 // 如果是macOS设置TouchBar
const contextMenu = Menu.buildFromTemplate([ if (process.platform === 'darwin') {
{ // macOS 上使用直接的控制按钮
label: i18n.global.t('common.tray.show'), const menu = new Menu();
click: () => {
BrowserWindow.getAllWindows()[0]?.show(); // 当前播放的音乐信息
} if (currentSong) {
}, menu.append(
{ type: 'separator' }, new MenuItem({
{ label: getSongTitle(currentSong),
label: i18n.global.t('common.language'), enabled: false,
submenu: LANGUAGES.map(({ label, value }) => ({ type: 'normal'
})
);
menu.append(new MenuItem({ type: 'separator' }));
}
// 上一首、播放/暂停、下一首的菜单项
// 在macOS上临时使用文本菜单项替代图标确保基本功能正常
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.prev'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
}
})
);
menu.append(
new MenuItem({
label: i18n.global.t(isPlaying ? 'common.tray.pause' : 'common.tray.play'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
})
);
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.next'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
}
})
);
// 分隔符
menu.append(new MenuItem({ type: 'separator' }));
// 显示主窗口
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.show'),
type: 'normal',
click: () => {
mainWindow.show();
}
})
);
// 语言切换子菜单
const languageSubmenu = Menu.buildFromTemplate(
LANGUAGES.map(({ label, value }) => ({
label, label,
type: 'radio', type: 'radio',
checked: i18n.global.locale === value, checked: i18n.global.locale === value,
click: () => { click: () => {
// 更新主进程的语言设置
i18n.global.locale = value; i18n.global.locale = value;
// 更新托盘菜单 updateTrayMenu(mainWindow);
updateTrayMenu(); mainWindow.webContents.send('language-changed', value);
// 通知渲染进程
const win = BrowserWindow.getAllWindows()[0];
win?.webContents.send('set-language', value);
} }
})) }))
}, );
{ type: 'separator' },
{
label: i18n.global.t('common.tray.quit'),
click: () => {
app.quit();
}
}
]);
// 设置系统托盘图标的上下文菜单 menu.append(
tray.setContextMenu(contextMenu); new MenuItem({
label: i18n.global.t('common.language'),
type: 'submenu',
submenu: languageSubmenu
})
);
// 退出按钮
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.quit'),
type: 'normal',
click: () => {
app.quit();
}
})
);
tray.setContextMenu(menu);
} else {
// Windows 和 Linux 使用原来的菜单样式
const menuTemplate: MenuItemConstructorOptions[] = [
// 当前播放的音乐信息
...((currentSong
? [
{
label: getSongTitle(currentSong),
enabled: false,
type: 'normal'
},
{ type: 'separator' }
]
: []) as MenuItemConstructorOptions[]),
{
label: i18n.global.t('common.tray.show'),
type: 'normal',
click: () => {
mainWindow.show();
}
},
{ type: 'separator' },
{
label: i18n.global.t('common.tray.prev'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
}
},
{
label: i18n.global.t(isPlaying ? 'common.tray.pause' : 'common.tray.play'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
},
{
label: i18n.global.t('common.tray.next'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
}
},
{ type: 'separator' },
{
label: i18n.global.t('common.language'),
type: 'submenu',
submenu: LANGUAGES.map(({ label, value }) => ({
label,
type: 'radio',
checked: i18n.global.locale === value,
click: () => {
i18n.global.locale = value;
updateTrayMenu(mainWindow);
mainWindow.webContents.send('language-changed', value);
}
}))
},
{ type: 'separator' },
{
label: i18n.global.t('common.tray.quit'),
type: 'normal',
click: () => {
app.quit();
}
}
];
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
}
}
// 初始化状态栏Tray
function initializeStatusBarTray(mainWindow: BrowserWindow) {
if (process.platform !== 'darwin') return;
const iconSize = getProperIconSize();
// 创建下一首按钮(调整顺序,先创建下一首按钮)
const nextIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'next.png'))
.resize(iconSize);
nextIcon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
nextTray = new Tray(nextIcon);
nextTray.setToolTip(i18n.global.t('common.tray.next'));
nextTray.on('click', () => {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
});
// 创建播放/暂停按钮
const playPauseIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', isPlaying ? 'pause.png' : 'play.png'))
.resize(iconSize);
playPauseIcon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
playPauseTray = new Tray(playPauseIcon);
playPauseTray.setToolTip(
isPlaying ? i18n.global.t('common.tray.pause') : i18n.global.t('common.tray.play')
);
playPauseTray.on('click', () => {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
});
// 创建上一首按钮(调整顺序,最后创建上一首按钮)
const prevIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'prev.png'))
.resize(iconSize);
prevIcon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
prevTray = new Tray(prevIcon);
prevTray.setToolTip(i18n.global.t('common.tray.prev'));
prevTray.on('click', () => {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
});
// 创建歌曲信息显示 - 需要使用特殊处理
const titleIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'note.png'))
.resize({ width: 16, height: 16 });
titleIcon.setTemplateImage(true);
songTitleTray = new Tray(titleIcon);
// 初始化显示文本
const initialText = getSongTitle(currentSong);
// 在macOS上特别设置title来显示文本确保它能正确显示
songTitleTray.setTitle(initialText, {
fontType: 'monospacedDigit' // 使用等宽字体以确保更好的可读性
});
songTitleTray.setToolTip(initialText);
songTitleTray.on('click', () => {
mainWindow.show();
});
// 强制更新一次所有图标
updateStatusBarTray();
// 打印调试信息
console.log('状态栏初始化完成,歌曲显示标题:', initialText);
} }
/** /**
* 初始化系统托盘 * 初始化系统托盘
*/ */
export function initializeTray(iconPath: string, mainWindow: BrowserWindow) { export function initializeTray(iconPath: string, mainWindow: BrowserWindow) {
// 根据平台选择合适的图标
const iconSize = process.platform === 'darwin' ? 18 : 16;
const iconFile = process.platform === 'darwin' ? 'icon_16x16.png' : 'icon_16x16.png';
const trayIcon = nativeImage const trayIcon = nativeImage
.createFromPath(join(iconPath, 'icon_16x16.png')) .createFromPath(join(iconPath, iconFile))
.resize({ width: 16, height: 16 }); .resize({ width: iconSize, height: iconSize });
tray = new Tray(trayIcon); tray = new Tray(trayIcon);
// 初始化菜单 // 设置托盘图标的提示文字
updateTrayMenu(); tray.setToolTip('Alger Music Player');
// 当系统托盘图标被点击时,切换窗口的显示/隐藏 // 初始化菜单
tray.on('click', () => { updateTrayMenu(mainWindow);
if (mainWindow.isVisible()) {
mainWindow.hide(); // 初始化状态栏控制按钮 (macOS)
} else { initializeStatusBarTray(mainWindow);
mainWindow.show();
} // 在 macOS 上,点击图标时显示菜单
}); if (process.platform === 'darwin') {
tray.on('click', () => {
if (tray) {
tray.popUpContextMenu();
}
});
} else {
// 在其他平台上,点击图标时切换窗口显示状态
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
}
return tray; return tray;
} }

View File

@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { exec } from 'child_process'; import { spawn } from 'child_process';
import { app, BrowserWindow, ipcMain } from 'electron'; import { app, BrowserWindow, ipcMain } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@@ -53,38 +53,49 @@ export function setupUpdateHandlers(_mainWindow: BrowserWindow) {
const { platform } = process; const { platform } = process;
// 关闭当前应用 // 先启动安装程序,再退出应用
app.quit(); try {
if (platform === 'win32') {
// 根据不同平台执行安装 // 使用spawn替代exec并使用detached选项确保子进程独立运行
if (platform === 'win32') { const child = spawn(filePath, [], {
exec(`"${filePath}"`, (error) => { detached: true,
if (error) { stdio: 'ignore'
console.error('Error starting installer:', error);
}
});
} else if (platform === 'darwin') {
// 挂载 DMG 文件
exec(`open "${filePath}"`, (error) => {
if (error) {
console.error('Error opening DMG:', error);
}
});
} else if (platform === 'linux') {
const ext = path.extname(filePath);
if (ext === '.AppImage') {
exec(`chmod +x "${filePath}" && "${filePath}"`, (error) => {
if (error) {
console.error('Error running AppImage:', error);
}
}); });
} else if (ext === '.deb') { child.unref();
exec(`pkexec dpkg -i "${filePath}"`, (error) => { } else if (platform === 'darwin') {
if (error) { // 挂载 DMG 文件
console.error('Error installing deb package:', error); const child = spawn('open', [filePath], {
} detached: true,
stdio: 'ignore'
}); });
child.unref();
} else if (platform === 'linux') {
const ext = path.extname(filePath);
if (ext === '.AppImage') {
// 先添加执行权限
fs.chmodSync(filePath, '755');
const child = spawn(filePath, [], {
detached: true,
stdio: 'ignore'
});
child.unref();
} else if (ext === '.deb') {
const child = spawn('pkexec', ['dpkg', '-i', filePath], {
detached: true,
stdio: 'ignore'
});
child.unref();
}
} }
// 给安装程序一点时间启动
setTimeout(() => {
app.quit();
}, 500);
} catch (error) {
console.error('启动安装程序失败:', error);
// 尽管出错,仍然尝试退出应用
app.quit();
} }
}); });
} }

View File

@@ -1,10 +1,19 @@
import { is } from '@electron-toolkit/utils'; import { is } from '@electron-toolkit/utils';
import { app, BrowserWindow, ipcMain, session, shell } from 'electron'; import { app, BrowserWindow, 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';
const store = new Store(); const store = new Store();
// 保存主窗口的大小和位置
let mainWindowState = {
width: 1200,
height: 780,
x: undefined as number | undefined,
y: undefined as number | undefined,
isMaximized: false
};
/** /**
* 初始化代理设置 * 初始化代理设置
*/ */
@@ -71,10 +80,109 @@ export function initializeWindowManager() {
} }
}); });
ipcMain.on('mini-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
// 保存当前窗口状态
const [width, height] = win.getSize();
const [x, y] = win.getPosition();
mainWindowState = {
width,
height,
x,
y,
isMaximized: win.isMaximized()
};
// 获取屏幕尺寸
const { width: screenWidth } = screen.getPrimaryDisplay().workAreaSize;
// 设置迷你窗口的大小和位置
win.unmaximize();
win.setMinimumSize(340, 64);
win.setMaximumSize(340, 64);
win.setSize(340, 64);
win.setPosition(screenWidth - 340, 20);
win.setAlwaysOnTop(true);
win.setSkipTaskbar(false);
win.setResizable(false);
// 导航到迷你模式路由
win.webContents.send('navigate', '/mini');
// 发送事件到渲染进程,通知切换到迷你模式
win.webContents.send('mini-mode', true);
}
});
// 恢复窗口
ipcMain.on('restore-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
// 恢复窗口的大小调整功能
win.setResizable(true);
win.setMaximumSize(0, 0);
// 恢复窗口的最小尺寸限制
win.setMinimumSize(1200, 780);
// 恢复窗口状态
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.setSkipTaskbar(false);
// 导航回主页面
win.webContents.send('navigate', '/');
// 发送事件到渲染进程,通知退出迷你模式
win.webContents.send('mini-mode', false);
}
});
// 监听代理设置变化 // 监听代理设置变化
store.onDidChange('set.proxyConfig', () => { store.onDidChange('set.proxyConfig', () => {
initializeProxy(); initializeProxy();
}); });
// 监听窗口大小调整事件
ipcMain.on('resize-window', (event, width, height) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
// 设置窗口的大小
console.log(`调整窗口大小: ${width} x ${height}`);
win.setSize(width, height);
}
});
// 专门用于迷你模式下调整窗口大小的事件
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);
}
}
});
} }
/** /**
@@ -91,7 +199,8 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false, sandbox: false,
contextIsolation: true contextIsolation: true,
webSecurity: false
} }
}); });
@@ -111,6 +220,11 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
if (is.dev && process.env.ELECTRON_RENDERER_URL) { if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.webContents.openDevTools({ mode: 'detach' }); mainWindow.webContents.openDevTools({ mode: 'detach' });
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
// 注册快捷键 打开开发者工具
globalShortcut.register('CommandOrControl+Shift+I', () => {
mainWindow.webContents.openDevTools({ mode: 'detach' });
});
} else { } else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')); mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
} }

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

@@ -20,5 +20,8 @@
"autoPlay": false, "autoPlay": false,
"downloadPath": "", "downloadPath": "",
"language": "zh-CN", "language": "zh-CN",
"alwaysShowDownloadButton": false "alwaysShowDownloadButton": false,
"unlimitedDownload": false,
"enableMusicUnblock": true,
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili", "youtube"]
} }

View File

@@ -6,6 +6,8 @@ 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,29 @@ interface UnblockResult {
}; };
} }
// 所有可用平台
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'kuwo', 'bilibili', 'youtube'];
/** /**
* 音乐解析函数 * 音乐解析函数
* @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> => {
// 所有可用平台 const platforms = enabledPlatforms || ALL_PLATFORMS;
const platforms: Platform[] = ['migu', 'kugou', 'pyncmd', 'joox', 'kuwo', 'bilibili', 'youtube']; 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), platforms,songData);
const result: UnblockResult = { const result: UnblockResult = {
data: { data: {
data, data,
@@ -58,7 +65,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

@@ -1,24 +1,42 @@
import { ElectronAPI } from '@electron-toolkit/preload'; import { ElectronAPI } from '@electron-toolkit/preload';
interface API {
minimize: () => void;
maximize: () => void;
close: () => void;
dragStart: (data: any) => void;
miniTray: () => void;
miniWindow: () => void;
restore: () => void;
restart: () => void;
resizeWindow: (width: number, height: number) => void;
resizeMiniWindow: (showPlaylist: boolean) => void;
openLyric: () => void;
sendLyric: (data: any) => void;
sendSong: (data: any) => void;
unblockMusic: (id: number, data: any, enabledSources?: string[]) => Promise<any>;
onLyricWindowClosed: (callback: () => void) => void;
startDownload: (url: string) => void;
onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
onLanguageChanged: (callback: (locale: string) => void) => void;
removeDownloadListeners: () => void;
invoke: (channel: string, ...args: any[]) => Promise<any>;
}
// 自定义IPC渲染进程通信接口
interface IpcRenderer {
send: (channel: string, ...args: any[]) => void;
invoke: (channel: string, ...args: any[]) => Promise<any>;
on: (channel: string, listener: (...args: any[]) => void) => () => void;
removeAllListeners: (channel: string) => void;
}
declare global { declare global {
interface Window { interface Window {
electron: ElectronAPI; electron: ElectronAPI;
api: { api: API;
sendLyric: (data: string) => void; ipcRenderer: IpcRenderer;
openLyric: () => void;
minimize: () => void;
maximize: () => void;
close: () => void;
dragStart: (data: string) => void;
miniTray: () => void;
restart: () => void;
unblockMusic: (id: number, data: any) => Promise<any>;
startDownload: (url: string) => void;
onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
removeDownloadListeners: () => void;
invoke: (channel: string, ...args: any[]) => Promise<any>;
};
$message: any; $message: any;
} }
} }

View File

@@ -8,10 +8,19 @@ const api = {
close: () => ipcRenderer.send('close-window'), close: () => ipcRenderer.send('close-window'),
dragStart: (data) => ipcRenderer.send('drag-start', data), dragStart: (data) => ipcRenderer.send('drag-start', data),
miniTray: () => ipcRenderer.send('mini-tray'), miniTray: () => ipcRenderer.send('mini-tray'),
miniWindow: () => ipcRenderer.send('mini-window'),
restore: () => ipcRenderer.send('restore-window'),
restart: () => ipcRenderer.send('restart'), restart: () => ipcRenderer.send('restart'),
resizeWindow: (width, height) => ipcRenderer.send('resize-window', width, height),
resizeMiniWindow: (showPlaylist) => ipcRenderer.send('resize-mini-window', showPlaylist),
openLyric: () => ipcRenderer.send('open-lyric'), openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data), sendLyric: (data) => ipcRenderer.send('send-lyric', data),
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id), sendSong: (data) => ipcRenderer.send('update-current-song', data),
unblockMusic: (id, data, enabledSources) => ipcRenderer.invoke('unblock-music', id, data, enabledSources),
// 歌词窗口关闭事件
onLyricWindowClosed: (callback: () => void) => {
ipcRenderer.on('lyric-window-closed', () => callback());
},
// 更新相关 // 更新相关
startDownload: (url: string) => ipcRenderer.send('start-download', url), startDownload: (url: string) => ipcRenderer.send('start-download', url),
onDownloadProgress: (callback: (progress: number, status: string) => void) => { onDownloadProgress: (callback: (progress: number, status: string) => void) => {
@@ -20,6 +29,12 @@ const api = {
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => { onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => {
ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath)); ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath));
}, },
// 语言相关
onLanguageChanged: (callback: (locale: string) => void) => {
ipcRenderer.on('language-changed', (_event, locale) => {
callback(locale);
});
},
removeDownloadListeners: () => { removeDownloadListeners: () => {
ipcRenderer.removeAllListeners('download-progress'); ipcRenderer.removeAllListeners('download-progress');
ipcRenderer.removeAllListeners('download-complete'); ipcRenderer.removeAllListeners('download-complete');
@@ -32,7 +47,11 @@ const api = {
'get-system-fonts', 'get-system-fonts',
'get-cached-lyric', 'get-cached-lyric',
'cache-lyric', 'cache-lyric',
'clear-lyric-cache' 'clear-lyric-cache',
// 统计相关
'record-visit',
'record-play',
'get-stats-summary'
]; ];
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args); return ipcRenderer.invoke(channel, ...args);
@@ -41,6 +60,29 @@ const api = {
} }
}; };
// 创建带类型的ipcRenderer对象暴露给渲染进程
const ipc = {
// 发送消息到主进程(无返回值)
send: (channel: string, ...args: any[]) => {
ipcRenderer.send(channel, ...args);
},
// 调用主进程方法(有返回值)
invoke: (channel: string, ...args: any[]) => {
return ipcRenderer.invoke(channel, ...args);
},
// 监听主进程消息
on: (channel: string, listener: (...args: any[]) => void) => {
ipcRenderer.on(channel, (_, ...args) => listener(...args));
return () => {
ipcRenderer.removeListener(channel, listener);
};
},
// 移除所有监听器
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel);
}
};
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise // renderer only if context isolation is enabled, otherwise
// just add to the DOM global. // just add to the DOM global.
@@ -48,6 +90,7 @@ if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api); contextBridge.exposeInMainWorld('api', api);
contextBridge.exposeInMainWorld('ipcRenderer', ipc);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@@ -56,4 +99,6 @@ if (process.contextIsolated) {
window.electron = electronAPI; window.electron = electronAPI;
// @ts-ignore (define in dts) // @ts-ignore (define in dts)
window.api = api; window.api = api;
// @ts-ignore (define in dts)
window.ipcRenderer = ipc;
} }

View File

@@ -11,88 +11,114 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { cloneDeep } from 'lodash';
import { darkTheme, lightTheme } from 'naive-ui'; import { darkTheme, lightTheme } from 'naive-ui';
import { computed, onMounted, onUnmounted, watch } from 'vue'; import { computed, nextTick, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import homeRouter from '@/router/home'; import homeRouter from '@/router/home';
import globalStore from '@/store'; import { useMenuStore } from '@/store/modules/menu';
import { isElectron } from '@/utils'; import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { isElectron, isLyricWindow } from '@/utils';
import { initAudioListeners } from './hooks/MusicHook';
import { isMobile } from './utils'; import { isMobile } from './utils';
import { useAppShortcuts } from './utils/appShortcuts';
const { locale } = useI18n(); const { locale } = useI18n();
const settingsStore = useSettingsStore();
const menuStore = useMenuStore();
const playerStore = usePlayerStore();
const router = useRouter();
const savedLanguage = isElectron // 监听语言变化
? window.electron.ipcRenderer.sendSync('get-store-value', 'set.language')
: JSON.parse(localStorage.getItem('appSettings') || '{}').language || 'zh-CN';
if (savedLanguage) {
locale.value = savedLanguage;
}
const theme = computed(() => {
return globalStore.state.theme;
});
// 监听字体变化并应用
watch( watch(
() => [globalStore.state.setData.fontFamily, globalStore.state.setData.fontScope], () => settingsStore.setData.language,
([newFont, fontScope]) => { (newLanguage) => {
const appElement = document.body; if (newLanguage && newLanguage !== locale.value) {
if (!appElement) return; locale.value = newLanguage;
const defaultFonts =
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
// 只有在全局模式下才应用字体
if (fontScope !== 'global') {
appElement.style.fontFamily = defaultFonts;
return;
}
if (newFont === 'system-ui') {
appElement.style.fontFamily = defaultFonts;
} else {
// 处理多个字体,确保每个字体名都被正确引用
const fontList = newFont.split(',').map((font) => {
const trimmedFont = font.trim();
// 如果字体名包含空格或特殊字符,添加引号(如果还没有引号的话)
return /[\s'"()]/.test(trimmedFont) && !/^['"].*['"]$/.test(trimmedFont)
? `"${trimmedFont}"`
: trimmedFont;
});
// 将选择的字体和默认字体组合
appElement.style.fontFamily = `${fontList.join(', ')}, ${defaultFonts}`;
} }
}, },
{ immediate: true } { immediate: true }
); );
// 监听来自主进程的语言切换事件 const theme = computed(() => {
const handleSetLanguage = (_: any, value: string) => { return settingsStore.theme;
// 更新 i18n locale
locale.value = value;
// 通过 mutation 更新 store
globalStore.commit('setLanguage', value);
};
onMounted(() => {
globalStore.dispatch('initializeSettings');
globalStore.dispatch('initializeTheme');
globalStore.dispatch('initializeSystemFonts');
globalStore.dispatch('initializePlayState');
if (isMobile.value) {
globalStore.commit(
'setMenus',
homeRouter.filter((item) => item.meta.isMobile)
);
}
window.electron.ipcRenderer.on('set-language', handleSetLanguage);
}); });
onUnmounted(() => { // 监听字体变化并应用
window.electron.ipcRenderer.removeListener('set-language', handleSetLanguage); watch(
() => [settingsStore.setData.fontFamily, settingsStore.setData.fontScope],
([newFont, fontScope]) => {
const appElement = document.body;
if (newFont && fontScope === 'global') {
appElement.style.fontFamily = newFont;
} else {
appElement.style.fontFamily = '';
}
}
);
const handleSetLanguage = (value: string) => {
console.log('应用语言变更:', value);
if (value) {
locale.value = value;
}
};
if (!isLyricWindow.value) {
settingsStore.initializeSettings();
settingsStore.initializeTheme();
settingsStore.initializeSystemFonts();
if (isMobile.value) {
menuStore.setMenus(homeRouter.filter((item) => item.meta.isMobile));
}
}
handleSetLanguage(settingsStore.setData.language);
// 监听迷你模式状态
if (isElectron) {
window.api.onLanguageChanged(handleSetLanguage);
window.electron.ipcRenderer.on('mini-mode', (_, value) => {
settingsStore.setMiniMode(value);
if (value) {
// 存储当前路由
localStorage.setItem('currentRoute', router.currentRoute.value.path);
router.push('/mini');
} else {
// 恢复当前路由
const currentRoute = localStorage.getItem('currentRoute');
if (currentRoute) {
router.push(currentRoute);
localStorage.removeItem('currentRoute');
} else {
router.push('/');
}
}
});
}
// 使用应用内快捷键
useAppShortcuts();
onMounted(async () => {
if (isLyricWindow.value) {
return;
}
// 先初始化播放状态
await playerStore.initializePlayState();
// 如果有正在播放的音乐,则初始化音频监听器
if (playerStore.playMusic && playerStore.playMusic.id) {
// 使用 nextTick 确保 DOM 更新后再初始化
await nextTick();
initAudioListeners();
if (isElectron) {
window.api.sendSong(cloneDeep(playerStore.playMusic));
}
}
}); });
</script> </script>

View File

@@ -0,0 +1,154 @@
import type { IBilibiliPlayUrl, IBilibiliVideoDetail } from '@/types/bilibili';
import { getSetData, isElectron } from '@/utils';
import request from '@/utils/request';
interface ISearchParams {
keyword: string;
page?: number;
pagesize?: number;
search_type?: string;
}
/**
* 搜索B站视频
* @param params 搜索参数
*/
export const searchBilibili = (params: ISearchParams) => {
console.log('调用B站搜索API参数:', params);
return request.get('/bilibili/search', {
params
});
};
interface IBilibiliResponse<T> {
code: number;
message: string;
ttl: number;
data: T;
}
/**
* 获取B站视频详情
* @param bvid B站视频BV号
* @returns 视频详情响应
*/
export const getBilibiliVideoDetail = (
bvid: string
): Promise<IBilibiliResponse<IBilibiliVideoDetail>> => {
console.log('调用B站视频详情APIbvid:', bvid);
return new Promise((resolve, reject) => {
request
.get('/bilibili/video/detail', {
params: { bvid }
})
.then((response) => {
console.log('B站视频详情API响应:', response.status);
// 检查响应状态和数据格式
if (response.status === 200 && response.data && response.data.data) {
console.log('B站视频详情API成功标题:', response.data.data.title);
resolve(response.data);
} else {
console.error('B站视频详情API响应格式不正确:', response.data);
reject(new Error('获取视频详情响应格式不正确'));
}
})
.catch((error) => {
console.error('B站视频详情API错误:', error);
reject(error);
});
});
};
/**
* 获取B站视频播放地址
* @param bvid B站视频BV号
* @param cid 视频分P的id
* @param qn 视频质量默认为0
* @param fnval 视频格式标志默认为80
* @param fnver 视频格式版本默认为0
* @param fourk 是否允许4K视频默认为1
* @returns 视频播放地址响应
*/
export const getBilibiliPlayUrl = (
bvid: string,
cid: number,
qn: number = 0,
fnval: number = 80,
fnver: number = 0,
fourk: number = 1
): Promise<IBilibiliResponse<IBilibiliPlayUrl>> => {
console.log('调用B站视频播放地址APIbvid:', bvid, 'cid:', cid);
return new Promise((resolve, reject) => {
request
.get('/bilibili/playurl', {
params: {
bvid,
cid,
qn,
fnval,
fnver,
fourk
}
})
.then((response) => {
console.log('B站视频播放地址API响应:', response.status);
// 检查响应状态和数据格式
if (response.status === 200 && response.data && response.data.data) {
if (response.data.data.dash?.audio?.length > 0) {
console.log(
'B站视频播放地址API成功获取到',
response.data.data.dash.audio.length,
'个音频地址'
);
} else if (response.data.data.durl?.length > 0) {
console.log(
'B站视频播放地址API成功获取到',
response.data.data.durl.length,
'个播放地址'
);
}
resolve(response.data);
} else {
console.error('B站视频播放地址API响应格式不正确:', response.data);
reject(new Error('获取视频播放地址响应格式不正确'));
}
})
.catch((error) => {
console.error('B站视频播放地址API错误:', error);
reject(error);
});
});
};
export const getBilibiliProxyUrl = (url: string) => {
const setData = getSetData();
const baseURL = isElectron
? `http://127.0.0.1:${setData?.musicApiPort}`
: import.meta.env.VITE_API;
const AUrl = url.startsWith('http') ? url : `https:${url}`;
return `${baseURL}/bilibili/stream-proxy?url=${encodeURIComponent(AUrl)}`;
};
export const getBilibiliAudioUrl = async (bvid: string, cid: number): Promise<string> => {
console.log('获取B站音频URL', { bvid, cid });
try {
const res = await getBilibiliPlayUrl(bvid, cid);
const playUrlData = res.data;
let url = '';
if (playUrlData.dash && playUrlData.dash.audio && playUrlData.dash.audio.length > 0) {
url = playUrlData.dash.audio[0].baseUrl;
} else if (playUrlData.durl && playUrlData.durl.length > 0) {
url = playUrlData.durl[0].url;
} else {
throw new Error('未找到可用的音频地址');
}
return getBilibiliProxyUrl(url);
} catch (error) {
console.error('获取B站音频URL失败:', error);
throw error;
}
};

View File

@@ -15,6 +15,6 @@ export interface Donor {
* 获取捐赠列表 * 获取捐赠列表
*/ */
export const getDonationList = async (): Promise<Donor[]> => { export const getDonationList = async (): Promise<Donor[]> => {
const { data } = await axios.get('http://110.42.251.190:8766/api/donations'); const { data } = await axios.get('http://donate.alger.fun/api/donations');
return data; return data;
}; };

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

@@ -0,0 +1,189 @@
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 音质设置
* @returns 解析后的音乐URL及相关信息
*/
export const parseFromGDMusic = async (
id: number,
data: any,
quality: string = '320'
): Promise<ParsedMusicResult | null> => {
try {
// 处理不同数据结构
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('搜索查询过短');
}
// 所有可用的音乐源
const allSources = [
'tencent', 'kugou', 'kuwo', 'migu', 'netease',
'joox', 'ytmusic', 'spotify', 'qobuz', 'deezer'
] 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;
} catch (error) {
console.error('GD音乐台解析完全失败:', error);
return null;
}
};
/**
* 获取音质映射
* @param qualitySetting 设置中的音质选项
* @returns 映射到GD音乐台的音质参数
*/
export const getQualityMapping = (qualitySetting: string): string => {
const qualityMap: Record<string, string> = {
standard: '128',
higher: '320',
exhigh: '320',
lossless: '740',
hires: '999',
jyeffect: '999',
sky: '999',
dolby: '999',
jymaster: '999'
};
return qualityMap[qualitySetting] || '320';
};
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

@@ -1,9 +1,11 @@
import { musicDB } from '@/hooks/MusicHook'; import { musicDB } from '@/hooks/MusicHook';
import store from '@/store'; import { useSettingsStore, useUserStore } from '@/store';
import type { ILyric } from '@/type/lyric'; 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, getQualityMapping } from './gdmusic';
const { addData, getData, deleteData } = musicDB; const { addData, getData, deleteData } = musicDB;
@@ -14,14 +16,16 @@ export const getMusicQualityDetail = (id: number) => {
// 根据音乐Id获取音乐播放URl // 根据音乐Id获取音乐播放URl
export const getMusicUrl = async (id: number, isDownloaded: boolean = false) => { export const getMusicUrl = async (id: number, isDownloaded: boolean = false) => {
const userStore = useUserStore();
const settingStore = useSettingsStore();
// 判断是否登录 // 判断是否登录
try { try {
if (store.state.user && isDownloaded && store.state.user.vipType !== 0) { if (userStore.user && isDownloaded && userStore.user.vipType !== 0) {
const url = '/song/download/url/v1'; const url = '/song/download/url/v1';
const res = await request.get(url, { const res = await request.get(url, {
params: { params: {
id, id,
level: store.state.setData.musicQuality || 'higher', level: settingStore.setData.musicQuality || 'higher',
cookie: `${localStorage.getItem('token')} os=pc;` cookie: `${localStorage.getItem('token')} os=pc;`
} }
}); });
@@ -37,7 +41,7 @@ export const getMusicUrl = async (id: number, isDownloaded: boolean = false) =>
return await request.get('/song/url/v1', { return await request.get('/song/url/v1', {
params: { params: {
id, id,
level: store.state.setData.musicQuality || 'higher' level: settingStore.setData.musicQuality || 'higher'
} }
}); });
}; };
@@ -76,10 +80,39 @@ export const getMusicLrc = async (id: number) => {
} }
}; };
export const getParsingMusicUrl = (id: number, data: any) => { export const getParsingMusicUrl = async (id: number, data: any) => {
if (isElectron) { const settingStore = useSettingsStore();
return window.api.unblockMusic(id, data);
// 如果禁用了音乐解析功能,则直接返回空结果
if (!settingStore.setData.enableMusicUnblock) {
return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } });
} }
// 检查是否选择了GD音乐台解析
const enabledSources = settingStore.setData.enabledMusicSources || [];
if (enabledSources.includes('gdmusic')) {
// 获取音质设置并转换为GD音乐台格式
try {
const quality = getQualityMapping(settingStore.setData.musicQuality || 'higher');
// 调用封装的GD音乐台解析服务
const gdResult = await parseFromGDMusic(id, data, quality);
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 } });
}; };
@@ -91,7 +124,7 @@ export const likeSong = (id: number, like: boolean = true) => {
// 获取用户喜欢的音乐列表 // 获取用户喜欢的音乐列表
export const getLikedList = (uid: number) => { export const getLikedList = (uid: number) => {
return request.get('/likelist', { return request.get('/likelist', {
params: { uid } params: { uid, noLogin: true }
}); });
}; };

75
src/renderer/api/stats.ts Normal file
View File

@@ -0,0 +1,75 @@
import { isElectron } from '@/utils';
import { useUserStore } from '../store/modules/user';
/**
* 获取用户ID
* @returns 用户ID或null
*/
function getUserId(): string | null {
const userStore = useUserStore();
return userStore.user?.userId?.toString() || null;
}
/**
* 记录页面访问
* @param page 页面名称或路径
*/
export async function recordVisit(page: string): Promise<void> {
if (!isElectron) return;
try {
const userId = getUserId();
await window.api.invoke('record-visit', page, userId);
console.log(`页面访问已记录: ${page}`);
} catch (error) {
console.error('记录页面访问失败:', error);
}
}
/**
* 记录歌曲播放
* @param songId 歌曲ID
* @param songName 歌曲名称
* @param artistName 艺术家名称
* @param duration 时长(秒)
* @param completedPlay 是否完整播放
*/
export async function recordPlay(
songId: string | number,
songName: string,
artistName: string,
duration: number = 0,
completedPlay: boolean = false
): Promise<void> {
if (!isElectron) return;
try {
const userId = getUserId();
await window.api.invoke('record-play', {
userId,
songId,
songName,
artistName,
duration,
completedPlay
});
console.log(`歌曲播放已记录: ${songName}`);
} catch (error) {
console.error('记录歌曲播放失败:', error);
}
}
/**
* 获取统计摘要
* @returns 统计数据摘要
*/
export async function getStatsSummary(): Promise<any> {
if (!isElectron) return null;
try {
return await window.api.invoke('get-stats-summary');
} catch (error) {
console.error('获取统计摘要失败:', error);
return null;
}
}

View File

@@ -1,3 +1,4 @@
import type { IUserDetail, IUserFollow } from '@/type/user';
import request from '@/utils/request'; import request from '@/utils/request';
// /user/detail // /user/detail
@@ -6,8 +7,8 @@ export function getUserDetail(uid: number) {
} }
// /user/playlist // /user/playlist
export function getUserPlaylist(uid: number) { export function getUserPlaylist(uid: number, limit: number = 30, offset: number = 0) {
return request.get('/user/playlist', { params: { uid } }); return request.get('/user/playlist', { params: { uid, limit, offset } });
} }
// 播放历史 // 播放历史
@@ -15,3 +16,56 @@ export function getUserPlaylist(uid: number) {
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 } });
} }
// 获取用户关注列表
// /user/follows?uid=32953014
export function getUserFollows(uid: number, limit: number = 30, offset: number = 0) {
return request.get('/user/follows', { params: { uid, limit, offset } });
}
// 获取用户粉丝列表
export function getUserFollowers(uid: number, limit: number = 30, offset: number = 0) {
return request.post('/user/followeds', { uid, limit, offset });
}
// 获取用户账号信息
export const getUserAccount = () => {
return request<any>({
url: '/user/account',
method: 'get'
});
};
// 获取用户详情
export const getUserDetailInfo = (params: { uid: string | number }) => {
return request<IUserDetail>({
url: '/user/detail',
method: 'get',
params
});
};
// 获取用户关注列表
export const getUserFollowsInfo = (params: {
uid: string | number;
limit?: number;
offset?: number;
}) => {
return request<{
follow: IUserFollow[];
more: boolean;
}>({
url: '/user/follows',
method: 'get',
params
});
};
// 获取用户歌单
export const getUserPlaylists = (params: { uid: string | number }) => {
return request({
url: '/user/playlist',
method: 'get',
params
});
};

View File

@@ -1,5 +1,6 @@
body { body {
/* background-color: #000; */ /* background-color: #000; */
overflow: hidden;
} }
.n-popover:has(.music-play) { .n-popover:has(.music-play) {

View File

@@ -2,17 +2,23 @@
// @ts-nocheck // @ts-nocheck
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {} export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar'] NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge'] NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup'] NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCarousel: typeof import('naive-ui')['NCarousel']
NCarouselItem: typeof import('naive-ui')['NCarouselItem']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NCheckbox: typeof import('naive-ui')['NCheckbox']
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup'] NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer'] NDrawer: typeof import('naive-ui')['NDrawer']
@@ -22,6 +28,8 @@ declare module 'vue' {
NEmpty: typeof import('naive-ui')['NEmpty'] NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage'] NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']

View File

@@ -46,6 +46,15 @@
{{ t('comp.coffee.qqGroup') }} {{ t('comp.coffee.qqGroup') }}
</p> </p>
</div> </div>
<div class="mt-4">
<!-- 赞赏列表地址 -->
<p
class="text-sm text-green-600 dark:text-gray-200 text-center cursor-pointer hover:text-green-500"
@click="toDonateList"
>
{{ t('comp.coffee.donateList') }}
</p>
</div>
</div> </div>
</n-popover> </n-popover>
</div> </div>
@@ -66,6 +75,10 @@ const copyQQ = () => {
message.success('已复制到剪贴板'); message.success('已复制到剪贴板');
}; };
const toDonateList = () => {
window.open('http://donate.alger.fun', '_blank');
};
defineProps({ defineProps({
alipayQR: { alipayQR: {
type: String, type: String,

View File

@@ -0,0 +1,355 @@
<template>
<div class="eq-control">
<div class="eq-header">
<h3>{{ t('player.eq.title') }}</h3>
<div class="eq-controls">
<n-switch v-model:value="isEnabled" @update:value="toggleEQ">
<template #checked>{{ t('player.eq.on') }}</template>
<template #unchecked>{{ t('player.eq.off') }}</template>
</n-switch>
</div>
</div>
<div class="eq-presets">
<n-scrollbar x-scrollable>
<n-space :size="6" :wrap="false">
<n-tag
v-for="preset in presetOptions"
:key="preset.value"
:type="currentPreset === preset.value ? 'success' : 'default'"
:bordered="false"
size="medium"
round
clickable
@click="applyPreset(preset.value)"
>
{{ preset.label }}
</n-tag>
</n-space>
</n-scrollbar>
</div>
<div class="eq-sliders">
<div v-for="freq in frequencies" :key="freq" class="eq-slider">
<div class="freq-label">{{ formatFreq(freq) }}</div>
<n-slider
v-model:value="eqValues[freq.toString()]"
:min="-12"
:max="12"
:step="0.1"
vertical
:disabled="!isEnabled"
@update:value="updateEQ(freq.toString(), $event)"
/>
<div class="gain-value">{{ eqValues[freq.toString()] }}dB</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { audioService } from '@/services/audioService';
const { t } = useI18n();
const frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const eqValues = ref<{ [key: string]: number }>({});
const isEnabled = ref(audioService.isEQEnabled());
const currentPreset = ref(audioService.getCurrentPreset() || 'flat');
// 预设配置
const presets = {
flat: {
label: t('player.eq.presets.flat'),
values: Object.fromEntries(frequencies.map((f) => [f, 0]))
},
pop: {
label: t('player.eq.presets.pop'),
values: {
31: -1.5,
62: 3.5,
125: 5.5,
250: 3.5,
500: -0.5,
1000: -1.5,
2000: 1.5,
4000: 2.5,
8000: 2.5,
16000: 2.5
}
},
rock: {
label: t('player.eq.presets.rock'),
values: {
31: 4.5,
62: 3.5,
125: 2,
250: 0.5,
500: -0.5,
1000: -1,
2000: 0.5,
4000: 2,
8000: 2.5,
16000: 3.5
}
},
classical: {
label: t('player.eq.presets.classical'),
values: {
31: 3.5,
62: 3,
125: 2.5,
250: 1.5,
500: -0.5,
1000: -1.5,
2000: -1.5,
4000: 0.5,
8000: 2,
16000: 3
}
},
jazz: {
label: t('player.eq.presets.jazz'),
values: {
31: 3,
62: 2,
125: 1.5,
250: 2,
500: -1,
1000: -1.5,
2000: -0.5,
4000: 1,
8000: 2.5,
16000: 3
}
},
hiphop: {
label: t('player.eq.presets.hiphop'),
values: {
31: 5,
62: 4.5,
125: 3,
250: 1.5,
500: -0.5,
1000: -1,
2000: 0.5,
4000: 1.5,
8000: 2,
16000: 2.5
}
},
vocal: {
label: t('player.eq.presets.vocal'),
values: {
31: -2,
62: -1.5,
125: -1,
250: 0.5,
500: 2,
1000: 3.5,
2000: 3,
4000: 1.5,
8000: 0.5,
16000: 0
}
},
dance: {
label: t('player.eq.presets.dance'),
values: {
31: 4,
62: 3.5,
125: 2.5,
250: 1,
500: 0,
1000: -0.5,
2000: 1.5,
4000: 2.5,
8000: 3,
16000: 2.5
}
},
acoustic: {
label: t('player.eq.presets.acoustic'),
values: {
31: 2,
62: 1.5,
125: 1,
250: 1.5,
500: 2,
1000: 1.5,
2000: 2,
4000: 2.5,
8000: 2,
16000: 1.5
}
}
};
const presetOptions = Object.entries(presets).map(([value, preset]) => ({
label: preset.label,
value
}));
const toggleEQ = (enabled: boolean) => {
audioService.setEQEnabled(enabled);
};
const applyPreset = (presetName: string) => {
currentPreset.value = presetName;
audioService.setCurrentPreset(presetName);
const preset = presets[presetName as keyof typeof presets];
if (preset) {
Object.entries(preset.values).forEach(([freq, gain]) => {
updateEQ(freq, gain);
});
}
};
onMounted(() => {
// 恢复 EQ 设置
const settings = audioService.getAllEQSettings();
eqValues.value = settings;
// 如果有保存的预设,应用该预设
const savedPreset = audioService.getCurrentPreset();
if (savedPreset && presets[savedPreset as keyof typeof presets]) {
currentPreset.value = savedPreset;
}
});
const updateEQ = (frequency: string, gain: number) => {
audioService.setEQFrequencyGain(frequency, gain);
eqValues.value = {
...eqValues.value,
[frequency]: gain
};
// 检查当前值是否与任何预设匹配
const currentValues = eqValues.value;
let matchedPreset: string | null = null;
// 检查是否与任何预设完全匹配
Object.entries(presets).forEach(([presetName, preset]) => {
const isMatch = Object.entries(preset.values).every(
([freq, value]) => Math.abs(currentValues[freq] - value) < 0.1
);
if (isMatch) {
matchedPreset = presetName;
}
});
// 更新当前预设状态
if (matchedPreset !== null) {
currentPreset.value = matchedPreset;
audioService.setCurrentPreset(matchedPreset);
} else if (currentPreset.value !== 'custom') {
// 如果与任何预设都不匹配,将状态设置为自定义
currentPreset.value = 'custom';
audioService.setCurrentPreset('custom');
}
};
const formatFreq = (freq: number) => {
if (freq >= 1000) {
return `${freq / 1000}kHz`;
}
return `${freq}Hz`;
};
</script>
<style lang="scss" scoped>
.eq-control {
@apply p-6 rounded-lg;
@apply bg-light dark:bg-dark;
@apply shadow-lg dark:shadow-none;
width: 100%;
max-width: 700px;
.eq-header {
@apply flex justify-between items-center mb-4;
h3 {
@apply text-xl font-semibold;
@apply text-gray-800 dark:text-gray-200;
}
}
.eq-presets {
@apply mb-2 relative;
height: 40px;
:deep(.n-scrollbar) {
@apply -mx-2 px-2;
}
:deep(.n-tag) {
@apply cursor-pointer transition-all duration-200;
text-align: center;
&:hover {
transform: translateY(-2px);
}
}
:deep(.n-space) {
flex-wrap: nowrap;
padding: 4px 0;
}
}
.eq-sliders {
@apply flex justify-between items-end;
@apply bg-gray-50 dark:bg-gray-800 gap-1;
@apply rounded-lg p-2;
height: 300px;
.eq-slider {
@apply flex flex-col items-center;
width: 45px;
height: 100%;
.n-slider {
flex: 1;
margin: 12px 0;
min-height: 180px;
}
.freq-label {
@apply text-xs font-medium text-center;
@apply text-gray-600 dark:text-gray-400;
white-space: nowrap;
margin: 8px 0;
height: 20px;
}
.gain-value {
@apply text-xs font-medium text-center;
@apply text-gray-600 dark:text-gray-400;
white-space: nowrap;
margin: 4px 0;
height: 16px;
}
}
}
}
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: theme('colors.gray.200');
--n-rail-color-hover: theme('colors.gray.300');
--n-fill-color: theme('colors.green.500');
--n-fill-color-hover: theme('colors.green.600');
--n-handle-color: theme('colors.green.500');
--n-handle-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.n-slider-handle {
@apply transition-all duration-200;
&:hover {
transform: scale(1.2);
}
}
}
</style>

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { isElectron } from '@/utils'; import { useSettingsStore } from '@/store/modules/settings';
const store = useStore(); const settingsStore = useSettingsStore();
const { locale } = useI18n(); const { locale } = useI18n();
const languages = [ const languages = [
@@ -13,40 +12,12 @@ const languages = [
{ label: 'English', value: 'en-US' } { label: 'English', value: 'en-US' }
]; ];
console.log('locale', locale);
// 使用计算属性来获取当前语言 // 使用计算属性来获取当前语言
const currentLanguage = computed({ const currentLanguage = computed({
get: () => store.state.setData.language || 'zh-CN', get: () => locale.value,
set: (value: string) => { set: (value) => {
handleLanguageChange(value); settingsStore.setLanguage(value);
}
});
// 当语言改变时的处理函数
const handleLanguageChange = (value: string) => {
// 更新 i18n locale
locale.value = value;
// 通过 mutation 更新 store
store.commit('setLanguage', value);
// 通知主进程语言已更改
if (isElectron) {
window.electron.ipcRenderer.send('change-language', value);
}
};
// 监听来自主进程的语言切换事件
const handleSetLanguage = (_: any, value: string) => {
handleLanguageChange(value);
};
onMounted(() => {
if (isElectron) {
window.electron.ipcRenderer.on('set-language', handleSetLanguage);
}
});
onUnmounted(() => {
if (isElectron) {
window.electron.ipcRenderer.removeListener('set-language', handleSetLanguage);
} }
}); });
</script> </script>

View File

@@ -12,21 +12,39 @@
> >
<div class="music-page"> <div class="music-page">
<div class="music-header h-12 flex items-center justify-between"> <div class="music-header h-12 flex items-center justify-between">
<n-ellipsis :line-clamp="1"> <n-ellipsis :line-clamp="1" class="flex-shrink-0 mr-3">
<div class="music-title"> <div class="music-title">
{{ name }} {{ name }}
</div> </div>
</n-ellipsis> </n-ellipsis>
<div class="music-close">
<!-- 搜索框 -->
<div class="flex-grow flex-1 flex items-center justify-end">
<div class="search-container">
<n-input
v-model:value="searchKeyword"
:placeholder="t('comp.musicList.searchSongs')"
clearable
round
size="small"
>
<template #prefix>
<i class="icon iconfont ri-search-line text-sm"></i>
</template>
</n-input>
</div>
</div>
<div class="music-close flex-shrink-0 ml-3">
<i class="icon iconfont ri-close-line" @click="close"></i> <i class="icon iconfont ri-close-line" @click="close"></i>
</div> </div>
</div> </div>
<div class="music-content"> <div class="music-content">
<!-- 左侧歌单信息 --> <!-- 左侧歌单信息 -->
<div class="music-info"> <div class="music-info">
<div class="music-cover"> <div class="music-cover">
<n-image <n-image
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '500y500')" :src="getCoverImgUrl"
class="cover-img" class="cover-img"
preview-disabled preview-disabled
:class="setAnimationClass('animate__fadeIn')" :class="setAnimationClass('animate__fadeIn')"
@@ -37,42 +55,48 @@
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" /> <n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
<span class="creator-name">{{ listInfo.creator.nickname }}</span> <span class="creator-name">{{ listInfo.creator.nickname }}</span>
</div> </div>
<div v-if="total" class="music-total">{{ t('player.songNum', { num: total }) }}</div>
<n-scrollbar style="max-height: 200"> <n-scrollbar style="max-height: 200px">
<div v-if="listInfo?.description" class="music-desc"> <div v-if="listInfo?.description" class="music-desc">
{{ listInfo.description }} {{ listInfo.description }}
</div> </div>
<play-bottom />
</n-scrollbar> </n-scrollbar>
</div> </div>
<!-- 右侧歌曲列表 --> <!-- 右侧歌曲列表 -->
<div class="music-list-container"> <div class="music-list-container">
<div class="music-list"> <div class="music-list">
<n-scrollbar @scroll="handleScroll"> <n-spin :show="loadingList || loading">
<n-spin :show="loadingList || loading"> <div class="music-list-content">
<div class="music-list-content"> <div v-if="filteredSongs.length === 0 && searchKeyword" class="no-result">
<div {{ t('comp.musicList.noSearchResults') }}
v-for="(item, index) in displayedSongs"
:key="item.id"
class="double-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="getItemAnimationDelay(index)"
>
<song-item
:item="formatDetail(item)"
:can-remove="canRemove"
@play="handlePlay"
@remove-song="(id) => emit('remove-song', id)"
/>
</div>
<div v-if="isLoadingMore" class="loading-more">
{{ t('common.loadingMore') }}
</div>
<play-bottom />
</div> </div>
</n-spin>
</n-scrollbar> <!-- 虚拟列表设置正确的固定高度 -->
<n-virtual-list
ref="songListRef"
class="song-virtual-list"
style="height: calc(70vh - 60px)"
:items="filteredSongs"
:item-size="70"
item-resizable
key-field="id"
@scroll="handleVirtualScroll"
>
<template #default="{ item }">
<div class="double-item">
<song-item
:item="formatSong(item)"
:can-remove="canRemove"
@play="handlePlay"
@remove-song="(id) => emit('remove-song', id)"
/>
</div>
</template>
</n-virtual-list>
</div>
</n-spin>
</div> </div>
<play-bottom /> <play-bottom />
</div> </div>
@@ -82,18 +106,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import PinyinMatch from 'pinyin-match';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils'; import { usePlayerStore } from '@/store/modules/player';
import { SongResult } from '@/type/music';
import { getImgUrl, isMobile, setAnimationClass } from '@/utils';
import PlayBottom from './common/PlayBottom.vue'; import PlayBottom from './common/PlayBottom.vue';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const playerStore = usePlayerStore();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
show: boolean; show: boolean;
@@ -119,10 +145,16 @@ const props = withDefaults(
const emit = defineEmits(['update:show', 'update:loading', 'remove-song']); const emit = defineEmits(['update:show', 'update:loading', 'remove-song']);
const page = ref(0); const page = ref(0);
const pageSize = 20; const pageSize = 40;
const isLoadingMore = ref(false); const isLoadingMore = ref(false);
const displayedSongs = ref<any[]>([]); const displayedSongs = ref<SongResult[]>([]);
const loadingList = ref(false); const loadingList = ref(false);
const loadedIds = ref(new Set<number>()); // 用于追踪已加载的歌曲ID
const isPlaylistLoading = ref(false); // 标记是否正在加载播放列表
const completePlaylist = ref<SongResult[]>([]); // 存储完整的播放列表
const hasMore = ref(true); // 标记是否还有更多数据可加载
const searchKeyword = ref(''); // 搜索关键词
const isFullPlaylistLoaded = ref(false); // 标记完整播放列表是否已加载完成
// 计算总数 // 计算总数
const total = computed(() => { const total = computed(() => {
@@ -132,108 +164,427 @@ const total = computed(() => {
return props.songList.length; return props.songList.length;
}); });
const formatDetail = computed(() => (detail: any) => { const getCoverImgUrl = computed(() => {
const song = { if (props.listInfo?.coverImgUrl) {
artists: detail.ar, return props.listInfo.coverImgUrl;
name: detail.al.name, }
id: detail.al.id
};
detail.song = song; const song = props.songList[0];
detail.picUrl = detail.al.picUrl; if (song?.picUrl) {
return detail; return song.picUrl;
}
if (song?.al?.picUrl) {
return song.al.picUrl;
}
if (song?.album?.picUrl) {
return song.album.picUrl;
}
return '';
}); });
const handlePlay = () => { // 过滤歌曲列表
const tracks = props.songList || []; const filteredSongs = computed(() => {
store.commit( if (!searchKeyword.value) {
'setPlayList', return displayedSongs.value;
tracks.map((item) => ({ }
...item,
picUrl: item.al.picUrl, const keyword = searchKeyword.value.toLowerCase().trim();
song: { return displayedSongs.value.filter((song) => {
artists: item.ar const songName = song.name?.toLowerCase() || '';
const albumName = song.al?.name?.toLowerCase() || '';
const artists = song.ar || song.artists || [];
// 原始文本匹配
const nameMatch = songName.includes(keyword);
const albumMatch = albumName.includes(keyword);
const artistsMatch = artists.some((artist: any) => {
return artist.name?.toLowerCase().includes(keyword);
});
// 拼音匹配
const namePinyinMatch = song.name && PinyinMatch.match(song.name, keyword);
const albumPinyinMatch = song.al?.name && PinyinMatch.match(song.al.name, keyword);
const artistsPinyinMatch = artists.some((artist: any) => {
return artist.name && PinyinMatch.match(artist.name, keyword);
});
return (
nameMatch ||
albumMatch ||
artistsMatch ||
namePinyinMatch ||
albumPinyinMatch ||
artistsPinyinMatch
);
});
});
// 格式化歌曲数据
const formatSong = (item: any) => {
if (!item) {
return null;
}
return {
...item,
picUrl: item.al?.picUrl || item.picUrl,
song: {
artists: item.ar || item.artists,
name: item.al?.name || item.name,
id: item.al?.id || item.id
}
};
};
/**
* 加载歌曲数据的核心函数
* @param ids 要加载的歌曲ID数组
* @param appendToList 是否将加载的歌曲追加到现有列表
* @param updateComplete 是否更新完整播放列表
*/
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
if (ids.length === 0) return [];
try {
console.log(`请求歌曲详情ID数量: ${ids.length}`);
const { data } = await getMusicDetail(ids);
if (data?.songs) {
console.log(`API返回歌曲数量: ${data.songs.length}`);
// 直接使用API返回的所有歌曲不再过滤已加载的歌曲
// 因为当需要完整加载列表时我们希望获取所有歌曲即使ID可能重复
const { songs } = data;
// 只在非更新完整列表时执行过滤
let newSongs = songs;
if (!updateComplete) {
// 在普通加载模式下继续过滤已加载的歌曲,避免重复
newSongs = songs.filter((song: any) => !loadedIds.value.has(song.id));
console.log(`过滤已加载ID后剩余歌曲数量: ${newSongs.length}`);
} }
}))
); // 更新已加载ID集合
songs.forEach((song: any) => {
loadedIds.value.add(song.id);
});
// 追加到显示列表 - 仅当appendToList=true时添加到displayedSongs
if (appendToList) {
displayedSongs.value.push(...newSongs);
}
// 更新完整播放列表 - 仅当updateComplete=true时添加到completePlaylist
if (updateComplete) {
completePlaylist.value.push(...songs);
console.log(`已添加到完整播放列表,当前完整列表长度: ${completePlaylist.value.length}`);
}
return updateComplete ? songs : newSongs;
}
console.log('API返回无歌曲数据');
return [];
} catch (error) {
console.error('加载歌曲失败:', error);
}
return [];
};
// 加载完整播放列表
const loadFullPlaylist = async () => {
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
isPlaylistLoading.value = true;
// 记录开始时间
const startTime = Date.now();
console.log(`开始加载完整播放列表,当前显示列表长度: ${displayedSongs.value.length}`);
try {
// 如果没有trackIds直接使用当前歌曲列表并标记为已完成
if (!props.listInfo?.trackIds) {
isFullPlaylistLoaded.value = true;
console.log('无trackIds信息使用当前列表作为完整列表');
return;
}
// 获取所有trackIds
const allIds = props.listInfo.trackIds.map((item) => item.id);
console.log(`歌单共有歌曲ID: ${allIds.length}首`);
// 重置completePlaylist和当前显示歌曲ID集合保证不会重复添加歌曲
completePlaylist.value = [];
// 使用Set记录所有已加载的歌曲ID
const loadedSongIds = new Set<number>();
// 将当前显示列表中的歌曲和ID添加到集合中
displayedSongs.value.forEach((song) => {
loadedSongIds.add(song.id as number);
// 将已有歌曲添加到completePlaylist
completePlaylist.value.push(song);
});
console.log(
`已有显示歌曲: ${displayedSongs.value.length}首已有ID数量: ${loadedSongIds.size}`
);
// 过滤出尚未加载的歌曲ID
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
console.log(`还需要加载的歌曲ID数量: ${unloadedIds.length}`);
if (unloadedIds.length === 0) {
console.log('所有歌曲已加载,无需再次加载');
isFullPlaylistLoaded.value = true;
hasMore.value = false;
return;
}
// 分批加载所有未加载的歌曲
const batchSize = 500; // 每批加载的歌曲数量
for (let i = 0; i < unloadedIds.length; i += batchSize) {
const batchIds = unloadedIds.slice(i, i + batchSize);
if (batchIds.length === 0) continue;
console.log(`请求第${Math.floor(i / batchSize) + 1}批歌曲,数量: ${batchIds.length}`);
// 关键修改: 设置appendToList为false避免loadSongs直接添加到displayedSongs
const loadedBatch = await loadSongs(batchIds, false, false);
// 添加新加载的歌曲到displayedSongs
if (loadedBatch.length > 0) {
// 过滤掉已有的歌曲,确保不会重复添加
const newSongs = loadedBatch.filter((song) => !loadedSongIds.has(song.id as number));
// 更新已加载ID集合
newSongs.forEach((song) => {
loadedSongIds.add(song.id as number);
});
console.log(`新增${newSongs.length}首歌曲到显示列表`);
// 更新显示列表和完整播放列表
if (newSongs.length > 0) {
// 添加到显示列表
displayedSongs.value = [...displayedSongs.value, ...newSongs];
// 添加到完整播放列表
completePlaylist.value.push(...newSongs);
// 如果当前正在播放的列表与这个列表匹配,实时更新播放列表
const currentPlaylist = playerStore.playList;
if (currentPlaylist.length > 0 && currentPlaylist[0].id === displayedSongs.value[0]?.id) {
console.log('实时更新当前播放列表');
playerStore.setPlayList(displayedSongs.value.map(formatSong));
}
}
}
// 添加小延迟避免请求过于密集
if (i + batchSize < unloadedIds.length) {
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 100);
});
}
}
// 加载完成,更新状态
isFullPlaylistLoaded.value = true;
hasMore.value = false;
// 计算加载耗时
const endTime = Date.now();
const timeUsed = Math.round(((endTime - startTime) / 1000) * 100) / 100;
console.log(
`完整播放列表加载完成,共加载${displayedSongs.value.length}首歌曲,耗时${timeUsed}秒`
);
console.log(`歌单应有${allIds.length}首歌,实际加载${displayedSongs.value.length}首`);
// 检查加载的歌曲数量是否与预期相符
if (displayedSongs.value.length !== allIds.length) {
console.warn(
`警告: 加载的歌曲数量(${displayedSongs.value.length})与歌单应有数量(${allIds.length})不符`
);
// 如果数量不符可能是API未返回所有歌曲打印缺失的歌曲ID
if (displayedSongs.value.length < allIds.length) {
const loadedIds = new Set(displayedSongs.value.map((song) => song.id));
const missingIds = allIds.filter((id) => !loadedIds.has(id));
console.warn(`缺失的歌曲ID: ${missingIds.join(', ')}`);
}
}
} catch (error) {
console.error('加载完整播放列表失败:', error);
} finally {
isPlaylistLoading.value = false;
}
};
// 处理播放
const handlePlay = async () => {
// 当搜索状态下播放时,只播放过滤后的歌曲
if (searchKeyword.value) {
playerStore.setPlayList(filteredSongs.value.map(formatSong));
return;
}
// 如果完整播放列表已加载完成
if (isFullPlaylistLoaded.value && completePlaylist.value.length > 0) {
playerStore.setPlayList(completePlaylist.value.map(formatSong));
return;
}
// 如果完整播放列表未加载完成,先使用当前已加载的歌曲开始播放
playerStore.setPlayList(displayedSongs.value.map(formatSong));
// 如果完整播放列表正在加载中,不需要重新触发加载
if (isPlaylistLoading.value) {
return;
}
// 在后台继续加载完整播放列表(如果未加载完成)
if (!isFullPlaylistLoaded.value) {
console.log('播放时继续在后台加载完整列表');
loadFullPlaylist();
}
}; };
const close = () => { const close = () => {
emit('update:show', false); emit('update:show', false);
}; };
// 优化加载更多歌曲的函数 // 加载更多歌曲
const loadMoreSongs = async () => { const loadMoreSongs = async () => {
if (isLoadingMore.value || displayedSongs.value.length >= total.value) return; if (isFullPlaylistLoaded.value) {
hasMore.value = false;
return;
}
if (searchKeyword.value) {
return;
}
if (isLoadingMore.value || displayedSongs.value.length >= total.value) {
hasMore.value = false;
return;
}
isLoadingMore.value = true; isLoadingMore.value = true;
try {
if (props.listInfo?.trackIds) {
// 如果有 trackIds需要分批请求歌曲详情
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, total.value);
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
if (trackIds.length > 0) { try {
const { data } = await getMusicDetail(trackIds); const start = displayedSongs.value.length;
displayedSongs.value = [...displayedSongs.value, ...data.songs]; const end = Math.min(start + pageSize, total.value);
page.value++;
if (props.listInfo?.trackIds) {
const trackIdsToLoad = props.listInfo.trackIds
.slice(start, end)
.map((item) => item.id)
.filter((id) => !loadedIds.value.has(id));
if (trackIdsToLoad.length > 0) {
await loadSongs(trackIdsToLoad, true, false);
} }
} else { } else if (start < props.songList.length) {
// 如果没有 trackIds直接使用 songList 分页
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
const newSongs = props.songList.slice(start, end); const newSongs = props.songList.slice(start, end);
displayedSongs.value = [...displayedSongs.value, ...newSongs]; newSongs.forEach((song) => {
page.value++; if (!loadedIds.value.has(song.id)) {
loadedIds.value.add(song.id);
displayedSongs.value.push(song);
}
});
} }
hasMore.value = displayedSongs.value.length < total.value;
} catch (error) { } catch (error) {
console.error('加载歌曲失败:', error); console.error('加载更多歌曲失败:', error);
} finally { } finally {
isLoadingMore.value = false; isLoadingMore.value = false;
loadingList.value = false; loadingList.value = false;
} }
}; };
const getItemAnimationDelay = (index: number) => { // 处理虚拟列表滚动事件
const currentPageIndex = index % pageSize; const handleVirtualScroll = (e: any) => {
return setAnimationDelay(currentPageIndex, 20); if (!e || !e.target) return;
};
// 修改滚动处理函数 const { scrollTop, scrollHeight, clientHeight } = e.target;
const handleScroll = (e: Event) => { const threshold = 200;
const target = e.target as HTMLElement;
if (!target) return;
const { scrollTop, scrollHeight, clientHeight } = target; if (
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) { scrollHeight - scrollTop - clientHeight < threshold &&
!isLoadingMore.value &&
hasMore.value &&
!searchKeyword.value // 搜索状态下不触发加载更多
) {
loadMoreSongs(); loadMoreSongs();
} }
}; };
watch( // 重置列表状态
() => props.show, const resetListState = () => {
(newVal) => { page.value = 0;
loadingList.value = newVal; loadedIds.value.clear();
if (!props.cover) { displayedSongs.value = [];
loadingList.value = false; completePlaylist.value = [];
} hasMore.value = true;
} loadingList.value = false;
); searchKeyword.value = ''; // 重置搜索关键词
isFullPlaylistLoaded.value = false; // 重置完整播放列表状态
};
// 监听 songList 变化,重置分页状态 // 初始化歌曲列表
const initSongList = (songs: any[]) => {
if (songs.length > 0) {
displayedSongs.value = [...songs];
songs.forEach((song) => loadedIds.value.add(song.id));
page.value = Math.ceil(songs.length / pageSize);
}
// 检查是否还有更多数据可加载
hasMore.value = displayedSongs.value.length < total.value;
};
watch(
() => props.listInfo,
(newListInfo) => {
if (newListInfo?.trackIds) {
loadFullPlaylist();
}
},
{ deep: true }
);
// 修改 songList 监听器
watch( watch(
() => props.songList, () => props.songList,
(newSongs) => { (newSongs) => {
page.value = 0; // 重置所有状态
displayedSongs.value = newSongs.slice(0, pageSize); resetListState();
if (newSongs.length > pageSize) {
page.value = 1; // 初始化歌曲列表
initSongList(newSongs);
// 如果还有更多歌曲需要加载,且差距较小,立即加载
if (hasMore.value && props.listInfo?.trackIds) {
setTimeout(() => {
loadMoreSongs();
}, 300);
} }
loadingList.value = false;
}, },
{ immediate: true } { immediate: true }
); );
// 监听搜索关键词变化
watch(searchKeyword, () => {
// 当搜索关键词为空时,考虑加载更多歌曲
if (!searchKeyword.value && hasMore.value && displayedSongs.value.length < total.value) {
loadMoreSongs();
}
});
// 组件卸载时清理状态
onUnmounted(() => {
isPlaylistLoading.value = false;
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -242,6 +593,10 @@ watch(
@apply text-xl font-bold text-gray-900 dark:text-white; @apply text-xl font-bold text-gray-900 dark:text-white;
} }
&-total {
@apply text-sm font-normal text-gray-500 dark:text-gray-400;
}
&-page { &-page {
@apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl; @apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
@@ -289,12 +644,34 @@ watch(
&-content { &-content {
@apply min-h-[calc(80vh-60px)]; @apply min-h-[calc(80vh-60px)];
} }
}
}
:deep(.n-virtual-list__scroll) { .search-container {
scrollbar-width: none; @apply max-w-md;
&::-webkit-scrollbar {
display: none; :deep(.n-input) {
} @apply bg-light-200 dark:bg-dark-200;
}
.icon {
@apply text-gray-500 dark:text-gray-400;
}
}
.no-result {
@apply text-center py-8 text-gray-500 dark:text-gray-400;
}
/* 虚拟列表样式 */
.song-virtual-list {
:deep(.n-virtual-list__scroll) {
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-400 dark:bg-gray-600 rounded;
} }
} }
} }
@@ -306,6 +683,7 @@ watch(
.music-content { .music-content {
@apply flex-col; @apply flex-col;
width: 100vw !important;
} }
.music-info { .music-info {
@@ -318,6 +696,18 @@ watch(
@apply flex-1 ml-4; @apply flex-1 ml-4;
} }
} }
.music-title {
@apply text-base;
}
.search-container {
@apply max-w-[50%];
}
.song-virtual-list {
height: calc(80vh - 120px) !important;
}
} }
.loading-more { .loading-more {

View File

@@ -191,9 +191,9 @@
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui'; import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
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 +222,7 @@ const emit = defineEmits<{
(e: 'prev', loading: (value: boolean) => void): void; (e: 'prev', loading: (value: boolean) => void): void;
}>(); }>();
const store = useStore(); 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,8 +359,8 @@ const loadMvUrl = async (mv: IMvItem) => {
const handleClose = () => { const handleClose = () => {
emit('update:show', false); emit('update:show', false);
if (store.state.playMusicUrl) { if (playerStore.playMusicUrl) {
store.commit('setIsPlay', true); playerStore.setIsPlay(true);
} }
}; };
@@ -543,7 +543,7 @@ watch(showControls, (newValue) => {
} }
}); });
const isMobile = computed(() => store.state.isMobile); const isMobile = computed(() => false); // TODO: 从 settings store 获取
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,179 +0,0 @@
<template>
<!-- 推荐歌手 -->
<n-scrollbar :size="100" :x-scrollable="true">
<div class="recommend-singer">
<div class="recommend-singer-list">
<div
v-if="dayRecommendData"
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(0, 100)"
>
<div
:style="
setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '500y500'))
"
class="recommend-singer-item-bg"
></div>
<div
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
@click="showMusic = true"
>
<div class="font-bold text-lg">
{{ t('comp.recommendSinger.title') }}
</div>
<div class="mt-2">
<p
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)"
:key="item.id"
class="text-el"
>
{{ item.name }}
<br />
</p>
</div>
</div>
</div>
<div
v-for="(item, index) in hotSingerData?.artists"
:key="item.id"
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(index + 1, 100)"
>
<div
:style="setBackgroundImg(getImgUrl(item.picUrl, '500y500'))"
class="recommend-singer-item-bg"
></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
{{ t('common.songCount', { count: item.musicSize }) }}
</div>
<div class="recommend-singer-item-info z-10">
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
<i class="iconfont icon-playfill text-xl"></i>
</div>
<div class="ml-4">
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<music-list
v-if="dayRecommendData?.dailySongs.length"
v-model:show="showMusic"
:name="t('comp.recommendSinger.songlist')"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
</div>
</n-scrollbar>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getDayRecommend, getHotSinger } from '@/api/home';
import MusicList from '@/components/MusicList.vue';
import router from '@/router';
import { IDayRecommend } from '@/type/day_recommend';
import type { IHotSinger } from '@/type/singer';
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
const store = useStore();
const { t } = useI18n();
// 歌手信息
const hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
onMounted(async () => {
await loadData();
});
const loadData = async () => {
try {
// 第一个请求:获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
// 第二个请求:获取每日推荐
try {
const {
data: { data: dayRecommend }
} = await getDayRecommend();
// 处理数据
if (dayRecommend) {
singerData.artists = singerData.artists.slice(0, 4);
}
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
} catch (error) {
console.error('error', error);
}
hotSingerData.value = singerData;
} catch (error) {
console.error('error', error);
}
};
const toSearchSinger = (keyword: string) => {
router.push({
path: '/search',
query: {
keyword
}
});
};
// 监听登录状态
watchEffect(() => {
if (store.state.user) {
loadData();
}
});
</script>
<style lang="scss" scoped>
.recommend-singer {
&-list {
@apply flex;
height: 280px;
}
&-item {
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between overflow-hidden;
&-bg {
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
filter: brightness(60%);
}
&-info {
@apply flex items-center p-2;
&-play {
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer text-white;
}
&-name {
@apply text-gray-100 dark:text-gray-100;
}
}
&-count {
@apply text-gray-100 dark:text-gray-100;
}
}
}
.mobile .recommend-singer {
&-list {
height: 180px;
@apply ml-4;
}
&-item {
@apply p-4 rounded-xl;
&-bg {
@apply rounded-xl;
}
}
}
</style>

View File

@@ -90,14 +90,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDateFormat } from '@vueuse/core'; import { useDateFormat } from '@vueuse/core';
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getArtistAlbums, getArtistDetail, getArtistTopSongs } from '@/api/artist'; import { getArtistAlbums, getArtistDetail, getArtistTopSongs } from '@/api/artist';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
import SearchItem from '@/components/common/SearchItem.vue'; import SearchItem from '@/components/common/SearchItem.vue';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { usePlayerStore, useSettingsStore } from '@/store';
import { IArtist } from '@/type/artist'; import { IArtist } from '@/type/artist';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
@@ -105,11 +105,17 @@ import PlayBottom from './PlayBottom.vue';
const { t } = useI18n(); const { t } = useI18n();
const settingsStore = useSettingsStore();
const playerStore = usePlayerStore();
const currentArtistId = computed({
get: () => settingsStore.currentArtistId,
set: (val) => settingsStore.setCurrentArtistId(val as number)
});
const modelValue = defineModel<boolean>('show', { required: true }); const modelValue = defineModel<boolean>('show', { required: true });
const store = useStore();
const activeTab = ref('songs'); const activeTab = ref('songs');
const currentArtistId = ref<number>();
// 歌手信息 // 歌手信息
const artistInfo = ref<IArtist>(); const artistInfo = ref<IArtist>();
@@ -134,15 +140,18 @@ const albumPage = ref({
}); });
watch(modelValue, (newVal) => { watch(modelValue, (newVal) => {
store.commit('setShowArtistDrawer', newVal); settingsStore.setShowArtistDrawer(newVal);
}); });
const loading = ref(false); const loading = ref(false);
// 加载歌手信息 // 加载歌手信息
const previousArtistId = ref<number>();
const loadArtistInfo = async (id: number) => { const loadArtistInfo = async (id: number) => {
if (currentArtistId.value === id) return; // if (currentArtistId.value === id) return;
if (previousArtistId.value === id) return;
activeTab.value = 'songs'; activeTab.value = 'songs';
loading.value = true; loading.value = true;
currentArtistId.value = id; previousArtistId.value = id;
try { try {
const info = await getArtistDetail(id); const info = await getArtistDetail(id);
if (info.data?.data?.artist) { if (info.data?.data?.artist) {
@@ -260,8 +269,7 @@ const formatPublishTime = (time: number) => {
}; };
const handlePlay = () => { const handlePlay = () => {
store.commit( playerStore.setPlayList(
'setPlayList',
songs.value.map((item) => ({ songs.value.map((item) => ({
...item, ...item,
picUrl: item.al.picUrl picUrl: item.al.picUrl

View File

@@ -0,0 +1,117 @@
<template>
<div class="bilibili-item" @click="handleClick">
<div class="bilibili-item-img">
<n-image class="w-full h-full" :src="item.pic" lazy preview-disabled />
<div class="play">
<i class="ri-play-fill text-4xl"></i>
</div>
<div class="duration">{{ formatDuration(item.duration) }}</div>
</div>
<div class="bilibili-item-info">
<p class="bilibili-item-title" v-html="item.title"></p>
<p class="bilibili-item-author"><i class="ri-user-line mr-1"></i>{{ item.author }}</p>
<div class="bilibili-item-stats">
<span><i class="ri-play-line mr-1"></i>{{ formatNumber(item.view) }}</span>
<span><i class="ri-chat-1-line mr-1"></i>{{ formatNumber(item.danmaku) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { IBilibiliSearchResult } from '@/types/bilibili';
const props = defineProps<{
item: IBilibiliSearchResult;
}>();
const emit = defineEmits<{
(e: 'play', item: IBilibiliSearchResult): void;
}>();
const handleClick = () => {
emit('play', props.item);
};
/**
* 格式化数字显示
*/
const formatNumber = (num?: number) => {
if (!num) return '0';
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}`;
}
return num.toString();
};
/**
* 格式化视频时长
*/
const formatDuration = (duration?: number | string) => {
if (!duration) return '00:00:00';
// 处理字符串格式 (例如 "4352:29")
if (typeof duration === 'string') {
// 检查是否是合法的格式
if (/^\d+:\d+$/.test(duration)) {
// 分解分钟和秒数
const [minutes, seconds] = duration.split(':').map(Number);
// 转换为时:分:秒格式
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return '00:00:00';
}
// 数字处理逻辑 (秒数转为"时:分:秒"格式)
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
</script>
<style scoped lang="scss">
.bilibili-item {
@apply rounded-lg flex items-start hover:bg-light-200 dark:hover:bg-dark-200 p-3 transition cursor-pointer border-none;
&-img {
@apply w-40 rounded-lg overflow-hidden relative mr-4;
aspect-ratio: 16/9;
&:hover {
.play {
@apply opacity-80;
}
}
.play {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity text-white;
}
.duration {
@apply absolute bottom-1 right-1 text-xs text-white px-1 py-0.5 rounded-sm bg-black/60 backdrop-blur-sm;
}
}
&-info {
@apply flex-1 overflow-hidden;
}
&-title {
@apply text-gray-800 dark:text-gray-200 text-sm font-medium mb-1 line-clamp-2 leading-tight;
}
&-author {
@apply text-gray-500 dark:text-gray-400 text-xs flex items-center mb-1;
}
&-stats {
@apply flex items-center text-xs text-gray-500 dark:text-gray-400 gap-3;
}
}
</style>

View File

@@ -76,12 +76,12 @@
</n-button> </n-button>
</div> </div>
<div class="p-6 rounded-lg shadow-lg bg-light dark:bg-gray-800"> <div class="p-6 rounded-lg shadow-lg">
<div class="description text-center text-sm text-gray-700 dark:text-gray-200"> <div class="description text-center text-sm text-gray-700 dark:text-gray-200">
<p>{{ t('donation.description') }}</p> <p>{{ t('donation.description') }}</p>
<p>{{ t('donation.message') }}</p> <p>{{ t('donation.message') }}</p>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between mt-6">
<div class="flex flex-col items-center gap-2"> <div class="flex flex-col items-center gap-2">
<n-image <n-image
:src="alipay" :src="alipay"
@@ -91,6 +91,13 @@
/> />
<span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.alipay') }}</span> <span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.alipay') }}</span>
</div> </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"> <div class="flex flex-col items-center gap-2">
<n-image <n-image
:src="wechat" :src="wechat"
@@ -225,6 +232,10 @@ const displayDonors = computed(() => {
const toggleExpand = () => { const toggleExpand = () => {
isExpanded.value = !isExpanded.value; isExpanded.value = !isExpanded.value;
}; };
const toDonateList = () => {
window.open('http://donate.alger.fun', '_blank');
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,7 +1,7 @@
<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="store.commit('setShowDownloadDrawer', true)"> <n-button circle @click="settingsStore.showDownloadDrawer = true">
<template #icon> <template #icon>
<i class="iconfont ri-download-cloud-2-line"></i> <i class="iconfont ri-download-cloud-2-line"></i>
</template> </template>
@@ -90,10 +90,25 @@
<!-- 已下载列表 --> <!-- 已下载列表 -->
<n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full"> <n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full">
<div class="downloaded-list"> <div class="downloaded-list">
<div v-if="downloadedList.length === 0" class="empty-tip"> <div v-if="isLoadingDownloaded" class="loading-tip">
<n-spin size="medium" />
<span class="loading-text">{{ t('download.loading') }}</span>
</div>
<div v-else-if="downloadedList.length === 0" class="empty-tip">
<n-empty :description="t('download.empty.noDownloaded')" /> <n-empty :description="t('download.empty.noDownloaded')" />
</div> </div>
<div v-else class="downloaded-content"> <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 class="downloaded-items">
<div v-for="item in downList" :key="item.path" class="downloaded-item"> <div v-for="item in downList" :key="item.path" class="downloaded-item">
<div class="downloaded-item-content"> <div class="downloaded-item-content">
@@ -172,18 +187,46 @@
}}</n-button> }}</n-button>
</template> </template>
</n-modal> </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 type { ProgressStatus } from 'naive-ui';
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
// import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
// import { audioService } from '@/services/audioService'; // import { audioService } from '@/services/audioService';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
// import { SongResult } from '@/type/music';
const { t } = useI18n(); const { t } = useI18n();
@@ -208,11 +251,14 @@ interface DownloadedItem {
} }
const message = useMessage(); const message = useMessage();
const store = useStore(); // const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const showDrawer = computed({ const showDrawer = computed({
get: () => store.state.showDownloadDrawer, get: () => settingsStore.showDownloadDrawer,
set: (val) => store.commit('setShowDownloadDrawer', val) set: (val) => {
settingsStore.showDownloadDrawer = val;
}
}); });
const downloadList = ref<DownloadItem[]>([]); const downloadList = ref<DownloadItem[]>([]);
@@ -220,13 +266,7 @@ const downloadedList = ref<DownloadedItem[]>(
JSON.parse(localStorage.getItem('downloadedList') || '[]') JSON.parse(localStorage.getItem('downloadedList') || '[]')
); );
const downList = computed(() => { const downList = computed(() => downloadedList.value);
return (downloadedList.value as DownloadedItem[]).reverse();
});
// 获取播放状态
// const play = computed(() => store.state.play as boolean);
// const currentMusic = computed(() => store.state.playMusic);
// 计算下载中的任务数量 // 计算下载中的任务数量
const downloadingCount = computed(() => { const downloadingCount = computed(() => {
@@ -312,142 +352,115 @@ const handleDelete = (item: DownloadedItem) => {
// 确认删除 // 确认删除
const confirmDelete = async () => { const confirmDelete = async () => {
if (!itemToDelete.value) return; const item = itemToDelete.value;
if (!item) return;
try { try {
const success = await window.electron.ipcRenderer.invoke( const success = await window.electron.ipcRenderer.invoke(
'delete-downloaded-music', 'delete-downloaded-music',
itemToDelete.value.path item.path
); );
if (success) { if (success) {
localStorage.setItem( const newList = downloadedList.value.filter(i => i.id !== item.id);
'downloadedList', downloadedList.value = newList;
JSON.stringify( localStorage.setItem('downloadedList', JSON.stringify(newList));
downloadedList.value.filter(
(item) => item.id !== (itemToDelete.value as DownloadedItem).id
)
)
);
await refreshDownloadedList();
message.success(t('download.delete.success')); message.success(t('download.delete.success'));
} else { } else {
message.error(t('download.delete.failed')); message.warning(t('download.delete.fileNotFound'));
} }
} catch (error) { } catch (error) {
console.error('Failed to delete music:', error); console.error('Failed to delete music:', error);
message.error(t('download.delete.failed')); message.warning(t('download.delete.recordRemoved'));
} finally { } finally {
showDeleteConfirm.value = false; showDeleteConfirm.value = false;
itemToDelete.value = null; itemToDelete.value = null;
} }
}; };
// 播放音乐 // 清空下载记录相关
// const handlePlayMusic = async (item: DownloadedItem) => { const showClearConfirm = ref(false);
// // 确保路径正确编码
// const encodedPath = encodeURIComponent(item.path);
// const localUrl = `local://${encodedPath}`;
// const musicInfo = { // 清空下载记录
// name: item.filename, const clearDownloadRecords = async () => {
// id: item.id,
// url: localUrl,
// playMusicUrl: localUrl,
// picUrl: item.picUrl,
// ar: item.ar || [{ name: '本地音乐' }],
// song: {
// artists: item.ar || [{ name: '本地音乐' }]
// },
// al: {
// picUrl: item.picUrl || '/images/default_cover.png'
// }
// };
// // 如果是当前播放的音乐,则切换播放状态
// if (currentMusic.value?.id === item.id) {
// if (play.value) {
// audioService.getCurrentSound()?.pause();
// store.commit('setPlayMusic', false);
// } else {
// audioService.getCurrentSound()?.play();
// store.commit('setPlayMusic', true);
// }
// return;
// }
// // 播放新的音乐
// store.commit('setPlay', musicInfo);
// store.commit('setPlayMusic', true);
// store.commit('setIsPlay', true);
// store.commit(
// 'setPlayList',
// downloadedList.value.map((item) => ({
// ...item,
// playMusicUrl: `local://${encodeURIComponent(item.path)}`
// }))
// );
// };
// 获取已下载音乐列表
const refreshDownloadedList = async () => {
try { 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 = []; downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
await window.electron.ipcRenderer.invoke('clear-downloaded-music');
message.success(t('download.clear.success'));
} catch (error) {
console.error('Failed to clear download records:', error);
message.error(t('download.clear.failed'));
} finally {
showClearConfirm.value = false;
} }
}; };
const setLocalDownloadedList = (list: DownloadedItem[]) => { // 播放音乐
const localList = localStorage.getItem('downloadedList'); // const handlePlay = async (musicInfo: SongResult) => {
// 合并 去重 // await playerStore.setPlay(musicInfo);
const saveList = [...(localList ? JSON.parse(localList) : []), ...list]; // playerStore.setPlayMusic(true);
const uniqueList = saveList.filter( // playerStore.setIsPlay(true);
(item, index, self) => index === self.findIndex((t) => t.id === item.id) // };
);
localStorage.setItem('downloadedList', JSON.stringify(uniqueList)); // 添加加载状态
downloadedList.value = uniqueList; const isLoadingDownloaded = ref(false);
// 获取已下载音乐列表
const refreshDownloadedList = async () => {
if (isLoadingDownloaded.value) return; // 防止重复加载
try {
isLoadingDownloaded.value = true;
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
if (!Array.isArray(list) || list.length === 0) {
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
return;
}
const songIds = list.filter(item => item.id).map(item => item.id);
if (songIds.length === 0) {
downloadedList.value = list;
localStorage.setItem('downloadedList', JSON.stringify(list));
return;
}
try {
const detailRes = await getMusicDetail(songIds);
const songDetails = detailRes.data.songs.reduce((acc, song) => {
acc[song.id] = song;
return acc;
}, {});
const updatedList = list.map(item => ({
...item,
picUrl: songDetails[item.id]?.al?.picUrl || item.picUrl || '/images/default_cover.png',
ar: songDetails[item.id]?.ar || item.ar || [{ name: t('download.localMusic') }]
}));
downloadedList.value = updatedList;
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
} catch (error) {
console.error('Failed to get music details:', error);
downloadedList.value = list;
localStorage.setItem('downloadedList', JSON.stringify(list));
}
} catch (error) {
console.error('Failed to get downloaded music list:', error);
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
} finally {
isLoadingDownloaded.value = false;
}
}; };
// 监听抽屉显示状态 // 监听抽屉显示状态
watch( watch(
() => showDrawer.value, () => showDrawer.value,
(newVal) => { (newVal) => {
if (newVal) { if (newVal && !isLoadingDownloaded.value) {
refreshDownloadedList(); refreshDownloadedList();
} }
} }
@@ -460,6 +473,12 @@ onMounted(() => {
// 监听下载进度 // 监听下载进度
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);
// 如果进度为100%,将状态设置为已完成
if (data.progress === 100) {
data.status = 'completed';
}
if (existingItem) { if (existingItem) {
Object.assign(existingItem, { Object.assign(existingItem, {
...data, ...data,
@@ -479,15 +498,14 @@ 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); // 延迟刷新已下载列表,避免文件系统未完全写入
// 刷新已下载列表 setTimeout(() => refreshDownloadedList(), 500);
refreshDownloadedList();
message.success(t('download.message.downloadComplete', { filename: data.filename })); 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',
@@ -495,12 +513,10 @@ 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( message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
t('download.message.downloadFailed', { filename: data.filename, error: data.error })
);
} }
}); });
@@ -522,7 +538,7 @@ onMounted(() => {
}); });
const handleDrawerClose = () => { const handleDrawerClose = () => {
store.commit('setShowDownloadDrawer', false); settingsStore.showDownloadDrawer = false;
}; };
</script> </script>
@@ -562,9 +578,18 @@ const handleDrawerClose = () => {
@apply flex-1 overflow-hidden pb-40; @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, .download-items,
.downloaded-items { .downloaded-items {
@apply space-y-3; @apply space-y-3 p-4;
} }
.total-progress { .total-progress {

View File

@@ -46,7 +46,7 @@ import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { isElectron, isMobile } from '@/utils'; import { isElectron, isMobile } from '@/utils';
import { getLatestReleaseInfo } from '@/utils/update'; import { getLatestReleaseInfo, getProxyNodes } from '@/utils/update';
import config from '../../../../package.json'; import config from '../../../../package.json';
@@ -63,6 +63,8 @@ const closeModal = () => {
} }
}; };
const proxyHosts = ref<string[]>([]);
onMounted(async () => { onMounted(async () => {
// 如果是 electron 环境,不显示安装提示 // 如果是 electron 环境,不显示安装提示
if (isElectron || isMobile.value) { if (isElectron || isMobile.value) {
@@ -78,6 +80,7 @@ onMounted(async () => {
// 获取最新版本信息 // 获取最新版本信息
releaseInfo.value = await getLatestReleaseInfo(); releaseInfo.value = await getLatestReleaseInfo();
showModal.value = true; showModal.value = true;
proxyHosts.value = await getProxyNodes();
}); });
const handleInstall = async (): Promise<void> => { const handleInstall = async (): Promise<void> => {
@@ -118,7 +121,8 @@ const handleInstall = async (): Promise<void> => {
} }
if (downloadUrl) { if (downloadUrl) {
window.open(`https://ghproxy.cn/${downloadUrl}`, '_blank'); const proxyDownloadUrl = `${proxyHosts.value[0]}/${downloadUrl}`;
window.open(proxyDownloadUrl, '_blank');
} else { } else {
// 如果没有找到对应的安装包,跳转到 release 页面 // 如果没有找到对应的安装包,跳转到 release 页面
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank'); window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');

View File

@@ -3,10 +3,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStore } from 'vuex'; import { computed } from 'vue';
import { usePlayerStore } from '@/store/modules/player';
const playerStore = usePlayerStore();
const isPlay = computed(() => playerStore.playMusicUrl);
const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean);
defineProps({ defineProps({
height: { height: {
type: String, type: String,

View File

@@ -110,12 +110,13 @@
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { createPlaylist, updatePlaylistTracks } from '@/api/music'; import { createPlaylist, updatePlaylistTracks } from '@/api/music';
import { getUserPlaylist } from '@/api/user'; import { getUserPlaylist } from '@/api/user';
import { useUserStore } from '@/store';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
const store = useUserStore();
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
@@ -127,7 +128,6 @@ const emit = defineEmits(['update:modelValue']);
const message = useMessage(); const message = useMessage();
const playlists = ref<any[]>([]); const playlists = ref<any[]>([]);
const creating = ref(false); const creating = ref(false);
const store = useStore();
const isCreating = ref(false); const isCreating = ref(false);
const formValue = ref({ const formValue = ref({
@@ -151,7 +151,7 @@ const toggleCreateForm = () => {
// 获取用户歌单 // 获取用户歌单
const fetchUserPlaylists = async () => { const fetchUserPlaylists = async () => {
try { try {
const { user } = store.state; const { user } = store;
if (!user?.userId) { if (!user?.userId) {
message.error(t('comp.playlistDrawer.loginFirst')); message.error(t('comp.playlistDrawer.loginFirst'));
emit('update:modelValue', false); emit('update:modelValue', false);

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="search-item" :class="[item.type, shape]" @click="handleClick"> <div class="search-item" :class="[shape, item.type]" @click="handleClick">
<div class="search-item-img"> <div class="search-item-img">
<n-image <n-image
class="w-full h-full" class="w-full h-full"
@@ -40,11 +40,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStore } from 'vuex';
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 { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { IMvItem } from '@/type/mv'; import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
@@ -72,6 +71,8 @@ const songList = ref<any[]>([]);
const showPop = ref(false); const showPop = ref(false);
const listInfo = ref<any>(null); const listInfo = ref<any>(null);
const playerStore = usePlayerStore();
const getCurrentMv = () => { const getCurrentMv = () => {
return { return {
id: props.item.id, id: props.item.id,
@@ -79,8 +80,6 @@ const getCurrentMv = () => {
} as unknown as IMvItem; } as unknown as IMvItem;
}; };
const store = useStore();
const handleClick = async () => { const handleClick = async () => {
listInfo.value = null; listInfo.value = null;
if (props.item.type === '专辑') { if (props.item.type === '专辑') {
@@ -108,12 +107,16 @@ const handleClick = async () => {
} }
if (props.item.type === 'mv') { if (props.item.type === 'mv') {
store.commit('setIsPlay', false); handleShowMv();
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
showPop.value = true;
} }
}; };
const handleShowMv = async () => {
playerStore.setIsPlay(false);
playerStore.setPlayMusic(false);
audioService.getCurrentSound()?.pause();
showPop.value = true;
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -168,15 +171,15 @@ const handleClick = async () => {
} }
} }
.mv { .search-item.mv {
&:hover { &:hover {
.play { .play {
@apply opacity-60; @apply opacity-60;
} }
} }
.search-item-img { .search-item-img {
width: 160px; width: 160px !important;
height: 90px; height: 90px !important;
@apply rounded-lg relative; @apply rounded-lg relative;
} }
.play { .play {

View File

@@ -90,10 +90,11 @@ import type { MenuOption } from 'naive-ui';
import { NImage, NText, useMessage } from 'naive-ui'; import { NImage, NText, useMessage } from 'naive-ui';
import { computed, h, inject, ref, useTemplateRef } from 'vue'; import { computed, h, inject, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getSongUrl } from '@/hooks/MusicListHook'; import { getSongUrl } from '@/hooks/MusicListHook';
import { useArtist } from '@/hooks/useArtist';
import { audioService } from '@/services/audioService'; 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 { getImgUrl, isElectron } from '@/utils';
import { getImageBackground } from '@/utils/linearColor'; import { getImageBackground } from '@/utils/linearColor';
@@ -120,11 +121,12 @@ const props = withDefaults(
} }
); );
const store = useStore(); const playerStore = usePlayerStore();
const message = useMessage(); const message = useMessage();
const play = computed(() => store.state.play as boolean); const play = computed(() => playerStore.isPlay);
const playMusic = computed(() => store.state.playMusic); const playMusic = computed(() => playerStore.playMusic);
const playLoading = computed( const playLoading = computed(
() => playMusic.value.id === props.item.id && playMusic.value.playLoading () => playMusic.value.id === props.item.id && playMusic.value.playLoading
); );
@@ -138,7 +140,9 @@ const dropdownY = ref(0);
const isDownloading = ref(false); const isDownloading = ref(false);
const openPlaylistDrawer = inject<(songId: number) => void>('openPlaylistDrawer'); const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
const { navigateToArtist } = useArtist();
const renderSongPreview = () => { const renderSongPreview = () => {
return h( return h(
@@ -281,7 +285,7 @@ const downloadMusic = async () => {
try { try {
isDownloading.value = true; isDownloading.value = true;
const data = (await getSongUrl(props.item.id, cloneDeep(props.item), true)) as any; const data = (await getSongUrl(props.item.id as number, cloneDeep(props.item), true)) as any;
if (!data || !data.url) { if (!data || !data.url) {
throw new Error(t('songItem.message.getUrlFailed')); throw new Error(t('songItem.message.getUrlFailed'));
} }
@@ -289,14 +293,17 @@ const downloadMusic = async () => {
// 构建文件名 // 构建文件名
const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(','); const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(',');
const filename = `${props.item.name} - ${artistNames}`; 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', { window.electron.ipcRenderer.send('download-music', {
url: data.url, url: data.url,
type: data.type, type: data.type,
filename, filename,
songInfo: { songInfo: {
...cloneDeep(props.item), ...songData,
downloadTime: Date.now() downloadTime: Date.now()
} }
}); });
@@ -354,33 +361,48 @@ const imageLoad = async () => {
// 播放音乐 设置音乐详情 打开音乐底栏 // 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => { const playMusicEvent = async (item: SongResult) => {
// 如果是当前正在播放的音乐,则切换播放/暂停状态
if (playMusic.value.id === item.id) { if (playMusic.value.id === item.id) {
if (play.value) { if (play.value) {
store.commit('setPlayMusic', false); playerStore.setPlayMusic(false);
audioService.getCurrentSound()?.pause(); audioService.getCurrentSound()?.pause();
} else { } else {
store.commit('setPlayMusic', true); playerStore.setPlayMusic(true);
audioService.getCurrentSound()?.play(); audioService.getCurrentSound()?.play();
} }
return; return;
} }
await store.commit('setPlay', item);
store.commit('setIsPlay', true); try {
emits('play', item); // 使用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(() => { const isFavorite = computed(() => {
return store.state.favoriteList.includes(props.item.id); // 将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) => { const toggleFavorite = async (e: Event) => {
e.stopPropagation(); e.stopPropagation();
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) { if (isFavorite.value) {
store.commit('removeFromFavorite', props.item.id); playerStore.removeFromFavorite(numericId);
} else { } else {
store.commit('addToFavorite', props.item.id); playerStore.addToFavorite(numericId);
} }
}; };
@@ -390,7 +412,7 @@ const toggleSelect = () => {
}; };
const handleArtistClick = (id: number) => { const handleArtistClick = (id: number) => {
store.commit('setCurrentArtistId', id); navigateToArtist(id);
}; };
// 获取歌手列表最多显示5个 // 获取歌手列表最多显示5个
@@ -400,7 +422,7 @@ const artists = computed(() => {
// 添加到下一首播放 // 添加到下一首播放
const handlePlayNext = () => { const handlePlayNext = () => {
store.commit('addToNextPlay', props.item); playerStore.addToNextPlay(props.item);
message.success(t('songItem.message.addedToNextPlay')); message.success(t('songItem.message.addedToNextPlay'));
}; };
</script> </script>

View File

@@ -35,23 +35,22 @@
</div> </div>
</div> </div>
<div class="modal-actions" :class="{ 'mt-6': !downloading }"> <div class="modal-actions" :class="{ 'mt-6': !downloading }">
<n-button <n-button class="cancel-btn" :disabled="downloading" @click="closeModal">
class="cancel-btn"
:disabled="downloading"
:loading="downloading"
@click="closeModal"
>
{{ t('comp.update.cancel') }} {{ t('comp.update.cancel') }}
</n-button> </n-button>
<n-button <n-button
v-if="!downloading"
type="primary" type="primary"
class="update-btn" class="update-btn"
:loading="downloading"
:disabled="downloading" :disabled="downloading"
@click="handleUpdate" @click="handleUpdate"
> >
{{ downloadBtnText }} {{ downloadBtnText }}
</n-button> </n-button>
<!-- 后台下载 -->
<n-button v-else class="update-btn" type="primary" @click="closeModal">
{{ t('comp.update.backgroundDownload') }}
</n-button>
</div> </div>
<div v-if="!downloading" class="modal-desc mt-4 text-center"> <div v-if="!downloading" class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400"> <p class="text-xs text-gray-400">
@@ -71,15 +70,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { marked } from 'marked'; import { marked } from 'marked';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, h, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useSettingsStore } from '@/store/modules/settings';
import { checkUpdate, getProxyNodes, UpdateResult } from '@/utils/update'; import { checkUpdate, getProxyNodes, UpdateResult } from '@/utils/update';
import config from '../../../../package.json'; import config from '../../../../package.json';
const { t } = useI18n(); const { t } = useI18n();
const dialog = useDialog();
const message = useMessage();
// 配置 marked // 配置 marked
marked.setOptions({ marked.setOptions({
@@ -87,7 +88,13 @@ marked.setOptions({
gfm: true // 启用 GitHub 风格的 Markdown gfm: true // 启用 GitHub 风格的 Markdown
}); });
const showModal = ref(false); const settingsStore = useSettingsStore();
const showModal = computed({
get: () => settingsStore.showUpdateModal,
set: (val) => settingsStore.setShowUpdateModal(val)
});
const updateInfo = ref<UpdateResult>({ const updateInfo = ref<UpdateResult>({
hasUpdate: false, hasUpdate: false,
latestVersion: '', latestVersion: '',
@@ -95,28 +102,6 @@ const updateInfo = ref<UpdateResult>({
releaseInfo: null releaseInfo: null
}); });
const store = useStore();
// 添加计算属性
const showUpdateModalState = computed({
get: () => store.state.showUpdateModal,
set: (val) => store.commit('setShowUpdateModal', val)
});
// 替换原来的 watch
watch(showUpdateModalState, (newVal) => {
if (newVal) {
showModal.value = true;
}
});
watch(
() => showModal.value,
(newVal) => {
showUpdateModalState.value = newVal;
}
);
// 解析 Markdown // 解析 Markdown
const parsedReleaseNotes = computed(() => { const parsedReleaseNotes = computed(() => {
if (!updateInfo.value.releaseInfo?.body) return ''; if (!updateInfo.value.releaseInfo?.body) return '';
@@ -152,6 +137,11 @@ const downloadBtnText = computed(() => {
return t('comp.update.nowUpdate'); return t('comp.update.nowUpdate');
}); });
// 下载完成后的文件路径
const downloadedFilePath = ref('');
// 防止对话框重复弹出
const isDialogShown = ref(false);
// 处理下载状态更新 // 处理下载状态更新
const handleDownloadProgress = (_event: any, progress: number, status: string) => { const handleDownloadProgress = (_event: any, progress: number, status: string) => {
downloadProgress.value = progress; downloadProgress.value = progress;
@@ -161,16 +151,73 @@ const handleDownloadProgress = (_event: any, progress: number, status: string) =
// 处理下载完成 // 处理下载完成
const handleDownloadComplete = (_event: any, success: boolean, filePath: string) => { const handleDownloadComplete = (_event: any, success: boolean, filePath: string) => {
downloading.value = false; downloading.value = false;
if (success) { closeModal();
window.electron.ipcRenderer.send('install-update', filePath);
} else { if (success && !isDialogShown.value) {
window.$message.error(t('comp.update.downloadFailed')); downloadedFilePath.value = filePath;
isDialogShown.value = true;
// 复制文件路径到剪贴板
const copyFilePath = () => {
navigator.clipboard
.writeText(filePath)
.then(() => {
message.success(t('comp.update.copySuccess'));
})
.catch(() => {
message.error(t('comp.update.copyFailed'));
});
};
// 使用naive-ui的对话框询问用户是否安装
const dialogRef = dialog.create({
title: t('comp.update.installConfirmTitle'),
content: () =>
h('div', { class: 'update-dialog-content' }, [
h('p', { class: 'content-text' }, t('comp.update.installConfirmContent')),
h('div', { class: 'divider' }),
h('p', { class: 'manual-tip' }, t('comp.update.manualInstallTip')),
h('div', { class: 'file-path-container' }, [
h('div', { class: 'file-path-box' }, [
h('p', { class: 'file-path-label' }, t('comp.update.fileLocation')),
h('div', { class: 'file-path-value' }, filePath)
]),
h(
'div',
{
class: 'copy-btn',
onClick: copyFilePath
},
[h('i', { class: 'ri-file-copy-line' }), h('span', t('comp.update.copy'))]
)
])
]),
positiveText: t('comp.update.yesInstall'),
negativeText: t('comp.update.noThanks'),
onPositiveClick: () => {
window.electron.ipcRenderer.send('install-update', filePath);
},
onNegativeClick: () => {
closeModal();
// 关闭当前窗口
dialogRef.destroy();
},
onClose: () => {
isDialogShown.value = false;
}
});
} else if (!success) {
message.error(t('comp.update.downloadFailed'));
} }
}; };
// 监听下载事件 // 监听下载事件
onMounted(() => { onMounted(() => {
checkForUpdates(); checkForUpdates();
// 确保事件监听器只注册一次
window.electron.ipcRenderer.removeListener('download-progress', handleDownloadProgress);
window.electron.ipcRenderer.removeListener('download-complete', handleDownloadComplete);
window.electron.ipcRenderer.on('download-progress', handleDownloadProgress); window.electron.ipcRenderer.on('download-progress', handleDownloadProgress);
window.electron.ipcRenderer.on('download-complete', handleDownloadComplete); window.electron.ipcRenderer.on('download-complete', handleDownloadComplete);
}); });
@@ -179,6 +226,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.electron.ipcRenderer.removeListener('download-progress', handleDownloadProgress); window.electron.ipcRenderer.removeListener('download-progress', handleDownloadProgress);
window.electron.ipcRenderer.removeListener('download-complete', handleDownloadComplete); window.electron.ipcRenderer.removeListener('download-complete', handleDownloadComplete);
isDialogShown.value = false;
}); });
const handleUpdate = async () => { const handleUpdate = async () => {
@@ -231,6 +279,7 @@ const handleUpdate = async () => {
try { try {
downloading.value = true; downloading.value = true;
downloadStatus.value = t('comp.update.prepareDownload'); downloadStatus.value = t('comp.update.prepareDownload');
isDialogShown.value = false;
// 获取代理节点列表 // 获取代理节点列表
const proxyHosts = await getProxyNodes(); const proxyHosts = await getProxyNodes();
@@ -240,11 +289,11 @@ const handleUpdate = async () => {
window.electron.ipcRenderer.send('start-download', proxyDownloadUrl); window.electron.ipcRenderer.send('start-download', proxyDownloadUrl);
} catch (error) { } catch (error) {
downloading.value = false; downloading.value = false;
window.$message.error(t('comp.update.startFailed')); message.error(t('comp.update.startFailed'));
console.error('下载失败:', error); console.error('下载失败:', error);
} }
} else { } else {
window.$message.error(t('comp.update.noDownloadUrl')); message.error(t('comp.update.noDownloadUrl'));
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank'); window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
} }
}; };
@@ -371,3 +420,110 @@ const handleUpdate = async () => {
} }
} }
</style> </style>
<style lang="scss">
/* 对话框内容样式 */
.update-dialog-content {
display: flex;
flex-direction: column;
gap: 12px;
.content-text {
font-size: 16px;
font-weight: 500;
}
.divider {
width: 100%;
height: 1px;
background-color: #e5e7eb;
margin: 4px 0;
}
.manual-tip {
font-size: 14px;
color: #6b7280;
}
.file-path-container {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
.file-path-box {
flex: 1;
.file-path-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.file-path-value {
padding: 8px;
border-radius: 4px;
background-color: #f3f4f6;
font-size: 12px;
font-family: monospace;
color: #1f2937;
word-break: break-all;
}
}
.copy-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-radius: 4px;
background-color: #e5e7eb;
color: #4b5563;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #d1d5db;
}
i {
font-size: 14px;
}
}
}
}
/* 深色模式样式 */
.dark .update-dialog-content {
.divider {
background-color: #374151;
}
.manual-tip {
color: #9ca3af;
}
.file-path-container {
.file-path-box {
.file-path-label {
color: #9ca3af;
}
.file-path-value {
background-color: #1f2937;
color: #d1d5db;
}
}
.copy-btn {
background-color: #374151;
color: #d1d5db;
&:hover {
background-color: #4b5563;
}
}
}
}
</style>

View File

@@ -26,7 +26,7 @@
v-model:show="showMusic" v-model:show="showMusic"
:name="albumName" :name="albumName"
:song-list="songList" :song-list="songList"
:cover="false" :cover="true"
:loading="loadingList" :loading="loadingList"
:list-info="albumInfo" :list-info="albumInfo"
/> />
@@ -62,17 +62,19 @@ const handleClick = async (item: any) => {
loadingList.value = true; loadingList.value = true;
showMusic.value = true; showMusic.value = true;
const res = await getAlbum(item.id); const res = await getAlbum(item.id);
songList.value = res.data.songs.map((song: any) => { const { songs, album } = res.data;
song.al.picUrl = song.al.picUrl || item.picUrl; songList.value = songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || album.picUrl;
song.picUrl = song.al.picUrl || album.picUrl || song.picUrl;
return song; return song;
}); });
albumInfo.value = { albumInfo.value = {
...res.data.album, ...album,
creator: { creator: {
avatarUrl: res.data.album.artist.img1v1Url, avatarUrl: album.artist.img1v1Url,
nickname: `${res.data.album.artist.name} - ${res.data.album.company}` nickname: `${album.artist.name} - ${album.company}`
}, },
description: res.data.album.description description: album.description
}; };
loadingList.value = false; loadingList.value = false;
}; };

View File

@@ -24,16 +24,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getRecommendMusic } from '@/api/home'; import { getRecommendMusic } from '@/api/home';
import SongItem from '@/components/common/SongItem.vue';
import { usePlayerStore } from '@/store/modules/player';
import type { IRecommendMusic } from '@/type/music'; import type { IRecommendMusic } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils'; import { setAnimationClass, setAnimationDelay } from '@/utils';
import SongItem from './common/SongItem.vue';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const playerStore = usePlayerStore();
// //
const recommendMusic = ref<IRecommendMusic>(); const recommendMusic = ref<IRecommendMusic>();
const loading = ref(false); const loading = ref(false);
@@ -52,7 +51,9 @@ onMounted(() => {
}); });
const handlePlay = () => { const handlePlay = () => {
store.commit('setPlayList', recommendMusic.value?.result); if (recommendMusic.value?.result) {
playerStore.setPlayList(recommendMusic.value.result);
}
}; };
</script> </script>

View File

@@ -0,0 +1,566 @@
<template>
<div class="recommend-singer">
<div class="recommend-singer-list">
<n-carousel
v-if="hotSingerData?.artists.length"
slides-per-view="auto"
:show-dots="false"
:space-between="20"
draggable
show-arrow
:autoplay="false"
>
<n-carousel-item
:class="setAnimationClass('animate__backInRight')"
:style="getCarouselItemStyle(0, 100, 6)"
>
<div v-if="dayRecommendData" class="recommend-singer-item relative">
<div
:style="
setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '500y500'))
"
class="recommend-singer-item-bg"
></div>
<div
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
@click="showMusic = true"
>
<div class="font-bold text-lg">
{{ t('comp.recommendSinger.title') }}
</div>
<div class="mt-2">
<p
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)"
:key="item.id"
class="text-el"
>
{{ item.name }}
<br />
</p>
</div>
</div>
</div>
</n-carousel-item>
<n-carousel-item
v-if="userStore.user && userPlaylist.length"
:class="setAnimationClass('animate__backInRight')"
:style="getCarouselItemStyleForPlaylist(userPlaylist.length)"
>
<div class="user-play">
<div class="user-play-title mb-3">
{{ t('comp.userPlayList.title', { name: userStore.user?.nickname }) }}
</div>
<div class="user-play-list" :class="getPlaylistGridClass(userPlaylist.length)">
<div
v-for="item in userPlaylist"
:key="item.id"
class="user-play-item"
@click="toPlaylist(item.id)"
>
<div class="user-play-item-img">
<img :src="getImgUrl(item.coverImgUrl, '200y200')" alt="" />
<div class="user-play-item-title">
<div class="user-play-item-title-name">{{ item.name }}</div>
<div class="user-play-item-list">
<div
v-for="song in item.tracks"
:key="song.id"
class="user-play-item-list-name"
>
{{ song.name }}
</div>
</div>
</div>
<div class="user-play-item-count">
<div class="user-play-item-count-tag">
{{ t('common.songCount', { count: item.trackCount }) }}
</div>
</div>
<div class="user-play-item-direct-play" @click.stop="handlePlayPlaylist(item.id)">
<i class="iconfont icon-playfill text-xl text-white"></i>
</div>
</div>
</div>
</div>
</div>
</n-carousel-item>
<n-carousel-item
v-for="(item, index) in hotSingerData?.artists"
:key="item.id"
:class="setAnimationClass('animate__backInRight')"
:style="getCarouselItemStyle(index + 1, 100, 6)"
>
<div
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(index + 2, 100)"
@click="handleArtistClick(item.id)"
>
<div
:style="setBackgroundImg(getImgUrl(item.picUrl, '500y500'))"
class="recommend-singer-item-bg"
></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
{{ t('common.songCount', { count: item.musicSize }) }}
</div>
<div class="recommend-singer-item-info z-10">
<div class="recommend-singer-item-info-name text-el text-right line-clamp-1">
{{ item.name }}
</div>
</div>
<!-- 播放按钮(hover时显示) -->
<div
class="recommend-singer-item-play-overlay"
@click.stop="handleArtistClick(item.id)"
>
<div class="recommend-singer-item-play-btn">
<i class="iconfont icon-playfill text-4xl"></i>
</div>
</div>
</div>
</n-carousel-item>
</n-carousel>
</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>
</template>
<script lang="ts" setup>
import { onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { getDayRecommend, getHotSinger } from '@/api/home';
import { getListDetail } from '@/api/list';
import { getMusicDetail } from '@/api/music';
import { getUserPlaylist } from '@/api/user';
import MusicList from '@/components/MusicList.vue';
import { useArtist } from '@/hooks/useArtist';
import { usePlayerStore, useUserStore } from '@/store';
import { IDayRecommend } from '@/type/day_recommend';
import { Playlist } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import { SongResult } from '@/type/music';
import type { IHotSinger } from '@/type/singer';
import {
getImgUrl,
isMobile,
setAnimationClass,
setAnimationDelay,
setBackgroundImg
} from '@/utils';
const userStore = useUserStore();
const playerStore = usePlayerStore();
const { t } = useI18n();
// 歌手信息
const hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
const userPlaylist = ref<Playlist[]>([]);
// 为歌单弹窗添加的状态
const showPlaylist = ref(false);
const playlistLoading = ref(false);
const playlistItem = ref<Playlist | null>(null);
const playlistDetail = ref<IListDetail | null>(null);
const { navigateToArtist } = useArtist();
/**
* 获取轮播项的样式
* @param index 项目索引(用于动画延迟)
* @param delayStep 动画延迟的步长(毫秒)
* @param totalItems 总共分成几等分默认为5
* @param maxWidth 最大宽度可选单位为px
* @returns 样式字符串
*/
const getCarouselItemStyle = (
index: number,
delayStep: number,
totalItems: number,
maxWidth?: number
) => {
if (isMobile.value) {
return 'width: 30%;';
}
const animationDelay = setAnimationDelay(index, delayStep);
const width = `calc((100% / ${totalItems}) - 16px)`;
const maxWidthStyle = maxWidth ? `max-width: ${maxWidth}px;` : '';
return `${animationDelay}; width: ${width}; ${maxWidthStyle}`;
};
/**
* 根据歌单数量获取轮播项的样式
* @param playlistCount 歌单数量
* @returns 样式字符串
*/
const getCarouselItemStyleForPlaylist = (playlistCount: number) => {
if (isMobile.value) {
return 'width: 100%;';
}
const animationDelay = setAnimationDelay(1, 100);
let width = '';
let maxWidth = '';
switch (playlistCount) {
case 1:
width = 'calc(100% / 4 - 16px)';
maxWidth = 'max-width: 180px;';
break;
case 2:
width = 'calc(100% / 3 - 16px)';
maxWidth = 'max-width: 380px;';
break;
case 3:
width = 'calc(100% / 2 - 16px)';
maxWidth = 'max-width: 520px;';
break;
default:
width = 'calc(100% / 1 - 16px)';
maxWidth = 'max-width: 656px;';
}
return `${animationDelay}; width: ${width}; ${maxWidth}`;
};
onMounted(async () => {
await loadData();
});
const loadData = async () => {
try {
// 获取每日推荐
try {
const {
data: { data: dayRecommend }
} = await getDayRecommend();
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
} catch (error) {
console.error('error', error);
}
if (userStore.user) {
const { data: playlistData } = await getUserPlaylist(userStore.user?.userId);
// 确保最多只显示4个歌单并按播放次数排序
userPlaylist.value = (playlistData.playlist as Playlist[])
.sort((a, b) => b.playCount - a.playCount)
.slice(0, 4);
}
// 获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
hotSingerData.value = singerData;
} catch (error) {
console.error('error', error);
}
};
const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
const toPlaylist = async (id: number) => {
playlistLoading.value = true;
playlistItem.value = null;
playlistDetail.value = null;
showPlaylist.value = true;
// 设置当前点击的歌单信息
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;
}
};
// 添加直接播放歌单的方法
const handlePlayPlaylist = async (id: number) => {
try {
// 先显示加载状态
playlistLoading.value = true;
// 获取歌单详情
const { data } = await getListDetail(id);
if (data?.playlist) {
// 先使用已有的tracks开始播放这些是已经在歌单详情中返回的前几首歌曲
if (data.playlist.tracks?.length > 0) {
// 格式化歌曲列表
const initialSongs = data.playlist.tracks.map((track) => ({
...track,
source: 'netease',
picUrl: track.al.picUrl
})) as unknown as SongResult[];
// 设置播放列表
playerStore.setPlayList(initialSongs);
// 开始播放第一首
await playerStore.setPlay(initialSongs[0]);
// 如果有trackIds异步加载完整歌单
if (data.playlist.trackIds?.length > initialSongs.length) {
loadFullPlaylist(data.playlist.trackIds, initialSongs);
}
}
}
// 关闭加载状态
playlistLoading.value = false;
} catch (error) {
console.error('播放歌单失败:', error);
playlistLoading.value = false;
}
};
// 异步加载完整歌单
const loadFullPlaylist = async (trackIds: { id: number }[], initialSongs: SongResult[]) => {
try {
// 获取已加载歌曲的ID集合避免重复加载
const loadedIds = new Set(initialSongs.map((song) => song.id));
// 筛选出未加载的ID
const unloadedTrackIds = trackIds
.filter((item) => !loadedIds.has(item.id as number))
.map((item) => item.id);
if (unloadedTrackIds.length === 0) return;
// 分批获取歌曲详情每批最多获取500首
const batchSize = 500;
const allSongs = [...initialSongs];
for (let i = 0; i < unloadedTrackIds.length; i += batchSize) {
const batchIds = unloadedTrackIds.slice(i, i + batchSize);
if (batchIds.length > 0) {
try {
const { data: songsData } = await getMusicDetail(batchIds);
if (songsData?.songs?.length) {
const formattedSongs = songsData.songs.map((item) => ({
...item,
source: 'netease',
picUrl: item.al.picUrl
})) as unknown as SongResult[];
allSongs.push(...formattedSongs);
}
} catch (error) {
console.error('获取批次歌曲详情失败:', error);
}
}
}
// 更新完整的播放列表但保持当前播放的歌曲不变
if (allSongs.length > initialSongs.length) {
console.log('更新播放列表,总歌曲数:', allSongs.length);
playerStore.setPlayList(allSongs);
}
} catch (error) {
console.error('加载完整歌单失败:', error);
}
};
// 监听登录状态
watchEffect(() => {
if (userStore.user) {
loadData();
}
});
const getPlaylistGridClass = (length: number) => {
switch (length) {
case 1:
return 'one-column';
case 2:
return 'two-columns';
case 3:
return 'three-columns';
default:
return 'four-columns';
}
};
</script>
<style lang="scss" scoped>
.recommend-singer {
&-list {
@apply flex;
height: 220px;
margin-right: 20px;
}
&-item {
@apply flex-1 h-full rounded-3xl p-5 flex flex-col justify-between overflow-hidden relative;
cursor: pointer;
transition: transform 0.3s ease;
&:hover {
transform: translateY(-5px);
}
&-bg {
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
filter: brightness(60%);
}
&-info {
@apply flex flex-col p-2;
&-name {
@apply text-gray-100 dark:text-gray-100;
}
}
&-count {
@apply text-gray-100 dark:text-gray-100;
}
&-play {
&-overlay {
@apply absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-black/20 z-20 opacity-0 transition-all duration-300 flex items-center justify-center;
backdrop-filter: blur(1px);
.recommend-singer-item:hover & {
opacity: 1;
}
}
&-btn {
@apply w-20 h-20 bg-transparent flex justify-center items-center text-white;
transform: translateY(50px) scale(0.8);
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.recommend-singer-item:hover & {
transform: translateY(0) scale(1);
}
}
}
}
}
.user-play {
@apply bg-light-300 dark:bg-dark-300 rounded-3xl px-4 py-3 h-full;
backdrop-filter: blur(20px);
&-title {
@apply text-gray-900 dark:text-gray-100 font-bold text-lg line-clamp-1;
}
&-list {
@apply grid gap-3 h-full;
&.one-column {
grid-template-columns: repeat(1, minmax(0, 1fr));
.user-play-item {
max-width: 100%;
}
}
&.two-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
.user-play-item {
max-width: 100%;
}
}
&.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
.user-play-item {
max-width: 100%;
}
}
&.four-columns {
grid-template-columns: repeat(4, minmax(0, 1fr));
.user-play-item {
max-width: 100%;
}
}
}
&-item {
@apply rounded-2xl overflow-hidden flex flex-col;
height: 176px;
&-img {
@apply relative cursor-pointer transition-all duration-300;
height: 0;
width: 100%;
padding-bottom: 100%; /* 确保宽高比为1:1即正方形 */
border-radius: 12px;
overflow: hidden;
&:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
img {
@apply absolute inset-0 w-full h-full object-cover;
}
}
&-title {
@apply absolute top-0 left-0 right-0 p-2 bg-gradient-to-b from-black/70 to-transparent z-10;
&-name {
@apply text-white font-medium text-sm line-clamp-3;
}
}
&-count {
@apply absolute bottom-2 left-2 z-10;
&-tag {
@apply px-2 py-0.5 text-xs text-white bg-black/50 backdrop-blur-sm rounded-full;
}
}
&-direct-play {
@apply absolute bottom-2 right-2 z-20 w-10 h-10 rounded-full bg-green-600 hover:bg-green-700 flex items-center justify-center cursor-pointer transform scale-90 hover:scale-100 transition-all;
&:hover {
@apply shadow-lg;
}
}
&-play-btn {
@apply flex items-center justify-center;
transform: scale(0.8);
transition: transform 0.3s ease;
.user-play-item:hover & {
transform: scale(1);
}
}
}
}
.mobile {
.recommend-singer {
&-list {
height: 180px;
@apply ml-4;
}
&-item {
@apply p-2 rounded-xl;
&-bg {
@apply rounded-xl;
}
}
}
}
</style>

View File

@@ -2,84 +2,119 @@
<div class="settings-panel transparent-popover"> <div class="settings-panel transparent-popover">
<div class="settings-title">{{ t('settings.lyricSettings.title') }}</div> <div class="settings-title">{{ t('settings.lyricSettings.title') }}</div>
<div class="settings-content"> <div class="settings-content">
<div class="settings-item"> <n-tabs type="line" animated size="small">
<span>{{ t('settings.lyricSettings.pureMode') }}</span> <!-- 显示设置 -->
<n-switch v-model:value="config.pureModeEnabled" /> <n-tab-pane :name="'display'" :tab="t('settings.lyricSettings.tabs.display')">
</div> <div class="tab-content">
<div class="settings-grid">
<div class="settings-item">
<span>{{ t('settings.lyricSettings.pureMode') }}</span>
<n-switch v-model:value="config.pureModeEnabled" />
</div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.hideCover') }}</span>
<n-switch v-model:value="config.hideCover" />
</div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.centerDisplay') }}</span>
<n-switch v-model:value="config.centerLyrics" />
</div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.showTranslation') }}</span>
<n-switch v-model:value="config.showTranslation" />
</div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.hideLyrics') }}</span>
<n-switch v-model:value="config.hideLyrics" />
</div>
</div>
</div>
</n-tab-pane>
<div class="settings-item"> <!-- 界面设置 -->
<span>{{ t('settings.lyricSettings.hideCover') }}</span> <n-tab-pane :name="'interface'" :tab="t('settings.lyricSettings.tabs.interface')">
<n-switch v-model:value="config.hideCover" /> <div class="tab-content">
</div> <div class="settings-grid">
<div class="settings-item">
<span>{{ t('settings.lyricSettings.hidePlayBar') }}</span>
<n-switch v-model:value="config.hidePlayBar" />
</div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.hideMiniPlayBar') }}</span>
<n-switch v-model:value="config.hideMiniPlayBar" />
</div>
</div>
<div class="theme-section">
<div class="section-title">{{ t('settings.lyricSettings.backgroundTheme') }}</div>
<n-radio-group v-model:value="config.theme" name="theme" class="theme-radio-group">
<n-space>
<n-radio value="default">{{
t('settings.lyricSettings.themeOptions.default')
}}</n-radio>
<n-radio value="light">{{
t('settings.lyricSettings.themeOptions.light')
}}</n-radio>
<n-radio value="dark">{{
t('settings.lyricSettings.themeOptions.dark')
}}</n-radio>
</n-space>
</n-radio-group>
</div>
</div>
</n-tab-pane>
<div class="settings-item"> <!-- 文字设置 -->
<span>{{ t('settings.lyricSettings.centerDisplay') }}</span> <n-tab-pane :name="'typography'" :tab="t('settings.lyricSettings.tabs.typography')">
<n-switch v-model:value="config.centerLyrics" /> <div class="tab-content">
</div> <div class="slider-section">
<div class="slider-item">
<span>{{ t('settings.lyricSettings.fontSize') }}</span>
<n-slider
v-model:value="config.fontSize"
:step="1"
:min="12"
:max="32"
:marks="{
12: t('settings.lyricSettings.fontSizeMarks.small'),
22: t('settings.lyricSettings.fontSizeMarks.medium'),
32: t('settings.lyricSettings.fontSizeMarks.large')
}"
/>
</div>
<div class="settings-item"> <div class="slider-item">
<span>{{ t('settings.lyricSettings.showTranslation') }}</span> <span>{{ t('settings.lyricSettings.letterSpacing') }}</span>
<n-switch v-model:value="config.showTranslation" /> <n-slider
</div> v-model:value="config.letterSpacing"
:step="0.2"
:min="-2"
:max="10"
:marks="{
'-2': t('settings.lyricSettings.letterSpacingMarks.compact'),
0: t('settings.lyricSettings.letterSpacingMarks.default'),
10: t('settings.lyricSettings.letterSpacingMarks.loose')
}"
/>
</div>
<div class="settings-item"> <div class="slider-item">
<span>{{ t('settings.lyricSettings.hidePlayBar') }}</span> <span>{{ t('settings.lyricSettings.lineHeight') }}</span>
<n-switch v-model:value="config.hidePlayBar" /> <n-slider
</div> v-model:value="config.lineHeight"
:step="0.1"
<div class="settings-slider"> :min="1"
<span>{{ t('settings.lyricSettings.fontSize') }}</span> :max="3"
<n-slider :marks="{
v-model:value="config.fontSize" 1: t('settings.lyricSettings.lineHeightMarks.compact'),
:step="1" 1.5: t('settings.lyricSettings.lineHeightMarks.default'),
:min="12" 3: t('settings.lyricSettings.lineHeightMarks.loose')
:max="32" }"
:marks="{ />
12: t('settings.lyricSettings.fontSizeMarks.small'), </div>
22: t('settings.lyricSettings.fontSizeMarks.medium'), </div>
32: t('settings.lyricSettings.fontSizeMarks.large') </div>
}" </n-tab-pane>
/> </n-tabs>
</div>
<div class="settings-slider">
<span>{{ t('settings.lyricSettings.letterSpacing') }}</span>
<n-slider
v-model:value="config.letterSpacing"
:step="0.2"
:min="-2"
:max="10"
:marks="{
'-2': t('settings.lyricSettings.letterSpacingMarks.compact'),
0: t('settings.lyricSettings.letterSpacingMarks.default'),
10: t('settings.lyricSettings.letterSpacingMarks.loose')
}"
/>
</div>
<div class="settings-slider">
<span>{{ t('settings.lyricSettings.lineHeight') }}</span>
<n-slider
v-model:value="config.lineHeight"
:step="0.1"
:min="1"
:max="3"
:marks="{
1: t('settings.lyricSettings.lineHeightMarks.compact'),
1.5: t('settings.lyricSettings.lineHeightMarks.default'),
3: t('settings.lyricSettings.lineHeightMarks.loose')
}"
/>
</div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.backgroundTheme') }}</span>
<n-radio-group v-model:value="config.theme" name="theme">
<n-radio value="default">{{ t('settings.lyricSettings.themeOptions.default') }}</n-radio>
<n-radio value="light">{{ t('settings.lyricSettings.themeOptions.light') }}</n-radio>
<n-radio value="dark">{{ t('settings.lyricSettings.themeOptions.dark') }}</n-radio>
</n-radio-group>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -88,35 +123,12 @@
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
const { t } = useI18n(); const { t } = useI18n();
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
interface LyricConfig {
hideCover: boolean;
centerLyrics: boolean;
fontSize: number;
letterSpacing: number;
lineHeight: number;
showTranslation: boolean;
theme: 'default' | 'light' | 'dark';
hidePlayBar: boolean;
pureModeEnabled: boolean;
}
const config = ref<LyricConfig>({
hideCover: false,
centerLyrics: false,
fontSize: 22,
letterSpacing: 0,
lineHeight: 2,
showTranslation: true,
theme: 'default',
hidePlayBar: false,
pureModeEnabled: false
});
const emit = defineEmits(['themeChange']); const emit = defineEmits(['themeChange']);
// 监听配置变化并保存到本地存储
watch( watch(
() => config.value, () => config.value,
(newConfig) => { (newConfig) => {
@@ -125,7 +137,6 @@ watch(
{ deep: true } { deep: true }
); );
// 监听主题变化
watch( watch(
() => config.value.theme, () => config.value.theme,
(newTheme) => { (newTheme) => {
@@ -133,14 +144,12 @@ watch(
} }
); );
// 更新 CSS 变量
const updateCSSVariables = (config: LyricConfig) => { const updateCSSVariables = (config: LyricConfig) => {
document.documentElement.style.setProperty('--lyric-font-size', `${config.fontSize}px`); document.documentElement.style.setProperty('--lyric-font-size', `${config.fontSize}px`);
document.documentElement.style.setProperty('--lyric-letter-spacing', `${config.letterSpacing}px`); document.documentElement.style.setProperty('--lyric-letter-spacing', `${config.letterSpacing}px`);
document.documentElement.style.setProperty('--lyric-line-height', config.lineHeight.toString()); document.documentElement.style.setProperty('--lyric-line-height', config.lineHeight.toString());
}; };
// 加载保存的配置
onMounted(() => { onMounted(() => {
const savedConfig = localStorage.getItem('music-full-config'); const savedConfig = localStorage.getItem('music-full-config');
if (savedConfig) { if (savedConfig) {
@@ -156,14 +165,50 @@ defineExpose({
<style scoped lang="scss"> <style scoped lang="scss">
.settings-panel { .settings-panel {
@apply p-4 w-72 rounded-lg relative overflow-hidden backdrop-blur-lg bg-black/10; @apply p-4 w-80 rounded-lg relative overflow-hidden backdrop-blur-lg bg-black/10;
.settings-title { .settings-title {
@apply text-base font-bold mb-4; @apply text-base font-bold mb-4;
color: var(--text-color-active); color: var(--text-color-active);
} }
.settings-content { .settings-content {
@apply space-y-4; :deep(.n-tabs-nav) {
@apply mb-3;
}
:deep(.n-tab-pane) {
@apply p-0;
}
:deep(.n-tabs-tab) {
@apply text-xs;
color: var(--text-color-primary);
&.n-tabs-tab--active {
color: var(--text-color-active);
}
}
:deep(.n-tabs-tab-wrapper) {
@apply pb-0;
}
:deep(.n-tabs-pane-wrapper) {
@apply px-2;
}
:deep(.n-tabs-bar) {
background-color: var(--text-color-active);
}
}
.tab-content {
@apply py-2;
}
.settings-grid {
@apply grid grid-cols-1 gap-3;
} }
.settings-item { .settings-item {
@@ -174,22 +219,38 @@ defineExpose({
} }
} }
.settings-slider { .section-title {
@apply space-y-2; @apply text-sm font-medium mb-2;
@apply mb-10 !important; color: var(--text-color-primary);
}
.theme-section {
@apply mt-4;
}
.slider-section {
@apply space-y-6;
}
.slider-item {
@apply space-y-2 mb-10 !important;
span { span {
@apply text-sm; @apply text-sm;
color: var(--text-color-primary); color: var(--text-color-primary);
} }
} }
.theme-radio-group {
@apply flex;
}
} }
// 修改 slider 字体颜色
:deep(.n-slider-mark) { :deep(.n-slider-mark) {
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
} }
// 修改 radio 字体颜色
:deep(.n-radio__label) { :deep(.n-radio__label) {
color: var(--text-color-active) !important; color: var(--text-color-active) !important;
@apply text-xs;
} }
</style> </style>

View File

@@ -0,0 +1,600 @@
<template>
<div
class="mini-play-bar"
:class="{ 'pure-mode': pureModeEnabled, 'mini-mode': settingsStore.isMiniMode }"
>
<div class="mini-bar-container">
<!-- 专辑封面 -->
<div class="album-cover" @click="setMusicFull">
<n-image
:src="getImgUrl(playMusic?.picUrl, '100y100')"
fallback-src="/placeholder.png"
class="cover-img"
preview-disabled
/>
</div>
<!-- 歌曲信息 -->
<div class="song-info" @click="setMusicFull">
<div class="song-title">{{ playMusic?.name || '未播放' }}</div>
<div class="song-artist">
<span
v-for="(artists, artistsindex) in artistList"
:key="artistsindex"
class="cursor-pointer hover:text-green-500"
@click.stop="handleArtistClick(artists.id)"
>
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
</span>
</div>
</div>
<!-- 控制按钮区域 -->
<div class="control-buttons">
<button class="control-button previous" @click="handlePrev">
<i class="iconfont icon-prev"></i>
</button>
<button class="control-button play" @click="playMusicEvent">
<i class="iconfont" :class="play ? 'icon-stop' : 'icon-play'"></i>
</button>
<button class="control-button next" @click="handleNext">
<i class="iconfont icon-next"></i>
</button>
</div>
<!-- 右侧功能按钮 -->
<div class="function-buttons">
<button class="function-button">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click="toggleFavorite"
></i>
</button>
<n-popover trigger="click" :z-index="99999999" placement="top" :show-arrow="false">
<template #trigger>
<button class="function-button" @click="mute">
<i class="iconfont" :class="getVolumeIcon"></i>
</button>
</template>
<div class="volume-slider-wrapper">
<n-slider
v-model:value="volumeSlider"
:step="0.01"
:tooltip="false"
vertical
></n-slider>
</div>
</n-popover>
<!-- 播放列表按钮 -->
<button v-if="!component" class="function-button" @click="togglePlaylist">
<i class="iconfont icon-list"></i>
</button>
</div>
<!-- 关闭按钮 -->
<button v-if="!component" class="close-button" @click="handleClose">
<i class="iconfont ri-close-line"></i>
</button>
</div>
<!-- 进度条 -->
<div
class="progress-bar"
@click="handleProgressClick"
@mousemove="handleProgressHover"
@mouseleave="handleProgressLeave"
>
<div class="progress-track"></div>
<div class="progress-fill" :style="{ width: `${(nowTime / allTime) * 100}%` }"></div>
</div>
<!-- 播放列表 - 单独放在外层不再使用 popover -->
<div
v-if="!component"
v-show="isPlaylistOpen"
class="playlist-container"
:class="{ 'mini-mode-list': settingsStore.isMiniMode }"
>
<n-scrollbar ref="palyListRef" class="playlist-scrollbar">
<div class="playlist-items">
<div v-for="item in playList" :key="item.id" 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>
</div>
</n-scrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, provide, ref, useTemplateRef } from 'vue';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { audioService } from '@/services/audioService';
import { usePlayerStore, useSettingsStore } from '@/store';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const { navigateToArtist } = useArtist();
withDefaults(
defineProps<{
pureModeEnabled?: boolean;
component?: boolean;
}>(),
{
component: false
}
);
// 处理关闭按钮点击
const handleClose = () => {
if (settingsStore.isMiniMode) {
window.api.restore();
}
};
// 是否播放
const play = computed(() => playerStore.play as boolean);
// 播放列表
const playList = computed(() => playerStore.playList as SongResult[]);
// 音量控制
const audioVolume = ref(
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
);
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
localStorage.setItem('volume', (value / 100).toString());
audioService.setVolume(value / 100);
audioVolume.value = value / 100;
}
});
// 音量图标
const getVolumeIcon = computed(() => {
if (audioVolume.value === 0) return 'ri-volume-mute-line';
if (audioVolume.value <= 0.5) return 'ri-volume-down-line';
return 'ri-volume-up-line';
});
// 静音
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
// 收藏相关
const isFavorite = computed(() => {
const numericId =
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
return playerStore.favoriteList.includes(numericId);
});
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
const numericId =
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(numericId);
}
};
// 播放列表相关
const palyListRef = useTemplateRef('palyListRef') as any;
const isPlaylistOpen = ref(false);
// 提供 openPlaylistDrawer 给子组件
provide('openPlaylistDrawer', (songId: number) => {
console.log('打开歌单抽屉', songId);
// 由于在迷你模式不处理这个功能,所以只记录日志
});
// 切换播放列表显示/隐藏
const togglePlaylist = () => {
isPlaylistOpen.value = !isPlaylistOpen.value;
console.log('切换播放列表状态', isPlaylistOpen.value);
// 调整窗口大小
if (settingsStore.isMiniMode) {
try {
if (isPlaylistOpen.value) {
// 打开播放列表时调整DOM
document.body.style.height = 'auto';
document.body.style.overflow = 'visible';
// 使用新的专用 API 调整窗口大小
if (window.api && typeof window.api.resizeMiniWindow === 'function') {
window.api.resizeMiniWindow(true);
}
} else {
// 关闭播放列表时强制调整DOM
document.body.style.height = '64px';
document.body.style.overflow = 'hidden';
// 使用新的专用 API 调整窗口大小
if (window.api && typeof window.api.resizeMiniWindow === 'function') {
window.api.resizeMiniWindow(false);
}
}
} catch (error) {
console.error('调整窗口大小失败:', error);
}
}
// 如果打开列表,滚动到当前播放歌曲
if (isPlaylistOpen.value) {
scrollToPlayList();
}
};
const scrollToPlayList = () => {
setTimeout(() => {
const currentIndex = playerStore.playListIndex;
const itemHeight = 69; // 每个列表项的高度
palyListRef.value?.scrollTo({
top: currentIndex * itemHeight,
behavior: 'smooth'
});
}, 50);
};
const handleDeleteSong = (song: SongResult) => {
if (song.id === playMusic.value.id) {
playerStore.nextPlay();
}
playerStore.removeFromPlayList(song.id as number);
};
// 艺术家点击
const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
// 进度条相关
const handleProgressClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
audioService.seek(allTime.value * percent);
nowTime.value = allTime.value * percent;
};
const hoverTime = ref(0);
const isHovering = ref(false);
const handleProgressHover = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
hoverTime.value = allTime.value * percent;
isHovering.value = true;
};
const handleProgressLeave = () => {
isHovering.value = false;
};
// 播放控制
const handlePrev = () => playerStore.prevPlay();
const handleNext = () => playerStore.nextPlay();
const playMusicEvent = async () => {
try {
if (!playerStore.playMusic?.id || !playerStore.playMusicUrl) {
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) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
// 切换到完整播放器
const setMusicFull = () => {
playerStore.setMusicFull(true);
};
</script>
<style lang="scss" scoped>
.mini-play-bar {
@apply w-full flex flex-col bg-light-200 dark:bg-dark-200 shadow-md bg-opacity-60 backdrop-blur dark:bg-opacity-60;
height: 64px;
border-radius: 8px;
position: relative;
&.mini-mode {
@apply shadow-lg;
-webkit-app-region: drag;
.mini-bar-container {
@apply px-2;
}
.song-info {
width: 120px;
.song-title {
@apply text-xs font-medium;
}
.song-artist {
@apply text-xs opacity-50;
}
}
.function-buttons {
-webkit-app-region: no-drag;
@apply space-x-1 ml-1;
.function-button {
width: 28px;
height: 28px;
.iconfont {
@apply text-base;
}
}
}
.control-buttons {
@apply mx-1 space-x-0.5;
-webkit-app-region: no-drag;
.control-button {
width: 28px;
height: 28px;
.iconfont {
@apply text-base;
}
}
}
.close-button {
-webkit-app-region: no-drag;
width: 28px;
height: 28px;
}
.album-cover {
@apply flex-shrink-0 mr-2;
width: 36px;
height: 36px;
-webkit-app-region: no-drag;
}
.progress-bar {
height: 2px !important;
&:hover {
height: 3px !important;
.progress-track,
.progress-fill {
height: 3px !important;
}
}
}
}
}
.mini-bar-container {
@apply flex items-center px-3 h-full relative;
}
.album-cover {
@apply flex-shrink-0 mr-3 cursor-pointer;
width: 40px;
height: 40px;
.cover-img {
@apply w-full h-full rounded-md object-cover pointer-events-none;
}
}
.song-info {
@apply flex flex-col justify-center min-w-0 flex-shrink mr-4 cursor-pointer;
width: 200px;
.song-title {
@apply text-sm font-medium truncate;
color: var(--text-color-1, #000);
}
.song-artist {
@apply text-xs truncate mt-0.5 opacity-60;
color: var(--text-color-2, #666);
}
}
.control-buttons {
@apply flex items-center space-x-1 mx-4;
}
.control-button {
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer text-gray-600;
width: 32px;
height: 32px;
&:hover {
@apply bg-gray-100 dark:bg-dark-300;
}
&.play {
@apply bg-primary text-white;
&:hover {
@apply bg-green-800;
}
}
.iconfont {
@apply text-lg;
}
}
.function-buttons {
@apply flex items-center ml-auto space-x-2;
}
.function-button {
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer;
width: 32px;
height: 32px;
color: var(--text-color-2, #666);
&:hover {
@apply bg-gray-100 dark:bg-dark-300;
color: var(--text-color-1, #000);
}
.iconfont {
@apply text-lg;
}
}
.close-button {
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer ml-2;
width: 32px;
height: 32px;
color: var(--text-color-2, #666);
&:hover {
@apply bg-gray-100 dark:bg-dark-300;
color: var(--text-color-1, #000);
}
}
.progress-bar {
@apply relative w-full cursor-pointer;
height: 2px;
&:hover {
height: 4px;
.progress-track,
.progress-fill {
height: 4px;
}
}
}
.progress-track {
@apply absolute inset-x-0 bottom-0 transition-all duration-200;
height: 2px;
background: rgba(0, 0, 0, 0.1);
.dark & {
background: rgba(255, 255, 255, 0.15);
}
}
.progress-fill {
@apply absolute bottom-0 left-0 transition-all duration-200;
height: 2px;
background: var(--primary-color, #18a058);
}
.like-active {
@apply text-red-500 hover:text-red-600 !important;
}
.volume-slider-wrapper {
@apply p-4 rounded-xl bg-white dark:bg-dark-100 shadow-lg;
width: 40px;
height: 160px;
}
// 播放列表样式
.playlist-container {
@apply fixed left-0 right-0 bg-white dark:bg-dark-100 overflow-hidden;
top: 64px;
height: 330px;
max-height: 330px;
&.mini-mode-list {
width: 340px;
@apply bg-opacity-90 dark:bg-opacity-90;
}
}
// 播放列表内容样式
.music-play-list-content {
@apply px-2 py-1;
.delete-btn {
@apply p-2 rounded-full transition-colors duration-200 cursor-pointer;
@apply hover:bg-red-50 dark:hover:bg-red-900/20;
.iconfont {
@apply text-lg;
}
}
}
// 过渡动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.playlist-scrollbar {
height: 100%;
}
.playlist-items {
padding: 4px 0;
}
.dark {
.song-info {
.song-title {
color: var(--text-color-1, #fff);
}
.song-artist {
color: var(--text-color-2, #fff);
}
}
}
</style>

View File

@@ -0,0 +1,504 @@
<template>
<div
class="mobile-play-bar"
:class="[
setAnimationClass('animate__fadeInUp'),
musicFullVisible ? 'play-bar-expanded' : 'play-bar-mini'
]"
:style="{
color: musicFullVisible
? textColors.theme === 'dark'
? '#ffffff'
: '#ffffff'
: settingsStore.theme === 'dark'
? '#ffffff'
: '#000000'
}"
>
<!-- 完整模式 - 在musicFullVisible为true时显示 -->
<template v-if="musicFullVisible">
<!-- 顶部信息区域 -->
<div class="music-info-header">
<div class="music-info-main">
<h1 class="music-title">{{ playMusic.name }}</h1>
<div class="artist-info">
<span class="artist-name">
<span v-for="(artists, artistsindex) in artistList" :key="artistsindex">
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
</span>
</span>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="music-progress-bar">
<span class="current-time">{{ secondToMinute(nowTime) }}</span>
<div class="progress-wrapper">
<n-slider
v-model:value="timeSlider"
:step="1"
:max="allTime"
:min="0"
:tooltip="false"
class="progress-slider"
></n-slider>
</div>
<span class="total-time">{{ secondToMinute(allTime) }}</span>
</div>
<!-- 主控制区 -->
<div class="player-controls">
<div class="control-btn like" @click="toggleFavorite">
<i class="iconfont ri-heart-3-fill" :class="{ 'like-active': isFavorite }"></i>
</div>
<div class="control-btn prev" @click="handlePrev">
<i class="iconfont ri-skip-back-fill"></i>
</div>
<div class="control-btn play-pause" @click="playMusicEvent">
<i class="iconfont" :class="play ? 'ri-pause-fill' : 'ri-play-fill'"></i>
</div>
<div class="control-btn next" @click="handleNext">
<i class="iconfont ri-skip-forward-fill"></i>
</div>
<n-popover
trigger="click"
:z-index="99999999"
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>
</template>
<!-- Mini模式 - 在musicFullVisible为false时显示 -->
<div v-else class="mobile-mini-controls">
<!-- 歌曲信息 -->
<div class="mini-song-info" @click="setMusicFull">
<n-image
:src="getImgUrl(playMusic?.picUrl, '100y100')"
class="mini-song-cover"
lazy
preview-disabled
/>
<div class="mini-song-text">
<n-ellipsis class="mini-song-title" line-clamp="1">
{{ playMusic.name }}
</n-ellipsis>
<n-ellipsis class="mini-song-artist" line-clamp="1">
<span v-for="(artists, artistsindex) in artistList" :key="artistsindex">
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
</span>
</n-ellipsis>
</div>
</div>
<!-- 播放按钮 -->
<div class="mini-playback-controls">
<div class="mini-control-btn play" @click="playMusicEvent">
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
</div>
<n-popover
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>
<!-- 全屏播放器 -->
<music-full ref="MusicFullRef" v-model="musicFullVisible" :background="background" />
</div>
</template>
<script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, artistList, nowTime, playMusic, sound, textColors } from '@/hooks/MusicHook';
import MusicFull from '@/layout/components/MusicFull.vue';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
// 是否播放
const play = computed(() => playerStore.isPlay);
// 播放列表
const playList = computed(() => playerStore.playList as SongResult[]);
// 背景颜色
const background = ref('#000');
// 播放进度条
const throttledSeek = useThrottleFn((value: number) => {
if (!sound.value) return;
sound.value.seek(value);
nowTime.value = value;
}, 50);
const timeSlider = computed({
get: () => nowTime.value,
set: throttledSeek
});
// 播放控制
function handleNext() {
playerStore.nextPlay();
}
function handlePrev() {
playerStore.prevPlay();
}
// 全屏播放器
const MusicFullRef = ref<any>(null);
const musicFullVisible = ref(false);
// 设置musicFull
const setMusicFull = () => {
musicFullVisible.value = !musicFullVisible.value;
playerStore.setMusicFull(musicFullVisible.value);
if (musicFullVisible.value) {
settingsStore.showArtistDrawer = false;
}
};
// 播放列表引用
const playListRef = ref<any>(null);
const scrollToPlayList = (val: boolean) => {
if (!val) return;
setTimeout(() => {
playListRef.value?.scrollTo({ top: playerStore.playListIndex * 56 });
}, 50);
};
// 收藏功能
const isFavorite = computed(() => {
return playerStore.favoriteList.includes(playMusic.value.id as number);
});
const toggleFavorite = () => {
console.log('isFavorite.value', isFavorite.value);
if (isFavorite.value) {
playerStore.removeFromFavorite(playMusic.value.id as number);
} else {
playerStore.addToFavorite(playMusic.value.id as number);
}
};
// 播放暂停按钮事件
const playMusicEvent = async () => {
try {
if (!playMusic.value?.id || !playerStore.playMusicUrl) {
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) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
watch(
() => playerStore.playMusic,
async () => {
background.value = playMusic.value.backgroundColor as string;
},
{ immediate: true, deep: true }
);
</script>
<style lang="scss" scoped>
.mobile-play-bar {
@apply fixed bottom-[56px] left-0 w-full flex flex-col shadow-lg;
z-index: 10000;
animation-duration: 0.3s !important;
transition: all 0.3s ease;
&.play-bar-expanded {
@apply bg-transparent;
height: auto; /* 自动适应内容高度 */
max-height: 230px; /* 限制最大高度 */
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.5) 20%,
rgba(0, 0, 0, 0.8) 80%,
rgba(0, 0, 0, 0.9) 100%
);
&::before {
content: '';
position: absolute;
top: -50px; /* 延伸到上方 */
left: 0;
right: 0;
bottom: 0;
background-image: v-bind('`url(${getImgUrl(playMusic?.picUrl, "300y300")})`');
background-size: cover;
background-position: center;
filter: blur(20px);
opacity: 0.2;
z-index: -1;
}
}
&.play-bar-mini {
@apply h-14 py-0 bg-light dark:bg-dark;
}
// 顶部信息区域
.music-info-header {
@apply flex justify-between items-start px-6 pt-3 pb-2 relative z-10;
.music-info-main {
@apply flex flex-col;
.music-title {
@apply text-xl font-bold text-white mb-1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.artist-info {
@apply flex items-center;
.artist-name {
@apply text-sm text-white opacity-90;
}
}
}
.action-stats {
@apply flex items-center gap-4;
.like-count,
.comment-count {
@apply flex items-center text-white;
i {
@apply text-base mr-1;
}
span {
@apply text-xs font-medium;
}
}
}
}
// 进度条
.music-progress-bar {
@apply flex items-center justify-between px-4 py-2 relative z-10;
.current-time,
.total-time {
@apply text-xs text-white opacity-80;
}
.progress-wrapper {
@apply flex-1 mx-3 flex flex-col items-center;
.progress-slider {
@apply w-full;
:deep(.n-slider) {
--n-rail-height: 3px;
--n-rail-color: rgba(255, 255, 255, 0.15);
--n-rail-color-dark: rgba(255, 255, 255, 0.15);
--n-fill-color: #1ed760; /* Spotify绿色可调整为其他绿色 */
--n-handle-size: 0px; /* 隐藏滑块 */
--n-handle-color: #1ed760;
&:hover {
--n-handle-size: 10px; /* 鼠标悬停时显示滑块 */
}
.n-slider-rail {
@apply rounded-full !important; /* 圆角进度条 */
}
.n-slider-fill {
@apply rounded-full !important;
box-shadow: 0 0 4px rgba(30, 215, 96, 0.5); /* 发光效果 */
}
.n-slider-handle {
@apply transition-all duration-200;
opacity: 0;
box-shadow: 0 0 4px rgba(255, 255, 255, 0.7);
}
&:hover .n-slider-handle,
&:active .n-slider-handle {
opacity: 1;
}
}
}
.quality-label {
@apply text-xs text-white opacity-70 mt-1;
}
}
}
// 主控制区
.player-controls {
@apply flex items-center justify-between px-8 py-3 relative z-10 pb-8;
.control-btn {
@apply flex items-center justify-center cursor-pointer transition;
i {
@apply text-white transition-all;
}
&.like i {
@apply text-2xl;
}
&.prev i,
&.next i {
@apply text-3xl;
}
&.play-pause {
@apply w-12 h-12 rounded-full flex items-center justify-center;
background: rgba(255, 255, 255, 0.2);
i {
@apply text-4xl;
}
}
&.list i {
@apply text-2xl;
}
.like-active {
@apply text-red-500;
}
}
}
// Mini模式样式
.mobile-mini-controls {
@apply flex items-center justify-between px-4 h-14;
.mini-song-info {
@apply flex items-center flex-1 min-w-0 cursor-pointer;
.mini-song-cover {
@apply w-8 h-8 rounded-md;
}
.mini-song-text {
@apply ml-3 min-w-0 flex-1;
.mini-song-title {
@apply text-sm font-medium;
}
.mini-song-artist {
@apply text-xs text-gray-500 dark:text-gray-400 mt-0.5;
}
}
}
.mini-playback-controls {
@apply flex items-center;
.mini-control-btn {
@apply flex items-center justify-center cursor-pointer transition;
&.play {
@apply w-9 h-9 rounded-full flex items-center justify-center mr-2;
@apply bg-gray-100 dark:bg-gray-800;
.iconfont {
@apply text-xl text-green-500 transition hover:text-green-600;
}
}
}
.mini-list-icon {
@apply text-xl p-1 transition cursor-pointer;
@apply hover:text-green-500;
}
}
}
}
.mobile-play-list-container {
height: 60vh;
width: 90vw;
max-width: 400px;
@apply relative rounded-t-2xl overflow-hidden;
.mobile-play-list-back {
backdrop-filter: blur(20px);
@apply absolute top-0 left-0 w-full h-full;
@apply bg-light dark:bg-black bg-opacity-90;
}
.mobile-play-list-item {
@apply px-3 py-1;
}
}
</style>

View File

@@ -13,7 +13,7 @@
? textColors.theme === 'dark' ? textColors.theme === 'dark'
? '#000000' ? '#000000'
: '#ffffff' : '#ffffff'
: store.state.theme === 'dark' : settingsStore.theme === 'dark'
? '#ffffff' ? '#ffffff'
: '#000000' : '#000000'
}" }"
@@ -25,6 +25,11 @@
:max="allTime" :max="allTime"
:min="0" :min="0"
:format-tooltip="formatTooltip" :format-tooltip="formatTooltip"
:show-tooltip="showSliderTooltip"
@mouseenter="showSliderTooltip = true"
@mouseleave="showSliderTooltip = false"
@dragstart="handleSliderDragStart"
@dragend="handleSliderDragEnd"
></n-slider> ></n-slider>
</div> </div>
<div class="play-bar-img-wrapper" @click="setMusicFull"> <div class="play-bar-img-wrapper" @click="setMusicFull">
@@ -113,12 +118,32 @@
<template #trigger> <template #trigger>
<i <i
class="iconfont ri-netease-cloud-music-line" class="iconfont ri-netease-cloud-music-line"
:class="{ 'text-green-500': isLyricWindowOpen }" :class="{ 'text-green-500': isLyricWindowOpen, 'disabled-icon': !playMusic.id }"
@click="openLyricWindow" @click="playMusic.id && openLyricWindow()"
></i> ></i>
</template> </template>
{{ t('player.playBar.lyric') }} {{ playMusic.id ? t('player.playBar.lyric') : t('player.playBar.noSongPlaying') }}
</n-tooltip> </n-tooltip>
<n-popover
v-if="isElectron"
trigger="click"
:z-index="99999999"
content-class="music-eq"
raw
:show-arrow="false"
:delay="200"
placement="top"
>
<template #trigger>
<n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont ri-equalizer-line" :class="{ 'text-green-500': isEQVisible }"></i>
</template>
{{ t('player.playBar.eq') }}
</n-tooltip>
</template>
<eq-control />
</n-popover>
<n-popover <n-popover
trigger="click" trigger="click"
:z-index="99999999" :z-index="99999999"
@@ -142,7 +167,14 @@
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList"> <n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
<template #default="{ item }"> <template #default="{ item }">
<div class="music-play-list-content"> <div class="music-play-list-content">
<song-item :key="item.id" :item="item" mini></song-item> <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> </div>
</template> </template>
</n-virtual-list> </n-virtual-list>
@@ -156,11 +188,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core'; import { useThrottleFn } from '@vueuse/core';
import { useMessage } from 'naive-ui';
import { computed, ref, useTemplateRef, watch } from 'vue'; import { computed, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import EqControl from '@/components/EQControl.vue';
import { import {
allTime, allTime,
artistList, artistList,
@@ -168,27 +201,29 @@ import {
nowTime, nowTime,
openLyric, openLyric,
playMusic, playMusic,
sound,
textColors textColors
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import MusicFull from '@/layout/components/MusicFull.vue';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils'; import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils';
import { showShortcutToast } from '@/utils/shortcutToast';
import MusicFull from './MusicFull.vue'; const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const message = useMessage();
// //
const play = computed(() => store.state.play as boolean); const play = computed(() => playerStore.isPlay);
// //
const playList = computed(() => store.state.playList as SongResult[]); const playList = computed(() => playerStore.playList as SongResult[]);
// //
const background = ref('#000'); const background = ref('#000');
watch( watch(
() => store.state.playMusic, () => playerStore.playMusic,
async () => { async () => {
background.value = playMusic.value.backgroundColor as string; background.value = playMusic.value.backgroundColor as string;
}, },
@@ -197,17 +232,47 @@ watch(
// seek // seek
const throttledSeek = useThrottleFn((value: number) => { const throttledSeek = useThrottleFn((value: number) => {
if (!sound.value) return; audioService.seek(value);
sound.value.seek(value);
nowTime.value = value; nowTime.value = value;
}, 50); // 50ms }, 50); // 50ms
// nowTime
const dragValue = ref(0);
//
const isDragging = ref(false);
// timeSlider // timeSlider
const timeSlider = computed({ const timeSlider = computed({
get: () => nowTime.value, get: () => (isDragging.value ? dragValue.value : nowTime.value),
set: throttledSeek set: (value) => {
if (isDragging.value) {
// nowTime seek
dragValue.value = value;
return;
}
// () seek
throttledSeek(value);
}
}); });
//
const handleSliderDragStart = () => {
isDragging.value = true;
//
dragValue.value = nowTime.value;
};
const handleSliderDragEnd = () => {
isDragging.value = false;
//
audioService.seek(dragValue.value);
nowTime.value = dragValue.value;
};
//
const formatTooltip = (value: number) => { const formatTooltip = (value: number) => {
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`; return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
}; };
@@ -230,9 +295,8 @@ const getVolumeIcon = computed(() => {
const volumeSlider = computed({ const volumeSlider = computed({
get: () => audioVolume.value * 100, get: () => audioVolume.value * 100,
set: (value) => { set: (value) => {
if (!sound.value) return;
localStorage.setItem('volume', (value / 100).toString()); localStorage.setItem('volume', (value / 100).toString());
sound.value.volume(value / 100); audioService.setVolume(value / 100);
audioVolume.value = value / 100; audioVolume.value = value / 100;
} }
}); });
@@ -247,7 +311,7 @@ const mute = () => {
}; };
// //
const playMode = computed(() => store.state.playMode); const playMode = computed(() => playerStore.playMode);
const playModeIcon = computed(() => { const playModeIcon = computed(() => {
switch (playMode.value) { switch (playMode.value) {
case 0: case 0:
@@ -275,49 +339,60 @@ const playModeText = computed(() => {
// //
const togglePlayMode = () => { const togglePlayMode = () => {
store.commit('togglePlayMode'); playerStore.togglePlayMode();
}; };
function handleNext() { function handleNext() {
store.commit('nextPlay'); playerStore.nextPlay();
} }
function handlePrev() { function handlePrev() {
store.commit('prevPlay'); playerStore.prevPlay();
} }
const MusicFullRef = ref<any>(null); const MusicFullRef = ref<any>(null);
const showSliderTooltip = ref(false);
// //
const playMusicEvent = async () => { const playMusicEvent = async () => {
try { try {
// URL //
if (!playMusic.value?.id || !store.state.playMusicUrl) { if (!playMusic.value?.id) {
console.warn('No valid music or URL available'); console.warn('没有有效的播放对象');
store.commit('setPlay', playMusic.value);
return; return;
} }
// ->
if (play.value) { if (play.value) {
//
if (audioService.getCurrentSound()) { if (audioService.getCurrentSound()) {
audioService.pause(); audioService.pause();
store.commit('setPlayMusic', false); playerStore.setPlayMusic(false);
} }
} else { return;
// }
if (audioService.getCurrentSound()) {
// // ->
audioService.play(); //
} else { if (audioService.getCurrentSound()) {
// audioService.play();
await audioService.play(store.state.playMusicUrl, playMusic.value); playerStore.setPlayMusic(true);
return;
}
// BURL
try {
// URL
const result = await playerStore.setPlay({ ...playMusic.value, playMusicUrl: undefined });
if (result) {
playerStore.setPlayMusic(true);
} }
store.commit('setPlayMusic', true); } catch (error) {
console.error('重新获取播放链接失败:', error);
message.error(t('player.playFailed'));
} }
} catch (error) { } catch (error) {
console.error('播放出错:', error); console.error('播放出错:', error);
store.commit('nextPlay'); message.error(t('player.playFailed'));
} }
}; };
@@ -326,31 +401,38 @@ const musicFullVisible = ref(false);
// musicFull // musicFull
const setMusicFull = () => { const setMusicFull = () => {
musicFullVisible.value = !musicFullVisible.value; musicFullVisible.value = !musicFullVisible.value;
store.commit('setMusicFull', musicFullVisible.value); playerStore.setMusicFull(musicFullVisible.value);
if (musicFullVisible.value) { if (musicFullVisible.value) {
store.commit('setShowArtistDrawer', false); settingsStore.showArtistDrawer = false;
} }
}; };
const palyListRef = useTemplateRef('palyListRef'); const palyListRef = useTemplateRef('palyListRef') as any;
const scrollToPlayList = (val: boolean) => { const scrollToPlayList = (val: boolean) => {
if (!val) return; if (!val) return;
setTimeout(() => { setTimeout(() => {
palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 }); palyListRef.value?.scrollTo({ top: playerStore.playListIndex * 62 });
}, 50); }, 50);
}; };
const isFavorite = computed(() => { const isFavorite = computed(() => {
return store.state.favoriteList.includes(playMusic.value.id); // idnumberBID
const numericId =
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
return playerStore.favoriteList.includes(numericId);
}); });
const toggleFavorite = async (e: Event) => { const toggleFavorite = async (e: Event) => {
e.stopPropagation(); e.stopPropagation();
// idnumberBID
const numericId =
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
if (isFavorite.value) { if (isFavorite.value) {
store.commit('removeFromFavorite', playMusic.value.id); playerStore.removeFromFavorite(numericId);
} else { } else {
store.commit('addToFavorite', playMusic.value.id); playerStore.addToFavorite(numericId);
} }
}; };
@@ -358,65 +440,13 @@ const openLyricWindow = () => {
openLyric(); openLyric();
}; };
const { navigateToArtist } = useArtist();
const handleArtistClick = (id: number) => { const handleArtistClick = (id: number) => {
musicFullVisible.value = false; musicFullVisible.value = false;
store.commit('setCurrentArtistId', id); navigateToArtist(id);
}; };
//
if (isElectron) {
window.electron.ipcRenderer.on('global-shortcut', (_, action: string) => {
console.log('action', action);
switch (action) {
case 'togglePlay':
playMusicEvent();
showShortcutToast(
store.state.play ? t('player.playBar.play') : t('player.playBar.pause'),
store.state.play ? 'ri-pause-circle-line' : 'ri-play-circle-line'
);
break;
case 'prevPlay':
handlePrev();
showShortcutToast(t('player.playBar.prev'), 'ri-skip-back-line');
break;
case 'nextPlay':
handleNext();
showShortcutToast(t('player.playBar.next'), 'ri-skip-forward-line');
break;
case 'volumeUp':
if (volumeSlider.value < 100) {
volumeSlider.value = Math.min(volumeSlider.value + 10, 100);
showShortcutToast(
`${t('player.playBar.volume')}${volumeSlider.value}%`,
'ri-volume-up-line'
);
}
break;
case 'volumeDown':
if (volumeSlider.value > 0) {
volumeSlider.value = Math.max(volumeSlider.value - 10, 0);
showShortcutToast(
`${t('player.playBar.volume')}${volumeSlider.value}%`,
'ri-volume-down-line'
);
}
break;
case 'toggleFavorite':
toggleFavorite(new Event('click'));
showShortcutToast(
isFavorite.value
? t('player.playBar.favorite', { name: playMusic.value.name })
: t('player.playBar.unFavorite', { name: playMusic.value.name }),
isFavorite.value ? 'ri-heart-fill' : 'ri-heart-line'
);
break;
default:
console.log('未知的快捷键动作:', action);
break;
}
});
}
// //
watch( watch(
() => MusicFullRef.value?.config?.hidePlayBar, () => MusicFullRef.value?.config?.hidePlayBar,
@@ -426,6 +456,17 @@ watch(
} }
} }
); );
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>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -542,7 +583,7 @@ watch(
.mobile { .mobile {
.music-play-bar { .music-play-bar {
@apply px-4 bottom-[70px] transition-all duration-300; @apply px-4 bottom-[56px] transition-all duration-300;
} }
.music-time { .music-time {
display: none; display: none;
@@ -610,8 +651,16 @@ watch(
opacity: 0; opacity: 0;
} }
&:hover .n-slider-handle { &:hover {
opacity: 1; .n-slider-handle {
opacity: 1;
}
}
//
.n-slider-tooltip {
@apply bg-gray-800 text-white text-xs py-1 px-2 rounded;
z-index: 999999;
} }
} }
} }
@@ -655,6 +704,13 @@ watch(
@apply text-red-500 hover:text-red-600 !important; @apply text-red-500 hover:text-red-600 !important;
} }
.disabled-icon {
@apply opacity-50 cursor-not-allowed !important;
&:hover {
@apply text-inherit !important;
}
}
.icon-loop, .icon-loop,
.icon-single-loop { .icon-single-loop {
font-size: 1.5rem; font-size: 1.5rem;
@@ -667,4 +723,23 @@ watch(
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
} }
.music-eq {
@apply p-4 rounded-3xl;
backdrop-filter: blur(20px);
@apply bg-light dark:bg-black bg-opacity-75;
}
.music-play-list-content {
@apply mx-2;
.delete-btn {
@apply p-2 rounded-full transition-colors duration-200 cursor-pointer;
@apply hover:bg-red-50 dark:hover:bg-red-900/20;
.iconfont {
@apply text-lg;
}
}
}
</style> </style>

View File

@@ -16,24 +16,54 @@
<div class="shortcut-info"> <div class="shortcut-info">
<span class="shortcut-label">{{ getShortcutLabel(key) }}</span> <span class="shortcut-label">{{ getShortcutLabel(key) }}</span>
</div> </div>
<div class="shortcut-input"> <div class="shortcut-controls">
<n-input <div class="shortcut-input">
:value="formatShortcut(shortcut)" <n-input
:status="duplicateKeys[key] ? 'error' : undefined" :value="formatShortcut(shortcut.key)"
:placeholder="t('settings.shortcutSettings.inputPlaceholder')" :status="duplicateKeys[key] ? 'error' : undefined"
readonly :placeholder="t('settings.shortcutSettings.inputPlaceholder')"
@keydown="(e) => handleKeyDown(e, key)" :disabled="!shortcut.enabled"
@focus="() => startRecording(key)" readonly
@blur="stopRecording" @keydown="(e) => handleKeyDown(e, key)"
/> @focus="() => startRecording(key)"
<n-tooltip v-if="duplicateKeys[key]" trigger="hover"> @blur="stopRecording"
<template #trigger> />
<n-icon class="error-icon" size="18"> <n-tooltip v-if="duplicateKeys[key]" trigger="hover">
<i class="ri-error-warning-line"></i> <template #trigger>
</n-icon> <n-icon class="error-icon" size="18">
</template> <i class="ri-error-warning-line"></i>
{{ t('settings.shortcutSettings.shortcutConflict') }} </n-icon>
</n-tooltip> </template>
{{ t('settings.shortcutSettings.shortcutConflict') }}
</n-tooltip>
</div>
<div class="shortcut-options">
<n-tooltip trigger="hover">
<template #trigger>
<n-switch v-model:value="shortcut.enabled" size="small" />
</template>
{{
shortcut.enabled
? t('settings.shortcutSettings.enabled')
: t('settings.shortcutSettings.disabled')
}}
</n-tooltip>
<n-tooltip v-if="shortcut.enabled" trigger="hover">
<template #trigger>
<n-select
v-model:value="shortcut.scope"
:options="scopeOptions"
size="small"
style="width: 100px"
/>
</template>
{{
shortcut.scope === 'global'
? t('settings.shortcutSettings.scopeGlobal')
: t('settings.shortcutSettings.scopeApp')
}}
</n-tooltip>
</div>
</div> </div>
</div> </div>
</n-space> </n-space>
@@ -46,6 +76,12 @@
<n-button size="small" @click="resetShortcuts">{{ <n-button size="small" @click="resetShortcuts">{{
t('settings.shortcutSettings.resetShortcuts') t('settings.shortcutSettings.resetShortcuts')
}}</n-button> }}</n-button>
<n-button size="small" type="warning" @click="disableAllShortcuts">{{
t('settings.shortcutSettings.disableAll')
}}</n-button>
<n-button size="small" type="success" @click="enableAllShortcuts">{{
t('settings.shortcutSettings.enableAll')
}}</n-button>
<n-button type="primary" size="small" :disabled="hasConflict" @click="handleSave"> <n-button type="primary" size="small" :disabled="hasConflict" @click="handleSave">
{{ t('common.save') }} {{ t('common.save') }}
</n-button> </n-button>
@@ -66,26 +102,37 @@ import { isElectron } from '@/utils';
const { t } = useI18n(); const { t } = useI18n();
interface ShortcutConfig {
key: string;
enabled: boolean;
scope: 'global' | 'app';
}
interface Shortcuts { interface Shortcuts {
togglePlay: string; togglePlay: ShortcutConfig;
prevPlay: string; prevPlay: ShortcutConfig;
nextPlay: string; nextPlay: ShortcutConfig;
volumeUp: string; volumeUp: ShortcutConfig;
volumeDown: string; volumeDown: ShortcutConfig;
toggleFavorite: string; toggleFavorite: ShortcutConfig;
toggleWindow: string; toggleWindow: ShortcutConfig;
} }
const defaultShortcuts: Shortcuts = { const defaultShortcuts: Shortcuts = {
togglePlay: 'CommandOrControl+Alt+P', togglePlay: { key: 'CommandOrControl+Alt+P', enabled: true, scope: 'global' },
prevPlay: 'Alt+Left', prevPlay: { key: 'Alt+Left', enabled: true, scope: 'global' },
nextPlay: 'Alt+Right', nextPlay: { key: 'Alt+Right', enabled: true, scope: 'global' },
volumeUp: 'Alt+Up', volumeUp: { key: 'Alt+Up', enabled: true, scope: 'app' },
volumeDown: 'Alt+Down', volumeDown: { key: 'Alt+Down', enabled: true, scope: 'app' },
toggleFavorite: 'CommandOrControl+Alt+L', toggleFavorite: { key: 'CommandOrControl+Alt+L', enabled: true, scope: 'app' },
toggleWindow: 'CommandOrControl+Alt+Shift+M' toggleWindow: { key: 'CommandOrControl+Alt+Shift+M', enabled: true, scope: 'global' }
}; };
const scopeOptions = [
{ label: t('settings.shortcutSettings.scopeGlobal'), value: 'global' },
{ label: t('settings.shortcutSettings.scopeApp'), value: 'app' }
];
const shortcuts = ref<Shortcuts>( const shortcuts = ref<Shortcuts>(
isElectron isElectron
? window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts') || defaultShortcuts ? window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts') || defaultShortcuts
@@ -93,7 +140,7 @@ const shortcuts = ref<Shortcuts>(
); );
// 临时存储编辑中的快捷键 // 临时存储编辑中的快捷键
const tempShortcuts = ref<Shortcuts>({ ...shortcuts.value }); const tempShortcuts = ref<Shortcuts>(cloneDeep(shortcuts.value));
// 监听快捷键更新 // 监听快捷键更新
if (isElectron) { if (isElectron) {
@@ -101,7 +148,7 @@ if (isElectron) {
const newShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts'); const newShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
if (newShortcuts) { if (newShortcuts) {
shortcuts.value = newShortcuts; shortcuts.value = newShortcuts;
tempShortcuts.value = { ...newShortcuts }; tempShortcuts.value = cloneDeep(newShortcuts);
} }
}); });
} }
@@ -116,12 +163,27 @@ onMounted(() => {
console.log('storedShortcuts', storedShortcuts); console.log('storedShortcuts', storedShortcuts);
if (storedShortcuts) { if (storedShortcuts) {
shortcuts.value = storedShortcuts; shortcuts.value = storedShortcuts;
tempShortcuts.value = { ...storedShortcuts }; tempShortcuts.value = cloneDeep(storedShortcuts);
} else { } else {
shortcuts.value = { ...defaultShortcuts }; shortcuts.value = { ...defaultShortcuts };
tempShortcuts.value = { ...defaultShortcuts }; tempShortcuts.value = cloneDeep(defaultShortcuts);
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', defaultShortcuts); window.electron.ipcRenderer.send('set-store-value', 'shortcuts', defaultShortcuts);
} }
// 转换旧格式的快捷键数据到新格式
if (storedShortcuts && typeof storedShortcuts.togglePlay === 'string') {
const convertedShortcuts = {} as Shortcuts;
Object.entries(storedShortcuts).forEach(([key, value]) => {
convertedShortcuts[key as keyof Shortcuts] = {
key: value as string,
enabled: true,
scope: ['volumeUp', 'volumeDown', 'toggleFavorite'].includes(key) ? 'app' : 'global'
};
});
shortcuts.value = convertedShortcuts;
tempShortcuts.value = cloneDeep(convertedShortcuts);
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', convertedShortcuts);
}
} }
}); });
@@ -144,13 +206,21 @@ const message = useMessage();
// 检查快捷键冲突 // 检查快捷键冲突
const duplicateKeys = computed(() => { const duplicateKeys = computed(() => {
const result: Record<string, boolean> = {}; const result: Record<string, boolean> = {};
const usedShortcuts = new Set<string>(); const usedShortcuts = new Map<string, string>();
Object.entries(tempShortcuts.value).forEach(([key, shortcut]) => { Object.entries(tempShortcuts.value).forEach(([key, shortcut]) => {
if (usedShortcuts.has(shortcut)) { // 只检查启用的快捷键
result[key] = true; if (!shortcut.enabled) return;
const conflictKey = usedShortcuts.get(shortcut.key);
if (conflictKey) {
// 只有相同作用域的快捷键才会被认为冲突
const conflictScope = tempShortcuts.value[conflictKey as keyof Shortcuts].scope;
if (shortcut.scope === conflictScope) {
result[key] = true;
}
} else { } else {
usedShortcuts.add(shortcut); usedShortcuts.set(shortcut.key, key);
} }
}); });
@@ -161,6 +231,8 @@ const duplicateKeys = computed(() => {
const hasConflict = computed(() => Object.keys(duplicateKeys.value).length > 0); const hasConflict = computed(() => Object.keys(duplicateKeys.value).length > 0);
const startRecording = (key: keyof Shortcuts) => { const startRecording = (key: keyof Shortcuts) => {
if (!tempShortcuts.value[key].enabled) return;
isRecording.value = true; isRecording.value = true;
currentKey.value = key; currentKey.value = key;
// 禁用全局快捷键 // 禁用全局快捷键
@@ -220,12 +292,12 @@ const handleKeyDown = (e: KeyboardEvent, key: keyof Shortcuts) => {
} }
if (!['Control', 'Alt', 'Shift', 'Meta', 'Command'].includes(keyName)) { if (!['Control', 'Alt', 'Shift', 'Meta', 'Command'].includes(keyName)) {
tempShortcuts.value[key] = [...modifiers, keyName].join('+'); tempShortcuts.value[key].key = [...modifiers, keyName].join('+');
} }
}; };
const resetShortcuts = () => { const resetShortcuts = () => {
tempShortcuts.value = { ...defaultShortcuts }; tempShortcuts.value = cloneDeep(defaultShortcuts);
message.success(t('settings.shortcutSettings.messages.resetSuccess')); message.success(t('settings.shortcutSettings.messages.resetSuccess'));
}; };
@@ -245,7 +317,7 @@ const saveShortcuts = () => {
// 先保存到 store // 先保存到 store
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', shortcutsToSave); window.electron.ipcRenderer.send('set-store-value', 'shortcuts', shortcutsToSave);
// 然后更新快捷键 // 然后更新快捷键
window.electron.ipcRenderer.send('update-shortcuts'); window.electron.ipcRenderer.send('update-shortcuts', shortcutsToSave);
message.success(t('settings.shortcutSettings.messages.saveSuccess')); message.success(t('settings.shortcutSettings.messages.saveSuccess'));
} catch (error) { } catch (error) {
console.error('保存快捷键失败:', error); console.error('保存快捷键失败:', error);
@@ -255,7 +327,7 @@ const saveShortcuts = () => {
}; };
const cancelEdit = () => { const cancelEdit = () => {
tempShortcuts.value = { ...shortcuts.value }; tempShortcuts.value = cloneDeep(shortcuts.value);
message.info(t('settings.shortcutSettings.messages.cancelEdit')); message.info(t('settings.shortcutSettings.messages.cancelEdit'));
emit('update:show', false); emit('update:show', false);
}; };
@@ -309,7 +381,7 @@ watch(visible, (newVal) => {
// 处理弹窗关闭后的事件 // 处理弹窗关闭后的事件
const handleAfterLeave = () => { const handleAfterLeave = () => {
// 重置临时数据 // 重置临时数据
tempShortcuts.value = { ...shortcuts.value }; tempShortcuts.value = cloneDeep(shortcuts.value);
}; };
// 处理取消按钮点击 // 处理取消按钮点击
@@ -324,6 +396,22 @@ const handleSave = () => {
visible.value = false; visible.value = false;
emit('change', shortcuts.value); emit('change', shortcuts.value);
}; };
// 全部禁用快捷键
const disableAllShortcuts = () => {
Object.keys(tempShortcuts.value).forEach((key) => {
tempShortcuts.value[key as keyof Shortcuts].enabled = false;
});
message.info(t('settings.shortcutSettings.messages.disableAll'));
};
// 全部启用快捷键
const enableAllShortcuts = () => {
Object.keys(tempShortcuts.value).forEach((key) => {
tempShortcuts.value[key as keyof Shortcuts].enabled = true;
});
message.info(t('settings.shortcutSettings.messages.enableAll'));
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -359,25 +447,32 @@ const handleSave = () => {
} }
.shortcut-info { .shortcut-info {
@apply flex flex-col; @apply flex flex-col min-w-[150px];
.shortcut-label { .shortcut-label {
@apply text-base font-medium; @apply text-base font-medium;
} }
} }
.shortcut-input { .shortcut-controls {
@apply flex items-center gap-2; @apply flex items-center gap-3 flex-1;
min-width: 200px;
:deep(.n-input) { .shortcut-input {
.n-input__input-el { @apply flex items-center gap-2 flex-1;
@apply text-center font-mono;
:deep(.n-input) {
.n-input__input-el {
@apply text-center font-mono;
}
}
.error-icon {
@apply text-red-500;
} }
} }
.error-icon { .shortcut-options {
@apply text-red-500; @apply flex items-center gap-2;
} }
} }
} }

View File

@@ -45,6 +45,10 @@ export const SEARCH_TYPES = [
{ {
label: 'MV', label: 'MV',
key: 1004 key: 1004
},
{
label: 'B站',
key: 2000
} }
// { // {
// label: '歌词', // label: '歌词',
@@ -63,3 +67,12 @@ export const SEARCH_TYPES = [
// key: 1018, // key: 1018,
// }, // },
]; ];
export const SEARCH_TYPE = {
MUSIC: 1, // 单曲
ALBUM: 10, // 专辑
ARTIST: 100, // 歌手
PLAYLIST: 1000, // 歌单
MV: 1004, // MV
BILIBILI: 2000 // B站视频
} as const;

View File

@@ -1,6 +1,7 @@
// musicHistoryHooks // musicHistoryHooks
import { useLocalStorage } from '@vueuse/core'; import { useLocalStorage } from '@vueuse/core';
import { recordPlay } from '@/api/stats';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
export const useMusicHistory = () => { export const useMusicHistory = () => {
@@ -14,6 +15,25 @@ export const useMusicHistory = () => {
} else { } else {
musicHistory.value.unshift({ ...music, count: 1 }); musicHistory.value.unshift({ ...music, count: 1 });
} }
// 记录播放统计
if (music?.id && music?.name) {
// 获取艺术家名称
let artistName = '未知艺术家';
if (music.ar) {
artistName = music.ar.map((artist) => artist.name).join('/');
} else if (music.song?.artists && music.song.artists.length > 0) {
artistName = music.song.artists.map((artist) => artist.name).join('/');
} else if (music.artists) {
artistName = music.artists.map((artist) => artist.name).join('/');
}
// 发送播放统计
recordPlay(music.id, music.name, artistName).catch((error) =>
console.error('记录播放统计失败:', error)
);
}
}; };
const delMusic = (music: SongResult) => { const delMusic = (music: SongResult) => {

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { ref } from 'vue';
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music'; import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook'; import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { useSettingsStore } from '@/store';
import type { ILyric, ILyricText, SongResult } from '@/type/music'; import type { ILyric, ILyricText, SongResult } from '@/type/music';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor'; import { getImageLinearBackground } from '@/utils/linearColor';
@@ -12,13 +13,17 @@ import { getImageLinearBackground } from '@/utils/linearColor';
const musicHistory = useMusicHistory(); const musicHistory = useMusicHistory();
// 获取歌曲url // 获取歌曲url
export const getSongUrl = async (id: number, songData: any, isDownloaded: boolean = false) => { export const getSongUrl = async (id: any, songData: any, isDownloaded: boolean = false) => {
const { data } = await getMusicUrl(id, isDownloaded); const settingsStore = useSettingsStore();
const { unlimitedDownload } = settingsStore.setData;
const { data } = await getMusicUrl(id, !unlimitedDownload);
let url = ''; let url = '';
let songDetail = null; let songDetail = null;
try { try {
if (data.data[0].freeTrialInfo || !data.data[0].url) { if (data.data[0].freeTrialInfo || !data.data[0].url) {
const res = await getParsingMusicUrl(id, songData); const res = await getParsingMusicUrl(id, cloneDeep(songData));
url = res.data.data.url; url = res.data.data.url;
songDetail = res.data.data; songDetail = res.data.data;
} else { } else {
@@ -49,11 +54,19 @@ const getSongDetail = async (playMusic: SongResult) => {
// 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据 // 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据
export const useMusicListHook = () => { export const useMusicListHook = () => {
const handlePlayMusic = async (state: any, playMusic: SongResult) => { const handlePlayMusic = async (state: any, playMusic: SongResult, isPlay: boolean = true) => {
const updatedPlayMusic = await getSongDetail(playMusic); const updatedPlayMusic = await getSongDetail(playMusic);
state.playMusic = updatedPlayMusic; state.playMusic = updatedPlayMusic;
state.playMusicUrl = updatedPlayMusic.playMusicUrl; state.playMusicUrl = updatedPlayMusic.playMusicUrl;
state.play = true;
// 记录当前设置的播放状态
state.play = isPlay;
// 每次设置新歌曲时,立即更新 localStorage
localStorage.setItem('currentPlayMusic', JSON.stringify(state.playMusic));
localStorage.setItem('currentPlayMusicUrl', state.playMusicUrl);
localStorage.setItem('isPlaying', state.play.toString());
// 设置网页标题 // 设置网页标题
document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`; document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
loadLrcAsync(state, updatedPlayMusic.id); loadLrcAsync(state, updatedPlayMusic.id);
@@ -156,7 +169,20 @@ export const useMusicListHook = () => {
state.play = true; state.play = true;
return; return;
} }
const playListIndex = (state.playListIndex + 1) % state.playList.length;
let playListIndex: number;
if (state.playMode === 2) {
// 随机播放模式
do {
playListIndex = Math.floor(Math.random() * state.playList.length);
} while (playListIndex === state.playListIndex && state.playList.length > 1);
} else {
// 列表循环模式
playListIndex = (state.playListIndex + 1) % state.playList.length;
}
state.playListIndex = playListIndex;
await handlePlayMusic(state, state.playList[playListIndex]); await handlePlayMusic(state, state.playList[playListIndex]);
}; };
@@ -226,7 +252,7 @@ export const useMusicListHook = () => {
}; };
// 异步加载歌词的方法 // 异步加载歌词的方法
const loadLrcAsync = async (state: any, playMusicId: number) => { const loadLrcAsync = async (state: any, playMusicId: any) => {
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) { if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
return; return;
} }

View File

@@ -0,0 +1,17 @@
import { useRouter } from 'vue-router';
export const useArtist = () => {
const router = useRouter();
/**
* 跳转到歌手详情页
* @param id 歌手ID
*/
const navigateToArtist = (id: number) => {
router.push(`/artist/detail/${id}`);
};
return {
navigateToArtist
};
};

View File

@@ -11,7 +11,7 @@
} }
.n-slider-handle-indicator--top { .n-slider-handle-indicator--top {
@apply bg-transparent text-2xl px-2 py-1 shadow-none mb-0 dark:text-[#ffffffdd] text-[#000000dd] !important; @apply bg-transparent text-2xl px-2 py-1 shadow-none mb-0 text-white bg-dark-300 dark:bg-gray-800 bg-opacity-80 rounded-lg !important;
mix-blend-mode: difference !important; mix-blend-mode: difference !important;
} }

View File

@@ -33,7 +33,6 @@
<!-- 样式表 --> <!-- 样式表 -->
<link rel="stylesheet" href="./assets/icon/iconfont.css" /> <link rel="stylesheet" href="./assets/icon/iconfont.css" />
<link rel="stylesheet" href="./assets/css/base.css" /> <link rel="stylesheet" href="./assets/css/base.css" />
<script defer src="https://cn.vercount.one/js"></script>
<!-- 动画配置 --> <!-- 动画配置 -->
<style> <style>
@@ -45,12 +44,6 @@
<body> <body>
<div id="app"></div> <div id="app"></div>
<div style="display: none">
Total Page View <span id="vercount_value_page_pv">Loading</span> Total Visits
<span id="vercount_value_site_pv">Loading</span> Site Total Visitors
<span id="vercount_value_site_uv">Loading</span>
</div>
<script type="module" src="./main.ts"></script> <script type="module" src="./main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="layout-page"> <div class="layout-page">
<div id="layout-main" class="layout-main"> <div id="layout-main" class="layout-main">
<title-bar v-if="isElectron" /> <title-bar />
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'"> <div class="layout-main-page">
<!-- 侧边菜单栏 --> <!-- 侧边菜单栏 -->
<app-menu v-if="!isMobile" class="menu" :menus="menus" /> <app-menu v-if="!isMobile" class="menu" :menus="menus" />
<div class="main"> <div class="main">
@@ -21,38 +21,50 @@
</router-view> </router-view>
</div> </div>
<play-bottom height="5rem" /> <play-bottom height="5rem" />
<app-menu v-if="isMobile && !store.state.musicFull" class="menu" :menus="menus" /> <app-menu v-if="isMobile && !playerStore.musicFull" class="menu" :menus="menus" />
</div> </div>
</div> </div>
<!-- 底部音乐播放 --> <!-- 底部音乐播放 -->
<play-bar v-show="isPlay" :style="isMobile && store.state.musicFull ? 'bottom: 0;' : ''" /> <template v-if="!settingsStore.isMiniMode">
<play-bar
v-if="!isMobile"
v-show="isPlay"
:style="playerStore.musicFull ? 'bottom: 0;' : ''"
/>
<mobile-play-bar
v-else
v-show="isPlay"
:style="isMobile && playerStore.musicFull ? 'bottom: 0;' : ''"
/>
</template>
<!-- 下载管理抽屉 --> <!-- 下载管理抽屉 -->
<download-drawer <download-drawer
v-if=" v-if="
isElectron && isElectron &&
(store.state.setData?.alwaysShowDownloadButton || (settingsStore.setData?.alwaysShowDownloadButton ||
store.state.showDownloadDrawer || settingsStore.showDownloadDrawer ||
store.state.hasDownloadingTasks) settingsStore.setData?.hasDownloadingTasks)
" "
/> />
</div> </div>
<install-app-modal v-if="!isElectron"></install-app-modal> <install-app-modal v-if="!isElectron"></install-app-modal>
<update-modal v-if="isElectron" /> <update-modal v-if="isElectron" />
<artist-drawer ref="artistDrawerRef" :show="artistDrawerShow" />
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" /> <playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, nextTick, onMounted, provide, ref, watch } from 'vue'; import { computed, defineAsyncComponent, onMounted, provide, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import DownloadDrawer from '@/components/common/DownloadDrawer.vue'; import DownloadDrawer from '@/components/common/DownloadDrawer.vue';
import InstallAppModal from '@/components/common/InstallAppModal.vue'; import InstallAppModal from '@/components/common/InstallAppModal.vue';
import PlayBottom from '@/components/common/PlayBottom.vue'; import PlayBottom from '@/components/common/PlayBottom.vue';
import UpdateModal from '@/components/common/UpdateModal.vue'; import UpdateModal from '@/components/common/UpdateModal.vue';
import homeRouter from '@/router/home'; import homeRouter from '@/router/home';
import { useMenuStore } from '@/store/modules/menu';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { isElectron, isMobile } from '@/utils'; import { isElectron, isMobile } from '@/utils';
const keepAliveInclude = computed(() => const keepAliveInclude = computed(() =>
@@ -66,43 +78,26 @@ const keepAliveInclude = computed(() =>
); );
const AppMenu = defineAsyncComponent(() => import('./components/AppMenu.vue')); const AppMenu = defineAsyncComponent(() => import('./components/AppMenu.vue'));
const PlayBar = defineAsyncComponent(() => import('./components/PlayBar.vue')); const PlayBar = defineAsyncComponent(() => import('@/components/player/PlayBar.vue'));
const MobilePlayBar = defineAsyncComponent(() => import('@/components/player/MobilePlayBar.vue'));
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue')); const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue')); const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
const ArtistDrawer = defineAsyncComponent(() => import('@/components/common/ArtistDrawer.vue'));
const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue')); const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue'));
const store = useStore(); const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const menuStore = useMenuStore();
const isPlay = computed(() => store.state.isPlay as boolean); const isPlay = computed(() => playerStore.playMusic && playerStore.playMusic.id);
const { menus } = store.state; const { menus } = menuStore;
const route = useRoute(); const route = useRoute();
onMounted(() => { onMounted(() => {
store.dispatch('initializeSettings'); settingsStore.initializeSettings();
store.dispatch('initializeTheme'); settingsStore.initializeTheme();
}); });
const artistDrawerRef = ref<InstanceType<typeof ArtistDrawer>>();
const artistDrawerShow = computed({
get: () => store.state.showArtistDrawer,
set: (val) => store.commit('setShowArtistDrawer', val)
});
// 监听歌手ID变化
watch(
() => store.state.currentArtistId,
(newId) => {
if (newId) {
artistDrawerShow.value = true;
nextTick(() => {
artistDrawerRef.value?.loadArtistInfo(newId);
});
}
}
);
const showPlaylistDrawer = ref(false); const showPlaylistDrawer = ref(false);
const currentSongId = ref<number | undefined>(); const currentSongId = ref<number | undefined>();
@@ -147,10 +142,11 @@ provide('openPlaylistDrawer', openPlaylistDrawer);
.mobile { .mobile {
.main-content { .main-content {
height: calc(100vh - 146px); height: calc(100vh - 154px);
overflow: auto; overflow: auto;
display: block; display: block;
flex: none; flex: none;
padding-bottom: 70px;
} }
} }
</style> </style>

View File

@@ -0,0 +1,16 @@
<!-- 迷你模式布局 -->
<template>
<div class="mini-layout">
<mini-play-bar />
</div>
</template>
<script setup lang="ts">
import MiniPlayBar from '@/components/player/MiniPlayBar.vue';
</script>
<style lang="scss" scoped>
.mini-layout {
@apply w-full h-full bg-transparent;
}
</style>

View File

@@ -113,8 +113,10 @@ const isText = ref(false);
.app-menu { .app-menu {
max-width: 100%; max-width: 100%;
width: 100vw; width: 100vw;
position: relative; position: fixed;
z-index: 999999; bottom: 0;
left: 0;
z-index: 99999;
@apply bg-light dark:bg-black border-t border-gray-200 dark:border-gray-700; @apply bg-light dark:bg-black border-t border-gray-200 dark:border-gray-700;
&-header { &-header {
@@ -122,12 +124,12 @@ const isText = ref(false);
} }
&-list { &-list {
@apply flex justify-between; @apply flex justify-between px-4;
} }
&-item { &-item {
&-link { &-link {
@apply my-4 w-auto; @apply my-2 w-auto;
} }
} }
} }

View File

@@ -11,7 +11,7 @@
<div <div
class="control-btn absolute top-8 left-8" class="control-btn absolute top-8 left-8"
:class="{ 'pure-mode': config.pureModeEnabled }" :class="{ 'pure-mode': config.pureModeEnabled }"
@click="isVisible = false" @click="closeMusicFull"
> >
<i class="ri-arrow-down-s-line"></i> <i class="ri-arrow-down-s-line"></i>
</div> </div>
@@ -29,8 +29,9 @@
</n-popover> </n-popover>
<div <div
v-show="!config.hideCover" v-if="!config.hideCover"
class="music-img" class="music-img"
:class="{ 'only-cover': config.hideLyrics }"
:style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }" :style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }"
> >
<n-image <n-image
@@ -40,7 +41,7 @@
lazy lazy
preview-disabled preview-disabled
/> />
<div> <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">
<n-ellipsis <n-ellipsis
@@ -62,15 +63,28 @@
</span> </span>
</n-ellipsis> </n-ellipsis>
</div> </div>
<mini-play-bar
v-if="!config.hideMiniPlayBar"
class="mt-4"
:pure-mode-enabled="config.pureModeEnabled"
component
/>
</div> </div>
</div> </div>
<div class="music-content" :class="{ center: config.centerLyrics && config.hideCover }">
<div
class="music-content"
:class="{
center: config.centerLyrics,
hide: config.hideLyrics
}"
>
<n-layout <n-layout
ref="lrcSider" ref="lrcSider"
class="music-lrc" class="music-lrc"
:style="{ :style="{
height: config.hidePlayBar ? '85vh' : '65vh', height: config.hidePlayBar ? '85vh' : '65vh',
width: config.hideCover ? '50vw' : '500px' width: isMobile ? '100vw' : config.hideCover ? '50vw' : '500px'
}" }"
:native-scrollbar="false" :native-scrollbar="false"
@mouseover="mouseOverLayout" @mouseover="mouseOverLayout"
@@ -112,7 +126,7 @@
</div> </div>
<!-- 无歌词 --> <!-- 无歌词 -->
<div v-if="!lrcArray.length" class="music-lrc-text mt-40"> <div v-if="!lrcArray.length" class="music-lrc-text">
<span>{{ t('player.lrc.noLrc') }}</span> <span>{{ t('player.lrc.noLrc') }}</span>
</div> </div>
</div> </div>
@@ -131,9 +145,9 @@
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import LyricSettings from '@/components/lyric/LyricSettings.vue'; import LyricSettings from '@/components/lyric/LyricSettings.vue';
import MiniPlayBar from '@/components/player/MiniPlayBar.vue';
import { import {
artistList, artistList,
lrcArray, lrcArray,
@@ -143,6 +157,10 @@ import {
textColors, textColors,
useLyricProgress useLyricProgress
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
import { getImgUrl, isMobile } from '@/utils'; import { getImgUrl, isMobile } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor'; import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
@@ -157,30 +175,8 @@ const isDark = ref(false);
const showStickyHeader = ref(false); const showStickyHeader = ref(false);
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>(); const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
interface LyricConfig {
hideCover: boolean;
centerLyrics: boolean;
fontSize: number;
letterSpacing: number;
lineHeight: number;
showTranslation: boolean;
theme: 'default' | 'light' | 'dark';
hidePlayBar: boolean;
pureModeEnabled: boolean;
}
// 移除 computed 配置 // 移除 computed 配置
const config = ref<LyricConfig>({ const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
hideCover: false,
centerLyrics: false,
fontSize: 22,
letterSpacing: 0,
lineHeight: 1.5,
showTranslation: true,
theme: 'default',
hidePlayBar: false,
pureModeEnabled: false
});
// 监听设置组件的配置变化 // 监听设置组件的配置变化
watch( watch(
@@ -372,13 +368,16 @@ onBeforeUnmount(() => {
} }
}); });
const store = useStore(); const settingsStore = useSettingsStore();
const { navigateToArtist } = useArtist();
const handleArtistClick = (id: number) => { const handleArtistClick = (id: number) => {
isVisible.value = false; isVisible.value = false;
store.commit('setCurrentArtistId', id); navigateToArtist(id);
}; };
const setData = computed(() => store.state.setData); const setData = computed(() => settingsStore.setData);
// 监听字体变化并更新 CSS 变量 // 监听字体变化并更新 CSS 变量
watch( watch(
@@ -431,6 +430,13 @@ const handleScroll = () => {
showStickyHeader.value = scrollTop > 100; showStickyHeader.value = scrollTop > 100;
}; };
const playerStore = usePlayerStore();
const closeMusicFull = () => {
isVisible.value = false;
playerStore.setMusicFull(false);
};
// 添加滚动监听 // 添加滚动监听
onMounted(() => { onMounted(() => {
if (lrcSider.value?.$el) { if (lrcSider.value?.$el) {
@@ -533,20 +539,61 @@ defineExpose({
animation-duration: 300ms; animation-duration: 300ms;
.music-img { .music-img {
@apply flex-1 flex justify-center mr-16 flex-col; @apply flex-1 flex justify-center mr-16 flex-col items-center;
max-width: 360px; max-width: 360px;
max-height: 360px; max-height: 360px;
transition: all 0.3s ease;
&.only-cover {
@apply mr-0 flex-initial;
max-width: none;
max-height: none;
.img {
@apply w-[50vh] h-[50vh] mb-8;
}
.music-info {
@apply text-center w-[600px];
.music-content-name {
@apply text-4xl mb-4;
color: var(--text-color-active);
}
.music-content-singer {
@apply text-xl mb-8 opacity-80;
color: var(--text-color-primary);
}
}
}
.img { .img {
@apply rounded-xl w-full h-full shadow-2xl; @apply rounded-xl w-full h-full shadow-2xl transition-all duration-300;
}
.music-info {
@apply w-full mt-4;
.music-content-name {
@apply text-2xl font-bold;
color: var(--text-color-active);
}
.music-content-singer {
@apply text-base mt-2 opacity-80;
color: var(--text-color-primary);
}
} }
} }
.music-content { .music-content {
@apply flex flex-col justify-center items-center relative; @apply flex flex-col justify-center items-center relative;
width: 500px; width: 500px;
transition: all 0.3s ease;
&.center { &.center {
@apply w-full; @apply w-auto;
.music-lrc { .music-lrc {
@apply w-full max-w-3xl mx-auto; @apply w-full max-w-3xl mx-auto;
} }
@@ -555,12 +602,8 @@ defineExpose({
} }
} }
&-name { &.hide {
@apply font-bold text-2xl pb-1 pt-4; @apply hidden;
}
&-singer {
@apply text-base;
} }
} }
@@ -647,12 +690,18 @@ defineExpose({
span { span {
padding-right: 0px !important; padding-right: 0px !important;
} }
} .hover-text {
.music-lrc-text { &:hover {
@apply text-xl text-center; background-color: transparent;
}
}
.music-lrc-text {
@apply text-xl text-center;
}
} }
.music-content { .music-content {
@apply h-[calc(100vh-120px)]; @apply h-[calc(100vh-120px)];
width: 100vw !important;
} }
} }
} }
@@ -663,8 +712,9 @@ defineExpose({
// 添加全局字体样式 // 添加全局字体样式
:root { :root {
--current-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, --current-font-family:
'Helvetica Neue', Arial, sans-serif; system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
} }
#drawer-target { #drawer-target {
@@ -686,7 +736,7 @@ defineExpose({
} }
.control-btn { .control-btn {
@apply w-9 h-9 flex items-center justify-center rounded cursor-pointer transition-all duration-300; @apply w-9 h-9 flex items-center justify-center rounded cursor-pointer transition-all duration-300 z-[9999];
background: rgba(142, 142, 142, 0.192); background: rgba(142, 142, 142, 0.192);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);

View File

@@ -1,5 +1,8 @@
<template> <template>
<div class="search-box flex"> <div class="search-box flex">
<div v-if="showBackButton" class="back-button" @click="goBack">
<i class="ri-arrow-left-line"></i>
</div>
<div class="search-box-input flex-1"> <div class="search-box-input flex-1">
<n-input <n-input
v-model:value="searchValue" v-model:value="searchValue"
@@ -16,7 +19,7 @@
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType"> <n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
<div class="w-20 px-3 flex justify-between items-center"> <div class="w-20 px-3 flex justify-between items-center">
<div> <div>
{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }} {{ searchTypeOptions.find((item) => item.key === searchStore.searchType)?.label }}
</div> </div>
<i class="iconfont icon-xiasanjiaoxing"></i> <i class="iconfont icon-xiasanjiaoxing"></i>
</div> </div>
@@ -28,11 +31,11 @@
<template #trigger> <template #trigger>
<div class="user-box"> <div class="user-box">
<n-avatar <n-avatar
v-if="store.state.user" v-if="userStore.user"
class="cursor-pointer" class="cursor-pointer"
circle circle
size="medium" size="medium"
:src="getImgUrl(store.state.user.avatarUrl)" :src="getImgUrl(userStore.user.avatarUrl)"
@click="selectItem('user')" @click="selectItem('user')"
/> />
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin"> <div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">
@@ -41,16 +44,16 @@
</div> </div>
</template> </template>
<div class="user-popover"> <div class="user-popover">
<div v-if="store.state.user" class="user-header" @click="selectItem('user')"> <div v-if="userStore.user" class="user-header" @click="selectItem('user')">
<n-avatar circle size="small" :src="getImgUrl(store.state.user?.avatarUrl)" /> <n-avatar circle size="small" :src="getImgUrl(userStore.user?.avatarUrl)" />
<span class="username">{{ store.state.user?.nickname || 'Theodore' }}</span> <span class="username">{{ userStore.user?.nickname || 'Theodore' }}</span>
</div> </div>
<div class="menu-items"> <div class="menu-items">
<div v-if="!store.state.user" class="menu-item" @click="toLogin"> <div v-if="!userStore.user" class="menu-item" @click="toLogin">
<i class="iconfont ri-login-box-line"></i> <i class="iconfont ri-login-box-line"></i>
<span>{{ t('comp.searchBar.toLogin') }}</span> <span>{{ t('comp.searchBar.toLogin') }}</span>
</div> </div>
<div v-if="store.state.user" class="menu-item" @click="selectItem('logout')"> <div v-if="userStore.user" class="menu-item" @click="selectItem('logout')">
<i class="iconfont ri-logout-box-r-line"></i> <i class="iconfont ri-logout-box-r-line"></i>
<span>{{ t('comp.searchBar.logout') }}</span> <span>{{ t('comp.searchBar.logout') }}</span>
</div> </div>
@@ -60,9 +63,9 @@
<span>{{ t('comp.searchBar.set') }}</span> <span>{{ t('comp.searchBar.set') }}</span>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<i class="iconfont" :class="isDarkTheme ? 'ri-moon-line' : 'ri-sun-line'"></i> <i class="iconfont" :class="isDark ? 'ri-moon-line' : 'ri-sun-line'"></i>
<span>{{ t('comp.searchBar.theme') }}</span> <span>{{ t('comp.searchBar.theme') }}</span>
<n-switch v-model:value="isDarkTheme" class="ml-auto"> <n-switch v-model:value="isDark" class="ml-auto">
<template #checked> <template #checked>
<i class="ri-moon-line"></i> <i class="ri-moon-line"></i>
</template> </template>
@@ -102,10 +105,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref, watchEffect } from 'vue'; import { computed, onMounted, ref, watch, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getSearchKeyword } from '@/api/home'; import { getSearchKeyword } from '@/api/home';
import { getUserDetail } from '@/api/login'; import { getUserDetail } from '@/api/login';
@@ -113,16 +115,31 @@ import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png'; import wechat from '@/assets/wechat.png';
import Coffee from '@/components/Coffee.vue'; import Coffee from '@/components/Coffee.vue';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const'; import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { useSearchStore } from '@/store/modules/search';
import { useSettingsStore } from '@/store/modules/settings';
import { useUserStore } from '@/store/modules/user';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
import { checkUpdate, UpdateResult } from '@/utils/update'; import { checkUpdate, UpdateResult } from '@/utils/update';
import config from '../../../../package.json'; import config from '../../../../package.json';
const router = useRouter(); const router = useRouter();
const store = useStore(); const searchStore = useSearchStore();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const userSetOptions = ref(USER_SET_OPTIONS); const userSetOptions = ref(USER_SET_OPTIONS);
const { t } = useI18n(); const { t } = useI18n();
// 显示返回按钮
const showBackButton = computed(() => {
return router.currentRoute.value.meta.back === true;
});
// 返回上一页
const goBack = () => {
router.back();
};
// 推荐热搜词 // 推荐热搜词
const hotSearchKeyword = ref(t('comp.searchBar.searchPlaceholder')); const hotSearchKeyword = ref(t('comp.searchBar.searchPlaceholder'));
const hotSearchValue = ref(''); const hotSearchValue = ref('');
@@ -137,15 +154,15 @@ const loadPage = async () => {
if (!token) return; if (!token) return;
const { data } = await getUserDetail(); const { data } = await getUserDetail();
console.log('data', data); console.log('data', data);
store.state.user = userStore.user =
data.profile || store.state.user || JSON.parse(localStorage.getItem('user') || '{}'); data.profile || userStore.user || JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify(store.state.user)); localStorage.setItem('user', JSON.stringify(userStore.user));
}; };
loadPage(); loadPage();
watchEffect(() => { watchEffect(() => {
if (store.state.user) { if (userStore.user) {
userSetOptions.value = USER_SET_OPTIONS; userSetOptions.value = USER_SET_OPTIONS;
} else { } else {
userSetOptions.value = USER_SET_OPTIONS.filter((item) => item.key !== 'logout'); userSetOptions.value = USER_SET_OPTIONS.filter((item) => item.key !== 'logout');
@@ -167,13 +184,25 @@ onMounted(() => {
checkForUpdates(); checkForUpdates();
}); });
const isDarkTheme = computed({ const isDark = computed({
get: () => store.state.theme === 'dark', get: () => settingsStore.theme === 'dark',
set: () => store.commit('toggleTheme') set: () => settingsStore.toggleTheme()
}); });
// 搜索词 // 搜索词
const searchValue = ref(''); const searchValue = ref('');
// 使用 watch 代替 watchEffect 监听搜索值变化,确保深度监听
watch(
() => searchStore.searchValue,
(newValue) => {
if (newValue) {
searchValue.value = newValue;
}
},
{ immediate: true }
);
const search = () => { const search = () => {
const { value } = searchValue; const { value } = searchValue;
if (value === '') { if (value === '') {
@@ -182,7 +211,7 @@ const search = () => {
} }
if (router.currentRoute.value.path === '/search') { if (router.currentRoute.value.path === '/search') {
store.state.searchValue = value; searchStore.searchValue = value;
return; return;
} }
@@ -190,15 +219,25 @@ const search = () => {
path: '/search', path: '/search',
query: { query: {
keyword: value, keyword: value,
type: store.state.searchType type: searchStore.searchType
} }
}); });
}; };
const selectSearchType = (key: number) => { const selectSearchType = (key: number) => {
store.state.searchType = key; searchStore.searchType = key;
if (searchValue.value) { if (searchValue.value) {
search(); if (router.currentRoute.value.path === '/search') {
search();
} else {
router.push({
path: '/search',
query: {
keyword: searchValue.value,
type: key
}
});
}
} }
}; };
@@ -208,7 +247,7 @@ const selectItem = async (key: string) => {
// switch 判断 // switch 判断
switch (key) { switch (key) {
case 'logout': case 'logout':
store.commit('logout'); userStore.handleLogout();
break; break;
case 'login': case 'login':
router.push('/login'); router.push('/login');
@@ -227,7 +266,7 @@ const selectItem = async (key: string) => {
}; };
const toGithub = () => { const toGithub = () => {
window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank'); window.open('http://donate.alger.fun', '_blank');
}; };
const updateInfo = ref<UpdateResult>({ const updateInfo = ref<UpdateResult>({
@@ -250,7 +289,7 @@ const checkForUpdates = async () => {
const toGithubRelease = () => { const toGithubRelease = () => {
if (updateInfo.value.hasUpdate) { if (updateInfo.value.hasUpdate) {
store.commit('setShowUpdateModal', true); settingsStore.showUpdateModal = true;
} else { } else {
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank'); window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
} }
@@ -258,6 +297,15 @@ const toGithubRelease = () => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.back-button {
@apply mr-2 flex items-center justify-center text-xl cursor-pointer;
@apply w-9 h-9 rounded-full;
@apply bg-light-100 dark:bg-dark-100 text-gray-900 dark:text-white;
@apply border dark:border-gray-600 border-gray-200;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
@apply transition-all duration-200;
}
.user-box { .user-box {
@apply ml-4 flex text-lg justify-center items-center rounded-full transition-colors duration-200; @apply ml-4 flex text-lg justify-center items-center rounded-full transition-colors duration-200;
@apply border dark:border-gray-600 border-gray-200 hover:border-gray-400 dark:hover:border-gray-400; @apply border dark:border-gray-600 border-gray-200 hover:border-gray-400 dark:hover:border-gray-400;

View File

@@ -2,19 +2,35 @@
<div id="title-bar" @mousedown="drag"> <div id="title-bar" @mousedown="drag">
<div id="title">Alger Music</div> <div id="title">Alger Music</div>
<div id="buttons"> <div id="buttons">
<div class="button" @click="minimize"> <n-button
<i class="iconfont icon-minisize"></i> v-if="!isElectron"
</div> type="primary"
<div class="button" @click="close"> size="small"
<i class="iconfont icon-close"></i> text
</div> title="下载应用"
@click="openDownloadPage"
>
<i class="ri-download-line"></i>
下载桌面版
</n-button>
<template v-if="isElectron">
<div class="button" @click="miniWindow">
<i class="iconfont ri-picture-in-picture-line"></i>
</div>
<div class="button" @click="minimize">
<i class="iconfont icon-minisize"></i>
</div>
<div class="button" @click="handleClose">
<i class="iconfont icon-close"></i>
</div>
</template>
</div> </div>
</div> </div>
<n-modal <n-modal
v-model:show="showCloseModal" v-model:show="showCloseModal"
preset="dialog" preset="dialog"
title="关闭应用" :title="t('comp.titleBar.closeApp')"
:style="{ width: '400px' }" :style="{ width: '400px' }"
:mask-closable="true" :mask-closable="true"
> >
@@ -42,16 +58,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useSettingsStore } from '@/store/modules/settings';
import { isElectron } from '@/utils'; import { isElectron } from '@/utils';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const settingsStore = useSettingsStore();
const showCloseModal = ref(false); const showCloseModal = ref(false);
const rememberChoice = ref(false); const rememberChoice = ref(false);
const openDownloadPage = () => {
if (!isElectron) {
window.open('http://donate.alger.fun/download', '_blank');
}
};
const minimize = () => { const minimize = () => {
if (!isElectron) { if (!isElectron) {
return; return;
@@ -59,10 +81,15 @@ const minimize = () => {
window.api.minimize(); window.api.minimize();
}; };
const miniWindow = () => {
if (!isElectron) return;
window.api.miniWindow();
};
const handleAction = (action: 'minimize' | 'close') => { const handleAction = (action: 'minimize' | 'close') => {
if (rememberChoice.value) { if (rememberChoice.value) {
store.commit('setSetData', { settingsStore.setSetData({
...store.state.setData, ...settingsStore.setData,
closeAction: action closeAction: action
}); });
} }
@@ -75,24 +102,16 @@ const handleAction = (action: 'minimize' | 'close') => {
showCloseModal.value = false; showCloseModal.value = false;
}; };
const close = () => { const handleClose = () => {
if (!isElectron) { const { closeAction } = settingsStore.setData;
return;
}
const { closeAction } = store.state.setData;
if (closeAction === 'minimize') { if (closeAction === 'minimize') {
window.api.miniTray(); window.api.miniTray();
return; } else if (closeAction === 'close') {
}
if (closeAction === 'close') {
window.api.close(); window.api.close();
return; } else {
showCloseModal.value = true;
} }
showCloseModal.value = true;
}; };
const drag = (event: MouseEvent) => { const drag = (event: MouseEvent) => {

View File

@@ -1,6 +1,3 @@
import 'vfonts/Lato.css';
import 'vfonts/FiraCode.css';
// tailwind css
import './index.css'; import './index.css';
import 'animate.css'; import 'animate.css';
import 'remixicon/fonts/remixicon.css'; import 'remixicon/fonts/remixicon.css';
@@ -9,10 +6,11 @@ import { createApp } from 'vue';
import i18n from '@/../i18n/renderer'; import i18n from '@/../i18n/renderer';
import router from '@/router'; import router from '@/router';
import store from '@/store'; import pinia from '@/store';
import App from './App.vue'; import App from './App.vue';
import directives from './directive'; import directives from './directive';
import { initAppShortcuts } from './utils/appShortcuts';
const app = createApp(App); const app = createApp(App);
@@ -20,7 +18,10 @@ Object.keys(directives).forEach((key: string) => {
app.directive(key, directives[key as keyof typeof directives]); app.directive(key, directives[key as keyof typeof directives]);
}); });
app.use(pinia);
app.use(router); app.use(router);
app.use(store);
app.use(i18n); app.use(i18n);
app.mount('#app'); app.mount('#app');
// 初始化应用内快捷键
initAppShortcuts();

View File

@@ -1,7 +1,21 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import { recordVisit } from '@/api/stats';
import AppLayout from '@/layout/AppLayout.vue'; import AppLayout from '@/layout/AppLayout.vue';
import MiniLayout from '@/layout/MiniLayout.vue';
import homeRouter from '@/router/home'; import homeRouter from '@/router/home';
import otherRouter from '@/router/other';
import { useSettingsStore } from '@/store/modules/settings';
// 由于 Vue Router 守卫在创建前不能直接使用组合式 API
// 我们创建一个辅助函数来获取 store 实例
let _settingsStore: ReturnType<typeof useSettingsStore> | null = null;
const getSettingsStore = () => {
if (!_settingsStore) {
_settingsStore = useSettingsStore();
}
return _settingsStore;
};
const loginRouter = { const loginRouter = {
path: '/login', path: '/login',
@@ -29,15 +43,51 @@ const routes = [
{ {
path: '/', path: '/',
component: AppLayout, component: AppLayout,
children: [...homeRouter, loginRouter, setRouter] children: [...homeRouter, loginRouter, setRouter, ...otherRouter]
}, },
{ {
path: '/lyric', path: '/lyric',
component: () => import('@/views/lyric/index.vue') component: () => import('@/views/lyric/index.vue')
},
{
path: '/mini',
component: MiniLayout
} }
]; ];
export default createRouter({ const router = createRouter({
routes, routes,
history: createWebHashHistory() history: createWebHashHistory()
}); });
// 添加全局前置守卫
router.beforeEach((to, _, next) => {
const settingsStore = getSettingsStore();
// 如果是迷你模式
if (settingsStore.isMiniMode) {
// 只允许访问 /mini 路由
if (to.path === '/mini') {
next();
} else {
next(false); // 阻止导航
}
} else if (to.path === '/mini') {
// 如果不是迷你模式但想访问 /mini 路由,重定向到首页
next('/');
} else {
// 其他情况正常导航
next();
}
});
// 添加全局后置钩子,记录页面访问
router.afterEach((to) => {
const pageName = to.name?.toString() || to.path;
// 使用setTimeout避免阻塞路由导航
setTimeout(() => {
recordVisit(pageName).catch((error) => console.error('记录页面访问失败:', error));
}, 100);
});
export default router;

View File

@@ -0,0 +1,58 @@
const otherRouter = [
{
path: '/user/follows',
name: 'userFollows',
meta: {
title: '关注列表',
keepAlive: true,
showInMenu: false,
back: true
},
component: () => import('@/views/user/follows.vue')
},
{
path: '/user/followers',
name: 'userFollowers',
meta: {
title: '粉丝列表',
keepAlive: true,
showInMenu: false,
back: true
},
component: () => import('@/views/user/followers.vue')
},
{
path: '/user/detail/:uid',
name: 'userDetail',
meta: {
title: '用户详情',
keepAlive: true,
showInMenu: false,
back: true
},
component: () => import('@/views/user/detail.vue')
},
{
path: '/artist/detail/:id',
name: 'artistDetail',
meta: {
title: '歌手详情',
keepAlive: true,
showInMenu: false,
back: true
},
component: () => import('@/views/artist/detail.vue')
},
{
path: '/bilibili/:bvid',
name: 'bilibiliPlayer',
meta: {
title: 'B站听书',
keepAlive: true,
showInMenu: false,
back: true
},
component: () => import('@/views/bilibili/BilibiliPlayer.vue')
}
];
export default otherRouter;

View File

@@ -1,16 +1,53 @@
import { Howl } from 'howler'; import { Howl, Howler } from 'howler';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { isElectron } from '@/utils'; // 导入isElectron常量
class AudioService { class AudioService {
private currentSound: Howl | null = null; private currentSound: Howl | null = null;
private currentTrack: SongResult | null = null; private currentTrack: SongResult | null = null;
private context: AudioContext | null = null;
private filters: BiquadFilterNode[] = [];
private source: MediaElementAudioSourceNode | null = null;
private gainNode: GainNode | null = null;
private bypass = false;
// 预设的 EQ 频段
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
// 默认的 EQ 设置
private defaultEQSettings: { [key: string]: number } = {
'31': 0,
'62': 0,
'125': 0,
'250': 0,
'500': 0,
'1000': 0,
'2000': 0,
'4000': 0,
'8000': 0,
'16000': 0
};
private retryCount = 0;
private seekLock = false;
private seekDebounceTimer: NodeJS.Timeout | null = null;
constructor() { constructor() {
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
this.initMediaSession(); this.initMediaSession();
} }
// 从本地存储加载 EQ 开关状态
const bypassState = localStorage.getItem('eqBypass');
this.bypass = bypassState ? JSON.parse(bypassState) : false;
} }
private initMediaSession() { private initMediaSession() {
@@ -28,21 +65,22 @@ class AudioService {
navigator.mediaSession.setActionHandler('seekto', (event) => { navigator.mediaSession.setActionHandler('seekto', (event) => {
if (event.seekTime && this.currentSound) { if (event.seekTime && this.currentSound) {
this.currentSound.seek(event.seekTime); // this.currentSound.seek(event.seekTime);
this.seek(event.seekTime);
} }
}); });
navigator.mediaSession.setActionHandler('seekbackward', (event) => { navigator.mediaSession.setActionHandler('seekbackward', (event) => {
if (this.currentSound) { if (this.currentSound) {
const currentTime = this.currentSound.seek() as number; const currentTime = this.currentSound.seek() as number;
this.currentSound.seek(currentTime - (event.seekOffset || 10)); this.seek(currentTime - (event.seekOffset || 10));
} }
}); });
navigator.mediaSession.setActionHandler('seekforward', (event) => { navigator.mediaSession.setActionHandler('seekforward', (event) => {
if (this.currentSound) { if (this.currentSound) {
const currentTime = this.currentSound.seek() as number; const currentTime = this.currentSound.seek() as number;
this.currentSound.seek(currentTime + (event.seekOffset || 10)); this.seek(currentTime + (event.seekOffset || 10));
} }
}); });
@@ -120,10 +158,213 @@ class AudioService {
} }
} }
// EQ 相关方法
public isEQEnabled(): boolean {
return !this.bypass;
}
public setEQEnabled(enabled: boolean) {
this.bypass = !enabled;
localStorage.setItem('eqBypass', JSON.stringify(this.bypass));
if (this.source && this.gainNode && this.context) {
this.applyBypassState();
}
}
public setEQFrequencyGain(frequency: string, gain: number) {
const filterIndex = this.frequencies.findIndex((f) => f.toString() === frequency);
if (filterIndex !== -1 && this.filters[filterIndex]) {
this.filters[filterIndex].gain.setValueAtTime(gain, this.context?.currentTime || 0);
this.saveEQSettings(frequency, gain);
}
}
public resetEQ() {
this.filters.forEach((filter) => {
filter.gain.setValueAtTime(0, this.context?.currentTime || 0);
});
localStorage.removeItem('eqSettings');
}
public getAllEQSettings(): { [key: string]: number } {
return this.loadEQSettings();
}
private saveEQSettings(frequency: string, gain: number) {
const settings = this.loadEQSettings();
settings[frequency] = gain;
localStorage.setItem('eqSettings', JSON.stringify(settings));
}
private loadEQSettings(): { [key: string]: number } {
const savedSettings = localStorage.getItem('eqSettings');
return savedSettings ? JSON.parse(savedSettings) : { ...this.defaultEQSettings };
}
private async disposeEQ(keepContext = false) {
try {
// 清理音频节点连接
if (this.source) {
this.source.disconnect();
this.source = null;
}
// 清理滤波器
this.filters.forEach((filter) => {
try {
filter.disconnect();
} catch (e) {
console.warn('清理滤波器时出错:', e);
}
});
this.filters = [];
// 清理增益节点
if (this.gainNode) {
this.gainNode.disconnect();
this.gainNode = null;
}
// 如果不需要保持上下文,则关闭它
if (!keepContext && this.context) {
try {
await this.context.close();
this.context = null;
} catch (e) {
console.warn('关闭音频上下文时出错:', e);
}
}
} catch (error) {
console.error('清理EQ资源时出错:', error);
}
}
private async setupEQ(sound: Howl) {
try {
if (!isElectron) {
console.log('Web环境中跳过EQ设置避免CORS问题');
this.bypass = true;
return;
}
const howl = sound as any;
// eslint-disable-next-line no-underscore-dangle
const audioNode = howl._sounds?.[0]?._node;
if (!audioNode || !(audioNode instanceof HTMLMediaElement)) {
if (this.retryCount < 3) {
console.warn('等待音频节点初始化,重试次数:', this.retryCount + 1);
await new Promise((resolve) => setTimeout(resolve, 100));
this.retryCount++;
return await this.setupEQ(sound);
}
throw new Error('无法获取音频节点,请重试');
}
this.retryCount = 0;
// 确保使用 Howler 的音频上下文
this.context = Howler.ctx as AudioContext;
if (!this.context || this.context.state === 'closed') {
Howler.ctx = new AudioContext();
this.context = Howler.ctx;
Howler.masterGain = this.context.createGain();
Howler.masterGain.connect(this.context.destination);
}
if (this.context.state === 'suspended') {
await this.context.resume();
}
// 清理现有连接
await this.disposeEQ(true);
try {
// 检查节点是否已经有源
const existingSource = (audioNode as any).source as MediaElementAudioSourceNode;
if (existingSource?.context === this.context) {
console.log('复用现有音频源节点');
this.source = existingSource;
} else {
// 创建新的源节点
console.log('创建新的音频源节点');
this.source = this.context.createMediaElementSource(audioNode);
(audioNode as any).source = this.source;
}
} catch (e) {
console.error('创建音频源节点失败:', e);
throw e;
}
// 创建增益节点
this.gainNode = this.context.createGain();
// 创建滤波器
this.filters = this.frequencies.map((freq) => {
const filter = this.context!.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 1;
filter.gain.value = this.loadEQSettings()[freq.toString()] || 0;
return filter;
});
// 应用EQ状态
this.applyBypassState();
// 设置音量
const volume = localStorage.getItem('volume');
if (this.gainNode) {
this.gainNode.gain.value = volume ? parseFloat(volume) : 1;
}
console.log('EQ初始化成功');
} catch (error) {
console.error('EQ初始化失败:', error);
await this.disposeEQ();
throw error;
}
}
private applyBypassState() {
if (!this.source || !this.gainNode || !this.context) return;
try {
// 断开所有现有连接
this.source.disconnect();
this.filters.forEach((filter) => filter.disconnect());
this.gainNode.disconnect();
if (this.bypass) {
// EQ被禁用时直接连接到输出
this.source.connect(this.gainNode);
this.gainNode.connect(this.context.destination);
} else {
// EQ启用时通过滤波器链连接
this.source.connect(this.filters[0]);
this.filters.forEach((filter, index) => {
if (index < this.filters.length - 1) {
filter.connect(this.filters[index + 1]);
}
});
this.filters[this.filters.length - 1].connect(this.gainNode);
this.gainNode.connect(this.context.destination);
}
} catch (error) {
console.error('应用EQ状态时出错:', error);
}
}
// 播放控制相关 // 播放控制相关
play(url?: string, track?: SongResult): Promise<Howl> { play(url?: string, track?: SongResult, isPlay: boolean = true): Promise<Howl> {
// 如果没有提供新的 URL 和 track且当前有音频实例则继续播放 // 如果没有提供新的 URL 和 track且当前有音频实例则继续播放
if (this.currentSound && !url && !track) { if (this.currentSound && !url && !track) {
// 如果有进行中的seek操作等待其完成
if (this.seekLock && this.seekDebounceTimer) {
clearTimeout(this.seekDebounceTimer);
this.seekLock = false;
}
this.currentSound.play(); this.currentSound.play();
return Promise.resolve(this.currentSound); return Promise.resolve(this.currentSound);
} }
@@ -137,19 +378,54 @@ class AudioService {
let retryCount = 0; let retryCount = 0;
const maxRetries = 1; const maxRetries = 1;
const tryPlay = () => { const tryPlay = async () => {
// 清理现有的音频实例
if (this.currentSound) {
this.currentSound.unload();
this.currentSound = null;
}
try { try {
console.log('audioService: 开始创建音频对象');
// 确保 Howler 上下文已初始化
if (!Howler.ctx) {
console.log('audioService: 初始化 Howler 上下文');
Howler.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
// 确保使用同一个音频上下文
if (Howler.ctx.state === 'closed') {
console.log('audioService: 重新创建音频上下文');
Howler.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
this.context = Howler.ctx;
Howler.masterGain = this.context.createGain();
Howler.masterGain.connect(this.context.destination);
}
// 恢复上下文状态
if (Howler.ctx.state === 'suspended') {
console.log('audioService: 恢复暂停的音频上下文');
await Howler.ctx.resume();
}
// 先停止并清理现有的音频实例
if (this.currentSound) {
console.log('audioService: 停止并清理现有的音频实例');
// 确保任何进行中的seek操作被取消
if (this.seekLock && this.seekDebounceTimer) {
clearTimeout(this.seekDebounceTimer);
this.seekLock = false;
}
this.currentSound.stop();
this.currentSound.unload();
this.currentSound = null;
}
// 清理 EQ 但保持上下文
console.log('audioService: 清理 EQ');
await this.disposeEQ(true);
this.currentTrack = track; this.currentTrack = track;
console.log('audioService: 创建新的 Howl 对象');
this.currentSound = new Howl({ this.currentSound = new Howl({
src: [url], src: [url],
html5: true, html5: true,
autoplay: true, autoplay: false, // 修改为 false不自动播放等待完全初始化后手动播放
volume: localStorage.getItem('volume') volume: localStorage.getItem('volume')
? parseFloat(localStorage.getItem('volume') as string) ? parseFloat(localStorage.getItem('volume') as string)
: 1, : 1,
@@ -161,6 +437,8 @@ class AudioService {
console.log(`Retrying playback (${retryCount}/${maxRetries})...`); console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount); setTimeout(tryPlay, 1000 * retryCount);
} else { } else {
// 发送URL过期事件通知外部需要重新获取URL
this.emit('url_expired', this.currentTrack);
reject(new Error('音频加载失败,请尝试切换其他歌曲')); reject(new Error('音频加载失败,请尝试切换其他歌曲'));
} }
}, },
@@ -171,16 +449,37 @@ class AudioService {
console.log(`Retrying playback (${retryCount}/${maxRetries})...`); console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount); setTimeout(tryPlay, 1000 * retryCount);
} else { } else {
// 发送URL过期事件通知外部需要重新获取URL
this.emit('url_expired', this.currentTrack);
reject(new Error('音频播放失败,请尝试切换其他歌曲')); reject(new Error('音频播放失败,请尝试切换其他歌曲'));
} }
}, },
onload: () => { onload: async () => {
// 音频加载成功后更新媒体会话 // 音频加载成功后设置 EQ 和更新媒体会话
if (track && this.currentSound) { if (this.currentSound) {
this.updateMediaSessionMetadata(track); try {
this.updateMediaSessionPositionState(); console.log('audioService: 音频加载成功,设置 EQ');
this.emit('load'); await this.setupEQ(this.currentSound);
resolve(this.currentSound); this.updateMediaSessionMetadata(track);
this.updateMediaSessionPositionState();
this.emit('load');
// 此时音频已完全初始化,根据 isPlay 参数决定是否播放
console.log('audioService: 音频完全初始化isPlay =', isPlay);
if (isPlay) {
console.log('audioService: 开始播放');
this.currentSound.play();
}
resolve(this.currentSound);
} catch (error) {
console.error('设置 EQ 失败:', error);
// 即使 EQ 设置失败,也继续播放(如果需要)
if (isPlay) {
this.currentSound.play();
}
resolve(this.currentSound);
}
} }
} }
}); });
@@ -227,6 +526,11 @@ class AudioService {
stop() { stop() {
if (this.currentSound) { if (this.currentSound) {
try { try {
// 确保任何进行中的seek操作被取消
if (this.seekLock && this.seekDebounceTimer) {
clearTimeout(this.seekDebounceTimer);
this.seekLock = false;
}
this.currentSound.stop(); this.currentSound.stop();
this.currentSound.unload(); this.currentSound.unload();
} catch (error) { } catch (error) {
@@ -238,6 +542,7 @@ class AudioService {
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'; navigator.mediaSession.playbackState = 'none';
} }
this.disposeEQ();
} }
setVolume(volume: number) { setVolume(volume: number) {
@@ -249,14 +554,26 @@ class AudioService {
seek(time: number) { seek(time: number) {
if (this.currentSound) { if (this.currentSound) {
this.currentSound.seek(time); try {
this.updateMediaSessionPositionState(); // 直接执行seek操作避免任何过滤或判断
this.currentSound.seek(time);
// 触发seek事件
this.updateMediaSessionPositionState();
this.emit('seek', time);
} catch (error) {
console.error('Seek操作失败:', error);
}
} }
} }
pause() { pause() {
if (this.currentSound) { if (this.currentSound) {
try { try {
// 如果有进行中的seek操作等待其完成
if (this.seekLock && this.seekDebounceTimer) {
clearTimeout(this.seekDebounceTimer);
this.seekLock = false;
}
this.currentSound.pause(); this.currentSound.pause();
} catch (error) { } catch (error) {
console.error('Error pausing audio:', error); console.error('Error pausing audio:', error);
@@ -267,6 +584,14 @@ class AudioService {
clearAllListeners() { clearAllListeners() {
this.callbacks = {}; this.callbacks = {};
} }
public getCurrentPreset(): string | null {
return localStorage.getItem('currentPreset');
}
public setCurrentPreset(preset: string): void {
localStorage.setItem('currentPreset', preset);
}
} }
export const audioService = new AudioService(); export const audioService = new AudioService();

View File

@@ -0,0 +1,186 @@
import { Howl, Howler } from 'howler';
import Tuna from 'tunajs';
// 类型定义扩展
interface HowlSound {
_sounds: Array<{
_node: HTMLMediaElement & {
destination?: MediaElementAudioSourceNode;
};
}>;
}
export interface EQSettings {
[key: string]: number;
}
export class EQService {
private context: AudioContext | null = null;
private tuna: any = null;
private equalizer: any = null;
private source: MediaElementAudioSourceNode | null = null;
private gainNode: GainNode | null = null;
private bypass = false;
// 预设频率
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
// 默认EQ设置
private defaultEQSettings: EQSettings = Object.fromEntries(
this.frequencies.map((f) => [f.toString(), 0])
);
constructor() {
this.loadSavedSettings();
this.bypass = localStorage.getItem('eqBypass') === 'true';
this.initializeUserGestureHandler();
}
// 初始化用户手势处理
private initializeUserGestureHandler() {
const handler = async () => {
if (this.context?.state === 'suspended') {
await this.context.resume();
}
document.removeEventListener('click', handler);
};
document.addEventListener('click', handler);
}
// 初始化音频上下文
public async setupAudioContext(howl: Howl) {
try {
// 使用Howler的现有上下文
this.context = (Howler.ctx as AudioContext) || new AudioContext();
// 初始化Howler的音频系统如果需要
if (!Howler.ctx) {
Howler.ctx = this.context;
Howler.masterGain = this.context.createGain();
Howler.masterGain.connect(this.context.destination);
}
// 确保上下文处于运行状态
if (this.context.state === 'suspended') {
await this.context.resume();
}
const sound = (howl as unknown as HowlSound)._sounds[0];
if (!sound?._node) throw new Error('无法获取音频节点');
// 清理现有资源
await this.dispose();
// 创建新的处理链
this.tuna = new Tuna(this.context);
// 创建/复用源节点
if (!sound._node.destination) {
this.source = this.context.createMediaElementSource(sound._node);
sound._node.destination = this.source;
} else {
this.source = sound._node.destination;
}
// 创建效果节点
this.gainNode = this.context.createGain();
this.equalizer = new this.tuna.Equalizer({
frequencies: this.frequencies,
gains: this.frequencies.map((f) => this.getSavedGain(f.toString())),
bypass: this.bypass
});
// 连接节点链
this.source!.connect(this.equalizer.input).connect(this.gainNode).connect(Howler.masterGain);
// 恢复音量设置
const volume = localStorage.getItem('volume');
this.gainNode.gain.value = volume ? parseFloat(volume) : 1;
} catch (error) {
console.error('音频上下文初始化失败:', error);
await this.dispose();
throw error;
}
}
// EQ功能开关
public setEnabled(enabled: boolean) {
this.bypass = !enabled;
localStorage.setItem('eqBypass', JSON.stringify(this.bypass));
if (this.equalizer) this.equalizer.bypass = this.bypass;
}
public isEnabled(): boolean {
return !this.bypass;
}
// 调整频率增益
public setFrequencyGain(frequency: string, gain: number) {
const index = this.frequencies.findIndex((f) => f.toString() === frequency);
if (index !== -1 && this.equalizer) {
this.equalizer.setGain(index, gain);
this.saveSettings(frequency, gain);
}
}
// 重置EQ设置
public resetEQ() {
this.frequencies.forEach((f) => {
this.setFrequencyGain(f.toString(), 0);
});
localStorage.removeItem('eqSettings');
}
// 获取当前设置
public getAllSettings(): EQSettings {
return this.loadSavedSettings();
}
// 保存/加载设置
private saveSettings(frequency: string, gain: number) {
const settings = this.loadSavedSettings();
settings[frequency] = gain;
localStorage.setItem('eqSettings', JSON.stringify(settings));
}
private loadSavedSettings(): EQSettings {
const saved = localStorage.getItem('eqSettings');
return saved ? JSON.parse(saved) : { ...this.defaultEQSettings };
}
private getSavedGain(frequency: string): number {
return this.loadSavedSettings()[frequency] || 0;
}
// 清理资源
public async dispose() {
try {
[this.source, this.equalizer, this.gainNode].forEach((node) => {
if (node) {
node.disconnect();
// 特殊清理Tuna节点
if (node instanceof Tuna.Equalizer) node.destroy();
}
});
if (this.context && this.context !== Howler.ctx) {
await this.context.close();
}
this.context = null;
this.tuna = null;
this.source = null;
this.equalizer = null;
this.gainNode = null;
} catch (error) {
console.error('资源清理失败:', error);
}
}
}
export const eqService = new EQService();

View File

@@ -1,391 +1,20 @@
import { createStore } from 'vuex'; import { createPinia } from 'pinia';
import router from '@/router';
import setData from '@/../main/set.json'; // 创建 pinia 实例
import { logout } from '@/api/login'; const pinia = createPinia();
import { getLikedList, likeSong } from '@/api/music';
import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home';
import type { SongResult } from '@/type/music';
import { isElectron } from '@/utils';
import { applyTheme, getCurrentTheme, ThemeType } from '@/utils/theme';
// 默认设置 // 添加路由到 Pinia
const defaultSettings = setData; pinia.use(({ store }) => {
store.router = markRaw(router);
function isValidUrl(urlString: string): boolean {
try {
return Boolean(new URL(urlString));
} catch (e) {
return false;
}
}
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
try {
const item = localStorage.getItem(key);
if (!item) return defaultValue;
// 尝试解析 JSON
const parsedItem = JSON.parse(item);
// 对于音乐 URL检查是否是有效的 URL 格式或本地文件路径
if (key === 'currentPlayMusicUrl' && typeof parsedItem === 'string') {
if (!parsedItem.startsWith('local://') && !isValidUrl(parsedItem)) {
console.warn(`Invalid URL in localStorage for key ${key}, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
}
// 对于播放列表,检查是否是数组且每个项都有必要的字段
if (key === 'playList') {
if (!Array.isArray(parsedItem)) {
console.warn(`Invalid playList format in localStorage, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
// 检查每个歌曲对象是否有必要的字段
const isValid = parsedItem.every((item) => item && typeof item === 'object' && 'id' in item);
if (!isValid) {
console.warn(`Invalid song objects in playList, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
}
// 对于当前播放音乐,检查是否是对象且包含必要的字段
if (key === 'currentPlayMusic') {
if (!parsedItem || typeof parsedItem !== 'object' || !('id' in parsedItem)) {
console.warn(`Invalid currentPlayMusic format in localStorage, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
}
return parsedItem;
} catch (error) {
console.warn(`Error parsing localStorage item for key ${key}:`, error);
// 如果解析失败,删除可能损坏的数据
localStorage.removeItem(key);
return defaultValue;
}
}
export interface State {
menus: any[];
play: boolean;
isPlay: boolean;
playMusic: SongResult;
playMusicUrl: string;
user: any;
playList: SongResult[];
playListIndex: number;
setData: typeof defaultSettings;
lyric: any;
isMobile: boolean;
searchValue: string;
searchType: number;
favoriteList: number[];
playMode: number;
theme: ThemeType;
musicFull: boolean;
showUpdateModal: boolean;
showArtistDrawer: boolean;
currentArtistId: number | null;
systemFonts: { label: string; value: string }[];
showDownloadDrawer: boolean;
}
const state: State = {
menus: homeRouter,
play: false,
isPlay: false,
playMusic: getLocalStorageItem('currentPlayMusic', {} as SongResult),
playMusicUrl: getLocalStorageItem('currentPlayMusicUrl', ''),
user: getLocalStorageItem('user', null),
playList: getLocalStorageItem('playList', []),
playListIndex: getLocalStorageItem('playListIndex', 0),
setData: defaultSettings,
lyric: {},
isMobile: false,
searchValue: '',
searchType: 1,
favoriteList: getLocalStorageItem('favoriteList', []),
playMode: getLocalStorageItem('playMode', 0),
theme: getCurrentTheme(),
musicFull: false,
showUpdateModal: false,
showArtistDrawer: false,
currentArtistId: null,
systemFonts: [{ label: '系统默认', value: 'system-ui' }],
showDownloadDrawer: false
};
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
const mutations = {
setMenus(state: State, menus: any[]) {
state.menus = menus;
},
async setPlay(state: State, playMusic: SongResult) {
await handlePlayMusic(state, playMusic);
localStorage.setItem('currentPlayMusic', JSON.stringify(state.playMusic));
localStorage.setItem('currentPlayMusicUrl', state.playMusicUrl);
},
setIsPlay(state: State, isPlay: boolean) {
state.isPlay = isPlay;
localStorage.setItem('isPlaying', isPlay.toString());
},
async setPlayMusic(state: State, play: boolean) {
state.play = play;
localStorage.setItem('isPlaying', play.toString());
},
setMusicFull(state: State, musicFull: boolean) {
state.musicFull = musicFull;
},
setPlayList(state: State, playList: SongResult[]) {
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
state.playList = playList;
localStorage.setItem('playList', JSON.stringify(playList));
localStorage.setItem('playListIndex', state.playListIndex.toString());
},
async nextPlay(state: State) {
await nextPlay(state);
},
async prevPlay(state: State) {
await prevPlay(state);
},
// 添加到下一首播放
addToNextPlay(state: State, song: SongResult) {
const playList = [...state.playList];
const currentIndex = state.playListIndex;
// 检查歌曲是否已经在播放列表中
const existingIndex = playList.findIndex((item) => item.id === song.id);
if (existingIndex !== -1) {
// 如果歌曲已经在列表中,将其移动到当前播放歌曲的下一个位置
playList.splice(existingIndex, 1);
}
// 在当前播放歌曲后插入新歌曲
playList.splice(currentIndex + 1, 0, song);
// 更新播放列表
state.playList = playList;
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
localStorage.setItem('playList', JSON.stringify(playList));
localStorage.setItem('playListIndex', state.playListIndex.toString());
},
setSetData(state: State, setData: any) {
state.setData = setData;
if (isElectron) {
// (window as any).electron.ipcRenderer.setStoreValue(
// 'set',
// JSON.parse(JSON.stringify(setData))
// );
window.electron.ipcRenderer.send(
'set-store-value',
'set',
JSON.parse(JSON.stringify(setData))
);
} else {
localStorage.setItem('appSettings', JSON.stringify(setData));
}
},
async addToFavorite(state: State, songId: number) {
// 先添加到本地
if (!state.favoriteList.includes(songId)) {
state.favoriteList = [songId, ...state.favoriteList];
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
}
// 如果用户已登录,尝试同步到服务器
if (state.user && localStorage.getItem('token')) {
try {
await likeSong(songId, true);
} catch (error) {
console.error('同步收藏到服务器失败,但已保存在本地:', error);
}
}
},
async removeFromFavorite(state: State, songId: number) {
// 先从本地移除
state.favoriteList = state.favoriteList.filter((id) => id !== songId);
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
// 如果用户已登录,尝试同步到服务器
if (state.user && localStorage.getItem('token')) {
try {
await likeSong(songId, false);
} catch (error) {
console.error('同步取消收藏到服务器失败,但已在本地移除:', error);
}
}
},
togglePlayMode(state: State) {
state.playMode = (state.playMode + 1) % 3;
localStorage.setItem('playMode', JSON.stringify(state.playMode));
},
toggleTheme(state: State) {
state.theme = state.theme === 'dark' ? 'light' : 'dark';
applyTheme(state.theme);
},
setShowUpdateModal(state, value) {
state.showUpdateModal = value;
},
logout(state: State) {
logout().then(() => {
state.user = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
});
},
setShowArtistDrawer(state, show: boolean) {
state.showArtistDrawer = show;
if (!show) {
state.currentArtistId = null;
}
},
setCurrentArtistId(state, id: number) {
state.currentArtistId = id;
},
setSystemFonts(state, fonts: string[]) {
state.systemFonts = [
{ label: '系统默认', value: 'system-ui' },
...fonts.map((font) => ({
label: font,
value: font
}))
];
},
setShowDownloadDrawer(state: State, show: boolean) {
state.showDownloadDrawer = show;
},
setLanguage(state: State, language: string) {
state.setData.language = language;
if (isElectron) {
window.electron.ipcRenderer.send('set-store-value', 'set.language', language);
} else {
localStorage.setItem('appSettings', JSON.stringify(state.setData));
}
},
getLanguage(state: State) {
return state.setData.language;
}
};
const actions = {
initializeSettings({ commit }: { commit: any }) {
if (isElectron) {
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
commit('setSetData', {
...defaultSettings,
...setData
});
} else {
const savedSettings = localStorage.getItem('appSettings');
if (savedSettings) {
commit('setSetData', {
...defaultSettings,
...JSON.parse(savedSettings)
});
} else {
commit('setSetData', defaultSettings);
}
}
},
initializeTheme({ state }: { state: State }) {
applyTheme(state.theme);
},
async initializeFavoriteList({ state }: { state: State }) {
// 先获取本地收藏列表
const localFavoriteList = localStorage.getItem('favoriteList');
const localList: number[] = localFavoriteList ? JSON.parse(localFavoriteList) : [];
// 如果用户已登录,尝试获取服务器收藏列表并合并
if (state.user && state.user.userId) {
try {
const res = await getLikedList(state.user.userId);
if (res.data?.ids) {
// 合并本地和服务器的收藏列表,去重
const serverList = res.data.ids.reverse();
const mergedList = Array.from(new Set([...localList, ...serverList]));
state.favoriteList = mergedList;
} else {
state.favoriteList = localList;
}
} catch (error) {
console.error('获取服务器收藏列表失败,使用本地数据:', error);
state.favoriteList = localList;
}
} else {
state.favoriteList = localList;
}
// 更新本地存储
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
},
showArtist({ commit }, id: number) {
commit('setCurrentArtistId', id);
},
async initializeSystemFonts({ commit, state }) {
// 如果已经有字体列表(不只是默认字体),则不重复获取
if (state.systemFonts.length > 1) return;
try {
const fonts = await window.api.invoke('get-system-fonts');
commit('setSystemFonts', fonts);
} catch (error) {
console.error('获取系统字体失败:', error);
}
},
async initializePlayState({ state, commit }: { state: State; commit: any }) {
const savedPlayList = getLocalStorageItem('playList', []);
const savedPlayMusic = getLocalStorageItem('currentPlayMusic', null);
if (savedPlayList.length > 0) {
commit('setPlayList', savedPlayList);
}
if (savedPlayMusic && Object.keys(savedPlayMusic).length > 0) {
// 不直接使用保存的 URL而是重新获取
try {
// 使用 handlePlayMusic 来重新获取音乐 URL
// 根据自动播放设置决定是否恢复播放状态
const shouldAutoPlay = state.setData.autoPlay;
if (shouldAutoPlay) {
await handlePlayMusic(state, savedPlayMusic);
}
state.play = shouldAutoPlay;
state.isPlay = true;
} catch (error) {
console.error('重新获取音乐链接失败:', error);
// 清除无效的播放状态
state.play = false;
state.isPlay = false;
state.playMusic = {} as SongResult;
state.playMusicUrl = '';
localStorage.removeItem('currentPlayMusic');
localStorage.removeItem('currentPlayMusicUrl');
localStorage.removeItem('isPlaying');
}
}
},
initializeLanguage({ state }: { state: State }) {
state.setData.language = getLocalStorageItem('appSettings', { language: 'zh-CN' }).language;
if (isElectron) {
window.electron.ipcRenderer.send('set-store-value', 'set.language', state.setData.language);
} else {
localStorage.setItem('appSettings', JSON.stringify(state.setData));
}
}
};
const store = createStore({
state,
mutations,
actions
}); });
export default store; // 导出所有 store
export * from './modules/lyric';
export * from './modules/menu';
export * from './modules/player';
export * from './modules/search';
export * from './modules/settings';
export * from './modules/user';
export default pinia;

View File

@@ -0,0 +1,15 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useLyricStore = defineStore('lyric', () => {
const lyric = ref({});
const setLyric = (newLyric: any) => {
lyric.value = newLyric;
};
return {
lyric,
setLyric
};
});

View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import homeRouter from '@/router/home';
export const useMenuStore = defineStore('menu', () => {
const menus = ref(homeRouter);
const setMenus = (newMenus: any[]) => {
menus.value = newMenus;
};
return {
menus,
setMenus
};
});

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