Compare commits

...

66 Commits

Author SHA1 Message Date
algerkong
7891bf45fd feat: 更新版本至 4.8.2 2025-06-28 18:16:19 +08:00
algerkong
2f339b1373 🐞 fix: 修复在歌词界面添加到歌单抽屉被遮挡问题 2025-06-28 17:46:24 +08:00
algerkong
749a2a69c4 feat: 重新设计歌词页面的迷你播放栏 2025-06-28 17:40:57 +08:00
algerkong
5b97010b32 🐞 fix: 修复解析错误问题, 优化播放效果 2025-06-28 17:31:37 +08:00
algerkong
694dff425b feat: 添加清除自定义音源功能 2025-06-28 17:26:07 +08:00
algerkong
e8cf253567 feat: 更新版本至 4.8.1 2025-06-27 18:59:40 +08:00
algerkong
4d831777f1 🔧 chore: 移除统计,更新支持的音乐源列表 2025-06-27 18:49:37 +08:00
algerkong
95c255d2ba 🐞 fix: 修复网页端和快捷键无法调整音量的问题 2025-06-23 20:58:08 +08:00
algerkong
d739a6701b feat: 添加通知抽屉组件,优化用户体验并支持多平台提示 2025-06-23 20:56:50 +08:00
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
98 changed files with 8181 additions and 2177 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,9 +1,4 @@
# 你的接口地址 (必填)
VITE_API = http://127.0.0.1:30488
# 音乐破解接口地址 web端
VITE_API_MUSIC = ***
# 本地运行代理地址
VITE_API_LOCAL = /api
VITE_API_MUSIC_PROXY = /music
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

View File

