62 Commits

Author SHA1 Message Date
algerkong
f33861fd25 feat: 优化B站视频代理URL获取逻辑 2025-04-02 08:55:54 +08:00
alger
7f7d41f883 feat: 更新迷你播放栏在页面显示逻辑 2025-04-02 00:07:37 +08:00
alger
7b27cf5bc6 fix: 修复类型问题 2025-04-01 23:34:17 +08:00
alger
ad8f7af3a9 feat: 更新至 v4.2.0
## v4.2.0

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

5
.gitignore vendored
View File

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

View File

@@ -1,9 +1,22 @@
# 更新日志 # 更新日志
## v3.9.3 ## v4.2.0
### ✨ 新功能 ### ✨ 新功能
- 实现国际化i18n功能 - 添加迷你播放器模式 [0f55795](https://github.com/algerkong/AlgerMusicPlayer/commit/0f55795))
- 增加动态代理节点获取和缓存机制 - 更新网易云音乐API版本添加B站视频搜索功能和播放器组件 ([280fec1](https://github.com/algerkong/AlgerMusicPlayer/commit/280fec1))
- 优化更新检查逻辑,增加多个代理源支持 - mac端添加状态栏 显示当前播放歌曲和操作按钮 ([374a7a8](https://github.com/algerkong/AlgerMusicPlayer/commit/374a7a8))
- 修改捐赠列表 API - 添加音频URL过期事件监听自动重新获取B站和网易云音乐音频URL并恢复播放 ([ee6e9d4](https://github.com/algerkong/AlgerMusicPlayer/commit/ee6e9d4))
- 优化搜索功能,改进搜索历史管理和路由处理逻辑 ([477f8bb](https://github.com/algerkong/AlgerMusicPlayer/commit/477f8bb))
- 在播放列表中添加歌曲删除功能,优化播放列表管理逻辑 ([a5f694e](https://github.com/algerkong/AlgerMusicPlayer/commit/a5f694e)) (#94)
- 优化歌词窗口字体控制按钮样式 ([c5e50c9](https://github.com/algerkong/AlgerMusicPlayer/commit/c5e50c9))
- 优化首页banner加载逻辑 ([01ccad4](https://github.com/algerkong/AlgerMusicPlayer/commit/01ccad4))
- 优化歌手详情页面 由抽屉改为页面 ([dfb8f55](https://github.com/algerkong/AlgerMusicPlayer/commit/dfb8f55))
- 增加用户关注列表和关注用户详情页 可查看听歌排行和用户歌单 ([2924ad6](https://github.com/algerkong/AlgerMusicPlayer/commit/2924ad6))
- 优化进度条 鼠标悬停直接显示进度信息 ([9ce872e](https://github.com/algerkong/AlgerMusicPlayer/commit/9ce872e))
- 优化应用更新下载功能 可后台下载 弹出下载完成提示 不再自动关闭应用 ([23b2340](https://github.com/algerkong/AlgerMusicPlayer/commit/23b2340))
### 🐛 Bug 修复
- 修复进度条多次拖动和多次暂停播放引发的歌曲重复播放bug ([cfe197c](https://github.com/algerkong/AlgerMusicPlayer/commit/cfe197c)) (#104)
- 修复关闭按钮最小化 还在任务栏显示的bug ([e0d1305](https://github.com/algerkong/AlgerMusicPlayer/commit/e0d1305))
- 修复播放列表中歌曲删除时类型不匹配的问题 ([8d6d052](https://github.com/algerkong/AlgerMusicPlayer/commit/8d6d052))

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "AlgerMusicPlayer", "name": "AlgerMusicPlayer",
"version": "3.9.3", "version": "4.2.0",
"description": "Alger Music Player", "description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>", "author": "Alger <algerkc@qq.com>",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -27,7 +27,7 @@
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.1.7", "electron-updater": "^6.1.7",
"font-list": "^1.5.1", "font-list": "^1.5.1",
"netease-cloud-music-api-alger": "^4.25.0", "netease-cloud-music-api-alger": "^4.26.1",
"vue-i18n": "9" "vue-i18n": "9"
}, },
"devDependencies": { "devDependencies": {
@@ -52,9 +52,9 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.7.7", "axios": "^1.7.7",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"electron": "^34.0.0", "electron": "^35.0.2",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"electron-vite": "^2.3.0", "electron-vite": "^3.0.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
@@ -67,23 +67,23 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^15.0.4", "marked": "^15.0.4",
"naive-ui": "^2.41.0", "naive-ui": "^2.41.0",
"postcss": "^8.4.49", "pinia": "^3.0.1",
"postcss": "^8.5.3",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"remixicon": "^4.2.0", "remixicon": "^4.6.0",
"sass": "^1.83.4", "sass": "^1.86.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",
"tunajs": "^1.0.15",
"typescript": "^5.5.2", "typescript": "^5.5.2",
"unplugin-auto-import": "^0.18.2", "unplugin-auto-import": "^19.1.1",
"unplugin-vue-components": "^0.27.4", "unplugin-vue-components": "^28.4.1",
"vfonts": "^0.1.0", "vite": "^6.2.2",
"vite": "^5.3.1",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "7.4.0", "vite-plugin-vue-devtools": "7.7.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vue-tsc": "^2.0.22", "vue-tsc": "^2.0.22"
"vuex": "^4.1.0"
}, },
"build": { "build": {
"appId": "com.alger.music", "appId": "com.alger.music",

BIN
resources/icons/next.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

BIN
resources/icons/pause.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

BIN
resources/icons/play.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

BIN
resources/icons/prev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

View File

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

View File

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

View File

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

View File

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

View File

@@ -181,7 +181,9 @@ export default {
default: 'Default', default: 'Default',
light: 'Light', light: 'Light',
dark: 'Dark' dark: 'Dark'
} },
hideMiniPlayBar: 'Hide Mini Play Bar',
hideLyrics: 'Hide Lyrics'
}, },
shortcutSettings: { shortcutSettings: {
title: 'Shortcut Settings', title: 'Shortcut Settings',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -181,7 +181,9 @@ export default {
default: '默认', default: '默认',
light: '亮色', light: '亮色',
dark: '暗色' dark: '暗色'
} },
hideMiniPlayBar: '隐藏迷你播放栏',
hideLyrics: '隐藏歌词'
}, },
shortcutSettings: { shortcutSettings: {
title: '快捷键设置', title: '快捷键设置',

View File

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

View File

@@ -9,7 +9,7 @@ import { initializeConfig } from './modules/config';
import { initializeFileManager } from './modules/fileManager'; import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts'; import { initializeFonts } from './modules/fonts';
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts'; import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeTray, updateTrayMenu } from './modules/tray'; import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
import { setupUpdateHandlers } from './modules/update'; import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager } from './modules/window'; import { createMainWindow, initializeWindowManager } from './modules/window';
import { startMusicApi } from './server'; import { startMusicApi } from './server';
@@ -109,11 +109,21 @@ if (!isSingleInstance) {
// 更新主进程的语言设置 // 更新主进程的语言设置
i18n.global.locale = locale; i18n.global.locale = locale;
// 更新托盘菜单 // 更新托盘菜单
updateTrayMenu(); updateTrayMenu(mainWindow);
// 通知所有窗口语言已更改 // 通知所有窗口语言已更改
mainWindow?.webContents.send('language-changed', locale); mainWindow?.webContents.send('language-changed', locale);
}); });
// 监听播放状态变化
ipcMain.on('update-play-state', (_, playing: boolean) => {
updatePlayState(playing);
});
// 监听当前歌曲变化
ipcMain.on('update-current-song', (_, song: any) => {
updateCurrentSong(song);
});
// 所有窗口关闭时的处理 // 所有窗口关闭时的处理
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {

View File

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

View File

@@ -257,6 +257,17 @@ async function processDownloadQueue(event: Electron.IpcMainEvent) {
} }
} }
/**
* 清理文件名中的非法字符
*/
function sanitizeFilename(filename: string): string {
// 替换 Windows 和 Unix 系统中的非法字符
return filename
.replace(/[<>:"/\\|?*]/g, '_') // 替换特殊字符为下划线
.replace(/\s+/g, ' ') // 将多个空格替换为单个空格
.trim(); // 移除首尾空格
}
/** /**
* 下载音乐功能 * 下载音乐功能
*/ */
@@ -276,9 +287,12 @@ async function downloadMusic(
const store = new Store(); const store = new Store();
const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads'); const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads');
// 清理文件名中的非法字符
const sanitizedFilename = sanitizeFilename(filename);
// 从URL中获取文件扩展名如果没有则使用传入的type或默认mp3 // 从URL中获取文件扩展名如果没有则使用传入的type或默认mp3
const urlExt = type ? `.${type}` : '.mp3'; const urlExt = type ? `.${type}` : '.mp3';
const filePath = path.join(downloadPath, `${filename}${urlExt}`); const filePath = path.join(downloadPath, `${sanitizedFilename}${urlExt}`);
// 检查文件是否已存在,如果存在则添加序号 // 检查文件是否已存在,如果存在则添加序号
finalFilePath = filePath; finalFilePath = filePath;

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,20 @@ declare global {
close: () => void; close: () => void;
dragStart: (data: string) => void; dragStart: (data: string) => void;
miniTray: () => void; miniTray: () => void;
miniWindow: () => void;
restore: () => void;
restart: () => void; restart: () => void;
resizeWindow: (width: number, height: number) => void;
resizeMiniWindow: (showPlaylist: boolean) => void;
unblockMusic: (id: number, data: any) => Promise<any>; unblockMusic: (id: number, data: any) => Promise<any>;
onLyricWindowClosed: (callback: () => void) => void;
startDownload: (url: string) => void; startDownload: (url: string) => void;
onDownloadProgress: (callback: (progress: number, status: string) => void) => void; onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void; onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
removeDownloadListeners: () => void; removeDownloadListeners: () => void;
onLanguageChanged: (callback: (locale: string) => void) => void;
invoke: (channel: string, ...args: any[]) => Promise<any>; invoke: (channel: string, ...args: any[]) => Promise<any>;
sendSong: (data: any) => void;
}; };
$message: any; $message: any;
} }

View File

@@ -8,10 +8,19 @@ const api = {
close: () => ipcRenderer.send('close-window'), close: () => ipcRenderer.send('close-window'),
dragStart: (data) => ipcRenderer.send('drag-start', data), dragStart: (data) => ipcRenderer.send('drag-start', data),
miniTray: () => ipcRenderer.send('mini-tray'), miniTray: () => ipcRenderer.send('mini-tray'),
miniWindow: () => ipcRenderer.send('mini-window'),
restore: () => ipcRenderer.send('restore-window'),
restart: () => ipcRenderer.send('restart'), restart: () => ipcRenderer.send('restart'),
resizeWindow: (width, height) => ipcRenderer.send('resize-window', width, height),
resizeMiniWindow: (showPlaylist) => ipcRenderer.send('resize-mini-window', showPlaylist),
openLyric: () => ipcRenderer.send('open-lyric'), openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data), sendLyric: (data) => ipcRenderer.send('send-lyric', data),
sendSong: (data) => ipcRenderer.send('update-current-song', data),
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id), unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id),
// 歌词窗口关闭事件
onLyricWindowClosed: (callback: () => void) => {
ipcRenderer.on('lyric-window-closed', () => callback());
},
// 更新相关 // 更新相关
startDownload: (url: string) => ipcRenderer.send('start-download', url), startDownload: (url: string) => ipcRenderer.send('start-download', url),
onDownloadProgress: (callback: (progress: number, status: string) => void) => { onDownloadProgress: (callback: (progress: number, status: string) => void) => {
@@ -20,6 +29,12 @@ const api = {
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => { onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => {
ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath)); ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath));
}, },
// 语言相关
onLanguageChanged: (callback: (locale: string) => void) => {
ipcRenderer.on('language-changed', (_event, locale) => {
callback(locale);
});
},
removeDownloadListeners: () => { removeDownloadListeners: () => {
ipcRenderer.removeAllListeners('download-progress'); ipcRenderer.removeAllListeners('download-progress');
ipcRenderer.removeAllListeners('download-complete'); ipcRenderer.removeAllListeners('download-complete');

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { musicDB } from '@/hooks/MusicHook'; import { musicDB } from '@/hooks/MusicHook';
import store from '@/store'; import { useSettingsStore, useUserStore } from '@/store';
import type { ILyric } from '@/type/lyric'; import type { ILyric } from '@/type/lyric';
import { isElectron } from '@/utils'; import { isElectron } from '@/utils';
import request from '@/utils/request'; import request from '@/utils/request';
@@ -14,14 +14,16 @@ export const getMusicQualityDetail = (id: number) => {
// 根据音乐Id获取音乐播放URl // 根据音乐Id获取音乐播放URl
export const getMusicUrl = async (id: number, isDownloaded: boolean = false) => { export const getMusicUrl = async (id: number, isDownloaded: boolean = false) => {
const userStore = useUserStore();
const settingStore = useSettingsStore();
// 判断是否登录 // 判断是否登录
try { try {
if (store.state.user && isDownloaded && store.state.user.vipType !== 0) { if (userStore.user && isDownloaded && userStore.user.vipType !== 0) {
const url = '/song/download/url/v1'; const url = '/song/download/url/v1';
const res = await request.get(url, { const res = await request.get(url, {
params: { params: {
id, id,
level: store.state.setData.musicQuality || 'higher', level: settingStore.setData.musicQuality || 'higher',
cookie: `${localStorage.getItem('token')} os=pc;` cookie: `${localStorage.getItem('token')} os=pc;`
} }
}); });
@@ -37,7 +39,7 @@ export const getMusicUrl = async (id: number, isDownloaded: boolean = false) =>
return await request.get('/song/url/v1', { return await request.get('/song/url/v1', {
params: { params: {
id, id,
level: store.state.setData.musicQuality || 'higher' level: settingStore.setData.musicQuality || 'higher'
} }
}); });
}; };
@@ -91,7 +93,7 @@ export const likeSong = (id: number, like: boolean = true) => {
// 获取用户喜欢的音乐列表 // 获取用户喜欢的音乐列表
export const getLikedList = (uid: number) => { export const getLikedList = (uid: number) => {
return request.get('/likelist', { return request.get('/likelist', {
params: { uid } params: { uid, noLogin: true }
}); });
}; };

View File

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

View File

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

View File

@@ -2,15 +2,19 @@
// @ts-nocheck // @ts-nocheck
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {} export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar'] NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge'] NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup'] NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCarousel: typeof import('naive-ui')['NCarousel']
NCarouselItem: typeof import('naive-ui')['NCarouselItem']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NCheckbox: typeof import('naive-ui')['NCheckbox']
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup'] NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']

View File

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

View File

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

View File

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

View File

@@ -37,12 +37,12 @@
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" /> <n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
<span class="creator-name">{{ listInfo.creator.nickname }}</span> <span class="creator-name">{{ listInfo.creator.nickname }}</span>
</div> </div>
<div v-if="total" class="music-total">{{ t('player.songNum', { num: total }) }}</div>
<n-scrollbar style="max-height: 200"> <n-scrollbar style="max-height: 200px">
<div v-if="listInfo?.description" class="music-desc"> <div v-if="listInfo?.description" class="music-desc">
{{ listInfo.description }} {{ listInfo.description }}
</div> </div>
<play-bottom />
</n-scrollbar> </n-scrollbar>
</div> </div>
@@ -60,7 +60,7 @@
:style="getItemAnimationDelay(index)" :style="getItemAnimationDelay(index)"
> >
<song-item <song-item
:item="formatDetail(item)" :item="formatSong(item)"
:can-remove="canRemove" :can-remove="canRemove"
@play="handlePlay" @play="handlePlay"
@remove-song="(id) => emit('remove-song', id)" @remove-song="(id) => emit('remove-song', id)"
@@ -69,6 +69,9 @@
<div v-if="isLoadingMore" class="loading-more"> <div v-if="isLoadingMore" class="loading-more">
{{ t('common.loadingMore') }} {{ t('common.loadingMore') }}
</div> </div>
<div v-if="!hasMore" class="loading-more">
{{ t('common.noMore') }}
</div>
<play-bottom /> <play-bottom />
</div> </div>
</n-spin> </n-spin>
@@ -82,17 +85,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { usePlayerStore } from '@/store/modules/player';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils'; import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue'; import PlayBottom from './common/PlayBottom.vue';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const playerStore = usePlayerStore();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -119,10 +123,14 @@ const props = withDefaults(
const emit = defineEmits(['update:show', 'update:loading', 'remove-song']); const emit = defineEmits(['update:show', 'update:loading', 'remove-song']);
const page = ref(0); const page = ref(0);
const pageSize = 20; const pageSize = 40;
const isLoadingMore = ref(false); const isLoadingMore = ref(false);
const displayedSongs = ref<any[]>([]); const displayedSongs = ref<any[]>([]);
const loadingList = ref(false); const loadingList = ref(false);
const loadedIds = ref(new Set<number>()); // 用于追踪已加载的歌曲ID
const isPlaylistLoading = ref(false); // 标记是否正在加载播放列表
const completePlaylist = ref<any[]>([]); // 存储完整的播放列表
const hasMore = ref(true); // 标记是否还有更多数据可加载
// 计算总数 // 计算总数
const total = computed(() => { const total = computed(() => {
@@ -132,108 +140,229 @@ const total = computed(() => {
return props.songList.length; return props.songList.length;
}); });
const formatDetail = computed(() => (detail: any) => { // 格式化歌曲数据
const song = { const formatSong = (item: any) => {
artists: detail.ar, return {
name: detail.al.name, ...item,
id: detail.al.id picUrl: item.al?.picUrl || item.picUrl,
song: {
artists: item.ar || item.artists,
name: item.al?.name || item.name,
id: item.al?.id || item.id
}
}; };
};
detail.song = song; /**
detail.picUrl = detail.al.picUrl; * 加载歌曲数据的核心函数
return detail; * @param ids 要加载的歌曲ID数组
}); * @param appendToList 是否将加载的歌曲追加到现有列表
* @param updateComplete 是否更新完整播放列表
*/
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
if (ids.length === 0) return [];
const handlePlay = () => { try {
const tracks = props.songList || []; const { data } = await getMusicDetail(ids);
store.commit( if (data?.songs) {
'setPlayList', // 更新已加载ID集合
tracks.map((item) => ({ const newSongs = data.songs.filter((song: any) => !loadedIds.value.has(song.id));
...item,
picUrl: item.al.picUrl, newSongs.forEach((song: any) => {
song: { loadedIds.value.add(song.id);
artists: item.ar });
if (appendToList) {
displayedSongs.value.push(...newSongs);
} }
}))
); if (updateComplete) {
completePlaylist.value.push(...newSongs);
}
return newSongs;
}
} catch (error) {
console.error('加载歌曲失败:', error);
}
return [];
};
// 加载完整播放列表
const loadFullPlaylist = async () => {
if (isPlaylistLoading.value) return;
isPlaylistLoading.value = true;
completePlaylist.value = [...displayedSongs.value]; // 先用当前已加载的歌曲初始化
try {
// 如果没有trackIds直接使用当前歌曲列表
if (!props.listInfo?.trackIds) {
return;
}
// 获取所有未加载的歌曲ID
const allIds = props.listInfo.trackIds.map((item) => item.id);
const unloadedIds = allIds.filter((id) => !loadedIds.value.has(id));
// 如果所有歌曲都已加载,直接返回
if (unloadedIds.length === 0) {
return;
}
// 分批加载未加载的歌曲
const batchSize = 500; // 每批加载的歌曲数量
for (let i = 0; i < unloadedIds.length; i += batchSize) {
const batchIds = unloadedIds.slice(i, i + batchSize);
if (batchIds.length === 0) continue;
await loadSongs(batchIds, false, true);
// 添加小延迟避免请求过于密集
if (i + batchSize < unloadedIds.length) {
// 使用 setTimeout 直接延迟,避免 Promise 相关的 linter 错误
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 300);
});
}
}
} catch (error) {
console.error('加载完整播放列表失败:', error);
} finally {
isPlaylistLoading.value = false;
}
};
// 处理播放
const handlePlay = async () => {
// 先使用当前已加载的歌曲开始播放
playerStore.setPlayList(displayedSongs.value.map(formatSong));
// 在后台加载完整播放列表
loadFullPlaylist().then(() => {
// 加载完成后,更新播放列表为完整列表
if (completePlaylist.value.length > 0) {
playerStore.setPlayList(completePlaylist.value.map(formatSong));
}
});
}; };
const close = () => { const close = () => {
emit('update:show', false); emit('update:show', false);
}; };
// 优化加载更多歌曲的函数 // 加载更多歌曲
const loadMoreSongs = async () => { const loadMoreSongs = async () => {
if (isLoadingMore.value || displayedSongs.value.length >= total.value) return; // 检查是否正在加载或已经加载完成
if (isLoadingMore.value || displayedSongs.value.length >= total.value) {
hasMore.value = false;
return;
}
isLoadingMore.value = true; isLoadingMore.value = true;
try { try {
if (props.listInfo?.trackIds) { const start = displayedSongs.value.length;
// 如果有 trackIds需要分批请求歌曲详情 const end = Math.min(start + pageSize, total.value);
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, total.value);
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
if (trackIds.length > 0) { if (props.listInfo?.trackIds) {
const { data } = await getMusicDetail(trackIds); // 获取这一批次需要加载的所有ID
displayedSongs.value = [...displayedSongs.value, ...data.songs]; const trackIdsToLoad = props.listInfo.trackIds
page.value++; .slice(start, end)
.map((item) => item.id)
.filter((id) => !loadedIds.value.has(id));
if (trackIdsToLoad.length > 0) {
await loadSongs(trackIdsToLoad, true, false);
} }
} else { } else if (start < props.songList.length) {
// 如果没有 trackIds直接使用 songList 分页 // 直接使用 songList 分页
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
const newSongs = props.songList.slice(start, end); const newSongs = props.songList.slice(start, end);
displayedSongs.value = [...displayedSongs.value, ...newSongs]; newSongs.forEach((song) => {
page.value++; if (!loadedIds.value.has(song.id)) {
loadedIds.value.add(song.id);
displayedSongs.value.push(song);
}
});
} }
// 更新是否还有更多数据的状态
hasMore.value = displayedSongs.value.length < total.value;
} catch (error) { } catch (error) {
console.error('加载歌曲失败:', error); console.error('加载更多歌曲失败:', error);
} finally { } finally {
isLoadingMore.value = false; isLoadingMore.value = false;
loadingList.value = false; loadingList.value = false;
} }
}; };
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize;
return setAnimationDelay(currentPageIndex, 20);
};
// 修改滚动处理函数 // 修改滚动处理函数
const handleScroll = (e: Event) => { const handleScroll = (e: Event) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (!target) return; if (!target) return;
const { scrollTop, scrollHeight, clientHeight } = target; const { scrollTop, scrollHeight, clientHeight } = target;
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) { const threshold = 200;
if (
scrollHeight - scrollTop - clientHeight < threshold &&
!isLoadingMore.value &&
hasMore.value
) {
loadMoreSongs(); loadMoreSongs();
} }
}; };
watch( const getItemAnimationDelay = (index: number) => {
() => props.show, const currentPageIndex = index % pageSize;
(newVal) => { return setAnimationDelay(currentPageIndex, 20);
loadingList.value = newVal; };
if (!props.cover) {
loadingList.value = false;
}
}
);
// 监听 songList 变化,重置分页状态 // 重置列表状态
const resetListState = () => {
page.value = 0;
loadedIds.value.clear();
displayedSongs.value = [];
completePlaylist.value = [];
hasMore.value = true;
loadingList.value = false;
};
// 初始化歌曲列表
const initSongList = (songs: any[]) => {
if (songs.length > 0) {
displayedSongs.value = [...songs];
songs.forEach((song) => loadedIds.value.add(song.id));
page.value = Math.ceil(songs.length / pageSize);
}
// 检查是否还有更多数据可加载
hasMore.value = displayedSongs.value.length < total.value;
};
// 修改 songList 监听器
watch( watch(
() => props.songList, () => props.songList,
(newSongs) => { (newSongs) => {
page.value = 0; // 重置所有状态
displayedSongs.value = newSongs.slice(0, pageSize); resetListState();
if (newSongs.length > pageSize) {
page.value = 1; // 初始化歌曲列表
initSongList(newSongs);
// 如果还有更多歌曲需要加载,且差距较小,立即加载
if (hasMore.value && props.listInfo?.trackIds) {
setTimeout(() => {
loadMoreSongs();
}, 300);
} }
loadingList.value = false;
}, },
{ immediate: true } { immediate: true }
); );
// 组件卸载时清理状态
onUnmounted(() => {
isPlaylistLoading.value = false;
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -242,6 +371,10 @@ watch(
@apply text-xl font-bold text-gray-900 dark:text-white; @apply text-xl font-bold text-gray-900 dark:text-white;
} }
&-total {
@apply text-sm font-normal text-gray-500 dark:text-gray-400;
}
&-page { &-page {
@apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl; @apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
@@ -306,6 +439,7 @@ watch(
.music-content { .music-content {
@apply flex-col; @apply flex-col;
width: 100vw !important;
} }
.music-info { .music-info {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="download-drawer-trigger"> <div class="download-drawer-trigger">
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0"> <n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
<n-button circle @click="store.commit('setShowDownloadDrawer', true)"> <n-button circle @click="settingsStore.showDownloadDrawer = true">
<template #icon> <template #icon>
<i class="iconfont ri-download-cloud-2-line"></i> <i class="iconfont ri-download-cloud-2-line"></i>
</template> </template>
@@ -179,11 +179,13 @@ import type { ProgressStatus } from 'naive-ui';
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
// import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
// import { audioService } from '@/services/audioService'; // import { audioService } from '@/services/audioService';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
// import { SongResult } from '@/type/music';
const { t } = useI18n(); const { t } = useI18n();
@@ -208,11 +210,14 @@ interface DownloadedItem {
} }
const message = useMessage(); const message = useMessage();
const store = useStore(); // const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const showDrawer = computed({ const showDrawer = computed({
get: () => store.state.showDownloadDrawer, get: () => settingsStore.showDownloadDrawer,
set: (val) => store.commit('setShowDownloadDrawer', val) set: (val) => {
settingsStore.showDownloadDrawer = val;
}
}); });
const downloadList = ref<DownloadItem[]>([]); const downloadList = ref<DownloadItem[]>([]);
@@ -224,10 +229,6 @@ const downList = computed(() => {
return (downloadedList.value as DownloadedItem[]).reverse(); return (downloadedList.value as DownloadedItem[]).reverse();
}); });
// 获取播放状态
// const play = computed(() => store.state.play as boolean);
// const currentMusic = computed(() => store.state.playMusic);
// 计算下载中的任务数量 // 计算下载中的任务数量
const downloadingCount = computed(() => { const downloadingCount = computed(() => {
return downloadList.value.filter((item) => item.status === 'downloading').length; return downloadList.value.filter((item) => item.status === 'downloading').length;
@@ -343,50 +344,10 @@ const confirmDelete = async () => {
}; };
// 播放音乐 // 播放音乐
// const handlePlayMusic = async (item: DownloadedItem) => { // const handlePlay = async (musicInfo: SongResult) => {
// // 确保路径正确编码 // await playerStore.setPlay(musicInfo);
// const encodedPath = encodeURIComponent(item.path); // playerStore.setPlayMusic(true);
// const localUrl = `local://${encodedPath}`; // playerStore.setIsPlay(true);
// const musicInfo = {
// name: item.filename,
// id: item.id,
// url: localUrl,
// playMusicUrl: localUrl,
// picUrl: item.picUrl,
// ar: item.ar || [{ name: '本地音乐' }],
// song: {
// artists: item.ar || [{ name: '本地音乐' }]
// },
// al: {
// picUrl: item.picUrl || '/images/default_cover.png'
// }
// };
// // 如果是当前播放的音乐,则切换播放状态
// if (currentMusic.value?.id === item.id) {
// if (play.value) {
// audioService.getCurrentSound()?.pause();
// store.commit('setPlayMusic', false);
// } else {
// audioService.getCurrentSound()?.play();
// store.commit('setPlayMusic', true);
// }
// return;
// }
// // 播放新的音乐
// store.commit('setPlay', musicInfo);
// store.commit('setPlayMusic', true);
// store.commit('setIsPlay', true);
// store.commit(
// 'setPlayList',
// downloadedList.value.map((item) => ({
// ...item,
// playMusicUrl: `local://${encodeURIComponent(item.path)}`
// }))
// );
// }; // };
// 获取已下载音乐列表 // 获取已下载音乐列表
@@ -522,7 +483,7 @@ onMounted(() => {
}); });
const handleDrawerClose = () => { const handleDrawerClose = () => {
store.commit('setShowDownloadDrawer', false); settingsStore.showDownloadDrawer = false;
}; };
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,10 +90,11 @@ import type { MenuOption } from 'naive-ui';
import { NImage, NText, useMessage } from 'naive-ui'; import { NImage, NText, useMessage } from 'naive-ui';
import { computed, h, inject, ref, useTemplateRef } from 'vue'; import { computed, h, inject, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getSongUrl } from '@/hooks/MusicListHook'; import { getSongUrl } from '@/hooks/MusicListHook';
import { useArtist } from '@/hooks/useArtist';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron } from '@/utils'; import { getImgUrl, isElectron } from '@/utils';
import { getImageBackground } from '@/utils/linearColor'; import { getImageBackground } from '@/utils/linearColor';
@@ -120,11 +121,12 @@ const props = withDefaults(
} }
); );
const store = useStore(); const playerStore = usePlayerStore();
const message = useMessage(); const message = useMessage();
const play = computed(() => store.state.play as boolean); const play = computed(() => playerStore.isPlay);
const playMusic = computed(() => store.state.playMusic); const playMusic = computed(() => playerStore.playMusic);
const playLoading = computed( const playLoading = computed(
() => playMusic.value.id === props.item.id && playMusic.value.playLoading () => playMusic.value.id === props.item.id && playMusic.value.playLoading
); );
@@ -138,7 +140,9 @@ const dropdownY = ref(0);
const isDownloading = ref(false); const isDownloading = ref(false);
const openPlaylistDrawer = inject<(songId: number) => void>('openPlaylistDrawer'); const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
const { navigateToArtist } = useArtist();
const renderSongPreview = () => { const renderSongPreview = () => {
return h( return h(
@@ -281,7 +285,7 @@ const downloadMusic = async () => {
try { try {
isDownloading.value = true; isDownloading.value = true;
const data = (await getSongUrl(props.item.id, cloneDeep(props.item), true)) as any; const data = (await getSongUrl(props.item.id as number, cloneDeep(props.item), true)) as any;
if (!data || !data.url) { if (!data || !data.url) {
throw new Error(t('songItem.message.getUrlFailed')); throw new Error(t('songItem.message.getUrlFailed'));
} }
@@ -354,33 +358,48 @@ const imageLoad = async () => {
// 播放音乐 设置音乐详情 打开音乐底栏 // 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => { const playMusicEvent = async (item: SongResult) => {
// 如果是当前正在播放的音乐,则切换播放/暂停状态
if (playMusic.value.id === item.id) { if (playMusic.value.id === item.id) {
if (play.value) { if (play.value) {
store.commit('setPlayMusic', false); playerStore.setPlayMusic(false);
audioService.getCurrentSound()?.pause(); audioService.getCurrentSound()?.pause();
} else { } else {
store.commit('setPlayMusic', true); playerStore.setPlayMusic(true);
audioService.getCurrentSound()?.play(); audioService.getCurrentSound()?.play();
} }
return; return;
} }
await store.commit('setPlay', item);
store.commit('setIsPlay', true); try {
emits('play', item); // 使用store的setPlay方法该方法已经包含了B站视频URL处理逻辑
const result = await playerStore.setPlay(item);
if (!result) {
throw new Error('播放失败');
}
playerStore.isPlay = true;
emits('play', item);
} catch (error) {
console.error('播放出错:', error);
}
}; };
// 判断是否已收藏 // 判断是否已收藏
const isFavorite = computed(() => { const isFavorite = computed(() => {
return store.state.favoriteList.includes(props.item.id); // 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.favoriteList.includes(numericId);
}); });
// 切换收藏状态 // 切换收藏状态
const toggleFavorite = async (e: Event) => { const toggleFavorite = async (e: Event) => {
e.stopPropagation(); e.stopPropagation();
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) { if (isFavorite.value) {
store.commit('removeFromFavorite', props.item.id); playerStore.removeFromFavorite(numericId);
} else { } else {
store.commit('addToFavorite', props.item.id); playerStore.addToFavorite(numericId);
} }
}; };
@@ -390,7 +409,7 @@ const toggleSelect = () => {
}; };
const handleArtistClick = (id: number) => { const handleArtistClick = (id: number) => {
store.commit('setCurrentArtistId', id); navigateToArtist(id);
}; };
// 获取歌手列表最多显示5个 // 获取歌手列表最多显示5个
@@ -400,7 +419,7 @@ const artists = computed(() => {
// 添加到下一首播放 // 添加到下一首播放
const handlePlayNext = () => { const handlePlayNext = () => {
store.commit('addToNextPlay', props.item); playerStore.addToNextPlay(props.item);
message.success(t('songItem.message.addedToNextPlay')); message.success(t('songItem.message.addedToNextPlay'));
}; };
</script> </script>

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,16 @@
<n-switch v-model:value="config.hidePlayBar" /> <n-switch v-model:value="config.hidePlayBar" />
</div> </div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.hideMiniPlayBar') }}</span>
<n-switch v-model:value="config.hideMiniPlayBar" />
</div>
<div class="settings-item">
<span>{{ t('settings.lyricSettings.hideLyrics') }}</span>
<n-switch v-model:value="config.hideLyrics" />
</div>
<div class="settings-slider"> <div class="settings-slider">
<span>{{ t('settings.lyricSettings.fontSize') }}</span> <span>{{ t('settings.lyricSettings.fontSize') }}</span>
<n-slider <n-slider
@@ -99,7 +109,9 @@ interface LyricConfig {
showTranslation: boolean; showTranslation: boolean;
theme: 'default' | 'light' | 'dark'; theme: 'default' | 'light' | 'dark';
hidePlayBar: boolean; hidePlayBar: boolean;
hideMiniPlayBar: boolean;
pureModeEnabled: boolean; pureModeEnabled: boolean;
hideLyrics: boolean;
} }
const config = ref<LyricConfig>({ const config = ref<LyricConfig>({
@@ -111,7 +123,9 @@ const config = ref<LyricConfig>({
showTranslation: true, showTranslation: true,
theme: 'default', theme: 'default',
hidePlayBar: false, hidePlayBar: false,
pureModeEnabled: false hideMiniPlayBar: false,
pureModeEnabled: false,
hideLyrics: false
}); });
const emit = defineEmits(['themeChange']); const emit = defineEmits(['themeChange']);

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -127,7 +127,7 @@ const isText = ref(false);
&-item { &-item {
&-link { &-link {
@apply my-4 w-auto; @apply my-2 w-auto;
} }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,3 @@
import 'vfonts/Lato.css';
import 'vfonts/FiraCode.css';
// tailwind css
import './index.css'; import './index.css';
import 'animate.css'; import 'animate.css';
import 'remixicon/fonts/remixicon.css'; import 'remixicon/fonts/remixicon.css';
@@ -9,7 +6,7 @@ import { createApp } from 'vue';
import i18n from '@/../i18n/renderer'; import i18n from '@/../i18n/renderer';
import router from '@/router'; import router from '@/router';
import store from '@/store'; import pinia from '@/store';
import App from './App.vue'; import App from './App.vue';
import directives from './directive'; import directives from './directive';
@@ -20,7 +17,7 @@ Object.keys(directives).forEach((key: string) => {
app.directive(key, directives[key as keyof typeof directives]); app.directive(key, directives[key as keyof typeof directives]);
}); });
app.use(pinia);
app.use(router); app.use(router);
app.use(store);
app.use(i18n); app.use(i18n);
app.mount('#app'); app.mount('#app');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,594 @@
import { cloneDeep } from 'lodash';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { getBilibiliAudioUrl } from '@/api/bilibili';
import { getLikedList, getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import type { ILyric, ILyricText, SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
import { useSettingsStore } from './settings';
import { useUserStore } from './user';
const musicHistory = useMusicHistory();
const preloadingSounds = ref<Howl[]>([]);
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch {
return defaultValue;
}
}
// 提取公共函数获取B站视频URL
export const getSongUrl = async (
id: string | number,
songData: SongResult,
isDownloaded: boolean = false
) => {
if (songData.playMusicUrl) {
return songData.playMusicUrl;
}
if (songData.source === 'bilibili' && songData.bilibiliData) {
console.log('加载B站音频URL');
if (!songData.playMusicUrl && songData.bilibiliData.bvid && songData.bilibiliData.cid) {
try {
songData.playMusicUrl = await getBilibiliAudioUrl(
songData.bilibiliData.bvid,
songData.bilibiliData.cid
);
return songData.playMusicUrl;
} catch (error) {
console.error('重启后获取B站音频URL失败:', error);
return '';
}
}
return songData.playMusicUrl || '';
}
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
const { data } = await getMusicUrl(numericId, isDownloaded);
let url = '';
let songDetail = null;
try {
if (data.data[0].freeTrialInfo || !data.data[0].url) {
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
url = res.data.data.url;
songDetail = res.data.data;
} else {
songDetail = data.data[0] as any;
}
} catch (error) {
console.error('error', error);
}
if (isDownloaded) {
return songDetail;
}
url = url || data.data[0].url;
return url;
};
const parseTime = (timeString: string): number => {
const [minutes, seconds] = timeString.split(':');
return Number(minutes) * 60 + Number(seconds);
};
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
const time = parseTime(timeText);
const text = lyricLine.replace(LRC_REGEX, '').trim();
return { time, text };
};
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
const lines = lyricsString.split('\n');
const lyrics: ILyricText[] = [];
const times: number[] = [];
lines.forEach((line) => {
const { time, text } = parseLyricLine(line);
times.push(time);
lyrics.push({ text, trText: '' });
});
return { lyrics, times };
};
export const loadLrc = async (id: string | number): Promise<ILyric> => {
if (typeof id === 'string' && id.includes('--')) {
console.log('B站音频无需加载歌词');
return {
lrcTimeArray: [],
lrcArray: []
};
}
try {
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
const { data } = await getMusicLrc(numericId);
const { lyrics, times } = parseLyrics(data.lrc.lyric);
const tlyric: Record<string, string> = {};
if (data.tlyric && data.tlyric.lyric) {
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
tLyrics.forEach((lyric, index) => {
tlyric[tTimes[index].toString()] = lyric.text;
});
}
lyrics.forEach((item, index) => {
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
});
return {
lrcTimeArray: times,
lrcArray: lyrics
};
} catch (err) {
console.error('Error loading lyrics:', err);
return {
lrcTimeArray: [],
lrcArray: []
};
}
};
const getSongDetail = async (playMusic: SongResult) => {
playMusic.playLoading = true;
if (playMusic.source === 'bilibili') {
console.log('处理B站音频详情');
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
playMusic.playLoading = false;
return { ...playMusic, backgroundColor, primaryColor } as SongResult;
}
const playMusicUrl = playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic));
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
playMusic.playLoading = false;
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult;
};
const preloadNextSong = (nextSongUrl: string) => {
try {
// 清理多余的预加载实例确保最多只有2个预加载音频
while (preloadingSounds.value.length >= 2) {
const oldestSound = preloadingSounds.value.shift();
if (oldestSound) {
try {
oldestSound.stop();
oldestSound.unload();
} catch (e) {
console.error('清理预加载音频实例失败:', e);
}
}
}
// 检查这个URL是否已经在预加载列表中
const existingPreload = preloadingSounds.value.find(
(sound) => (sound as any)._src === nextSongUrl
);
if (existingPreload) {
console.log('该音频已在预加载列表中,跳过:', nextSongUrl);
return existingPreload;
}
const sound = new Howl({
src: [nextSongUrl],
html5: true,
preload: true,
autoplay: false
});
preloadingSounds.value.push(sound);
sound.on('loaderror', () => {
console.error('预加载音频失败:', nextSongUrl);
const index = preloadingSounds.value.indexOf(sound);
if (index > -1) {
preloadingSounds.value.splice(index, 1);
}
try {
sound.stop();
sound.unload();
} catch (e) {
console.error('卸载预加载音频失败:', e);
}
});
return sound;
} catch (error) {
console.error('预加载音频出错:', error);
return null;
}
};
const fetchSongs = async (playList: SongResult[], startIndex: number, endIndex: number) => {
try {
const songs = playList.slice(Math.max(0, startIndex), Math.min(endIndex, playList.length));
const detailedSongs = await Promise.all(
songs.map(async (song: SongResult) => {
try {
if (!song.playMusicUrl || (song.source === 'netease' && !song.backgroundColor)) {
return await getSongDetail(song);
}
return song;
} catch (error) {
console.error('获取歌曲详情失败:', error);
return song;
}
})
);
const nextSong = detailedSongs[0];
if (nextSong && !(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
try {
nextSong.lyric = await loadLrc(nextSong.id);
} catch (error) {
console.error('加载歌词失败:', error);
}
}
detailedSongs.forEach((song, index) => {
if (song && startIndex + index < playList.length) {
playList[startIndex + index] = song;
}
});
if (nextSong && nextSong.playMusicUrl) {
preloadNextSong(nextSong.playMusicUrl);
}
} catch (error) {
console.error('获取歌曲列表失败:', error);
}
};
const loadLrcAsync = async (playMusic: SongResult) => {
if (playMusic.lyric && playMusic.lyric.lrcTimeArray.length > 0) {
return;
}
const lyrics = await loadLrc(playMusic.id);
playMusic.lyric = lyrics;
};
export const usePlayerStore = defineStore('player', () => {
const play = ref(false);
const isPlay = ref(false);
const playMusic = ref<SongResult>(getLocalStorageItem('currentPlayMusic', {} as SongResult));
const playMusicUrl = ref(getLocalStorageItem('currentPlayMusicUrl', ''));
const playList = ref<SongResult[]>(getLocalStorageItem('playList', []));
const playListIndex = ref(getLocalStorageItem('playListIndex', 0));
const playMode = ref(getLocalStorageItem('playMode', 0));
const musicFull = ref(false);
const favoriteList = ref<number[]>(getLocalStorageItem('favoriteList', []));
const savedPlayProgress = ref<number | undefined>();
const currentSong = computed(() => playMusic.value);
const isPlaying = computed(() => isPlay.value);
const currentPlayList = computed(() => playList.value);
const currentPlayListIndex = computed(() => playListIndex.value);
const handlePlayMusic = async (music: SongResult, isPlay: boolean = true) => {
// 处理B站视频确保URL有效
if (music.source === 'bilibili' && music.bilibiliData) {
try {
console.log('处理B站视频检查URL有效性');
// 清除之前的URL强制重新获取
music.playMusicUrl = undefined;
// 重新获取B站视频URL
if (music.bilibiliData.bvid && music.bilibiliData.cid) {
music.playMusicUrl = await getBilibiliAudioUrl(
music.bilibiliData.bvid,
music.bilibiliData.cid
);
console.log('获取B站URL成功:', music.playMusicUrl);
}
} catch (error) {
console.error('获取B站音频URL失败:', error);
throw error; // 向上抛出错误,让调用者处理
}
}
const updatedPlayMusic = await getSongDetail(music);
playMusic.value = updatedPlayMusic;
playMusicUrl.value = updatedPlayMusic.playMusicUrl as string;
play.value = isPlay;
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
localStorage.setItem('isPlaying', play.value.toString());
let title = updatedPlayMusic.name;
if (updatedPlayMusic.source === 'netease' && updatedPlayMusic?.song?.artists) {
title += ` - ${updatedPlayMusic.song.artists.reduce(
(prev: string, curr: any) => `${prev}${curr.name}/`,
''
)}`;
} else if (updatedPlayMusic.source === 'bilibili' && updatedPlayMusic?.song?.ar?.[0]) {
title += ` - ${updatedPlayMusic.song.ar[0].name}`;
}
document.title = title;
loadLrcAsync(playMusic.value);
musicHistory.addMusic(playMusic.value);
playListIndex.value = playList.value.findIndex(
(item: SongResult) => item.id === music.id && item.source === music.source
);
fetchSongs(playList.value, playListIndex.value + 1, playListIndex.value + 6);
};
const setPlay = async (song: SongResult) => {
try {
await handlePlayMusic(song);
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
return true;
} catch (error) {
console.error('设置播放失败:', error);
return false;
}
};
const setIsPlay = (value: boolean) => {
isPlay.value = value;
play.value = value;
localStorage.setItem('isPlaying', value.toString());
// 通知主进程播放状态变化
window.electron?.ipcRenderer.send('update-play-state', value);
};
const setPlayMusic = async (value: boolean | SongResult) => {
if (typeof value === 'boolean') {
setIsPlay(value);
} else {
await handlePlayMusic(value);
play.value = true;
isPlay.value = true;
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
}
};
const setMusicFull = (value: boolean) => {
musicFull.value = value;
};
const setPlayList = (list: SongResult[]) => {
playListIndex.value = list.findIndex((item) => item.id === playMusic.value.id);
playList.value = list;
localStorage.setItem('playList', JSON.stringify(list));
localStorage.setItem('playListIndex', playListIndex.value.toString());
};
const addToNextPlay = (song: SongResult) => {
const list = [...playList.value];
const currentIndex = playListIndex.value;
const existingIndex = list.findIndex((item) => item.id === song.id);
if (existingIndex !== -1) {
list.splice(existingIndex, 1);
}
list.splice(currentIndex + 1, 0, song);
setPlayList(list);
};
const nextPlay = async () => {
if (playList.value.length === 0) {
play.value = true;
return;
}
let nowPlayListIndex: number;
if (playMode.value === 2) {
do {
nowPlayListIndex = Math.floor(Math.random() * playList.value.length);
} while (nowPlayListIndex === playListIndex.value && playList.value.length > 1);
} else {
nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
}
playListIndex.value = nowPlayListIndex;
// 获取下一首歌曲
const nextSong = playList.value[playListIndex.value];
// 如果是B站视频确保重新获取URL
if (nextSong.source === 'bilibili' && nextSong.bilibiliData) {
// 清除之前的URL确保重新获取
nextSong.playMusicUrl = undefined;
console.log('下一首是B站视频已清除URL强制重新获取');
}
await handlePlayMusic(nextSong);
};
const prevPlay = async () => {
if (playList.value.length === 0) {
play.value = true;
return;
}
const nowPlayListIndex =
(playListIndex.value - 1 + playList.value.length) % playList.value.length;
// 获取上一首歌曲
const prevSong = playList.value[nowPlayListIndex];
// 如果是B站视频确保重新获取URL
if (prevSong.source === 'bilibili' && prevSong.bilibiliData) {
// 清除之前的URL确保重新获取
prevSong.playMusicUrl = undefined;
console.log('上一首是B站视频已清除URL强制重新获取');
}
await handlePlayMusic(prevSong);
await fetchSongs(playList.value, playListIndex.value - 5, nowPlayListIndex);
};
const togglePlayMode = () => {
playMode.value = (playMode.value + 1) % 3;
localStorage.setItem('playMode', JSON.stringify(playMode.value));
};
const addToFavorite = async (id: number) => {
if (!favoriteList.value.includes(id)) {
favoriteList.value.push(id);
localStorage.setItem('favoriteList', JSON.stringify(favoriteList.value));
}
};
const removeFromFavorite = async (id: number) => {
favoriteList.value = favoriteList.value.filter((item) => item !== id);
localStorage.setItem('favoriteList', JSON.stringify(favoriteList.value));
};
const removeFromPlayList = (id: number) => {
const index = playList.value.findIndex((item) => item.id === id);
if (index === -1) return;
// 如果删除的是当前播放的歌曲,先切换到下一首
if (id === playMusic.value.id) {
nextPlay();
}
// 从播放列表中移除,使用不可变的方式
const newPlayList = [...playList.value];
newPlayList.splice(index, 1);
setPlayList(newPlayList);
};
// 初始化播放状态
const initializePlayState = async () => {
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);
}
if (savedPlayMusic && Object.keys(savedPlayMusic).length > 0) {
try {
console.log('恢复上次播放的音乐:', savedPlayMusic.name);
console.log('settingStore.setData', settingStore.setData);
const isPlaying = settingStore.setData.autoPlay;
// 如果是B站视频确保播放URL能够在重启后正确恢复
if (savedPlayMusic.source === 'bilibili' && savedPlayMusic.bilibiliData) {
console.log('恢复B站视频播放', savedPlayMusic.bilibiliData);
// 清除之前可能存在的播放URL确保重新获取
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');
}
}
} catch (error) {
console.error('重新获取音乐链接失败:', error);
play.value = false;
isPlay.value = false;
playMusic.value = {} as SongResult;
playMusicUrl.value = '';
localStorage.removeItem('currentPlayMusic');
localStorage.removeItem('currentPlayMusicUrl');
localStorage.removeItem('isPlaying');
localStorage.removeItem('playProgress');
}
}
};
const initializeFavoriteList = async () => {
const userStore = useUserStore();
const localFavoriteList = localStorage.getItem('favoriteList');
const localList: number[] = localFavoriteList ? JSON.parse(localFavoriteList) : [];
if (userStore.user && userStore.user.userId) {
try {
const res = await getLikedList(userStore.user.userId);
if (res.data?.ids) {
const serverList = res.data.ids.reverse();
const mergedList = Array.from(new Set([...localList, ...serverList]));
favoriteList.value = mergedList;
} else {
favoriteList.value = localList;
}
} catch (error) {
console.error('获取服务器收藏列表失败,使用本地数据:', error);
favoriteList.value = localList;
}
} else {
favoriteList.value = localList;
}
localStorage.setItem('favoriteList', JSON.stringify(favoriteList.value));
};
return {
play,
isPlay,
playMusic,
playMusicUrl,
playList,
playListIndex,
playMode,
musicFull,
savedPlayProgress,
favoriteList,
currentSong,
isPlaying,
currentPlayList,
currentPlayListIndex,
setPlay,
setIsPlay,
nextPlay,
prevPlay,
setPlayMusic,
setMusicFull,
setPlayList,
addToNextPlay,
togglePlayMode,
initializePlayState,
initializeFavoriteList,
addToFavorite,
removeFromFavorite,
removeFromPlayList
};
});

View File

@@ -0,0 +1,22 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useSearchStore = defineStore('search', () => {
const searchValue = ref('');
const searchType = ref(1);
const setSearchValue = (value: string) => {
searchValue.value = value;
};
const setSearchType = (type: number) => {
searchType.value = type;
};
return {
searchValue,
searchType,
setSearchValue,
setSearchType
};
});

View File

@@ -0,0 +1,136 @@
import { cloneDeep } from 'lodash';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import setDataDefault from '@/../main/set.json';
import { isElectron } from '@/utils';
import { applyTheme, getCurrentTheme, ThemeType } from '@/utils/theme';
export const useSettingsStore = defineStore('settings', () => {
// 初始化时先从存储中读取设置
const getInitialSettings = () => {
if (isElectron) {
const savedSettings = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
return savedSettings || setDataDefault;
}
const savedSettings = localStorage.getItem('appSettings');
return savedSettings ? JSON.parse(savedSettings) : setDataDefault;
};
const setData = ref(getInitialSettings());
const theme = ref<ThemeType>(getCurrentTheme());
const isMobile = ref(false);
const isMiniMode = ref(false);
const showUpdateModal = ref(false);
const showArtistDrawer = ref(false);
const currentArtistId = ref<number | null>(null);
const systemFonts = ref<{ label: string; value: string }[]>([
{ label: '系统默认', value: 'system-ui' }
]);
const showDownloadDrawer = ref(false);
const setSetData = (data: any) => {
// 合并现有设置和新设置
const mergedData = {
...setData.value,
...data
};
if (isElectron) {
window.electron.ipcRenderer.send('set-store-value', 'set', cloneDeep(mergedData));
} else {
localStorage.setItem('appSettings', JSON.stringify(cloneDeep(mergedData)));
}
setData.value = cloneDeep(mergedData);
};
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark';
applyTheme(theme.value);
};
const setMiniMode = (value: boolean) => {
isMiniMode.value = value;
};
const setShowUpdateModal = (value: boolean) => {
showUpdateModal.value = value;
};
const setShowArtistDrawer = (show: boolean) => {
showArtistDrawer.value = show;
if (!show) {
currentArtistId.value = null;
}
};
const setCurrentArtistId = (id: number) => {
currentArtistId.value = id;
};
const setSystemFonts = (fonts: string[]) => {
systemFonts.value = [
{ label: '系统默认', value: 'system-ui' },
...fonts.map((font) => ({
label: font,
value: font
}))
];
};
const setShowDownloadDrawer = (show: boolean) => {
showDownloadDrawer.value = show;
};
const setLanguage = (language: string) => {
setSetData({ language });
if (isElectron) {
window.electron.ipcRenderer.send('change-language', language);
}
};
const initializeSettings = () => {
// const savedSettings = getInitialSettings();
// setData.value = savedSettings;
};
const initializeTheme = () => {
applyTheme(theme.value);
};
const initializeSystemFonts = async () => {
if (!isElectron) return;
if (systemFonts.value.length > 1) return;
try {
const fonts = await window.api.invoke('get-system-fonts');
setSystemFonts(fonts);
} catch (error) {
console.error('获取系统字体失败:', error);
}
};
return {
setData,
theme,
isMobile,
isMiniMode,
showUpdateModal,
showArtistDrawer,
currentArtistId,
systemFonts,
showDownloadDrawer,
setSetData,
toggleTheme,
setMiniMode,
setShowUpdateModal,
setShowArtistDrawer,
setCurrentArtistId,
setSystemFonts,
setShowDownloadDrawer,
setLanguage,
initializeSettings,
initializeTheme,
initializeSystemFonts
};
});

View File

@@ -0,0 +1,84 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { logout } from '@/api/login';
import { getLikedList } from '@/api/music';
interface UserData {
userId: number;
[key: string]: any;
}
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch {
return defaultValue;
}
}
export const useUserStore = defineStore('user', () => {
// 状态
const user = ref<UserData | null>(getLocalStorageItem('user', null));
const searchValue = ref('');
const searchType = ref(1);
// 方法
const setUser = (userData: UserData) => {
user.value = userData;
localStorage.setItem('user', JSON.stringify(userData));
};
const handleLogout = async () => {
try {
await logout();
user.value = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
} catch (error) {
console.error('登出失败:', error);
}
};
const setSearchValue = (value: string) => {
searchValue.value = value;
};
const setSearchType = (type: number) => {
searchType.value = type;
};
// 初始化
const initializeUser = async () => {
const savedUser = getLocalStorageItem<UserData | null>('user', null);
if (savedUser) {
user.value = savedUser;
// 如果用户已登录,获取收藏列表
if (localStorage.getItem('token')) {
try {
const { data } = await getLikedList(savedUser.userId);
return data?.ids || [];
} catch (error) {
console.error('获取收藏列表失败:', error);
return [];
}
}
}
return [];
};
return {
// 状态
user,
searchValue,
searchType,
// 方法
setUser,
handleLogout,
setSearchValue,
setSearchType,
initializeUser
};
});

View File

@@ -13,23 +13,29 @@ export interface ILyric {
} }
export interface SongResult { export interface SongResult {
id: number; id: string | number;
type: number;
name: string; name: string;
copywriter?: any;
picUrl: string; picUrl: string;
canDislike: boolean; playCount?: number;
trackNumberUpdateTime?: any; song?: any;
song: Song; copywriter?: string;
alg: string; type?: number;
count?: number; canDislike?: boolean;
program?: any;
alg?: string;
ar: Artist[];
al: Album;
count: number;
playMusicUrl?: string;
playLoading?: boolean; playLoading?: boolean;
ar?: Artist[]; lyric?: ILyric;
al?: Album;
backgroundColor?: string; backgroundColor?: string;
primaryColor?: string; primaryColor?: string;
playMusicUrl?: string; bilibiliData?: {
lyric?: ILyric; bvid: string;
cid: number;
};
source?: 'netease' | 'bilibili';
} }
export interface Song { export interface Song {
@@ -214,3 +220,16 @@ interface FreeTrialPrivilege {
resConsumable: boolean; resConsumable: boolean;
userConsumable: boolean; userConsumable: boolean;
} }
export interface IArtists {
id: number;
name: string;
picUrl: string | null;
alias: string[];
albumSize: number;
picId: number;
fansGroup: null;
img1v1Url: string;
img1v1: number;
trans: null;
}

View File

@@ -14,6 +14,20 @@ export interface IUserDetail {
profileVillageInfo: ProfileVillageInfo; profileVillageInfo: ProfileVillageInfo;
} }
export interface IUserFollow {
followed: boolean;
follows: boolean;
nickname: string;
avatarUrl: string;
userId: number;
gender: number;
signature: string;
backgroundUrl: string;
vipType: number;
userType: number;
accountType: number;
}
interface ProfileVillageInfo { interface ProfileVillageInfo {
title: string; title: string;
imageUrl?: any; imageUrl?: any;

View File

@@ -0,0 +1,114 @@
export interface IBilibiliSearchResult {
id: number;
bvid: string;
title: string;
pic: string;
duration: number | string;
pubdate: number;
ctime: number;
author: string;
view: number;
danmaku: number;
owner: {
mid: number;
name: string;
face: string;
};
stat: {
view: number;
danmaku: number;
reply: number;
favorite: number;
coin: number;
share: number;
like: number;
};
}
export interface IBilibiliVideoDetail {
aid: number;
bvid: string;
title: string;
pic: string;
desc: string;
duration: number;
pubdate: number;
ctime: number;
owner: {
mid: number;
name: string;
face: string;
};
stat: {
view: number;
danmaku: number;
reply: number;
favorite: number;
coin: number;
share: number;
like: number;
};
pages: IBilibiliPage[];
}
export interface IBilibiliPage {
cid: number;
page: number;
part: string;
duration: number;
dimension: {
width: number;
height: number;
rotate: number;
};
}
export interface IBilibiliPlayUrl {
durl?: {
order: number;
length: number;
size: number;
ahead: string;
vhead: string;
url: string;
backup_url: string[];
}[];
dash?: {
duration: number;
minBufferTime: number;
min_buffer_time: number;
video: IBilibiliDashItem[];
audio: IBilibiliDashItem[];
};
support_formats: {
quality: number;
format: string;
new_description: string;
display_desc: string;
}[];
accept_quality: number[];
accept_description: string[];
quality: number;
format: string;
timelength: number;
high_format: string;
}
export interface IBilibiliDashItem {
id: number;
baseUrl: string;
base_url: string;
backupUrl: string[];
backup_url: string[];
bandwidth: number;
mimeType: string;
mime_type: string;
codecs: string;
width?: number;
height?: number;
frameRate?: string;
frame_rate?: string;
startWithSap?: number;
start_with_sap?: number;
codecid: number;
}

View File

@@ -8,6 +8,7 @@ export interface IElectronAPI {
openLyric: () => void; openLyric: () => void;
sendLyric: (data: string) => void; sendLyric: (data: string) => void;
unblockMusic: (id: number) => Promise<string>; unblockMusic: (id: number) => Promise<string>;
onLanguageChanged: (callback: (locale: string) => void) => void;
store: { store: {
get: (key: string) => Promise<any>; get: (key: string) => Promise<any>;
set: (key: string, value: any) => Promise<boolean>; set: (key: string, value: any) => Promise<boolean>;

View File

@@ -1,6 +1,7 @@
import { useWindowSize } from '@vueuse/core';
import { computed } from 'vue'; import { computed } from 'vue';
import store from '@/store'; import { useSettingsStore } from '@/store/modules/settings';
// 设置歌手背景图片 // 设置歌手背景图片
export const setBackgroundImg = (url: String) => { export const setBackgroundImg = (url: String) => {
@@ -8,10 +9,11 @@ export const setBackgroundImg = (url: String) => {
}; };
// 设置动画类型 // 设置动画类型
export const setAnimationClass = (type: String) => { export const setAnimationClass = (type: String) => {
if (store.state.setData && store.state.setData.noAnimate) { const settingsStore = useSettingsStore();
if (settingsStore.setData && settingsStore.setData.noAnimate) {
return ''; return '';
} }
const speed = store.state.setData?.animationSpeed || 1; const speed = settingsStore.setData?.animationSpeed || 1;
let speedClass = ''; let speedClass = '';
if (speed <= 0.3) speedClass = 'animate__slower'; if (speed <= 0.3) speedClass = 'animate__slower';
@@ -23,10 +25,11 @@ export const setAnimationClass = (type: String) => {
}; };
// 设置动画延时 // 设置动画延时
export const setAnimationDelay = (index: number = 6, time: number = 50) => { export const setAnimationDelay = (index: number = 6, time: number = 50) => {
if (store.state.setData?.noAnimate) { const settingsStore = useSettingsStore();
if (settingsStore.setData?.noAnimate) {
return ''; return '';
} }
const speed = store.state.setData?.animationSpeed || 1; const speed = settingsStore.setData?.animationSpeed || 1;
return `animation-delay:${(index * time) / (speed * 2)}ms`; return `animation-delay:${(index * time) / (speed * 2)}ms`;
}; };
@@ -59,20 +62,53 @@ export const formatNumber = (num: string | number) => {
}; };
export const getImgUrl = (url: string | undefined, size: string = '') => { export const getImgUrl = (url: string | undefined, size: string = '') => {
if (!url) return '';
if (url.includes('thumbnail')) {
// 只替换最后一个 thumbnail 参数的尺寸
return url.replace(/thumbnail=\d+y\d+(?!.*thumbnail)/, `thumbnail=${size}`);
}
const imgUrl = `${url}?param=${size}`; const imgUrl = `${url}?param=${size}`;
return imgUrl; return imgUrl;
}; };
export const isMobile = computed(() => { export const isMobile = computed(() => {
const flag = navigator.userAgent.match( const { width } = useWindowSize();
const userAgentFlag = navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
); );
store.state.isMobile = !!flag; const isMobileWidth = width.value < 500;
const isMobileDevice = !!userAgentFlag || isMobileWidth;
// 给html标签 添加mobile const settingsStore = useSettingsStore();
if (flag) document.documentElement.classList.add('mobile'); settingsStore.isMobile = isMobileDevice;
return !!flag;
// 给html标签 添加或移除mobile类
if (isMobileDevice) {
document.documentElement.classList.add('mobile');
} else {
document.documentElement.classList.add('pc');
document.documentElement.classList.remove('mobile');
}
return isMobileDevice;
}); });
export const isElectron = (window as any).electron !== undefined; export const isElectron = (window as any).electron !== undefined;
export const isLyricWindow = computed(() => {
return window.location.hash.includes('lyric');
});
export const getSetData = (): any => {
let setData = null;
if (window.electron) {
setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
} else {
const settingsStore = useSettingsStore();
setData = settingsStore.setData;
}
return setData;
};

View File

@@ -1,16 +1,11 @@
import axios, { InternalAxiosRequestConfig } from 'axios'; import axios, { InternalAxiosRequestConfig } from 'axios';
import store from '@/store'; import { useUserStore } from '@/store/modules/user';
import { isElectron } from '.'; import { getSetData, isElectron } from '.';
let setData: any = null; let setData: any = null;
const getSetData = () => {
if (window.electron) {
setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
}
};
getSetData();
// 扩展请求配置接口 // 扩展请求配置接口
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig { interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
retryCount?: number; retryCount?: number;
@@ -22,7 +17,8 @@ const baseURL = window.electron
const request = axios.create({ const request = axios.create({
baseURL, baseURL,
timeout: 5000 timeout: 5000,
withCredentials: true
}); });
// 最大重试次数 // 最大重试次数
@@ -33,7 +29,10 @@ const RETRY_DELAY = 500;
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
(config: CustomAxiosRequestConfig) => { (config: CustomAxiosRequestConfig) => {
getSetData(); setData = getSetData();
config.baseURL = window.electron
? `http://127.0.0.1:${setData?.musicApiPort}`
: import.meta.env.VITE_API;
// 只在retryCount未定义时初始化为0 // 只在retryCount未定义时初始化为0
if (config.retryCount === undefined) { if (config.retryCount === undefined) {
config.retryCount = 0; config.retryCount = 0;
@@ -46,8 +45,13 @@ request.interceptors.request.use(
timestamp: Date.now() timestamp: Date.now()
}; };
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token && config.method !== 'post') {
config.params.cookie = config.params.cookie !== undefined ? config.params.cookie : token; config.params.cookie = config.params.cookie !== undefined ? config.params.cookie : token;
} else if (token && config.method === 'post') {
config.data = {
...config.data,
cookie: token
};
} }
if (isElectron) { if (isElectron) {
const proxyConfig = setData?.proxyConfig; const proxyConfig = setData?.proxyConfig;
@@ -75,7 +79,7 @@ request.interceptors.response.use(
return response; return response;
}, },
async (error) => { async (error) => {
console.log('error', error); console.error('error', error);
const config = error.config as CustomAxiosRequestConfig; const config = error.config as CustomAxiosRequestConfig;
// 如果没有配置,直接返回错误 // 如果没有配置,直接返回错误
@@ -84,9 +88,10 @@ request.interceptors.response.use(
} }
// 处理 301 状态码 // 处理 301 状态码
if (error.response?.status === 301) { if (error.response?.status === 301 && config.params.noLogin !== true) {
// 使用 store mutation 清除用户信息 // 使用 store mutation 清除用户信息
store.commit('logout'); const userStore = useUserStore();
userStore.handleLogout();
console.log(`301 状态码,清除登录信息后重试第 ${config.retryCount}`); console.log(`301 状态码,清除登录信息后重试第 ${config.retryCount}`);
config.retryCount = 3; config.retryCount = 3;
} }
@@ -98,7 +103,7 @@ request.interceptors.response.use(
!NO_RETRY_URLS.includes(config.url as string) !NO_RETRY_URLS.includes(config.url as string)
) { ) {
config.retryCount++; config.retryCount++;
console.log(`请求重试第 ${config.retryCount}`); console.error(`请求重试第 ${config.retryCount}`);
// 延迟重试 // 延迟重试
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
@@ -107,7 +112,7 @@ request.interceptors.response.use(
return request(config); return request(config);
} }
console.log(`重试${MAX_RETRIES}次后仍然失败`); console.error(`重试${MAX_RETRIES}次后仍然失败`);
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@@ -0,0 +1,81 @@
import i18n from '@/../i18n/renderer';
import { audioService } from '@/services/audioService';
import { usePlayerStore, useSettingsStore } from '@/store';
import { isElectron } from '.';
import { showShortcutToast } from './shortcutToast';
const { t } = i18n.global;
export function initShortcut() {
if (isElectron) {
window.electron.ipcRenderer.on('global-shortcut', async (_, action: string) => {
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const currentSound = audioService.getCurrentSound();
const showToast = (message: string, iconName: string) => {
if (settingsStore.isMiniMode) {
return;
}
showShortcutToast(message, iconName);
};
switch (action) {
case 'togglePlay':
if (playerStore.play) {
await audioService.pause();
showToast(t('player.playBar.pause'), 'ri-pause-circle-line');
} else {
await audioService.play();
showToast(t('player.playBar.play'), 'ri-play-circle-line');
}
break;
case 'prevPlay':
playerStore.prevPlay();
showToast(t('player.playBar.prev'), 'ri-skip-back-line');
break;
case 'nextPlay':
playerStore.nextPlay();
showToast(t('player.playBar.next'), 'ri-skip-forward-line');
break;
case 'volumeUp':
if (currentSound && currentSound?.volume() < 1) {
currentSound?.volume((currentSound?.volume() || 0) + 0.1);
showToast(
`${t('player.playBar.volume')}${Math.round((currentSound?.volume() || 0) * 100)}%`,
'ri-volume-up-line'
);
}
break;
case 'volumeDown':
if (currentSound && currentSound?.volume() > 0) {
currentSound?.volume((currentSound?.volume() || 0) - 0.1);
showToast(
`${t('player.playBar.volume')}${Math.round((currentSound?.volume() || 0) * 100)}%`,
'ri-volume-down-line'
);
}
break;
case 'toggleFavorite': {
const isFavorite = playerStore.favoriteList.includes(Number(playerStore.playMusic.id));
const numericId = Number(playerStore.playMusic.id);
if (isFavorite) {
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(numericId);
}
showToast(
isFavorite
? t('player.playBar.favorite', { name: playerStore.playMusic.name })
: t('player.playBar.unFavorite', { name: playerStore.playMusic.name }),
isFavorite ? 'ri-heart-fill' : 'ri-heart-line'
);
break;
}
default:
console.log('未知的快捷键动作:', action);
break;
}
});
}
}

View File

@@ -0,0 +1,334 @@
<template>
<n-scrollbar v-loading="loading" class="artist-page">
<!-- 歌手信息头部 -->
<div class="artist-header">
<div class="artist-cover">
<n-image
:src="getImgUrl(artistInfo?.avatar, '300y300')"
class="artist-avatar"
preview-disabled
/>
</div>
<div class="artist-info">
<h1 class="artist-name">{{ artistInfo?.name }}</h1>
<div v-if="artistInfo?.alias?.length" class="artist-alias">
{{ artistInfo.alias.join(' / ') }}
</div>
<div v-if="artistInfo?.briefDesc" class="artist-desc">
{{ artistInfo.briefDesc }}
</div>
</div>
</div>
<!-- 标签页切换 -->
<n-tabs v-model:value="activeTab" class="content-tabs" type="line" animated>
<n-tab-pane name="songs" :tab="t('artist.hotSongs')">
<div class="songs-list">
<div class="song-list-content">
<song-item
v-for="song in songs"
:key="song.id"
:item="song"
:list="true"
@play="handlePlay"
/>
<div v-if="songLoading" class="loading-more">{{ t('common.loading') }}</div>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="albums" :tab="t('artist.albums')">
<div class="albums-list">
<div class="albums-grid">
<search-item
v-for="album in albums"
:key="album.id"
shape="square"
:item="{
id: album.id,
picUrl: album.picUrl,
name: album.name,
desc: formatPublishTime(album.publishTime),
size: album.size,
type: '专辑'
}"
/>
<div v-if="albumLoading" class="loading-more">{{ t('common.loading') }}</div>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="about" :tab="t('artist.description')">
<div class="artist-description">
<div class="description-content" v-html="artistInfo?.briefDesc"></div>
</div>
</n-tab-pane>
</n-tabs>
<play-bottom />
</n-scrollbar>
</template>
<script setup lang="ts">
import { useDateFormat, useThrottleFn } from '@vueuse/core';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { getArtistAlbums, getArtistDetail, getArtistTopSongs } from '@/api/artist';
import { getMusicDetail } from '@/api/music';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SearchItem from '@/components/common/SearchItem.vue';
import SongItem from '@/components/common/SongItem.vue';
import { usePlayerStore } from '@/store';
import { IArtist } from '@/type/artist';
import { getImgUrl } from '@/utils';
const { t } = useI18n();
const route = useRoute();
const playerStore = usePlayerStore();
const artistId = computed(() => Number(route.params.id));
const activeTab = ref('songs');
// 歌手信息
const artistInfo = ref<IArtist>();
const songs = ref<any[]>([]);
const albums = ref<any[]>([]);
// 加载状态
const loading = ref(false);
const songLoading = ref(false);
const albumLoading = ref(false);
// 分页参数
const songPage = ref({
page: 1,
pageSize: 30,
hasMore: true
});
const albumPage = ref({
page: 1,
pageSize: 30,
hasMore: true
});
// 加载歌手信息
const loadArtistInfo = async () => {
if (!artistId.value) return;
loading.value = true;
try {
const info = await getArtistDetail(artistId.value);
if (info.data?.data?.artist) {
artistInfo.value = info.data.data.artist;
}
// 重置分页并加载初始数据
resetPagination();
await Promise.all([loadSongs(), loadAlbums()]);
} catch (error) {
console.error('加载歌手信息失败:', error);
} finally {
loading.value = false;
}
};
// 重置分页
const resetPagination = () => {
songPage.value = {
page: 1,
pageSize: 30,
hasMore: true
};
albumPage.value = {
page: 1,
pageSize: 30,
hasMore: true
};
songs.value = [];
albums.value = [];
};
// 加载歌曲
const loadSongs = async () => {
if (!artistId.value || !songPage.value.hasMore || songLoading.value) return;
try {
songLoading.value = true;
const { page, pageSize } = songPage.value;
const res = await getArtistTopSongs({
id: artistId.value,
limit: pageSize,
offset: (page - 1) * pageSize
});
const ids = res.data.songs.map((item) => item.id);
const songsDetail = await getMusicDetail(ids);
if (songsDetail.data?.songs) {
const newSongs = songsDetail.data.songs.map((item) => {
return {
...item,
picUrl: item.al.picUrl,
song: {
artists: item.ar,
name: item.name,
id: item.id
}
};
});
songs.value = page === 1 ? newSongs : [...songs.value, ...newSongs];
songPage.value.hasMore = newSongs.length === pageSize;
songPage.value.page++;
}
} catch (error) {
console.error('加载歌曲失败:', error);
} finally {
songLoading.value = false;
}
};
// 加载专辑
const loadAlbums = async () => {
if (!artistId.value || !albumPage.value.hasMore || albumLoading.value) return;
try {
albumLoading.value = true;
const { page, pageSize } = albumPage.value;
const res = await getArtistAlbums({
id: artistId.value,
limit: pageSize,
offset: (page - 1) * pageSize
});
if (res.data?.hotAlbums) {
const newAlbums = res.data.hotAlbums;
albums.value = page === 1 ? newAlbums : [...albums.value, ...newAlbums];
albumPage.value.hasMore = newAlbums.length === pageSize;
albumPage.value.page++;
}
} catch (error) {
console.error('加载专辑失败:', error);
} finally {
albumLoading.value = false;
}
};
// 格式化发布时间
const formatPublishTime = (time: number) => {
return useDateFormat(time, 'YYYY-MM-DD').value;
};
const handlePlay = () => {
playerStore.setPlayList(
songs.value.map((item) => ({
...item,
picUrl: item.al.picUrl
}))
);
};
// 添加滚动处理函数
const handleScroll = useThrottleFn(() => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
if (documentHeight - (scrollTop + windowHeight) < 100) {
if (activeTab.value === 'songs') {
loadSongs();
} else if (activeTab.value === 'albums') {
loadAlbums();
}
}
}, 200);
// 监听页面滚动
onMounted(() => {
loadArtistInfo();
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
});
// 监听路由参数变化
watch(
() => route.params.id,
(newId) => {
if (newId) {
loadArtistInfo();
}
}
);
</script>
<style lang="scss" scoped>
.artist-page {
@apply min-h-screen w-full bg-light dark:bg-dark pb-24;
.nav-header {
@apply flex items-center px-4 py-3 sticky top-0 bg-light dark:bg-dark z-10;
i {
@apply text-xl mr-4 cursor-pointer;
}
.page-title {
@apply text-base font-medium truncate;
}
}
.artist-header {
@apply flex flex-col md:flex-row gap-4 md:gap-6 px-4 pb-4;
.artist-cover {
@apply flex justify-center md:justify-start;
.artist-avatar {
@apply w-40 h-40 md:w-48 md:h-48 rounded-2xl object-cover;
}
}
.artist-info {
@apply flex-1;
.artist-name {
@apply text-2xl md:text-4xl font-bold mb-2 text-center md:text-left;
}
.artist-alias {
@apply text-gray-500 dark:text-gray-400 mb-2 text-center md:text-left;
}
.artist-desc {
@apply text-sm text-gray-600 dark:text-gray-300 line-clamp-3 text-center md:text-left;
}
}
}
.content-tabs {
@apply px-4;
:deep(.n-tabs-nav) {
@apply sticky top-0 bg-light dark:bg-dark z-10;
}
}
.albums-grid {
@apply grid gap-6 grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6;
}
.loading-more {
@apply text-center py-4 text-gray-500 dark:text-gray-400;
}
.artist-description {
.description-content {
@apply text-sm leading-relaxed whitespace-pre-wrap;
}
}
}
</style>

View File

@@ -0,0 +1,614 @@
<template>
<div class="bilibili-player-page">
<n-scrollbar class="content-scrollbar">
<div class="content-wrapper">
<div v-if="isLoading" class="loading-wrapper">
<n-spin size="large" />
<p>听书加载中...</p>
</div>
<div v-else-if="errorMessage" class="error-wrapper">
<i class="ri-error-warning-line text-4xl text-red-500"></i>
<p>{{ errorMessage }}</p>
<n-button type="primary" @click="loadVideoSource">重试</n-button>
</div>
<div v-else-if="videoDetail" class="bilibili-info-wrapper" :class="mainContentAnimation">
<div class="bilibili-cover">
<n-image
:src="getBilibiliProxyUrl(videoDetail.pic)"
class="cover-image"
preview-disabled
/>
<!-- 悬浮的播放按钮 -->
<div class="play-overlay">
<div class="play-icon-bg" @click="playCurrentAudio">
<i class="ri-play-fill"></i>
</div>
<!-- 固定在右下角的大型播放按钮 -->
<n-button
type="primary"
size="large"
class="corner-play-button"
:loading="partLoading"
@click="playCurrentAudio"
>
<template #icon>
<i class="ri-play-fill"></i>
</template>
立即播放
</n-button>
</div>
</div>
<div class="video-info">
<div class="title">{{ videoDetail?.title || '加载中...' }}</div>
<div class="author">
<i class="ri-user-line mr-1"></i>
<span>{{ videoDetail.owner?.name }}</span>
</div>
<div class="stats">
<span
><i class="ri-play-line mr-1"></i>{{ formatNumber(videoDetail.stat?.view) }}</span
>
<span
><i class="ri-chat-1-line mr-1"></i
>{{ formatNumber(videoDetail.stat?.danmaku) }}</span
>
<span
><i class="ri-thumb-up-line mr-1"></i
>{{ formatNumber(videoDetail.stat?.like) }}</span
>
</div>
<div class="description">
<p>{{ videoDetail.desc }}</p>
</div>
<div class="duration">
<p>总时长: {{ formatTotalDuration(videoDetail.duration) }}</p>
</div>
</div>
</div>
<div
v-if="videoDetail?.pages && videoDetail.pages.length > 1"
class="video-parts"
:class="partsListAnimation"
>
<div class="parts-title">
分P列表 ({{ videoDetail.pages.length }})
<n-spin v-if="partLoading" size="small" class="ml-2" />
</div>
<div class="parts-list">
<n-button
v-for="page in videoDetail.pages"
:key="page.cid"
:type="isCurrentPlayingPage(page) ? 'primary' : 'default'"
:disabled="partLoading"
size="small"
class="part-item"
@click="switchPage(page)"
>
{{ page.part }}
</n-button>
</div>
</div>
<!-- 底部留白 -->
<div class="pb-20"></div>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getBilibiliPlayUrl, getBilibiliProxyUrl, getBilibiliVideoDetail } from '@/api/bilibili';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import type { IBilibiliPage, IBilibiliVideoDetail } from '@/types/bilibili';
import { setAnimationClass } from '@/utils';
defineOptions({
name: 'BilibiliPlayer'
});
// 使用路由获取参数
const route = useRoute();
const router = useRouter();
const message = useMessage();
const playerStore = usePlayerStore();
// 从路由参数获取bvid
const bvid = computed(() => route.params.bvid as string);
const isLoading = ref(true); // 初始加载状态
const partLoading = ref(false); // 分P加载状态仅影响分P选择
const errorMessage = ref('');
const videoDetail = ref<IBilibiliVideoDetail | null>(null);
const currentPage = ref<IBilibiliPage | null>(null);
const audioList = ref<SongResult[]>([]);
// 只在初始加载时应用动画
const initialLoadDone = ref(false);
const mainContentAnimation = computed(() => {
if (!initialLoadDone.value) {
return setAnimationClass('animate__fadeInDown');
}
return '';
});
const partsListAnimation = computed(() => {
if (!initialLoadDone.value) {
return setAnimationClass('animate__fadeInUp');
}
return '';
});
// 监听bvid变化
watch(
() => bvid.value,
async (newBvid) => {
if (newBvid) {
// 新的视频ID重置初始加载状态
initialLoadDone.value = false;
await loadVideoDetail(newBvid);
}
}
);
// 组件挂载时加载数据
onMounted(async () => {
if (bvid.value) {
await loadVideoDetail(bvid.value);
} else {
message.error('视频ID无效');
router.back();
}
});
const loadVideoDetail = async (bvid: string) => {
if (!bvid) return;
isLoading.value = true;
errorMessage.value = '';
audioList.value = [];
try {
console.log('加载B站视频详情:', bvid);
const res = await getBilibiliVideoDetail(bvid);
console.log('B站视频详情数据:', res.data);
// 确保响应式数据更新
videoDetail.value = JSON.parse(JSON.stringify(res.data));
// 默认加载第一个分P
if (videoDetail.value?.pages && videoDetail.value.pages.length > 0) {
console.log('视频有多个分P共', videoDetail.value.pages.length, '个');
const [firstPage] = videoDetail.value.pages;
currentPage.value = firstPage;
await loadVideoSource();
} else {
console.log('视频无分P或分P数据为空');
errorMessage.value = '无法加载视频分P信息';
}
} catch (error) {
console.error('获取视频详情失败', error);
errorMessage.value = '获取视频详情失败';
} finally {
isLoading.value = false;
// 标记初始加载完成
initialLoadDone.value = true;
}
};
const loadVideoSource = async () => {
if (!bvid.value || !currentPage.value?.cid) {
console.error('缺少必要参数:', { bvid: bvid.value, cid: currentPage.value?.cid });
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
console.log('加载音频源:', bvid.value, currentPage.value.cid);
// 将当前视频转换为音频格式加入播放列表
const tempAudio = createSongFromBilibiliVideo(); // 创建一个临时对象还没有URL
// 加载当前分P的音频URL
const currentAudio = await loadSongUrl(currentPage.value, tempAudio);
// 将所有分P添加到播放列表
if (videoDetail.value?.pages) {
audioList.value = videoDetail.value.pages.map((page, index) => {
// 第一个分P直接使用已获取的音频URL
if (index === 0 && currentPage.value?.cid === page.cid) {
return currentAudio;
}
// 其他分P创建占位对象稍后按需加载
return {
id: `${videoDetail.value!.aid}--${page.cid}`, // 使用aid+cid作为唯一ID
name: `${page.part || ''} - ${videoDetail.value!.title}`,
picUrl: getBilibiliProxyUrl(videoDetail.value!.pic),
type: 0,
canDislike: false,
alg: '',
source: 'bilibili', // 设置来源为B站
song: {
name: `${page.part || ''} - ${videoDetail.value!.title}`,
id: `${videoDetail.value!.aid}--${page.cid}`,
ar: [
{
name: videoDetail.value!.owner.name,
id: videoDetail.value!.owner.mid
}
],
al: {
picUrl: getBilibiliProxyUrl(videoDetail.value!.pic)
}
} as any,
bilibiliData: {
bvid: bvid.value,
cid: page.cid
}
} as SongResult;
});
console.log('已生成音频列表,共', audioList.value.length, '首');
// 预加载下一集
if (audioList.value.length > 1) {
const nextIndex = 1; // 默认加载第二个分P
const nextPage = videoDetail.value.pages[nextIndex];
const nextAudio = audioList.value[nextIndex];
loadSongUrl(nextPage, nextAudio).catch((e) => console.warn('预加载下一个分P失败:', e));
}
}
} catch (error) {
console.error('获取音频播放地址失败', error);
errorMessage.value = '获取音频播放地址失败';
} finally {
isLoading.value = false;
}
};
const createSongFromBilibiliVideo = (): SongResult => {
if (!videoDetail.value || !currentPage.value) {
throw new Error('视频详情未加载');
}
const pageName = currentPage.value.part || '';
const title = `${pageName} - ${videoDetail.value.title}`;
return {
id: `${videoDetail.value.aid}--${currentPage.value.cid}`, // 使用aid+cid作为唯一ID
name: title,
picUrl: getBilibiliProxyUrl(videoDetail.value.pic),
type: 0,
canDislike: false,
alg: '',
// 设置来源为B站
source: 'bilibili',
// playMusicUrl属性稍后通过loadSongUrl函数添加
song: {
name: title,
id: `${videoDetail.value.aid}--${currentPage.value.cid}`,
ar: [
{
name: videoDetail.value.owner.name,
id: videoDetail.value.owner.mid
}
],
al: {
picUrl: getBilibiliProxyUrl(videoDetail.value.pic)
}
} as any,
bilibiliData: {
bvid: bvid.value,
cid: currentPage.value.cid
}
} as SongResult;
};
const loadSongUrl = async (
page: IBilibiliPage,
songItem: SongResult,
forceRefresh: boolean = false
) => {
if (songItem.playMusicUrl && !forceRefresh) return songItem; // 如果已有URL且不强制刷新则直接返回
try {
console.log(`加载分P音频URL: ${page.part}, cid: ${page.cid}`);
const res = await getBilibiliPlayUrl(bvid.value, page.cid);
const playUrlData = res.data;
let url = '';
// 尝试获取音频URL
if (playUrlData.dash && playUrlData.dash.audio && playUrlData.dash.audio.length > 0) {
url = playUrlData.dash.audio[0].baseUrl;
console.log('获取到dash音频URL:', url);
} else if (playUrlData.durl && playUrlData.durl.length > 0) {
url = playUrlData.durl[0].url;
console.log('获取到durl音频URL:', url);
} else {
throw new Error('未找到可用的音频地址');
}
// 设置代理URL
songItem.playMusicUrl = getBilibiliProxyUrl(url);
return songItem;
} catch (error) {
console.error(`加载分P音频URL失败: ${page.part}`, error);
return songItem;
}
};
const switchPage = async (page: IBilibiliPage) => {
if (partLoading.value || currentPage.value?.cid === page.cid) return;
console.log('切换到分P:', page.part);
// 立即更新UI选中状态
currentPage.value = page;
// 查找对应的音频项
const audioItem = audioList.value.find((item) => item.bilibiliData?.cid === page.cid);
if (audioItem) {
// 设置局部加载状态
try {
partLoading.value = true;
// 每次切换分P都强制重新加载音频URL以解决之前的URL可能失效的问题
await loadSongUrl(page, audioItem, true);
// 切换后自动播放
playCurrentAudio();
} catch (error) {
console.error('切换分P时加载音频URL失败:', error);
message.error('获取音频地址失败,请重试');
} finally {
partLoading.value = false;
}
} else {
console.error('未找到对应的音频项');
message.error('未找到对应的音频,请重试');
}
};
const playCurrentAudio = async () => {
if (audioList.value.length === 0) {
console.error('音频列表为空');
errorMessage.value = '音频列表为空,请重试';
return;
}
// 获取当前分P的音频
const currentIndex = audioList.value.findIndex(
(item) => item.bilibiliData?.cid === currentPage.value?.cid
);
if (currentIndex === -1) {
console.error('未找到当前分P的音频');
errorMessage.value = '未找到当前分P的音频';
return;
}
const currentAudio = audioList.value[currentIndex];
console.log('准备播放当前选中的分P:', currentAudio.name);
try {
// 每次播放前都强制重新加载当前分P的音频URL解决可能的URL失效问题
partLoading.value = true;
await loadSongUrl(currentPage.value!, currentAudio, true);
if (!currentAudio.playMusicUrl) {
throw new Error('获取音频URL失败');
}
// 预加载下一个分P的音频URL如果有
const nextIndex = (currentIndex + 1) % audioList.value.length;
if (nextIndex !== currentIndex) {
const nextAudio = audioList.value[nextIndex];
const nextPage = videoDetail.value!.pages.find((p) => p.cid === nextAudio.bilibiliData?.cid);
if (nextPage) {
console.log('预加载下一个分P:', nextPage.part);
loadSongUrl(nextPage, nextAudio).catch((e) => console.warn('预加载下一个分P失败:', e));
}
}
// 将B站音频列表设置为播放列表
playerStore.setPlayList(audioList.value);
// 播放当前选中的分P
console.log('播放当前选中的分P:', currentAudio.name, '音频URL:', currentAudio.playMusicUrl);
playerStore.setPlayMusic(currentAudio);
// 播放后通知用户已开始播放
message.success('已开始播放');
} catch (error) {
console.error('播放音频失败:', error);
errorMessage.value = error instanceof Error ? error.message : '播放失败,请重试';
} finally {
partLoading.value = false;
}
};
/**
* 格式化总时长
*/
const formatTotalDuration = (seconds?: number) => {
if (!seconds) return '00:00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
/**
* 格式化数字显示
*/
const formatNumber = (num?: number) => {
if (!num) return '0';
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}`;
}
return num.toString();
};
// 判断是否是当前正在播放的分P
const isCurrentPlayingPage = (page: IBilibiliPage) => {
// 只根据播放器状态判断不再使用UI选中状态
const currentPlayingMusic = playerStore.playMusic as any;
if (
currentPlayingMusic &&
typeof currentPlayingMusic === 'object' &&
currentPlayingMusic.bilibiliData
) {
// 比较当前播放的音频的cid与此分P的cid
return (
currentPlayingMusic.bilibiliData.cid === page.cid &&
currentPlayingMusic.bilibiliData.bvid === bvid.value
);
}
// 如果没有正在播放的音乐则使用UI选择状态
return currentPage.value?.cid === page.cid;
};
// 监听播放器状态变化保持分P列表选中状态同步
watch(
() => playerStore.playMusic,
(newMusic: any) => {
if (
newMusic &&
typeof newMusic === 'object' &&
newMusic.bilibiliData &&
newMusic.bilibiliData.bvid === bvid.value
) {
// 查找对应的分P
const playingPage = videoDetail.value?.pages?.find(
(p) => p.cid === newMusic.bilibiliData.cid
);
// 无条件更新UI状态以确保UI状态与播放状态一致
if (playingPage) {
currentPage.value = playingPage;
}
}
}
);
</script>
<style scoped lang="scss">
.bilibili-player-page {
@apply h-full flex flex-col;
.content-scrollbar {
@apply flex-1 overflow-hidden;
}
.content-wrapper {
@apply flex flex-col p-4;
}
}
.bilibili-info-wrapper {
@apply flex flex-col md:flex-row gap-4 w-full;
.bilibili-cover {
@apply relative w-full md:w-1/3 aspect-video rounded-lg overflow-hidden;
.cover-image {
@apply w-full h-full object-cover;
}
.play-overlay {
@apply absolute inset-0;
.play-icon-bg {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-white opacity-0 hover:opacity-100 transition-opacity cursor-pointer;
i {
@apply text-4xl;
}
}
.corner-play-button {
@apply absolute right-3 bottom-3 shadow-lg flex items-center gap-1 px-4 py-1 text-sm transition-all duration-200;
&:hover {
@apply transform scale-110;
}
i {
@apply text-xl;
}
}
}
}
}
.loading-wrapper,
.error-wrapper {
@apply w-full flex flex-col items-center justify-center py-16 rounded-lg bg-gray-100 dark:bg-gray-800;
aspect-ratio: 16/9;
p {
@apply mt-4 text-gray-600 dark:text-gray-400;
}
}
.error-wrapper {
button {
@apply mt-4;
}
}
.video-info {
@apply flex-1 p-4 rounded-lg bg-gray-100 dark:bg-gray-800;
.title {
@apply text-lg font-medium mb-4 text-gray-900 dark:text-white;
}
.author {
@apply flex items-center text-sm mb-2;
}
.stats {
@apply flex gap-4 text-xs text-gray-500 dark:text-gray-400 mb-3;
}
.description {
@apply text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap mb-3;
max-height: 100px;
overflow-y: auto;
}
.duration {
@apply text-sm text-gray-600 dark:text-gray-400;
}
}
.video-parts {
@apply mt-4;
.parts-title {
@apply text-sm font-medium mb-2 flex items-center;
}
.parts-list {
@apply flex flex-wrap gap-2 max-h-60 overflow-y-auto pb-4;
.part-item {
@apply text-xs mb-2;
}
}
}
</style>

View File

@@ -63,7 +63,7 @@
:class="setAnimationClass('animate__bounceInLeft')" :class="setAnimationClass('animate__bounceInLeft')"
:style="getItemAnimationDelay(index)" :style="getItemAnimationDelay(index)"
:selectable="isSelecting" :selectable="isSelecting"
:selected="selectedSongs.includes(song.id)" :selected="selectedSongs.includes(song.id as number)"
@play="handlePlay" @play="handlePlay"
@select="handleSelect" @select="handleSelect"
/> />
@@ -88,18 +88,18 @@ import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { getSongUrl } from '@/hooks/MusicListHook'; import { getSongUrl } from '@/hooks/MusicListHook';
import { usePlayerStore } from '@/store';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { isElectron, setAnimationClass, setAnimationDelay } from '@/utils'; import { isElectron, setAnimationClass, setAnimationDelay } from '@/utils';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const playerStore = usePlayerStore();
const message = useMessage(); const message = useMessage();
const favoriteList = computed(() => store.state.favoriteList); const favoriteList = computed(() => playerStore.favoriteList);
const favoriteSongs = ref<SongResult[]>([]); const favoriteSongs = ref<SongResult[]>([]);
const loading = ref(false); const loading = ref(false);
const noMore = ref(false); const noMore = ref(false);
@@ -277,7 +277,7 @@ const handleScroll = (e: any) => {
}; };
onMounted(async () => { onMounted(async () => {
await store.dispatch('initializeFavoriteList'); await playerStore.initializeFavoriteList();
await getFavoriteSongs(); await getFavoriteSongs();
}); });
@@ -293,7 +293,7 @@ watch(
); );
const handlePlay = () => { const handlePlay = () => {
store.commit('setPlayList', favoriteSongs.value); playerStore.setPlayList(favoriteSongs.value);
}; };
const getItemAnimationDelay = (index: number) => { const getItemAnimationDelay = (index: number) => {
@@ -319,7 +319,7 @@ const isIndeterminate = computed(() => {
// 处理全选/取消全选 // 处理全选/取消全选
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
selectedSongs.value = favoriteSongs.value.map((song) => song.id); selectedSongs.value = favoriteSongs.value.map((song) => song.id as number);
} else { } else {
selectedSongs.value = []; selectedSongs.value = [];
} }

View File

@@ -34,11 +34,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { useMusicHistory } from '@/hooks/MusicHistoryHook'; import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils'; import { setAnimationClass, setAnimationDelay } from '@/utils';
@@ -47,12 +47,12 @@ defineOptions({
}); });
const { t } = useI18n(); const { t } = useI18n();
const store = useStore();
const { delMusic, musicList } = useMusicHistory(); const { delMusic, musicList } = useMusicHistory();
const scrollbarRef = ref(); const scrollbarRef = ref();
const loading = ref(false); const loading = ref(false);
const noMore = ref(false); const noMore = ref(false);
const displayList = ref<SongResult[]>([]); const displayList = ref<SongResult[]>([]);
const playerStore = usePlayerStore();
// 无限滚动相关配置 // 无限滚动相关配置
const pageSize = 20; const pageSize = 20;
@@ -71,7 +71,7 @@ const getHistorySongs = async () => {
const endIndex = startIndex + pageSize; const endIndex = startIndex + pageSize;
const currentPageItems = musicList.value.slice(startIndex, endIndex); const currentPageItems = musicList.value.slice(startIndex, endIndex);
const currentIds = currentPageItems.map((item) => item.id); const currentIds = currentPageItems.map((item) => item.id as number);
const res = await getMusicDetail(currentIds); const res = await getMusicDetail(currentIds);
if (res.data.songs) { if (res.data.songs) {
@@ -112,7 +112,7 @@ const handleScroll = (e: any) => {
// 播放全部 // 播放全部
const handlePlay = () => { const handlePlay = () => {
store.commit('setPlayList', displayList.value); playerStore.setPlayList(displayList.value);
}; };
onMounted(() => { onMounted(() => {

View File

@@ -2,7 +2,7 @@
<n-scrollbar :size="100" :x-scrollable="false"> <n-scrollbar :size="100" :x-scrollable="false">
<div class="main-page"> <div class="main-page">
<!-- 推荐歌手 --> <!-- 推荐歌手 -->
<recommend-singer /> <top-banner />
<div class="main-content"> <div class="main-content">
<!-- 歌单分类列表 --> <!-- 歌单分类列表 -->
<playlist-type v-if="!isMobile" /> <playlist-type v-if="!isMobile" />
@@ -19,10 +19,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import PlaylistType from '@/components/PlaylistType.vue'; import PlaylistType from '@/components/home/PlaylistType.vue';
import RecommendAlbum from '@/components/RecommendAlbum.vue'; import RecommendAlbum from '@/components/home/RecommendAlbum.vue';
import RecommendSinger from '@/components/RecommendSinger.vue'; import RecommendSonglist from '@/components/home/RecommendSonglist.vue';
import RecommendSonglist from '@/components/RecommendSonglist.vue'; import TopBanner from '@/components/home/TopBanner.vue';
import { isMobile } from '@/utils'; import { isMobile } from '@/utils';
import FavoriteList from '@/views/favorite/index.vue'; import FavoriteList from '@/views/favorite/index.vue';

View File

@@ -36,7 +36,7 @@
<div class="recommend-item-img"> <div class="recommend-item-img">
<n-image <n-image
class="recommend-item-img-img" class="recommend-item-img-img"
:src="getImgUrl(item.picUrl || item.coverImgUrl, '200y200')" :src="getImgUrl(item.picUrl || item.coverImgUrl, '300y300')"
width="200" width="200"
height="200" height="200"
lazy lazy

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