Compare commits

..

120 Commits

Author SHA1 Message Date
alger
d7169efcf5 feat: 更新版本至 4.8.0 2025-06-20 23:32:29 +08:00
alger
a7cce28050 feat: 在构建配置中添加对 Linux RPM 包的支持 2025-06-20 23:32:08 +08:00
alger
e99385c512 feat: 添加版本号比较函数,优化更新检查逻辑 2025-06-20 23:17:16 +08:00
alger
72c11eef6c 🎨 style: 更新歌词区域按钮样式,修复歌词时间调整按钮不显示问题 2025-06-20 23:06:57 +08:00
alger
facb03d3e1 feat: 添加 linux rpm 构建目标支持 2025-06-20 22:27:48 +08:00
alger
902feff2fb 🔧 chore: 降级 electron 版本至 35.2.0 2025-06-20 22:23:56 +08:00
alger
426cafd54c feat: 更新 AppMenu 组件,增加移动端适配,优化工具提示的禁用条件 2025-06-20 21:15:13 +08:00
alger
e4ed089085 chore: 更新 .gitignore 文件,添加 android/app/release 目录以排除构建文件 2025-06-20 21:08:43 +08:00
alger
7f6e11e508 feat: 移除代理配置,简化构建命令,更新环境变量设置 2025-06-20 21:08:26 +08:00
alger
81b61e4575 feat: 增强移动端播放页面效果,优化横屏效果,添加播放列表功能 2025-06-20 21:07:17 +08:00
Alger
66aa6b7aff Merge pull request #326 from hecai84/main
添加任务栏缩略图控制按钮
2025-06-20 17:49:09 +08:00
hecai
58ab9906cc 启动默认显示缩略图控制按钮。
(cherry picked from commit 1f438e391ab7bb37e38a31ec571724d33f35310b)
2025-06-19 09:26:28 +08:00
hecai
9bec67ebf9 添加任务栏缩略图控制按钮
(cherry picked from commit e0ddb7cb4821b5b48ed3ffb99a44c00c8cb4d46e)
2025-06-18 15:52:37 +08:00
Alger
386c9c7067 Merge pull request #320 from Hellodwadawd12312312/feature
修复音频初始化音量问题,完善翻译
2025-06-17 11:34:26 +08:00
Felix
b95f5e1b2f small fix 2025-06-16 08:44:50 +02:00
Qumo
090103bf1a Update audioService.ts 2025-06-16 07:47:40 +02:00
Qumo
5ee60d751e Update audioService.ts 2025-06-16 07:39:35 +02:00
Qumo
a85b5ff58b Merge branch 'algerkong:main' into feature 2025-06-16 07:28:28 +02:00
alger
58922dc91b feat: 更新类型定义文件路径,移除旧的 auto-imports 和 components 定义文件 2025-06-12 22:58:01 +08:00
alger
0d89e15e01 feat: 添加横屏模式支持,优化歌词和播放控制布局 2025-06-12 22:57:24 +08:00
algerkong
b9c38d257a feat: 重构播放控制逻辑,添加播放进度恢复功能并清理无用代码 2025-06-11 20:12:52 +08:00
algerkong
d227ac8b34 feat: 优化播放栏无法控制隐藏问题 2025-06-11 20:10:33 +08:00
algerkong
f9d85f11ad feat: 去除无用代码 2025-06-11 20:05:43 +08:00
Felix
49595ef57f more translation 2025-06-10 13:31:33 +02:00
Alger
cceb1de3fb Merge pull request #304 from Hellodwadawd12312312/feature
添加搜索类型的翻译
2025-06-09 10:41:25 +08:00
Felix
f59b5d5602 translation 2025-06-08 14:49:24 +02:00
alger
fabcf289dc feat: 优化歌曲列表组件布局,添加底部间距以提升视觉效果 2025-06-07 22:47:34 +08:00
alger
934580552d feat: 优化歌词组件和移动端界面设计 2025-06-07 22:30:39 +08:00
alger
6f1909a028 🐞 fix: 修复刷新后第一次播放出现的无法播放问题 2025-06-07 22:10:55 +08:00
alger
c5d71cf53c feat: 添加 Vite 配置文件并更新 package.json,支持开发模式下的 Web 预览 2025-06-07 21:34:19 +08:00
alger
21b2fc08be feat: 优化移动端界面设计以及歌词界面设计 添加播放模式选择 2025-06-07 10:48:54 +08:00
alger
155bdf246c feat: 优化提示组件,支持位置和图标显示选项 2025-06-06 23:37:24 +08:00
alger
e46df8a04e feat: 优化窗口大小管理功能,优化窗口状态保存与恢复逻辑
- 引入窗口大小管理器,初始化窗口大小管理
- 优化窗口状态保存与恢复,确保在迷你模式下正确应用窗口大小
- 移除不必要的代码,简化窗口管理逻辑
- 更新窗口创建逻辑,确保窗口大小和位置的正确性
2025-06-06 23:37:06 +08:00
alger
b203077cad feat: 添加下载设置功能,支持自定义文件名格式和下载路径配置
- 新增下载设置抽屉,允许用户设置下载路径和文件名格式
- 支持多种文件名格式预设和自定义格式
- 实现下载项的显示名称格式化
- 优化下载管理逻辑,避免重复通知
2025-06-05 23:02:41 +08:00
alger
a08fbf1ec8 style: 优化播放列表抽屉样式,调整标题和按钮颜色以提升可读性 2025-06-05 22:19:55 +08:00
alger
edd393c8ac feat: 新增歌单导入功能
添加歌单导入功能,支持通过链接、文本和元数据三种方式导入歌单
- 实现链接导入、文本导入和元数据导入三种方式
- 添加导入状态检查和显示功能
2025-06-04 22:53:49 +08:00
alger
8988cdb082 feat: 在音乐列表页面中添加 Electron 环境判断,优化多选下载操作的显示逻辑 2025-06-04 22:46:35 +08:00
alger
1221101821 feat: 列表添加多选下载功能,支持批量选择和下载音乐 2025-06-04 20:19:44 +08:00
alger
3ac3159058 feat: 添加下载管理页面, 引入文件类型检测库以支持多种音频格式 2025-06-03 22:35:04 +08:00
Alger
bfaa06b0d5 Merge pull request #281 from algerkong/feat/window-auto-size
feat: 添加主窗口自适应大小功能,页面缩放功能,支持缩放因子的调整和重置,并在搜索栏中提供缩放控制
2025-05-28 22:12:29 +08:00
alger
61700473b9 feat: 添加主窗口自适应大小功能,页面缩放功能,支持缩放因子的调整和重置,并在搜索栏中提供缩放控制 2025-05-28 22:08:17 +08:00
alger
bf4bcfcde6 chore: 更新 .gitignore 文件 2025-05-28 22:06:13 +08:00
Alger
475d7d2595 Merge pull request #280 from algerkong/fix/day-list
feat: 重构每日推荐数据加载逻辑,提取为独立函数并优化用户状态判断
2025-05-28 22:04:14 +08:00
alger
5e704a1f3c feat: 重构每日推荐数据加载逻辑,提取为独立函数并优化用户状态判断 2025-05-28 22:02:59 +08:00
Alger
6faab820da Merge pull request #279 from algerkong/fix/volume-color
feat: 添加mini播放栏鼠标滚轮调整音量 并优化音量滑块数字不展示问题
2025-05-28 22:01:07 +08:00
alger
5c7278544a feat: 添加mini播放栏鼠标滚轮调整音量 并优化音量滑块数字不展示问题 2025-05-28 21:58:32 +08:00
Alger
4c24bb9257 feat: Update README.md 2025-05-27 15:18:42 +08:00
alger
c975344dd0 feat: 添加歌词矫正功能,支持增加和减少矫正时间 2025-05-26 22:58:42 +08:00
Alger
08a14359a5 Merge pull request #268 from algerkong/fix/modal-zindex
fix: 修复更多设置弹窗被歌词窗口遮挡问题 并优化为互斥弹窗, 优化样式
2025-05-25 19:28:54 +08:00
alger
62e5166953 fix: 修复更多设置弹窗被歌词窗口遮挡问题 并优化为互斥弹窗, 优化样式 2025-05-25 19:26:24 +08:00
alger
7685ad3939 feat: 在配置中添加 publicDir 选项以指定资源目录 2025-05-25 11:35:16 +08:00
alger
d7c06586d6 feat: 在 EQControl 组件标题中添加桌面版可用提示标签 2025-05-25 11:07:06 +08:00
alger
5070a085e9 feat: 优化收藏和历史列表组件,添加加载状态管理和动画效果 2025-05-24 19:23:38 +08:00
alger
e5adb8aa72 fix: 修复设置页面动画速度滑块样式和文本错误 2025-05-24 19:23:13 +08:00
alger
d08439c99e feat:v4.7.1 2025-05-24 10:11:50 +08:00
alger
dee4515cb3 fix: 修复切换收藏和不喜欢状态时事件处理逻辑 2025-05-24 10:11:29 +08:00
alger
53bc1774ff fix: 修复下载请求中的音乐 URL 处理逻辑 2025-05-24 10:02:15 +08:00
alger
589540be29 feat: 优化歌曲组件事件处理,使用展开运算符简化事件传递 2025-05-23 22:43:01 +08:00
alger
2bcae85419 feat: 更新应用ico图标 2025-05-23 21:57:34 +08:00
alger
6e68927eec feat: 更新应用图标 2025-05-23 21:46:18 +08:00
alger
a4eea18fa5 feat:更新 4.7.0 2025-05-23 21:33:58 +08:00
alger
fe5b1d5de8 feat: 添加主窗口失去焦点时禁用最大化功能 2025-05-23 21:29:43 +08:00
alger
c8e6db11c9 feat: 更新应用图标,替换为新版本的图标文件 2025-05-23 20:43:13 +08:00
alger
5bef0e44a0 feat: 为歌曲下拉菜单添加圆角样式,优化歌曲预览布局 2025-05-23 20:08:57 +08:00
alger
d56a25eb3c feat: 在用户歌单中添加“我创建的”标签,优化获取用户歌单的逻辑 2025-05-23 20:08:40 +08:00
alger
a449b74ef2 feat: 添加 husky 预提交和预推送钩子,运行类型检查以确保代码质量 2025-05-23 20:07:29 +08:00
alger
ad7b504eef 🦄 refactor: 重构歌曲组件,添加基础组件和多种样式,优化播放列表抽屉功能 2025-05-23 19:39:46 +08:00
alger
6048e243c7 feat: 在歌手详情页添加歌曲操作工具栏,支持播放全部、添加到播放列表和搜索功能,布局切换功能 2025-05-23 19:39:32 +08:00
alger
0c74291a34 feat: 添加所有用户的关注和粉丝列表点击 优化播放排行获取和无权限展示 2025-05-23 19:39:26 +08:00
alger
7fa0fa5221 feat: 添加 macOS 下点击 Dock 图标激活主窗口的功能 2025-05-23 19:39:21 +08:00
alger
95af222da7 feat: 添加鼠标滚轮调整音量功能,并显示音量百分比 2025-05-23 19:39:16 +08:00
alger
9eefe62fba refactor: 移除未使用的导入和格式问题 2025-05-22 22:21:53 +08:00
Alger
b621995e24 Merge pull request #256 from algerkong/fix/downloadurl
fix: 修复并优化下载功能,重构添加 hook
2025-05-22 22:15:58 +08:00
Alger
91f97ff76b Merge branch 'main' into fix/downloadurl 2025-05-22 22:15:51 +08:00
Alger
cce2b96d29 Merge pull request #255 from algerkong/feat/nolike
feat: 歌曲右键 添加不喜欢功能以过滤每日推荐歌曲
2025-05-22 22:12:46 +08:00
alger
a0935c74fe feat: 歌曲右键 添加不喜欢功能以过滤每日推荐歌曲 2025-05-22 22:11:10 +08:00
alger
df5ecb6eb5 feat: 添加 tab监听以刷新下载列表 2025-05-22 20:59:06 +08:00
alger
ca51020602 refactor: 将下载逻辑提取到useDownload hook中 2025-05-22 20:58:47 +08:00
alger
258828ffbd feat: 双击播放由双击歌曲名改为双击整个组件都可以 2025-05-22 20:12:55 +08:00
alger
91b1ff7df9 fix: 修复播放音乐时URL未正确更新的问题 2025-05-22 20:12:47 +08:00
alger
8cc617a5f6 docs: 更新文档图片和README内容 2025-05-20 22:45:45 +08:00
alger
170ac45115 style: 顶部定时 添加悬停缩放效果和光标指针样式 2025-05-20 21:22:41 +08:00
alger
2dd45351e5 feat: 添加定时器过期检查功能 优化顶部定时点击 2025-05-20 20:57:16 +08:00
alger
f5f0dbb222 feat: 优化播放栏,整合高级控制菜单,将定时、均衡器、速度控制改为更多设置按钮显示, 添加定时关闭顶部显示功能 2025-05-19 23:13:06 +08:00
Alger
7fca6db2a3 Merge pull request #241 from Java-wyx/speed-up
feat: 添加播放速度控制功能
2025-05-19 19:17:26 +08:00
Java-wyx
655473699a feat: 添加播放速度控制功能
现有播放器不支持改变播放速度,用户无法实现 0.5×、1.5×、2.0× 等快进/慢放需求。为了提升可用性和灵活性,决定在播放栏增加速度选择菜单,并支持 Media Session API 同步速率
2025-05-19 17:59:20 +08:00
Alger
4d371df510 Merge pull request #236 from algerkong/dev
feat: 优化页面效果 音源解析优化
2025-05-18 13:12:18 +08:00
alger
a21521cc6f docs: 更新预览地址 2025-05-18 13:09:49 +08:00
alger
01a3a7a501 feat: 添加音乐平台链接,优化移动端样式 2025-05-18 12:45:19 +08:00
alger
e47c84e5eb feat:优化B站音频解析功能 2025-05-18 12:44:23 +08:00
alger
54cbb84e6e style(player): 统一音源选项的标签格式 2025-05-18 12:43:27 +08:00
alger
f68f49973a perf(请求): 增加请求超时时间至15000毫秒 2025-05-18 12:43:09 +08:00
alger
e9fe9000f6 🐞 fix(player): 修复播放状态判断逻辑
修复在播放相同ID但不同URL的音乐时,播放状态判断逻辑错误的问题。现在只有当音乐ID和URL都相同时才会切换播放/暂停状态。
2025-05-18 12:42:15 +08:00
alger
6d4e6ef214 feat: 移除不必要的Content-Security-Policy 2025-05-18 10:57:19 +08:00
alger
2379b2c9cc feat: 点击下一首自动播放,优化 https问题 2025-05-17 20:10:07 +08:00
Alger
8c6b69e762 Merge pull request #234 from algerkong/feat/control-status-bar
 feat: 添加mac状态栏播放按键控制功能开关