@@ -1,58 +1,74 @@
# 更新日志
## v4.7.0
> 如果更新遇到问题,请前往 <a href="http://donate.alger.fun/download" target="_blank">下载 AlgerMusicPlayer</a>
> 请我喝咖啡(支持作者) ☕️ <a href="http://donate.alger.fun/donate" target="_blank" style="color: red; font-weight: bold;">赏你</a>
> 帮我点个 star <a href="https://github.com/algerkong/AlgerMusicPlayer" target="_blank">github star</a>
> QQ频道 <a href="https://pd.qq.com/s/cs056n33q?b=5" target="_blank">加入频道</a>
### ✨ 新功能
- 替换扁平风格图标 ([c8e6db1](https://github.com/algerkong/AlgerMusicPlayer/commit/c8e6db1))
- 添加双击歌曲播放功能 ([258828f](https://github.com/algerkong/AlgerMusicPlayer/commit/258828f))
- 添加排行榜页面 ([a8010c8](https://github.com/algerkong/AlgerMusicPlayer/commit/a8010c8))
- 添加播放速度控制功能 ([6554736](https://github.com/algerkong/AlgerMusicPlayer/commit/6554736)) (#241) [感谢Java-wyx的 PR](https://github.com/Java-wyx)
- 修改播放列表展示形式,添加清空播放列表功能 ([2e96161](https://github.com/algerkong/AlgerMusicPlayer/commit/2e96161))
- 歌单列表添加布局切换、播放全部、收藏、添加到播放列表功能 ([b32408b](https://github.com/algerkong/AlgerMusicPlayer/commit/b32408b))
- 优化播放栏,整合高级控制菜单([f5f0dbb](https://github.com/algerkong/AlgerMusicPlayer/commit/f5f0dbb))
- 添加顶部定时显示,定时器过期检查功能, ([2dd4535](https://github.com/algerkong/AlgerMusicPlayer/commit/2dd4535))([170ac45](https://github.com/algerkong/AlgerMusicPlayer/commit/170ac45))
- 添加 mac 状态栏播放按键控制功能开关 ([2476fbd](https://github.com/algerkong/AlgerMusicPlayer/commit/2476fbd))
- 收藏列表添加升序降序排列 ([2803d40](https://github.com/algerkong/AlgerMusicPlayer/commit/2803d40))
- 为歌曲下拉菜单添加圆角样式,优化歌曲预览布局 ([5bef0e4](https://github.com/algerkong/AlgerMusicPlayer/commit/5bef0e4))
- 在用户歌单中添加"我创建的"标签,优化获取用户歌单的逻辑 ([d56a25e](https://github.com/algerkong/AlgerMusicPlayer/commit/d56a25e))
- 在歌手详情页添加歌曲操作工具栏,支持播放全部、添加到播放列表和搜索功能 ([6048e24](https://github.com/algerkong/AlgerMusicPlayer/commit/6048e24))
- 添加所有用户的关注和粉丝列表点击,优化播放排行获取和无权限展示 ([0c74291](https://github.com/algerkong/AlgerMusicPlayer/commit/0c74291))
- 添加 macOS 下点击 Dock 图标激活主窗口的功能 ([7fa0fa5](https://github.com/algerkong/AlgerMusicPlayer/commit/7fa0fa5))
- 添加鼠标滚轮调整音量功能,并显示音量百分比 ([95af222](https://github.com/algerkong/AlgerMusicPlayer/commit/95af222))
- 添加歌曲右键不喜欢功能以过滤每日推荐歌曲 ([a0935c7](https://github.com/algerkong/AlgerMusicPlayer/commit/a0935c7))
- 添加音乐平台链接,优化移动端样式 ([01a3a7a](https://github.com/algerkong/AlgerMusicPlayer/commit/01a3a7a))
- 在应用菜单中添加工具提示功能 ([5084da3](https://github.com/algerkong/AlgerMusicPlayer/commit/5084da3))
## v4.8.2
### 🎨 优化
- 重新设计pc端歌词页面Mini播放栏
- 添加清除歌曲自定义解析功能
### 🐛 Bug 修复
- 修复播放音乐时 URL 未正确更新的问题 ([91b1ff7](https://github.com/algerkong/AlgerMusicPlayer/commit/91b1ff7))
- 修复播放状态判断逻辑 ([e9fe900](https://github.com/algerkong/AlgerMusicPlayer/commit/e9fe900))
- 修复收藏歌曲功能未同步问题 ([69b1e54](https://github.com/algerkong/AlgerMusicPlayer/commit/69b1e54))
- 优化下载列表显示 ([df5ecb6](https://github.com/algerkong/AlgerMusicPlayer/commit/df5ecb6))
- 修复歌曲单独解析失败问题
- 修复歌词页面加入歌单抽屉被遮挡问题
## v4.8.1
### 🐛 Bug 修复
- 修复无法快捷键调整问题
### 🎨 优化
- 添加 husky 预提交和预推送钩子,运行类型检查以确保代码质量 ([a449b74](https://github.com/algerkong/AlgerMusicPlayer/commit/a449b74))
- 重构歌曲组件,添加基础组件和多种样式,优化播放列表抽屉功能 ([ad7b504](https://github.com/algerkong/AlgerMusicPlayer/commit/ad7b504))
- 将下载逻辑提取优化([ca51020](https://github.com/algerkong/AlgerMusicPlayer/commit/ca51020))
- 优化B站音频解析功能 ([e47c84e](https://github.com/algerkong/AlgerMusicPlayer/commit/e47c84e))
- 统一音源选项的标签格式 ([54cbb84](https://github.com/algerkong/AlgerMusicPlayer/commit/54cbb84))
- 移动端去除定时关闭功能 ([f7951ec](https://github.com/algerkong/AlgerMusicPlayer/commit/f7951ec))
- 优化网页端下载程序功能 ([56b3ecf](https://github.com/algerkong/AlgerMusicPlayer/commit/56b3ecf))
- 退出登录时刷新页面 ([54f82d3](https://github.com/algerkong/AlgerMusicPlayer/commit/54f82d3))
- 优化音乐资源解析
- 去除无用代码,优化加载速度
### 其他优化
([ae1a7c9](https://github.com/algerkong/AlgerMusicPlayer/commit/ae1a7c9))
([f68f499](https://github.com/algerkong/AlgerMusicPlayer/commit/f68f499))
([6d4e6ef](https://github.com/algerkong/AlgerMusicPlayer/commit/6d4e6ef))
([2379b2c](https://github.com/algerkong/AlgerMusicPlayer/commit/2379b2c))
([33a1057](https://github.com/algerkong/AlgerMusicPlayer/commit/33a1057))
([54d66d0](https://github.com/algerkong/AlgerMusicPlayer/commit/54d66d0))
([3c792ce](https://github.com/algerkong/AlgerMusicPlayer/commit/3c792ce))
([35b84f3](https://github.com/algerkong/AlgerMusicPlayer/commit/35b84f3))
([278db37](https://github.com/algerkong/AlgerMusicPlayer/commit/278db37))
## v4.8.0
### ✨ 新功能
- 增强移动端播放页面效果,添加播放模式选择,添加横屏模式,添加播放列表功能 ([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 修复
- 修复音频初始化音量问题,完善翻译 ([#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))
### 🎨 优化
- 优化歌曲列表组件布局([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

@@ -23,6 +23,9 @@
<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#)
主要功能如下
- 🎵 音乐推荐
- 🔐 网易云账号登录与同步
@@ -41,12 +44,11 @@
- 🎼 音乐功能
- 支持歌单、MV、专辑等完整音乐服务
- 灰色音乐资源解析(基于 @unblockneteasemusic/server
- 音乐单独解析
- 音乐资源解析(基于 @unblockneteasemusic/server
- EQ均衡器
- 定时播放 远程控制播放 倍速播放
- 高品质音乐
- 音乐文件下载(支持右键下载和批量下载, 附带歌词封面等信息)
- 音乐文件下载
- 搜索 MV 音乐 专辑 歌单 bilibili
- 音乐单独选择音源解析
- 🚀 技术特性
@@ -72,17 +74,10 @@
npm install
npm run dev
```
## 项目打包
```bash
# web
npm run build
# win
npm run build:win
# mac
npm run build:mac
# linux
npm run build:linux
```
## 开发文档
点击这里[开发文档](./DEV.md)
## 赞赏☕️
@@ -100,5 +95,6 @@ npm run build:linux
## 欢迎提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']
}
}

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,6 +1,6 @@
{
"name": "AlgerMusicPlayer",
"version": "4.7.0",
"version": "4.8.2",
"description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>",
"main": "./out/main/index.js",
@@ -14,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",
@@ -28,9 +29,12 @@
"cors": "^2.8.5",
"electron-store": "^8.1.0",
"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",
@@ -58,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",
@@ -164,6 +168,13 @@
"x64",
"arm64"
]
},
{
"target": "rpm",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",

View File

@@ -27,6 +27,7 @@ export default {
refresh: 'Refresh',
retry: 'Retry',
reset: 'Reset',
back: 'Back',
copySuccess: 'Copied to clipboard',
copyFailed: 'Copy failed',
validation: {

View File

@@ -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',
@@ -116,5 +120,73 @@ export default {
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,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',
@@ -36,7 +38,8 @@ export default {
failed: 'Reparse failed',
warning: 'Please select a music source',
bilibiliNotSupported: 'Bilibili videos do not support reparsing',
processing: 'Processing...'
processing: 'Processing...',
clear: 'Clear Custom Source'
},
playBar: {
expand: 'Expand Lyrics',

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

@@ -168,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

@@ -27,6 +27,7 @@ export default {
refresh: '刷新',
retry: '重试',
reset: '重置',
back: '返回',
copySuccess: '已复制到剪贴板',
copyFailed: '复制失败',
validation: {

View File

@@ -88,7 +88,11 @@ export default {
restart: '重启',
refresh: '刷新',
currentVersion: '当前版本',
searchPlaceholder: '搜索点什么吧...'
searchPlaceholder: '搜索点什么吧...',
zoom: '页面缩放',
zoom100: '标准缩放100%',
resetZoom: '点击重置缩放',
zoomDefault: '标准缩放'
},
titleBar: {
closeTitle: '请选择关闭方式',
@@ -114,5 +118,73 @@ export default {
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

@@ -11,10 +11,12 @@ export default {
mute: '静音',
unmute: '取消静音',
songNum: '歌曲总数:{num}',
addCorrection: '提前 {num} 秒',
subtractCorrection: '延迟 {num} 秒',
playFailed: '当前歌曲播放失败,播放下一首',
playMode: {
sequence: '顺序播放',
loop: '循环播放',
loop: '单曲循环',
random: '随机播放'
},
fullscreen: {
@@ -36,7 +38,8 @@ export default {
failed: '重新解析失败',
warning: '请选择一个音源',
bilibiliNotSupported: 'B站视频不支持重新解析',
processing: '解析中...'
processing: '解析中...',
clear: '清除自定义音源'
},
playBar: {
expand: '展开歌词',

View File

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

View File

@@ -167,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: '默认',
@@ -191,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: '快捷键设置',

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

@@ -10,11 +10,11 @@ import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts';
import { initializeRemoteControl } from './modules/remoteControl';
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeStats, setupStatsHandlers } from './modules/statsService';
import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager } from './modules/window';
import { startMusicApi } from './server';
import { initWindowSizeManager } from './modules/window-size';
// 导入所有图标
const iconPath = join(__dirname, '../../resources');
@@ -50,12 +50,6 @@ function initialize() {
// 初始化托盘
initializeTray(iconPath, mainWindow);
// 初始化统计服务
initializeStats();
// 设置统计相关的IPC处理程序
setupStatsHandlers(ipcMain);
// 启动音乐API
startMusicApi();
@@ -99,6 +93,9 @@ if (!isSingleInstance) {
optimizer.watchWindowShortcuts(window);
});
// 初始化窗口大小管理器
initWindowSizeManager();
// 初始化应用
initialize();

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

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

View File

@@ -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,21 +1,34 @@
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,
isMaximized: false
};
// 保存主窗口引用,以便在 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
};
/**
* 初始化代理设置
@@ -43,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监听
*/
@@ -65,6 +110,7 @@ export function initializeWindowManager() {
} else {
win.maximize();
}
// 状态保存在事件监听器中处理
}
});
@@ -86,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);
@@ -115,6 +156,9 @@ export function initializeWindowManager() {
// 发送事件到渲染进程,通知切换到迷你模式
win.webContents.send('mini-mode', true);
// 迷你窗口使用默认的缩放比
win.webContents.setZoomFactor(1);
}
});
@@ -124,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);
@@ -147,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);
}
});
@@ -155,38 +234,8 @@ 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', () => {
// 当应用被激活时,检查主窗口是否存在
@@ -203,26 +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('开始创建主窗口...');
mainWindow.setMinimumSize(1200, 780);
// 获取窗口创建选项
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.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) => {
@@ -244,10 +342,11 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
initWindowSizeHandlers(mainWindow);
// 保存主窗口引用
mainWindowInstance = mainWindow;
mainWindow.on('blur', () => mainWindow && mainWindow.setMaximizable(false))
return mainWindow;
}

View File

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

View File

@@ -1,6 +1,6 @@
import match from '@unblockneteasemusic/server';
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili';
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili';
interface SongData {
name: string;
@@ -30,7 +30,50 @@ interface UnblockResult {
}
// 所有可用平台
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'kuwo', 'bilibili'];
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];
/**
* 确保对象数据结构完整处理null或undefined的情况
* @param data 需要处理的数据对象
*/
function ensureDataStructure(data: any): any {
// 如果数据本身为空,则返回一个基本结构
if (!data) {
return {
name: '',
artists: [],
album: { name: '' }
};
}
// 确保name字段存在
if (data.name === undefined || data.name === null) {
data.name = '';
}
// 确保artists字段存在且为数组
if (!data.artists || !Array.isArray(data.artists)) {
data.artists = data.ar && Array.isArray(data.ar) ? data.ar : [];
}
// 确保artists中的每个元素都有name属性
if (data.artists.length > 0) {
data.artists = data.artists.map(artist => {
return artist ? { name: artist.name || '' } : { name: '' };
});
}
// 确保album对象存在并有name属性
if (!data.album || typeof data.album !== 'object') {
data.album = data.al && typeof data.al === 'object' ? data.al : { name: '' };
}
if (!data.album.name) {
data.album.name = '';
}
return data;
}
/**
* 音乐解析函数
@@ -46,16 +89,18 @@ const unblockMusic = async (
retryCount = 1,
enabledPlatforms?: Platform[]
): Promise<UnblockResult> => {
// 过滤 enabledPlatforms确保只包含 ALL_PLATFORMS 中存在的平台
const filteredPlatforms = enabledPlatforms
? enabledPlatforms.filter(platform => ALL_PLATFORMS.includes(platform))
: ALL_PLATFORMS;
songData.album = songData.album || songData.al;
songData.artists = songData.artists || songData.ar;
// 处理歌曲数据,确保数据结构完整
const processedSongData = ensureDataStructure(songData);
const retry = async (attempt: number): Promise<UnblockResult> => {
try {
const data = await match(parseInt(String(id), 10), filteredPlatforms, songData);
const data = await match(parseInt(String(id), 10), filteredPlatforms, processedSongData);
const result: UnblockResult = {
data: {
data,

View File

@@ -47,11 +47,7 @@ const api = {
'get-system-fonts',
'get-cached-lyric',
'cache-lyric',
'clear-lyric-cache',
// 统计相关
'record-visit',
'record-play',
'get-stats-summary'
'clear-lyric-cache'
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);

View File

@@ -4,6 +4,7 @@
<n-dialog-provider>
<n-message-provider>
<router-view></router-view>
<traffic-warning-drawer v-if="!isElectron"></traffic-warning-drawer>
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
@@ -17,6 +18,8 @@ import { computed, nextTick, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import TrafficWarningDrawer from '@/components/TrafficWarningDrawer.vue';
import homeRouter from '@/router/home';
import { useMenuStore } from '@/store/modules/menu';
import { usePlayerStore } from '@/store/modules/player';

View File

@@ -73,9 +73,8 @@ export const parseFromGDMusic = async (
throw new Error('搜索查询过短');
}
// 所有可用的音乐源 netease、kuwo、joox、tidal
const allSources = [
'kuwo', 'joox', 'tidal', 'netease'
// 所有可用的音乐源 netease、joox、tidal
const allSources = ['joox', 'tidal', 'netease'
] as MusicSourceType[];
console.log('GD音乐台开始搜索:', searchQuery);

View File

@@ -82,6 +82,66 @@ export const getMusicLrc = async (id: number) => {
}
};
/**
* 从Bilibili获取音频URL
* @param data 歌曲数据
* @returns 解析结果
*/
const getBilibiliAudio = async (data: SongResult) => {
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 searchQuery = [songName, artistName, albumName].filter(Boolean).join(' ').trim();
console.log('开始搜索bilibili音频:', searchQuery);
const url = await searchAndGetBilibiliAudioUrl(searchQuery);
return {
data: {
code: 200,
message: 'success',
data: { url }
}
};
};
/**
* 从GD音乐台获取音频URL
* @param id 歌曲ID
* @param data 歌曲数据
* @returns 解析结果失败时返回null
*/
const getGDMusicAudio = async (id: number, data: SongResult) => {
try {
const gdResult = await parseFromGDMusic(id, data, '999');
if (gdResult) {
return gdResult;
}
} catch (error) {
console.error('GD音乐台解析失败:', error);
}
return null;
};
/**
* 使用unblockMusic解析音频URL
* @param id 歌曲ID
* @param data 歌曲数据
* @param sources 音源列表
* @returns 解析结果
*/
const getUnblockMusicAudio = (id: number, data: SongResult, sources: any[]) => {
const filteredSources = sources.filter(source => source !== 'gdmusic');
console.log(`使用unblockMusic解析音源:`, filteredSources);
return window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources));
};
/**
* 获取解析后的音乐URL
* @param id 歌曲ID
* @param data 歌曲数据
* @returns 解析结果
*/
export const getParsingMusicUrl = async (id: number, data: SongResult) => {
const settingStore = useSettingsStore();
@@ -90,65 +150,51 @@ export const getParsingMusicUrl = async (id: number, data: SongResult) => {
return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } });
}
// 获取音源设置,优先使用歌曲自定义音源
// 1. 确定使用的音源列表(自定义或全局)
const songId = String(id);
const savedSource = localStorage.getItem(`song_source_${songId}`);
let enabledSources: any[] = [];
const savedSourceStr = localStorage.getItem(`song_source_${songId}`);
let musicSources: any[] = [];
// 如果有歌曲自定义音源,使用自定义音源
if (savedSource) {
try {
enabledSources = JSON.parse(savedSource);
console.log(`使用歌曲 ${id} 自定义音源:`, enabledSources);
if(enabledSources.includes('bilibili')){
// 构建搜索关键词,依次判断歌曲名称、歌手名称和专辑名称是否存在
const songName = data?.name || '';
const artistName = Array.isArray(data?.ar) && data.ar.length > 0 && data.ar[0]?.name ? data.ar[0].name : '';
const albumName = data?.al && typeof data.al === 'object' && data.al?.name ? data.al.name : '';
const name = [songName, artistName, albumName].filter(Boolean).join(' ').trim();
console.log('开始搜索bilibili音频', name);
return {
data: {
code: 200,
message: 'success',
data: {
url: await searchAndGetBilibiliAudioUrl(name)
}
}
}
try {
if (savedSourceStr) {
// 使用自定义音源
musicSources = JSON.parse(savedSourceStr);
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
} else {
// 使用全局音源设置
musicSources = settingStore.setData.enabledMusicSources || [];
console.log(`使用全局音源设置:`, musicSources);
if (isElectron && musicSources.length > 0) {
return getUnblockMusicAudio(id, data, musicSources);
}
} catch (e) {
console.error('e',e)
console.error('解析自定义音源失败, 使用全局设置', e);
enabledSources = settingStore.setData.enabledMusicSources || [];
}
} else {
// 没有自定义音源,使用全局音源设置
enabledSources = settingStore.setData.enabledMusicSources || [];
} catch (e) {
console.error('解析音源设置失败,使用全局设置', e);
musicSources = settingStore.setData.enabledMusicSources || [];
}
// 检查是否选择了GD音乐台解析
// 2. 按优先级解析
if (enabledSources.includes('gdmusic')) {
// 获取音质设置并转换为GD音乐台格式
try {
const gdResult = await parseFromGDMusic(id, data, '999');
if (gdResult) {
return gdResult;
}
} catch (error) {
console.error('GD音乐台解析失败:', error);
}
console.log('GD音乐台所有音源均解析失败尝试使用unblockMusic');
// 2.1 Bilibili解析(优先级最高)
if (musicSources.includes('bilibili')) {
return await getBilibiliAudio(data);
}
// 如果GD音乐台解析失败或者未启用尝试使用unblockMusic
if (isElectron) {
const filteredSources = enabledSources.filter(source => source !== 'gdmusic');
return window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources));
// 2.2 GD音乐台解析
if (musicSources.includes('gdmusic')) {
const gdResult = await getGDMusicAudio(id, data);
if (gdResult) return gdResult;
// GD解析失败继续下一步
console.log('GD音乐台解析失败尝试使用其他音源');
}
console.log('musicSources',musicSources)
// 2.3 使用unblockMusic解析其他音源
if (isElectron && musicSources.length > 0) {
return getUnblockMusicAudio(id, data, musicSources);
}
// 3. 后备方案使用API请求
console.log('无可用音源或不在Electron环境中使用API请求');
return requestMusic.get<any>('/music', { params: { id } });
};

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
src/renderer/assets/gzh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 43 KiB

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

@@ -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();

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

@@ -0,0 +1,411 @@
<template>
<div class="traffic-warning-trigger">
<n-button circle secondary class="mac-style-button" @click="showDrawer = true">
<template #icon>
<i class="iconfont ri-information-line"></i>
</template>
</n-button>
</div>
<n-drawer
v-model:show="showDrawer"
:width="isMobile ? '100%' : '800px'"
:height="isMobile ? '100%' : '100%'"
:placement="isMobile ? 'bottom' : 'right'"
@after-leave="handleDrawerClose"
:z-index="999999999"
:mask-closable="false"
>
<n-drawer-content
title="欢迎使用 AlgerMusicPlayer"
closable
:native-scrollbar="false"
class="mac-style-drawer"
>
<div class="drawer-container">
<div class="warning-content">
<div class="warning-message">
<h3>获取完整体验</h3>
<p class="platform-support">
<span>
<i class="ri-window-line mr-1"></i>Windows 10+
</span>
<span>
<i class="ri-apple-line mr-1"></i>macOS
</span>
<span>
<i class="ri-ubuntu-line mr-1"></i>Linux
</span>
<span>
<i class="ri-android-line mr-1"></i>Android
</span>
</p>
<p class="description">
下载桌面应用以获得最佳音乐体验包含完整功能与更高音质
目前无iOS版本请使用安卓应用或网页版
</p>
</div>
<div class="action-links">
<a href="https://mp.weixin.qq.com/s/9pr1XQB36gShM_-TG2LBdg" target="_blank" class="doc-link">
<i class="ri-file-text-line mr-1"></i> 查看使用文档
</a>
<a href="http://donate.alger.fun/download" target="_blank" class="download-link">
<i class="ri-download-2-line mr-1"></i> 立即下载
</a>
</div>
<div class="qrcode-section">
<img class="qrcode" src="@/assets/gzh.png" alt="公众号" />
<p>关注公众号获取最新版本与更新信息</p>
</div>
<div class="support-section">
<h4>支持项目</h4>
<p class="support-desc">您的支持是我们持续改进的动力</p>
<div class="payment-options">
<div class="payment-option">
<div class="payment-icon wechat">
<img src="@/assets/wechat.png" alt="微信支付" />
</div>
<span>微信支付</span>
</div>
<div class="payment-option">
<div class="payment-icon alipay">
<img src="@/assets/alipay.png" alt="支付宝" />
</div>
<span>支付宝</span>
</div>
</div>
</div>
<div class="drawer-actions">
<n-button secondary class="action-button" @click="markAsDonated">已支持</n-button>
<n-button type="primary" class="action-button primary" @click="remindLater">稍后提醒</n-button>
</div>
</div>
</div>
</n-drawer-content>
</n-drawer>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { isMobile } from '@/utils';
// 控制抽屉显示状态
const showDrawer = ref(false);
// 处理抽屉关闭后的操作
const handleDrawerClose = () => {
// 抽屉关闭后的逻辑
};
// 一天后提醒
const remindLater = () => {
const now = new Date();
localStorage.setItem('trafficDonated4RemindLater', now.toISOString());
showDrawer.value = false;
};
// 标记为已捐赠(永久不再提示)
const markAsDonated = () => {
localStorage.setItem('trafficDonated4Never', '1');
showDrawer.value = false;
};
// 组件挂载时检查是否需要显示
onMounted(() => {
// 优先判断是否永久不再提示
if (localStorage.getItem('trafficDonated4Never')) return;
// 判断一天后提醒
const remindLaterTime = localStorage.getItem('trafficDonated4RemindLater');
if (remindLaterTime) {
const lastRemind = new Date(remindLaterTime);
const now = new Date();
const hoursDiff = (now.getTime() - lastRemind.getTime()) / (1000 * 60 * 60);
if (hoursDiff < 24) return;
}
// 延迟20秒显示
setTimeout(() => {
showDrawer.value = true;
}, 20000);
});
</script>
<style scoped lang="scss">
.traffic-warning-trigger {
display: inline-block;
.mac-style-button {
background-color: rgba(0, 0, 0, 0.05);
color: #333;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
}
.mac-style-drawer {
border-radius: 10px 0 0 10px;
overflow: hidden;
position: relative;
}
.drawer-container {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
}
.warning-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.app-icon {
width: 100px;
height: 100px;
margin-bottom: 12px;
img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 14px;
}
}
.warning-message {
text-align: center;
max-width: 520px;
h3 {
font-size: 28px;
font-weight: 600;
margin-bottom: 18px;
color: #333;
}
.platform-support {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 16px;
span {
display: flex;
align-items: center;
font-size: 16px;
color: #444;
}
}
.description {
font-size: 16px;
line-height: 1.6;
color: #444;
margin: 0 auto;
}
}
.action-links {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
margin: 6px 0;
a {
display: inline-flex;
align-items: center;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
text-decoration: none;
transition: all 0.2s ease;
&.doc-link {
color: #555;
background-color: rgba(0, 0, 0, 0.05);
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
&.download-link {
color: #fff;
background-color: #007aff;
&:hover {
background-color: #0062cc;
}
}
}
}
.qrcode-section {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
.qrcode {
width: 180px;
height: 180px;
border-radius: 10px;
padding: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
background: white;
}
p {
margin-top: 14px;
font-size: 15px;
color: #0062cc;
}
}
.support-section {
width: 100%;
text-align: center;
h4 {
font-size: 22px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.support-desc {
font-size: 15px;
color: #555;
margin-bottom: 20px;
}
}
.payment-options {
display: flex;
justify-content: center;
gap: 100px;
flex-wrap: wrap;
padding-bottom: 100px;
}
.payment-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
.payment-icon {
width: 220px;
height: 220px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
span {
font-size: 15px;
color: #444;
}
}
.drawer-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 30px;
width: 100%;
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
background-color: #fff;
z-index: 999999999;
.action-button {
min-width: 110px;
border-radius: 8px;
font-size: 16px;
padding: 8px 16px;
&.primary {
background-color: #007aff;
color: white;
&:hover {
background-color: #0062cc;
}
}
}
}
@media (max-width: 768px) {
.warning-message {
h3 {
font-size: 20px;
}
.platform-support {
gap: 12px;
}
.description {
font-size: 13px;
}
}
.app-icon {
width: 64px;
height: 64px;
}
.qrcode-section {
.qrcode {
width: 140px;
height: 140px;
}
}
.payment-option {
.payment-icon {
width: 190px;
height: 190px;
}
}
.drawer-actions {
flex-wrap: wrap;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
background-color: #fff;
z-index: 999999999;
.action-button {
flex: 1 0 auto;
}
}
}
</style>

View File

@@ -99,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>
@@ -150,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 = () => {

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" v-model:value="tabName">
<!-- 下载列表 -->
<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 tabName = ref('downloading');
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,20 +91,6 @@ onMounted(() => {
}
});
});
const handleDrawerClose = () => {
settingsStore.showDownloadDrawer = false;
};
watch(
() => tabName.value,
(newVal) => {
if (newVal) {
refreshDownloadedList();
}
}
);
</script>
<style lang="scss" scoped>
@@ -568,117 +109,4 @@ watch(
}
}
}
.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

@@ -5,6 +5,7 @@
placement="right"
@update:show="$emit('update:modelValue', $event)"
:unstable-show-mask="false"
:show-mask="false"
>
<n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer">
<n-scrollbar class="h-full">

View File

@@ -126,7 +126,6 @@ const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => {
baseItem.value?.toggleSelect();
emit('select', props.item.id, !props.selected);
};
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => {

View File

@@ -131,7 +131,6 @@ 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);

View File

@@ -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';
@@ -142,7 +142,7 @@ import { IDayRecommend } from '@/type/day_recommend';
import { Playlist } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import { SongResult } from '@/type/music';
import type { Artist, IHotSinger } from '@/type/singer';
import type { IHotSinger } from '@/type/singer';
import {
getImgUrl,
isMobile,
@@ -150,7 +150,6 @@ import {
setAnimationDelay,
setBackgroundImg
} from '@/utils';
import { getArtistDetail } from '@/api/artist';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
const userStore = useUserStore();
@@ -233,41 +232,35 @@ onMounted(async () => {
loadNonUserData();
});
const JayChouId = 6452;
const loadArtistData = async () => {
// 提取每日推荐加载逻辑到单独的函数
const loadDayRecommendData = async () => {
try {
const { data: artistData }: { data: { data: { artist: Artist } } } = await getArtistDetail(JayChouId);
console.log('artistData', artistData);
if (hotSingerData.value) {
// 将周杰伦数据放在第一位
hotSingerData.value.artists = [artistData.data.artist, ...hotSingerData.value.artists];
}
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);
console.error('获取每日推荐失败:', error);
}
}
};
// 加载不需要登录的数据
const loadNonUserData = async () => {
try {
// 获取每日推荐
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);
// 获取每日推荐仅在用户未登录时加载已登录用户会通过watchEffect触发loadDayRecommendData
if (!userStore.user) {
await loadDayRecommendData();
}
// 获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
hotSingerData.value = singerData;
await loadArtistData();
} catch (error) {
console.error('加载热门歌手数据失败:', error);
}
@@ -419,9 +412,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

@@ -68,11 +68,11 @@
</span>
</n-ellipsis>
</div>
<mini-play-bar
<simple-play-bar
v-if="!config.hideMiniPlayBar"
class="mt-4"
:pure-mode-enabled="config.pureModeEnabled"
component
:isDark=" textColors.theme === 'dark'"
/>
</div>
</div>
@@ -96,7 +96,6 @@
@mouseleave="mouseLeaveLayout"
>
<!-- 歌曲信息 -->
<div ref="lrcContainer" class="music-lrc-container">
<div
v-if="config.hideCover"
@@ -135,12 +134,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>
@@ -152,7 +152,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import LyricSettings from '@/components/lyric/LyricSettings.vue';
import MiniPlayBar from '@/components/player/MiniPlayBar.vue';
import SimplePlayBar from '@/components/player/SimplePlayBar.vue';
import LyricCorrectionControl from '@/components/lyric/LyricCorrectionControl.vue';
import {
artistList,
lrcArray,
@@ -160,7 +161,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 +800,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

@@ -26,7 +26,7 @@
</n-dropdown>
<!-- EQ 均衡器弹窗 -->
<n-modal v-model:show="showEQModal" :mask-closable="true" :unstable-show-mask="false">
<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>
@@ -36,7 +36,7 @@
</n-modal>
<!-- 定时关闭弹窗 -->
<n-modal v-model:show="playerStore.showSleepTimer" :mask-closable="true" :unstable-show-mask="false">
<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>
@@ -46,7 +46,7 @@
</n-modal>
<!-- 播放速度设置弹窗 -->
<n-modal v-model:show="showSpeedModal" :mask-closable="true" :unstable-show-mask="false">
<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>
@@ -68,7 +68,7 @@
</template>
<script lang="ts" setup>
import { ref, computed, h } from 'vue';
import { ref, computed, h, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { DropdownOption } from 'naive-ui';
import { usePlayerStore } from '@/store/modules/player';
@@ -84,10 +84,34 @@ 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 },
@@ -131,6 +155,12 @@ const dropdownOptions = computed<DropdownOption[]>(() => [
// 处理菜单选择
const handleSelect = (key: string) => {
// 先关闭所有弹窗
showEQModal.value = false;
playerStore.showSleepTimer = false;
showSpeedModal.value = false;
// 然后仅打开所选弹窗
switch (key) {
case 'eq':
showEQModal.value = true;
@@ -212,11 +242,15 @@ const selectSpeed = (speed: number) => {
.eq-modal-content,
.timer-modal-content,
.speed-modal-content {
@apply p-6 rounded-3xl bg-white dark:bg-dark;
@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;

View File

@@ -54,7 +54,7 @@
<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匹配函数
@@ -529,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,31 +62,9 @@
<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>
<!-- 定时关闭按钮 -->
@@ -93,7 +72,7 @@
</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,21 +112,19 @@
import { useThrottleFn } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, artistList, nowTime, playMusic, sound, textColors } from '@/hooks/MusicHook';
import MusicFull from '@/layout/components/MusicFull.vue';
import 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');
@@ -206,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);
};
// 收藏功能
@@ -251,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; /* 自动适应内容高度 */
@@ -285,7 +240,7 @@ watch(
}
&.play-bar-mini {
@apply h-14 py-0 bg-light dark:bg-dark;
@apply h-14 py-0;
}
// 顶部信息区域
@@ -423,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 {
@@ -440,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'
: ''
]"
@@ -148,8 +148,8 @@
{{ 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>
@@ -169,7 +169,7 @@ 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,
@@ -343,7 +343,12 @@ const playMusicEvent = async () => {
}
};
const musicFullVisible = ref(false);
const musicFullVisible = computed({
get: () => playerStore.musicFull,
set: (value) => {
playerStore.setMusicFull(value);
}
})
// 设置musicFull
const setMusicFull = () => {
@@ -483,12 +488,13 @@ const openPlayListDrawer = () => {
.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-gray-800 px-2 py-1 rounded-md;
@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;
}
}
@@ -597,7 +603,7 @@ const openPlayListDrawer = () => {
// 确保悬停时提示样式正确
.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;
}
}

View File

@@ -117,6 +117,10 @@ const handleClearPlaylist = () => {
message.info(t('player.playList.alreadyEmpty'));
return;
}
if(isMobile.value){
closePanel();
}
dialog.warning({
title: t('player.playList.clearConfirmTitle'),
@@ -194,7 +198,7 @@ const handleDeleteSong = (song: SongResult) => {
}
.title {
@apply text-base font-medium;
@apply text-base font-medium text-gray-800 dark:text-gray-200;
}
.header-actions {
@@ -203,7 +207,7 @@ const handleDeleteSong = (song: SongResult) => {
.action-btn,
.close-btn {
@apply w-8 h-8 flex items-center justify-center rounded-full cursor-pointer mx-1;
@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 {
@@ -254,17 +258,18 @@ const handleDeleteSong = (song: SongResult) => {
// 移动端适配
@media (max-width: 768px) {
.playlist-panel {
position: fixed;
width: 100%;
height: 60vh;
height: 80vh;
top: auto;
bottom: 56px; // 移动端底部留出导航栏高度
border-radius: 16px 16px 0 0;
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;
@apply text-center relative px-4;
&::before {
content: '';
@@ -280,7 +285,11 @@ const handleDeleteSong = (song: SongResult) => {
}
&-content {
height: calc(60vh - 60px);
height: calc(80vh - 60px);
@apply px-4;
.delete-btn{
@apply visible;
}
}
}
}

View File

@@ -52,12 +52,21 @@
<div v-if="playMusic.source === 'bilibili'" class="text-red-500 text-sm">
{{ t('player.reparse.bilibiliNotSupported') }}
</div>
<!-- 清除自定义音源 -->
<div class="text-red-500 text-sm flex items-center bg-light-200 dark:bg-dark-200 rounded-lg p-2 cursor-pointer" @click="clearCustomSource">
<div class="flex items-center justify-center w-6 h-6 mr-3 text-lg">
<i class="ri-close-circle-line"></i>
</div>
<div>
{{ t('player.reparse.clear') }}
</div>
</div>
</div>
</n-popover>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessage } from 'naive-ui';
import { playMusic } from '@/hooks/MusicHook';
@@ -76,18 +85,13 @@ const currentReparsingSource = ref<Platform | null>(null);
// 实际存储选中音源的值
const selectedSourcesValue = ref<Platform[]>([]);
// 判断当前歌曲是否有自定义解析记录
const isReparse = computed(() => {
const songId = String(playMusic.value.id);
return localStorage.getItem(`song_source_${songId}`) !== null;
});
const isReparse = ref(localStorage.getItem(`song_source_${String(playMusic.value.id)}`) !== null);
// 可选音源列表
const musicSourceOptions = ref([
{ label: 'MiGu', value: 'migu' as Platform },
{ label: 'KuGou', value: 'kugou' as Platform },
{ label: 'pyncmd', value: 'pyncmd' as Platform },
{ label: 'KuWo', value: 'kuwo' as Platform },
{ label: 'Bilibili', value: 'bilibili' as Platform },
{ label: 'GdMuisc', value: 'gdmusic' as Platform }
]);
@@ -102,7 +106,6 @@ const getSourceIcon = (source: Platform) => {
const iconMap: Record<Platform, string> = {
'migu': 'ri-music-2-fill',
'kugou': 'ri-music-fill',
'kuwo': 'ri-album-fill',
'qq': 'ri-qq-fill',
'joox': 'ri-disc-fill',
'pyncmd': 'ri-netease-cloud-music-fill',
@@ -129,6 +132,13 @@ const initSelectedSources = () => {
}
};
// 清除自定义音源
const clearCustomSource = () => {
localStorage.removeItem(`song_source_${String(playMusic.value.id)}`);
selectedSourcesValue.value = [];
isReparse.value = false;
};
// 直接重新解析当前歌曲
const directReparseMusic = async (source: Platform) => {
if (isReparsing.value || playMusic.value.source === 'bilibili') {

View File

@@ -0,0 +1,509 @@
<template>
<div class="play-bar" :class="{ 'dark-theme': isDarkMode }" ref="playBarRef">
<div class="container">
<!-- 顶部进度条和时间 -->
<div class="top-section">
<!-- 进度条 -->
<div class="progress-bar" @click="handleProgressClick">
<div class="progress-track"></div>
<div class="progress-fill" :style="{ width: `${(nowTime / allTime) * 100}%` }"></div>
</div>
<!-- 时间显示 -->
<div class="time-display">
<span class="current-time">{{ formatTime(nowTime) }}</span>
<span class="total-time">{{ formatTime(allTime) }}</span>
</div>
</div>
<!-- 主控制区域 -->
<div class="controls-section">
<div class="left-controls">
<button class="control-btn small-btn" @click="togglePlayMode">
<i class="iconfont" :class="playModeIcon"></i>
</button>
</div>
<div class="center-controls">
<!-- 上一首 -->
<button class="control-btn" @click="handlePrev">
<i class="iconfont icon-prev"></i>
</button>
<!-- 播放/暂停 -->
<button class="control-btn play-btn" @click="playMusicEvent">
<i class="iconfont" :class="play ? 'icon-stop' : 'icon-play'"></i>
</button>
<!-- 下一首 -->
<button class="control-btn" @click="handleNext">
<i class="iconfont icon-next"></i>
</button>
</div>
<div class="right-controls">
<!-- 播放列表按钮 -->
<button class="control-btn small-btn" @click="openPlayListDrawer">
<i class="iconfont icon-list"></i>
</button>
</div>
</div>
<!-- 底部控制区域 -->
<div class="bottom-section">
<div class="spacer"></div>
<!-- 音量控制 -->
<div class="volume-control">
<i class="iconfont" :class="getVolumeIcon" @click="mute"></i>
<div class="volume-slider">
<n-slider
v-model:value="volumeSlider"
:step="1"
:tooltip="false"
@wheel.prevent="handleVolumeWheel"
></n-slider>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue';
import { secondToMinute } from '@/utils';
import { allTime, nowTime, playMusic } from '@/hooks/MusicHook';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
const props = withDefaults(defineProps<{
isDark: boolean;
}>(), {
isDark: false
});
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const playBarRef = ref<HTMLElement | null>(null);
// 播放状态
const play = computed(() => playerStore.isPlay);
// 播放模式
const playMode = computed(() => playerStore.playMode);
const playModeIcon = computed(() => {
switch (playMode.value) {
case 0:
return 'ri-repeat-2-line';
case 1:
return 'ri-repeat-one-line';
case 2:
return 'ri-shuffle-line';
default:
return 'ri-repeat-2-line';
}
});
// 切换播放模式
const togglePlayMode = () => {
playerStore.togglePlayMode();
};
// 音量控制
const audioVolume = ref(
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
);
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
localStorage.setItem('volume', (value / 100).toString());
audioService.setVolume(value / 100);
audioVolume.value = value / 100;
}
});
// 音量图标
const getVolumeIcon = computed(() => {
if (audioVolume.value === 0) return 'ri-volume-mute-line';
if (audioVolume.value <= 0.5) return 'ri-volume-down-line';
return 'ri-volume-up-line';
});
// 静音切换
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
// 鼠标滚轮调整音量
const 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 handlePrev = () => playerStore.prevPlay();
const handleNext = () => playerStore.nextPlay();
const playMusicEvent = async () => {
try {
await playerStore.setPlay({ ...playMusic.value });
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
// 进度条控制
const handleProgressClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
audioService.seek(allTime.value * percent);
nowTime.value = allTime.value * percent;
};
// 格式化时间
const formatTime = (seconds: number) => {
return secondToMinute(seconds);
};
// 打开播放列表抽屉
const openPlayListDrawer = () => {
playerStore.setPlayListDrawerVisible(true);
};
// 深色模式
const isDarkMode = computed(() => settingsStore.theme === 'dark' || props.isDark);
// 主题颜色应用函数
const applyThemeColor = (colorValue: string) => {
if (!colorValue || !playBarRef.value) return;
console.log('应用主题色:', colorValue);
const playBarElement = playBarRef.value;
// 解析RGB值
const rgbMatch = colorValue.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbMatch) {
const [_, r, g, b] = rgbMatch.map(Number);
// 计算颜色亮度 (0-255)
// 使用加权平均值公式: 0.299*R + 0.587*G + 0.114*B
const brightness = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
console.log(`主题色亮度: ${brightness}/255`);
// 设置主色
playBarElement.style.setProperty('--fill-color', colorValue);
// 亮度自适应处理
if (brightness > 200) { // 非常亮的颜色
// 深化主色以增加对比度
const darkenedColor = `rgb(${Math.max(0, r - 60)}, ${Math.max(0, g - 60)}, ${Math.max(0, b - 60)})`;
playBarElement.style.setProperty('--fill-color-alt', darkenedColor);
playBarElement.style.setProperty('--fill-color-transparent', `rgba(${r}, ${g}, ${b}, 0.5)`); // 提高透明度
playBarElement.style.setProperty('--text-on-fill', '#000000'); // 亮色背景上用黑色文字
playBarElement.style.setProperty('--high-contrast-color', '#000000'); // 高对比度颜色
playBarElement.classList.add('light-theme-color');
playBarElement.classList.remove('dark-theme-color');
} else if (brightness < 50) { // 非常暗的颜色
// 提亮主色以增加可见性
const lightenedColor = `rgb(${Math.min(255, r + 60)}, ${Math.min(255, g + 60)}, ${Math.min(255, b + 60)})`;
playBarElement.style.setProperty('--fill-color-alt', lightenedColor);
playBarElement.style.setProperty('--fill-color-transparent', `rgba(${r}, ${g}, ${b}, 0.7)`); // 提高透明度
playBarElement.style.setProperty('--text-on-fill', '#ffffff'); // 暗色背景上用白色文字
playBarElement.style.setProperty('--high-contrast-color', '#ffffff'); // 高对比度颜色
playBarElement.classList.add('dark-theme-color');
playBarElement.classList.remove('light-theme-color');
} else {
// 计算辅助色和高亮色
// 普通亮度颜色,正常处理
playBarElement.style.setProperty('--fill-color-alt', colorValue); // 保持一致
playBarElement.style.setProperty('--fill-color-transparent', `rgba(${r}, ${g}, ${b}, 0.25)`);
// 根据亮度决定文本颜色
const textColor = brightness > 125 ? '#000000' : '#ffffff';
playBarElement.style.setProperty('--text-on-fill', textColor);
playBarElement.style.setProperty('--high-contrast-color', textColor);
playBarElement.classList.remove('light-theme-color');
playBarElement.classList.remove('dark-theme-color');
}
// 设置亮色(用于高亮效果)
const lightenedColor = `rgb(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)})`;
playBarElement.style.setProperty('--fill-color-light', lightenedColor);
} else {
// 无法解析RGB值时的默认设置
playBarElement.style.setProperty('--fill-color', colorValue);
playBarElement.style.setProperty('--fill-color-transparent', `${colorValue}40`);
playBarElement.style.setProperty('--fill-color-light', `${colorValue}80`);
playBarElement.style.setProperty('--fill-color-alt', colorValue);
playBarElement.style.setProperty('--text-on-fill', '#ffffff');
playBarElement.style.setProperty('--high-contrast-color', '#ffffff');
}
};
// 监听主题色变化
watch(() => playerStore.playMusic.primaryColor, (newVal) => {
if (newVal) {
applyThemeColor(newVal);
}
});
onMounted(() => {
if (playerStore.playMusic?.primaryColor) {
setTimeout(() => {
applyThemeColor(playerStore.playMusic.primaryColor as string);
}, 50);
}
});
</script>
<style lang="scss" scoped>
.play-bar {
@apply w-full;
border-radius: 12px;
transition: all 0.3s ease;
/* 默认变量 */
--text-on-fill: #ffffff;
--high-contrast-color: #ffffff;
&.dark-theme {
--text-color: #333333;
--muted-color: rgba(0, 0, 0, 0.6);
--track-color: rgba(0, 0, 0, 0.2);
--track-color-hover: rgba(0, 0, 0, 0.4);
--fill-color: #1ed760;
--fill-color-alt: #1ed760;
--fill-color-transparent: rgba(30, 215, 96, 0.25);
--fill-color-light: rgba(30, 215, 96, 0.5);
--button-bg: rgba(0, 0, 0, 0.1);
--button-hover: rgba(0, 0, 0, 0.2);
}
&:not(.dark-theme) {
--text-color: #f1f1f1;
--muted-color: rgba(255, 255, 255, 0.6);
--track-color: rgba(255, 255, 255, 0.1);
--track-color-hover: rgba(255, 255, 255, 0.2);
--fill-color: #73e49a;
--fill-color-alt: #73e49a;
--fill-color-transparent: rgba(115, 228, 154, 0.25);
--fill-color-light: rgba(115, 228, 154, 0.5);
--button-bg: rgba(255, 255, 255, 0.05);
--button-hover: rgba(255, 255, 255, 0.1);
}
/* 极亮主题色适配 */
&.light-theme-color {
.progress-fill {
box-shadow: 0 0 8px var(--fill-color-transparent), inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.control-btn.play-btn {
box-shadow: 0 3px 8px var(--fill-color-transparent), 0 1px 2px rgba(0, 0, 0, 0.3);
color: var(--text-on-fill);
}
.volume-control .iconfont:hover {
color: var(--fill-color-alt);
}
}
/* 极暗主题色适配 */
&.dark-theme-color {
.progress-fill {
box-shadow: 0 0 10px var(--fill-color-transparent), inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.control-btn.play-btn {
box-shadow: 0 3px 12px var(--fill-color-transparent), 0 0 0 1px rgba(255, 255, 255, 0.2);
.iconfont {
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
}
.volume-control .iconfont:hover {
color: var(--fill-color-light);
}
}
}
.container {
@apply flex flex-col;
}
.top-section {
@apply mb-3;
.progress-bar {
@apply relative cursor-pointer h-2 mb-2 w-full;
.progress-track {
@apply absolute inset-0 rounded-full transition-all duration-150;
background-color: var(--track-color);
}
.progress-fill {
@apply absolute top-0 left-0 h-full rounded-full transition-all duration-150;
background: linear-gradient(90deg, var(--fill-color), var(--fill-color-light));
box-shadow: 0 0 8px var(--fill-color-transparent);
}
&:hover {
.progress-track{
background-color: var(--track-color-hover);
}
.progress-track, .progress-fill {
@apply h-full;
}
.progress-fill {
box-shadow: 0 0 12px var(--fill-color-transparent);
}
}
}
.time-display {
@apply flex justify-between text-base;
color: var(--muted-color);
.time-separator {
@apply mx-1;
}
.current-time {
opacity: 0.8;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
}
}
}
.controls-section {
@apply flex items-center justify-between mb-4;
.left-controls, .right-controls {
@apply flex items-center;
}
.center-controls {
@apply flex items-center justify-center space-x-6;
}
}
.bottom-section {
@apply flex items-center justify-between mt-2;
}
.control-btn {
@apply flex items-center justify-center rounded-full outline-none border-0 transition-all duration-200;
color: var(--text-color);
background: transparent;
width: 32px;
height: 32px;
cursor: pointer;
&:hover {
background-color: var(--button-bg);
transform: scale(1.05);
}
&:active {
background-color: var(--button-hover);
transform: scale(0.95);
}
&.play-btn {
background: linear-gradient(145deg, var(--fill-color), var(--fill-color-alt));
color: var(--text-on-fill);
width: 46px;
height: 46px;
box-shadow: 0 3px 8px var(--fill-color-transparent);
&:hover {
box-shadow: 0 4px 12px var(--fill-color-transparent);
}
.iconfont {
font-size: 1.25rem;
}
}
&.small-btn {
@apply text-2xl;
width: 28px;
height: 28px;
}
.iconfont {
@apply text-2xl;
}
}
.volume-control {
@apply flex items-center space-x-2;
color: var(--text-color);
.iconfont {
@apply cursor-pointer text-base;
transition: transform 0.2s ease, color 0.2s ease;
&:hover {
transform: scale(1.1);
color: var(--fill-color);
}
}
.volume-slider {
@apply w-24;
:deep(.n-slider) {
--n-rail-height: 3px;
--n-fill-color: var(--fill-color);
--n-rail-color: var(--track-color);
--n-handle-size: 12px;
.n-slider-rail {
@apply rounded-full;
}
.n-slider-rail__fill {
background: linear-gradient(90deg, var(--fill-color), var(--fill-color-light));
box-shadow: 0 0 6px var(--fill-color-transparent);
}
.n-slider-handle {
@apply opacity-0 transition-opacity duration-200;
background: white;
box-shadow: 0 0 6px var(--fill-color-transparent), 0 0 0 1px var(--high-contrast-color);
border: 2px solid var(--fill-color);
}
&:hover .n-slider-handle {
@apply opacity-100;
}
}
}
}
.spacer {
flex: 1;
}
.like-active {
color: var(--fill-color);
text-shadow: 0 0 8px var(--fill-color-transparent);
}
</style>

View File

@@ -56,7 +56,7 @@ const props = defineProps({
},
sources: {
type: Array as () => Platform[],
default: () => ['migu', 'kugou', 'pyncmd', 'bilibili', 'kuwo']
default: () => ['migu', 'kugou', 'pyncmd', 'bilibili']
}
});
@@ -67,11 +67,10 @@ const visible = ref(props.show);
const selectedSources = ref<Platform[]>(props.sources);
const musicSourceOptions = ref([
{ label: 'MiGu音乐', value: 'migu' },
{ label: '酷狗音乐', value: 'kugou' },
{ label: 'MG', value: 'migu' },
{ label: 'KG', value: 'kugou' },
{ label: 'pyncmd', value: 'pyncmd' },
{ label: '酷我音乐', value: 'kuwo' },
{ label: 'Bilibili音乐', value: 'bilibili' },
{ label: 'Bilibili', value: 'bilibili' },
{ label: 'GD音乐台', value: 'gdmusic' }
]);
@@ -102,7 +101,7 @@ watch(
const handleConfirm = () => {
// 确保至少选择一个音源
const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili', 'kuwo'];
const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili'];
const valuesToEmit = selectedSources.value.length > 0
? [...new Set(selectedSources.value)]
: defaultPlatforms;

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

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

View File

@@ -19,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());
@@ -482,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;
}
@@ -517,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 {};
};
@@ -828,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('无法获取音频实例,跳过进度更新初始化');
}

View File

@@ -5,12 +5,149 @@ 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 歌曲信息
@@ -33,49 +170,36 @@ export const useDownload = () => {
// 构建文件名
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;
// 发送下载请求
window.electron.ipcRenderer.send('download-music', {
url: musicUrl,
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'));
// 监听下载完成事件
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);
// 简化的监听逻辑,基本通知由全局监听器处理
setTimeout(() => {
isDownloading.value = false;
}, 2000);
} catch (error: any) {
console.error('Download error:', error);
isDownloading.value = false;
@@ -103,27 +227,17 @@ export const useDownload = () => {
isDownloading.value = true;
message.success(t('favorite.downloading'));
// 移除旧的监听器
window.electron.ipcRenderer.removeAllListeners('music-download-complete');
let successCount = 0;
let failCount = 0;
// 添加新的监听器
window.electron.ipcRenderer.on('music-download-complete', (_, result) => {
if (result.success) {
successCount++;
} else {
failCount++;
}
// 当所有下载完成时
if (successCount + failCount === songs.length) {
const totalCount = songs.length;
// 下载进度追踪
const trackProgress = () => {
if (successCount + failCount === totalCount) {
isDownloading.value = false;
message.success(t('favorite.downloadSuccess'));
window.electron.ipcRenderer.removeAllListeners('music-download-complete');
}
});
};
// 并行获取所有歌曲的下载链接
const downloadUrls = await Promise.all(
@@ -133,6 +247,7 @@ export const useDownload = () => {
return { song, ...data };
} catch (error) {
console.error(`获取歌曲 ${song.name} 下载链接失败:`, error);
failCount++;
return { song, url: null };
}
})
@@ -142,21 +257,41 @@ export const useDownload = () => {
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()
};
window.electron.ipcRenderer.send('download-music', {
ipcRenderer?.send('download-music', {
url,
filename: `${song.name} - ${(song.ar || song.song?.artists)?.map((a) => a.name).join(',')}`,
filename,
songInfo,
type
});
successCount++;
});
// 所有下载开始后,检查进度
trackProgress();
} catch (error) {
console.error('下载失败:', error);
isDownloading.value = false;

View File

@@ -53,9 +53,9 @@ export function useSongItem(props: {
const handleImageLoad = async (imageElement: HTMLImageElement) => {
if (!imageElement) return;
const { backgroundColor } = await getImageBackground(imageElement);
// eslint-disable-next-line vue/no-mutating-props
const { backgroundColor, primaryColor } = await getImageBackground(imageElement);
props.item.backgroundColor = backgroundColor;
props.item.primaryColor = primaryColor;
};
// 播放音乐
@@ -74,7 +74,7 @@ export function useSongItem(props: {
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
e && e.stopPropagation();
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) {
@@ -86,7 +86,7 @@ export function useSongItem(props: {
// 切换不喜欢状态
const toggleDislike = async (e: Event) => {
e.stopPropagation();
e && e.stopPropagation();
if (isDislike.value) {
playerStore.removeFromDislikeList(props.item.id);
return;

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"
@@ -21,7 +21,7 @@
</router-view>
</div>
<play-bottom />
<app-menu v-if="isMobile && !playerStore.musicFull" class="menu" :menus="menus" />
<app-menu v-if="shouldShowMobileMenu" class="menu" :menus="menus" />
</div>
</div>
<!-- 底部音乐播放 -->
@@ -103,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();
@@ -115,6 +125,8 @@ const currentSongId = ref<number | undefined>();
const openPlaylistDrawer = (songId: number, isOpen: boolean = true) => {
currentSongId.value = songId;
showPlaylistDrawer.value = isOpen;
playerStore.setMusicFull(false);
playerStore.setPlayListDrawerVisible(!isOpen);
};
// 将方法提供给全局
@@ -156,7 +168,10 @@ provide('openPlaylistDrawer', openPlaylistDrawer);
overflow: auto;
display: block;
flex: none;
padding-bottom: 70px;
}
.mobile-content {
height: calc(100vh - 75px);
}
}
</style>

View File

@@ -9,15 +9,14 @@
</div>
<div class="app-menu-list">
<div v-for="(item, index) in menus" :key="item.path" class="app-menu-item">
<n-tooltip :delay="200" :disabled="isText" placement="bottom">
<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' : ''">{{
item.meta.title }}</span>
<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">{{ item.meta.title }}</div>
<div v-if="!isText">{{ t(item.meta.title) }}</div>
</n-tooltip>
</div>
</div>
@@ -27,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: {
@@ -58,6 +60,8 @@ watch(
}
);
const { t } = useI18n();
const isChecked = (index: number) => {
return path.value === props.menus[index].path;
};
@@ -115,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;
@@ -127,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 判断
@@ -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
@@ -37,7 +37,7 @@ const layoutRouter = [
path: '/toplist',
name: 'toplist',
meta: {
title: '排行榜',
title: 'comp.toplist',
icon: 'ri-bar-chart-grouped-fill',
keepAlive: true,
isMobile: true
@@ -48,7 +48,7 @@ const layoutRouter = [
path: '/mv',
name: 'mv',
meta: {
title: 'MV',
title: 'comp.mv',
icon: 'icon-recordfill',
keepAlive: true,
isMobile: false
@@ -60,7 +60,7 @@ const layoutRouter = [
name: 'history',
component: () => import('@/views/historyAndFavorite/index.vue'),
meta: {
title: '收藏历史',
title: 'comp.history',
icon: 'icon-a-TicketStar',
keepAlive: true
}
@@ -69,7 +69,7 @@ const layoutRouter = [
path: '/user',
name: 'user',
meta: {
title: '用户',
title: 'comp.user',
icon: 'icon-Profile',
keepAlive: true,
noScroll: true,
@@ -81,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

@@ -1,12 +1,17 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { recordVisit } from '@/api/stats';
import { useUserStore } from '../store/modules/user';
import AppLayout from '@/layout/AppLayout.vue';
import MiniLayout from '@/layout/MiniLayout.vue';
import homeRouter from '@/router/home';
import otherRouter from '@/router/other';
import { useSettingsStore } from '@/store/modules/settings';
function getUserId(): string | null {
const userStore = useUserStore();
return userStore.user?.userId?.toString() || null;
}
// 由于 Vue Router 守卫在创建前不能直接使用组合式 API
// 我们创建一个辅助函数来获取 store 实例
let _settingsStore: ReturnType<typeof useSettingsStore> | null = null;
@@ -20,30 +25,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',
@@ -86,7 +81,8 @@ router.afterEach((to) => {
const pageName = to.name?.toString() || to.path;
// 使用setTimeout避免阻塞路由导航
setTimeout(() => {
recordVisit(pageName).catch((error) => console.error('记录页面访问失败:', error));
const userId = getUserId();
console.log('pageName', pageName, userId);
}, 100);
});

View File

@@ -21,6 +21,18 @@ const otherRouter = [
},
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',
@@ -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;

View File

@@ -330,15 +330,17 @@ class AudioService {
// 应用EQ状态
this.applyBypassState();
// 设置音量
const volume = localStorage.getItem('volume');
if (this.gainNode) {
this.gainNode.gain.value = volume ? parseFloat(volume) : 1;
// 从 localStorage 应用音量到增益节点
const savedVolume = localStorage.getItem('volume');
if (savedVolume) {
this.applyVolume(parseFloat(savedVolume));
} else {
this.applyVolume(1);
}
console.log('EQ初始化成功');
console.log('EQ initialization successful');
} catch (error) {
console.error('EQ初始化失败:', error);
console.error('EQ initialization failed:', error);
await this.disposeEQ();
throw error;
}
@@ -456,7 +458,7 @@ class AudioService {
}
// 播放控制相关
play(url?: string, track?: SongResult, isPlay: boolean = true): Promise<Howl> {
play(url?: string, track?: SongResult, isPlay: boolean = true, seekTime: number = 0): Promise<Howl> {
// 每次调用play方法时尝试强制重置锁注意仅在页面刷新后的第一次播放时应用
if (!this.currentSound) {
console.log('首次播放请求,强制重置操作锁');
@@ -563,11 +565,9 @@ class AudioService {
this.currentSound = new Howl({
src: [url],
html5: true,
autoplay: false, // 修改为 false不自动播放等待完全初始化后手动播放
volume: localStorage.getItem('volume')
? parseFloat(localStorage.getItem('volume') as string)
: 1,
rate: this.playbackRate, // 设置初始播放速度
autoplay: false,
volume: 1, // 禁用 Howler.js 音量控制
rate: this.playbackRate,
format: ['mp3', 'aac'],
onloaderror: (_, error) => {
console.error('Audio load error:', error);
@@ -596,31 +596,43 @@ class AudioService {
}
},
onload: async () => {
// 音频加载成功后设置 EQ 和更新媒体会话
if (this.currentSound) {
try {
console.log('audioService: 音频加载成功,设置 EQ');
await this.setupEQ(this.currentSound);
this.updateMediaSessionMetadata(track);
this.updateMediaSessionPositionState();
this.emit('load');
// 此时音频已完全初始化,根据 isPlay 参数决定是否播放
console.log('audioService: 音频完全初始化isPlay =', isPlay);
if (isPlay) {
console.log('audioService: 开始播放');
this.currentSound.play();
}
resolve(this.currentSound);
} catch (error) {
console.error('设置 EQ 失败:', error);
// 即使 EQ 设置失败,也继续播放(如果需要)
if (isPlay) {
this.currentSound.play();
}
resolve(this.currentSound);
try {
// 初始化音频管道
await this.setupEQ(this.currentSound!);
// 重新应用已保存的音量
const savedVolume = localStorage.getItem('volume');
if (savedVolume) {
this.applyVolume(parseFloat(savedVolume));
}
// 音频加载成功后设置 EQ 和更新媒体会话
if (this.currentSound) {
try {
if (seekTime > 0) {
this.currentSound.seek(seekTime);
}
console.log('audioService: 音频加载成功,设置 EQ');
this.updateMediaSessionMetadata(track);
this.updateMediaSessionPositionState();
this.emit('load');
// 此时音频已完全初始化,根据 isPlay 参数决定是否播放
console.log('audioService: 音频完全初始化isPlay =', isPlay);
if (isPlay) {
console.log('audioService: 开始播放');
this.currentSound.play();
}
resolve(this.currentSound);
} catch (error) {
console.error('Audio initialization failed:', error);
reject(error);
}
}
} catch (error) {
console.error('Audio initialization failed:', error);
reject(error);
}
}
});
@@ -699,10 +711,7 @@ class AudioService {
}
setVolume(volume: number) {
if (this.currentSound) {
this.currentSound.volume(volume);
localStorage.setItem('volume', volume.toString());
}
this.applyVolume(volume);
}
seek(time: number) {
@@ -779,6 +788,63 @@ class AudioService {
public getPlaybackRate(): number {
return this.playbackRate;
}
// 新的音量调节方法
private applyVolume(volume: number) {
// 确保值在0到1之间
const normalizedVolume = Math.max(0, Math.min(1, volume));
// 使用线性缩放音量
const linearVolume = normalizedVolume;
// 将音量应用到所有相关节点
if (this.gainNode) {
// 立即设置音量
this.gainNode.gain.cancelScheduledValues(this.context!.currentTime);
this.gainNode.gain.setValueAtTime(linearVolume, this.context!.currentTime);
} else {
this.currentSound?.volume(linearVolume);
}
// 保存值
localStorage.setItem('volume', linearVolume.toString());
console.log('Volume applied (linear):', linearVolume);
}
// 添加方法检查当前音频是否在加载状态
isLoading(): boolean {
if (!this.currentSound) return false;
// 检查Howl对象的内部状态
// 如果状态为1表示已经加载但未完成状态为2表示正在加载
const state = (this.currentSound as any)._state;
// 如果操作锁激活也认为是加载状态
return this.operationLock || (state === 'loading' || state === 1);
}
// 检查音频是否真正在播放
isActuallyPlaying(): boolean {
if (!this.currentSound) return false;
try {
// 综合判断:
// 1. Howler API是否报告正在播放
// 2. 是否不在加载状态
// 3. 确保音频上下文状态正常
const isPlaying = this.currentSound.playing();
const isLoading = this.isLoading();
const contextRunning = Howler.ctx && Howler.ctx.state === 'running';
console.log(`实际播放状态检查: playing=${isPlaying}, loading=${isLoading}, contextRunning=${contextRunning}`);
// 只有在三个条件都满足时才认为是真正在播放
return isPlaying && !isLoading && contextRunning;
} catch (error) {
console.error('检查播放状态出错:', error);
return false;
}
}
}
export const audioService = new AudioService();

View File

@@ -1,6 +1,7 @@
import { cloneDeep } from 'lodash';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useThrottleFn } from '@vueuse/core';
import i18n from '@/../i18n/renderer';
import { getBilibiliAudioUrl } from '@/api/bilibili';
@@ -395,7 +396,6 @@ export const usePlayerStore = defineStore('player', () => {
const musicFull = ref(false);
const favoriteList = ref<Array<number | string>>(getLocalStorageItem('favoriteList', []));
const dislikeList = ref<Array<number | string>>(getLocalStorageItem('dislikeList', []));
const savedPlayProgress = ref<number | undefined>();
const showSleepTimer = ref(false); // 定时弹窗
// 添加播放列表抽屉状态
const playListDrawerVisible = ref(false);
@@ -557,19 +557,102 @@ export const usePlayerStore = defineStore('player', () => {
}
};
// 添加用户意图跟踪变量
const userPlayIntent = ref(true);
let checkPlayTime: NodeJS.Timeout | null = null;
// 添加独立的播放状态检测函数
const checkPlaybackState = (song: SongResult, timeout: number = 4000) => {
if(checkPlayTime) {
clearTimeout(checkPlayTime);
}
const sound = audioService.getCurrentSound();
if (!sound) return;
// 使用audioService的事件系统监听播放状态
// 添加一次性播放事件监听器
const onPlayHandler = () => {
// 播放事件触发,表示成功播放
console.log('播放事件触发,歌曲成功开始播放');
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
};
// 添加一次性播放错误事件监听器
const onPlayErrorHandler = async () => {
console.log('播放错误事件触发尝试重新获取URL');
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
// 只有用户仍然希望播放时才重试
if (userPlayIntent.value && play.value) {
// 重置URL并重新播放
playMusic.value.playMusicUrl = undefined;
// 保持播放状态但强制重新获取URL
const refreshedSong = { ...song, isFirstPlay: true };
await handlePlayMusic(refreshedSong, true);
}
};
// 注册事件监听器
audioService.on('play', onPlayHandler);
audioService.on('playerror', onPlayErrorHandler);
// 额外的安全检查:如果指定时间后仍未播放也未触发错误,且用户仍希望播放
checkPlayTime = setTimeout(() => {
// 使用更准确的方法检查是否真正在播放
if (!audioService.isActuallyPlaying() && userPlayIntent.value && play.value) {
console.log(`${timeout}ms后歌曲未真正播放且用户仍希望播放尝试重新获取URL`);
// 移除事件监听器
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
// 重置URL并重新播放
playMusic.value.playMusicUrl = undefined;
// 保持播放状态强制重新获取URL
(async () => {
const refreshedSong = { ...song, isFirstPlay: true };
await handlePlayMusic(refreshedSong, true);
})();
}
}, timeout);
};
const setPlay = async (song: SongResult) => {
try {
// 如果是当前正在播放的音乐,则切换播放/暂停状态
if (playMusic.value.id === song.id && playMusic.value.playMusicUrl === song.playMusicUrl) {
if (play.value) {
// 检查URL是否已过期
if (song.expiredAt && song.expiredAt < Date.now()) {
console.info(`歌曲URL已过期重新获取: ${song.name}`);
song.playMusicUrl = undefined;
// 重置过期时间,以便重新获取
song.expiredAt = undefined;
}
// 如果是当前正在播放的音乐,则切换播放/暂停状态
if (playMusic.value.id === song.id && playMusic.value.playMusicUrl === song.playMusicUrl && !song.isFirstPlay) {
if (play.value) {
setPlayMusic(false);
audioService.getCurrentSound()?.pause();
// 设置用户意图为暂停
userPlayIntent.value = false;
} else {
setPlayMusic(true);
audioService.getCurrentSound()?.play();
// 设置用户意图为播放
userPlayIntent.value = true;
const sound = audioService.getCurrentSound();
if (sound) {
sound.play();
// 使用独立的播放状态检测函数
checkPlaybackState(playMusic.value);
}
}
return;
}
if(song.isFirstPlay) {
song.isFirstPlay = false;
}
// 直接调用 handlePlayMusic它会处理索引更新和播放逻辑
const success = await handlePlayMusic(song);
@@ -597,10 +680,14 @@ export const usePlayerStore = defineStore('player', () => {
const setPlayMusic = async (value: boolean | SongResult) => {
if (typeof value === 'boolean') {
setIsPlay(value);
// 记录用户的播放意图
userPlayIntent.value = value;
} else {
await handlePlayMusic(value);
play.value = true;
isPlay.value = true;
// 设置为播放意图
userPlayIntent.value = true;
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
}
@@ -800,8 +887,7 @@ export const usePlayerStore = defineStore('player', () => {
}
};
// 修改nextPlay方法改进播放逻辑
const nextPlay = async () => {
const _nextPlay = async () => {
try {
@@ -924,9 +1010,10 @@ export const usePlayerStore = defineStore('player', () => {
}
};
// 修改 prevPlay 方法,使用与 nextPlay 相似的逻辑改进
const prevPlay = async () => {
// 节流
const nextPlay = useThrottleFn(_nextPlay, 500);
const _prevPlay = async () => {
try {
@@ -1007,6 +1094,9 @@ export const usePlayerStore = defineStore('player', () => {
}
};
// 节流
const prevPlay = useThrottleFn(_prevPlay, 500);
const togglePlayMode = () => {
playMode.value = (playMode.value + 1) % 3;
localStorage.setItem('playMode', JSON.stringify(playMode.value));
@@ -1076,7 +1166,6 @@ export const usePlayerStore = defineStore('player', () => {
const settingStore = useSettingsStore();
const savedPlayList = getLocalStorageItem('playList', []);
const savedPlayMusic = getLocalStorageItem<SongResult | null>('currentPlayMusic', null);
const savedProgress = localStorage.getItem('playProgress');
if (savedPlayList.length > 0) {
setPlayList(savedPlayList);
@@ -1095,21 +1184,7 @@ export const usePlayerStore = defineStore('player', () => {
savedPlayMusic.playMusicUrl = undefined;
}
await handlePlayMusic({ ...savedPlayMusic, playMusicUrl: undefined }, isPlaying);
if (savedProgress) {
try {
const progress = JSON.parse(savedProgress);
if (progress && progress.songId === savedPlayMusic.id) {
savedPlayProgress.value = progress.progress;
} else {
localStorage.removeItem('playProgress');
}
} catch (e) {
console.error('解析保存的播放进度失败', e);
localStorage.removeItem('playProgress');
}
}
await handlePlayMusic({ ...savedPlayMusic, isFirstPlay: true, playMusicUrl: undefined }, isPlaying);
} catch (error) {
console.error('重新获取音乐链接失败:', error);
play.value = false;
@@ -1196,13 +1271,13 @@ export const usePlayerStore = defineStore('player', () => {
// 播放新音频,传递是否应该播放的状态
console.log('调用audioService.play播放状态:', shouldPlay);
const newSound = await audioService.play(playMusicUrl.value, playMusic.value, shouldPlay);
// 如果有保存的进度,设置播放位置
if (initialPosition > 0) {
newSound.seek(initialPosition);
const newSound = await audioService.play(playMusicUrl.value, playMusic.value, shouldPlay, initialPosition || 0);
// 添加播放状态检测(仅当需要播放时)
if (shouldPlay) {
checkPlaybackState(playMusic.value);
}
// 发布音频就绪事件,让 MusicHook.ts 来处理设置监听器
window.dispatchEvent(new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } }));
@@ -1233,15 +1308,18 @@ export const usePlayerStore = defineStore('player', () => {
// 延迟较长时间,确保锁已完全释放
setTimeout(() => {
// 直接重试当前歌曲,而不是切换到下一首
playAudio().catch(e => {
console.error('重试播放失败,切换到下一首:', e);
// 只有再次失败切换到下一首
if (playList.value.length > 1) {
nextPlay();
}
});
// 如果用户仍希望播放
if (userPlayIntent.value && play.value) {
// 直接重试当前歌曲,而不是切换到下一首
playAudio().catch(e => {
console.error('重试播放失败切换到下一首:', e);
// 只有再次失败才切换到下一首
if (playList.value.length > 1) {
nextPlay();
}
});
}
}, 1000);
} else {
// 其他错误,切换到下一首
@@ -1271,7 +1349,7 @@ export const usePlayerStore = defineStore('player', () => {
return false;
}
// 保存用户选择的音源
// 保存用户选择的音源作为数组传递确保unblockMusic可以使用
const songId = String(currentSong.id);
localStorage.setItem(`song_source_${songId}`, JSON.stringify([sourcePlatform]));
@@ -1285,11 +1363,17 @@ export const usePlayerStore = defineStore('player', () => {
const numericId = typeof currentSong.id === 'string'
? parseInt(currentSong.id, 10)
: currentSong.id;
console.log(`使用音源 ${sourcePlatform} 重新解析歌曲 ${numericId}`);
const res = await getParsingMusicUrl(numericId, cloneDeep(currentSong));
// 克隆一份歌曲数据,防止修改原始数据
const songData = cloneDeep(currentSong);
const res = await getParsingMusicUrl(numericId, songData);
if (res && res.data && res.data.data && res.data.data.url) {
// 更新URL
const newUrl = res.data.data.url;
console.log(`解析成功获取新URL: ${newUrl.substring(0, 50)}...`);
// 使用新URL更新播放
const updatedMusic = {
@@ -1304,6 +1388,7 @@ export const usePlayerStore = defineStore('player', () => {
return true;
} else {
console.warn(`使用音源 ${sourcePlatform} 解析失败`);
return false;
}
} catch (error) {
@@ -1325,6 +1410,8 @@ export const usePlayerStore = defineStore('player', () => {
currentSound.pause();
}
setPlayMusic(false);
// 明确设置用户意图为暂停
userPlayIntent.value = false;
} catch (error) {
console.error('暂停播放失败:', error);
}
@@ -1339,7 +1426,6 @@ export const usePlayerStore = defineStore('player', () => {
playListIndex,
playMode,
musicFull,
savedPlayProgress,
favoriteList,
dislikeList,
playListDrawerVisible,
@@ -1364,8 +1450,8 @@ export const usePlayerStore = defineStore('player', () => {
clearPlayAll,
setPlay,
setIsPlay,
nextPlay,
prevPlay,
nextPlay: nextPlay as unknown as typeof _nextPlay,
prevPlay: prevPlay as unknown as typeof _prevPlay,
setPlayMusic,
setMusicFull,
setPlayList,

View File

@@ -45,6 +45,7 @@ export interface SongResult {
// 时长
duration?: number;
dt?: number;
isFirstPlay?: boolean;
}
export interface Song {
@@ -247,7 +248,6 @@ export interface IArtists {
export type MusicSourceType =
| 'tencent'
| 'kugou'
| 'kuwo'
| 'migu'
| 'netease'
| 'joox'

View File

@@ -10,6 +10,10 @@ export interface LyricConfig {
pureModeEnabled: boolean;
hideMiniPlayBar: boolean;
hideLyrics: boolean;
// 移动端配置
mobileLayout: 'default' | 'ios' | 'android';
mobileCoverStyle: 'record' | 'square' | 'full';
mobileShowLyricLines: number;
}
export const DEFAULT_LYRIC_CONFIG: LyricConfig = {
@@ -23,5 +27,9 @@ export const DEFAULT_LYRIC_CONFIG: LyricConfig = {
hidePlayBar: false,
hideMiniPlayBar: true,
pureModeEnabled: false,
hideLyrics: false
hideLyrics: false,
// 移动端默认配置
mobileLayout: 'ios',
mobileCoverStyle: 'full',
mobileShowLyricLines: 3
};

View File

@@ -1,5 +1,5 @@
// 音乐平台类型
export type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'gdmusic';
export type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili' | 'gdmusic';
// 默认平台列表
export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili', 'kuwo'];
export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];

View File

@@ -67,7 +67,6 @@ export async function handleShortcutAction(action: string) {
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const currentSound = audioService.getCurrentSound();
const showToast = (message: string, iconName: string) => {
if (settingsStore.isMiniMode) {
return;
@@ -95,19 +94,25 @@ export async function handleShortcutAction(action: string) {
showToast(t('player.playBar.next'), 'ri-skip-forward-line');
break;
case 'volumeUp':
if (currentSound && currentSound?.volume() < 1) {
currentSound?.volume((currentSound?.volume() || 0) + 0.1);
// 从localStorage获取当前音量
const currentVolumeUp = parseFloat(localStorage.getItem('volume') || '1');
if (currentVolumeUp < 1) {
const newVolume = Math.min(1, currentVolumeUp + 0.1);
await audioService.setVolume(newVolume);
showToast(
`${t('player.playBar.volume')}${Math.round((currentSound?.volume() || 0) * 100)}%`,
`${t('player.playBar.volume')}${Math.round(newVolume * 100)}%`,
'ri-volume-up-line'
);
}
break;
case 'volumeDown':
if (currentSound && currentSound?.volume() > 0) {
currentSound?.volume((currentSound?.volume() || 0) - 0.1);
// 从localStorage获取当前音量
const currentVolumeDown = parseFloat(localStorage.getItem('volume') || '1');
if (currentVolumeDown > 0) {
const newVolume = Math.max(0, currentVolumeDown - 0.1);
await audioService.setVolume(newVolume);
showToast(
`${t('player.playBar.volume')}${Math.round((currentSound?.volume() || 0) * 100)}%`,
`${t('player.playBar.volume')}${Math.round(newVolume * 100)}%`,
'ri-volume-down-line'
);
}

View File

@@ -5,7 +5,16 @@ import ShortcutToast from '@/components/ShortcutToast.vue';
let container: HTMLDivElement | null = null;
let toastInstance: any = null;
export function showShortcutToast(message: string, iconName: string) {
interface ToastOptions {
position?: 'top' | 'center' | 'bottom';
showIcon?: boolean;
}
export function showShortcutToast(
message: string,
iconName = '',
options: ToastOptions = {}
) {
// 如果容器不存在,创建一个新的容器
if (!container) {
container = document.createElement('div');
@@ -20,6 +29,8 @@ export function showShortcutToast(message: string, iconName: string) {
// 创建新的 toast 实例
const vnode = createVNode(ShortcutToast, {
position: options.position || 'center',
showIcon: options.showIcon !== undefined ? options.showIcon : true,
onDestroy: () => {
if (container) {
render(null, container);
@@ -35,6 +46,11 @@ export function showShortcutToast(message: string, iconName: string) {
// 显示 toast
if (toastInstance) {
toastInstance.show(message, iconName);
toastInstance.show(message, iconName, { showIcon: options.showIcon });
}
}
// 新增便捷方法 - 底部无图标 toast
export function showBottomToast(message: string) {
showShortcutToast(message, '', { position: 'bottom', showIcon: false });
}

View File

@@ -183,6 +183,27 @@ export const formatDate = (dateStr: string): string => {
return useDateFormat(new Date(dateStr), 'YYYY-MM-DD HH:mm').value;
};
/**
* 比较两个版本号
* @param v1 版本号1
* @param v2 版本号2
* @returns 如果v1大于v2返回1如果v1小于v2返回-1如果相等返回0
*/
export const compareVersions = (v1: string, v2: string): number => {
const v1Parts = v1.split('.').map(Number);
const v2Parts = v2.split('.').map(Number);
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
const v1Part = v1Parts[i] || 0;
const v2Part = v2Parts[i] || 0;
if (v1Part > v2Part) return 1;
if (v1Part < v2Part) return -1;
}
return 0;
};
/**
* 检查更新
*/
@@ -197,7 +218,8 @@ export const checkUpdate = async (
}
const latestVersion = releaseInfo.tag_name.replace('v', '');
if (latestVersion === currentVersion) {
// 比较版本号,只有当新版本大于当前版本时才返回更新信息
if (compareVersions(latestVersion, currentVersion) <= 0) {
return null;
}
console.log('latestVersion', latestVersion);

View File

@@ -109,13 +109,16 @@
@scroll="handleVirtualScroll"
>
<template #default="{ item, index }">
<div class="double-item">
<song-item
:index="index"
:compact="isCompactLayout"
:item="formatSong(item)"
@play="handlePlay"
/>
<div>
<div class="double-item">
<song-item
:index="index"
:compact="isCompactLayout"
:item="formatSong(item)"
@play="handlePlay"
/>
</div>
<div v-if="index === filteredSongs.length - 1" class="h-36"></div>
</div>
</template>
</n-virtual-list>
@@ -697,7 +700,7 @@ const handleVirtualScroll = (e: any) => {
}
.albums-grid {
@apply grid gap-6 grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6;
@apply grid gap-6 grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6 pb-40;
}
.loading-more {
@@ -782,4 +785,10 @@ const handleVirtualScroll = (e: any) => {
@apply mb-2 bg-light-100 bg-opacity-30 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
}
}
.mobile {
.songs-toolbar{
@apply mb-0;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,7 @@
:key="song.id"
:item="song"
:favorite="false"
class="favorite-list-item"
:class="setAnimationClass('animate__bounceInLeft')"
:style="getItemAnimationDelay(index)"
:selectable="isSelecting"
@@ -103,7 +104,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, ref, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
@@ -339,20 +340,27 @@ const handleBatchDownload = async () => {
}
};
const hasLoaded = ref(false);
onMounted(async () => {
await playerStore.initializeFavoriteList();
await getFavoriteSongs();
if (!hasLoaded.value) {
await playerStore.initializeFavoriteList();
await getFavoriteSongs();
hasLoaded.value = true;
}
});
// 监听收藏列表变化
// 监听收藏列表变化,变化时重置并重新加载
watch(
favoriteList,
() => {
async () => {
hasLoaded.value = false;
currentPage.value = 1;
noMore.value = false;
getFavoriteSongs();
await getFavoriteSongs();
hasLoaded.value = true;
},
{ deep: true, immediate: true }
{ deep: true }
);
const handlePlay = () => {
@@ -505,7 +513,9 @@ const handleBatchDownload = async () => {
.favorite-list {
@apply space-y-2 pb-4 px-4;
&-item {
@apply bg-light-100 dark:bg-dark-100 hover:bg-light-200 dark:hover:bg-dark-200 transition-all;
}
&-more {
@apply mt-4;
@@ -529,7 +539,7 @@ const handleBatchDownload = async () => {
.mobile {
.favorite-page {
@apply p-4;
@apply p-4 m-0;
.favorite-header {
@apply mb-4;

View File

@@ -32,7 +32,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { getBilibiliProxyUrl, getBilibiliVideoDetail } from '@/api/bilibili';
@@ -42,11 +42,6 @@ import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'History'
});
const { t } = useI18n();
const { delMusic, musicList } = useMusicHistory();
const scrollbarRef = ref();
@@ -54,6 +49,7 @@ const loading = ref(false);
const noMore = ref(false);
const displayList = ref<SongResult[]>([]);
const playerStore = usePlayerStore();
const hasLoaded = ref(false);
// 无限滚动相关配置
const pageSize = 100;
@@ -178,10 +174,26 @@ const handlePlay = () => {
playerStore.setPlayList(displayList.value);
};
onMounted(() => {
getHistorySongs();
onMounted(async () => {
if (!hasLoaded.value) {
await getHistorySongs();
hasLoaded.value = true;
}
});
// 监听历史列表变化,变化时重置并重新加载
watch(
musicList,
async () => {
hasLoaded.value = false;
currentPage.value = 1;
noMore.value = false;
await getHistorySongs();
hasLoaded.value = true;
},
{ deep: true }
);
// 重写删除方法,需要同时更新 displayList
const handleDelMusic = async (item: SongResult) => {
delMusic(item);
@@ -205,7 +217,7 @@ const handleDelMusic = async (item: SongResult) => {
.history-item {
@apply flex items-center justify-between;
&-content {
@apply flex-1;
@apply flex-1 bg-light-100 dark:bg-dark-100 hover:bg-light-200 dark:hover:bg-dark-200 transition-all;
}
&-count {
@apply px-4 text-lg text-center;

View File

@@ -1,13 +1,17 @@
<template>
<div class="flex gap-4 h-full pb-4">
<favorite class="flex-item" />
<history class="flex-item" />
<history-list class="flex-item" />
</div>
</template>
<script setup lang="ts">
defineOptions({
name: 'History'
});
import Favorite from '@/views/favorite/index.vue';
import History from '@/views/history/index.vue';
import HistoryList from '@/views/history/index.vue';
</script>
<style scoped>

View File

@@ -146,7 +146,7 @@ const loginPhone = async () => {
<style lang="scss" scoped>
.login-page {
@apply flex flex-col items-center justify-center p-20 pt-20;
@apply flex flex-col items-center justify-center pt-20;
@apply bg-light dark:bg-black;
}
@@ -216,4 +216,10 @@ const loginPhone = async () => {
}
}
}
.mobile {
.login-page {
@apply pt-0;
}
}
</style>

View File

@@ -36,6 +36,48 @@
</template>
{{ t('comp.musicList.addToPlaylist') }}
</n-tooltip>
<!-- 多选/下载操作 -->
<div v-if="filteredSongs.length > 0 && isElectron" class="flex items-center gap-2">
<n-tooltip v-if="!isSelecting" placement="bottom" trigger="hover">
<template #trigger>
<div class="action-button hover-green" @click="startSelect">
<i class="icon iconfont ri-checkbox-multiple-line"></i>
</div>
</template>
{{ t('favorite.batchDownload')}}
</n-tooltip>
<div v-else class="flex items-center gap-2">
<n-checkbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
>
{{ t('common.selectAll') }}
</n-checkbox>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<div
class="action-button hover-green"
:class="{ 'opacity-50 pointer-events-none': selectedSongs.length === 0 || isDownloading }"
@click="selectedSongs.length && !isDownloading && handleBatchDownload()"
>
<i class="icon iconfont ri-download-line" :class="{ 'animate-spin': isDownloading }"></i>
</div>
</template>
{{ t('favorite.download', { count: selectedSongs.length }) }}
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<div class="action-button" @click="cancelSelect">
<i class="icon iconfont ri-close-line"></i>
</div>
</template>
{{ t('common.cancel') }}
</n-tooltip>
</div>
</div>
<!-- 布局切换按钮 -->
<div class="layout-toggle" v-if="!isMobile">
<n-tooltip placement="bottom" trigger="hover">
@@ -118,7 +160,7 @@
<n-virtual-list
ref="songListRef"
class="song-virtual-list"
style="height: calc(80vh - 60px)"
style="max-height: calc(100vh - 130px);"
:items="filteredSongs"
:item-size="isCompactLayout ? 50 : 70"
item-resizable
@@ -126,15 +168,21 @@
@scroll="handleVirtualScroll"
>
<template #default="{ item, index }">
<div class="double-item">
<song-item
:index="index"
:compact="isCompactLayout"
:item="formatSong(item)"
:can-remove="canRemove"
@play="handlePlay"
@remove-song="handleRemoveSong"
/>
<div>
<div class="double-item">
<song-item
:index="index"
:compact="isCompactLayout"
:item="formatSong(item)"
:can-remove="canRemove"
:selectable="isSelecting"
:selected="selectedSongs.includes(item.id as number)"
@play="handlePlay"
@remove-song="handleRemoveSong"
@select="(id, selected) => handleSelect(id, selected)"
/>
</div>
<div v-if="index === filteredSongs.length - 1" class="h-36"></div>
</div>
</template>
</n-virtual-list>
@@ -165,7 +213,8 @@ import SongItem from '@/components/common/SongItem.vue';
import PlayBottom from '@/components/common/PlayBottom.vue';
import { useMusicStore, usePlayerStore } from '@/store';
import { SongResult } from '@/type/music';
import { getImgUrl, isMobile, setAnimationClass } from '@/utils';
import { getImgUrl, isElectron, isMobile, setAnimationClass } from '@/utils';
import { useDownload } from '@/hooks/useDownload';
const { t } = useI18n();
const route = useRoute();
@@ -838,6 +887,53 @@ const addToPlaylist = () => {
message.success(t('comp.musicList.addToPlaylistSuccess', { count: newSongs.length }));
};
// 多选下载相关状态和方法
const isSelecting = ref(false);
const selectedSongs = ref<number[]>([]);
const { isDownloading, batchDownloadMusic } = useDownload();
const startSelect = () => {
isSelecting.value = true;
selectedSongs.value = [];
};
const cancelSelect = () => {
isSelecting.value = false;
selectedSongs.value = [];
};
const handleSelect = (songId: number, selected: boolean) => {
if (selected) {
selectedSongs.value.push(songId);
} else {
selectedSongs.value = selectedSongs.value.filter((id) => id !== songId);
}
};
const isAllSelected = computed(() => {
return (
filteredSongs.value.length > 0 &&
selectedSongs.value.length === filteredSongs.value.length
);
});
const isIndeterminate = computed(() => {
return (
selectedSongs.value.length > 0 &&
selectedSongs.value.length < filteredSongs.value.length
);
});
const handleSelectAll = (checked: boolean) => {
if (checked) {
selectedSongs.value = filteredSongs.value.map((song) => song.id as number);
} else {
selectedSongs.value = [];
}
};
const handleBatchDownload = async () => {
const selectedSongsList = selectedSongs.value
.map((songId) => filteredSongs.value.find((s) => s.id === songId))
.filter((song) => song) as SongResult[];
await batchDownloadMusic(selectedSongsList);
cancelSelect();
};
</script>
<style scoped lang="scss">

View File

@@ -0,0 +1,627 @@
<template>
<div class="import-playlist-page">
<div class="import-header" :class="setAnimationClass('animate__fadeInLeft')">
<div class="import-header-left">
<h2>{{ t('comp.playlist.import.title') }}</h2>
<div class="import-desc">{{ t('comp.playlist.import.description') }}</div>
</div>
</div>
<div class="import-content" :class="setAnimationClass('animate__fadeInUp')">
<n-card class="import-card">
<n-tabs type="line" animated>
<!-- 链接导入 -->
<n-tab-pane name="link" :tab="t('comp.playlist.import.linkTab')">
<div class="tab-content">
<div class="link-inputs">
<div v-for="(link, index) in linkInputs" :key="index" class="link-row">
<n-input
v-model:value="link.value"
:placeholder="t('comp.playlist.import.linkPlaceholder')"
class="link-input"
/>
<n-button
quaternary
circle
type="error"
@click="removeLinkRow(index)"
v-if="linkInputs.length > 1"
>
<template #icon>
<i class="iconfont ri-delete-bin-line"></i>
</template>
</n-button>
</div>
<div class="link-actions">
<n-button @click="addLinkRow" secondary size="small">
<template #icon>
<i class="iconfont ri-add-line"></i>
</template>
{{ t('comp.playlist.import.addLinkButton') }}
</n-button>
</div>
</div>
<div class="link-tips">
<p>{{ t('comp.playlist.import.linkTips') }}</p>
<ul>
<li>{{ t('comp.playlist.import.linkTip1') }}</li>
<li>{{ t('comp.playlist.import.linkTip2') }}</li>
<li>{{ t('comp.playlist.import.linkTip3') }}</li>
</ul>
</div>
<div class="action-buttons">
<n-checkbox v-model:checked="importToStarPlaylist">
{{ t('comp.playlist.import.importToStarPlaylist') }}
</n-checkbox>
<n-input
v-if="!importToStarPlaylist"
v-model:value="playlistName"
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
class="playlist-name-input"
/>
<n-button
type="primary"
:loading="importing"
:disabled="!isLinkInputValid"
@click="handleImportByLink"
>
{{ t('comp.playlist.import.importButton') }}
</n-button>
</div>
</div>
</n-tab-pane>
<!-- 文字导入 -->
<n-tab-pane name="text" :tab="t('comp.playlist.import.textTab')">
<div class="tab-content">
<n-input
v-model:value="textInput"
type="textarea"
:placeholder="t('comp.playlist.import.textPlaceholder')"
:rows="6"
/>
<div class="text-tips">
<p>{{ t('comp.playlist.import.textTips') }}</p>
<p class="text-format">{{ t('comp.playlist.import.textFormat') }}</p>
</div>
<div class="action-buttons">
<n-checkbox v-model:checked="importToStarPlaylist">
{{ t('comp.playlist.import.importToStarPlaylist') }}
</n-checkbox>
<n-input
v-if="!importToStarPlaylist"
v-model:value="playlistName"
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
class="playlist-name-input"
/>
<n-button
type="primary"
:loading="importing"
:disabled="!textInput.trim()"
@click="handleImportByText"
>
{{ t('comp.playlist.import.importButton') }}
</n-button>
</div>
</div>
</n-tab-pane>
<!-- 元数据导入 -->
<n-tab-pane name="local" :tab="t('comp.playlist.import.localTab')">
<div class="tab-content">
<div class="metadata-inputs">
<div v-for="(item, index) in localMetadata" :key="index" class="metadata-row">
<n-input
v-model:value="item.name"
:placeholder="t('comp.playlist.import.songNamePlaceholder')"
class="metadata-input"
/>
<n-input
v-model:value="item.artist"
:placeholder="t('comp.playlist.import.artistNamePlaceholder')"
class="metadata-input"
/>
<n-input
v-model:value="item.album"
:placeholder="t('comp.playlist.import.albumNamePlaceholder')"
class="metadata-input"
/>
<n-button
quaternary
circle
type="error"
@click="removeMetadataRow(index)"
v-if="localMetadata.length > 1"
>
<template #icon>
<i class="iconfont ri-delete-bin-line"></i>
</template>
</n-button>
</div>
<div class="metadata-actions">
<n-button @click="addMetadataRow" secondary size="small">
<template #icon>
<i class="iconfont ri-add-line"></i>
</template>
{{ t('comp.playlist.import.addSongButton') }}
</n-button>
</div>
</div>
<div class="local-tips">
<p>{{ t('comp.playlist.import.localTips') }}</p>
</div>
<div class="action-buttons">
<n-checkbox v-model:checked="importToStarPlaylist">
{{ t('comp.playlist.import.importToStarPlaylist') }}
</n-checkbox>
<n-input
v-if="!importToStarPlaylist"
v-model:value="playlistName"
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
class="playlist-name-input"
/>
<n-button
type="primary"
:loading="importing"
:disabled="!isLocalMetadataValid"
@click="handleImportByLocal"
>
{{ t('comp.playlist.import.importButton') }}
</n-button>
</div>
</div>
</n-tab-pane>
</n-tabs>
</n-card>
<!-- 导入状态 -->
<n-card v-if="taskId" class="import-status-card">
<div class="status-header">
<h3>{{ t('comp.playlist.import.importStatus') }}</h3>
<n-button text @click="refreshStatus">
<template #icon>
<i class="iconfont ri-refresh-line"></i>
</template>
{{ t('comp.playlist.import.refresh') }}
</n-button>
</div>
<n-spin :show="checkingStatus">
<div class="status-content">
<div class="status-item">
<span class="status-label">{{ t('comp.playlist.import.taskId') }}:</span>
<span class="status-value">{{ taskId }}</span>
</div>
<div class="status-item">
<span class="status-label">{{ t('comp.playlist.import.status') }}:</span>
<span class="status-value" :class="`status-${taskStatus}`">
{{ getStatusText(taskStatus) }}
</span>
</div>
<div v-if="taskStatus === 'success'" class="status-item">
<span class="status-label">{{ t('comp.playlist.import.successCount') }}:</span>
<span class="status-value success-count">{{ successCount }}</span>
</div>
<div v-if="taskStatus === 'failed'" class="status-item">
<span class="status-label">{{ t('comp.playlist.import.failReason') }}:</span>
<span class="status-value fail-reason">{{ failReason }}</span>
</div>
</div>
</n-spin>
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessage } from 'naive-ui';
import { importPlaylist, getImportTaskStatus } from '@/api/playlist';
import { setAnimationClass } from '@/utils';
const { t } = useI18n();
const message = useMessage();
// 表单数据
const linkInputs = ref([{ value: '' }]);
const textInput = ref('');
const localMetadata = ref([{ name: '', artist: '', album: '' }]);
const playlistName = ref('');
const importToStarPlaylist = ref(false);
// 链接相关函数
const addLinkRow = () => {
linkInputs.value.push({ value: '' });
};
const removeLinkRow = (index: number) => {
linkInputs.value.splice(index, 1);
};
// 验证链接是否有效
const isLinkInputValid = computed(() => {
return linkInputs.value.some(item => item.value.trim() !== '');
});
// 元数据相关函数
const addMetadataRow = () => {
localMetadata.value.push({ name: '', artist: '', album: '' });
};
const removeMetadataRow = (index: number) => {
localMetadata.value.splice(index, 1);
};
// 验证元数据是否有效
const isLocalMetadataValid = computed(() => {
return localMetadata.value.some(item => item.name.trim() !== '');
});
// 导入状态
const importing = ref(false);
const taskId = ref('');
const taskStatus = ref('');
const successCount = ref(0);
const failReason = ref('');
const checkingStatus = ref(false);
const statusCheckInterval = ref<number | null>(null);
// 处理链接导入
const handleImportByLink = async () => {
if (!isLinkInputValid.value) {
message.warning(t('comp.playlist.import.emptyLinkWarning'));
return;
}
try {
importing.value = true;
// 处理链接格式
const links = linkInputs.value
.filter(link => link.value.trim())
.map(link => link.value.trim());
const encodedLinks = JSON.stringify(links);
const params: any = {
link: encodedLinks
};
if (importToStarPlaylist.value) {
params.importStarPlaylist = true;
} else if (playlistName.value) {
params.playlistName = playlistName.value;
}
const res = await importPlaylist(params);
if (res.data.code === 200) {
message.success(t('comp.playlist.import.importSuccess'));
taskId.value = res.data.data.taskId;
startStatusCheck();
} else {
message.error(res.data.message || t('comp.playlist.import.importFailed'));
}
} catch (error) {
console.error('导入歌单失败:', error);
message.error(t('comp.playlist.import.importFailed'));
} finally {
importing.value = false;
}
};
// 处理文字导入
const handleImportByText = async () => {
if (!textInput.value.trim()) {
message.warning(t('comp.playlist.import.emptyTextWarning'));
return;
}
try {
importing.value = true;
const encodedText = encodeURIComponent(textInput.value);
const params: any = {
text: encodedText
};
if (importToStarPlaylist.value) {
params.importStarPlaylist = true;
} else if (playlistName.value) {
params.playlistName = playlistName.value;
}
const res = await importPlaylist(params);
if (res.data.code === 200) {
message.success(t('comp.playlist.import.importSuccess'));
taskId.value = res.data.data.taskId;
startStatusCheck();
} else {
message.error(res.data.message || t('comp.playlist.import.importFailed'));
}
} catch (error) {
console.error('导入歌单失败:', error);
message.error(t('comp.playlist.import.importFailed'));
} finally {
importing.value = false;
}
};
// 处理元数据导入
const handleImportByLocal = async () => {
if (!isLocalMetadataValid.value) {
message.warning(t('comp.playlist.import.emptyLocalWarning'));
return;
}
try {
importing.value = true;
// 过滤掉空的行
const filteredData = localMetadata.value.filter(item => item.name.trim() !== '');
const encodedLocal = JSON.stringify(filteredData);
const params: any = {
local: encodedLocal
};
if (importToStarPlaylist.value) {
params.importStarPlaylist = true;
} else if (playlistName.value) {
params.playlistName = playlistName.value;
}
const res = await importPlaylist(params);
if (res.data.code === 200) {
message.success(t('comp.playlist.import.importSuccess'));
taskId.value = res.data.data.taskId;
startStatusCheck();
} else {
message.error(res.data.message || t('comp.playlist.import.importFailed'));
}
} catch (error) {
console.error('导入歌单失败:', error);
message.error(t('comp.playlist.import.importFailed'));
} finally {
importing.value = false;
}
};
// 开始检查任务状态
const startStatusCheck = () => {
// 清除之前的定时器
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
}
// 立即检查一次
checkTaskStatus();
// 设置定时检查
statusCheckInterval.value = window.setInterval(() => {
checkTaskStatus();
}, 3000); // 每3秒检查一次
};
// 检查任务状态
const checkTaskStatus = async () => {
if (!taskId.value) return;
try {
checkingStatus.value = true;
const res = await getImportTaskStatus(taskId.value);
if (res.data.code === 200) {
// 新的API返回格式处理
if (res.data.data.tasks && res.data.data.tasks.length > 0) {
const taskData = res.data.data.tasks[0];
// 将API返回的status映射到组件内部使用的taskStatus
const statusMap: Record<string, string> = {
'PENDING': 'pending',
'PROCESSING': 'processing',
'COMPLETE': 'success',
'FAILED': 'failed'
};
taskStatus.value = statusMap[taskData.status] || 'pending';
if (taskStatus.value === 'success') {
successCount.value = taskData.succCount || 0;
// 成功后停止检查
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
statusCheckInterval.value = null;
}
} else if (taskStatus.value === 'failed') {
failReason.value = taskData.msg || t('comp.playlist.import.unknownError');
// 失败后停止检查
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
statusCheckInterval.value = null;
}
}
}
}
} catch (error) {
console.error('检查任务状态失败:', error);
} finally {
checkingStatus.value = false;
}
};
// 手动刷新状态
const refreshStatus = () => {
checkTaskStatus();
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return t('comp.playlist.import.statusPending');
case 'processing':
return t('comp.playlist.import.statusProcessing');
case 'success':
return t('comp.playlist.import.statusSuccess');
case 'failed':
return t('comp.playlist.import.statusFailed');
default:
return t('comp.playlist.import.statusUnknown');
}
};
onMounted(() => {
// 如果有任务ID开始检查状态
if (taskId.value) {
startStatusCheck();
}
});
onUnmounted(() => {
// 清除定时器
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
statusCheckInterval.value = null;
}
});
</script>
<style lang="scss" scoped>
.import-playlist-page {
@apply h-full overflow-auto pr-4;
}
.import-header {
@apply flex justify-between items-center mb-6;
.import-header-left {
h2 {
@apply text-2xl font-bold text-gray-900 dark:text-white mb-2;
}
.import-desc {
@apply text-sm text-gray-500 dark:text-gray-400;
}
}
}
.import-content {
@apply space-y-6;
}
.import-card {
@apply rounded-lg;
.tab-content {
@apply mt-4 space-y-4;
}
.link-tips, .text-tips, .local-tips {
@apply text-sm text-gray-500 dark:text-gray-400;
ul {
@apply list-disc pl-5 mt-2;
}
}
.text-format, .local-format {
@apply mt-2 font-medium;
}
.code-example {
@apply mt-2 p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm overflow-auto;
}
.link-inputs {
@apply space-y-3;
.link-row {
@apply flex items-center space-x-2;
.link-input {
@apply flex-1;
}
}
.link-actions {
@apply mt-3 flex justify-end;
}
}
.metadata-inputs {
@apply space-y-3;
.metadata-row {
@apply flex items-center space-x-2;
.metadata-input {
@apply flex-1;
}
}
.metadata-actions {
@apply mt-3 flex justify-end;
}
}
.action-buttons {
@apply flex items-center space-x-4 mt-6;
.playlist-name-input {
@apply max-w-xs;
}
}
}
.import-status-card {
@apply rounded-lg;
.status-header {
@apply flex justify-between items-center mb-4;
h3 {
@apply text-lg font-medium text-gray-900 dark:text-white;
}
}
.status-content {
@apply space-y-3;
}
.status-item {
@apply flex items-center;
.status-label {
@apply text-gray-500 dark:text-gray-400 w-24;
}
.status-value {
@apply font-medium;
}
.status-pending, .status-processing {
@apply text-blue-500;
}
.status-success {
@apply text-green-500;
}
.status-failed {
@apply text-red-500;
}
.success-count {
@apply text-green-500;
}
.fail-reason {
@apply text-red-500;
}
}
}
</style>

View File

@@ -104,7 +104,7 @@
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-400" v-if="!isMobile">{{ setData.animationSpeed }}x</span>
<div>
<div class="w-40 flex justify-end">
<template v-if="!isMobile"><n-slider
v-model:value="setData.animationSpeed"
:min="0.1"
@@ -116,7 +116,6 @@
3: t('settings.basic.animationSpeed.fast')
}"
:disabled="setData.noAnimate"
class="w-40"
/></template>
<template v-else>
<n-input-number
@@ -172,7 +171,6 @@
<a class="text-green-400 hover:text-green-500" href="https://music.163.com/store/vip" target="_blank">网易云音乐会员</a>
<a class="text-green-400 hover:text-green-500" href="https://y.qq.com/portal/vipportal/" target="_blank">QQ音乐会员</a>
<a class="text-green-400 hover:text-green-500" href="https://vip.kugou.com/" target="_blank">酷狗音乐会员</a>
<a class="text-green-400 hover:text-green-500" href="https://vip1.kuwo.cn/" target="_blank">酷我音乐会员</a>
</div>
</div>
</div>
@@ -444,7 +442,7 @@
<!-- 捐赠支持 -->
<div id="donation" ref="donationRef" class="settings-section">
<div class="settings-section-title">{{ t('settings.sectio ns.donation') }}</div>
<div class="settings-section-title">{{ t('settings.sections.donation') }}</div>
<div class="settings-section-content">
<div class="set-item">
<div>
@@ -523,7 +521,7 @@ import { type Platform } from '@/types/music';
import config from '../../../../package.json';
// 所有平台默认值
const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili', 'kuwo'];
const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];
const platform = window.electron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';

View File

@@ -28,7 +28,10 @@
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class="title">{{ t('user.playlist.created') }}</div>
<div class="title">
<div>{{ t('user.playlist.created') }}</div>
<div class="import-btn" @click="goToImportPlaylist" v-if="isElectron">{{ t('comp.playlist.import.button') }}</div>
</div>
<n-scrollbar>
<div
v-for="(item, index) in playList"
@@ -44,7 +47,7 @@
/>
<div class="play-list-item-info">
<div class="play-list-item-name">
{{ item.name }}
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
<div v-if="item.creator.userId === user.userId" class="playlist-creator-tag">
{{ t('user.playlist.mine') }}
</div>
@@ -87,6 +90,10 @@
</n-scrollbar>
</div>
</div>
<!-- 未登录时显示登录组件 -->
<div v-if="!isLoggedIn && isMobile" class="login-container" :class="setAnimationClass('animate__fadeIn')">
<login-component @login-success="handleLoginSuccess" />
</div>
</div>
</template>
@@ -105,7 +112,8 @@ import { usePlayerStore } from '@/store/modules/player';
import { useUserStore } from '@/store/modules/user';
import type { Playlist } from '@/type/listDetail';
import type { IUserDetail } from '@/type/user';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import { getImgUrl, isElectron, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import LoginComponent from '@/views/login/index.vue';
defineOptions({
name: 'User'
@@ -126,6 +134,10 @@ const message = useMessage();
const user = computed(() => userStore.user);
const goToImportPlaylist = () => {
router.push('/playlist/import');
};
onBeforeUnmount(() => {
mounted.value = false;
});
@@ -136,7 +148,7 @@ const checkLoginStatus = () => {
const userData = localStorage.getItem('user');
if (!token || !userData) {
router.push('/login');
!isMobile.value && router.push('/login');
return false;
}
@@ -263,6 +275,14 @@ const showFollowList = () => {
// if (!user.value) return;
// router.push('/user/followers');
// };
const handleLoginSuccess = () => {
// 处理登录成功后的逻辑
checkLoginStatus();
loadData();
};
const isLoggedIn = computed(() => userStore.user);
</script>
<style lang="scss" scoped>
@@ -278,8 +298,13 @@ const showFollowList = () => {
@apply bg-black bg-opacity-40;
}
.title {
@apply text-lg font-bold;
@apply text-lg font-bold flex items-center justify-between;
@apply text-gray-900 dark:text-white;
.import-btn {
@apply bg-light-100 font-normal rounded-lg px-2 py-1 text-opacity-70 text-sm hover:bg-light-200 hover:text-green-500 dark:bg-dark-200 dark:hover:bg-dark-300 dark:hover:text-green-400;
@apply cursor-pointer;
@apply transition-all duration-200;
}
}
.user-name {
@@ -371,6 +396,7 @@ const showFollowList = () => {
height: 18px;
font-size: 10px;
font-weight: 500;
min-width: 60px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
@@ -386,5 +412,9 @@ const showFollowList = () => {
.user-page {
@apply px-4;
}
.login-container {
@apply flex justify-center items-center h-full w-full;
}
}
</style>

View File

@@ -22,8 +22,8 @@
"baseUrl": ".",
"types": [
"naive-ui/volar",
"./auto-imports.d.ts",
"./components.d.ts"
"./src/renderer/auto-imports.d.ts",
"./src/renderer/components.d.ts"
],
"paths": {
"@/*": ["src/renderer/*"],

42
vite.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import { resolve } from 'path';
import AutoImport from 'unplugin-auto-import/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import viteCompression from 'vite-plugin-compression';
import VueDevTools from 'vite-plugin-vue-devtools';
export default defineConfig({
base: './',
// 项目src
root: resolve('src/renderer'),
resolve: {
alias: {
'@': resolve('src/renderer'),
'@renderer': resolve('src/renderer')
}
},
plugins: [
vue(),
viteCompression(),
VueDevTools(),
AutoImport({
imports: [
'vue',
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
}
]
}),
Components({
resolvers: [NaiveUiResolver()]
})
],
publicDir: resolve('resources'),
server: {
host: '0.0.0.0',
proxy: {
}
}
});