2025-05-17 14:47:14 +08:00
alger
ae1a7c963f 🌈 style: 移除未使用的SleepTimerPopover组件 2025-05-17 14:46:35 +08:00
alger
2476fbd6e3 feat: 添加mac状态栏播放按键控制功能开关 2025-05-17 14:45:39 +08:00
alger
f7951ec22f feat: 移动端去除定时关闭 2025-05-17 14:11:10 +08:00
alger
33a1057de9 feat: 修改移动端展示菜单 2025-05-17 13:53:52 +08:00
alger
2e96161bd0 feat: 修改播放列表展示形式,优化播放逻辑,添加清空播放列表功能 2025-05-17 13:27:50 +08:00
alger
56b3ecfd25 🔧 chore: 优化网页端下载程序功能 2025-05-15 22:06:12 +08:00
alger
54d66d05f4 🔧 chore: 更新 MusicListPage 组件,添加移动端布局判断,优化紧凑布局逻辑 2025-05-15 21:33:44 +08:00
alger
b32408b44e feat: 歌单列表相添加布局切换、播放全部、收藏、添加到播放列表 2025-05-15 21:20:01 +08:00
alger
3c792ce3cc 🔧 chore: 调整 PlaylistDrawer 组件的样式,增加内边距 2025-05-15 21:17:14 +08:00
alger
5084da333f feat: 在应用菜单中添加工具提示功能 2025-05-15 21:16:48 +08:00
alger
a8010c8ca7 feat: 添加排行榜页面 2025-05-15 21:16:33 +08:00
algerkong
e1ddffc8ae feat: 更新 README 2025-05-15 15:11:46 +08:00
alger
69b1e541c6 feat: 在收藏列表中添加歌曲点赞功能 2025-05-15 00:08:27 +08:00
alger
35b84f3e6a 🔧 chore: 更新收藏列表中活动项的背景颜色和文本颜色 2025-05-14 21:46:15 +08:00
alger
28b9fd5475 feat: 更新 README 和国际化文件,添加QQ 频道信息 2025-05-14 21:41:38 +08:00
Alger
dc70fde9e4 Merge pull request #227 from algerkong/fix/mini-bar-volume
🔧 chore:  mini播放栏不再显示音量调节
2025-05-14 21:26:57 +08:00
alger
278db37a88 🔧 chore: mini播放栏不再显示音量调节 2025-05-14 21:26:23 +08:00
alger
2803d40dd1 feat: 收藏列表添加升序降序排列 2025-05-14 21:18:42 +08:00
alger
54f82d384e feat: 退出登录 刷新页面 2025-05-14 21:18:11 +08:00
alger
7d1ffa603c 🔧 chore: 更新获取最新发布信息的 API URL 2025-05-13 22:38:03 +08:00
alger
49f7728eac feat: 更新至 v4.6.0 2025-05-11 21:51:42 +08:00
alger
890c0c86c1 🔧 chore: 尝试解决 windows 桌面歌词窗口标题出现的问题 2025-05-11 21:45:45 +08:00
alger
15f4ea4708 🔧 chore: 移除 MvPlayer 组件中未使用的 playerStore 引用,简化代码结构 2025-05-11 15:40:30 +08:00
Alger
dbb3fbcc09 Merge pull request #216 from algerkong/feat/search-music-play
 feat: 搜索列表添加下一首播放功能,修改播放逻辑搜索的歌曲点击播放不重新覆盖播放列表, 添加全部播放功能
2025-05-11 15:38:35 +08:00
125 changed files with 11511 additions and 3760 deletions

View File

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

View File

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

View File

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

View File

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

11
.gitignore vendored
View File

@@ -26,4 +26,13 @@ out
.github/deploy_keys
resources/android/**/*
resources/android/**/*
android/app/release
.cursor
.auto-imports.d.ts
.components.d.ts
src/renderer/auto-imports.d.ts
src/renderer/components.d.ts

2
.husky/pre-commit Normal file
View File

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

2
.husky/pre-push Executable file
View File

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

View File

@@ -1,25 +1,62 @@
# 更新日志
## v4.5.0
## 更新时间 2025 年 4 月 29 日 23:50
## v4.8.0
> 如果更新遇到问题,请前往 <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" style="color: red; font-weight: bold;">赏你</a>
> QQ群 976962720
> 帮我点个 star <a href="https://github.com/algerkong/AlgerMusicPlayer" target="_blank">github star</a>
> 微信公众号 微信搜索 <span style="font-weight: bold;">AlgerMusic</span>
> QQ频道 AlgerMusic <a href="https://pd.qq.com/s/cs056n33q?b=5" target="_blank">加入频道</a>
### ✨ 新功能
- 添加远程控制功能,支持使用局域网链接地址远程控制音乐播放操作(默认关闭需要在设置中开启) ([c82ffd0](https://github.com/algerkong/AlgerMusicPlayer/commit/c82ffd0))#169
- 更新歌手数据加载逻辑,首页添加周杰伦歌手信息常驻 ([3069514](https://github.com/algerkong/AlgerMusicPlayer/commit/3069514))
- 增强移动端播放页面效果,添加播放模式选择,添加横屏模式,添加播放列表功能 ([81b61e4](https://github.com/algerkong/AlgerMusicPlayer/commit/81b61e4))([0d89e15](https://github.com/algerkong/AlgerMusicPlayer/commit/0d89e15))([9345805](https://github.com/algerkong/AlgerMusicPlayer/commit/9345805))
- 优化移动端界面动画效果,播放栏,返回效果等一系列功能
- 添加下载管理页面, 引入文件类型检测库以支持多种音频格式,支持自定义文件名格式和下载路径配置 ([3ac3159](https://github.com/algerkong/AlgerMusicPlayer/commit/3ac3159)),([b203077](https://github.com/algerkong/AlgerMusicPlayer/commit/b203077))
- 新增歌单导入功能 ([edd393c](https://github.com/algerkong/AlgerMusicPlayer/commit/edd393c))
- 列表添加多选下载功能,支持批量选择和下载音乐 ([1221101](https://github.com/algerkong/AlgerMusicPlayer/commit/1221101)),([8988cdb](https://github.com/algerkong/AlgerMusicPlayer/commit/8988cdb))([21b2fc0](https://github.com/algerkong/AlgerMusicPlayer/commit/21b2fc0))
- Windows添加任务栏缩略图播放控制按钮 ([9bec67e](https://github.com/algerkong/AlgerMusicPlayer/commit/9bec67e))([58ab990](https://github.com/algerkong/AlgerMusicPlayer/commit/58ab990)) 感谢[HE Cai](https://github.com/hecai84)的pr
- 添加主窗口自适应大小功能,页面缩放功能,支持缩放因子的调整和重置 ([6170047](https://github.com/algerkong/AlgerMusicPlayer/commit/6170047)), ([e46df8a](https://github.com/algerkong/AlgerMusicPlayer/commit/e46df8a))
- 添加歌词时间矫正功能,支持增加和减少矫正时间 ([c975344](https://github.com/algerkong/AlgerMusicPlayer/commit/c975344))
### 🐛 Bug 修复
- 修复音乐播放重复声音的问题,添加锁机制,添加防抖机制,优化音频服务和快捷键处理逻辑 ([159dd03](https://github.com/algerkong/AlgerMusicPlayer/commit/159dd03)) (#173)
- 修复歌手页面数据加载不全问题 ([57424f9](https://github.com/algerkong/AlgerMusicPlayer/commit/57424f9)) (#165)
- 修复音频初始化音量问题,完善翻译 ([#320](https://github.com/algerkong/AlgerMusicPlayer/pull/320)) 感谢[Qumo](https://github.com/Hellodwadawd12312312)的pr
- 重构每日推荐数据加载逻辑,提取为独立函数并优化用户状态判断 ([5e704a1](https://github.com/algerkong/AlgerMusicPlayer/commit/5e704a1))
- 修复刷新后第一次播放出现的无法播放问题 ([6f1909a](https://github.com/algerkong/AlgerMusicPlayer/commit/6f1909a))
- 修复更多设置弹窗被歌词窗口遮挡问题,并优化为互斥弹窗,优化样式 ([62e5166](https://github.com/algerkong/AlgerMusicPlayer/commit/62e5166))
- 修复设置页面动画速度滑块样式和文本错误 ([e5adb8a](https://github.com/algerkong/AlgerMusicPlayer/commit/e5adb8a))
- 修复音频服务相关问题 ([090103b](https://github.com/algerkong/AlgerMusicPlayer/commit/090103b)),([5ee60d7](https://github.com/algerkong/AlgerMusicPlayer/commit/5ee60d7))
- 修复播放栏无法控制隐藏问题 ([d227ac8](https://github.com/algerkong/AlgerMusicPlayer/commit/d227ac8))
### 🎨 优化
- 优化应用图标,更新应用图标资源 ([0128662](https://github.com/algerkong/AlgerMusicPlayer/commit/0128662))
- 优化设置页面,拆分组件 ([c98fa20](https://github.com/algerkong/AlgerMusicPlayer/commit/c98fa20))
- 优化设置模块,合并默认设置与存储设置,初始化时读取设置 ([0a22c7b](https://github.com/algerkong/AlgerMusicPlayer/commit/0a22c7b))
- 优化歌曲列表组件布局([fabcf28](https://github.com/algerkong/AlgerMusicPlayer/commit/fabcf28))
- 重构播放控制逻辑,添加播放进度恢复功能并清理无用代码 ([b9c38d2](https://github.com/algerkong/AlgerMusicPlayer/commit/b9c38d2))
- 优化提示组件,支持位置和图标显示选项 ([155bdf2](https://github.com/algerkong/AlgerMusicPlayer/commit/155bdf2))
- 添加mini播放栏鼠标滚轮调整音量并优化音量滑块数字不展示问题 ([5c72785](https://github.com/algerkong/AlgerMusicPlayer/commit/5c72785))
- 优化收藏和历史列表组件,添加加载状态管理和动画效果 ([5070a08](https://github.com/algerkong/AlgerMusicPlayer/commit/5070a08))
- 翻译优化
- 代码优化
## 赞赏支持☕️
[赞赏列表](http://donate.alger.fun/)
<table>
<tr>
<th style="text-align:center">微信赞赏</th>
<th style="width:100px"></th>
<th style="text-align:center">支付宝赞赏</th>
</tr>
<tr>
<td align="center">
<img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width="200"><br>
<h6>☕️喝点咖啡继续干</h6>
</td>
<td></td>
<td align="center">
<img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Alipay QRcode" width="200"><br>
<h6>🍔来个汉堡</h6>
</td>
</tr>
</table>

192
DEV.md Normal file
View File

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

View File

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

90
auto-imports.d.ts vendored
View File

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

33
components.d.ts vendored
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 406 KiB

BIN
docs/image5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
docs/image6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

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

View File

@@ -1,11 +1,12 @@
{
"name": "AlgerMusicPlayer",
"version": "4.5.0",
"version": "4.8.0",
"description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>",
"main": "./out/main/index.js",
"homepage": "https://github.com/algerkong/AlgerMusicPlayer",
"scripts": {
"prepare": "husky",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -13,7 +14,8 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"dev:web": "vite dev",
"build": "electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
@@ -26,9 +28,13 @@
"@unblockneteasemusic/server": "^0.27.8-patch.1",
"cors": "^2.8.5",
"electron-store": "^8.1.0",
"express": "^4.18.2",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"express": "^4.18.2",
"file-type": "^21.0.0",
"font-list": "^1.5.1",
"husky": "^9.1.7",
"music-metadata": "^11.2.3",
"netease-cloud-music-api-alger": "^4.26.1",
"node-id3": "^0.2.9",
"node-machine-id": "^1.1.12",
@@ -56,7 +62,7 @@
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
"cross-env": "^7.0.3",
"electron": "^36.2.0",
"electron": "^35.2.0",
"electron-builder": "^25.1.8",
"electron-vite": "^3.1.0",
"eslint": "^9.0.0",
@@ -103,7 +109,9 @@
{
"from": "resources/html",
"to": "html",
"filter": ["**/*"]
"filter": [
"**/*"
]
}
],
"mac": {
@@ -160,6 +168,13 @@
"x64",
"arm64"
]
},
{
"target": "rpm",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -27,6 +27,9 @@ export default {
refresh: 'Refresh',
retry: 'Retry',
reset: 'Reset',
back: 'Back',
copySuccess: 'Copied to clipboard',
copyFailed: 'Copy failed',
validation: {
required: 'This field is required',
invalidInput: 'Invalid input',

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ export default {
mute: 'Mute',
unmute: 'Unmute',
songNum: 'Song Number: {num}',
addCorrection: 'Add {num} seconds',
subtractCorrection: 'Subtract {num} seconds',
playFailed: 'Play Failed, Play Next Song',
playMode: {
sequence: 'Sequence',
@@ -58,7 +60,9 @@ export default {
next: 'Next',
volume: 'Volume',
favorite: 'Favorite {name}',
unFavorite: 'Unfavorite {name}'
unFavorite: 'Unfavorite {name}',
playbackSpeed: 'Playback Speed',
advancedControls: 'Advanced Controls',
},
eq: {
title: 'Equalizer',
@@ -105,5 +109,13 @@ export default {
playbackStopped: 'Music playback stopped',
minutesRemaining: '{minutes} min remaining',
songsRemaining: '{count} songs remaining'
},
playList: {
clearAll: 'Clear Playlist',
alreadyEmpty: 'Playlist is already empty',
cleared: 'Playlist cleared',
empty: 'Playlist is empty',
clearConfirmTitle: 'Clear Playlist',
clearConfirmContent: 'This will clear all songs in the playlist and stop the current playback. Continue?'
}
};

View File

@@ -16,5 +16,12 @@ export default {
noMore: 'No more results',
error: {
searchFailed: 'Search failed'
},
search: {
single: 'Single',
album: 'Album',
playlist: 'Playlist',
mv: 'MV',
bilibili: 'Bilibili'
}
};

View File

@@ -66,7 +66,9 @@ export default {
noMusicSources: 'No sources selected',
gdmusicInfo: 'GD Music Station intelligently resolves music from multiple platforms automatically',
autoPlay: 'Auto Play',
autoPlayDesc: 'Auto resume playback when reopening the app'
autoPlayDesc: 'Auto resume playback when reopening the app',
showStatusBar: "Show Status Bar",
showStatusBarContent: "You can display the music control function in your mac status bar (effective after a restart)"
},
application: {
closeAction: 'Close Action',
@@ -166,42 +168,57 @@ export default {
},
lyricSettings: {
title: 'Lyric Settings',
tabs: {
display: 'Display',
interface: 'Interface',
typography: 'Typography',
mobile: 'Mobile'
},
pureMode: 'Pure Mode',
hideCover: 'Hide Cover',
centerDisplay: 'Center Display',
showTranslation: 'Show Translation',
hideLyrics: 'Hide Lyrics',
hidePlayBar: 'Hide Play Bar',
fontSize: 'Font Size',
letterSpacing: 'Letter Spacing',
lineHeight: 'Line Height',
hideMiniPlayBar: 'Hide Mini Play Bar',
backgroundTheme: 'Background Theme',
fontSizeMarks: {
small: 'Small',
medium: 'Medium',
large: 'Large'
},
letterSpacingMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
lineHeightMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
themeOptions: {
default: 'Default',
light: 'Light',
dark: 'Dark'
},
hideMiniPlayBar: 'Hide Mini Play Bar',
hideLyrics: 'Hide Lyrics',
tabs: {
interface: 'Interface',
display: 'Display',
typography: 'Typography'
}
fontSize: 'Font Size',
fontSizeMarks: {
small: 'Small',
medium: 'Medium',
large: 'Large'
},
letterSpacing: 'Letter Spacing',
letterSpacingMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
lineHeight: 'Line Height',
lineHeightMarks: {
compact: 'Compact',
default: 'Default',
loose: 'Loose'
},
mobileLayout: 'Mobile Layout',
layoutOptions: {
default: 'Default',
ios: 'iOS Style',
android: 'Android Style'
},
mobileCoverStyle: 'Cover Style',
coverOptions: {
record: 'Record',
square: 'Square',
full: 'Full Screen'
},
lyricLines: 'Lyric Lines',
mobileUnavailable: 'This setting is only available on mobile devices'
},
shortcutSettings: {
title: 'Shortcut Settings',

View File

@@ -6,7 +6,9 @@ export default {
addToPlaylist: 'Add to Playlist',
favorite: 'Like',
unfavorite: 'Unlike',
removeFromPlaylist: 'Remove from Playlist'
removeFromPlaylist: 'Remove from Playlist',
dislike: 'Dislike',
undislike: 'Undislike',
},
message: {
downloading: 'Downloading, please wait...',
@@ -14,5 +16,13 @@ export default {
downloadQueued: 'Added to download queue',
addedToNextPlay: 'Added to play next',
getUrlFailed: 'Failed to get music download URL, please check if logged in'
},
dialog: {
dislike:{
title: 'Dislike',
content: 'Are you sure you want to dislike this song?',
positiveText: 'Dislike',
negativeText: 'Cancel'
}
}
};

View File

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

View File

@@ -27,6 +27,9 @@ export default {
refresh: '刷新',
retry: '重试',
reset: '重置',
back: '返回',
copySuccess: '已复制到剪贴板',
copyFailed: '复制失败',
validation: {
required: '此项是必填的',
invalidInput: '输入无效',

View File

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

View File

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

View File

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

View File

@@ -11,10 +11,12 @@ export default {
mute: '静音',
unmute: '取消静音',
songNum: '歌曲总数:{num}',
addCorrection: '提前 {num} 秒',
subtractCorrection: '延迟 {num} 秒',
playFailed: '当前歌曲播放失败,播放下一首',
playMode: {
sequence: '顺序播放',
loop: '循环播放',
loop: '单曲循环',
random: '随机播放'
},
fullscreen: {
@@ -59,7 +61,9 @@ export default {
volume: '音量',
favorite: '已收藏{name}',
unFavorite: '已取消收藏{name}',
miniPlayBar: '迷你播放栏'
miniPlayBar: '迷你播放栏',
playbackSpeed: '播放速度',
advancedControls: '更多设置s',
},
eq: {
title: '均衡器',
@@ -106,5 +110,13 @@ export default {
playbackStopped: '音乐播放已停止',
minutesRemaining: '剩余{minutes}分钟',
songsRemaining: '剩余{count}首歌'
},
playList: {
clearAll: '清空播放列表',
alreadyEmpty: '播放列表已经为空',
cleared: '已清空播放列表',
empty: '播放列表为空',
clearConfirmTitle: '清空播放列表',
clearConfirmContent: '这将清空所有播放列表中的歌曲并停止当前播放。是否继续?'
}
};

View File

@@ -16,5 +16,12 @@ export default {
noMore: '没有更多了',
error: {
searchFailed: '搜索失败'
},
search: {
single: '单曲',
album: '专辑',
playlist: '歌单',
mv: 'MV',
bilibili: 'B站'
}
};

View File

@@ -44,7 +44,7 @@ export default {
},
playback: {
quality: '音质设置',
qualityDesc: '选择音乐播放音质VIP',
qualityDesc: '选择音乐播放音质(网易云VIP',
qualityOptions: {
standard: '标准',
higher: '较高',
@@ -66,7 +66,9 @@ export default {
noMusicSources: '未选择音源',
gdmusicInfo: 'GD音乐台可自动解析多个平台音源自动选择最佳结果',
autoPlay: '自动播放',
autoPlayDesc: '重新打开应用时是否自动继续播放'
autoPlayDesc: '重新打开应用时是否自动继续播放',
showStatusBar: '是否显示状态栏控制功能',
showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)',
},
application: {
closeAction: '关闭行为',
@@ -165,19 +167,33 @@ export default {
portNumber: '请输入有效的端口号(1-65535)'
},
lyricSettings: {
title: '页面设置',
title: '歌词设置',
tabs: {
display: '显示',
interface: '界面',
typography: '文字',
mobile: '移动端'
},
pureMode: '纯净模式',
hideCover: '隐藏封面',
centerDisplay: '居中显示',
showTranslation: '显示翻译',
hideLyrics: '隐藏歌词',
hidePlayBar: '隐藏播放栏',
hideMiniPlayBar: '隐藏迷你播放栏',
backgroundTheme: '背景主题',
themeOptions: {
default: '默认',
light: '亮色',
dark: '暗色'
},
fontSize: '字体大小',
fontSizeMarks: {
small: '小',
medium: '中',
large: '大'
},
letterSpacing: '字间距',
letterSpacing: '字间距',
letterSpacingMarks: {
compact: '紧凑',
default: '默认',
@@ -189,19 +205,20 @@ export default {
default: '默认',
loose: '宽松'
},
backgroundTheme: '背景主题',
themeOptions: {
mobileLayout: '移动端布局',
layoutOptions: {
default: '默认',
light: '亮色',
dark: '暗色'
ios: 'iOS风格',
android: '安卓风格'
},
hideMiniPlayBar: '隐藏迷你播放栏',
hideLyrics: '隐藏歌词',
tabs: {
interface: '界面',
typography: '文字',
display: '显示'
}
mobileCoverStyle: '封面样式',
coverOptions: {
record: '唱片',
square: '方形',
full: '全屏'
},
lyricLines: '歌词行数',
mobileUnavailable: '此设置仅在移动端可用'
},
shortcutSettings: {
title: '快捷键设置',

View File

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

View File

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

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

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

View File

@@ -15,6 +15,7 @@ import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } fr
import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager } from './modules/window';
import { startMusicApi } from './server';
import { initWindowSizeManager } from './modules/window-size';
// 导入所有图标
const iconPath = join(__dirname, '../../resources');
@@ -99,6 +100,9 @@ if (!isSingleInstance) {
optimizer.watchWindowShortcuts(window);
});
// 初始化窗口大小管理器
initWindowSizeManager();
// 初始化应用
initialize();

View File

@@ -122,6 +122,8 @@ const createWin = () => {
}
});
lyricWindow.on('blur', () => lyricWindow && lyricWindow.setMaximizable(false))
return lyricWindow;
};

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import { join } from 'path';
import type { Language } from '../../i18n/main';
import i18n from '../../i18n/main';
import { getStore } from './config';
// 歌曲信息接口定义
interface SongInfo {
@@ -327,7 +328,8 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
// 初始化状态栏Tray
function initializeStatusBarTray(mainWindow: BrowserWindow) {
if (process.platform !== 'darwin') return;
const store = getStore()
if (process.platform !== 'darwin' || !store.get('set.showTopAction')) return;
const iconSize = getProperIconSize();

View File

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

View File

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

View File

@@ -23,5 +23,7 @@
"alwaysShowDownloadButton": false,
"unlimitedDownload": false,
"enableMusicUnblock": true,
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili", "kuwo"]
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili", "kuwo"],
"showTopAction": false,
"contentZoomFactor": 1
}

View File

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

View File

@@ -126,26 +126,6 @@ export const parseFromGDMusic = async (
}
};
/**
* 获取音质映射
* @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;

View File

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

View File

@@ -5,7 +5,9 @@ import { isElectron } from '@/utils';
import request from '@/utils/request';
import requestMusic from '@/utils/request_music';
import { cloneDeep } from 'lodash';
import { parseFromGDMusic, getQualityMapping } from './gdmusic';
import { parseFromGDMusic } from './gdmusic';
import type { SongResult } from '@/type/music';
import { searchAndGetBilibiliAudioUrl } from './bilibili';
const { addData, getData, deleteData } = musicDB;
@@ -80,7 +82,7 @@ export const getMusicLrc = async (id: number) => {
}
};
export const getParsingMusicUrl = async (id: number, data: any) => {
export const getParsingMusicUrl = async (id: number, data: SongResult) => {
const settingStore = useSettingsStore();
// 如果禁用了音乐解析功能,则直接返回空结果
@@ -98,7 +100,25 @@ export const getParsingMusicUrl = async (id: number, data: any) => {
try {
enabledSources = JSON.parse(savedSource);
console.log(`使用歌曲 ${id} 自定义音源:`, enabledSources);
if(enabledSources.includes('bilibili')){
// 构建搜索关键词,依次判断歌曲名称、歌手名称和专辑名称是否存在
const songName = data?.name || '';
const artistName = Array.isArray(data?.ar) && data.ar.length > 0 && data.ar[0]?.name ? data.ar[0].name : '';
const albumName = data?.al && typeof data.al === 'object' && data.al?.name ? data.al.name : '';
const name = [songName, artistName, albumName].filter(Boolean).join(' ').trim();
console.log('开始搜索bilibili音频', name);
return {
data: {
code: 200,
message: 'success',
data: {
url: await searchAndGetBilibiliAudioUrl(name)
}
}
}
}
} catch (e) {
console.error('e',e)
console.error('解析自定义音源失败, 使用全局设置', e);
enabledSources = settingStore.setData.enabledMusicSources || [];
}
@@ -108,13 +128,11 @@ export const getParsingMusicUrl = async (id: number, data: any) => {
}
// 检查是否选择了GD音乐台解析
if (enabledSources.includes('gdmusic')) {
// 获取音质设置并转换为GD音乐台格式
try {
const quality = getQualityMapping(settingStore.setData.musicQuality || 'higher');
// 调用封装的GD音乐台解析服务
const gdResult = await parseFromGDMusic(id, data, quality);
const gdResult = await parseFromGDMusic(id, data, '999');
if (gdResult) {
return gdResult;
}
@@ -201,3 +219,11 @@ export function getPlaylistDetail(id: string) {
}
});
}
export function subscribePlaylist(params: { t: number; id: number }) {
return request({
url: '/playlist/subscribe',
method: 'post',
params
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,12 @@
<div class="description">
<p>{{ t('donation.description') }}</p>
<p>{{ t('donation.message') }}</p>
<n-button type="primary" @click="toDonateList">
<template #icon>
<i class="ri-cup-line"></i>
</template>
{{ t('donation.toDonateList') }}
</n-button>
</div>
<div class="qrcode-grid">
<div class="qrcode-item">
@@ -16,15 +22,6 @@
<span class="qrcode-label">{{ t('common.alipay') }}</span>
</div>
<div class="donate-button">
<n-button type="primary" @click="toDonateList">
<template #icon>
<i class="ri-cup-line"></i>
</template>
{{ t('donation.toDonateList') }}
</n-button>
</div>
<div class="qrcode-item">
<n-image
:src="wechat"
@@ -66,7 +63,7 @@
<div class="donor-info">
<div class="donor-meta">
<div class="donor-name">{{ donor.name }}</div>
<div class="price-tag">{{ donor.amount }}</div>
<!-- <div class="price-tag">{{ donor.amount }}</div> -->
</div>
<div class="donation-date">{{ donor.date }}</div>
</div>
@@ -102,7 +99,7 @@
</div>
</div>
<div v-if="sortedDonors.length > 8" class="expand-button">
<div v-if="donors.length > 8" class="expand-button">
<n-button text @click="toggleExpand">
<template #icon>
<i :class="isExpanded ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i>
@@ -153,18 +150,13 @@ onActivated(() => {
fetchDonors();
});
// 只按金额排序的捐赠列表
const sortedDonors = computed(() => {
return [...donors.value].sort((a, b) => b.amount - a.amount);
});
const isExpanded = ref(false);
const displayDonors = computed(() => {
if (isExpanded.value) {
return sortedDonors.value;
return donors.value;
}
return sortedDonors.value.slice(0, 8);
return donors.value.slice(0, 8);
});
const toggleExpand = () => {
@@ -172,7 +164,7 @@ const toggleExpand = () => {
};
const toDonateList = () => {
window.open('http://donate.alger.fun', '_blank');
window.open('http://donate.alger.fun/download', '_blank');
};
</script>
@@ -211,13 +203,9 @@ const toDonateList = () => {
@apply rounded-lg p-2.5 transition-all duration-200 hover:shadow-md;
@apply bg-light-100 dark:bg-gray-800/5 backdrop-blur-sm;
@apply border border-gray-200 dark:border-gray-700/10;
@apply flex flex-col justify-between;
@apply flex flex-col;
min-height: 100px;
&.no-message {
@apply justify-between;
}
.card-content {
@apply flex items-start gap-2 mb-2;
}
@@ -327,7 +315,7 @@ const toDonateList = () => {
}
.qrcode-grid {
@apply flex justify-between items-center gap-4;
@apply flex justify-between items-center gap-4 flex-wrap;
.qrcode-item {
@apply flex flex-col items-center gap-2;

View File

@@ -1,475 +1,34 @@
<template>
<div class="download-drawer-trigger">
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
<n-button circle @click="settingsStore.showDownloadDrawer = true">
<n-button circle @click="navigateToDownloads">
<template #icon>
<i class="iconfont ri-download-cloud-2-line"></i>
</template>
</n-button>
</n-badge>
</div>
<n-drawer
v-model:show="showDrawer"
:height="'80%'"
placement="bottom"
@after-leave="handleDrawerClose"
>
<n-drawer-content :title="t('download.title')" closable :native-scrollbar="false">
<div class="drawer-container">
<n-tabs type="line" animated class="h-full">
<!-- 下载列表 -->
<n-tab-pane name="downloading" :tab="t('download.tabs.downloading')" class="h-full">
<div class="download-list">
<div v-if="downloadList.length === 0" class="empty-tip">
<n-empty :description="t('download.empty.noTasks')" />
</div>
<template v-else>
<div class="total-progress">
<div class="total-progress-text">
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
</div>
<n-progress
type="line"
:percentage="Number(totalProgress.toFixed(1))"
:height="12"
:border-radius="6"
:indicator-placement="'inside'"
/>
</div>
<div class="download-content">
<div class="download-items">
<div v-for="item in downloadList" :key="item.path" class="download-item">
<div class="download-item-content">
<div class="download-item-cover">
<n-image
:src="getImgUrl(item.songInfo?.picUrl, '200y200')"
preview-disabled
:object-fit="'cover'"
class="cover-image"
/>
</div>
<div class="download-item-info">
<div class="download-item-name" :title="item.filename">
{{ item.filename }}
</div>
<div class="download-item-artist">
{{
item.songInfo?.ar?.map((a) => a.name).join(', ') ||
t('download.artist.unknown')
}}
</div>
<div class="download-item-progress">
<n-progress
type="line"
:percentage="item.progress"
:processing="item.status === 'downloading'"
:status="getProgressStatus(item)"
:height="8"
/>
</div>
<div class="download-item-size">
<span
>{{ formatSize(item.loaded) }} / {{ formatSize(item.total) }}</span
>
</div>
</div>
<div class="download-item-status">
<n-tag :type="getStatusType(item)" size="small">
{{ getStatusText(item) }}
</n-tag>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</n-tab-pane>
<!-- 已下载列表 -->
<n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full">
<div class="downloaded-list">
<div v-if="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')" />
</div>
<div v-else class="downloaded-content">
<div class="downloaded-header">
<div class="header-title">
{{ t('download.count', { count: downloadedList.length }) }}
</div>
<n-button secondary size="small" @click="showClearConfirm = true">
<template #icon>
<i class="iconfont ri-delete-bin-line mr-1"></i>
</template>
{{ t('download.clearAll') }}
</n-button>
</div>
<div class="downloaded-items">
<div v-for="item in downList" :key="item.path" class="downloaded-item">
<div class="downloaded-item-content">
<div class="downloaded-item-cover">
<n-image
:src="getImgUrl(item.picUrl, '200y200')"
preview-disabled
:object-fit="'cover'"
class="cover-image"
/>
</div>
<div class="downloaded-item-info">
<div class="downloaded-item-name" :title="item.filename">
{{ item.filename }}
</div>
<div class="downloaded-item-artist">
{{ item.ar?.map((a) => a.name).join(', ') }}
</div>
<div class="downloaded-item-size">{{ formatSize(item.size) }}</div>
</div>
<div class="downloaded-item-actions">
<!-- <n-button text type="primary" size="large" @click="handlePlayMusic(item)">
<template #icon>
<i class="iconfont ri-play-circle-line text-xl"></i>
</template>
</n-button> -->
<n-button
text
type="primary"
size="large"
@click="openDirectory(item.path)"
>
<template #icon>
<i class="iconfont ri-folder-open-line text-xl"></i>
</template>
</n-button>
<n-button text type="error" size="large" @click="handleDelete(item)">
<template #icon>
<i class="iconfont ri-delete-bin-line text-xl"></i>
</template>
</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>
</n-drawer-content>
</n-drawer>
<!-- 删除确认对话框 -->
<n-modal
v-model:show="showDeleteConfirm"
preset="dialog"
type="warning"
:title="t('download.delete.title')"
>
<template #header>
<div class="flex items-center">
<i class="iconfont ri-error-warning-line mr-2 text-xl"></i>
<span>{{ t('download.delete.title') }}</span>
</div>
</template>
<div class="delete-confirm-content">
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
</div>
<template #action>
<n-button size="small" @click="showDeleteConfirm = false">{{
t('download.delete.cancel')
}}</n-button>
<n-button size="small" type="warning" @click="confirmDelete">{{
t('download.delete.confirm')
}}</n-button>
</template>
</n-modal>
<!-- 清空确认对话框 -->
<n-modal
v-model:show="showClearConfirm"
preset="dialog"
type="warning"
:title="t('download.clear.title')"
>
<template #header>
<div class="flex items-center">
<i class="iconfont ri-delete-bin-line mr-2 text-xl"></i>
<span>{{ t('download.clear.title') }}</span>
</div>
</template>
<div class="delete-confirm-content">
{{ t('download.clear.message') }}
</div>
<template #action>
<n-button size="small" @click="showClearConfirm = false">{{
t('download.clear.cancel')
}}</n-button>
<n-button size="small" type="warning" @click="clearDownloadRecords">{{
t('download.clear.confirm')
}}</n-button>
</template>
</n-modal>
</template>
<script setup lang="ts">
import type { ProgressStatus } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getMusicDetail } from '@/api/music';
// import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
// import { audioService } from '@/services/audioService';
import { getImgUrl } from '@/utils';
// import { SongResult } from '@/type/music';
const { t } = useI18n();
interface DownloadItem {
filename: string;
progress: number;
loaded: number;
total: number;
path: string;
status: 'downloading' | 'completed' | 'error';
error?: string;
songInfo?: any;
}
interface DownloadedItem {
filename: string;
path: string;
size: number;
id: number;
picUrl: string;
ar: { name: string }[];
}
const message = useMessage();
// const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const showDrawer = computed({
get: () => settingsStore.showDownloadDrawer,
set: (val) => {
settingsStore.showDownloadDrawer = val;
}
});
const downloadList = ref<DownloadItem[]>([]);
const downloadedList = ref<DownloadedItem[]>(
JSON.parse(localStorage.getItem('downloadedList') || '[]')
);
const downList = computed(() => downloadedList.value);
const router = useRouter();
const downloadList = ref<any[]>([]);
// 计算下载中的任务数量
const downloadingCount = computed(() => {
return downloadList.value.filter((item) => item.status === 'downloading').length;
});
// 计算总进度
const totalProgress = computed(() => {
if (downloadList.value.length === 0) return 0;
const total = downloadList.value.reduce((sum, item) => sum + item.progress, 0);
return total / downloadList.value.length;
});
watch(totalProgress, (newVal) => {
if (newVal === 100) {
refreshDownloadedList();
}
});
// 获取状态类型
const getStatusType = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return 'info';
case 'completed':
return 'success';
case 'error':
return 'error';
default:
return 'default';
}
// 导航到下载页面
const navigateToDownloads = () => {
router.push('/downloads');
};
// 获取状态文本
const getStatusText = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return t('download.status.downloading');
case 'completed':
return t('download.status.completed');
case 'error':
return t('download.status.failed');
default:
return t('download.status.unknown');
}
};
// 获取进度条状态
const getProgressStatus = (item: DownloadItem): ProgressStatus => {
switch (item.status) {
case 'completed':
return 'success';
case 'error':
return 'error';
default:
return 'info';
}
};
// 格式化文件大小
const formatSize = (bytes: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
// 打开目录
const openDirectory = (path: string) => {
window.electron.ipcRenderer.send('open-directory', path);
};
// 删除相关
const showDeleteConfirm = ref(false);
const itemToDelete = ref<DownloadedItem | null>(null);
// 处理删除点击
const handleDelete = (item: DownloadedItem) => {
itemToDelete.value = item;
showDeleteConfirm.value = true;
};
// 确认删除
const confirmDelete = async () => {
const item = itemToDelete.value;
if (!item) return;
try {
const success = await window.electron.ipcRenderer.invoke(
'delete-downloaded-music',
item.path
);
if (success) {
const newList = downloadedList.value.filter(i => i.id !== item.id);
downloadedList.value = newList;
localStorage.setItem('downloadedList', JSON.stringify(newList));
message.success(t('download.delete.success'));
} else {
message.warning(t('download.delete.fileNotFound'));
}
} catch (error) {
console.error('Failed to delete music:', error);
message.warning(t('download.delete.recordRemoved'));
} finally {
showDeleteConfirm.value = false;
itemToDelete.value = null;
}
};
// 清空下载记录相关
const showClearConfirm = ref(false);
// 清空下载记录
const clearDownloadRecords = async () => {
try {
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 handlePlay = async (musicInfo: SongResult) => {
// await playerStore.setPlay(musicInfo);
// playerStore.setPlayMusic(true);
// playerStore.setIsPlay(true);
// };
// 添加加载状态
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(
() => showDrawer.value,
(newVal) => {
if (newVal && !isLoadingDownloaded.value) {
refreshDownloadedList();
}
}
);
// 监听下载进度
onMounted(() => {
refreshDownloadedList();
// 监听下载进度
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
@@ -501,9 +60,6 @@ onMounted(() => {
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
if (data.success) {
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
// 延迟刷新已下载列表,避免文件系统未完全写入
setTimeout(() => refreshDownloadedList(), 500);
message.success(t('download.message.downloadComplete', { filename: data.filename }));
} else {
const existingItem = downloadList.value.find(item => item.filename === data.filename);
if (existingItem) {
@@ -516,7 +72,6 @@ onMounted(() => {
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
}, 3000);
}
message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
}
});
@@ -536,10 +91,6 @@ onMounted(() => {
}
});
});
const handleDrawerClose = () => {
settingsStore.showDownloadDrawer = false;
};
</script>
<style lang="scss" scoped>
@@ -558,117 +109,4 @@ const handleDrawerClose = () => {
}
}
}
.drawer-container {
@apply h-full;
}
.download-list,
.downloaded-list {
@apply flex flex-col h-full;
.empty-tip {
@apply flex-1 flex items-center justify-center;
@apply text-gray-400 dark:text-gray-600;
}
}
.download-content,
.downloaded-content {
@apply flex-1 overflow-hidden pb-40;
}
.downloaded-header {
@apply flex items-center justify-between p-4 bg-light-100 dark:bg-dark-200 sticky top-0 z-10;
@apply border-b border-gray-100 dark:border-gray-800;
.header-title {
@apply text-sm font-medium text-gray-600 dark:text-gray-400;
}
}
.download-items,
.downloaded-items {
@apply space-y-3 p-4;
}
.total-progress {
@apply px-4 py-3 bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
@apply border-b border-gray-100 dark:border-gray-800;
@apply sticky top-0 z-10;
&-text {
@apply mb-2 text-sm font-medium text-gray-600 dark:text-gray-400;
}
}
.download-item,
.downloaded-item {
@apply p-3 rounded-lg;
@apply bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
@apply border border-gray-100 dark:border-gray-700;
@apply transition-all duration-300;
@apply hover:bg-light-300 dark:hover:bg-dark-300;
@apply hover:shadow-md;
&-content {
@apply flex items-center gap-3;
}
&-cover {
@apply w-10 h-10 flex-shrink-0 rounded-lg overflow-hidden;
@apply shadow-md;
.cover-image {
@apply w-full h-full object-cover;
}
}
&-info {
@apply flex-1 min-w-0;
}
&-name {
@apply text-sm font-medium truncate;
@apply text-gray-900 dark:text-gray-100;
}
&-artist {
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
}
&-progress {
@apply mt-1;
}
&-size {
@apply text-xs text-gray-500 dark:text-gray-400 mt-1;
}
&-status {
@apply flex-shrink-0;
}
}
.downloaded-item {
&-actions {
@apply flex items-center gap-1;
.n-button {
@apply p-2;
@apply hover:bg-gray-200/80 dark:hover:bg-gray-600/80;
@apply rounded-lg;
@apply transition-colors duration-300;
.iconfont {
@apply text-xl;
}
}
}
}
.delete-confirm-content {
@apply py-6 px-4;
@apply text-base text-gray-600 dark:text-gray-400;
}
</style>

View File

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

View File

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

View File

@@ -33,7 +33,6 @@
<script setup lang="ts">
import { getAlbum, getListDetail } from '@/api/list';
import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils';
@@ -129,9 +128,7 @@ const handleClick = async () => {
};
const handleShowMv = async () => {
playerStore.setIsPlay(false);
playerStore.setPlayMusic(false);
audioService.getCurrentSound()?.pause();
playerStore.handlePause();
showPop.value = true;
};
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@
<div class="mt-2">
<p
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)"
v-for="item in getDisplayDaySongs.slice(0, 5)"
:key="item.id"
class="text-el"
>
@@ -128,7 +128,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref, watchEffect } from 'vue';
import { onMounted, ref, watchEffect, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
@@ -247,19 +247,31 @@ const loadArtistData = async () => {
}
}
// 提取每日推荐加载逻辑到单独的函数
const loadDayRecommendData = async () => {
try {
const {
data: { data: dayRecommend }
} = await getDayRecommend();
const dayRecommendSource = dayRecommend as unknown as IDayRecommend;
dayRecommendData.value = {
...dayRecommendSource,
dailySongs: dayRecommendSource.dailySongs.filter((song: any) => !playerStore.dislikeList.includes(song.id))
};
} catch (error) {
console.error('获取每日推荐失败:', error);
}
};
// 加载不需要登录的数据
const loadNonUserData = async () => {
try {
// 获取每日推荐
try {
const {
data: { data: dayRecommend }
} = await getDayRecommend();
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
} catch (error) {
console.error('获取每日推荐失败:', error);
// 获取每日推荐仅在用户未登录时加载已登录用户会通过watchEffect触发loadDayRecommendData
if (!userStore.user) {
await loadDayRecommendData();
}
// 获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
hotSingerData.value = singerData;
@@ -288,6 +300,12 @@ const loadUserData = async () => {
const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
const getDisplayDaySongs = computed(() => {
if(!dayRecommendData.value){
return [];
}
return dayRecommendData.value.dailySongs.filter((song) => !playerStore.dislikeList.includes(song.id));
})
const showDayRecommend = () => {
if (!dayRecommendData.value?.dailySongs) return;
@@ -295,7 +313,7 @@ const showDayRecommend = () => {
navigateToMusicList(router, {
type: 'dailyRecommend',
name: t('comp.recommendSinger.songlist'),
songList: dayRecommendData.value.dailySongs,
songList: getDisplayDaySongs.value,
canRemove: false
});
};
@@ -410,9 +428,11 @@ const loadFullPlaylist = async (trackIds: { id: number }[], initialSongs: SongRe
watchEffect(() => {
if (userStore.user) {
loadUserData();
loadDayRecommendData();
}
});
const getPlaylistGridClass = (length: number) => {
switch (length) {
case 1:

View File

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

View File

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

View File

@@ -135,12 +135,13 @@
<span>{{ t('player.lrc.noLrc') }}</span>
</div>
</div>
<!-- 歌词右下角矫正按钮组件 -->
<LyricCorrectionControl
v-if="!isMobile"
:correction-time="correctionTime"
@adjust="adjustCorrectionTime"
/>
</n-layout>
<!-- 时间矫正 -->
<!-- <div class="music-content-time">
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
<n-button @click="addCorrectionTime(0.2)">+</n-button>
</div> -->
</div>
</div>
</n-drawer>
@@ -153,6 +154,7 @@ import { useI18n } from 'vue-i18n';
import LyricSettings from '@/components/lyric/LyricSettings.vue';
import MiniPlayBar from '@/components/player/MiniPlayBar.vue';
import LyricCorrectionControl from '@/components/lyric/LyricCorrectionControl.vue';
import {
artistList,
lrcArray,
@@ -160,7 +162,9 @@ import {
playMusic,
setAudioTime,
textColors,
useLyricProgress
useLyricProgress,
correctionTime,
adjustCorrectionTime
} from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { usePlayerStore } from '@/store/modules/player';
@@ -797,4 +801,12 @@ defineExpose({
color: white;
animation: spin 1s linear infinite;
}
.lyric-correction {
/* 仅在 hover 歌词区域时显示 */
.music-lrc:hover & {
opacity: 1 !important;
pointer-events: auto !important;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -52,9 +52,9 @@
></i>
</div>
<n-popover trigger="hover" :z-index="99999999" placement="top" :show-arrow="false">
<n-popover v-if="component" trigger="hover" :z-index="99999999" placement="top" :show-arrow="false">
<template #trigger>
<div class="function-button" @click="mute">
<div class="function-button" @click="mute" @wheel.prevent="handleVolumeWheel">
<i class="iconfont" :class="getVolumeIcon"></i>
</div>
</template>
@@ -64,6 +64,7 @@
:step="0.01"
:tooltip="false"
vertical
@wheel.prevent="handleVolumeWheel"
></n-slider>
</div>
</n-popover>
@@ -183,6 +184,14 @@ const mute = () => {
}
};
// 鼠标滚轮调整音量
const handleVolumeWheel = (e: WheelEvent) => {
// 向上滚动增加音量,向下滚动减少音量
const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
volumeSlider.value = newValue;
};
// 收藏相关
const isFavorite = computed(() => {
// 对于B站视频使用ID匹配函数
@@ -312,25 +321,7 @@ 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);
}
playerStore.setPlay(playerStore.playMusic);
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
@@ -547,6 +538,50 @@ const setMusicFull = () => {
.volume-slider-wrapper {
@apply p-2 py-4 rounded-xl bg-white dark:bg-dark-100 shadow-lg bg-opacity-90 backdrop-blur;
height: 160px;
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: theme('colors.gray.200');
--n-rail-color-dark: theme('colors.gray.700');
--n-fill-color: theme('colors.green.500');
--n-handle-size: 12px;
--n-handle-color: theme('colors.green.500');
&.n-slider--vertical {
height: 100%;
.n-slider-rail {
width: 4px;
}
&:hover {
.n-slider-rail {
width: 6px;
}
.n-slider-handle {
width: 14px;
height: 14px;
}
}
}
.n-slider-rail {
@apply overflow-hidden transition-all duration-200;
@apply bg-gray-500 dark:bg-dark-300 bg-opacity-10 !important;
}
.n-slider-handle {
@apply transition-all duration-200;
opacity: 0;
}
&:hover {
.n-slider-handle {
opacity: 1;
}
}
}
}
// 播放列表样式

View File

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

View File

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

View File

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

View File

@@ -84,12 +84,12 @@ const isReparse = computed(() => {
// 可选音源列表
const musicSourceOptions = ref([
{ label: 'MiGu音乐', value: 'migu' as Platform },
{ label: '酷狗音乐', value: 'kugou' as Platform },
{ label: 'MiGu', value: 'migu' as Platform },
{ label: 'KuGou', value: 'kugou' as Platform },
{ label: 'pyncmd', value: 'pyncmd' as Platform },
{ label: '酷我音乐', value: 'kuwo' as Platform },
{ label: 'Bilibili音乐', value: 'bilibili' as Platform },
{ label: 'GD音乐台', value: 'gdmusic' as Platform }
{ label: 'KuWo', value: 'kuwo' as Platform },
{ label: 'Bilibili', value: 'bilibili' as Platform },
{ label: 'GdMuisc', value: 'gdmusic' as Platform }
]);
// 检查音源是否被选中

View File

@@ -0,0 +1,342 @@
<template>
<div class="sleep-timer-content">
<h3 class="timer-title">{{ t('player.sleepTimer.title') }}</h3>
<div v-if="hasTimerActive" class="sleep-timer-active">
<div class="timer-status">
<template v-if="timerType === 'time'">
<div class="timer-value countdown-timer">{{ formattedRemainingTime }}</div>
</template>
<template v-else-if="timerType === 'songs'">
<div class="timer-value">{{ remainingSongs }}</div>
<div class="timer-label">{{ t('player.sleepTimer.songsRemaining', { count: remainingSongs }) }}</div>
</template>
<template v-else-if="timerType === 'end'">
<div class="timer-value">{{ t('player.sleepTimer.activeUntilEnd') }}</div>
<div class="timer-label">{{ t('player.sleepTimer.afterPlaylist') }}</div>
</template>
</div>
<n-button type="error" class="cancel-timer-btn" @click="handleCancelTimer" round>
{{ t('player.sleepTimer.cancel') }}
</n-button>
</div>
<div v-else class="sleep-timer-options">
<!-- 按时间定时 -->
<div class="option-section">
<h4 class="option-title">{{ t('player.sleepTimer.timeMode') }}</h4>
<div class="time-options">
<n-button
v-for="minutes in [15, 30, 60, 90]"
:key="minutes"
size="small"
class="time-option-btn"
@click="handleSetTimeTimer(minutes)"
round
>
{{ minutes }}{{ t('player.sleepTimer.minutes') }}
</n-button>
<div class="custom-time">
<n-input-number
v-model:value="customMinutes"
:min="1"
:max="300"
size="small"
class="custom-time-input"
round
/>
<n-button
size="small"
type="primary"
class="custom-time-btn"
:disabled="!customMinutes"
@click="handleSetTimeTimer(customMinutes)"
round
>
{{ t('player.sleepTimer.set') }}
</n-button>
</div>
</div>
</div>
<!-- 按歌曲数定时 -->
<div class="option-section">
<h4 class="option-title">{{ t('player.sleepTimer.songsMode') }}</h4>
<div class="songs-options">
<n-button
v-for="songs in [1, 3, 5, 10]"
:key="songs"
size="small"
class="songs-option-btn"
@click="handleSetSongsTimer(songs)"
round
>
{{ songs }}{{ t('player.sleepTimer.songs') }}
</n-button>
<div class="custom-songs">
<n-input-number
v-model:value="customSongs"
:min="1"
:max="50"
size="small"
class="custom-songs-input"
round
/>
<n-button
size="small"
type="primary"
class="custom-songs-btn"
:disabled="!customSongs"
@click="handleSetSongsTimer(customSongs)"
round
>
{{ t('player.sleepTimer.set') }}
</n-button>
</div>
</div>
</div>
<!-- 播放完列表后关闭 -->
<div class="option-section playlist-end-section">
<n-button block class="playlist-end-btn" @click="handleSetPlaylistEndTimer" round>
{{ t('player.sleepTimer.playlistEnd') }}
</n-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { usePlayerStore } from '@/store/modules/player';
const { t } = useI18n();
const playerStore = usePlayerStore();
// 从store获取所有相关状态
const { sleepTimer } = storeToRefs(playerStore);
// 本地状态用于UI展示
const customMinutes = ref(30);
const customSongs = ref(5);
// 添加一个刷新触发变量,用于强制更新倒计时
const refreshTrigger = ref(0);
// 计算属性,判断定时器状态
const hasTimerActive = computed(() => {
return playerStore.hasSleepTimerActive;
});
const timerType = computed(() => {
return sleepTimer.value.type;
});
// 剩余歌曲数
const remainingSongs = computed(() => {
return playerStore.sleepTimerRemainingSongs;
});
// 处理设置时间定时器
function handleSetTimeTimer(minutes: number) {
playerStore.setSleepTimerByTime(minutes);
}
// 处理设置歌曲数定时器
function handleSetSongsTimer(songs: number) {
playerStore.setSleepTimerBySongs(songs);
}
// 处理设置播放列表结束定时器
function handleSetPlaylistEndTimer() {
playerStore.setSleepTimerAtPlaylistEnd();
}
// 处理取消定时器
function handleCancelTimer() {
playerStore.clearSleepTimer();
}
// 格式化剩余时间为 HH:MM:SS
const formattedRemainingTime = computed(() => {
// 依赖刷新触发器强制更新
void refreshTrigger.value;
if (timerType.value !== 'time' || !sleepTimer.value.endTime) {
return '00:00:00';
}
const remaining = Math.max(0, sleepTimer.value.endTime - Date.now());
const totalSeconds = Math.floor(remaining / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const formattedHours = hours.toString().padStart(2, '0');
const formattedMinutes = minutes.toString().padStart(2, '0');
const formattedSeconds = seconds.toString().padStart(2, '0');
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
});
// 监听剩余时间变化
let timerInterval: number | null = null;
onMounted(() => {
// 如果当前有定时器开始更新UI
if (hasTimerActive.value && timerType.value === 'time') {
startTimerUpdate();
}
// 监听定时器状态变化
watch(
() => [hasTimerActive.value, timerType.value],
([newHasTimer, newType]) => {
if (newHasTimer && newType === 'time') {
startTimerUpdate();
} else {
stopTimerUpdate();
}
}
);
});
// 启动定时器更新UI
function startTimerUpdate() {
stopTimerUpdate(); // 先停止之前的计时器
// 每秒更新UI
timerInterval = window.setInterval(() => {
// 更新刷新触发器,强制重新计算
refreshTrigger.value = Date.now();
}, 500) as unknown as number;
}
// 停止定时器更新UI
function stopTimerUpdate() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
onUnmounted(() => {
stopTimerUpdate();
});
</script>
<style lang="scss" scoped>
.sleep-timer-content {
@apply w-full p-4;
.timer-title {
@apply text-lg font-medium mb-4 text-center;
}
// 激活状态显示
.sleep-timer-active {
@apply flex flex-col items-center;
// 定时状态卡片
.timer-status {
@apply flex flex-col items-center justify-center p-8 mb-5 w-full rounded-2xl dark:bg-gray-800 dark:bg-opacity-40 dark:shadow-gray-900/20;
background-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
// 定时值显示
.timer-value {
@apply text-4xl font-semibold mb-2 text-green-500;
&.countdown-timer {
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
}
}
// 标签文本
.timer-label {
@apply text-base text-gray-600 dark:text-gray-300;
}
}
// 取消按钮
.cancel-timer-btn {
@apply w-full py-3 text-base rounded-full transition-all duration-200;
&:hover {
@apply transform scale-105 shadow-md;
}
&:active {
@apply transform scale-95;
}
}
}
// 定时器选项区域
.sleep-timer-options {
@apply flex flex-col;
// 选项部分
.option-section {
@apply mb-7;
// 选项标题
.option-title {
@apply text-base font-medium mb-4 text-gray-700 dark:text-gray-200;
letter-spacing: 0.3px;
}
// 时间/歌曲选项容器
.time-options, .songs-options {
@apply flex flex-wrap gap-2;
// 选项按钮共享样式
.time-option-btn, .songs-option-btn {
@apply px-4 py-2 rounded-full text-gray-800 dark:text-gray-200 transition-all duration-200;
background-color: rgba(255, 255, 255, 0.5);
@apply dark:bg-gray-800 dark:bg-opacity-40 hover:bg-white dark:hover:bg-gray-700;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.1);
@apply dark:shadow-gray-900/20;
&:hover {
@apply transform scale-105 shadow-md;
}
&:active {
@apply transform scale-95;
}
}
// 自定义输入区域
.custom-time, .custom-songs {
@apply flex items-center space-x-2 mt-4 w-full;
// 输入框
.custom-time-input, .custom-songs-input {
@apply flex-1;
}
// 设置按钮
.custom-time-btn, .custom-songs-btn {
@apply py-2 px-4 rounded-full transition-all duration-200;
}
}
}
}
// 播放列表结束选项
.playlist-end-section {
@apply mt-2;
.playlist-end-btn {
@apply py-3 text-base rounded-full transition-all duration-200;
}
}
}
}
</style>

View File

@@ -1,478 +0,0 @@
<template>
<n-popover
trigger="click"
:z-index="99999999"
content-class="sleep-timer"
raw
:show-arrow="false"
placement="top"
display-directive="show"
>
<template #trigger>
<n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<div class="control-btn timer" :class="{ 'mobile-timer': mode === 'mobile' }">
<i
class="iconfont ri-time-line"
:class="{ 'text-green-500': hasTimerActive }"
></i>
</div>
</template>
{{ t('player.sleepTimer.title') }}
</n-tooltip>
</template>
<div class="sleep-timer-container" :class="{ 'mobile-sleep-timer-container': mode === 'mobile' }">
<div class="sleep-timer-back"></div>
<div class="sleep-timer-content">
<div class="sleep-timer-title">{{ t('player.sleepTimer.title') }}</div>
<div v-if="hasTimerActive" class="sleep-timer-active">
<div class="timer-status">
<template v-if="timerType === 'time'">
<div class="timer-value countdown-timer">{{ formattedRemainingTime }}</div>
<div class="timer-label">{{ t('player.sleepTimer.minutesRemaining', { minutes: Math.ceil(remainingMinutes/60) }) }}</div>
</template>
<template v-else-if="timerType === 'songs'">
<div class="timer-value">{{ remainingSongs }}</div>
<div class="timer-label">{{ t('player.sleepTimer.songsRemaining', { count: remainingSongs }) }}</div>
</template>
<template v-else-if="timerType === 'end'">
<div class="timer-value">{{ t('player.sleepTimer.activeUntilEnd') }}</div>
<div class="timer-label">{{ t('player.sleepTimer.afterPlaylist') }}</div>
</template>
</div>
<n-button type="error" class="cancel-timer-btn" @click="handleCancelTimer" round>
{{ t('player.sleepTimer.cancel') }}
</n-button>
</div>
<div v-else class="sleep-timer-options">
<!-- 按时间定时 -->
<div class="option-section">
<h4 class="option-title">{{ t('player.sleepTimer.timeMode') }}</h4>
<div class="time-options">
<n-button
v-for="minutes in [15, 30, 60, 90]"
:key="minutes"
size="small"
class="time-option-btn"
@click="handleSetTimeTimer(minutes)"
round
>
{{ minutes }}{{ t('player.sleepTimer.minutes') }}
</n-button>
<div class="custom-time">
<n-input-number
v-model:value="customMinutes"
:min="1"
:max="300"
size="small"
class="custom-time-input"
round
/>
<n-button
size="small"
type="primary"
class="custom-time-btn"
:disabled="!customMinutes"
@click="handleSetTimeTimer(customMinutes)"
round
>
{{ t('player.sleepTimer.set') }}
</n-button>
</div>
</div>
</div>
<!-- 按歌曲数定时 -->
<div class="option-section">
<h4 class="option-title">{{ t('player.sleepTimer.songsMode') }}</h4>
<div class="songs-options">
<n-button
v-for="songs in [1, 3, 5, 10]"
:key="songs"
size="small"
class="songs-option-btn"
@click="handleSetSongsTimer(songs)"
round
>
{{ songs }}{{ t('player.sleepTimer.songs') }}
</n-button>
<div class="custom-songs">
<n-input-number
v-model:value="customSongs"
:min="1"
:max="50"
size="small"
class="custom-songs-input"
round
/>
<n-button
size="small"
type="primary"
class="custom-songs-btn"
:disabled="!customSongs"
@click="handleSetSongsTimer(customSongs)"
round
>
{{ t('player.sleepTimer.set') }}
</n-button>
</div>
</div>
</div>
<!-- 播放完列表后关闭 -->
<div class="option-section playlist-end-section">
<n-button block class="playlist-end-btn" @click="handleSetPlaylistEndTimer" round>
{{ t('player.sleepTimer.playlistEnd') }}
</n-button>
</div>
</div>
</div>
</div>
</n-popover>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { usePlayerStore } from '@/store/modules/player';
// 组件接收一个mode参数用于标识是移动端还是桌面端
defineProps({
mode: {
type: String,
default: 'desktop' // 'desktop' 或 'mobile'
}
});
const { t } = useI18n();
const playerStore = usePlayerStore();
// 从store获取所有相关状态
const { sleepTimer} = storeToRefs(playerStore);
// 本地状态用于UI展示
const customMinutes = ref(30);
const customSongs = ref(5);
// 添加一个刷新触发变量,用于强制更新倒计时
const refreshTrigger = ref(0);
// 计算属性,判断定时器状态
const hasTimerActive = computed(() => {
return playerStore.hasSleepTimerActive;
});
const timerType = computed(() => {
return sleepTimer.value.type;
});
// 剩余时间(分钟)
const remainingMinutes = computed(() => {
return playerStore.sleepTimerRemainingTime;
});
// 剩余歌曲数
const remainingSongs = computed(() => {
return playerStore.sleepTimerRemainingSongs;
});
// 处理设置时间定时器
function handleSetTimeTimer(minutes: number) {
playerStore.setSleepTimerByTime(minutes);
}
// 处理设置歌曲数定时器
function handleSetSongsTimer(songs: number) {
playerStore.setSleepTimerBySongs(songs);
}
// 处理设置播放列表结束定时器
function handleSetPlaylistEndTimer() {
playerStore.setSleepTimerAtPlaylistEnd();
}
// 处理取消定时器
function handleCancelTimer() {
playerStore.clearSleepTimer();
}
// 格式化剩余时间为 HH:MM:SS
const formattedRemainingTime = computed(() => {
// 依赖刷新触发器强制更新
void refreshTrigger.value;
if (timerType.value !== 'time' || !sleepTimer.value.endTime) {
return '00:00:00';
}
const remaining = Math.max(0, sleepTimer.value.endTime - Date.now());
const totalSeconds = Math.floor(remaining / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const formattedHours = hours.toString().padStart(2, '0');
const formattedMinutes = minutes.toString().padStart(2, '0');
const formattedSeconds = seconds.toString().padStart(2, '0');
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
});
// 监听剩余时间变化
let timerInterval: number | null = null;
onMounted(() => {
// 如果当前有定时器开始更新UI
if (hasTimerActive.value && timerType.value === 'time') {
startTimerUpdate();
}
// 监听定时器状态变化
watch(
() => [hasTimerActive.value, timerType.value],
([newHasTimer, newType]) => {
if (newHasTimer && newType === 'time') {
startTimerUpdate();
} else {
stopTimerUpdate();
}
}
);
});
// 启动定时器更新UI
function startTimerUpdate() {
stopTimerUpdate(); // 先停止之前的计时器
// 每秒更新UI
timerInterval = window.setInterval(() => {
// 更新刷新触发器,强制重新计算
refreshTrigger.value = Date.now();
}, 500) as unknown as number;
}
// 停止定时器更新UI
function stopTimerUpdate() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
onUnmounted(() => {
stopTimerUpdate();
});
</script>
<style lang="scss" scoped>
.control-btn.timer {
@apply cursor-pointer;
.iconfont {
@apply text-2xl m-4 transition-all duration-300 ease-in-out;
&:hover {
@apply text-green-500 transform scale-110;
}
}
&.mobile-timer {
@apply flex items-center justify-center;
height: 56px;
width: 56px;
}
}
// 主容器样式
.sleep-timer-container {
width: 380px;
height: auto;
@apply relative overflow-hidden;
border-radius: 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.1);
transform-origin: top center;
animation: popoverEnter 0.2s cubic-bezier(0.3, 0, 0.2, 1);
// 入场动画
@keyframes popoverEnter {
from {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// 移动端样式
&.mobile-sleep-timer-container {
width: 92vw;
max-width: 400px;
@apply rounded-t-2xl;
max-height: 500px;
}
// 背景毛玻璃效果
.sleep-timer-back {
@apply absolute top-0 left-0 w-full h-full dark:bg-gray-900 dark:bg-opacity-75 dark:border-gray-700;
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
background-color: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.2);
}
// 内容区域
.sleep-timer-content {
@apply p-6 relative z-10;
// 标题
.sleep-timer-title {
@apply text-xl font-bold text-center mb-6 text-gray-800 dark:text-gray-100;
letter-spacing: 0.5px;
}
// 激活状态显示
.sleep-timer-active {
@apply flex flex-col items-center;
// 定时状态卡片
.timer-status {
@apply flex flex-col items-center justify-center p-8 mb-5 w-full rounded-2xl dark:bg-gray-800 dark:bg-opacity-40 dark:shadow-gray-900/20;
background-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
// 定时值显示
.timer-value {
@apply text-4xl font-semibold mb-2 text-green-500;
&.countdown-timer {
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
}
}
// 标签文本
.timer-label {
@apply text-base text-gray-600 dark:text-gray-300;
}
}
// 取消按钮
.cancel-timer-btn {
@apply w-full py-3 text-base rounded-full transition-all duration-200;
&:hover {
@apply transform scale-105 shadow-md;
}
&:active {
@apply transform scale-95;
}
}
}
// 定时器选项区域
.sleep-timer-options {
@apply flex flex-col;
// 选项部分
.option-section {
@apply mb-7;
// 选项标题
.option-title {
@apply text-base font-medium mb-4 text-gray-700 dark:text-gray-200;
letter-spacing: 0.3px;
}
// 时间/歌曲选项容器
.time-options, .songs-options {
@apply flex flex-wrap gap-2;
// 选项按钮共享样式
.time-option-btn, .songs-option-btn {
@apply px-4 py-2 rounded-full text-gray-800 dark:text-gray-200 transition-all duration-200;
background-color: rgba(255, 255, 255, 0.5);
@apply dark:bg-gray-800 dark:bg-opacity-40 hover:bg-white dark:hover:bg-gray-700;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.1);
@apply dark:shadow-gray-900/20;
&:hover {
@apply transform scale-105 shadow-md;
}
&:active {
@apply transform scale-95;
}
}
// 自定义输入区域
.custom-time, .custom-songs {
@apply flex items-center space-x-2 mt-4 w-full;
// 输入框
.custom-time-input, .custom-songs-input {
@apply flex-1;
:deep(.n-input) {
@apply rounded-full dark:bg-gray-800 dark:bg-opacity-40 dark:border-gray-700;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background-color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
&:hover, &:focus {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
}
}
// 设置按钮
.custom-time-btn, .custom-songs-btn {
@apply py-2 px-4 rounded-full transition-all duration-200;
&:hover {
@apply transform scale-105 shadow-md;
}
&:active {
@apply transform scale-95;
}
}
}
}
}
// 播放列表结束选项
.playlist-end-section {
@apply mt-2;
.playlist-end-btn {
@apply py-3 text-base rounded-full text-gray-800 dark:text-gray-200 transition-all duration-200;
background-color: rgba(255, 255, 255, 0.5);
@apply dark:bg-gray-800 dark:bg-opacity-40 hover:bg-white dark:hover:bg-gray-700;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.1);
@apply dark:shadow-gray-900/20;
&:hover {
@apply transform scale-105 shadow-md;
}
&:active {
@apply transform scale-95;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div>
<!-- 定时关闭倒计时显示区域 -->
<div v-if="hasActiveSleepTimer" class="sleep-timer-countdown" @click="handleShowTimer">
<i class="iconfont ri-time-line mr-1"></i>
<span>{{ formattedRemainingTime }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { usePlayerStore } from '@/store/modules/player';
const { t } = useI18n();
// 定时器状态
const playerStore = usePlayerStore();
const { sleepTimer } = storeToRefs(playerStore);
const hasActiveSleepTimer = computed(() => playerStore.hasSleepTimerActive);
const refreshTrigger = ref(0);
// 检查定时器是否已结束
const checkTimerExpired = () => {
if (sleepTimer.value.type === 'time' && sleepTimer.value.endTime) {
const now = Date.now();
if (now >= sleepTimer.value.endTime) {
playerStore.clearSleepTimer();
}
}
}
// 在组件挂载时检查定时器状态
onMounted(() => {
checkTimerExpired();
});
// 倒计时显示
const formattedRemainingTime = computed(() => {
// 依赖刷新触发器强制更新
void refreshTrigger.value;
if (sleepTimer.value.type !== 'time' || !sleepTimer.value.endTime) {
if (sleepTimer.value.type === 'songs' && sleepTimer.value.remainingSongs) {
return t('player.sleepTimer.songsRemaining', { count: sleepTimer.value.remainingSongs });
}
if (sleepTimer.value.type === 'end') {
return t('player.sleepTimer.activeUntilEnd');
}
return '';
}
const remaining = Math.max(0, sleepTimer.value.endTime - Date.now());
const totalSeconds = Math.floor(remaining / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
});
// 监听剩余时间变化
let timerUpdateInterval: number | null = null;
watch(
() => hasActiveSleepTimer.value,
(newHasTimer) => {
if (newHasTimer && sleepTimer.value.type === 'time') {
startTimerUpdate();
} else if (!newHasTimer) {
stopTimerUpdate();
}
},
{ immediate: true }
);
// 启动定时器更新UI
function startTimerUpdate() {
stopTimerUpdate(); // 先停止之前的计时器
// 每秒更新UI
timerUpdateInterval = window.setInterval(() => {
// 更新刷新触发器,强制重新计算
refreshTrigger.value = Date.now();
}, 1000) as unknown as number;
}
// 停止定时器更新UI
function stopTimerUpdate() {
if (timerUpdateInterval) {
clearInterval(timerUpdateInterval);
timerUpdateInterval = null;
}
}
const handleShowTimer = () => {
playerStore.showSleepTimer = !playerStore.showSleepTimer;
};
// 播放器卸载时清除定时器
onUnmounted(() => {
stopTimerUpdate();
});
</script>
<style lang="scss" scoped>
.sleep-timer-countdown {
@apply fixed top-[28px] left-1/2 transform -translate-x-1/2 -translate-y-full py-1 px-3 rounded-b-lg bg-green-500 text-white text-sm flex items-center hover:scale-110 transition-all cursor-pointer;
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
z-index: 9998;
min-width: 80px;
text-align: center;
animation: fadeInDown 0.3s ease-out;
-webkit-app-region: no-drag;
@keyframes fadeInDown {
from {
transform: translate(-50%, -150%);
opacity: 0;
}
to {
transform: translate(-50%, -100%);
opacity: 1;
}
}
span {
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
font-weight: 500;
}
}
</style>

View File

@@ -23,51 +23,28 @@ export const USER_SET_OPTIONS = [
export const SEARCH_TYPES = [
{
label: '单曲',
label: 'search.search.single', // 单曲
key: 1
},
{
label: '专辑',
label: 'search.search.album', // 专辑
key: 10
},
// {
// label: '歌手',
// key: 100,
// },
{
label: '歌单',
label: 'search.search.playlist', // 歌单
key: 1000
},
// {
// label: '用户',
// key: 1002,
// },
{
label: 'MV',
label: 'search.search.mv', // MV
key: 1004
},
{
label: 'B站',
label: 'search.search.bilibili', // B站
key: 2000
}
// {
// label: '歌词',
// key: 1006,
// },
// {
// label: '电台',
// key: 1009,
// },
// {
// label: '视频',
// key: 1014,
// },
// {
// label: '综合',
// key: 1018,
// },
];
export const SEARCH_TYPE = {
MUSIC: 1, // 单曲
ALBUM: 10, // 专辑

View File

@@ -9,6 +9,7 @@ import pinia, { usePlayerStore } from '@/store';
import type { Artist, ILyricText, SongResult } from '@/type/music';
import { isElectron } from '@/utils';
import { getTextColors } from '@/utils/linearColor';
import { getSongUrl } from '@/store/modules/player';
const windowData = window as any;
@@ -18,7 +19,6 @@ export const lrcTimeArray = ref<number[]>([]); // 歌词时间数组
export const nowTime = ref(0); // 当前播放时间
export const allTime = ref(0); // 总播放时间
export const nowIndex = ref(0); // 当前播放歌词
export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const playMusic = computed(() => playerStore.playMusic as SongResult); // 当前播放歌曲
export const sound = ref<Howl | null>(audioService.getCurrentSound());
@@ -481,25 +481,64 @@ export const pause = () => {
}
};
// 增加矫正时间
export const addCorrectionTime = (time: number) => (correctionTime.value += time);
// 歌词矫正时间映射(每首歌独立)
const CORRECTION_KEY = 'lyric-correction-map';
const correctionTimeMap = ref<Record<string, number>>({});
// 减少矫正时间
export const reduceCorrectionTime = (time: number) => (correctionTime.value -= time);
// 初始化 correctionTimeMap
const loadCorrectionMap = () => {
try {
const raw = localStorage.getItem(CORRECTION_KEY);
correctionTimeMap.value = raw ? JSON.parse(raw) : {};
} catch {
correctionTimeMap.value = {};
}
};
const saveCorrectionMap = () => {
localStorage.setItem(CORRECTION_KEY, JSON.stringify(correctionTimeMap.value));
};
loadCorrectionMap();
// 歌词矫正时间,当前歌曲
export const correctionTime = ref(0);
// 切歌时自动读取矫正时间
watch(
() => playMusic.value?.id,
(id) => {
if (!id) return;
correctionTime.value = correctionTimeMap.value[id] ?? 0;
},
{ immediate: true }
);
/**
* 调整歌词矫正时间(每首歌独立)
* @param delta 增加/减少的秒数(正为加,负为减)
*/
export const adjustCorrectionTime = (delta: number) => {
const id = playMusic.value?.id;
if (!id) return;
const newVal = Math.max(-10, Math.min(10, (correctionTime.value ?? 0) + delta));
correctionTime.value = newVal;
correctionTimeMap.value[id] = newVal;
saveCorrectionMap();
};
// 获取当前播放歌词
export const isCurrentLrc = (index: number, time: number): boolean => {
const currentTime = lrcTimeArray.value[index];
const nextTime = lrcTimeArray.value[index + 1];
const nowTime = time + correctionTime.value;
const isTrue = nowTime > currentTime && nowTime < nextTime;
return isTrue;
const correctedTime = time + correctionTime.value;
return correctedTime > currentTime && correctedTime < nextTime;
};
// 获取当前播放歌词INDEX
export const getLrcIndex = (time: number): number => {
const correctedTime = time + correctionTime.value;
for (let i = 0; i < lrcTimeArray.value.length; i++) {
if (isCurrentLrc(i, time)) {
if (isCurrentLrc(i, correctedTime - correctionTime.value)) {
nowIndex.value = i;
return i;
}
@@ -516,15 +555,22 @@ const currentLrcTiming = computed(() => {
// 获取歌词样式
export const getLrcStyle = (index: number) => {
if (index === nowIndex.value) {
const currentTime = nowTime.value + correctionTime.value;
const start = lrcTimeArray.value[index];
const end = lrcTimeArray.value[index + 1] ?? (start + 1);
if (currentTime >= start && currentTime < end) {
// 当前句,显示进度
const progress = ((currentTime - start) / (end - start)) * 100;
return {
backgroundImage: `linear-gradient(to right, #ffffff ${currentLrcProgress.value}%, #ffffff8a ${currentLrcProgress.value}%)`,
backgroundImage: `linear-gradient(to right, #ffffff ${progress}%, #ffffff8a ${progress}%)`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
transition: 'background-image 0.1s linear'
};
}
// 其它句
return {};
};
@@ -827,24 +873,6 @@ export const initAudioListeners = async () => {
if (finalSound) {
// 更新全局 sound 引用
sound.value = finalSound;
// 如果当前处于播放状态,启动进度更新
if (playerStore.play) {
// 如果有保存的播放进度,应用它
if (playerStore.savedPlayProgress !== undefined) {
try {
// 设置音频位置
finalSound.seek(playerStore.savedPlayProgress);
// 同时更新时间显示
nowTime.value = playerStore.savedPlayProgress;
console.log('恢复播放进度:', playerStore.savedPlayProgress);
} catch (e) {
console.error('恢复播放进度失败:', e);
}
}
startProgressAnimation();
}
} else {
console.warn('无法获取音频实例,跳过进度更新初始化');
}
@@ -905,7 +933,7 @@ audioService.on('url_expired', async (expiredTrack) => {
// 处理网易云音乐重新获取URL
console.log('重新获取网易云音乐URL');
try {
const { getSongUrl } = await import('@/store/modules/player');
const newUrl = await getSongUrl(expiredTrack.id, expiredTrack as any);
if (newUrl) {

View File

@@ -0,0 +1,308 @@
import { cloneDeep } from 'lodash';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessage } from 'naive-ui';
import { getSongUrl } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import { isElectron } from '@/utils';
const ipcRenderer = isElectron ? window.electron.ipcRenderer : null;
// 全局下载管理(闭包模式)
const createDownloadManager = () => {
// 正在下载的文件集合
const activeDownloads = new Set<string>();
// 已经发送了通知的文件集合(避免重复通知)
const notifiedDownloads = new Set<string>();
// 事件监听器是否已初始化
let isInitialized = false;
// 监听器引用(用于清理)
let completeListener: ((event: any, data: any) => void) | null = null;
let errorListener: ((event: any, data: any) => void) | null = null;
return {
// 添加下载
addDownload: (filename: string) => {
activeDownloads.add(filename);
},
// 移除下载
removeDownload: (filename: string) => {
activeDownloads.delete(filename);
// 延迟清理通知记录
setTimeout(() => {
notifiedDownloads.delete(filename);
}, 5000);
},
// 标记文件已通知
markNotified: (filename: string) => {
notifiedDownloads.add(filename);
},
// 检查文件是否已通知
isNotified: (filename: string) => {
return notifiedDownloads.has(filename);
},
// 清理所有下载
clearDownloads: () => {
activeDownloads.clear();
notifiedDownloads.clear();
},
// 初始化事件监听器
initEventListeners: (message: any, t: any) => {
if (isInitialized) return;
// 移除可能存在的旧监听器
if (completeListener) {
ipcRenderer?.removeListener('music-download-complete', completeListener);
}
if (errorListener) {
ipcRenderer?.removeListener('music-download-error', errorListener);
}
// 创建新的监听器
completeListener = (_event, data) => {
if (!data.filename || !activeDownloads.has(data.filename)) return;
// 如果该文件已经通知过,则跳过
if (notifiedDownloads.has(data.filename)) return;
// 标记为已通知
notifiedDownloads.add(data.filename);
// 从活动下载移除
activeDownloads.delete(data.filename);
};
errorListener = (_event, data) => {
if (!data.filename || !activeDownloads.has(data.filename)) return;
// 如果该文件已经通知过,则跳过
if (notifiedDownloads.has(data.filename)) return;
// 标记为已通知
notifiedDownloads.add(data.filename);
// 显示失败通知
message.error(t('songItem.message.downloadFailed', {
filename: data.filename,
error: data.error || '未知错误'
}));
// 从活动下载移除
activeDownloads.delete(data.filename);
};
// 添加监听器
ipcRenderer?.on('music-download-complete', completeListener);
ipcRenderer?.on('music-download-error', errorListener);
isInitialized = true;
},
// 清理事件监听器
cleanupEventListeners: () => {
if (!isInitialized) return;
if (completeListener) {
ipcRenderer?.removeListener('music-download-complete', completeListener);
completeListener = null;
}
if (errorListener) {
ipcRenderer?.removeListener('music-download-error', errorListener);
errorListener = null;
}
isInitialized = false;
},
// 获取活跃下载数量
getActiveDownloadCount: () => {
return activeDownloads.size;
},
// 检查是否有特定文件正在下载
hasDownload: (filename: string) => {
return activeDownloads.has(filename);
}
};
};
// 创建单例下载管理器
const downloadManager = createDownloadManager();
export const useDownload = () => {
const { t } = useI18n();
const message = useMessage();
const isDownloading = ref(false);
// 初始化事件监听器
downloadManager.initEventListeners(message, t);
/**
* 下载单首音乐
* @param song 歌曲信息
* @returns Promise<void>
*/
const downloadMusic = async (song: SongResult) => {
if (isDownloading.value) {
message.warning(t('songItem.message.downloading'));
return;
}
try {
isDownloading.value = true;
const musicUrl = (await getSongUrl(song.id as number, cloneDeep(song), true)) as any;
if (!musicUrl) {
throw new Error(t('songItem.message.getUrlFailed'));
}
// 构建文件名
const artistNames = (song.ar || song.song?.artists)?.map((a) => a.name).join(',');
const filename = `${song.name} - ${artistNames}`;
// 检查是否已在下载
if (downloadManager.hasDownload(filename)) {
isDownloading.value = false;
return;
}
// 添加到活动下载集合
downloadManager.addDownload(filename);
const songData = cloneDeep(song);
songData.ar = songData.ar || songData.song?.artists;
// 发送下载请求
ipcRenderer?.send('download-music', {
url: typeof musicUrl === 'string' ? musicUrl : musicUrl.url,
filename,
songInfo: {
...songData,
downloadTime: Date.now()
},
type: musicUrl.type
});
message.success(t('songItem.message.downloadQueued'));
// 简化的监听逻辑,基本通知由全局监听器处理
setTimeout(() => {
isDownloading.value = false;
}, 2000);
} catch (error: any) {
console.error('Download error:', error);
isDownloading.value = false;
message.error(error.message || t('songItem.message.downloadFailed'));
}
};
/**
* 批量下载音乐
* @param songs 歌曲列表
* @returns Promise<void>
*/
const batchDownloadMusic = async (songs: SongResult[]) => {
if (isDownloading.value) {
message.warning(t('favorite.downloading'));
return;
}
if (songs.length === 0) {
message.warning(t('favorite.selectSongsFirst'));
return;
}
try {
isDownloading.value = true;
message.success(t('favorite.downloading'));
let successCount = 0;
let failCount = 0;
const totalCount = songs.length;
// 下载进度追踪
const trackProgress = () => {
if (successCount + failCount === totalCount) {
isDownloading.value = false;
message.success(t('favorite.downloadSuccess'));
}
};
// 并行获取所有歌曲的下载链接
const downloadUrls = await Promise.all(
songs.map(async (song) => {
try {
const data = (await getSongUrl(song.id, song, true)) as any;
return { song, ...data };
} catch (error) {
console.error(`获取歌曲 ${song.name} 下载链接失败:`, error);
failCount++;
return { song, url: null };
}
})
);
// 开始下载有效的链接
downloadUrls.forEach(({ song, url, type }) => {
if (!url) {
failCount++;
trackProgress();
return;
}
const songData = cloneDeep(song);
const filename = `${song.name} - ${(song.ar || song.song?.artists)?.map((a) => a.name).join(',')}`;
// 检查是否已在下载
if (downloadManager.hasDownload(filename)) {
failCount++;
trackProgress();
return;
}
// 添加到活动下载集合
downloadManager.addDownload(filename);
const songInfo = {
...songData,
ar: songData.ar || songData.song?.artists,
downloadTime: Date.now()
};
ipcRenderer?.send('download-music', {
url,
filename,
songInfo,
type
});
successCount++;
});
// 所有下载开始后,检查进度
trackProgress();
} catch (error) {
console.error('下载失败:', error);
isDownloading.value = false;
message.destroyAll();
message.error(t('favorite.downloadFailed'));
}
};
return {
isDownloading,
downloadMusic,
batchDownloadMusic
};
};

View File

@@ -0,0 +1,188 @@
import { usePlayerStore } from '@/store';
import type { SongResult } from '@/type/music';
import { computed, ref } from 'vue';
import { getImgUrl } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
import { useMessage, useDialog } from 'naive-ui';
import { useI18n } from 'vue-i18n';
import { useDownload } from './useDownload';
import { useArtist } from './useArtist';
export function useSongItem(props: {
item: SongResult;
canRemove?: boolean;
}) {
const { t } = useI18n();
const playerStore = usePlayerStore();
const message = useMessage();
const dialog = useDialog();
const { downloadMusic } = useDownload();
const { navigateToArtist } = useArtist();
// 状态变量
const showDropdown = ref(false);
const dropdownX = ref(0);
const dropdownY = ref(0);
const isHovering = ref(false);
// 计算属性
const play = computed(() => playerStore.isPlay);
const playMusic = computed(() => playerStore.playMusic);
const playLoading = computed(
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
);
const isPlaying = computed(() => playMusic.value.id === props.item.id);
// 收藏与不喜欢状态
const isFavorite = computed(() => {
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.favoriteList.includes(numericId);
});
const isDislike = computed(() => {
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.dislikeList.includes(numericId);
});
// 获取艺术家列表
const artists = computed(() => {
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
});
// 处理图片加载
const handleImageLoad = async (imageElement: HTMLImageElement) => {
if (!imageElement) return;
const { backgroundColor, primaryColor } = await getImageBackground(imageElement);
props.item.backgroundColor = backgroundColor;
props.item.primaryColor = primaryColor;
};
// 播放音乐
const playMusicEvent = async (item: SongResult) => {
try {
const result = await playerStore.setPlay(item);
if (!result) {
throw new Error('播放失败');
}
return true;
} catch (error) {
console.error('播放出错:', error);
return false;
}
};
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e && e.stopPropagation();
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(numericId);
}
};
// 切换不喜欢状态
const toggleDislike = async (e: Event) => {
e && e.stopPropagation();
if (isDislike.value) {
playerStore.removeFromDislikeList(props.item.id);
return;
}
dialog.warning({
title: t('songItem.dialog.dislike.title'),
content: t('songItem.dialog.dislike.content'),
positiveText: t('songItem.dialog.dislike.positiveText'),
negativeText: t('songItem.dialog.dislike.negativeText'),
onPositiveClick: () => {
playerStore.addToDislikeList(props.item.id);
}
});
};
// 添加到下一首播放
const handlePlayNext = () => {
playerStore.addToNextPlay(props.item);
message.success(t('songItem.message.addedToNextPlay'));
};
// 获取歌曲时长
const getDuration = (item: SongResult): number => {
if (item.duration) return item.duration;
if (typeof item.dt === 'number') return item.dt;
return 0;
};
// 格式化时长
const formatDuration = (ms: number): string => {
if (!ms) return '--:--';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
// 处理右键菜单
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
};
// 处理菜单点击
const handleMenuClick = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
};
// 处理艺术家点击
const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
// 鼠标悬停处理
const handleMouseEnter = () => {
isHovering.value = true;
};
const handleMouseLeave = () => {
isHovering.value = false;
};
return {
t,
play,
playMusic,
playLoading,
isPlaying,
isFavorite,
isDislike,
artists,
showDropdown,
dropdownX,
dropdownY,
isHovering,
playerStore,
message,
getImgUrl,
handleImageLoad,
playMusicEvent,
toggleFavorite,
toggleDislike,
handlePlayNext,
getDuration,
formatDuration,
handleContextMenu,
handleMenuClick,
handleArtistClick,
handleMouseEnter,
handleMouseLeave,
downloadMusic
};
}

View File

@@ -0,0 +1,92 @@
import { ref } from 'vue';
/**
* 页面缩放功能的组合式API
* 提供页面缩放相关的状态和方法
*/
export function useZoom() {
// 缩放相关常量
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 1.5;
const ZOOM_STEP = 0.05; // 5%的步长
// 当前缩放因子
const zoomFactor = ref(1);
// 初始化获取当前缩放比例
const initZoomFactor = async () => {
try {
const currentZoom = await window.ipcRenderer.invoke('get-content-zoom');
zoomFactor.value = currentZoom;
} catch (error) {
console.error('获取缩放比例失败:', error);
}
};
// 增加缩放比例保证100%为节点
const increaseZoom = () => {
let newZoom;
// 如果当前缩放低于100%并且增加后会超过100%则直接设为100%
if (zoomFactor.value < 1.0 && zoomFactor.value + ZOOM_STEP > 1.0) {
newZoom = 1.0; // 精确设置为100%
} else {
newZoom = Math.min(MAX_ZOOM, Math.round((zoomFactor.value + ZOOM_STEP) * 20) / 20);
}
setZoomFactor(newZoom);
};
// 减少缩放比例保证100%为节点
const decreaseZoom = () => {
let newZoom;
// 如果当前缩放大于100%并且减少后会低于100%则直接设为100%
if (zoomFactor.value > 1.0 && zoomFactor.value - ZOOM_STEP < 1.0) {
newZoom = 1.0; // 精确设置为100%
} else {
newZoom = Math.max(MIN_ZOOM, Math.round((zoomFactor.value - ZOOM_STEP) * 20) / 20);
}
setZoomFactor(newZoom);
};
// 重置缩放比例到系统建议值
const resetZoom = async () => {
try {
setZoomFactor(1);
} catch (error) {
console.error('重置缩放比例失败:', error);
}
};
// 设置为100%标准缩放
const setZoom100 = () => {
setZoomFactor(1.0);
};
// 设置缩放比例
const setZoomFactor = (zoom: number) => {
window.ipcRenderer.send('set-content-zoom', zoom);
zoomFactor.value = zoom;
};
// 检查是否为100%缩放
const isZoom100 = () => {
return Math.abs(zoomFactor.value - 1.0) < 0.001;
};
return {
zoomFactor,
initZoomFactor,
increaseZoom,
decreaseZoom,
resetZoom,
setZoom100,
setZoomFactor,
isZoom100,
MIN_ZOOM,
MAX_ZOOM,
ZOOM_STEP
};
}

View File

@@ -9,7 +9,7 @@
<!-- 搜索栏 -->
<search-bar />
<!-- 主页面路由 -->
<div class="main-content" :native-scrollbar="false">
<div class="main-content" :native-scrollbar="false" :class="{'mobile-content': !shouldShowMobileMenu}">
<router-view
v-slot="{ Component }"
class="main-page"
@@ -20,8 +20,8 @@
</keep-alive>
</router-view>
</div>
<play-bottom height="5rem" />
<app-menu v-if="isMobile && !playerStore.musicFull" class="menu" :menus="menus" />
<play-bottom />
<app-menu v-if="shouldShowMobileMenu" class="menu" :menus="menus" />
</div>
</div>
<!-- 底部音乐播放 -->
@@ -37,19 +37,22 @@
:style="isMobile && playerStore.musicFull ? 'bottom: 0;' : ''"
/>
</template>
<!-- 下载管理抽屉 -->
<download-drawer
v-if="
isElectron &&
(settingsStore.setData?.alwaysShowDownloadButton ||
settingsStore.showDownloadDrawer ||
settingsStore.setData?.hasDownloadingTasks)
"
/>
</div>
<install-app-modal v-if="!isElectron"></install-app-modal>
<update-modal v-if="isElectron" />
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
<SleepTimerTop v-if="!isMobile"/>
<!-- 下载管理抽屉 -->
<download-drawer
v-if="
isElectron &&
(settingsStore.setData?.alwaysShowDownloadButton ||
settingsStore.showDownloadDrawer ||
settingsStore.setData?.hasDownloadingTasks)
"
/>
<!-- 播放列表抽屉 -->
<playing-list-drawer />
</div>
</template>
@@ -67,6 +70,7 @@ import { useMenuStore } from '@/store/modules/menu';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { isElectron, isMobile } from '@/utils';
import SleepTimerTop from '@/components/player/SleepTimerTop.vue';
const keepAliveInclude = computed(() => {
const allRoutes = [...homeRouter, ...otherRouter];
@@ -88,7 +92,7 @@ const PlayBar = defineAsyncComponent(() => import('@/components/player/PlayBar.v
const MobilePlayBar = defineAsyncComponent(() => import('@/components/player/MobilePlayBar.vue'));
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
const PlayingListDrawer = defineAsyncComponent(() => import('@/components/player/PlayingListDrawer.vue'));
const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue'));
const playerStore = usePlayerStore();
@@ -99,6 +103,16 @@ const isPlay = computed(() => playerStore.playMusic && playerStore.playMusic.id)
const { menus } = menuStore;
const route = useRoute();
// 判断当前路由是否应该在移动端显示AppMenu
const shouldShowMobileMenu = computed(() => {
// 过滤出在menus中定义的路径
const menuPaths = menus.map((item: any) => item.path);
// 检查当前路由路径是否在menus中
return menuPaths.includes(route.path) && isMobile.value && !playerStore.musicFull;
});
provide('shouldShowMobileMenu', shouldShowMobileMenu);
onMounted(() => {
settingsStore.initializeSettings();
settingsStore.initializeTheme();
@@ -108,9 +122,9 @@ const showPlaylistDrawer = ref(false);
const currentSongId = ref<number | undefined>();
// 提供一个方法来打开歌单抽屉
const openPlaylistDrawer = (songId: number) => {
const openPlaylistDrawer = (songId: number, isOpen: boolean = true) => {
currentSongId.value = songId;
showPlaylistDrawer.value = true;
showPlaylistDrawer.value = isOpen;
};
// 将方法提供给全局
@@ -152,7 +166,10 @@ provide('openPlaylistDrawer', openPlaylistDrawer);
overflow: auto;
display: block;
flex: none;
padding-bottom: 70px;
}
.mobile-content {
height: calc(100vh - 75px);
}
}
</style>

View File

@@ -9,19 +9,15 @@
</div>
<div class="app-menu-list">
<div v-for="(item, index) in menus" :key="item.path" class="app-menu-item">
<router-link class="app-menu-item-link" :to="item.path">
<i
class="iconfont app-menu-item-icon"
:style="iconStyle(index)"
:class="item.meta.icon"
></i>
<span
v-if="isText"
class="app-menu-item-text ml-3"
:class="isChecked(index) ? 'text-green-500' : ''"
>{{ item.meta.title }}</span
>
</router-link>
<n-tooltip :delay="200" :disabled="isText || isMobile" placement="bottom">
<template #trigger>
<router-link class="app-menu-item-link" :to="item.path">
<i class="iconfont app-menu-item-icon" :style="iconStyle(index)" :class="item.meta.icon"></i>
<span v-if="isText" class="app-menu-item-text ml-3" :class="isChecked(index) ? 'text-green-500' : ''">{{ t(item.meta.title) }}</span>
</router-link>
</template>
<div v-if="!isText">{{ t(item.meta.title) }}</div>
</n-tooltip>
</div>
</div>
</div>
@@ -30,8 +26,11 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import icon from '@/assets/icon.png';
import { isMobile } from '@/utils';
const props = defineProps({
size: {
@@ -61,6 +60,8 @@ watch(
}
);
const { t } = useI18n();
const isChecked = (index: number) => {
return path.value === props.menus[index].path;
};
@@ -83,6 +84,7 @@ const isText = ref(false);
.app-menu-expanded {
@apply w-[160px];
.app-menu-item {
@apply hover:bg-gray-100 dark:hover:bg-gray-800 rounded mr-4;
}
@@ -117,7 +119,7 @@ const isText = ref(false);
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-none border-gray-200 dark:border-gray-700;
&-header {
display: none;
@@ -129,9 +131,16 @@ const isText = ref(false);
&-item {
&-link {
@apply my-2 w-auto;
@apply my-2 w-auto px-2;
width: auto !important;
margin-top: 8px;
margin-bottom: 8px;
}
}
&-expanded {
@apply w-full;
}
}
}
</style>

View File

@@ -19,7 +19,7 @@
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
<div class="w-20 px-3 flex justify-between items-center">
<div>
{{ searchTypeOptions.find((item) => item.key === searchStore.searchType)?.label }}
{{ searchTypeOptions.find(item => item.key === searchStore.searchType)?.label }}
</div>
<i class="iconfont icon-xiasanjiaoxing"></i>
</div>
@@ -62,6 +62,24 @@
<i class="iconfont ri-settings-3-line"></i>
<span>{{ t('comp.searchBar.set') }}</span>
</div>
<div class="menu-item" v-if="isElectron">
<i class="iconfont ri-zoom-in-line"></i>
<span>{{ t('comp.searchBar.zoom')}}</span>
<div class="zoom-controls ml-auto">
<n-button quaternary circle size="tiny" @click="decreaseZoom">
<i class="ri-subtract-line"></i>
</n-button>
<n-tooltip trigger="hover">
<template #trigger>
<span class="zoom-value" :class="{'zoom-100': isZoom100()}" @click="resetZoom">{{ Math.round(zoomFactor * 100) }}%</span>
</template>
{{ isZoom100() ? t('comp.searchBar.zoom100'): t('comp.searchBar.resetZoom')}}
</n-tooltip>
<n-button quaternary circle size="tiny" @click="increaseZoom">
<i class="ri-add-line"></i>
</n-button>
</div>
</div>
<div class="menu-item">
<i class="iconfont" :class="isDark ? 'ri-moon-line' : 'ri-sun-line'"></i>
<span>{{ t('comp.searchBar.theme') }}</span>
@@ -108,17 +126,17 @@
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getSearchKeyword } from '@/api/home';
import { getUserDetail } from '@/api/login';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
import Coffee from '@/components/Coffee.vue';
import { useZoom } from '@/hooks/useZoom';
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, isElectron } from '@/utils';
import { checkUpdate, UpdateResult } from '@/utils/update';
import config from '../../../../package.json';
@@ -128,7 +146,17 @@ const searchStore = useSearchStore();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const userSetOptions = ref(USER_SET_OPTIONS);
const { t } = useI18n();
const { t, locale } = useI18n();
// 使用缩放hook
const {
zoomFactor,
initZoomFactor,
increaseZoom,
decreaseZoom,
resetZoom,
isZoom100
} = useZoom();
// 显示返回按钮
const showBackButton = computed(() => {
@@ -174,7 +202,7 @@ const restartApp = () => {
};
const toLogin = () => {
router.push('/login');
router.push('/user');
};
// 页面初始化
@@ -182,6 +210,7 @@ onMounted(() => {
loadHotSearchKeyword();
loadPage();
checkForUpdates();
isElectron && initZoomFactor();
});
const isDark = computed({
@@ -241,7 +270,15 @@ const selectSearchType = (key: number) => {
}
};
const searchTypeOptions = ref(SEARCH_TYPES);
const rawSearchTypes = ref(SEARCH_TYPES)
const searchTypeOptions = computed(() => {
// 引用 locale 以创建响应式依赖
locale.value;
return rawSearchTypes.value.map(type => ({
label: t(type.label),
key: type.key
}))
});
const selectItem = async (key: string) => {
// switch 判断
@@ -266,7 +303,7 @@ const selectItem = async (key: string) => {
};
const toGithub = () => {
window.open('http://donate.alger.fun', '_blank');
window.open('http://donate.alger.fun/download', '_blank');
};
const updateInfo = ref<UpdateResult>({
@@ -381,6 +418,23 @@ const toGithubRelease = () => {
@apply bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300;
}
}
// 缩放控制样式
.zoom-controls {
@apply flex items-center gap-1;
.zoom-value {
@apply text-xs px-2 py-0.5 rounded cursor-pointer;
@apply bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300;
@apply hover:bg-gray-200 dark:hover:bg-gray-600;
transition: all 0.2s ease;
&.zoom-100 {
@apply bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 font-bold;
@apply hover:bg-green-200 dark:hover:bg-green-800;
}
}
}
}
}
}

View File

@@ -1,24 +0,0 @@
<template>
<div class="lrc-full">
{{ lrcIndex }}
</div>
</template>
<script setup lang="ts">
defineProps({
lrcList: {
type: Array,
default: () => []
},
lrcIndex: {
type: Number,
default: 0
},
lrcTime: {
type: Number,
default: 0
}
});
</script>
<style scoped lang="scss"></style>

View File

@@ -3,7 +3,7 @@ const layoutRouter = [
path: '/',
name: 'home',
meta: {
title: '首页',
title: 'comp.home',
icon: 'icon-Home',
keepAlive: true,
isMobile: true
@@ -14,7 +14,7 @@ const layoutRouter = [
path: '/search',
name: 'search',
meta: {
title: '搜索',
title: 'comp.search',
noScroll: true,
icon: 'icon-Search',
keepAlive: true,
@@ -26,7 +26,7 @@ const layoutRouter = [
path: '/list',
name: 'list',
meta: {
title: '歌单',
title: 'comp.list',
icon: 'icon-Paper',
keepAlive: true,
isMobile: true
@@ -34,32 +34,33 @@ const layoutRouter = [
component: () => import('@/views/list/index.vue')
},
{
path: '/mv',
name: 'mv',
path: '/toplist',
name: 'toplist',
meta: {
title: 'MV',
icon: 'icon-recordfill',
title: 'comp.toplist',
icon: 'ri-bar-chart-grouped-fill',
keepAlive: true,
isMobile: true
},
component: () => import('@/views/toplist/index.vue')
},
{
path: '/mv',
name: 'mv',
meta: {
title: 'comp.mv',
icon: 'icon-recordfill',
keepAlive: true,
isMobile: false
},
component: () => import('@/views/mv/index.vue')
},
// {
// path: '/history',
// name: 'history',
// meta: {
// title: '历史',
// icon: 'icon-a-TicketStar',
// keepAlive: true,
// },
// component: () => import('@/views/history/index.vue'),
// },
{
path: '/history',
name: 'history',
component: () => import('@/views/historyAndFavorite/index.vue'),
meta: {
title: '收藏历史',
title: 'comp.history',
icon: 'icon-a-TicketStar',
keepAlive: true
}
@@ -68,7 +69,7 @@ const layoutRouter = [
path: '/user',
name: 'user',
meta: {
title: '用户',
title: 'comp.user',
icon: 'icon-Profile',
keepAlive: true,
noScroll: true,
@@ -80,10 +81,11 @@ const layoutRouter = [
path: '/set',
name: 'set',
meta: {
title: '设置',
title: 'comp.settings',
icon: 'ri-settings-3-fill',
keepAlive: true,
noScroll: true
noScroll: true,
back: true
},
component: () => import('@/views/set/index.vue')
}

View File

@@ -20,30 +20,20 @@ const getSettingsStore = () => {
const loginRouter = {
path: '/login',
name: 'login',
mate: {
meta: {
keepAlive: true,
title: '登录',
icon: 'icon-Home'
icon: 'icon-Home',
back: true
},
component: () => import('@/views/login/index.vue')
};
const setRouter = {
path: '/set',
name: 'set',
mate: {
keepAlive: true,
title: '设置',
icon: 'icon-Home'
},
component: () => import('@/views/set/index.vue')
};
const routes = [
{
path: '/',
component: AppLayout,
children: [...homeRouter, loginRouter, setRouter, ...otherRouter]
children: [...homeRouter, loginRouter, ...otherRouter]
},
{
path: '/lyric',

View File

@@ -4,7 +4,7 @@ const otherRouter = [
name: 'userFollows',
meta: {
title: '关注列表',
keepAlive: true,
keepAlive: false,
showInMenu: false,
back: true
},
@@ -15,18 +15,30 @@ const otherRouter = [
name: 'userFollowers',
meta: {
title: '粉丝列表',
keepAlive: true,
keepAlive: false,
showInMenu: false,
back: true
},
component: () => import('@/views/user/followers.vue')
},
{
path: '/downloads',
name: 'downloads',
meta: {
title: '下载管理',
keepAlive: true,
showInMenu: true,
back: true,
icon: 'ri-download-cloud-2-line'
},
component: () => import('@/views/download/DownloadPage.vue')
},
{
path: '/user/detail/:uid',
name: 'userDetail',
meta: {
title: '用户详情',
keepAlive: true,
keepAlive: false,
showInMenu: false,
back: true
},
@@ -64,6 +76,16 @@ const otherRouter = [
back: true
},
component: () => import('@/views/music/MusicListPage.vue')
}
},
{
path: '/playlist/import',
name: 'playlistImport',
meta: {
title: '歌单导入',
keepAlive: true,
back: true
},
component: () => import('@/views/playlist/ImportPlaylist.vue')
},
];
export default otherRouter;

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