mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-08 01:20:49 +08:00
Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2cdd1d8d7 | ||
|
|
c90cfbf3cd | ||
|
|
2c5bfac439 | ||
|
|
1865bd95bc | ||
|
|
fd37015466 | ||
|
|
7df1c25168 | ||
|
|
ed9cf9c4c5 | ||
|
|
35b9cbfdbd | ||
|
|
df6da2eb9e | ||
|
|
2d966036bb | ||
|
|
499857a679 | ||
|
|
7624a1a71e | ||
|
|
05b85c4b7b | ||
|
|
27d5bd8f81 | ||
|
|
c5da42b67d | ||
|
|
5e484334de | ||
|
|
25b90fafdc | ||
|
|
a676136f48 | ||
|
|
76e55d4e6b | ||
|
|
b7de5fc173 | ||
|
|
7bc8405df0 | ||
|
|
a7f2045c7b | ||
|
|
b9b52f4d9f | ||
|
|
09f8837fe4 | ||
|
|
d7fea7f888 | ||
|
|
bb7d1e332f | ||
|
|
2dc907a20f | ||
|
|
ad3e52f6e1 | ||
|
|
b593ca3011 | ||
|
|
988418e8d1 | ||
|
|
1922311238 | ||
|
|
3b1488f147 | ||
|
|
5f4b53c167 | ||
|
|
e17941dfb0 | ||
|
|
9aff694116 | ||
|
|
c2983ba079 | ||
|
|
541ff2b76c | ||
|
|
55b50d764b | ||
|
|
30ff7b2930 | ||
|
|
a24f901d1d | ||
|
|
7f7d41f883 | ||
|
|
7b27cf5bc6 | ||
|
|
ad8f7af3a9 | ||
|
|
2599766e3e | ||
|
|
0f55795ca9 | ||
|
|
8d6d0527db | ||
|
|
374a7a837d | ||
|
|
e0d13057c3 | ||
|
|
23b2340169 | ||
|
|
7e826311fe | ||
|
|
cfe197c805 | ||
|
|
230132904e | ||
|
|
fb44ae45cc | ||
|
|
9ce872eebe | ||
|
|
ee6e9d43fd | ||
|
|
1a440fad09 | ||
|
|
477f8bb99b | ||
|
|
56c3ca1cce | ||
|
|
a5f694ea72 | ||
|
|
280fec1990 | ||
|
|
c5e50c9fd5 | ||
|
|
01ccad4df7 | ||
|
|
dfb8f55fba | ||
|
|
2924ad6c18 | ||
|
|
9f5bac29a0 | ||
|
|
2fe1f0c04c | ||
|
|
2a12f57cb2 | ||
|
|
4c10533a3d | ||
|
|
cda440b01a | ||
|
|
7b9e23743b | ||
|
|
e43270f35d | ||
|
|
9431faf932 | ||
|
|
be03b5f8fc | ||
|
|
8a414d0c25 | ||
|
|
f9fd9afcdd | ||
|
|
b114cf4a33 | ||
|
|
fa39d4ca55 | ||
|
|
650e4ff786 | ||
|
|
e355341596 | ||
|
|
4fa5ed0ca6 | ||
|
|
df9a1370c3 | ||
|
|
6a8813531f | ||
|
|
e5e45148c3 | ||
|
|
4a66796747 | ||
|
|
7f8ab8be7c | ||
|
|
ce276df55c | ||
|
|
ccc59ea893 | ||
|
|
0b409f38d6 | ||
|
|
f9878ed88a | ||
|
|
e43e85480d | ||
|
|
b97170d1b2 | ||
|
|
b9aa1d574a | ||
|
|
dd7b06d7e5 | ||
|
|
ddafcfba10 | ||
|
|
da5b8c408a | ||
|
|
fb35d42fc4 | ||
|
|
dfd5d4c8b7 | ||
|
|
e5309cedee | ||
|
|
d335f57a1a | ||
|
|
c703d9c197 | ||
|
|
87a0ceb5b0 | ||
|
|
891d70f3ed | ||
|
|
37b5908ddc | ||
|
|
ead017e4b1 | ||
|
|
da2a32e420 | ||
|
|
5fc6edba20 | ||
|
|
1ef47b8f1d | ||
|
|
cdaab19afa | ||
|
|
6a0b03cfe1 | ||
|
|
d449930a02 | ||
|
|
820509dbea | ||
|
|
1493ab7317 | ||
|
|
c6ca63ee11 | ||
|
|
4fa1295b84 | ||
|
|
174428b386 | ||
|
|
599b0251af | ||
|
|
25c2180247 | ||
|
|
a6ff0e7f5c | ||
|
|
2e06711600 | ||
|
|
80770d6c75 | ||
|
|
1e068df2ad | ||
|
|
4172ff9fc6 | ||
|
|
83a7df9fe8 | ||
|
|
ba95dc11fe | ||
|
|
93829acdab | ||
|
|
1f0f35dd51 | ||
|
|
be94d6ff8e | ||
|
|
1bdb8fcb4a | ||
|
|
914e043502 | ||
|
|
dfa175b8b2 | ||
|
|
a94e0efba5 | ||
|
|
fb0831f2eb | ||
|
|
573023600a | ||
|
|
041aad31b4 | ||
|
|
f652932d71 |
92
.cursor/rules/music-vue-rule.mdc
Normal file
92
.cursor/rules/music-vue-rule.mdc
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
description: 这个规则是项目描述
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
您是 TypeScript、Node.js、Vue3、Electron、naive-ui、VueUse 和 Tailwind 方面的专家。
|
||||
|
||||
项目结构
|
||||
- 这是 Electron 项目,使用 Vue3 和 Pinia 进行开发的第三方网易云音乐播放器。
|
||||
- 使用 Vue3 和 Pinia 进行开发。
|
||||
- 使用 Pinia 进行状态管理。
|
||||
- 使用 VueUse 进行状态管理。
|
||||
- 使用 naive-ui 进行 UI 设计。
|
||||
- 使用 Tailwind 进行样式设计。
|
||||
- 使用 remixicon 进行图标设计。
|
||||
- 使用 vite 进行项目构建。
|
||||
- 使用 electron-builder 进行项目打包。
|
||||
- 使用 electron-vite 进行项目开发。
|
||||
- 使用 netease-cloud-music-api 进行网易云音乐接口调用。
|
||||
- 使用 electron-store 进行本地数据存储。
|
||||
- 使用 axios 进行网络请求。
|
||||
- 使用 @unblockneteasemusic/server 进行网易云音乐解锁。
|
||||
- 使用 vue-i18n 进行国际化。目录为 src/i18n
|
||||
|
||||
代码风格和结构
|
||||
- 编写简洁、技术性的 TypeScript 代码,并提供准确示例。
|
||||
- 使用组合 API 和声明性编程模式;避免使用选项 API。
|
||||
- 优先使用迭代和模块化,而不是代码重复。
|
||||
- 使用带有助动词的描述性变量名称(例如 isLoading、hasError)。
|
||||
- 结构文件:导出的组件、可组合项、帮助程序、静态内容、类型。
|
||||
|
||||
命名约定
|
||||
- 使用带破折号的小写字母表示目录(例如 components/auth-wizard)。
|
||||
- 使用 PascalCase 表示组件名称(例如 AuthWizard.vue)。
|
||||
- 使用 camelCase 表示可组合项(例如 useAuthState.ts)。
|
||||
|
||||
TypeScript 用法
|
||||
- 对所有代码使用 TypeScript;优先使用类型而不是接口。
|
||||
- 避免使用枚举;改用 const 对象。
|
||||
- 将 Vue 3 与 TypeScript 结合使用,利用 defineComponent 和 PropType。
|
||||
|
||||
语法和格式
|
||||
- 对方法和计算属性使用箭头函数。
|
||||
- 避免在条件中使用不必要的花括号;对简单语句使用简洁的语法。
|
||||
- 使用模板语法进行声明式渲染。
|
||||
|
||||
UI 和样式
|
||||
- 使用 naive-ui 和 Tailwind 进行组件和样式设计。
|
||||
- 使用 Tailwind CSS 实现响应式设计;采用移动优先方法。
|
||||
|
||||
图标
|
||||
- 使用 remixicon 作为图标库。
|
||||
|
||||
性能优化
|
||||
- 对异步组件使用 Suspense。
|
||||
- 为路由和组件实现延迟加载。
|
||||
|
||||
关键约定
|
||||
- 对常见可组合项和实用函数使用 VueUse。
|
||||
- 使用 Pinia 进行状态管理。
|
||||
- 优化 Web Vitals(LCP、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
148
.cursor/rules/project.mdc
Normal 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 实现响应式设计
|
||||
@@ -4,8 +4,7 @@ require('@rushstack/eslint-patch/modern-module-resolution');
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'eslint-config-airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'@vue/typescript/recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vue-scoped-css/base',
|
||||
@@ -38,6 +37,7 @@ module.exports = {
|
||||
rules: {
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
'no-nested-ternary': 'off',
|
||||
'no-console': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
|
||||
51
.github/workflows/deploy.yml
vendored
Normal file
51
.github/workflows/deploy.yml
vendored
Normal 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)"
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,9 +16,14 @@ dist.zip
|
||||
.vscode
|
||||
|
||||
bun.lockb
|
||||
bun.lock
|
||||
|
||||
.env.*.local
|
||||
|
||||
out
|
||||
|
||||
.cursorrules
|
||||
.cursorrules
|
||||
|
||||
.github/deploy_keys
|
||||
|
||||
resources/android/**/*
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,12 +1,23 @@
|
||||
# 更新日志
|
||||
|
||||
## v3.7.1
|
||||
### 🐞 Bug修复
|
||||
- 修复单曲循环后点击歌词快进会有重复的声音播放
|
||||
- 修复最小化点击后恢复窗口按空格会继续最小化
|
||||
## v4.4.0
|
||||
## 更新时间 2025 年 4 月 23 日 00:16
|
||||
> 如果更新遇到问题,请前往 <a href="http://donate.alger.fun/download" target="_blank">下载 AlgerMusicPlayer</a>
|
||||
|
||||
> 帮我点个 star <a href="https://github.com/algerkong/AlgerMusicPlayer" target="_blank">github star</a>
|
||||
|
||||
## 咖啡☕️
|
||||
| 微信 | | 支付宝 |
|
||||
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
|
||||
| <img src="https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/AlgerMusicPlayer/dev_electron/src/renderer/assets/wechat.png" alt="WeChat QRcode" width=200>| | <img src="https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/AlgerMusicPlayer/dev_electron/src/renderer/assets/alipay.png" alt="Wechat QRcode" width=200> |
|
||||
> 请我喝咖啡 ☕️ <a href="http://donate.alger.fun/donate" target="_blank">赏你</a>
|
||||
|
||||
> QQ群 976962720
|
||||
|
||||
### ✨ 新功能
|
||||
- 优化音源解析功能,添加音源配置,添加GD音乐台解析支持 ([ed9cf9c](https://github.com/algerkong/AlgerMusicPlayer/commit/ed9cf9c))
|
||||
|
||||
### 🐛 Bug 修复
|
||||
- 修复下载管理切换 tab 程序卡死问题 ([27d5bd8](https://github.com/algerkong/AlgerMusicPlayer/commit/27d5bd8)) (#153)
|
||||
- 修复歌曲播放地址缓存导致播放失败问题,添加过期时间 ([76e55d4](https://github.com/algerkong/AlgerMusicPlayer/commit/76e55d4))
|
||||
- 修复 Electron 版本更新导致的桌面歌词窗口出现边框的问题 ([a676136](https://github.com/algerkong/AlgerMusicPlayer/commit/a676136))
|
||||
- 优化底部菜单位置和间距 修复移动端菜单不显示问题 ([25b90fa](https://github.com/algerkong/AlgerMusicPlayer/commit/25b90fa))
|
||||
|
||||
### 🔧 其他变更
|
||||
- 更新项目依赖 ([35b9cbf](https://github.com/algerkong/AlgerMusicPlayer/commit/35b9cbf))([7624a1a](https://github.com/algerkong/AlgerMusicPlayer/commit/7624a1a))
|
||||
11
README.md
11
README.md
@@ -45,15 +45,16 @@ QQ群:789288579
|
||||
- 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
|
||||
## 项目统计
|
||||
[](https://starchart.cc/algerkong/AlgerMusicPlayer)
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
101
android/.gitignore
vendored
Normal file
101
android/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
@@ -5,6 +5,7 @@ import AutoImport from 'unplugin-auto-import/vite';
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
import VueDevTools from 'vite-plugin-vue-devtools';
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
@@ -23,7 +24,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
viteCompression(),
|
||||
// VueDevTools(),
|
||||
VueDevTools(),
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
|
||||
76
package.json
76
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "AlgerMusicPlayer",
|
||||
"version": "3.7.1",
|
||||
"version": "4.4.0",
|
||||
"description": "Alger Music Player",
|
||||
"author": "Alger <algerkc@qq.com>",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -21,65 +21,71 @@
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@unblockneteasemusic/server": "^0.27.8-patch.1",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^6.1.7",
|
||||
"netease-cloud-music-api-alger": "^4.25.0"
|
||||
"electron-updater": "^6.6.2",
|
||||
"font-list": "^1.5.1",
|
||||
"netease-cloud-music-api-alger": "^4.26.1",
|
||||
"node-id3": "^0.2.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"vue-i18n": "^11.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^1.0.2",
|
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config": "^2.1.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@tailwindcss/postcss7-compat": "^2.2.4",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^20.14.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.1",
|
||||
"@typescript-eslint/parser": "^8.30.1",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@vue/compiler-sfc": "^3.5.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"@vue/runtime-core": "^3.5.0",
|
||||
"@vueuse/core": "^11.0.3",
|
||||
"@vueuse/electron": "^11.0.3",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"@vueuse/electron": "^11.3.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.7",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^31.0.2",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"electron": "^35.2.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^3.1.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"eslint-plugin-vue": "^9.26.0",
|
||||
"eslint-plugin-vue-scoped-css": "^2.7.2",
|
||||
"eslint-plugin-vue": "^10.0.0",
|
||||
"eslint-plugin-vue-scoped-css": "^2.9.0",
|
||||
"howler": "^2.2.4",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^15.0.4",
|
||||
"naive-ui": "^2.41.0",
|
||||
"postcss": "^8.4.49",
|
||||
"pinia": "^3.0.1",
|
||||
"pinyin-match": "^1.2.6",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.3.2",
|
||||
"remixicon": "^4.2.0",
|
||||
"sass": "^1.82.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"remixicon": "^4.6.0",
|
||||
"sass": "^1.86.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tunajs": "^1.0.15",
|
||||
"typescript": "^5.5.2",
|
||||
"unplugin-auto-import": "^0.18.2",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vfonts": "^0.1.0",
|
||||
"vite": "^5.3.1",
|
||||
"unplugin-auto-import": "^19.1.1",
|
||||
"unplugin-vue-components": "^28.4.1",
|
||||
"vite": "^6.2.2",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "7.4.0",
|
||||
"vue": "^3.4.30",
|
||||
"vue-router": "^4.4.3",
|
||||
"vue-tsc": "^2.0.22",
|
||||
"vuex": "^4.1.0",
|
||||
"animate.css": "^4.1.1"
|
||||
"vite-plugin-vue-devtools": "7.7.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-tsc": "^2.0.22"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.alger.music",
|
||||
|
||||
BIN
resources/icons/next.png
Normal file
BIN
resources/icons/next.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
BIN
resources/icons/pause.png
Normal file
BIN
resources/icons/pause.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 B |
BIN
resources/icons/play.png
Normal file
BIN
resources/icons/play.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 251 B |
BIN
resources/icons/prev.png
Normal file
BIN
resources/icons/prev.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 289 B |
5
src/i18n/lang/en-US/artist.ts
Normal file
5
src/i18n/lang/en-US/artist.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
hotSongs: 'Hot Songs',
|
||||
albums: 'Albums',
|
||||
description: 'Artist Introduction'
|
||||
};
|
||||
52
src/i18n/lang/en-US/common.ts
Normal file
52
src/i18n/lang/en-US/common.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export default {
|
||||
play: 'Play',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
volume: 'Volume',
|
||||
settings: 'Settings',
|
||||
search: 'Search',
|
||||
loading: 'Loading...',
|
||||
loadingMore: 'Loading more...',
|
||||
alipay: 'Alipay',
|
||||
wechat: 'WeChat Pay',
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
show: 'Show',
|
||||
hide: 'Hide',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
configure: 'Configure',
|
||||
open: 'Open',
|
||||
modify: 'Modify',
|
||||
success: 'Operation Successful',
|
||||
error: 'Operation Failed',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
refresh: 'Refresh',
|
||||
retry: 'Retry',
|
||||
validation: {
|
||||
required: 'This field is required',
|
||||
invalidInput: 'Invalid input',
|
||||
selectRequired: 'Please select an option',
|
||||
numberRange: 'Please enter a number between {min} and {max}',
|
||||
ipAddress: 'Please enter a valid IP address',
|
||||
portNumber: 'Please enter a valid port number (1-65535)'
|
||||
},
|
||||
viewMore: 'View More',
|
||||
noMore: 'No more',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
songCount: '{count} songs',
|
||||
tray: {
|
||||
show: 'Show',
|
||||
quit: 'Quit',
|
||||
playPause: 'Play/Pause',
|
||||
prev: 'Previous',
|
||||
next: 'Next',
|
||||
pause: 'Pause',
|
||||
play: 'Play'
|
||||
},
|
||||
language: 'Language'
|
||||
};
|
||||
109
src/i18n/lang/en-US/comp.ts
Normal file
109
src/i18n/lang/en-US/comp.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export default {
|
||||
installApp: {
|
||||
description: 'Install the application on the desktop for a better experience',
|
||||
noPrompt: 'Do not prompt again',
|
||||
install: 'Install now',
|
||||
cancel: 'Cancel',
|
||||
download: 'Download',
|
||||
downloadFailed: 'Download failed',
|
||||
downloadComplete: 'Download complete',
|
||||
downloadProblem: 'Download problem? Go to',
|
||||
downloadProblemLinkText: 'Download the latest version'
|
||||
},
|
||||
playlistDrawer: {
|
||||
title: 'Add to playlist',
|
||||
createPlaylist: 'Create new playlist',
|
||||
cancelCreate: 'Cancel create',
|
||||
create: 'Create',
|
||||
playlistName: 'Playlist name',
|
||||
privatePlaylist: 'Private playlist',
|
||||
publicPlaylist: 'Public playlist',
|
||||
createSuccess: 'Playlist created successfully',
|
||||
createFailed: 'Playlist creation failed',
|
||||
addSuccess: 'Song added successfully',
|
||||
addFailed: 'Song addition failed',
|
||||
private: 'Private',
|
||||
public: 'Public',
|
||||
count: 'songs',
|
||||
loginFirst: 'Please login first',
|
||||
getPlaylistFailed: 'Get playlist failed',
|
||||
inputPlaylistName: 'Please enter the playlist name'
|
||||
},
|
||||
update: {
|
||||
title: 'New version found',
|
||||
currentVersion: 'Current version',
|
||||
cancel: 'Do not update',
|
||||
prepareDownload: 'Preparing to download...',
|
||||
downloading: 'Downloading...',
|
||||
nowUpdate: 'Update now',
|
||||
downloadFailed: 'Download failed, please try again or download manually',
|
||||
startFailed: 'Start download failed, please try again or download manually',
|
||||
noDownloadUrl:
|
||||
'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: {
|
||||
title: 'Buy me a coffee',
|
||||
alipay: 'Alipay',
|
||||
wechat: 'Wechat',
|
||||
alipayQR: 'Alipay QR code',
|
||||
wechatQR: 'Wechat QR code',
|
||||
coffeeDesc: 'A cup of coffee, a support',
|
||||
coffeeDescLinkText: 'View more',
|
||||
qqGroup: 'QQ group: 789288579',
|
||||
messages: {
|
||||
copySuccess: 'Copied to clipboard'
|
||||
},
|
||||
donateList: 'Buy me a coffee'
|
||||
},
|
||||
playlistType: {
|
||||
title: 'Playlist Category',
|
||||
showAll: 'Show all',
|
||||
hide: 'Hide some'
|
||||
},
|
||||
recommendAlbum: {
|
||||
title: 'Latest Album'
|
||||
},
|
||||
recommendSinger: {
|
||||
title: 'Daily Recommendation',
|
||||
songlist: 'Daily Recommendation List'
|
||||
},
|
||||
recommendSonglist: {
|
||||
title: 'Weekly Hot Music'
|
||||
},
|
||||
searchBar: {
|
||||
login: 'Login',
|
||||
toLogin: 'To Login',
|
||||
logout: 'Logout',
|
||||
set: 'Set',
|
||||
theme: 'Theme',
|
||||
restart: 'Restart',
|
||||
refresh: 'Refresh',
|
||||
currentVersion: 'Current Version',
|
||||
searchPlaceholder: 'Search for something...'
|
||||
},
|
||||
titleBar: {
|
||||
closeTitle: 'Choose how to close',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
exitApp: 'Exit App',
|
||||
rememberChoice: 'Remember my choice',
|
||||
closeApp: 'Close App'
|
||||
},
|
||||
userPlayList: {
|
||||
title: "{name}'s Playlist"
|
||||
},
|
||||
musicList: {
|
||||
searchSongs: 'Search Songs',
|
||||
noSearchResults: 'No search results'
|
||||
}
|
||||
};
|
||||
7
src/i18n/lang/en-US/donation.ts
Normal file
7
src/i18n/lang/en-US/donation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
description:
|
||||
'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.',
|
||||
refresh: 'Refresh List',
|
||||
toDonateList: 'Buy me a coffee'
|
||||
};
|
||||
49
src/i18n/lang/en-US/download.ts
Normal file
49
src/i18n/lang/en-US/download.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export default {
|
||||
title: 'Download Manager',
|
||||
localMusic: 'Local Music',
|
||||
count: '{count} songs in total',
|
||||
clearAll: 'Clear All',
|
||||
tabs: {
|
||||
downloading: 'Downloading',
|
||||
downloaded: 'Downloaded'
|
||||
},
|
||||
empty: {
|
||||
noTasks: 'No download tasks',
|
||||
noDownloaded: 'No downloaded songs'
|
||||
},
|
||||
progress: {
|
||||
total: 'Total Progress: {progress}%'
|
||||
},
|
||||
status: {
|
||||
downloading: 'Downloading',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
unknown: 'Unknown'
|
||||
},
|
||||
artist: {
|
||||
unknown: 'Unknown Artist'
|
||||
},
|
||||
delete: {
|
||||
title: 'Delete Confirmation',
|
||||
message: 'Are you sure you want to delete "{filename}"? This action cannot be undone.',
|
||||
confirm: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
success: 'Successfully deleted',
|
||||
failed: 'Failed to delete',
|
||||
fileNotFound: 'File not found or moved, removed from records',
|
||||
recordRemoved: 'Failed to delete file, but removed from records'
|
||||
},
|
||||
clear: {
|
||||
title: 'Clear Download Records',
|
||||
message:
|
||||
'Are you sure you want to clear all download records? This will not delete the actual music files, but will clear all records.',
|
||||
confirm: 'Clear',
|
||||
cancel: 'Cancel',
|
||||
success: 'Download records cleared'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} download completed',
|
||||
downloadFailed: '{filename} download failed: {error}'
|
||||
},
|
||||
loading: 'Loading...'
|
||||
};
|
||||
15
src/i18n/lang/en-US/favorite.ts
Normal file
15
src/i18n/lang/en-US/favorite.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
title: 'Favorites',
|
||||
count: 'Total {count}',
|
||||
batchDownload: 'Batch Download',
|
||||
selectAll: 'All',
|
||||
download: 'Download ({count})',
|
||||
cancel: 'Cancel',
|
||||
emptyTip: 'No favorite songs yet',
|
||||
viewMore: 'View More',
|
||||
noMore: 'No more',
|
||||
downloadSuccess: 'Download completed',
|
||||
downloadFailed: 'Download failed',
|
||||
downloading: 'Downloading, please wait...',
|
||||
selectSongsFirst: 'Please select songs to download first'
|
||||
};
|
||||
5
src/i18n/lang/en-US/history.ts
Normal file
5
src/i18n/lang/en-US/history.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
title: 'Play History',
|
||||
playCount: '{count}',
|
||||
getHistoryFailed: 'Failed to get play history'
|
||||
};
|
||||
29
src/i18n/lang/en-US/index.ts
Normal file
29
src/i18n/lang/en-US/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import artist from './artist';
|
||||
import common from './common';
|
||||
import comp from './comp';
|
||||
import donation from './donation';
|
||||
import download from './download';
|
||||
import favorite from './favorite';
|
||||
import history from './history';
|
||||
import login from './login';
|
||||
import player from './player';
|
||||
import search from './search';
|
||||
import settings from './settings';
|
||||
import songItem from './songItem';
|
||||
import user from './user';
|
||||
|
||||
export default {
|
||||
common,
|
||||
donation,
|
||||
favorite,
|
||||
history,
|
||||
login,
|
||||
player,
|
||||
search,
|
||||
settings,
|
||||
songItem,
|
||||
user,
|
||||
download,
|
||||
comp,
|
||||
artist
|
||||
};
|
||||
22
src/i18n/lang/en-US/login.ts
Normal file
22
src/i18n/lang/en-US/login.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
title: {
|
||||
qr: 'QR Code Login',
|
||||
phone: 'Phone Login'
|
||||
},
|
||||
qrTip: 'Scan with NetEase Cloud Music APP',
|
||||
phoneTip: 'Login with NetEase Cloud account',
|
||||
placeholder: {
|
||||
phone: 'Phone Number',
|
||||
password: 'Password'
|
||||
},
|
||||
button: {
|
||||
login: 'Login',
|
||||
switchToQr: 'QR Code Login',
|
||||
switchToPhone: 'Phone Login'
|
||||
},
|
||||
message: {
|
||||
loginSuccess: 'Login successful',
|
||||
loadError: 'Error loading login information',
|
||||
qrCheckError: 'Error checking QR code status'
|
||||
}
|
||||
};
|
||||
77
src/i18n/lang/en-US/player.ts
Normal file
77
src/i18n/lang/en-US/player.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export default {
|
||||
nowPlaying: 'Now Playing',
|
||||
playlist: 'Playlist',
|
||||
lyrics: 'Lyrics',
|
||||
previous: 'Previous',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
next: 'Next',
|
||||
volumeUp: 'Volume Up',
|
||||
volumeDown: 'Volume Down',
|
||||
mute: 'Mute',
|
||||
unmute: 'Unmute',
|
||||
songNum: 'Song Number: {num}',
|
||||
playFailed: 'Play Failed, Play Next Song',
|
||||
playMode: {
|
||||
sequence: 'Sequence',
|
||||
loop: 'Loop',
|
||||
random: 'Random'
|
||||
},
|
||||
fullscreen: {
|
||||
enter: 'Enter Fullscreen',
|
||||
exit: 'Exit Fullscreen'
|
||||
},
|
||||
close: 'Close',
|
||||
modeHint: {
|
||||
single: 'Single',
|
||||
list: 'Next'
|
||||
},
|
||||
lrc: {
|
||||
noLrc: 'No lyrics, please enjoy'
|
||||
},
|
||||
playBar: {
|
||||
expand: 'Expand Lyrics',
|
||||
collapse: 'Collapse Lyrics',
|
||||
like: 'Like',
|
||||
lyric: 'Lyric',
|
||||
noSongPlaying: 'No song playing',
|
||||
eq: 'Equalizer',
|
||||
playList: 'Play List',
|
||||
playMode: {
|
||||
sequence: 'Sequence',
|
||||
loop: 'Loop',
|
||||
random: 'Random'
|
||||
},
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
prev: 'Previous',
|
||||
next: 'Next',
|
||||
volume: 'Volume',
|
||||
favorite: 'Favorite {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'
|
||||
}
|
||||
}
|
||||
};
|
||||
19
src/i18n/lang/en-US/search.ts
Normal file
19
src/i18n/lang/en-US/search.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
title: {
|
||||
hotSearch: 'Hot Search',
|
||||
searchList: 'Search Results',
|
||||
searchHistory: 'Search History'
|
||||
},
|
||||
button: {
|
||||
clear: 'Clear',
|
||||
back: 'Back'
|
||||
},
|
||||
loading: {
|
||||
more: 'Loading...',
|
||||
failed: 'Search failed'
|
||||
},
|
||||
noMore: 'No more results',
|
||||
error: {
|
||||
searchFailed: 'Search failed'
|
||||
}
|
||||
};
|
||||
234
src/i18n/lang/en-US/settings.ts
Normal file
234
src/i18n/lang/en-US/settings.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
export default {
|
||||
theme: 'Theme',
|
||||
language: 'Language',
|
||||
regard: 'About',
|
||||
logout: 'Logout',
|
||||
sections: {
|
||||
basic: 'Basic Settings',
|
||||
playback: 'Playback Settings',
|
||||
application: 'Application Settings',
|
||||
network: 'Network Settings',
|
||||
system: 'System Management',
|
||||
donation: 'Donation',
|
||||
regard: 'About'
|
||||
},
|
||||
basic: {
|
||||
themeMode: 'Theme Mode',
|
||||
themeModeDesc: 'Switch between light/dark theme',
|
||||
language: 'Language Settings',
|
||||
languageDesc: 'Change display language',
|
||||
font: 'Font Settings',
|
||||
fontDesc: 'Select fonts, prioritize fonts in order',
|
||||
fontScope: {
|
||||
global: 'Global',
|
||||
lyric: 'Lyrics Only'
|
||||
},
|
||||
animation: 'Animation Speed',
|
||||
animationDesc: 'Enable/disable animations',
|
||||
animationSpeed: {
|
||||
slow: 'Very Slow',
|
||||
normal: 'Normal',
|
||||
fast: 'Very Fast'
|
||||
},
|
||||
fontPreview: {
|
||||
title: 'Font Preview',
|
||||
chinese: 'Chinese',
|
||||
english: 'English',
|
||||
japanese: 'Japanese',
|
||||
korean: 'Korean',
|
||||
chineseText: '静夜思 床前明月光 疑是地上霜',
|
||||
englishText: 'The quick brown fox jumps over the lazy dog',
|
||||
japaneseText: 'あいうえお かきくけこ さしすせそ',
|
||||
koreanText: '가나다라마 바사아자차 카타파하'
|
||||
}
|
||||
},
|
||||
playback: {
|
||||
quality: 'Audio Quality',
|
||||
qualityDesc: 'Select music playback quality (VIP)',
|
||||
qualityOptions: {
|
||||
standard: 'Standard',
|
||||
higher: 'Higher',
|
||||
exhigh: 'Extreme',
|
||||
lossless: 'Lossless',
|
||||
hires: 'Hi-Res',
|
||||
jyeffect: 'HD Surround',
|
||||
sky: 'Immersive',
|
||||
dolby: 'Dolby Atmos',
|
||||
jymaster: 'Master'
|
||||
},
|
||||
musicSources: 'Music Sources',
|
||||
musicSourcesDesc: 'Select music sources for song resolution',
|
||||
musicSourcesWarning: 'At least one music source must be selected',
|
||||
musicUnblockEnable: 'Enable Music Unblocking',
|
||||
musicUnblockEnableDesc: 'When enabled, attempts to resolve unplayable songs',
|
||||
configureMusicSources: 'Configure Sources',
|
||||
selectedMusicSources: 'Selected sources:',
|
||||
noMusicSources: 'No sources selected',
|
||||
gdmusicInfo: 'GD Music Station intelligently resolves music from multiple platforms automatically',
|
||||
autoPlay: 'Auto Play',
|
||||
autoPlayDesc: 'Auto resume playback when reopening the app'
|
||||
},
|
||||
application: {
|
||||
closeAction: 'Close Action',
|
||||
closeActionDesc: 'Choose action when closing window',
|
||||
closeOptions: {
|
||||
ask: 'Ask Every Time',
|
||||
minimize: 'Minimize to Tray',
|
||||
close: 'Exit Directly'
|
||||
},
|
||||
shortcut: 'Shortcut Settings',
|
||||
shortcutDesc: 'Customize global shortcuts',
|
||||
download: 'Download Management',
|
||||
downloadDesc: 'Always show download list button',
|
||||
unlimitedDownload: 'Unlimited Download',
|
||||
unlimitedDownloadDesc: 'Enable unlimited download mode for music , default limit 300 songs',
|
||||
downloadPath: 'Download Directory',
|
||||
downloadPathDesc: 'Choose download location for music files'
|
||||
},
|
||||
network: {
|
||||
apiPort: 'Music API Port',
|
||||
apiPortDesc: 'Restart required after modification',
|
||||
proxy: 'Proxy Settings',
|
||||
proxyDesc: 'Enable proxy when unable to access music',
|
||||
proxyHost: 'Proxy Host',
|
||||
proxyHostPlaceholder: 'Enter proxy host',
|
||||
proxyPort: 'Proxy Port',
|
||||
proxyPortPlaceholder: 'Enter proxy port',
|
||||
realIP: 'RealIP Settings',
|
||||
realIPDesc: 'Use realIP parameter with mainland China IP to resolve access restrictions abroad',
|
||||
messages: {
|
||||
proxySuccess: 'Proxy settings saved, restart required to take effect',
|
||||
proxyError: 'Please check your input',
|
||||
realIPSuccess: 'RealIP settings saved',
|
||||
realIPError: 'Please enter a valid IP address'
|
||||
}
|
||||
},
|
||||
system: {
|
||||
cache: 'Cache Management',
|
||||
cacheDesc: 'Clear cache',
|
||||
cacheClearTitle: 'Select cache types to clear:',
|
||||
cacheTypes: {
|
||||
history: {
|
||||
label: 'Play History',
|
||||
description: 'Clear played song records'
|
||||
},
|
||||
favorite: {
|
||||
label: 'Favorites',
|
||||
description: 'Clear local favorite songs (cloud favorites not affected)'
|
||||
},
|
||||
user: {
|
||||
label: 'User Data',
|
||||
description: 'Clear login info and user-related data'
|
||||
},
|
||||
settings: {
|
||||
label: 'App Settings',
|
||||
description: 'Clear all custom app settings'
|
||||
},
|
||||
downloads: {
|
||||
label: 'Download History',
|
||||
description: 'Clear download history (downloaded files not affected)'
|
||||
},
|
||||
resources: {
|
||||
label: 'Music Resources',
|
||||
description: 'Clear cached music files, lyrics and other resources'
|
||||
},
|
||||
lyrics: {
|
||||
label: 'Lyrics Resources',
|
||||
description: 'Clear cached lyrics resources'
|
||||
}
|
||||
},
|
||||
restart: 'Restart',
|
||||
restartDesc: 'Restart application',
|
||||
messages: {
|
||||
clearSuccess: 'Cache cleared successfully, some settings will take effect after restart'
|
||||
}
|
||||
},
|
||||
about: {
|
||||
version: 'Version',
|
||||
checkUpdate: 'Check for Updates',
|
||||
checking: 'Checking...',
|
||||
latest: 'Already latest version',
|
||||
hasUpdate: 'New version available',
|
||||
gotoUpdate: 'Go to Update',
|
||||
gotoGithub: 'Go to Github',
|
||||
author: 'Author',
|
||||
authorDesc: 'algerkong Give a star🌟',
|
||||
messages: {
|
||||
checkError: 'Failed to check for updates, please try again later'
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
selectProxyProtocol: 'Please select proxy protocol',
|
||||
proxyHost: 'Please enter proxy host',
|
||||
portNumber: 'Please enter a valid port number (1-65535)'
|
||||
},
|
||||
lyricSettings: {
|
||||
title: 'Lyric Settings',
|
||||
pureMode: 'Pure Mode',
|
||||
hideCover: 'Hide Cover',
|
||||
centerDisplay: 'Center Display',
|
||||
showTranslation: 'Show Translation',
|
||||
hidePlayBar: 'Hide Play Bar',
|
||||
fontSize: 'Font Size',
|
||||
letterSpacing: 'Letter Spacing',
|
||||
lineHeight: 'Line Height',
|
||||
backgroundTheme: 'Background Theme',
|
||||
fontSizeMarks: {
|
||||
small: 'Small',
|
||||
medium: 'Medium',
|
||||
large: 'Large'
|
||||
},
|
||||
letterSpacingMarks: {
|
||||
compact: 'Compact',
|
||||
default: 'Default',
|
||||
loose: 'Loose'
|
||||
},
|
||||
lineHeightMarks: {
|
||||
compact: 'Compact',
|
||||
default: 'Default',
|
||||
loose: 'Loose'
|
||||
},
|
||||
themeOptions: {
|
||||
default: 'Default',
|
||||
light: 'Light',
|
||||
dark: 'Dark'
|
||||
},
|
||||
hideMiniPlayBar: 'Hide Mini Play Bar',
|
||||
hideLyrics: 'Hide Lyrics',
|
||||
tabs: {
|
||||
interface: 'Interface',
|
||||
display: 'Display',
|
||||
typography: 'Typography'
|
||||
}
|
||||
},
|
||||
shortcutSettings: {
|
||||
title: 'Shortcut Settings',
|
||||
shortcut: 'Shortcut',
|
||||
shortcutDesc: 'Customize global shortcuts',
|
||||
shortcutConflict: 'Shortcut Conflict',
|
||||
inputPlaceholder: 'Click to input shortcut',
|
||||
resetShortcuts: 'Reset',
|
||||
disableAll: 'Disable All',
|
||||
enableAll: 'Enable All',
|
||||
togglePlay: 'Play/Pause',
|
||||
prevPlay: 'Previous',
|
||||
nextPlay: 'Next',
|
||||
volumeUp: 'Volume Up',
|
||||
volumeDown: 'Volume Down',
|
||||
toggleFavorite: 'Favorite/Unfavorite',
|
||||
toggleWindow: 'Show/Hide Window',
|
||||
scopeGlobal: 'Global',
|
||||
scopeApp: 'App Only',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
messages: {
|
||||
resetSuccess: 'Shortcuts reset successfully, please save',
|
||||
conflict: 'Shortcut conflict, please reset',
|
||||
saveSuccess: 'Shortcuts saved successfully',
|
||||
saveError: 'Failed to save shortcuts',
|
||||
cancelEdit: 'Edit cancelled',
|
||||
disableAll: 'All shortcuts disabled, please save to apply',
|
||||
enableAll: 'All shortcuts enabled, please save to apply'
|
||||
}
|
||||
}
|
||||
};
|
||||
18
src/i18n/lang/en-US/songItem.ts
Normal file
18
src/i18n/lang/en-US/songItem.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
menu: {
|
||||
play: 'Play',
|
||||
playNext: 'Play Next',
|
||||
download: 'Download',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
favorite: 'Like',
|
||||
unfavorite: 'Unlike',
|
||||
removeFromPlaylist: 'Remove from Playlist'
|
||||
},
|
||||
message: {
|
||||
downloading: 'Downloading, please wait...',
|
||||
downloadFailed: 'Download failed',
|
||||
downloadQueued: 'Added to download queue',
|
||||
addedToNextPlay: 'Added to play next',
|
||||
getUrlFailed: 'Failed to get music download URL, please check if logged in'
|
||||
}
|
||||
};
|
||||
42
src/i18n/lang/en-US/user.ts
Normal file
42
src/i18n/lang/en-US/user.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export default {
|
||||
profile: {
|
||||
followers: 'Followers',
|
||||
following: 'Following',
|
||||
level: 'Level'
|
||||
},
|
||||
playlist: {
|
||||
created: 'Created Playlists',
|
||||
trackCount: '{count} tracks',
|
||||
playCount: 'Played {count} times'
|
||||
},
|
||||
ranking: {
|
||||
title: 'Listening History',
|
||||
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: {
|
||||
loadFailed: 'Failed to load user page',
|
||||
deleteSuccess: 'Successfully deleted',
|
||||
deleteFailed: 'Failed to delete'
|
||||
}
|
||||
};
|
||||
5
src/i18n/lang/zh-CN/artist.ts
Normal file
5
src/i18n/lang/zh-CN/artist.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
hotSongs: '热门歌曲',
|
||||
albums: '专辑',
|
||||
description: '艺人介绍'
|
||||
};
|
||||
51
src/i18n/lang/zh-CN/common.ts
Normal file
51
src/i18n/lang/zh-CN/common.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export default {
|
||||
play: '播放',
|
||||
next: '下一首',
|
||||
previous: '上一首',
|
||||
volume: '音量',
|
||||
settings: '设置',
|
||||
search: '搜索',
|
||||
loading: '加载中...',
|
||||
loadingMore: '加载更多...',
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
on: '开启',
|
||||
off: '关闭',
|
||||
show: '显示',
|
||||
hide: '隐藏',
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
configure: '配置',
|
||||
open: '打开',
|
||||
modify: '修改',
|
||||
success: '操作成功',
|
||||
error: '操作失败',
|
||||
warning: '警告',
|
||||
info: '提示',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
refresh: '刷新',
|
||||
retry: '重试',
|
||||
validation: {
|
||||
required: '此项是必填的',
|
||||
invalidInput: '输入无效',
|
||||
selectRequired: '请选择一个选项',
|
||||
numberRange: '请输入 {min} 到 {max} 之间的数字'
|
||||
},
|
||||
viewMore: '查看更多',
|
||||
noMore: '没有更多了',
|
||||
selectAll: '全选',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
songCount: '{count}首',
|
||||
language: '语言',
|
||||
tray: {
|
||||
show: '显示',
|
||||
quit: '退出',
|
||||
playPause: '播放/暂停',
|
||||
prev: '上一首',
|
||||
next: '下一首',
|
||||
pause: '暂停',
|
||||
play: '播放'
|
||||
}
|
||||
};
|
||||
107
src/i18n/lang/zh-CN/comp.ts
Normal file
107
src/i18n/lang/zh-CN/comp.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export default {
|
||||
installApp: {
|
||||
description: '在桌面安装应用,获得更好的体验',
|
||||
noPrompt: '不再提示',
|
||||
install: '立即安装',
|
||||
cancel: '暂不安装',
|
||||
download: '下载',
|
||||
downloadFailed: '下载失败',
|
||||
downloadComplete: '下载完成',
|
||||
downloadProblem: '下载遇到问题?去',
|
||||
downloadProblemLinkText: '下载最新版本'
|
||||
},
|
||||
playlistDrawer: {
|
||||
title: '添加到歌单',
|
||||
createPlaylist: '创建新歌单',
|
||||
cancelCreate: '取消创建',
|
||||
create: '创建',
|
||||
playlistName: '歌单名称',
|
||||
privatePlaylist: '私密歌单',
|
||||
publicPlaylist: '公开歌单',
|
||||
createSuccess: '歌单创建成功',
|
||||
createFailed: '歌单创建失败',
|
||||
addSuccess: '歌曲添加成功',
|
||||
addFailed: '歌曲添加失败',
|
||||
private: '私密',
|
||||
public: '公开',
|
||||
count: '首歌曲',
|
||||
loginFirst: '请先登录',
|
||||
getPlaylistFailed: '获取歌单失败',
|
||||
inputPlaylistName: '请输入歌单名称'
|
||||
},
|
||||
update: {
|
||||
title: '发现新版本',
|
||||
currentVersion: '当前版本',
|
||||
cancel: '暂不更新',
|
||||
prepareDownload: '准备下载...',
|
||||
downloading: '下载中...',
|
||||
nowUpdate: '立即更新',
|
||||
downloadFailed: '下载失败,请重试或手动下载',
|
||||
startFailed: '启动下载失败,请重试或手动下载',
|
||||
noDownloadUrl: '未找到适合当前系统的安装包,请手动下载',
|
||||
installConfirmTitle: '安装更新',
|
||||
installConfirmContent: '是否关闭应用并安装更新?',
|
||||
manualInstallTip: '如果关闭应用后没有正常弹出安装程序,请至下载文件夹查找文件并手动打开。',
|
||||
yesInstall: '立即安装',
|
||||
noThanks: '稍后安装',
|
||||
fileLocation: '文件位置',
|
||||
copy: '复制路径',
|
||||
copySuccess: '路径已复制到剪贴板',
|
||||
copyFailed: '复制失败',
|
||||
backgroundDownload: '后台下载'
|
||||
},
|
||||
coffee: {
|
||||
title: '请我喝咖啡',
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
alipayQR: '支付宝收款码',
|
||||
wechatQR: '微信收款码',
|
||||
coffeeDesc: '一杯咖啡,一份支持',
|
||||
coffeeDescLinkText: '查看更多',
|
||||
qqGroup: 'QQ群:789288579',
|
||||
messages: {
|
||||
copySuccess: '已复制到剪贴板'
|
||||
},
|
||||
donateList: '请我喝咖啡'
|
||||
},
|
||||
playlistType: {
|
||||
title: '歌单分类',
|
||||
showAll: '显示全部',
|
||||
hide: '隐藏一些'
|
||||
},
|
||||
recommendAlbum: {
|
||||
title: '最新专辑'
|
||||
},
|
||||
recommendSinger: {
|
||||
title: '每日推荐',
|
||||
songlist: '每日推荐列表'
|
||||
},
|
||||
recommendSonglist: {
|
||||
title: '本周最热音乐'
|
||||
},
|
||||
searchBar: {
|
||||
login: '登录',
|
||||
toLogin: '去登录',
|
||||
logout: '退出登录',
|
||||
set: '设置',
|
||||
theme: '主题',
|
||||
restart: '重启',
|
||||
refresh: '刷新',
|
||||
currentVersion: '当前版本',
|
||||
searchPlaceholder: '搜索点什么吧...'
|
||||
},
|
||||
titleBar: {
|
||||
closeTitle: '请选择关闭方式',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
exitApp: '退出应用',
|
||||
rememberChoice: '记住我的选择',
|
||||
closeApp: '关闭应用'
|
||||
},
|
||||
userPlayList: {
|
||||
title: '{name}的常听'
|
||||
},
|
||||
musicList: {
|
||||
searchSongs: '搜索歌曲',
|
||||
noSearchResults: '没有找到相关歌曲'
|
||||
}
|
||||
};
|
||||
6
src/i18n/lang/zh-CN/donation.ts
Normal file
6
src/i18n/lang/zh-CN/donation.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
description: '您的捐赠将用于支持开发和维护工作,包括但不限于服务器维护、域名续费等。',
|
||||
message: '留言时可留下您的邮箱或 github名称。',
|
||||
refresh: '刷新列表',
|
||||
toDonateList: '请我喝咖啡'
|
||||
};
|
||||
48
src/i18n/lang/zh-CN/download.ts
Normal file
48
src/i18n/lang/zh-CN/download.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export default {
|
||||
title: '下载管理',
|
||||
localMusic: '本地音乐',
|
||||
count: '共 {count} 首歌曲',
|
||||
clearAll: '清空记录',
|
||||
tabs: {
|
||||
downloading: '下载中',
|
||||
downloaded: '已下载'
|
||||
},
|
||||
empty: {
|
||||
noTasks: '暂无下载任务',
|
||||
noDownloaded: '暂无已下载歌曲'
|
||||
},
|
||||
progress: {
|
||||
total: '总进度: {progress}%'
|
||||
},
|
||||
status: {
|
||||
downloading: '下载中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
unknown: '未知'
|
||||
},
|
||||
artist: {
|
||||
unknown: '未知歌手'
|
||||
},
|
||||
delete: {
|
||||
title: '删除确认',
|
||||
message: '确定要删除歌曲 "{filename}" 吗?此操作不可恢复。',
|
||||
confirm: '确定删除',
|
||||
cancel: '取消',
|
||||
success: '删除成功',
|
||||
failed: '删除失败',
|
||||
fileNotFound: '文件不存在或已被移动,已从记录中移除',
|
||||
recordRemoved: '文件删除失败,但已从记录中移除'
|
||||
},
|
||||
clear: {
|
||||
title: '清空下载记录',
|
||||
message: '确定要清空所有下载记录吗?此操作不会删除已下载的音乐文件,但将清空所有记录。',
|
||||
confirm: '确定清空',
|
||||
cancel: '取消',
|
||||
success: '下载记录已清空'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} 下载完成',
|
||||
downloadFailed: '{filename} 下载失败: {error}'
|
||||
},
|
||||
loading: '加载中...'
|
||||
};
|
||||
11
src/i18n/lang/zh-CN/favorite.ts
Normal file
11
src/i18n/lang/zh-CN/favorite.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
title: '我的收藏',
|
||||
count: '共 {count} 首',
|
||||
batchDownload: '批量下载',
|
||||
download: '下载 ({count})',
|
||||
emptyTip: '还没有收藏歌曲',
|
||||
downloadSuccess: '下载完成',
|
||||
downloadFailed: '下载失败',
|
||||
downloading: '正在下载中,请稍候...',
|
||||
selectSongsFirst: '请先选择要下载的歌曲'
|
||||
};
|
||||
5
src/i18n/lang/zh-CN/history.ts
Normal file
5
src/i18n/lang/zh-CN/history.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
title: '播放历史',
|
||||
playCount: '{count}',
|
||||
getHistoryFailed: '获取历史记录失败'
|
||||
};
|
||||
29
src/i18n/lang/zh-CN/index.ts
Normal file
29
src/i18n/lang/zh-CN/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import artist from './artist';
|
||||
import common from './common';
|
||||
import comp from './comp';
|
||||
import donation from './donation';
|
||||
import download from './download';
|
||||
import favorite from './favorite';
|
||||
import history from './history';
|
||||
import login from './login';
|
||||
import player from './player';
|
||||
import search from './search';
|
||||
import settings from './settings';
|
||||
import songItem from './songItem';
|
||||
import user from './user';
|
||||
|
||||
export default {
|
||||
common,
|
||||
donation,
|
||||
favorite,
|
||||
history,
|
||||
login,
|
||||
player,
|
||||
search,
|
||||
settings,
|
||||
songItem,
|
||||
user,
|
||||
download,
|
||||
comp,
|
||||
artist
|
||||
};
|
||||
22
src/i18n/lang/zh-CN/login.ts
Normal file
22
src/i18n/lang/zh-CN/login.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
title: {
|
||||
qr: '扫码登录',
|
||||
phone: '手机号登录'
|
||||
},
|
||||
qrTip: '使用网易云APP扫码登录',
|
||||
phoneTip: '使用网易云账号登录',
|
||||
placeholder: {
|
||||
phone: '手机号',
|
||||
password: '密码'
|
||||
},
|
||||
button: {
|
||||
login: '登录',
|
||||
switchToQr: '扫码登录',
|
||||
switchToPhone: '手机号登录'
|
||||
},
|
||||
message: {
|
||||
loginSuccess: '登录成功',
|
||||
loadError: '加载登录信息时出错',
|
||||
qrCheckError: '检查二维码状态时出错'
|
||||
}
|
||||
};
|
||||
78
src/i18n/lang/zh-CN/player.ts
Normal file
78
src/i18n/lang/zh-CN/player.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export default {
|
||||
nowPlaying: '正在播放',
|
||||
playlist: '播放列表',
|
||||
lyrics: '歌词',
|
||||
previous: '上一个',
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
next: '下一个',
|
||||
volumeUp: '音量增加',
|
||||
volumeDown: '音量减少',
|
||||
mute: '静音',
|
||||
unmute: '取消静音',
|
||||
songNum: '歌曲总数:{num}',
|
||||
playFailed: '当前歌曲播放失败,播放下一首',
|
||||
playMode: {
|
||||
sequence: '顺序播放',
|
||||
loop: '循环播放',
|
||||
random: '随机播放'
|
||||
},
|
||||
fullscreen: {
|
||||
enter: '全屏',
|
||||
exit: '退出全屏'
|
||||
},
|
||||
close: '关闭',
|
||||
modeHint: {
|
||||
single: '单曲循环',
|
||||
list: '自动播放下一个'
|
||||
},
|
||||
lrc: {
|
||||
noLrc: '暂无歌词, 请欣赏'
|
||||
},
|
||||
playBar: {
|
||||
expand: '展开歌词',
|
||||
collapse: '收起歌词',
|
||||
like: '喜欢',
|
||||
lyric: '歌词',
|
||||
noSongPlaying: '没有正在播放的歌曲',
|
||||
eq: '均衡器',
|
||||
playList: '播放列表',
|
||||
playMode: {
|
||||
sequence: '顺序播放',
|
||||
loop: '循环播放',
|
||||
random: '随机播放'
|
||||
},
|
||||
play: '开始播放',
|
||||
pause: '暂停播放',
|
||||
prev: '上一首',
|
||||
next: '下一首',
|
||||
volume: '音量',
|
||||
favorite: '已收藏{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: '自定义'
|
||||
}
|
||||
}
|
||||
};
|
||||
19
src/i18n/lang/zh-CN/search.ts
Normal file
19
src/i18n/lang/zh-CN/search.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
title: {
|
||||
hotSearch: '热搜列表',
|
||||
searchList: '搜索列表',
|
||||
searchHistory: '搜索历史'
|
||||
},
|
||||
button: {
|
||||
clear: '清空',
|
||||
back: '返回'
|
||||
},
|
||||
loading: {
|
||||
more: '加载中...',
|
||||
failed: '搜索失败'
|
||||
},
|
||||
noMore: '没有更多了',
|
||||
error: {
|
||||
searchFailed: '搜索失败'
|
||||
}
|
||||
};
|
||||
5
src/i18n/lang/zh-CN/settings.json
Normal file
5
src/i18n/lang/zh-CN/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
"playback": {
|
||||
"musicSources": "音源设置",
|
||||
"musicSourcesDesc": "选择音乐解析使用的音源平台",
|
||||
"musicSourcesWarning": "至少需要选择一个音源平台"
|
||||
}
|
||||
234
src/i18n/lang/zh-CN/settings.ts
Normal file
234
src/i18n/lang/zh-CN/settings.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
export default {
|
||||
theme: '主题',
|
||||
language: '语言',
|
||||
regard: '关于',
|
||||
logout: '退出登录',
|
||||
sections: {
|
||||
basic: '基础设置',
|
||||
playback: '播放设置',
|
||||
application: '应用设置',
|
||||
network: '网络设置',
|
||||
system: '系统管理',
|
||||
donation: '捐赠支持',
|
||||
regard: '关于'
|
||||
},
|
||||
basic: {
|
||||
themeMode: '主题模式',
|
||||
themeModeDesc: '切换日间/夜间主题',
|
||||
language: '语言设置',
|
||||
languageDesc: '切换显示语言',
|
||||
font: '字体设置',
|
||||
fontDesc: '选择字体,优先使用排在前面的字体',
|
||||
fontScope: {
|
||||
global: '全局',
|
||||
lyric: '仅歌词'
|
||||
},
|
||||
animation: '动画速度',
|
||||
animationDesc: '是否开启动画',
|
||||
animationSpeed: {
|
||||
slow: '极慢',
|
||||
normal: '正常',
|
||||
fast: '极快'
|
||||
},
|
||||
fontPreview: {
|
||||
title: '字体预览',
|
||||
chinese: '中文',
|
||||
english: 'English',
|
||||
japanese: '日本語',
|
||||
korean: '한국어',
|
||||
chineseText: '静夜思 床前明月光 疑是地上霜',
|
||||
englishText: 'The quick brown fox jumps over the lazy dog',
|
||||
japaneseText: 'あいうえお かきくけこ さしすせそ',
|
||||
koreanText: '가나다라마 바사아자차 카타파하'
|
||||
}
|
||||
},
|
||||
playback: {
|
||||
quality: '音质设置',
|
||||
qualityDesc: '选择音乐播放音质(VIP)',
|
||||
qualityOptions: {
|
||||
standard: '标准',
|
||||
higher: '较高',
|
||||
exhigh: '极高',
|
||||
lossless: '无损',
|
||||
hires: 'Hi-Res',
|
||||
jyeffect: '高清环绕声',
|
||||
sky: '沉浸环绕声',
|
||||
dolby: '杜比全景声',
|
||||
jymaster: '超清母带'
|
||||
},
|
||||
musicSources: '音源设置',
|
||||
musicSourcesDesc: '选择音乐解析使用的音源平台',
|
||||
musicSourcesWarning: '至少需要选择一个音源平台',
|
||||
musicUnblockEnable: '启用音乐解析',
|
||||
musicUnblockEnableDesc: '开启后将尝试解析无法播放的音乐',
|
||||
configureMusicSources: '配置音源',
|
||||
selectedMusicSources: '已选音源:',
|
||||
noMusicSources: '未选择音源',
|
||||
gdmusicInfo: 'GD音乐台可自动解析多个平台音源,自动选择最佳结果',
|
||||
autoPlay: '自动播放',
|
||||
autoPlayDesc: '重新打开应用时是否自动继续播放'
|
||||
},
|
||||
application: {
|
||||
closeAction: '关闭行为',
|
||||
closeActionDesc: '选择关闭窗口时的行为',
|
||||
closeOptions: {
|
||||
ask: '每次询问',
|
||||
minimize: '最小化到托盘',
|
||||
close: '直接退出'
|
||||
},
|
||||
shortcut: '快捷键设置',
|
||||
shortcutDesc: '自定义全局快捷键',
|
||||
download: '下载管理',
|
||||
downloadDesc: '是否始终显示下载列表按钮',
|
||||
unlimitedDownload: '无限制下载',
|
||||
unlimitedDownloadDesc: '开启后将无限制下载音乐(可能出现下载失败的情况), 默认限制 300 首',
|
||||
downloadPath: '下载目录',
|
||||
downloadPathDesc: '选择音乐文件的下载位置'
|
||||
},
|
||||
network: {
|
||||
apiPort: '音乐API端口',
|
||||
apiPortDesc: '修改后需要重启应用',
|
||||
proxy: '代理设置',
|
||||
proxyDesc: '无法访问音乐时可以开启代理',
|
||||
proxyHost: '代理地址',
|
||||
proxyHostPlaceholder: '请输入代理地址',
|
||||
proxyPort: '代理端口',
|
||||
proxyPortPlaceholder: '请输入代理端口',
|
||||
realIP: 'realIP设置',
|
||||
realIPDesc: '由于限制,此项目在国外使用会受到限制可使用realIP参数,传进国内IP解决',
|
||||
messages: {
|
||||
proxySuccess: '代理设置已保存,重启应用后生效',
|
||||
proxyError: '请检查输入是否正确',
|
||||
realIPSuccess: '真实IP设置已保存',
|
||||
realIPError: '请输入有效的IP地址'
|
||||
}
|
||||
},
|
||||
system: {
|
||||
cache: '缓存管理',
|
||||
cacheDesc: '清除缓存',
|
||||
cacheClearTitle: '请选择要清除的缓存类型:',
|
||||
cacheTypes: {
|
||||
history: {
|
||||
label: '播放历史',
|
||||
description: '清除播放过的歌曲记录'
|
||||
},
|
||||
favorite: {
|
||||
label: '收藏记录',
|
||||
description: '清除本地收藏的歌曲记录(不会影响云端收藏)'
|
||||
},
|
||||
user: {
|
||||
label: '用户数据',
|
||||
description: '清除登录信息和用户相关数据'
|
||||
},
|
||||
settings: {
|
||||
label: '应用设置',
|
||||
description: '清除应用的所有自定义设置'
|
||||
},
|
||||
downloads: {
|
||||
label: '下载记录',
|
||||
description: '清除下载历史记录(不会删除已下载的文件)'
|
||||
},
|
||||
resources: {
|
||||
label: '音乐资源',
|
||||
description: '清除已加载的音乐文件、歌词等资源缓存'
|
||||
},
|
||||
lyrics: {
|
||||
label: '歌词资源',
|
||||
description: '清除已加载的歌词资源缓存'
|
||||
}
|
||||
},
|
||||
restart: '重启',
|
||||
restartDesc: '重启应用',
|
||||
messages: {
|
||||
clearSuccess: '清除成功,部分设置在重启后生效'
|
||||
}
|
||||
},
|
||||
about: {
|
||||
version: '版本',
|
||||
checkUpdate: '检查更新',
|
||||
checking: '检查中...',
|
||||
latest: '当前已是最新版本',
|
||||
hasUpdate: '发现新版本',
|
||||
gotoUpdate: '前往更新',
|
||||
gotoGithub: '前往 Github',
|
||||
author: '作者',
|
||||
authorDesc: 'algerkong 点个star🌟呗',
|
||||
messages: {
|
||||
checkError: '检查更新失败,请稍后重试'
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
selectProxyProtocol: '请选择代理协议',
|
||||
proxyHost: '请输入代理地址',
|
||||
portNumber: '请输入有效的端口号(1-65535)'
|
||||
},
|
||||
lyricSettings: {
|
||||
title: '页面设置',
|
||||
pureMode: '纯净模式',
|
||||
hideCover: '隐藏封面',
|
||||
centerDisplay: '居中显示',
|
||||
showTranslation: '显示翻译',
|
||||
hidePlayBar: '隐藏播放栏',
|
||||
fontSize: '字体大小',
|
||||
fontSizeMarks: {
|
||||
small: '小',
|
||||
medium: '中',
|
||||
large: '大'
|
||||
},
|
||||
letterSpacing: '文字间距',
|
||||
letterSpacingMarks: {
|
||||
compact: '紧凑',
|
||||
default: '默认',
|
||||
loose: '宽松'
|
||||
},
|
||||
lineHeight: '行高',
|
||||
lineHeightMarks: {
|
||||
compact: '紧凑',
|
||||
default: '默认',
|
||||
loose: '宽松'
|
||||
},
|
||||
backgroundTheme: '背景主题',
|
||||
themeOptions: {
|
||||
default: '默认',
|
||||
light: '亮色',
|
||||
dark: '暗色'
|
||||
},
|
||||
hideMiniPlayBar: '隐藏迷你播放栏',
|
||||
hideLyrics: '隐藏歌词',
|
||||
tabs: {
|
||||
interface: '界面',
|
||||
typography: '文字',
|
||||
display: '显示'
|
||||
}
|
||||
},
|
||||
shortcutSettings: {
|
||||
title: '快捷键设置',
|
||||
shortcut: '快捷键',
|
||||
shortcutDesc: '自定义快捷键',
|
||||
shortcutConflict: '快捷键冲突',
|
||||
inputPlaceholder: '点击输入快捷键',
|
||||
resetShortcuts: '恢复默认',
|
||||
disableAll: '全部禁用',
|
||||
enableAll: '全部启用',
|
||||
togglePlay: '播放/暂停',
|
||||
prevPlay: '上一首',
|
||||
nextPlay: '下一首',
|
||||
volumeUp: '音量增加',
|
||||
volumeDown: '音量减少',
|
||||
toggleFavorite: '收藏/取消收藏',
|
||||
toggleWindow: '显示/隐藏窗口',
|
||||
scopeGlobal: '全局',
|
||||
scopeApp: '应用内',
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
messages: {
|
||||
resetSuccess: '已恢复默认快捷键,请记得保存',
|
||||
conflict: '存在冲突的快捷键,请重新设置',
|
||||
saveSuccess: '快捷键设置已保存',
|
||||
saveError: '保存快捷键失败,请重试',
|
||||
cancelEdit: '已取消修改',
|
||||
disableAll: '已禁用所有快捷键,请记得保存',
|
||||
enableAll: '已启用所有快捷键,请记得保存'
|
||||
}
|
||||
}
|
||||
};
|
||||
18
src/i18n/lang/zh-CN/songItem.ts
Normal file
18
src/i18n/lang/zh-CN/songItem.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
menu: {
|
||||
play: '播放',
|
||||
playNext: '下一首播放',
|
||||
download: '下载歌曲',
|
||||
addToPlaylist: '添加到歌单',
|
||||
favorite: '喜欢',
|
||||
unfavorite: '取消喜欢',
|
||||
removeFromPlaylist: '从歌单中删除'
|
||||
},
|
||||
message: {
|
||||
downloading: '正在下载中,请稍候...',
|
||||
downloadFailed: '下载失败',
|
||||
downloadQueued: '已加入下载队列',
|
||||
addedToNextPlay: '已添加到下一首播放',
|
||||
getUrlFailed: '获取音乐下载地址失败,请检查是否登录'
|
||||
}
|
||||
};
|
||||
42
src/i18n/lang/zh-CN/user.ts
Normal file
42
src/i18n/lang/zh-CN/user.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export default {
|
||||
profile: {
|
||||
followers: '粉丝',
|
||||
following: '关注',
|
||||
level: '等级'
|
||||
},
|
||||
playlist: {
|
||||
created: '创建的歌单',
|
||||
trackCount: '{count}首',
|
||||
playCount: '播放{count}次'
|
||||
},
|
||||
ranking: {
|
||||
title: '听歌排行',
|
||||
playCount: '{count}次'
|
||||
},
|
||||
follow: {
|
||||
title: '关注列表',
|
||||
viewPlaylist: '查看歌单',
|
||||
noFollowings: '暂无关注',
|
||||
loadMore: '加载更多',
|
||||
noSignature: '这个家伙很懒,什么都没留下'
|
||||
},
|
||||
follower: {
|
||||
title: '粉丝列表',
|
||||
noFollowers: '暂无粉丝',
|
||||
loadMore: '加载更多'
|
||||
},
|
||||
detail: {
|
||||
playlists: '歌单',
|
||||
records: '听歌排行',
|
||||
noPlaylists: '暂无歌单',
|
||||
noRecords: '暂无听歌记录',
|
||||
artist: '歌手',
|
||||
noSignature: '这个人很懒,什么都没留下',
|
||||
invalidUserId: '用户ID无效'
|
||||
},
|
||||
message: {
|
||||
loadFailed: '加载用户页面失败',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteFailed: '删除失败'
|
||||
}
|
||||
};
|
||||
38
src/i18n/main.ts
Normal file
38
src/i18n/main.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import enUS from './lang/en-US';
|
||||
import zhCN from './lang/zh-CN';
|
||||
|
||||
const messages = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS
|
||||
} as const;
|
||||
|
||||
type Language = keyof typeof messages;
|
||||
|
||||
// 为主进程提供一个简单的 i18n 实现
|
||||
const mainI18n = {
|
||||
global: {
|
||||
currentLocale: 'zh-CN' as Language,
|
||||
get locale() {
|
||||
return this.currentLocale;
|
||||
},
|
||||
set locale(value: Language) {
|
||||
this.currentLocale = value;
|
||||
},
|
||||
t(key: string) {
|
||||
const keys = key.split('.');
|
||||
let current: any = messages[this.currentLocale];
|
||||
for (const k of keys) {
|
||||
if (current[k] === undefined) {
|
||||
// 如果找不到翻译,返回键名
|
||||
return key;
|
||||
}
|
||||
current = current[k];
|
||||
}
|
||||
return current;
|
||||
},
|
||||
messages
|
||||
}
|
||||
};
|
||||
|
||||
export type { Language };
|
||||
export default mainI18n;
|
||||
21
src/i18n/renderer.ts
Normal file
21
src/i18n/renderer.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import enUS from './lang/en-US';
|
||||
import zhCN from './lang/zh-CN';
|
||||
|
||||
const messages = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS
|
||||
};
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
fallbackLocale: 'en-US',
|
||||
messages,
|
||||
globalInjection: true,
|
||||
silentTranslationWarn: true,
|
||||
silentFallbackWarn: true
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -2,12 +2,16 @@ import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||||
import { app, ipcMain, nativeImage } from 'electron';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { Language } from '../i18n/main';
|
||||
import i18n from '../i18n/main';
|
||||
import { loadLyricWindow } from './lyric';
|
||||
import { initializeCacheManager } from './modules/cache';
|
||||
import { initializeConfig } from './modules/config';
|
||||
import { initializeFileManager } from './modules/fileManager';
|
||||
import { initializeFonts } from './modules/fonts';
|
||||
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
|
||||
import { initializeTray } from './modules/tray';
|
||||
import { initializeStats, setupStatsHandlers } from './modules/statsService';
|
||||
import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
|
||||
import { setupUpdateHandlers } from './modules/update';
|
||||
import { createMainWindow, initializeWindowManager } from './modules/window';
|
||||
import { startMusicApi } from './server';
|
||||
|
||||
@@ -26,13 +30,20 @@ let mainWindow: Electron.BrowserWindow;
|
||||
// 初始化应用
|
||||
function initialize() {
|
||||
// 初始化配置管理
|
||||
initializeConfig();
|
||||
// 初始化缓存管理
|
||||
initializeCacheManager();
|
||||
const store = initializeConfig();
|
||||
|
||||
// 设置初始语言
|
||||
const savedLanguage = store.get('set.language') as Language;
|
||||
if (savedLanguage) {
|
||||
i18n.global.locale = savedLanguage;
|
||||
}
|
||||
|
||||
// 初始化文件管理
|
||||
initializeFileManager();
|
||||
// 初始化窗口管理
|
||||
initializeWindowManager();
|
||||
// 初始化字体管理
|
||||
initializeFonts();
|
||||
|
||||
// 创建主窗口
|
||||
mainWindow = createMainWindow(icon);
|
||||
@@ -40,6 +51,12 @@ function initialize() {
|
||||
// 初始化托盘
|
||||
initializeTray(iconPath, mainWindow);
|
||||
|
||||
// 初始化统计服务
|
||||
initializeStats();
|
||||
|
||||
// 设置统计相关的IPC处理程序
|
||||
setupStatsHandlers(ipcMain);
|
||||
|
||||
// 启动音乐API
|
||||
startMusicApi();
|
||||
|
||||
@@ -48,46 +65,87 @@ function initialize() {
|
||||
|
||||
// 初始化快捷键
|
||||
initializeShortcuts(mainWindow);
|
||||
|
||||
// 初始化更新处理程序
|
||||
setupUpdateHandlers(mainWindow);
|
||||
}
|
||||
|
||||
// 应用程序准备就绪时的处理
|
||||
app.whenReady().then(() => {
|
||||
// 设置应用ID
|
||||
electronApp.setAppUserModelId('com.alger.music');
|
||||
// 检查是否为第一个实例
|
||||
const isSingleInstance = app.requestSingleInstanceLock();
|
||||
|
||||
// 监听窗口创建事件
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
if (!isSingleInstance) {
|
||||
app.quit();
|
||||
} else {
|
||||
// 当第二个实例启动时,将焦点转移到第一个实例的窗口
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化应用
|
||||
initialize();
|
||||
// 应用程序准备就绪时的处理
|
||||
app.whenReady().then(() => {
|
||||
// 设置应用ID
|
||||
electronApp.setAppUserModelId('com.alger.music');
|
||||
|
||||
// macOS 激活应用时的处理
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) initialize();
|
||||
// 监听窗口创建事件
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// 初始化应用
|
||||
initialize();
|
||||
|
||||
// macOS 激活应用时的处理
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) initialize();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 监听快捷键更新
|
||||
ipcMain.on('update-shortcuts', () => {
|
||||
registerShortcuts(mainWindow);
|
||||
});
|
||||
// 监听快捷键更新
|
||||
ipcMain.on('update-shortcuts', () => {
|
||||
registerShortcuts(mainWindow);
|
||||
});
|
||||
|
||||
// 所有窗口关闭时的处理
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
// 监听语言切换
|
||||
ipcMain.on('change-language', (_, locale: Language) => {
|
||||
// 更新主进程的语言设置
|
||||
i18n.global.locale = locale;
|
||||
// 更新托盘菜单
|
||||
updateTrayMenu(mainWindow);
|
||||
// 通知所有窗口语言已更改
|
||||
mainWindow?.webContents.send('language-changed', locale);
|
||||
});
|
||||
|
||||
// 重启应用
|
||||
ipcMain.on('restart', () => {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
});
|
||||
// 监听播放状态变化
|
||||
ipcMain.on('update-play-state', (_, playing: boolean) => {
|
||||
updatePlayState(playing);
|
||||
});
|
||||
|
||||
// 获取系统架构信息
|
||||
ipcMain.on('get-arch', (event) => {
|
||||
event.returnValue = process.arch;
|
||||
});
|
||||
// 监听当前歌曲变化
|
||||
ipcMain.on('update-current-song', (_, song: any) => {
|
||||
updateCurrentSong(song);
|
||||
});
|
||||
|
||||
// 所有窗口关闭时的处理
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// 重启应用
|
||||
ipcMain.on('restart', () => {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
});
|
||||
|
||||
// 获取系统架构信息
|
||||
ipcMain.on('get-arch', (event) => {
|
||||
event.returnValue = process.arch;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import path, { join } from 'path';
|
||||
const store = new Store();
|
||||
let lyricWindow: BrowserWindow | null = null;
|
||||
|
||||
// 跟踪拖动状态
|
||||
let isDragging = false;
|
||||
|
||||
// 添加窗口大小变化防护
|
||||
let originalSize = { width: 0, height: 0 };
|
||||
|
||||
const createWin = () => {
|
||||
console.log('Creating lyric window');
|
||||
|
||||
@@ -15,31 +21,81 @@ const createWin = () => {
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
displayId?: number;
|
||||
}) || {};
|
||||
const { x, y, width, height } = windowBounds;
|
||||
|
||||
// 获取屏幕尺寸
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
|
||||
const { x, y, width, height, displayId } = windowBounds;
|
||||
|
||||
// 验证保存的位置是否有效
|
||||
const validPosition =
|
||||
x !== undefined && y !== undefined && x >= 0 && y >= 0 && x < screenWidth && y < screenHeight;
|
||||
// 获取所有屏幕的信息
|
||||
const displays = screen.getAllDisplays();
|
||||
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({
|
||||
width: width || 800,
|
||||
height: height || 200,
|
||||
x: validPosition ? x : undefined,
|
||||
y: validPosition ? y : undefined,
|
||||
width: validWidth,
|
||||
height: validHeight,
|
||||
x: windowX,
|
||||
y: windowY,
|
||||
frame: false,
|
||||
show: false,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
hasShadow: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: true,
|
||||
roundedCorners: false,
|
||||
// 添加跨屏幕支持选项
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
},
|
||||
backgroundColor: '#00000000'
|
||||
});
|
||||
|
||||
// 监听窗口关闭事件
|
||||
@@ -50,6 +106,20 @@ const createWin = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 监听窗口大小变化事件,保存新的尺寸
|
||||
lyricWindow.on('resize', () => {
|
||||
// 如果正在拖动,忽略大小调整事件
|
||||
if (isDragging) return;
|
||||
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
const [width, height] = lyricWindow.getSize();
|
||||
const [x, y] = lyricWindow.getPosition();
|
||||
|
||||
// 保存窗口位置和大小
|
||||
store.set('lyricWindowBounds', { x, y, width, height });
|
||||
}
|
||||
});
|
||||
|
||||
return lyricWindow;
|
||||
};
|
||||
|
||||
@@ -118,6 +188,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
lyricWindow.webContents.send('lyric-window-close');
|
||||
mainWin.webContents.send('lyric-control-back', 'close');
|
||||
mainWin.webContents.send('lyric-window-closed');
|
||||
lyricWindow.destroy();
|
||||
lyricWindow = null;
|
||||
}
|
||||
@@ -136,26 +207,75 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
|
||||
}
|
||||
});
|
||||
|
||||
// 开始拖动时设置标志
|
||||
ipcMain.on('lyric-drag-start', () => {
|
||||
isDragging = true;
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
// 记录原始窗口大小
|
||||
const [width, height] = lyricWindow.getSize();
|
||||
originalSize = { width, height };
|
||||
|
||||
// 在拖动时暂时禁用大小调整
|
||||
lyricWindow.setResizable(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 结束拖动时清除标志
|
||||
ipcMain.on('lyric-drag-end', () => {
|
||||
isDragging = false;
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
// 确保窗口大小恢复原样
|
||||
lyricWindow.setSize(originalSize.width, originalSize.height);
|
||||
|
||||
// 拖动结束后恢复可调整大小
|
||||
lyricWindow.setResizable(true);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理拖动移动
|
||||
ipcMain.on('lyric-drag-move', (_, { deltaX, deltaY }) => {
|
||||
if (!lyricWindow || lyricWindow.isDestroyed()) return;
|
||||
if (!lyricWindow || lyricWindow.isDestroyed() || !isDragging) return;
|
||||
|
||||
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 newY = Math.max(0, Math.min(currentY + deltaY, screenHeight - windowHeight));
|
||||
// 使用记录的原始大小,而不是当前大小
|
||||
const windowWidth = originalSize.width;
|
||||
const windowHeight = originalSize.height;
|
||||
|
||||
lyricWindow.setPosition(newX, newY);
|
||||
// 计算新位置
|
||||
const newX = currentX + deltaX;
|
||||
const newY = currentY + deltaY;
|
||||
|
||||
// 保存新位置
|
||||
store.set('lyricWindowBounds', {
|
||||
...lyricWindow.getBounds(),
|
||||
x: newX,
|
||||
y: newY
|
||||
});
|
||||
try {
|
||||
// 获取当前鼠标所在的显示器
|
||||
const mousePoint = screen.getCursorScreenPoint();
|
||||
const currentDisplay = screen.getDisplayNearestPoint(mousePoint);
|
||||
|
||||
// 拖动期间使用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);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加鼠标穿透事件处理
|
||||
|
||||
@@ -4,15 +4,29 @@ import Store from 'electron-store';
|
||||
import set from '../set.json';
|
||||
import { defaultShortcuts } from './shortcuts';
|
||||
|
||||
interface StoreType {
|
||||
set: {
|
||||
isProxy: boolean;
|
||||
noAnimate: boolean;
|
||||
animationSpeed: number;
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
musicApiPort: number;
|
||||
type SetConfig = {
|
||||
isProxy: boolean;
|
||||
proxyConfig: {
|
||||
enable: boolean;
|
||||
protocol: string;
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
enableRealIP: boolean;
|
||||
realIP: string;
|
||||
noAnimate: boolean;
|
||||
animationSpeed: number;
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
musicApiPort: number;
|
||||
closeAction: 'ask' | 'minimize' | 'close';
|
||||
musicQuality: string;
|
||||
fontFamily: string;
|
||||
fontScope: 'global' | 'lyric';
|
||||
language: string;
|
||||
};
|
||||
interface StoreType {
|
||||
set: SetConfig;
|
||||
shortcuts: typeof defaultShortcuts;
|
||||
}
|
||||
|
||||
@@ -25,7 +39,7 @@ export function initializeConfig() {
|
||||
store = new Store<StoreType>({
|
||||
name: 'config',
|
||||
defaults: {
|
||||
set,
|
||||
set: set as SetConfig,
|
||||
shortcuts: defaultShortcuts
|
||||
}
|
||||
});
|
||||
|
||||
63
src/main/modules/deviceInfo.ts
Normal file
63
src/main/modules/deviceInfo.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { app } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { machineIdSync } from 'node-machine-id';
|
||||
import os from 'os';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
/**
|
||||
* 获取设备唯一标识符
|
||||
* 优先使用存储的ID,如果没有则获取机器ID并存储
|
||||
*/
|
||||
export function getDeviceId(): string {
|
||||
let deviceId = store.get('deviceId') as string | undefined;
|
||||
|
||||
if (!deviceId) {
|
||||
try {
|
||||
// 使用node-machine-id获取设备唯一标识
|
||||
deviceId = machineIdSync(true);
|
||||
} catch (error) {
|
||||
console.error('获取机器ID失败:', error);
|
||||
// 如果获取失败,使用主机名和MAC地址组合作为备选方案
|
||||
const networkInterfaces = os.networkInterfaces();
|
||||
let macAddress = '';
|
||||
|
||||
// 尝试获取第一个非内部网络接口的MAC地址
|
||||
Object.values(networkInterfaces).forEach((interfaces) => {
|
||||
if (interfaces) {
|
||||
interfaces.forEach((iface) => {
|
||||
if (!iface.internal && !macAddress && iface.mac !== '00:00:00:00:00:00') {
|
||||
macAddress = iface.mac;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
deviceId = `${os.hostname()}-${macAddress}`.replace(/:/g, '');
|
||||
}
|
||||
|
||||
// 存储设备ID
|
||||
if (deviceId) {
|
||||
store.set('deviceId', deviceId);
|
||||
} else {
|
||||
// 如果所有方法都失败,使用随机ID
|
||||
deviceId = Math.random().toString(36).substring(2, 15);
|
||||
store.set('deviceId', deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统信息
|
||||
*/
|
||||
export function getSystemInfo() {
|
||||
return {
|
||||
osType: os.type(),
|
||||
osVersion: os.release(),
|
||||
osArch: os.arch(),
|
||||
platform: process.platform,
|
||||
appVersion: app.getVersion()
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import { app, dialog, ipcMain, protocol, shell } from 'electron';
|
||||
import { app, dialog, ipcMain, Notification, protocol, shell } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as NodeID3 from 'node-id3';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getStore } from './config';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 3;
|
||||
const downloadQueue: { url: string; filename: string; songInfo: any; type?: string }[] = [];
|
||||
let activeDownloads = 0;
|
||||
@@ -60,13 +65,22 @@ export function initializeFileManager() {
|
||||
// 通用的打开目录处理
|
||||
ipcMain.on('open-directory', (_, filePath) => {
|
||||
try {
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
shell.openPath(filePath);
|
||||
// 验证文件路径
|
||||
if (!filePath) {
|
||||
console.error('无效的文件路径: 路径为空');
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一处理路径分隔符
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
|
||||
if (fs.statSync(normalizedPath).isDirectory()) {
|
||||
shell.openPath(normalizedPath);
|
||||
} else {
|
||||
shell.showItemInFolder(filePath);
|
||||
shell.showItemInFolder(normalizedPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening path:', error);
|
||||
console.error('打开路径失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,20 +122,37 @@ export function initializeFileManager() {
|
||||
});
|
||||
|
||||
// 获取已下载音乐列表
|
||||
ipcMain.handle('get-downloaded-music', () => {
|
||||
ipcMain.handle('get-downloaded-music', async () => {
|
||||
try {
|
||||
const store = new Store();
|
||||
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
|
||||
|
||||
// 过滤出实际存在的文件
|
||||
const validSongs = Object.entries(songInfos)
|
||||
.filter(([path]) => fs.existsSync(path))
|
||||
.map(([_, info]) => info)
|
||||
// 异步处理文件存在性检查
|
||||
const entriesArray = Object.entries(songInfos);
|
||||
const validEntriesPromises = await Promise.all(
|
||||
entriesArray.map(async ([path, info]) => {
|
||||
try {
|
||||
const exists = await fs.promises.access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return exists ? info : null;
|
||||
} catch (error) {
|
||||
console.error('Error checking file existence:', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 过滤有效的歌曲并排序
|
||||
const validSongs = validEntriesPromises
|
||||
.filter(song => song !== null)
|
||||
.sort((a, b) => (b.downloadTime || 0) - (a.downloadTime || 0));
|
||||
|
||||
// 更新存储,移除不存在的文件记录
|
||||
const newSongInfos = validSongs.reduce((acc, song) => {
|
||||
acc[song.path] = song;
|
||||
if (song && song.path) {
|
||||
acc[song.path] = song;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
store.set('downloadedSongs', newSongInfos);
|
||||
@@ -161,6 +192,13 @@ export function initializeFileManager() {
|
||||
downloadStore.set('history', []);
|
||||
});
|
||||
|
||||
// 添加清除已下载音乐记录的处理函数
|
||||
ipcMain.handle('clear-downloaded-music', () => {
|
||||
const store = new Store();
|
||||
store.set('downloadedSongs', {});
|
||||
return true;
|
||||
});
|
||||
|
||||
// 添加清除音频缓存的处理函数
|
||||
ipcMain.on('clear-audio-cache', () => {
|
||||
audioCacheStore.set('cache', {});
|
||||
@@ -249,7 +287,18 @@ async function processDownloadQueue(event: Electron.IpcMainEvent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载音乐功能
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
function sanitizeFilename(filename: string): string {
|
||||
// 替换 Windows 和 Unix 系统中的非法字符
|
||||
return filename
|
||||
.replace(/[<>:"/\\|?*]/g, '_') // 替换特殊字符为下划线
|
||||
.replace(/\s+/g, ' ') // 将多个空格替换为单个空格
|
||||
.trim(); // 移除首尾空格
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载音乐和歌词
|
||||
*/
|
||||
async function downloadMusic(
|
||||
event: Electron.IpcMainEvent,
|
||||
@@ -264,12 +313,18 @@ async function downloadMusic(
|
||||
let writer: fs.WriteStream | null = null;
|
||||
|
||||
try {
|
||||
const store = new Store();
|
||||
const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads');
|
||||
// 使用配置Store来获取设置
|
||||
const configStore = getStore();
|
||||
const downloadPath =
|
||||
(configStore.get('set.downloadPath') as string) || app.getPath('downloads');
|
||||
const apiPort = configStore.get('set.musicApiPort') || 30488;
|
||||
|
||||
// 清理文件名中的非法字符
|
||||
const sanitizedFilename = sanitizeFilename(filename);
|
||||
|
||||
// 从URL中获取文件扩展名,如果没有则使用传入的type或默认mp3
|
||||
const urlExt = type ? `.${type}` : '.mp3';
|
||||
const filePath = path.join(downloadPath, `${filename}${urlExt}`);
|
||||
const filePath = path.join(downloadPath, `${sanitizedFilename}${urlExt}`);
|
||||
|
||||
// 检查文件是否已存在,如果存在则添加序号
|
||||
finalFilePath = filePath;
|
||||
@@ -290,7 +345,9 @@ async function downloadMusic(
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 30000 // 30秒超时
|
||||
timeout: 30000, // 30秒超时
|
||||
httpAgent: new http.Agent({ keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true })
|
||||
});
|
||||
|
||||
writer = fs.createWriteStream(finalFilePath);
|
||||
@@ -328,9 +385,121 @@ async function downloadMusic(
|
||||
throw new Error('文件下载不完整');
|
||||
}
|
||||
|
||||
// 下载歌词
|
||||
let lyricData = null;
|
||||
let lyricsContent = '';
|
||||
try {
|
||||
if (songInfo?.id) {
|
||||
// 下载歌词,使用配置的端口
|
||||
const lyricsResponse = await axios.get(
|
||||
`http://localhost:${apiPort}/lyric?id=${songInfo.id}`
|
||||
);
|
||||
if (lyricsResponse.data && (lyricsResponse.data.lrc || lyricsResponse.data.tlyric)) {
|
||||
lyricData = lyricsResponse.data;
|
||||
|
||||
// 处理歌词内容
|
||||
if (lyricsResponse.data.lrc && lyricsResponse.data.lrc.lyric) {
|
||||
lyricsContent = lyricsResponse.data.lrc.lyric;
|
||||
|
||||
// 如果有翻译歌词,合并到主歌词中
|
||||
if (lyricsResponse.data.tlyric && lyricsResponse.data.tlyric.lyric) {
|
||||
// 解析原歌词和翻译
|
||||
const originalLyrics = parseLyrics(lyricsResponse.data.lrc.lyric);
|
||||
const translatedLyrics = parseLyrics(lyricsResponse.data.tlyric.lyric);
|
||||
|
||||
// 合并歌词
|
||||
const mergedLyrics = mergeLyrics(originalLyrics, translatedLyrics);
|
||||
lyricsContent = mergedLyrics;
|
||||
}
|
||||
}
|
||||
|
||||
// 不再单独写入歌词文件,只保存在ID3标签中
|
||||
console.log('歌词已准备好,将写入ID3标签');
|
||||
}
|
||||
}
|
||||
} catch (lyricError) {
|
||||
console.error('下载歌词失败:', lyricError);
|
||||
// 继续处理,不影响音乐下载
|
||||
}
|
||||
|
||||
// 下载封面
|
||||
let coverImageBuffer: Buffer | null = null;
|
||||
try {
|
||||
if (songInfo?.picUrl || songInfo?.al?.picUrl) {
|
||||
const picUrl = songInfo.picUrl || songInfo.al?.picUrl;
|
||||
if (picUrl && picUrl !== '/images/default_cover.png') {
|
||||
const coverResponse = await axios({
|
||||
url: picUrl.replace('http://', 'https://'),
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// 获取封面图片的buffer
|
||||
coverImageBuffer = Buffer.from(coverResponse.data);
|
||||
|
||||
// 不再单独保存封面文件,只保存在ID3标签中
|
||||
console.log('封面已准备好,将写入ID3标签');
|
||||
}
|
||||
}
|
||||
} catch (coverError) {
|
||||
console.error('下载封面失败:', coverError);
|
||||
// 继续处理,不影响音乐下载
|
||||
}
|
||||
|
||||
// 在写入ID3标签前,先移除可能存在的旧标签
|
||||
try {
|
||||
NodeID3.removeTags(finalFilePath);
|
||||
} catch (err) {
|
||||
console.error('Error removing existing ID3 tags:', err);
|
||||
}
|
||||
|
||||
// 强化ID3标签的写入格式
|
||||
|
||||
const artistNames =
|
||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家';
|
||||
const tags = {
|
||||
title: filename,
|
||||
artist: artistNames,
|
||||
TPE1: artistNames,
|
||||
TPE2: artistNames,
|
||||
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
|
||||
APIC: {
|
||||
// 专辑封面
|
||||
imageBuffer: coverImageBuffer,
|
||||
type: {
|
||||
id: 3,
|
||||
name: 'front cover'
|
||||
},
|
||||
description: 'Album cover',
|
||||
mime: 'image/jpeg'
|
||||
},
|
||||
USLT: {
|
||||
// 歌词
|
||||
language: 'chi',
|
||||
description: 'Lyrics',
|
||||
text: lyricsContent || ''
|
||||
},
|
||||
trackNumber: songInfo?.no || undefined,
|
||||
year: songInfo?.publishTime
|
||||
? new Date(songInfo.publishTime).getFullYear().toString()
|
||||
: undefined
|
||||
};
|
||||
|
||||
try {
|
||||
const success = NodeID3.write(tags, finalFilePath);
|
||||
if (!success) {
|
||||
console.error('Failed to write ID3 tags');
|
||||
} else {
|
||||
console.log('ID3 tags written successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error writing ID3 tags:', err);
|
||||
}
|
||||
|
||||
// 保存下载信息
|
||||
try {
|
||||
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
|
||||
const songInfos = configStore.get('downloadedSongs', {}) as Record<string, any>;
|
||||
const defaultInfo = {
|
||||
name: filename,
|
||||
ar: [{ name: '本地音乐' }],
|
||||
@@ -341,24 +510,48 @@ async function downloadMusic(
|
||||
id: songInfo?.id || 0,
|
||||
name: songInfo?.name || filename,
|
||||
filename,
|
||||
picUrl: songInfo?.picUrl || defaultInfo.picUrl,
|
||||
picUrl: songInfo?.picUrl || songInfo?.al?.picUrl || defaultInfo.picUrl,
|
||||
ar: songInfo?.ar || defaultInfo.ar,
|
||||
al: songInfo?.al || {
|
||||
picUrl: songInfo?.picUrl || defaultInfo.picUrl,
|
||||
name: songInfo?.name || filename
|
||||
},
|
||||
size: totalSize,
|
||||
path: finalFilePath,
|
||||
downloadTime: Date.now(),
|
||||
al: songInfo?.al || { picUrl: songInfo?.picUrl || defaultInfo.picUrl },
|
||||
type: type || 'mp3'
|
||||
type: type || 'mp3',
|
||||
lyric: lyricData
|
||||
};
|
||||
|
||||
// 保存到下载记录
|
||||
songInfos[finalFilePath] = newSongInfo;
|
||||
store.set('downloadedSongs', songInfos);
|
||||
configStore.set('downloadedSongs', songInfos);
|
||||
|
||||
// 添加到下载历史
|
||||
const history = downloadStore.get('history', []) as any[];
|
||||
history.unshift(newSongInfo);
|
||||
downloadStore.set('history', history);
|
||||
|
||||
// 发送桌面通知
|
||||
try {
|
||||
const artistNames =
|
||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/') ||
|
||||
'未知艺术家';
|
||||
const notification = new Notification({
|
||||
title: '下载完成',
|
||||
body: `${songInfo?.name || filename} - ${artistNames}`,
|
||||
silent: false
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
shell.showItemInFolder(finalFilePath);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
} catch (notifyError) {
|
||||
console.error('发送通知失败:', notifyError);
|
||||
}
|
||||
|
||||
// 发送下载完成事件
|
||||
event.reply('music-download-complete', {
|
||||
success: true,
|
||||
@@ -393,3 +586,56 @@ async function downloadMusic(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数 - 解析歌词文本成时间戳和内容的映射
|
||||
function parseLyrics(lyricsText: string): Map<string, string> {
|
||||
const lyricMap = new Map<string, string>();
|
||||
const lines = lyricsText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// 匹配时间标签,形如 [00:00.000]
|
||||
const timeTagMatches = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g);
|
||||
if (!timeTagMatches) continue;
|
||||
|
||||
// 提取歌词内容(去除时间标签)
|
||||
const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim();
|
||||
if (!content) continue;
|
||||
|
||||
// 将每个时间标签与歌词内容关联
|
||||
for (const timeTag of timeTagMatches) {
|
||||
lyricMap.set(timeTag, content);
|
||||
}
|
||||
}
|
||||
|
||||
return lyricMap;
|
||||
}
|
||||
|
||||
// 辅助函数 - 合并原文歌词和翻译歌词
|
||||
function mergeLyrics(
|
||||
originalLyrics: Map<string, string>,
|
||||
translatedLyrics: Map<string, string>
|
||||
): string {
|
||||
const mergedLines: string[] = [];
|
||||
|
||||
// 对每个时间戳,组合原始歌词和翻译
|
||||
for (const [timeTag, originalContent] of originalLyrics.entries()) {
|
||||
const translatedContent = translatedLyrics.get(timeTag);
|
||||
|
||||
// 添加原始歌词行
|
||||
mergedLines.push(`${timeTag}${originalContent}`);
|
||||
|
||||
// 如果有翻译,添加翻译行(时间戳相同,这样可以和原歌词同步显示)
|
||||
if (translatedContent) {
|
||||
mergedLines.push(`${timeTag}${translatedContent}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间顺序排序
|
||||
mergedLines.sort((a, b) => {
|
||||
const timeA = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
|
||||
const timeB = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
|
||||
return timeA.localeCompare(timeB);
|
||||
});
|
||||
|
||||
return mergedLines.join('\n');
|
||||
}
|
||||
|
||||
42
src/main/modules/fonts.ts
Normal file
42
src/main/modules/fonts.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { getFonts } from 'font-list';
|
||||
|
||||
/**
|
||||
* 清理字体名称
|
||||
* @param fontName 原始字体名称
|
||||
* @returns 清理后的字体名称
|
||||
*/
|
||||
function cleanFontName(fontName: string): string {
|
||||
return fontName
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '') // 移除首尾的引号
|
||||
.replace(/\s+/g, ' '); // 将多个空格替换为单个空格
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统字体列表
|
||||
*/
|
||||
async function getSystemFonts(): Promise<string[]> {
|
||||
try {
|
||||
// 使用 font-list 获取系统字体
|
||||
const fonts = await getFonts();
|
||||
// 清理字体名称并去重
|
||||
const cleanedFonts = [...new Set(fonts.map(cleanFontName))];
|
||||
// 添加系统默认字体并排序
|
||||
return ['system-ui', ...cleanedFonts].sort();
|
||||
} catch (error) {
|
||||
console.error('获取系统字体失败:', error);
|
||||
// 如果获取失败,至少返回系统默认字体
|
||||
return ['system-ui'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化字体管理模块
|
||||
*/
|
||||
export function initializeFonts() {
|
||||
// 添加获取系统字体的 IPC 处理
|
||||
ipcMain.handle('get-system-fonts', async () => {
|
||||
return await getSystemFonts();
|
||||
});
|
||||
}
|
||||
@@ -7,66 +7,93 @@ ipcMain.on('get-platform', (event) => {
|
||||
event.returnValue = process.platform;
|
||||
});
|
||||
|
||||
// 定义快捷键配置接口
|
||||
export interface ShortcutConfig {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
scope: 'global' | 'app';
|
||||
}
|
||||
|
||||
export interface ShortcutsConfig {
|
||||
[key: string]: ShortcutConfig;
|
||||
}
|
||||
|
||||
// 定义默认快捷键
|
||||
export const defaultShortcuts = {
|
||||
togglePlay: 'CommandOrControl+Alt+P',
|
||||
prevPlay: 'CommandOrControl+Alt+Left',
|
||||
nextPlay: 'CommandOrControl+Alt+Right',
|
||||
volumeUp: 'CommandOrControl+Alt+Up',
|
||||
volumeDown: 'CommandOrControl+Alt+Down',
|
||||
toggleFavorite: 'CommandOrControl+Alt+L',
|
||||
toggleWindow: 'CommandOrControl+Alt+Shift+M'
|
||||
export const defaultShortcuts: ShortcutsConfig = {
|
||||
togglePlay: { key: 'CommandOrControl+Alt+P', enabled: true, scope: 'global' },
|
||||
prevPlay: { key: 'Alt+Left', enabled: true, scope: 'global' },
|
||||
nextPlay: { key: 'Alt+Right', enabled: true, scope: 'global' },
|
||||
volumeUp: { key: 'Alt+Up', enabled: true, scope: 'app' },
|
||||
volumeDown: { key: 'Alt+Down', enabled: true, scope: 'app' },
|
||||
toggleFavorite: { key: 'CommandOrControl+Alt+L', enabled: true, scope: 'app' },
|
||||
toggleWindow: { key: 'CommandOrControl+Alt+Shift+M', enabled: true, scope: 'global' }
|
||||
};
|
||||
|
||||
let mainWindowRef: Electron.BrowserWindow | null = null;
|
||||
|
||||
// 注册快捷键
|
||||
export function registerShortcuts(mainWindow: Electron.BrowserWindow) {
|
||||
export function registerShortcuts(
|
||||
mainWindow: Electron.BrowserWindow,
|
||||
shortcutsConfig?: ShortcutsConfig
|
||||
) {
|
||||
mainWindowRef = mainWindow;
|
||||
const store = getStore();
|
||||
const shortcuts = store.get('shortcuts');
|
||||
const shortcuts =
|
||||
shortcutsConfig || (store.get('shortcuts') as ShortcutsConfig) || defaultShortcuts;
|
||||
|
||||
// 注销所有已注册的快捷键
|
||||
globalShortcut.unregisterAll();
|
||||
|
||||
// 显示/隐藏主窗口
|
||||
globalShortcut.register(shortcuts.toggleWindow, () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
// 对旧格式数据进行兼容处理
|
||||
if (shortcuts && typeof shortcuts.togglePlay === 'string') {
|
||||
// 将 shortcuts 强制转换为 unknown,再转为 Record<string, string>
|
||||
const oldShortcuts = { ...shortcuts } as unknown as Record<string, string>;
|
||||
const newShortcuts: ShortcutsConfig = {};
|
||||
|
||||
Object.entries(oldShortcuts).forEach(([key, value]) => {
|
||||
newShortcuts[key] = {
|
||||
key: value,
|
||||
enabled: true,
|
||||
scope: ['volumeUp', 'volumeDown', 'toggleFavorite'].includes(key) ? 'app' : 'global'
|
||||
};
|
||||
});
|
||||
|
||||
store.set('shortcuts', newShortcuts);
|
||||
registerShortcuts(mainWindow, newShortcuts);
|
||||
return;
|
||||
}
|
||||
|
||||
// 注册全局快捷键
|
||||
Object.entries(shortcuts).forEach(([action, config]) => {
|
||||
const { key, enabled, scope } = config as ShortcutConfig;
|
||||
|
||||
// 只注册启用且作用域为全局的快捷键
|
||||
if (!enabled || scope !== 'global') return;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'toggleWindow':
|
||||
globalShortcut.register(key, () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
globalShortcut.register(key, () => {
|
||||
mainWindow.webContents.send('global-shortcut', action);
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`注册快捷键 ${key} 失败:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 播放/暂停
|
||||
globalShortcut.register(shortcuts.togglePlay, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'togglePlay');
|
||||
});
|
||||
|
||||
// 上一首
|
||||
globalShortcut.register(shortcuts.prevPlay, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'prevPlay');
|
||||
});
|
||||
|
||||
// 下一首
|
||||
globalShortcut.register(shortcuts.nextPlay, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'nextPlay');
|
||||
});
|
||||
|
||||
// 音量增加
|
||||
globalShortcut.register(shortcuts.volumeUp, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'volumeUp');
|
||||
});
|
||||
|
||||
// 音量减少
|
||||
globalShortcut.register(shortcuts.volumeDown, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'volumeDown');
|
||||
});
|
||||
|
||||
// 收藏当前歌曲
|
||||
globalShortcut.register(shortcuts.toggleFavorite, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'toggleFavorite');
|
||||
});
|
||||
// 通知渲染进程更新应用内快捷键
|
||||
mainWindow.webContents.send('update-app-shortcuts', shortcuts);
|
||||
}
|
||||
|
||||
// 初始化快捷键
|
||||
@@ -85,4 +112,11 @@ export function initializeShortcuts(mainWindow: Electron.BrowserWindow) {
|
||||
registerShortcuts(mainWindowRef);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听快捷键更新事件
|
||||
ipcMain.on('update-shortcuts', (_, shortcutsConfig: ShortcutsConfig) => {
|
||||
if (mainWindowRef) {
|
||||
registerShortcuts(mainWindowRef, shortcutsConfig);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
122
src/main/modules/statsService.ts
Normal file
122
src/main/modules/statsService.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import axios from 'axios';
|
||||
import { app } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
import { getDeviceId, getSystemInfo } from './deviceInfo';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
// 统计服务配置
|
||||
const STATS_API_URL = 'http://donate.alger.fun/state/api/stats';
|
||||
|
||||
/**
|
||||
* 记录应用安装/启动
|
||||
*/
|
||||
export async function recordInstallation(): Promise<void> {
|
||||
try {
|
||||
const deviceId = getDeviceId();
|
||||
const systemInfo = getSystemInfo();
|
||||
|
||||
// 发送请求到统计服务器
|
||||
await axios.post(`${STATS_API_URL}/installation`, {
|
||||
deviceId,
|
||||
osType: systemInfo.osType,
|
||||
osVersion: systemInfo.osVersion,
|
||||
appVersion: systemInfo.appVersion
|
||||
});
|
||||
|
||||
console.log('应用启动统计已记录');
|
||||
|
||||
// 记录最后一次启动时间
|
||||
store.set('lastStartTime', new Date().toISOString());
|
||||
} catch (error) {
|
||||
console.error('记录应用启动统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 IPC 处理程序以接收渲染进程的统计请求
|
||||
* @param ipcMain Electron IPC主对象
|
||||
*/
|
||||
export function setupStatsHandlers(ipcMain: Electron.IpcMain): void {
|
||||
// 处理页面访问统计
|
||||
ipcMain.handle('record-visit', async (_, page: string, userId?: string) => {
|
||||
try {
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
await axios.post(`${STATS_API_URL}/visit`, {
|
||||
deviceId,
|
||||
userId,
|
||||
page
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('记录页面访问统计失败:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 处理播放统计
|
||||
ipcMain.handle(
|
||||
'record-play',
|
||||
async (
|
||||
_,
|
||||
songData: {
|
||||
userId: string | null;
|
||||
songId: string | number;
|
||||
songName: string;
|
||||
artistName: string;
|
||||
duration?: number;
|
||||
completedPlay?: boolean;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
const { songId, songName, artistName, duration = 0, completedPlay = false } = songData;
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
await axios.post(`${STATS_API_URL}/play`, {
|
||||
deviceId,
|
||||
userId: songData.userId,
|
||||
songId: songId.toString(),
|
||||
songName,
|
||||
artistName,
|
||||
duration,
|
||||
completedPlay
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('记录播放统计失败:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 处理获取统计摘要
|
||||
ipcMain.handle('get-stats-summary', async () => {
|
||||
try {
|
||||
const response = await axios.get(`${STATS_API_URL}/summary`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取统计摘要失败:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时初始化统计服务
|
||||
*/
|
||||
export function initializeStats(): void {
|
||||
// 记录应用启动统计
|
||||
recordInstallation().catch((error) => {
|
||||
console.error('初始化统计服务失败:', error);
|
||||
});
|
||||
|
||||
// 注册应用退出时的回调
|
||||
app.on('will-quit', () => {
|
||||
// 可以在这里添加应用退出时的统计逻辑
|
||||
console.log('应用退出');
|
||||
});
|
||||
}
|
||||
@@ -1,45 +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 type { Language } 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;
|
||||
// 为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 }[] = [
|
||||
{ label: '简体中文', value: 'zh-CN' },
|
||||
{ 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(mainWindow: BrowserWindow) {
|
||||
if (!tray) return;
|
||||
|
||||
// 如果是macOS,设置TouchBar
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS 上使用直接的控制按钮
|
||||
const menu = new Menu();
|
||||
|
||||
// 当前播放的音乐信息
|
||||
if (currentSong) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: getSongTitle(currentSong),
|
||||
enabled: false,
|
||||
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,
|
||||
type: 'radio',
|
||||
checked: i18n.global.locale === value,
|
||||
click: () => {
|
||||
i18n.global.locale = value;
|
||||
updateTrayMenu(mainWindow);
|
||||
mainWindow.webContents.send('language-changed', value);
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
menu.append(
|
||||
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) {
|
||||
// 根据平台选择合适的图标
|
||||
const iconSize = process.platform === 'darwin' ? 18 : 16;
|
||||
const iconFile = process.platform === 'darwin' ? 'icon_16x16.png' : 'icon_16x16.png';
|
||||
|
||||
const trayIcon = nativeImage
|
||||
.createFromPath(join(iconPath, 'icon_16x16.png'))
|
||||
.resize({ width: 16, height: 16 });
|
||||
.createFromPath(join(iconPath, iconFile))
|
||||
.resize({ width: iconSize, height: iconSize });
|
||||
|
||||
tray = new Tray(trayIcon);
|
||||
|
||||
// 创建一个上下文菜单
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示',
|
||||
click: () => {
|
||||
// 设置托盘图标的提示文字
|
||||
tray.setToolTip('Alger Music Player');
|
||||
|
||||
// 初始化菜单
|
||||
updateTrayMenu(mainWindow);
|
||||
|
||||
// 初始化状态栏控制按钮 (macOS)
|
||||
initializeStatusBarTray(mainWindow);
|
||||
|
||||
// 在 macOS 上,点击图标时显示菜单
|
||||
if (process.platform === 'darwin') {
|
||||
tray.on('click', () => {
|
||||
if (tray) {
|
||||
tray.popUpContextMenu();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 在其他平台上,点击图标时切换窗口显示状态
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
mainWindow.destroy();
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// 设置系统托盘图标的上下文菜单
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
101
src/main/modules/update.ts
Normal file
101
src/main/modules/update.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import axios from 'axios';
|
||||
import { spawn } from 'child_process';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export function setupUpdateHandlers(_mainWindow: BrowserWindow) {
|
||||
ipcMain.on('start-download', async (event, url: string) => {
|
||||
try {
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
onDownloadProgress: (progressEvent: { loaded: number; total?: number }) => {
|
||||
if (!progressEvent.total) return;
|
||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||
const downloaded = (progressEvent.loaded / 1024 / 1024).toFixed(2);
|
||||
const total = (progressEvent.total / 1024 / 1024).toFixed(2);
|
||||
event.sender.send('download-progress', percent, `已下载 ${downloaded}MB / ${total}MB`);
|
||||
}
|
||||
});
|
||||
|
||||
const fileName = url.split('/').pop() || 'update.exe';
|
||||
const downloadPath = path.join(app.getPath('downloads'), fileName);
|
||||
|
||||
// 创建写入流
|
||||
const writer = fs.createWriteStream(downloadPath);
|
||||
|
||||
// 将响应流写入文件
|
||||
response.data.pipe(writer);
|
||||
|
||||
// 处理写入完成
|
||||
writer.on('finish', () => {
|
||||
event.sender.send('download-complete', true, downloadPath);
|
||||
});
|
||||
|
||||
// 处理写入错误
|
||||
writer.on('error', (error) => {
|
||||
console.error('Write file error:', error);
|
||||
event.sender.send('download-complete', false, '');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
event.sender.send('download-complete', false, '');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('install-update', (_event, filePath: string) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error('Installation file not found:', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const { platform } = process;
|
||||
|
||||
// 先启动安装程序,再退出应用
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
// 使用spawn替代exec,并使用detached选项确保子进程独立运行
|
||||
const child = spawn(filePath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
child.unref();
|
||||
} else if (platform === 'darwin') {
|
||||
// 挂载 DMG 文件
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
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 { join } from 'path';
|
||||
|
||||
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', () => {
|
||||
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: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
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) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
|
||||
// 注册快捷键 打开开发者工具
|
||||
globalShortcut.register('CommandOrControl+Shift+I', () => {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
});
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
@@ -5,16 +5,22 @@ import server from 'netease-cloud-music-api-alger/server';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { unblockMusic } from './unblockMusic';
|
||||
import { unblockMusic, type Platform } from './unblockMusic';
|
||||
|
||||
const store = new Store();
|
||||
if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
|
||||
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
|
||||
}
|
||||
|
||||
// 处理解锁音乐请求
|
||||
ipcMain.handle('unblock-music', async (_, id, data) => {
|
||||
return unblockMusic(id, data);
|
||||
// 设置音乐解析的处理程序
|
||||
ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {
|
||||
try {
|
||||
const result = await unblockMusic(id, songData, 1, enabledSources as Platform[]);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('音乐解析失败:', error);
|
||||
return { error: (error as Error).message || '未知错误' };
|
||||
}
|
||||
});
|
||||
|
||||
async function startMusicApi(): Promise<void> {
|
||||
|
||||
@@ -14,5 +14,14 @@
|
||||
"authorUrl": "https://github.com/algerkong",
|
||||
"musicApiPort": 30488,
|
||||
"closeAction": "ask",
|
||||
"musicQuality": "higher"
|
||||
"musicQuality": "higher",
|
||||
"fontFamily": "system-ui",
|
||||
"fontScope": "global",
|
||||
"autoPlay": false,
|
||||
"downloadPath": "",
|
||||
"language": "zh-CN",
|
||||
"alwaysShowDownloadButton": false,
|
||||
"unlimitedDownload": false,
|
||||
"enableMusicUnblock": true,
|
||||
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili", "youtube"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import match from '@unblockneteasemusic/server';
|
||||
import Store from 'electron-store';
|
||||
|
||||
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'youtube';
|
||||
|
||||
@@ -7,6 +6,8 @@ interface SongData {
|
||||
name: string;
|
||||
artists: Array<{ name: string }>;
|
||||
album?: { name: string };
|
||||
ar?: Array<{ name: string }>;
|
||||
al?: { name: string };
|
||||
}
|
||||
|
||||
interface ResponseData {
|
||||
@@ -28,97 +29,29 @@ interface UnblockResult {
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheData extends UnblockResult {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CacheStore {
|
||||
[key: string]: CacheData;
|
||||
}
|
||||
|
||||
// 初始化缓存存储
|
||||
const store = new Store<CacheStore>({
|
||||
name: 'unblock-cache'
|
||||
});
|
||||
|
||||
// 缓存过期时间(24小时)
|
||||
const CACHE_EXPIRY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
* @param cacheData 缓存数据
|
||||
* @returns boolean
|
||||
*/
|
||||
const isCacheValid = (cacheData: CacheData | null): boolean => {
|
||||
if (!cacheData) return false;
|
||||
const now = Date.now();
|
||||
return now - cacheData.timestamp < CACHE_EXPIRY;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从缓存中获取数据
|
||||
* @param id 歌曲ID
|
||||
* @returns CacheData | null
|
||||
*/
|
||||
const getFromCache = (id: string | number): CacheData | null => {
|
||||
const cacheData = store.get(String(id)) as CacheData | null;
|
||||
if (isCacheValid(cacheData)) {
|
||||
return cacheData;
|
||||
}
|
||||
// 清除过期缓存
|
||||
store.delete(String(id));
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将数据存入缓存
|
||||
* @param id 歌曲ID
|
||||
* @param data 解析结果
|
||||
*/
|
||||
const saveToCache = (id: string | number, data: UnblockResult): void => {
|
||||
const cacheData: CacheData = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
store.set(String(id), cacheData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
const cleanExpiredCache = (): void => {
|
||||
const allData = store.store;
|
||||
Object.entries(allData).forEach(([id, data]) => {
|
||||
if (!isCacheValid(data)) {
|
||||
store.delete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
// 所有可用平台
|
||||
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'kuwo', 'bilibili', 'youtube'];
|
||||
|
||||
/**
|
||||
* 音乐解析函数
|
||||
* @param id 歌曲ID
|
||||
* @param songData 歌曲信息
|
||||
* @param retryCount 重试次数
|
||||
* @param enabledPlatforms 启用的平台列表,默认为所有平台
|
||||
* @returns Promise<UnblockResult>
|
||||
*/
|
||||
const unblockMusic = async (
|
||||
id: number | string,
|
||||
songData: SongData,
|
||||
retryCount = 3
|
||||
retryCount = 1,
|
||||
enabledPlatforms?: Platform[]
|
||||
): Promise<UnblockResult> => {
|
||||
// 检查缓存
|
||||
const cachedData = getFromCache(id);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// 所有可用平台
|
||||
const platforms: Platform[] = ['migu', 'kugou', 'pyncmd', 'joox', 'kuwo', 'bilibili', 'youtube'];
|
||||
|
||||
const platforms = enabledPlatforms || ALL_PLATFORMS;
|
||||
songData.album = songData.album || songData.al;
|
||||
songData.artists = songData.artists || songData.ar;
|
||||
const retry = async (attempt: number): Promise<UnblockResult> => {
|
||||
try {
|
||||
const data = await match(parseInt(String(id), 10), platforms, songData);
|
||||
const data = await match(parseInt(String(id), 10), platforms,songData);
|
||||
const result: UnblockResult = {
|
||||
data: {
|
||||
data,
|
||||
@@ -128,14 +61,11 @@ const unblockMusic = async (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 保存到缓存
|
||||
saveToCache(id, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (attempt < retryCount) {
|
||||
// 延迟重试,每次重试增加延迟时间
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
|
||||
return retry(attempt + 1);
|
||||
}
|
||||
|
||||
@@ -149,14 +79,4 @@ const unblockMusic = async (
|
||||
return retry(1);
|
||||
};
|
||||
|
||||
// 定期清理过期缓存(每小时执行一次)
|
||||
setInterval(cleanExpiredCache, 60 * 60 * 1000);
|
||||
|
||||
export {
|
||||
cleanExpiredCache, // 导出清理缓存函数,以便手动调用
|
||||
type Platform,
|
||||
type ResponseData,
|
||||
type SongData,
|
||||
unblockMusic,
|
||||
type UnblockResult
|
||||
};
|
||||
export { type Platform, type ResponseData, type SongData, unblockMusic, type UnblockResult };
|
||||
|
||||
46
src/preload/index.d.ts
vendored
46
src/preload/index.d.ts
vendored
@@ -1,20 +1,42 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
interface API {
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
dragStart: (data: any) => void;
|
||||
miniTray: () => void;
|
||||
miniWindow: () => void;
|
||||
restore: () => void;
|
||||
restart: () => void;
|
||||
resizeWindow: (width: number, height: number) => void;
|
||||
resizeMiniWindow: (showPlaylist: boolean) => void;
|
||||
openLyric: () => void;
|
||||
sendLyric: (data: any) => void;
|
||||
sendSong: (data: any) => void;
|
||||
unblockMusic: (id: number, data: any, enabledSources?: string[]) => Promise<any>;
|
||||
onLyricWindowClosed: (callback: () => void) => void;
|
||||
startDownload: (url: string) => void;
|
||||
onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
|
||||
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
|
||||
onLanguageChanged: (callback: (locale: string) => void) => void;
|
||||
removeDownloadListeners: () => void;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
}
|
||||
|
||||
// 自定义IPC渲染进程通信接口
|
||||
interface IpcRenderer {
|
||||
send: (channel: string, ...args: any[]) => void;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
on: (channel: string, listener: (...args: any[]) => void) => () => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
api: {
|
||||
sendLyric: (data: string) => void;
|
||||
openLyric: () => void;
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
dragStart: (data: string) => void;
|
||||
miniTray: () => void;
|
||||
restart: () => void;
|
||||
unblockMusic: (id: number, data: any) => Promise<any>;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
};
|
||||
api: API;
|
||||
ipcRenderer: IpcRenderer;
|
||||
$message: any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,78 @@ const api = {
|
||||
close: () => ipcRenderer.send('close-window'),
|
||||
dragStart: (data) => ipcRenderer.send('drag-start', data),
|
||||
miniTray: () => ipcRenderer.send('mini-tray'),
|
||||
miniWindow: () => ipcRenderer.send('mini-window'),
|
||||
restore: () => ipcRenderer.send('restore-window'),
|
||||
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'),
|
||||
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
|
||||
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id),
|
||||
sendSong: (data) => ipcRenderer.send('update-current-song', data),
|
||||
unblockMusic: (id, data, enabledSources) => ipcRenderer.invoke('unblock-music', id, data, enabledSources),
|
||||
// 歌词窗口关闭事件
|
||||
onLyricWindowClosed: (callback: () => void) => {
|
||||
ipcRenderer.on('lyric-window-closed', () => callback());
|
||||
},
|
||||
// 更新相关
|
||||
startDownload: (url: string) => ipcRenderer.send('start-download', url),
|
||||
onDownloadProgress: (callback: (progress: number, status: string) => void) => {
|
||||
ipcRenderer.on('download-progress', (_event, progress, status) => callback(progress, status));
|
||||
},
|
||||
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => {
|
||||
ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath));
|
||||
},
|
||||
// 语言相关
|
||||
onLanguageChanged: (callback: (locale: string) => void) => {
|
||||
ipcRenderer.on('language-changed', (_event, locale) => {
|
||||
callback(locale);
|
||||
});
|
||||
},
|
||||
removeDownloadListeners: () => {
|
||||
ipcRenderer.removeAllListeners('download-progress');
|
||||
ipcRenderer.removeAllListeners('download-complete');
|
||||
},
|
||||
// 歌词缓存相关
|
||||
invoke: (channel: string, ...args: any[]) => {
|
||||
const validChannels = ['get-cached-lyric', 'cache-lyric', 'clear-lyric-cache'];
|
||||
const validChannels = [
|
||||
'get-lyrics',
|
||||
'clear-lyrics-cache',
|
||||
'get-system-fonts',
|
||||
'get-cached-lyric',
|
||||
'cache-lyric',
|
||||
'clear-lyric-cache',
|
||||
// 统计相关
|
||||
'record-visit',
|
||||
'record-play',
|
||||
'get-stats-summary'
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
return Promise.reject(new Error(`Invalid channel: ${channel}`));
|
||||
return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`));
|
||||
}
|
||||
};
|
||||
|
||||
// 创建带类型的ipcRenderer对象,暴露给渲染进程
|
||||
const ipc = {
|
||||
// 发送消息到主进程(无返回值)
|
||||
send: (channel: string, ...args: any[]) => {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
},
|
||||
// 调用主进程方法(有返回值)
|
||||
invoke: (channel: string, ...args: any[]) => {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
// 监听主进程消息
|
||||
on: (channel: string, listener: (...args: any[]) => void) => {
|
||||
ipcRenderer.on(channel, (_, ...args) => listener(...args));
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
};
|
||||
},
|
||||
// 移除所有监听器
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,6 +90,7 @@ if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', ipc);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -37,4 +99,6 @@ if (process.contextIsolated) {
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api;
|
||||
// @ts-ignore (define in dts)
|
||||
window.ipcRenderer = ipc;
|
||||
}
|
||||
|
||||
@@ -11,27 +11,113 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { darkTheme, lightTheme } from 'naive-ui';
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, nextTick, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import homeRouter from '@/router/home';
|
||||
import store from '@/store';
|
||||
import { isElectron } from '@/utils';
|
||||
import { useMenuStore } from '@/store/modules/menu';
|
||||
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 { useAppShortcuts } from './utils/appShortcuts';
|
||||
|
||||
const { locale } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const menuStore = useMenuStore();
|
||||
const playerStore = usePlayerStore();
|
||||
const router = useRouter();
|
||||
|
||||
// 监听语言变化
|
||||
watch(
|
||||
() => settingsStore.setData.language,
|
||||
(newLanguage) => {
|
||||
if (newLanguage && newLanguage !== locale.value) {
|
||||
locale.value = newLanguage;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const theme = computed(() => {
|
||||
return store.state.theme;
|
||||
return settingsStore.theme;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('initializeSettings');
|
||||
store.dispatch('initializeTheme');
|
||||
// 监听字体变化并应用
|
||||
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) {
|
||||
store.commit(
|
||||
'setMenus',
|
||||
homeRouter.filter((item) => item.meta.isMobile)
|
||||
);
|
||||
menuStore.setMenus(homeRouter.filter((item) => item.meta.isMobile));
|
||||
}
|
||||
}
|
||||
|
||||
handleSetLanguage(settingsStore.setData.language);
|
||||
|
||||
// 监听迷你模式状态
|
||||
if (isElectron) {
|
||||
window.api.onLanguageChanged(handleSetLanguage);
|
||||
window.electron.ipcRenderer.on('mini-mode', (_, value) => {
|
||||
settingsStore.setMiniMode(value);
|
||||
if (value) {
|
||||
// 存储当前路由
|
||||
localStorage.setItem('currentRoute', router.currentRoute.value.path);
|
||||
router.push('/mini');
|
||||
} else {
|
||||
// 恢复当前路由
|
||||
const currentRoute = localStorage.getItem('currentRoute');
|
||||
if (currentRoute) {
|
||||
router.push(currentRoute);
|
||||
localStorage.removeItem('currentRoute');
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 使用应用内快捷键
|
||||
useAppShortcuts();
|
||||
|
||||
onMounted(async () => {
|
||||
if (isLyricWindow.value) {
|
||||
return;
|
||||
}
|
||||
// 先初始化播放状态
|
||||
await playerStore.initializePlayState();
|
||||
// 如果有正在播放的音乐,则初始化音频监听器
|
||||
if (playerStore.playMusic && playerStore.playMusic.id) {
|
||||
// 使用 nextTick 确保 DOM 更新后再初始化
|
||||
await nextTick();
|
||||
initAudioListeners();
|
||||
if (isElectron) {
|
||||
window.api.sendSong(cloneDeep(playerStore.playMusic));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
154
src/renderer/api/bilibili.ts
Normal file
154
src/renderer/api/bilibili.ts
Normal 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站视频详情API,bvid:', 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站视频播放地址API,bvid:', 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;
|
||||
}
|
||||
};
|
||||
20
src/renderer/api/donation.ts
Normal file
20
src/renderer/api/donation.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export interface Donor {
|
||||
id: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
date: string;
|
||||
message?: string;
|
||||
avatar?: string;
|
||||
badge: string;
|
||||
badgeColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取捐赠列表
|
||||
*/
|
||||
export const getDonationList = async (): Promise<Donor[]> => {
|
||||
const { data } = await axios.get('http://donate.alger.fun/api/donations');
|
||||
return data;
|
||||
};
|
||||
189
src/renderer/api/gdmusic.ts
Normal file
189
src/renderer/api/gdmusic.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import axios from 'axios';
|
||||
import type { MusicSourceType } from '@/type/music';
|
||||
|
||||
/**
|
||||
* GD音乐台解析服务
|
||||
*/
|
||||
export interface GDMusicResponse {
|
||||
url: string;
|
||||
br: number;
|
||||
size: number;
|
||||
md5: string;
|
||||
platform: string;
|
||||
gain: number;
|
||||
}
|
||||
|
||||
export interface ParsedMusicResult {
|
||||
data: {
|
||||
data: GDMusicResponse;
|
||||
params: {
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从GD音乐台解析音乐URL
|
||||
* @param id 音乐ID
|
||||
* @param data 音乐数据,包含名称和艺术家信息
|
||||
* @param quality 音质设置
|
||||
* @returns 解析后的音乐URL及相关信息
|
||||
*/
|
||||
export const parseFromGDMusic = async (
|
||||
id: number,
|
||||
data: any,
|
||||
quality: string = '320'
|
||||
): Promise<ParsedMusicResult | null> => {
|
||||
try {
|
||||
// 处理不同数据结构
|
||||
if (!data) {
|
||||
console.error('GD音乐台解析:歌曲数据为空');
|
||||
throw new Error('歌曲数据为空');
|
||||
}
|
||||
|
||||
const songName = data.name || '';
|
||||
let artistNames = '';
|
||||
|
||||
// 处理不同的艺术家字段结构
|
||||
if (data.artists && Array.isArray(data.artists)) {
|
||||
artistNames = data.artists.map(artist => artist.name).join(' ');
|
||||
} else if (data.ar && Array.isArray(data.ar)) {
|
||||
artistNames = data.ar.map(artist => artist.name).join(' ');
|
||||
} else if (data.artist) {
|
||||
artistNames = typeof data.artist === 'string' ? data.artist : '';
|
||||
}
|
||||
|
||||
const searchQuery = `${songName} ${artistNames}`.trim();
|
||||
|
||||
if (!searchQuery || searchQuery.length < 2) {
|
||||
console.error('GD音乐台解析:搜索查询过短', { name: songName, artists: artistNames });
|
||||
throw new Error('搜索查询过短');
|
||||
}
|
||||
|
||||
// 所有可用的音乐源
|
||||
const allSources = [
|
||||
'tencent', 'kugou', 'kuwo', 'migu', 'netease',
|
||||
'joox', 'ytmusic', 'spotify', 'qobuz', 'deezer'
|
||||
] as MusicSourceType[];
|
||||
|
||||
console.log('GD音乐台开始搜索:', searchQuery);
|
||||
|
||||
// 依次尝试所有音源
|
||||
for (const source of allSources) {
|
||||
try {
|
||||
const result = await searchAndGetUrl(source, searchQuery, quality);
|
||||
if (result) {
|
||||
console.log(`GD音乐台成功通过 ${result.source} 解析音乐!`);
|
||||
// 返回符合原API格式的数据
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
url: result.url.replace(/\\/g, ''),
|
||||
br: parseInt(result.br, 10) * 1000 || 320000,
|
||||
size: result.size || 0,
|
||||
md5: '',
|
||||
platform: 'gdmusic',
|
||||
gain: 0
|
||||
},
|
||||
params: {
|
||||
id: parseInt(String(id), 10),
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`GD音乐台 ${source} 音源解析失败:`, error);
|
||||
// 该音源失败,继续尝试下一个音源
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('GD音乐台所有音源均解析失败');
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('GD音乐台解析完全失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取音质映射
|
||||
* @param qualitySetting 设置中的音质选项
|
||||
* @returns 映射到GD音乐台的音质参数
|
||||
*/
|
||||
export const getQualityMapping = (qualitySetting: string): string => {
|
||||
const qualityMap: Record<string, string> = {
|
||||
standard: '128',
|
||||
higher: '320',
|
||||
exhigh: '320',
|
||||
lossless: '740',
|
||||
hires: '999',
|
||||
jyeffect: '999',
|
||||
sky: '999',
|
||||
dolby: '999',
|
||||
jymaster: '999'
|
||||
};
|
||||
return qualityMap[qualitySetting] || '320';
|
||||
};
|
||||
|
||||
interface GDMusicUrlResult {
|
||||
url: string;
|
||||
br: string;
|
||||
size: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const baseUrl = 'https://music-api.gdstudio.xyz/api.php';
|
||||
|
||||
/**
|
||||
* 在指定音源搜索歌曲并获取URL
|
||||
* @param source 音源
|
||||
* @param searchQuery 搜索关键词
|
||||
* @param quality 音质
|
||||
* @returns 音乐URL结果
|
||||
*/
|
||||
async function searchAndGetUrl(
|
||||
source: MusicSourceType,
|
||||
searchQuery: string,
|
||||
quality: string
|
||||
): Promise<GDMusicUrlResult | null> {
|
||||
// 1. 搜索歌曲
|
||||
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=1&pages=1`;
|
||||
console.log(`GD音乐台尝试音源 ${source} 搜索:`, searchUrl);
|
||||
|
||||
const searchResponse = await axios.get(searchUrl, { timeout: 5000 });
|
||||
|
||||
if (searchResponse.data && Array.isArray(searchResponse.data) && searchResponse.data.length > 0) {
|
||||
const firstResult = searchResponse.data[0];
|
||||
if (!firstResult || !firstResult.id) {
|
||||
console.log(`GD音乐台 ${source} 搜索结果无效`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackId = firstResult.id;
|
||||
const trackSource = firstResult.source || source;
|
||||
|
||||
// 2. 获取歌曲URL
|
||||
const songUrl = `${baseUrl}?types=url&source=${trackSource}&id=${trackId}&br=${quality}`;
|
||||
console.log(`GD音乐台尝试获取 ${trackSource} 歌曲URL:`, songUrl);
|
||||
|
||||
const songResponse = await axios.get(songUrl, { timeout: 5000 });
|
||||
|
||||
if (songResponse.data && songResponse.data.url) {
|
||||
return {
|
||||
url: songResponse.data.url,
|
||||
br: songResponse.data.br,
|
||||
size: songResponse.data.size || 0,
|
||||
source: trackSource
|
||||
};
|
||||
} else {
|
||||
console.log(`GD音乐台 ${trackSource} 未返回有效URL`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
console.log(`GD音乐台 ${source} 搜索结果为空`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import store from '@/store';
|
||||
import { musicDB } from '@/hooks/MusicHook';
|
||||
import { useSettingsStore, useUserStore } from '@/store';
|
||||
import type { ILyric } from '@/type/lyric';
|
||||
import { isElectron } from '@/utils';
|
||||
import request from '@/utils/request';
|
||||
import requestMusic from '@/utils/request_music';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { parseFromGDMusic, getQualityMapping } from './gdmusic';
|
||||
|
||||
const { addData, getData, deleteData } = musicDB;
|
||||
|
||||
// 获取音乐音质详情
|
||||
export const getMusicQualityDetail = (id: number) => {
|
||||
@@ -10,22 +15,33 @@ export const getMusicQualityDetail = (id: number) => {
|
||||
};
|
||||
|
||||
// 根据音乐Id获取音乐播放URl
|
||||
export const getMusicUrl = async (id: number) => {
|
||||
const res = await request.get('/song/download/url/v1', {
|
||||
params: {
|
||||
id,
|
||||
level: store.state.setData.musicQuality || 'higher'
|
||||
}
|
||||
});
|
||||
export const getMusicUrl = async (id: number, isDownloaded: boolean = false) => {
|
||||
const userStore = useUserStore();
|
||||
const settingStore = useSettingsStore();
|
||||
// 判断是否登录
|
||||
try {
|
||||
if (userStore.user && isDownloaded && userStore.user.vipType !== 0) {
|
||||
const url = '/song/download/url/v1';
|
||||
const res = await request.get(url, {
|
||||
params: {
|
||||
id,
|
||||
level: settingStore.setData.musicQuality || 'higher',
|
||||
cookie: `${localStorage.getItem('token')} os=pc;`
|
||||
}
|
||||
});
|
||||
|
||||
if (res.data.data.url) {
|
||||
return { data: { data: [{ ...res.data.data }] } };
|
||||
if (res.data.data.url) {
|
||||
return { data: { data: [{ ...res.data.data }] } };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
|
||||
return await request.get('/song/url/v1', {
|
||||
params: {
|
||||
id,
|
||||
level: store.state.setData.musicQuality || 'higher'
|
||||
level: settingStore.setData.musicQuality || 'higher'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -37,30 +53,66 @@ export const getMusicDetail = (ids: Array<number>) => {
|
||||
|
||||
// 根据音乐Id获取音乐歌词
|
||||
export const getMusicLrc = async (id: number) => {
|
||||
if (isElectron) {
|
||||
// 先尝试从缓存获取
|
||||
const cachedLyric = await window.api.invoke('get-cached-lyric', id);
|
||||
console.log('cachedLyric', cachedLyric);
|
||||
if (cachedLyric) {
|
||||
return { data: cachedLyric };
|
||||
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000; // 10天的毫秒数
|
||||
|
||||
try {
|
||||
// 尝试获取缓存的歌词
|
||||
const cachedLyric = await getData('music_lyric', id);
|
||||
if (cachedLyric?.createTime && Date.now() - cachedLyric.createTime < TEN_DAYS_MS) {
|
||||
return { ...cachedLyric };
|
||||
}
|
||||
|
||||
// 获取新的歌词数据
|
||||
const res = await request.get<ILyric>('/lyric', { params: { id } });
|
||||
|
||||
// 只有在成功获取新数据后才删除旧缓存并添加新缓存
|
||||
if (res?.data) {
|
||||
if (cachedLyric) {
|
||||
await deleteData('music_lyric', id);
|
||||
}
|
||||
addData('music_lyric', { id, data: res.data, createTime: Date.now() });
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('获取歌词失败:', error);
|
||||
throw error; // 向上抛出错误,让调用者处理
|
||||
}
|
||||
|
||||
// 如果缓存中没有,则从服务器获取
|
||||
const res = await request.get<ILyric>('/lyric', { params: { id } });
|
||||
|
||||
// 缓存完整的响应数据
|
||||
if (isElectron && res) {
|
||||
await window.api.invoke('cache-lyric', id, res.data);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getParsingMusicUrl = (id: number, data: any) => {
|
||||
if (isElectron) {
|
||||
return window.api.unblockMusic(id, data);
|
||||
export const getParsingMusicUrl = async (id: number, data: any) => {
|
||||
const settingStore = useSettingsStore();
|
||||
|
||||
// 如果禁用了音乐解析功能,则直接返回空结果
|
||||
if (!settingStore.setData.enableMusicUnblock) {
|
||||
return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } });
|
||||
}
|
||||
|
||||
// 检查是否选择了GD音乐台解析
|
||||
const enabledSources = settingStore.setData.enabledMusicSources || [];
|
||||
if (enabledSources.includes('gdmusic')) {
|
||||
// 获取音质设置并转换为GD音乐台格式
|
||||
try {
|
||||
const quality = getQualityMapping(settingStore.setData.musicQuality || 'higher');
|
||||
|
||||
// 调用封装的GD音乐台解析服务
|
||||
const gdResult = await parseFromGDMusic(id, data, quality);
|
||||
if (gdResult) {
|
||||
return gdResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('GD音乐台解析失败:', error);
|
||||
}
|
||||
|
||||
console.log('GD音乐台所有音源均解析失败,尝试使用unblockMusic');
|
||||
}
|
||||
|
||||
// 如果GD音乐台解析失败或者未启用,尝试使用unblockMusic
|
||||
if (isElectron) {
|
||||
const filteredSources = enabledSources.filter(source => source !== 'gdmusic');
|
||||
return window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources));
|
||||
}
|
||||
|
||||
return requestMusic.get<any>('/music', { params: { id } });
|
||||
};
|
||||
|
||||
@@ -70,6 +122,22 @@ export const likeSong = (id: number, like: boolean = true) => {
|
||||
};
|
||||
|
||||
// 获取用户喜欢的音乐列表
|
||||
export const getLikedList = () => {
|
||||
return request.get('/likelist');
|
||||
export const getLikedList = (uid: number) => {
|
||||
return request.get('/likelist', {
|
||||
params: { uid, noLogin: true }
|
||||
});
|
||||
};
|
||||
|
||||
// 创建歌单
|
||||
export const createPlaylist = (params: { name: string; privacy: number }) => {
|
||||
return request.post('/playlist/create', params);
|
||||
};
|
||||
|
||||
// 添加或删除歌单歌曲
|
||||
export const updatePlaylistTracks = (params: {
|
||||
op: 'add' | 'del';
|
||||
pid: number;
|
||||
tracks: string;
|
||||
}) => {
|
||||
return request.get('/playlist/tracks', { params });
|
||||
};
|
||||
|
||||
75
src/renderer/api/stats.ts
Normal file
75
src/renderer/api/stats.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { isElectron } from '@/utils';
|
||||
|
||||
import { useUserStore } from '../store/modules/user';
|
||||
|
||||
/**
|
||||
* 获取用户ID
|
||||
* @returns 用户ID或null
|
||||
*/
|
||||
function getUserId(): string | null {
|
||||
const userStore = useUserStore();
|
||||
return userStore.user?.userId?.toString() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录页面访问
|
||||
* @param page 页面名称或路径
|
||||
*/
|
||||
export async function recordVisit(page: string): Promise<void> {
|
||||
if (!isElectron) return;
|
||||
try {
|
||||
const userId = getUserId();
|
||||
await window.api.invoke('record-visit', page, userId);
|
||||
console.log(`页面访问已记录: ${page}`);
|
||||
} catch (error) {
|
||||
console.error('记录页面访问失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录歌曲播放
|
||||
* @param songId 歌曲ID
|
||||
* @param songName 歌曲名称
|
||||
* @param artistName 艺术家名称
|
||||
* @param duration 时长(秒)
|
||||
* @param completedPlay 是否完整播放
|
||||
*/
|
||||
export async function recordPlay(
|
||||
songId: string | number,
|
||||
songName: string,
|
||||
artistName: string,
|
||||
duration: number = 0,
|
||||
completedPlay: boolean = false
|
||||
): Promise<void> {
|
||||
if (!isElectron) return;
|
||||
try {
|
||||
const userId = getUserId();
|
||||
|
||||
await window.api.invoke('record-play', {
|
||||
userId,
|
||||
songId,
|
||||
songName,
|
||||
artistName,
|
||||
duration,
|
||||
completedPlay
|
||||
});
|
||||
|
||||
console.log(`歌曲播放已记录: ${songName}`);
|
||||
} catch (error) {
|
||||
console.error('记录歌曲播放失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计摘要
|
||||
* @returns 统计数据摘要
|
||||
*/
|
||||
export async function getStatsSummary(): Promise<any> {
|
||||
if (!isElectron) return null;
|
||||
try {
|
||||
return await window.api.invoke('get-stats-summary');
|
||||
} catch (error) {
|
||||
console.error('获取统计摘要失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IUserDetail, IUserFollow } from '@/type/user';
|
||||
import request from '@/utils/request';
|
||||
|
||||
// /user/detail
|
||||
@@ -6,8 +7,8 @@ export function getUserDetail(uid: number) {
|
||||
}
|
||||
|
||||
// /user/playlist
|
||||
export function getUserPlaylist(uid: number) {
|
||||
return request.get('/user/playlist', { params: { uid } });
|
||||
export function getUserPlaylist(uid: number, limit: number = 30, offset: number = 0) {
|
||||
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) {
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
3687
src/renderer/assets/css/animate.css
vendored
3687
src/renderer/assets/css/animate.css
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
body {
|
||||
/* background-color: #000; */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.n-popover:has(.music-play) {
|
||||
@@ -9,3 +10,11 @@ body {
|
||||
border-radius: 0.5rem !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.n-popover:has(.transparent-popover ) {
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.settings-slider .n-slider-mark {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
45
src/renderer/components.d.ts
vendored
45
src/renderer/components.d.ts
vendored
@@ -2,11 +2,56 @@
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NBadge: typeof import('naive-ui')['NBadge']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCarousel: typeof import('naive-ui')['NCarousel']
|
||||
NCarouselItem: typeof import('naive-ui')['NCarouselItem']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NGrid: typeof import('naive-ui')['NGrid']
|
||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NPopover: typeof import('naive-ui')['NPopover']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NRadio: typeof import('naive-ui')['NRadio']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSlider: typeof import('naive-ui')['NSlider']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||
NTabs: typeof import('naive-ui')['NTabs']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
quaternary
|
||||
class="inline-flex items-center gap-2 px-4 py-2 transition-all duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
请我喝咖啡
|
||||
{{ t('comp.coffee.title') }}
|
||||
</n-button>
|
||||
</slot>
|
||||
</template>
|
||||
@@ -17,20 +17,24 @@
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image
|
||||
:src="alipayQR"
|
||||
alt="支付宝收款码"
|
||||
:alt="t('comp.coffee.alipayQR')"
|
||||
class="w-32 h-32 rounded-lg cursor-none"
|
||||
preview-disabled
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">支付宝</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">{{
|
||||
t('comp.coffee.alipay')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image
|
||||
:src="wechatQR"
|
||||
alt="微信收款码"
|
||||
:alt="t('comp.coffee.wechatQR')"
|
||||
class="w-32 h-32 rounded-lg cursor-none"
|
||||
preview-disabled
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">微信支付</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">{{
|
||||
t('comp.coffee.wechat')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +43,16 @@
|
||||
class="text-sm text-gray-700 dark:text-gray-200 text-center cursor-pointer hover:text-green-500"
|
||||
@click="copyQQ"
|
||||
>
|
||||
QQ群:789288579
|
||||
{{ t('comp.coffee.qqGroup') }}
|
||||
</p>
|
||||
</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>
|
||||
@@ -48,17 +61,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NImage, NPopover } from 'naive-ui';
|
||||
import { NButton, NImage, NPopover, useMessage } from 'naive-ui';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import alipay from '@/assets/alipay.png';
|
||||
import wechat from '@/assets/wechat.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const message = useMessage();
|
||||
const copyQQ = () => {
|
||||
navigator.clipboard.writeText('789288579');
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
const toDonateList = () => {
|
||||
window.open('http://donate.alger.fun', '_blank');
|
||||
};
|
||||
|
||||
defineProps({
|
||||
alipayQR: {
|
||||
type: String,
|
||||
|
||||
355
src/renderer/components/EQControl.vue
Normal file
355
src/renderer/components/EQControl.vue
Normal 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>
|
||||
27
src/renderer/components/LanguageSwitcher.vue
Normal file
27
src/renderer/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { locale } = useI18n();
|
||||
|
||||
const languages = [
|
||||
{ label: '简体中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' }
|
||||
];
|
||||
|
||||
console.log('locale', locale);
|
||||
// 使用计算属性来获取当前语言
|
||||
const currentLanguage = computed({
|
||||
get: () => locale.value,
|
||||
set: (value) => {
|
||||
settingsStore.setLanguage(value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-select v-model:value="currentLanguage" :options="languages" size="small" />
|
||||
</template>
|
||||
@@ -12,21 +12,39 @@
|
||||
>
|
||||
<div class="music-page">
|
||||
<div class="music-header h-12 flex items-center justify-between">
|
||||
<n-ellipsis :line-clamp="1">
|
||||
<n-ellipsis :line-clamp="1" class="flex-shrink-0 mr-3">
|
||||
<div class="music-title">
|
||||
{{ name }}
|
||||
</div>
|
||||
</n-ellipsis>
|
||||
<div class="music-close">
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="flex-grow flex-1 flex items-center justify-end">
|
||||
<div class="search-container">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="t('comp.musicList.searchSongs')"
|
||||
clearable
|
||||
round
|
||||
size="small"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon iconfont ri-search-line text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-close flex-shrink-0 ml-3">
|
||||
<i class="icon iconfont ri-close-line" @click="close"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="music-content">
|
||||
<!-- 左侧歌单信息 -->
|
||||
<div class="music-info">
|
||||
<div class="music-cover">
|
||||
<n-image
|
||||
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '500y500')"
|
||||
:src="getCoverImgUrl"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
:class="setAnimationClass('animate__fadeIn')"
|
||||
@@ -37,35 +55,48 @@
|
||||
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
|
||||
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
|
||||
</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">
|
||||
{{ listInfo.description }}
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 右侧歌曲列表 -->
|
||||
<div class="music-list-container">
|
||||
<div class="music-list">
|
||||
<n-scrollbar @scroll="handleScroll">
|
||||
<n-spin :show="loadingList || loading">
|
||||
<div class="music-list-content">
|
||||
<div
|
||||
v-for="(item, index) in displayedSongs"
|
||||
:key="item.id"
|
||||
class="double-item"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
>
|
||||
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
||||
</div>
|
||||
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
|
||||
<play-bottom />
|
||||
<n-spin :show="loadingList || loading">
|
||||
<div class="music-list-content">
|
||||
<div v-if="filteredSongs.length === 0 && searchKeyword" class="no-result">
|
||||
{{ t('comp.musicList.noSearchResults') }}
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
|
||||
<!-- 虚拟列表,设置正确的固定高度 -->
|
||||
<n-virtual-list
|
||||
ref="songListRef"
|
||||
class="song-virtual-list"
|
||||
style="height: calc(70vh - 60px)"
|
||||
:items="filteredSongs"
|
||||
:item-size="70"
|
||||
item-resizable
|
||||
key-field="id"
|
||||
@scroll="handleVirtualScroll"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="double-item">
|
||||
<song-item
|
||||
:item="formatSong(item)"
|
||||
:can-remove="canRemove"
|
||||
@play="handlePlay"
|
||||
@remove-song="(id) => emit('remove-song', id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</div>
|
||||
@@ -75,16 +106,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
import PinyinMatch from 'pinyin-match';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { SongResult } from '@/type/music';
|
||||
import { getImgUrl, isMobile, setAnimationClass } from '@/utils';
|
||||
|
||||
import PlayBottom from './common/PlayBottom.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
const playerStore = usePlayerStore();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
@@ -97,21 +132,29 @@ const props = withDefaults(
|
||||
[key: string]: any;
|
||||
};
|
||||
cover?: boolean;
|
||||
canRemove?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
cover: true,
|
||||
zIndex: 9996
|
||||
zIndex: 9996,
|
||||
canRemove: false
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['update:show', 'update:loading']);
|
||||
const emit = defineEmits(['update:show', 'update:loading', 'remove-song']);
|
||||
|
||||
const page = ref(0);
|
||||
const pageSize = 20;
|
||||
const pageSize = 40;
|
||||
const isLoadingMore = ref(false);
|
||||
const displayedSongs = ref<any[]>([]);
|
||||
const displayedSongs = ref<SongResult[]>([]);
|
||||
const loadingList = ref(false);
|
||||
const loadedIds = ref(new Set<number>()); // 用于追踪已加载的歌曲ID
|
||||
const isPlaylistLoading = ref(false); // 标记是否正在加载播放列表
|
||||
const completePlaylist = ref<SongResult[]>([]); // 存储完整的播放列表
|
||||
const hasMore = ref(true); // 标记是否还有更多数据可加载
|
||||
const searchKeyword = ref(''); // 搜索关键词
|
||||
const isFullPlaylistLoaded = ref(false); // 标记完整播放列表是否已加载完成
|
||||
|
||||
// 计算总数
|
||||
const total = computed(() => {
|
||||
@@ -121,108 +164,427 @@ const total = computed(() => {
|
||||
return props.songList.length;
|
||||
});
|
||||
|
||||
const formatDetail = computed(() => (detail: any) => {
|
||||
const song = {
|
||||
artists: detail.ar,
|
||||
name: detail.al.name,
|
||||
id: detail.al.id
|
||||
};
|
||||
const getCoverImgUrl = computed(() => {
|
||||
if (props.listInfo?.coverImgUrl) {
|
||||
return props.listInfo.coverImgUrl;
|
||||
}
|
||||
|
||||
detail.song = song;
|
||||
detail.picUrl = detail.al.picUrl;
|
||||
return detail;
|
||||
const song = props.songList[0];
|
||||
if (song?.picUrl) {
|
||||
return song.picUrl;
|
||||
}
|
||||
if (song?.al?.picUrl) {
|
||||
return song.al.picUrl;
|
||||
}
|
||||
if (song?.album?.picUrl) {
|
||||
return song.album.picUrl;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
const tracks = props.songList || [];
|
||||
store.commit(
|
||||
'setPlayList',
|
||||
tracks.map((item) => ({
|
||||
...item,
|
||||
picUrl: item.al.picUrl,
|
||||
song: {
|
||||
artists: item.ar
|
||||
// 过滤歌曲列表
|
||||
const filteredSongs = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return displayedSongs.value;
|
||||
}
|
||||
|
||||
const keyword = searchKeyword.value.toLowerCase().trim();
|
||||
return displayedSongs.value.filter((song) => {
|
||||
const songName = song.name?.toLowerCase() || '';
|
||||
const albumName = song.al?.name?.toLowerCase() || '';
|
||||
const artists = song.ar || song.artists || [];
|
||||
|
||||
// 原始文本匹配
|
||||
const nameMatch = songName.includes(keyword);
|
||||
const albumMatch = albumName.includes(keyword);
|
||||
const artistsMatch = artists.some((artist: any) => {
|
||||
return artist.name?.toLowerCase().includes(keyword);
|
||||
});
|
||||
|
||||
// 拼音匹配
|
||||
const namePinyinMatch = song.name && PinyinMatch.match(song.name, keyword);
|
||||
const albumPinyinMatch = song.al?.name && PinyinMatch.match(song.al.name, keyword);
|
||||
const artistsPinyinMatch = artists.some((artist: any) => {
|
||||
return artist.name && PinyinMatch.match(artist.name, keyword);
|
||||
});
|
||||
|
||||
return (
|
||||
nameMatch ||
|
||||
albumMatch ||
|
||||
artistsMatch ||
|
||||
namePinyinMatch ||
|
||||
albumPinyinMatch ||
|
||||
artistsPinyinMatch
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 格式化歌曲数据
|
||||
const formatSong = (item: any) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
picUrl: item.al?.picUrl || item.picUrl,
|
||||
song: {
|
||||
artists: item.ar || item.artists,
|
||||
name: item.al?.name || item.name,
|
||||
id: item.al?.id || item.id
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载歌曲数据的核心函数
|
||||
* @param ids 要加载的歌曲ID数组
|
||||
* @param appendToList 是否将加载的歌曲追加到现有列表
|
||||
* @param updateComplete 是否更新完整播放列表
|
||||
*/
|
||||
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
try {
|
||||
console.log(`请求歌曲详情,ID数量: ${ids.length}`);
|
||||
const { data } = await getMusicDetail(ids);
|
||||
|
||||
if (data?.songs) {
|
||||
console.log(`API返回歌曲数量: ${data.songs.length}`);
|
||||
|
||||
// 直接使用API返回的所有歌曲,不再过滤已加载的歌曲
|
||||
// 因为当需要完整加载列表时,我们希望获取所有歌曲,即使ID可能重复
|
||||
const { songs } = data;
|
||||
|
||||
// 只在非更新完整列表时执行过滤
|
||||
let newSongs = songs;
|
||||
if (!updateComplete) {
|
||||
// 在普通加载模式下继续过滤已加载的歌曲,避免重复
|
||||
newSongs = songs.filter((song: any) => !loadedIds.value.has(song.id));
|
||||
console.log(`过滤已加载ID后剩余歌曲数量: ${newSongs.length}`);
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
// 更新已加载ID集合
|
||||
songs.forEach((song: any) => {
|
||||
loadedIds.value.add(song.id);
|
||||
});
|
||||
|
||||
// 追加到显示列表 - 仅当appendToList=true时添加到displayedSongs
|
||||
if (appendToList) {
|
||||
displayedSongs.value.push(...newSongs);
|
||||
}
|
||||
|
||||
// 更新完整播放列表 - 仅当updateComplete=true时添加到completePlaylist
|
||||
if (updateComplete) {
|
||||
completePlaylist.value.push(...songs);
|
||||
console.log(`已添加到完整播放列表,当前完整列表长度: ${completePlaylist.value.length}`);
|
||||
}
|
||||
|
||||
return updateComplete ? songs : newSongs;
|
||||
}
|
||||
console.log('API返回无歌曲数据');
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('加载歌曲失败:', error);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// 加载完整播放列表
|
||||
const loadFullPlaylist = async () => {
|
||||
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
|
||||
|
||||
isPlaylistLoading.value = true;
|
||||
// 记录开始时间
|
||||
const startTime = Date.now();
|
||||
console.log(`开始加载完整播放列表,当前显示列表长度: ${displayedSongs.value.length}`);
|
||||
|
||||
try {
|
||||
// 如果没有trackIds,直接使用当前歌曲列表并标记为已完成
|
||||
if (!props.listInfo?.trackIds) {
|
||||
isFullPlaylistLoaded.value = true;
|
||||
console.log('无trackIds信息,使用当前列表作为完整列表');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有trackIds
|
||||
const allIds = props.listInfo.trackIds.map((item) => item.id);
|
||||
console.log(`歌单共有歌曲ID: ${allIds.length}首`);
|
||||
|
||||
// 重置completePlaylist和当前显示歌曲ID集合,保证不会重复添加歌曲
|
||||
completePlaylist.value = [];
|
||||
|
||||
// 使用Set记录所有已加载的歌曲ID
|
||||
const loadedSongIds = new Set<number>();
|
||||
|
||||
// 将当前显示列表中的歌曲和ID添加到集合中
|
||||
displayedSongs.value.forEach((song) => {
|
||||
loadedSongIds.add(song.id as number);
|
||||
// 将已有歌曲添加到completePlaylist
|
||||
completePlaylist.value.push(song);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`已有显示歌曲: ${displayedSongs.value.length}首,已有ID数量: ${loadedSongIds.size}`
|
||||
);
|
||||
|
||||
// 过滤出尚未加载的歌曲ID
|
||||
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
|
||||
console.log(`还需要加载的歌曲ID数量: ${unloadedIds.length}`);
|
||||
|
||||
if (unloadedIds.length === 0) {
|
||||
console.log('所有歌曲已加载,无需再次加载');
|
||||
isFullPlaylistLoaded.value = true;
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 分批加载所有未加载的歌曲
|
||||
const batchSize = 500; // 每批加载的歌曲数量
|
||||
|
||||
for (let i = 0; i < unloadedIds.length; i += batchSize) {
|
||||
const batchIds = unloadedIds.slice(i, i + batchSize);
|
||||
if (batchIds.length === 0) continue;
|
||||
|
||||
console.log(`请求第${Math.floor(i / batchSize) + 1}批歌曲,数量: ${batchIds.length}`);
|
||||
// 关键修改: 设置appendToList为false,避免loadSongs直接添加到displayedSongs
|
||||
const loadedBatch = await loadSongs(batchIds, false, false);
|
||||
|
||||
// 添加新加载的歌曲到displayedSongs
|
||||
if (loadedBatch.length > 0) {
|
||||
// 过滤掉已有的歌曲,确保不会重复添加
|
||||
const newSongs = loadedBatch.filter((song) => !loadedSongIds.has(song.id as number));
|
||||
|
||||
// 更新已加载ID集合
|
||||
newSongs.forEach((song) => {
|
||||
loadedSongIds.add(song.id as number);
|
||||
});
|
||||
|
||||
console.log(`新增${newSongs.length}首歌曲到显示列表`);
|
||||
|
||||
// 更新显示列表和完整播放列表
|
||||
if (newSongs.length > 0) {
|
||||
// 添加到显示列表
|
||||
displayedSongs.value = [...displayedSongs.value, ...newSongs];
|
||||
|
||||
// 添加到完整播放列表
|
||||
completePlaylist.value.push(...newSongs);
|
||||
|
||||
// 如果当前正在播放的列表与这个列表匹配,实时更新播放列表
|
||||
const currentPlaylist = playerStore.playList;
|
||||
if (currentPlaylist.length > 0 && currentPlaylist[0].id === displayedSongs.value[0]?.id) {
|
||||
console.log('实时更新当前播放列表');
|
||||
playerStore.setPlayList(displayedSongs.value.map(formatSong));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加小延迟避免请求过于密集
|
||||
if (i + batchSize < unloadedIds.length) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 加载完成,更新状态
|
||||
isFullPlaylistLoaded.value = true;
|
||||
hasMore.value = false;
|
||||
|
||||
// 计算加载耗时
|
||||
const endTime = Date.now();
|
||||
const timeUsed = Math.round(((endTime - startTime) / 1000) * 100) / 100;
|
||||
|
||||
console.log(
|
||||
`完整播放列表加载完成,共加载${displayedSongs.value.length}首歌曲,耗时${timeUsed}秒`
|
||||
);
|
||||
console.log(`歌单应有${allIds.length}首歌,实际加载${displayedSongs.value.length}首`);
|
||||
|
||||
// 检查加载的歌曲数量是否与预期相符
|
||||
if (displayedSongs.value.length !== allIds.length) {
|
||||
console.warn(
|
||||
`警告: 加载的歌曲数量(${displayedSongs.value.length})与歌单应有数量(${allIds.length})不符`
|
||||
);
|
||||
|
||||
// 如果数量不符,可能是API未返回所有歌曲,打印缺失的歌曲ID
|
||||
if (displayedSongs.value.length < allIds.length) {
|
||||
const loadedIds = new Set(displayedSongs.value.map((song) => song.id));
|
||||
const missingIds = allIds.filter((id) => !loadedIds.has(id));
|
||||
console.warn(`缺失的歌曲ID: ${missingIds.join(', ')}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载完整播放列表失败:', error);
|
||||
} finally {
|
||||
isPlaylistLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理播放
|
||||
const handlePlay = async () => {
|
||||
// 当搜索状态下播放时,只播放过滤后的歌曲
|
||||
if (searchKeyword.value) {
|
||||
playerStore.setPlayList(filteredSongs.value.map(formatSong));
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果完整播放列表已加载完成
|
||||
if (isFullPlaylistLoaded.value && completePlaylist.value.length > 0) {
|
||||
playerStore.setPlayList(completePlaylist.value.map(formatSong));
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果完整播放列表未加载完成,先使用当前已加载的歌曲开始播放
|
||||
playerStore.setPlayList(displayedSongs.value.map(formatSong));
|
||||
|
||||
// 如果完整播放列表正在加载中,不需要重新触发加载
|
||||
if (isPlaylistLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 在后台继续加载完整播放列表(如果未加载完成)
|
||||
if (!isFullPlaylistLoaded.value) {
|
||||
console.log('播放时继续在后台加载完整列表');
|
||||
loadFullPlaylist();
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('update:show', false);
|
||||
};
|
||||
|
||||
// 优化加载更多歌曲的函数
|
||||
// 加载更多歌曲
|
||||
const loadMoreSongs = async () => {
|
||||
if (isLoadingMore.value || displayedSongs.value.length >= total.value) return;
|
||||
if (isFullPlaylistLoaded.value) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchKeyword.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingMore.value || displayedSongs.value.length >= total.value) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingMore.value = true;
|
||||
try {
|
||||
if (props.listInfo?.trackIds) {
|
||||
// 如果有 trackIds,需要分批请求歌曲详情
|
||||
const start = page.value * pageSize;
|
||||
const end = Math.min((page.value + 1) * pageSize, total.value);
|
||||
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
|
||||
|
||||
if (trackIds.length > 0) {
|
||||
const { data } = await getMusicDetail(trackIds);
|
||||
displayedSongs.value = [...displayedSongs.value, ...data.songs];
|
||||
page.value++;
|
||||
try {
|
||||
const start = displayedSongs.value.length;
|
||||
const end = Math.min(start + pageSize, total.value);
|
||||
|
||||
if (props.listInfo?.trackIds) {
|
||||
const trackIdsToLoad = props.listInfo.trackIds
|
||||
.slice(start, end)
|
||||
.map((item) => item.id)
|
||||
.filter((id) => !loadedIds.value.has(id));
|
||||
|
||||
if (trackIdsToLoad.length > 0) {
|
||||
await loadSongs(trackIdsToLoad, true, false);
|
||||
}
|
||||
} else {
|
||||
// 如果没有 trackIds,直接使用 songList 分页
|
||||
const start = page.value * pageSize;
|
||||
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
|
||||
} else if (start < props.songList.length) {
|
||||
const newSongs = props.songList.slice(start, end);
|
||||
displayedSongs.value = [...displayedSongs.value, ...newSongs];
|
||||
page.value++;
|
||||
newSongs.forEach((song) => {
|
||||
if (!loadedIds.value.has(song.id)) {
|
||||
loadedIds.value.add(song.id);
|
||||
displayedSongs.value.push(song);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hasMore.value = displayedSongs.value.length < total.value;
|
||||
} catch (error) {
|
||||
console.error('加载歌曲失败:', error);
|
||||
console.error('加载更多歌曲失败:', error);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
loadingList.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % pageSize;
|
||||
return setAnimationDelay(currentPageIndex, 20);
|
||||
};
|
||||
// 处理虚拟列表滚动事件
|
||||
const handleVirtualScroll = (e: any) => {
|
||||
if (!e || !e.target) return;
|
||||
|
||||
// 修改滚动处理函数
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const threshold = 200;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) {
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < threshold &&
|
||||
!isLoadingMore.value &&
|
||||
hasMore.value &&
|
||||
!searchKeyword.value // 搜索状态下不触发加载更多
|
||||
) {
|
||||
loadMoreSongs();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
loadingList.value = newVal;
|
||||
if (!props.cover) {
|
||||
loadingList.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
// 重置列表状态
|
||||
const resetListState = () => {
|
||||
page.value = 0;
|
||||
loadedIds.value.clear();
|
||||
displayedSongs.value = [];
|
||||
completePlaylist.value = [];
|
||||
hasMore.value = true;
|
||||
loadingList.value = false;
|
||||
searchKeyword.value = ''; // 重置搜索关键词
|
||||
isFullPlaylistLoaded.value = false; // 重置完整播放列表状态
|
||||
};
|
||||
|
||||
// 监听 songList 变化,重置分页状态
|
||||
// 初始化歌曲列表
|
||||
const initSongList = (songs: any[]) => {
|
||||
if (songs.length > 0) {
|
||||
displayedSongs.value = [...songs];
|
||||
songs.forEach((song) => loadedIds.value.add(song.id));
|
||||
page.value = Math.ceil(songs.length / pageSize);
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据可加载
|
||||
hasMore.value = displayedSongs.value.length < total.value;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.listInfo,
|
||||
(newListInfo) => {
|
||||
if (newListInfo?.trackIds) {
|
||||
loadFullPlaylist();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
// 修改 songList 监听器
|
||||
watch(
|
||||
() => props.songList,
|
||||
(newSongs) => {
|
||||
page.value = 0;
|
||||
displayedSongs.value = newSongs.slice(0, pageSize);
|
||||
if (newSongs.length > pageSize) {
|
||||
page.value = 1;
|
||||
// 重置所有状态
|
||||
resetListState();
|
||||
|
||||
// 初始化歌曲列表
|
||||
initSongList(newSongs);
|
||||
|
||||
// 如果还有更多歌曲需要加载,且差距较小,立即加载
|
||||
if (hasMore.value && props.listInfo?.trackIds) {
|
||||
setTimeout(() => {
|
||||
loadMoreSongs();
|
||||
}, 300);
|
||||
}
|
||||
loadingList.value = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听搜索关键词变化
|
||||
watch(searchKeyword, () => {
|
||||
// 当搜索关键词为空时,考虑加载更多歌曲
|
||||
if (!searchKeyword.value && hasMore.value && displayedSongs.value.length < total.value) {
|
||||
loadMoreSongs();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时清理状态
|
||||
onUnmounted(() => {
|
||||
isPlaylistLoading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -231,6 +593,10 @@ watch(
|
||||
@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 {
|
||||
@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);
|
||||
@@ -278,12 +644,34 @@ watch(
|
||||
&-content {
|
||||
@apply min-h-[calc(80vh-60px)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.search-container {
|
||||
@apply max-w-md;
|
||||
|
||||
:deep(.n-input) {
|
||||
@apply bg-light-200 dark:bg-dark-200;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.no-result {
|
||||
@apply text-center py-8 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* 虚拟列表样式 */
|
||||
.song-virtual-list {
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: thin;
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 dark:bg-gray-600 rounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +683,7 @@ watch(
|
||||
|
||||
.music-content {
|
||||
@apply flex-col;
|
||||
width: 100vw !important;
|
||||
}
|
||||
|
||||
.music-info {
|
||||
@@ -307,6 +696,18 @@ watch(
|
||||
@apply flex-1 ml-4;
|
||||
}
|
||||
}
|
||||
|
||||
.music-title {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply max-w-[50%];
|
||||
}
|
||||
|
||||
.song-virtual-list {
|
||||
height: calc(80vh - 120px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
上一个
|
||||
{{ t('player.previous') }}
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="top">
|
||||
@@ -68,7 +68,7 @@
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
{{ isPlaying ? '暂停' : '播放' }}
|
||||
{{ isPlaying ? t('player.pause') : t('player.play') }}
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip v-if="!props.noList" placement="top">
|
||||
@@ -82,7 +82,7 @@
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
下一个
|
||||
{{ t('player.next') }}
|
||||
</n-tooltip>
|
||||
|
||||
<div class="time-display">
|
||||
@@ -104,7 +104,7 @@
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
{{ volume === 0 ? '取消静音' : '静音' }}
|
||||
{{ volume === 0 ? t('player.unmute') : t('player.mute') }}
|
||||
</n-tooltip>
|
||||
<n-slider
|
||||
v-model:value="volume"
|
||||
@@ -129,7 +129,9 @@
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
{{ playMode === 'single' ? '单曲循环' : '列表循环' }}
|
||||
{{
|
||||
playMode === 'single' ? t('player.modeHint.single') : t('player.modeHint.list')
|
||||
}}
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="top">
|
||||
@@ -144,7 +146,7 @@
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
||||
{{ isFullscreen ? t('player.fullscreen.exit') : t('player.fullscreen.enter') }}
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="top">
|
||||
@@ -157,7 +159,7 @@
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
关闭
|
||||
{{ t('player.close') }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +172,7 @@
|
||||
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
|
||||
</n-icon>
|
||||
<div class="mode-text">
|
||||
{{ playMode === 'single' ? '单曲循环' : '自动播放下一个' }}
|
||||
{{ playMode === 'single' ? t('player.modeHint.single') : t('player.modeHint.list') }}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -188,11 +190,13 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getMvUrl } from '@/api/mv';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
|
||||
const { t } = useI18n();
|
||||
type PlayMode = 'single' | 'auto';
|
||||
const PLAY_MODE = {
|
||||
Single: 'single' as PlayMode,
|
||||
@@ -218,7 +222,7 @@ const emit = defineEmits<{
|
||||
(e: 'prev', loading: (value: boolean) => void): void;
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
const playerStore = usePlayerStore();
|
||||
const mvUrl = ref<string>();
|
||||
const playMode = ref<PlayMode>(PLAY_MODE.Auto);
|
||||
|
||||
@@ -355,8 +359,8 @@ const loadMvUrl = async (mv: IMvItem) => {
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:show', false);
|
||||
if (store.state.playMusicUrl) {
|
||||
store.commit('setIsPlay', true);
|
||||
if (playerStore.playMusicUrl) {
|
||||
playerStore.setIsPlay(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -539,7 +543,7 @@ watch(showControls, (newValue) => {
|
||||
}
|
||||
});
|
||||
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
const isMobile = computed(() => false); // TODO: 从 settings store 获取
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,175 +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-xl">每日推荐</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">
|
||||
{{ 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="每日推荐列表"
|
||||
:song-list="dayRecommendData?.dailySongs"
|
||||
:cover="false"
|
||||
/>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
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 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>
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- 标签页切换 -->
|
||||
<n-tabs v-model:value="activeTab" class="flex-1" type="line" animated>
|
||||
<n-tab-pane name="songs" tab="热门歌曲">
|
||||
<n-tab-pane name="songs" :tab="t('artist.hotSongs')">
|
||||
<div ref="songListRef" class="songs-list">
|
||||
<n-scrollbar style="max-height: 61vh" :size="5" @scroll="handleSongScroll">
|
||||
<div class="song-list-content">
|
||||
@@ -44,14 +44,14 @@
|
||||
:list="true"
|
||||
@play="handlePlay"
|
||||
/>
|
||||
<div v-if="songLoading" class="loading-more">加载中...</div>
|
||||
<div v-if="songLoading" class="loading-more">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="albums" tab="专辑">
|
||||
<n-tab-pane name="albums" :tab="t('artist.albums')">
|
||||
<div ref="albumListRef" class="albums-list">
|
||||
<n-scrollbar style="max-height: 61vh" :size="5" @scroll="handleAlbumScroll">
|
||||
<div class="albums-grid">
|
||||
@@ -69,14 +69,14 @@
|
||||
type: '专辑'
|
||||
}"
|
||||
/>
|
||||
<div v-if="albumLoading" class="loading-more">加载中...</div>
|
||||
<div v-if="albumLoading" class="loading-more">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="about" tab="艺人介绍">
|
||||
<n-tab-pane name="about" :tab="t('artist.description')">
|
||||
<div class="artist-description">
|
||||
<n-scrollbar style="max-height: 60vh">
|
||||
<div class="description-content" v-html="artistInfo?.briefDesc"></div>
|
||||
@@ -90,23 +90,32 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDateFormat } from '@vueuse/core';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getArtistAlbums, getArtistDetail, getArtistTopSongs } from '@/api/artist';
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SearchItem from '@/components/common/SearchItem.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { usePlayerStore, useSettingsStore } from '@/store';
|
||||
import { IArtist } from '@/type/artist';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
import PlayBottom from './PlayBottom.vue';
|
||||
|
||||
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 store = useStore();
|
||||
const activeTab = ref('songs');
|
||||
const currentArtistId = ref<number>();
|
||||
|
||||
// 歌手信息
|
||||
const artistInfo = ref<IArtist>();
|
||||
@@ -131,15 +140,18 @@ const albumPage = ref({
|
||||
});
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
store.commit('setShowArtistDrawer', newVal);
|
||||
settingsStore.setShowArtistDrawer(newVal);
|
||||
});
|
||||
const loading = ref(false);
|
||||
// 加载歌手信息
|
||||
|
||||
const previousArtistId = ref<number>();
|
||||
const loadArtistInfo = async (id: number) => {
|
||||
if (currentArtistId.value === id) return;
|
||||
// if (currentArtistId.value === id) return;
|
||||
if (previousArtistId.value === id) return;
|
||||
activeTab.value = 'songs';
|
||||
loading.value = true;
|
||||
currentArtistId.value = id;
|
||||
previousArtistId.value = id;
|
||||
try {
|
||||
const info = await getArtistDetail(id);
|
||||
if (info.data?.data?.artist) {
|
||||
@@ -257,8 +269,7 @@ const formatPublishTime = (time: number) => {
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
store.commit(
|
||||
'setPlayList',
|
||||
playerStore.setPlayList(
|
||||
songs.value.map((item) => ({
|
||||
...item,
|
||||
picUrl: item.al.picUrl
|
||||
|
||||
117
src/renderer/components/common/BilibiliItem.vue
Normal file
117
src/renderer/components/common/BilibiliItem.vue
Normal 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>
|
||||
@@ -5,7 +5,7 @@
|
||||
<template #icon>
|
||||
<i class="ri-refresh-line"></i>
|
||||
</template>
|
||||
刷新列表
|
||||
{{ t('donation.refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="donation-grid" :class="{ 'grid-expanded': isExpanded }">
|
||||
@@ -72,33 +72,40 @@
|
||||
<template #icon>
|
||||
<i :class="isExpanded ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i>
|
||||
</template>
|
||||
{{ isExpanded ? '收起' : '展开更多' }}
|
||||
{{ isExpanded ? t('common.collapse') : t('common.expand') }}
|
||||
</n-button>
|
||||
</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">
|
||||
<p>您的捐赠将用于支持开发和维护工作,包括但不限于服务器维护、域名续费等。</p>
|
||||
<p>留言时可留下您的邮箱或 github名称。</p>
|
||||
<p>{{ t('donation.description') }}</p>
|
||||
<p>{{ t('donation.message') }}</p>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex justify-between mt-6">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image
|
||||
:src="alipay"
|
||||
alt="支付宝收款码"
|
||||
:alt="t('common.alipay')"
|
||||
class="w-60 h-60 rounded-lg cursor-none"
|
||||
preview-disabled
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">支付宝</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.alipay') }}</span>
|
||||
</div>
|
||||
<n-button type="primary" @click="toDonateList">
|
||||
<template #icon>
|
||||
<i class="ri-cup-line"></i>
|
||||
</template>
|
||||
{{ t('donation.toDonateList') }}
|
||||
<i class="ri-arrow-right-s-line"></i>
|
||||
</n-button>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image
|
||||
:src="wechat"
|
||||
alt="微信收款码"
|
||||
:alt="t('common.wechat')"
|
||||
class="w-60 h-60 rounded-lg cursor-none"
|
||||
preview-disabled
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">微信支付</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.wechat') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,40 +113,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import 'animate.css';
|
||||
|
||||
import axios from 'axios';
|
||||
import { computed, onActivated, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import type { Donor } from '@/api/donation';
|
||||
import { getDonationList } from '@/api/donation';
|
||||
import alipay from '@/assets/alipay.png';
|
||||
import wechat from '@/assets/wechat.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 默认头像
|
||||
const defaultAvatar = 'https://avatars.githubusercontent.com/u/0?v=4';
|
||||
|
||||
// 捐赠者数据
|
||||
interface Donor {
|
||||
id: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
date: string;
|
||||
message?: string;
|
||||
avatar?: string;
|
||||
badge: string;
|
||||
badgeColor: string;
|
||||
}
|
||||
|
||||
const donors = ref<Donor[]>([]);
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
const fetchDonors = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/data/main/donors.json'
|
||||
);
|
||||
donors.value = response.data.map((donor: Donor, index: number) => ({
|
||||
const data = await getDonationList();
|
||||
donors.value = data.map((donor, index) => ({
|
||||
...donor,
|
||||
avatar: `https://api.dicebear.com/7.x/micah/svg?seed=${index}`
|
||||
}));
|
||||
@@ -238,6 +232,10 @@ const displayDonors = computed(() => {
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
const toDonateList = () => {
|
||||
window.open('http://donate.alger.fun', '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="download-drawer-trigger">
|
||||
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
|
||||
<n-button circle @click="showDrawer = true">
|
||||
<n-button circle @click="settingsStore.showDownloadDrawer = true">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-download-cloud-2-line"></i>
|
||||
</template>
|
||||
@@ -9,19 +9,26 @@
|
||||
</n-badge>
|
||||
</div>
|
||||
|
||||
<n-drawer v-model:show="showDrawer" :height="'80%'" placement="bottom">
|
||||
<n-drawer-content title="下载管理" closable :native-scrollbar="false">
|
||||
<n-drawer
|
||||
v-model:show="showDrawer"
|
||||
:height="'80%'"
|
||||
placement="bottom"
|
||||
@after-leave="handleDrawerClose"
|
||||
>
|
||||
<n-drawer-content :title="t('download.title')" closable :native-scrollbar="false">
|
||||
<div class="drawer-container">
|
||||
<n-tabs type="line" animated class="h-full">
|
||||
<!-- 下载列表 -->
|
||||
<n-tab-pane name="downloading" tab="下载中" class="h-full">
|
||||
<n-tab-pane name="downloading" :tab="t('download.tabs.downloading')" class="h-full">
|
||||
<div class="download-list">
|
||||
<div v-if="downloadList.length === 0" class="empty-tip">
|
||||
<n-empty description="暂无下载任务" />
|
||||
<n-empty :description="t('download.empty.noTasks')" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="total-progress">
|
||||
<div class="total-progress-text">总进度: {{ totalProgress.toFixed(1) }}%</div>
|
||||
<div class="total-progress-text">
|
||||
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Number(totalProgress.toFixed(1))"
|
||||
@@ -47,7 +54,10 @@
|
||||
{{ item.filename }}
|
||||
</div>
|
||||
<div class="download-item-artist">
|
||||
{{ item.songInfo?.ar?.map((a) => a.name).join(', ') || '未知歌手' }}
|
||||
{{
|
||||
item.songInfo?.ar?.map((a) => a.name).join(', ') ||
|
||||
t('download.artist.unknown')
|
||||
}}
|
||||
</div>
|
||||
<div class="download-item-progress">
|
||||
<n-progress
|
||||
@@ -78,14 +88,29 @@
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 已下载列表 -->
|
||||
<n-tab-pane name="downloaded" tab="已下载" class="h-full">
|
||||
<n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full">
|
||||
<div class="downloaded-list">
|
||||
<div v-if="downloadedList.length === 0" class="empty-tip">
|
||||
<n-empty description="暂无已下载歌曲" />
|
||||
<div v-if="isLoadingDownloaded" class="loading-tip">
|
||||
<n-spin size="medium" />
|
||||
<span class="loading-text">{{ t('download.loading') }}</span>
|
||||
</div>
|
||||
<div v-else-if="downloadedList.length === 0" class="empty-tip">
|
||||
<n-empty :description="t('download.empty.noDownloaded')" />
|
||||
</div>
|
||||
<div v-else class="downloaded-content">
|
||||
<div class="downloaded-header">
|
||||
<div class="header-title">
|
||||
{{ t('download.count', { count: downloadedList.length }) }}
|
||||
</div>
|
||||
<n-button secondary size="small" @click="showClearConfirm = true">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-delete-bin-line mr-1"></i>
|
||||
</template>
|
||||
{{ t('download.clearAll') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="downloaded-items">
|
||||
<div v-for="item in downloadedList" :key="item.path" class="downloaded-item">
|
||||
<div v-for="item in downList" :key="item.path" class="downloaded-item">
|
||||
<div class="downloaded-item-content">
|
||||
<div class="downloaded-item-cover">
|
||||
<n-image
|
||||
@@ -105,11 +130,11 @@
|
||||
<div class="downloaded-item-size">{{ formatSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="downloaded-item-actions">
|
||||
<n-button text type="primary" size="large" @click="handlePlayMusic(item)">
|
||||
<!-- <n-button text type="primary" size="large" @click="handlePlayMusic(item)">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-play-circle-line text-xl"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-button> -->
|
||||
<n-button
|
||||
text
|
||||
type="primary"
|
||||
@@ -138,19 +163,54 @@
|
||||
</n-drawer>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<n-modal v-model:show="showDeleteConfirm" preset="dialog" type="warning" title="删除确认">
|
||||
<n-modal
|
||||
v-model:show="showDeleteConfirm"
|
||||
preset="dialog"
|
||||
type="warning"
|
||||
:title="t('download.delete.title')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<i class="iconfont ri-error-warning-line mr-2 text-xl"></i>
|
||||
<span>删除确认</span>
|
||||
<span>{{ t('download.delete.title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="delete-confirm-content">
|
||||
确定要删除歌曲 "{{ itemToDelete?.filename }}" 吗?此操作不可恢复。
|
||||
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
|
||||
</div>
|
||||
<template #action>
|
||||
<n-button size="small" @click="showDeleteConfirm = false">取消</n-button>
|
||||
<n-button size="small" type="warning" @click="confirmDelete">确定删除</n-button>
|
||||
<n-button size="small" @click="showDeleteConfirm = false">{{
|
||||
t('download.delete.cancel')
|
||||
}}</n-button>
|
||||
<n-button size="small" type="warning" @click="confirmDelete">{{
|
||||
t('download.delete.confirm')
|
||||
}}</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 清空确认对话框 -->
|
||||
<n-modal
|
||||
v-model:show="showClearConfirm"
|
||||
preset="dialog"
|
||||
type="warning"
|
||||
:title="t('download.clear.title')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<i class="iconfont ri-delete-bin-line mr-2 text-xl"></i>
|
||||
<span>{{ t('download.clear.title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="delete-confirm-content">
|
||||
{{ t('download.clear.message') }}
|
||||
</div>
|
||||
<template #action>
|
||||
<n-button size="small" @click="showClearConfirm = false">{{
|
||||
t('download.clear.cancel')
|
||||
}}</n-button>
|
||||
<n-button size="small" type="warning" @click="clearDownloadRecords">{{
|
||||
t('download.clear.confirm')
|
||||
}}</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -158,12 +218,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProgressStatus } from 'naive-ui';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import { audioService } from '@/services/audioService';
|
||||
// import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
// import { audioService } from '@/services/audioService';
|
||||
import { getImgUrl } from '@/utils';
|
||||
// import { SongResult } from '@/type/music';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface DownloadItem {
|
||||
filename: string;
|
||||
@@ -185,15 +250,23 @@ interface DownloadedItem {
|
||||
ar: { name: string }[];
|
||||
}
|
||||
|
||||
const store = useStore();
|
||||
const message = useMessage();
|
||||
const showDrawer = ref(false);
|
||||
const downloadList = ref<DownloadItem[]>([]);
|
||||
const downloadedList = ref<DownloadedItem[]>([]);
|
||||
// const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 获取播放状态
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
const currentMusic = computed(() => store.state.playMusic);
|
||||
const showDrawer = computed({
|
||||
get: () => settingsStore.showDownloadDrawer,
|
||||
set: (val) => {
|
||||
settingsStore.showDownloadDrawer = val;
|
||||
}
|
||||
});
|
||||
|
||||
const downloadList = ref<DownloadItem[]>([]);
|
||||
const downloadedList = ref<DownloadedItem[]>(
|
||||
JSON.parse(localStorage.getItem('downloadedList') || '[]')
|
||||
);
|
||||
|
||||
const downList = computed(() => downloadedList.value);
|
||||
|
||||
// 计算下载中的任务数量
|
||||
const downloadingCount = computed(() => {
|
||||
@@ -231,13 +304,13 @@ const getStatusType = (item: DownloadItem) => {
|
||||
const getStatusText = (item: DownloadItem) => {
|
||||
switch (item.status) {
|
||||
case 'downloading':
|
||||
return '下载中';
|
||||
return t('download.status.downloading');
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
return t('download.status.completed');
|
||||
case 'error':
|
||||
return '失败';
|
||||
return t('download.status.failed');
|
||||
default:
|
||||
return '未知';
|
||||
return t('download.status.unknown');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,8 +337,7 @@ const formatSize = (bytes: number) => {
|
||||
|
||||
// 打开目录
|
||||
const openDirectory = (path: string) => {
|
||||
const directory = path.substring(0, path.lastIndexOf('/'));
|
||||
window.electron.ipcRenderer.send('open-directory', directory);
|
||||
window.electron.ipcRenderer.send('open-directory', path);
|
||||
};
|
||||
|
||||
// 删除相关
|
||||
@@ -280,113 +352,107 @@ const handleDelete = (item: DownloadedItem) => {
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = async () => {
|
||||
if (!itemToDelete.value) return;
|
||||
const item = itemToDelete.value;
|
||||
if (!item) return;
|
||||
|
||||
try {
|
||||
const success = await window.electron.ipcRenderer.invoke(
|
||||
'delete-downloaded-music',
|
||||
itemToDelete.value.path
|
||||
item.path
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await refreshDownloadedList();
|
||||
message.success('删除成功');
|
||||
const newList = downloadedList.value.filter(i => i.id !== item.id);
|
||||
downloadedList.value = newList;
|
||||
localStorage.setItem('downloadedList', JSON.stringify(newList));
|
||||
message.success(t('download.delete.success'));
|
||||
} else {
|
||||
message.error('删除失败');
|
||||
message.warning(t('download.delete.fileNotFound'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete music:', error);
|
||||
message.error('删除失败');
|
||||
message.warning(t('download.delete.recordRemoved'));
|
||||
} finally {
|
||||
showDeleteConfirm.value = false;
|
||||
itemToDelete.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 播放音乐
|
||||
const handlePlayMusic = async (item: DownloadedItem) => {
|
||||
// 确保路径正确编码
|
||||
const encodedPath = encodeURIComponent(item.path);
|
||||
const localUrl = `local://${encodedPath}`;
|
||||
// 清空下载记录相关
|
||||
const showClearConfirm = ref(false);
|
||||
|
||||
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;
|
||||
// 清空下载记录
|
||||
const clearDownloadRecords = async () => {
|
||||
try {
|
||||
downloadedList.value = [];
|
||||
localStorage.setItem('downloadedList', '[]');
|
||||
await window.electron.ipcRenderer.invoke('clear-downloaded-music');
|
||||
message.success(t('download.clear.success'));
|
||||
} catch (error) {
|
||||
console.error('Failed to clear download records:', error);
|
||||
message.error(t('download.clear.failed'));
|
||||
} finally {
|
||||
showClearConfirm.value = false;
|
||||
}
|
||||
|
||||
// 播放新的音乐
|
||||
store.commit('setPlay', musicInfo);
|
||||
store.commit('setPlayMusic', true);
|
||||
store.commit('setIsPlay', true);
|
||||
|
||||
store.commit(
|
||||
'setPlayList',
|
||||
downloadedList.value.map((item) => ({
|
||||
...item,
|
||||
playMusicUrl: `local://${encodeURIComponent(item.path)}`
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
// 播放音乐
|
||||
// const handlePlay = async (musicInfo: SongResult) => {
|
||||
// await playerStore.setPlay(musicInfo);
|
||||
// playerStore.setPlayMusic(true);
|
||||
// playerStore.setIsPlay(true);
|
||||
// };
|
||||
|
||||
// 添加加载状态
|
||||
const isLoadingDownloaded = ref(false);
|
||||
|
||||
// 获取已下载音乐列表
|
||||
const refreshDownloadedList = async () => {
|
||||
if (isLoadingDownloaded.value) return; // 防止重复加载
|
||||
|
||||
try {
|
||||
isLoadingDownloaded.value = true;
|
||||
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
|
||||
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
downloadedList.value = [];
|
||||
localStorage.setItem('downloadedList', '[]');
|
||||
return;
|
||||
}
|
||||
|
||||
const songIds = list.filter((item) => item.id).map((item) => item.id);
|
||||
|
||||
// 如果有歌曲ID,获取详细信息
|
||||
if (songIds.length > 0) {
|
||||
try {
|
||||
const detailRes = await getMusicDetail(songIds);
|
||||
const songDetails = detailRes.data.songs.reduce((acc, song) => {
|
||||
acc[song.id] = song;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
downloadedList.value = list.map((item) => {
|
||||
const songDetail = songDetails[item.id];
|
||||
return {
|
||||
...item,
|
||||
picUrl: songDetail?.al?.picUrl || item.picUrl || '/images/default_cover.png',
|
||||
ar: songDetail?.ar || item.ar || [{ name: '本地音乐' }]
|
||||
};
|
||||
});
|
||||
} catch (detailError) {
|
||||
console.error('Failed to get music details:', detailError);
|
||||
downloadedList.value = list;
|
||||
}
|
||||
} else {
|
||||
const songIds = list.filter(item => item.id).map(item => item.id);
|
||||
if (songIds.length === 0) {
|
||||
downloadedList.value = list;
|
||||
localStorage.setItem('downloadedList', JSON.stringify(list));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const detailRes = await getMusicDetail(songIds);
|
||||
const songDetails = detailRes.data.songs.reduce((acc, song) => {
|
||||
acc[song.id] = song;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const updatedList = list.map(item => ({
|
||||
...item,
|
||||
picUrl: songDetails[item.id]?.al?.picUrl || item.picUrl || '/images/default_cover.png',
|
||||
ar: songDetails[item.id]?.ar || item.ar || [{ name: t('download.localMusic') }]
|
||||
}));
|
||||
|
||||
downloadedList.value = updatedList;
|
||||
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
|
||||
} catch (error) {
|
||||
console.error('Failed to get music details:', error);
|
||||
downloadedList.value = list;
|
||||
localStorage.setItem('downloadedList', JSON.stringify(list));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get downloaded music list:', error);
|
||||
downloadedList.value = [];
|
||||
localStorage.setItem('downloadedList', '[]');
|
||||
} finally {
|
||||
isLoadingDownloaded.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -394,7 +460,7 @@ const refreshDownloadedList = async () => {
|
||||
watch(
|
||||
() => showDrawer.value,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (newVal && !isLoadingDownloaded.value) {
|
||||
refreshDownloadedList();
|
||||
}
|
||||
}
|
||||
@@ -407,6 +473,12 @@ onMounted(() => {
|
||||
// 监听下载进度
|
||||
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||
|
||||
// 如果进度为100%,将状态设置为已完成
|
||||
if (data.progress === 100) {
|
||||
data.status = 'completed';
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
Object.assign(existingItem, {
|
||||
...data,
|
||||
@@ -426,15 +498,14 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
// 监听下载完成
|
||||
window.electron.ipcRenderer.on('music-download-complete', (_, data) => {
|
||||
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
|
||||
if (data.success) {
|
||||
// 从下载列表中移除
|
||||
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||
// 刷新已下载列表
|
||||
refreshDownloadedList();
|
||||
message.success(`${data.filename} 下载完成`);
|
||||
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
||||
// 延迟刷新已下载列表,避免文件系统未完全写入
|
||||
setTimeout(() => refreshDownloadedList(), 500);
|
||||
message.success(t('download.message.downloadComplete', { filename: data.filename }));
|
||||
} else {
|
||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||
const existingItem = downloadList.value.find(item => item.filename === data.filename);
|
||||
if (existingItem) {
|
||||
Object.assign(existingItem, {
|
||||
status: 'error',
|
||||
@@ -442,10 +513,10 @@ onMounted(() => {
|
||||
progress: 0
|
||||
});
|
||||
setTimeout(() => {
|
||||
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
|
||||
}, 3000);
|
||||
}
|
||||
message.error(`${data.filename} 下载失败: ${data.error}`);
|
||||
message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -465,6 +536,10 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
settingsStore.showDownloadDrawer = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -503,9 +578,18 @@ onMounted(() => {
|
||||
@apply flex-1 overflow-hidden pb-40;
|
||||
}
|
||||
|
||||
.downloaded-header {
|
||||
@apply flex items-center justify-between p-4 bg-light-100 dark:bg-dark-200 sticky top-0 z-10;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
.header-title {
|
||||
@apply text-sm font-medium text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.download-items,
|
||||
.downloaded-items {
|
||||
@apply space-y-3;
|
||||
@apply space-y-3 p-4;
|
||||
}
|
||||
|
||||
.total-progress {
|
||||
|
||||
@@ -13,24 +13,28 @@
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<h2 class="app-name">Alger Music Player {{ config.version }}</h2>
|
||||
<p class="app-desc mb-2">在桌面安装应用,获得更好的体验</p>
|
||||
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
|
||||
<p class="app-desc mb-2">{{ t('comp.installApp.description') }}</p>
|
||||
<n-checkbox v-model:checked="noPrompt">{{ t('comp.installApp.noPrompt') }}</n-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
|
||||
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
|
||||
<n-button class="cancel-btn" @click="closeModal">{{
|
||||
t('comp.installApp.cancel')
|
||||
}}</n-button>
|
||||
<n-button type="primary" class="install-btn" @click="handleInstall">{{
|
||||
t('comp.installApp.install')
|
||||
}}</n-button>
|
||||
</div>
|
||||
<div class="modal-desc mt-4 text-center">
|
||||
<p class="text-xs text-gray-400">
|
||||
下载遇到问题?去
|
||||
{{ t('comp.installApp.downloadProblem') }}
|
||||
<a
|
||||
class="text-green-500"
|
||||
target="_blank"
|
||||
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
|
||||
>GitHub</a
|
||||
>
|
||||
下载最新版本
|
||||
{{ t('comp.installApp.downloadProblemLinkText') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,12 +43,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { isElectron, isMobile } from '@/utils';
|
||||
import { getLatestReleaseInfo } from '@/utils/update';
|
||||
import { getLatestReleaseInfo, getProxyNodes } from '@/utils/update';
|
||||
|
||||
import config from '../../../../package.json';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showModal = ref(false);
|
||||
const noPrompt = ref(false);
|
||||
const releaseInfo = ref<any>(null);
|
||||
@@ -56,6 +63,8 @@ const closeModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const proxyHosts = ref<string[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
// 如果是 electron 环境,不显示安装提示
|
||||
if (isElectron || isMobile.value) {
|
||||
@@ -71,6 +80,7 @@ onMounted(async () => {
|
||||
// 获取最新版本信息
|
||||
releaseInfo.value = await getLatestReleaseInfo();
|
||||
showModal.value = true;
|
||||
proxyHosts.value = await getProxyNodes();
|
||||
});
|
||||
|
||||
const handleInstall = async (): Promise<void> => {
|
||||
@@ -111,7 +121,8 @@ const handleInstall = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
if (downloadUrl) {
|
||||
window.open(`https://ghproxy.cn/${downloadUrl}`, '_blank');
|
||||
const proxyDownloadUrl = `${proxyHosts.value[0]}/${downloadUrl}`;
|
||||
window.open(proxyDownloadUrl, '_blank');
|
||||
} else {
|
||||
// 如果没有找到对应的安装包,跳转到 release 页面
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
</template>
|
||||
|
||||
<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({
|
||||
height: {
|
||||
type: String,
|
||||
|
||||
370
src/renderer/components/common/PlaylistDrawer.vue
Normal file
370
src/renderer/components/common/PlaylistDrawer.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<n-drawer
|
||||
:show="modelValue"
|
||||
:width="400"
|
||||
placement="right"
|
||||
@update:show="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer">
|
||||
<n-scrollbar class="h-full">
|
||||
<div class="playlist-drawer">
|
||||
<!-- 创建新歌单按钮和表单 -->
|
||||
<div class="create-playlist-section">
|
||||
<div
|
||||
class="create-playlist-button"
|
||||
:class="{ 'is-expanded': isCreating }"
|
||||
@click="toggleCreateForm"
|
||||
>
|
||||
<div class="create-playlist-icon">
|
||||
<i class="iconfont" :class="isCreating ? 'ri-close-line' : 'ri-add-line'"></i>
|
||||
</div>
|
||||
<div class="create-playlist-text">
|
||||
{{
|
||||
isCreating
|
||||
? t('comp.playlistDrawer.cancelCreate')
|
||||
: t('comp.playlistDrawer.createPlaylist')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建歌单表单 -->
|
||||
<div class="create-playlist-form" :class="{ 'is-visible': isCreating }">
|
||||
<n-input
|
||||
v-model:value="formValue.name"
|
||||
:placeholder="t('comp.playlistDrawer.playlistName')"
|
||||
maxlength="40"
|
||||
class="mac-style-input"
|
||||
:status="inputError ? 'error' : undefined"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="iconfont ri-music-2-line"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
<div class="privacy-switch">
|
||||
<div class="privacy-label">
|
||||
<i
|
||||
class="iconfont"
|
||||
:class="formValue.privacy ? 'ri-lock-line' : 'ri-earth-line'"
|
||||
></i>
|
||||
<span>{{
|
||||
formValue.privacy
|
||||
? t('comp.playlistDrawer.privatePlaylist')
|
||||
: t('comp.playlistDrawer.publicPlaylist')
|
||||
}}</span>
|
||||
</div>
|
||||
<n-switch v-model:value="formValue.privacy" class="mac-style-switch">
|
||||
<template #checked>{{ t('comp.playlistDrawer.private') }}</template>
|
||||
<template #unchecked>{{ t('comp.playlistDrawer.public') }}</template>
|
||||
</n-switch>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<n-button
|
||||
type="primary"
|
||||
quaternary
|
||||
class="mac-style-button"
|
||||
:loading="creating"
|
||||
:disabled="!formValue.name"
|
||||
@click="handleCreatePlaylist"
|
||||
>
|
||||
{{ t('comp.playlistDrawer.create') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌单列表 -->
|
||||
<div class="playlist-list">
|
||||
<div
|
||||
v-for="playlist in playlists"
|
||||
:key="playlist.id"
|
||||
class="playlist-item"
|
||||
@click="handleAddToPlaylist(playlist)"
|
||||
>
|
||||
<n-image
|
||||
:src="getImgUrl(playlist.coverImgUrl || playlist.picUrl, '100y100')"
|
||||
class="playlist-item-img"
|
||||
preview-disabled
|
||||
:img-props="{
|
||||
crossorigin: 'anonymous'
|
||||
}"
|
||||
/>
|
||||
<div class="playlist-item-info">
|
||||
<div class="playlist-item-name">{{ playlist.name }}</div>
|
||||
<div class="playlist-item-count">
|
||||
{{ playlist.trackCount }}
|
||||
{{ t('comp.playlistDrawer.count') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-item-action">
|
||||
<i class="iconfont ri-add-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { createPlaylist, updatePlaylistTracks } from '@/api/music';
|
||||
import { getUserPlaylist } from '@/api/user';
|
||||
import { useUserStore } from '@/store';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
const store = useUserStore();
|
||||
const { t } = useI18n();
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
songId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const message = useMessage();
|
||||
const playlists = ref<any[]>([]);
|
||||
const creating = ref(false);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const formValue = ref({
|
||||
name: '',
|
||||
privacy: false
|
||||
});
|
||||
|
||||
const inputError = computed(() => {
|
||||
return isCreating.value && !formValue.value.name;
|
||||
});
|
||||
|
||||
const toggleCreateForm = () => {
|
||||
if (creating.value) return;
|
||||
isCreating.value = !isCreating.value;
|
||||
if (!isCreating.value) {
|
||||
formValue.value.name = '';
|
||||
formValue.value.privacy = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取用户歌单
|
||||
const fetchUserPlaylists = async () => {
|
||||
try {
|
||||
const { user } = store;
|
||||
if (!user?.userId) {
|
||||
message.error(t('comp.playlistDrawer.loginFirst'));
|
||||
emit('update:modelValue', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getUserPlaylist(user.userId);
|
||||
if (res.data?.playlist) {
|
||||
playlists.value = res.data.playlist;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取歌单失败:', error);
|
||||
message.error(t('comp.playlistDrawer.getPlaylistFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 添加到歌单
|
||||
const handleAddToPlaylist = async (playlist: any) => {
|
||||
if (!props.songId) return;
|
||||
try {
|
||||
const res = await updatePlaylistTracks({
|
||||
op: 'add',
|
||||
pid: playlist.id,
|
||||
tracks: props.songId.toString()
|
||||
});
|
||||
console.log('res.data', res.data);
|
||||
|
||||
if (res.status === 200) {
|
||||
message.success(t('comp.playlistDrawer.addSuccess'));
|
||||
emit('update:modelValue', false);
|
||||
} else {
|
||||
throw new Error(res.data?.msg || t('comp.playlistDrawer.addFailed'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('添加到歌单失败:', error);
|
||||
message.error(error.message || t('comp.playlistDrawer.addFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 创建歌单
|
||||
const handleCreatePlaylist = async () => {
|
||||
if (!formValue.value.name) {
|
||||
message.error(t('comp.playlistDrawer.inputPlaylistName'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
creating.value = true;
|
||||
|
||||
const res = await createPlaylist({
|
||||
name: formValue.value.name,
|
||||
privacy: formValue.value.privacy ? 10 : 0
|
||||
});
|
||||
|
||||
if (res.data?.id) {
|
||||
message.success(t('comp.playlistDrawer.createSuccess'));
|
||||
isCreating.value = false;
|
||||
formValue.value.name = '';
|
||||
formValue.value.privacy = false;
|
||||
await fetchUserPlaylists();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建歌单失败:', error);
|
||||
message.error(t('comp.playlistDrawer.createFailed'));
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听显示状态变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
fetchUserPlaylists();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mac-style-drawer {
|
||||
@apply h-full;
|
||||
|
||||
:deep(.n-drawer-header__main) {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
|
||||
:deep(.n-drawer-content) {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
:deep(.n-drawer-content-wrapper) {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
:deep(.n-scrollbar-rail) {
|
||||
@apply right-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-drawer {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
.create-playlist-section {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.create-playlist-button {
|
||||
@apply flex items-center gap-4 p-3 rounded-xl cursor-pointer transition-all duration-200
|
||||
bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700;
|
||||
|
||||
&.is-expanded {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
|
||||
.create-playlist-icon {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
@apply w-10 h-10 rounded-xl bg-green-500 flex items-center justify-center text-white
|
||||
transition-all duration-300;
|
||||
|
||||
.iconfont {
|
||||
@apply text-xl transition-transform duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
&-text {
|
||||
@apply text-sm font-medium transition-colors duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
.create-playlist-form {
|
||||
@apply max-h-0 overflow-hidden transition-all duration-300 ease-in-out opacity-0;
|
||||
|
||||
&.is-visible {
|
||||
@apply max-h-[200px] mt-4 opacity-100;
|
||||
}
|
||||
|
||||
.mac-style-input {
|
||||
@apply rounded-lg;
|
||||
:deep(.n-input-wrapper) {
|
||||
@apply bg-gray-50 dark:bg-gray-800 border-0;
|
||||
}
|
||||
:deep(.n-input__input) {
|
||||
@apply text-sm;
|
||||
}
|
||||
:deep(.n-input__prefix) {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@apply mt-4;
|
||||
.mac-style-button {
|
||||
@apply w-full rounded-lg text-sm py-2 bg-green-500 hover:bg-green-600 text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-switch {
|
||||
@apply flex items-center justify-between mt-4 px-2;
|
||||
|
||||
.privacy-label {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-switch) {
|
||||
@apply h-5 min-w-[40px];
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
@apply flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all duration-200
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800;
|
||||
|
||||
&-img {
|
||||
@apply w-10 h-10 rounded-xl;
|
||||
}
|
||||
|
||||
&-info {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
&-name {
|
||||
@apply text-sm font-medium truncate;
|
||||
}
|
||||
|
||||
&-count {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
&-action {
|
||||
@apply w-8 h-8 rounded-lg flex items-center justify-center
|
||||
text-gray-400 hover:text-green-500 transition-colors duration-200;
|
||||
|
||||
.iconfont {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<n-image
|
||||
class="w-full h-full"
|
||||
@@ -40,11 +40,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getAlbum, getListDetail } from '@/api/list';
|
||||
import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
@@ -72,6 +71,8 @@ const songList = ref<any[]>([]);
|
||||
const showPop = ref(false);
|
||||
const listInfo = ref<any>(null);
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const getCurrentMv = () => {
|
||||
return {
|
||||
id: props.item.id,
|
||||
@@ -79,8 +80,6 @@ const getCurrentMv = () => {
|
||||
} as unknown as IMvItem;
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const handleClick = async () => {
|
||||
listInfo.value = null;
|
||||
if (props.item.type === '专辑') {
|
||||
@@ -108,12 +107,16 @@ const handleClick = async () => {
|
||||
}
|
||||
|
||||
if (props.item.type === 'mv') {
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
showPop.value = true;
|
||||
handleShowMv();
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowMv = async () => {
|
||||
playerStore.setIsPlay(false);
|
||||
playerStore.setPlayMusic(false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
showPop.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -168,15 +171,15 @@ const handleClick = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.mv {
|
||||
.search-item.mv {
|
||||
&:hover {
|
||||
.play {
|
||||
@apply opacity-60;
|
||||
}
|
||||
}
|
||||
.search-item-img {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
width: 160px !important;
|
||||
height: 90px !important;
|
||||
@apply rounded-lg relative;
|
||||
}
|
||||
.play {
|
||||
|
||||
@@ -73,9 +73,10 @@
|
||||
<n-dropdown
|
||||
v-if="isElectron"
|
||||
:show="showDropdown"
|
||||
:options="dropdownOptions"
|
||||
:x="dropdownX"
|
||||
:y="dropdownY"
|
||||
:options="dropdownOptions"
|
||||
:z-index="99999"
|
||||
placement="bottom-start"
|
||||
@clickoutside="showDropdown = false"
|
||||
@select="handleSelect"
|
||||
@@ -86,16 +87,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import type { MenuOption } from 'naive-ui';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, h, ref, useTemplateRef } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { NImage, NText, useMessage } from 'naive-ui';
|
||||
import { computed, h, inject, ref, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getSongUrl } from '@/hooks/MusicListHook';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore } from '@/store';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { getImgUrl, isElectron } from '@/utils';
|
||||
import { getImageBackground } from '@/utils/linearColor';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: SongResult;
|
||||
@@ -104,21 +109,24 @@ const props = withDefaults(
|
||||
favorite?: boolean;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
canRemove?: boolean;
|
||||
}>(),
|
||||
{
|
||||
mini: false,
|
||||
list: false,
|
||||
favorite: true,
|
||||
selectable: false,
|
||||
selected: false
|
||||
selected: false,
|
||||
canRemove: false
|
||||
}
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
const playMusic = computed(() => store.state.playMusic);
|
||||
const play = computed(() => playerStore.isPlay);
|
||||
const playMusic = computed(() => playerStore.playMusic);
|
||||
const playLoading = computed(
|
||||
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
|
||||
);
|
||||
@@ -132,14 +140,116 @@ const dropdownY = ref(0);
|
||||
|
||||
const isDownloading = ref(false);
|
||||
|
||||
const dropdownOptions = computed<MenuOption[]>(() => [
|
||||
{
|
||||
label: isDownloading.value ? '下载中...' : `下载 ${props.item.name}`,
|
||||
key: 'download',
|
||||
icon: () => h('i', { class: 'iconfont ri-download-line' }),
|
||||
disabled: isDownloading.value
|
||||
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
|
||||
|
||||
const { navigateToArtist } = useArtist();
|
||||
|
||||
const renderSongPreview = () => {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex items-center gap-3 px-2 py-1 dark:border-gray-800'
|
||||
},
|
||||
[
|
||||
h(NImage, {
|
||||
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
|
||||
class: 'w-10 h-10 rounded-lg flex-shrink-0',
|
||||
previewDisabled: true,
|
||||
imgProps: {
|
||||
crossorigin: 'anonymous'
|
||||
}
|
||||
}),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex-1 min-w-0 py-1'
|
||||
},
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'mb-1'
|
||||
},
|
||||
[
|
||||
h(
|
||||
NText,
|
||||
{
|
||||
depth: 1,
|
||||
class: 'text-sm font-medium'
|
||||
},
|
||||
{
|
||||
default: () => props.item.name
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const dropdownOptions = computed<MenuOption[]>(() => {
|
||||
const options: MenuOption[] = [
|
||||
{
|
||||
key: 'header',
|
||||
type: 'render',
|
||||
render: renderSongPreview
|
||||
},
|
||||
{
|
||||
key: 'divider1',
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
label: t('songItem.menu.play'),
|
||||
key: 'play',
|
||||
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
|
||||
},
|
||||
{
|
||||
label: t('songItem.menu.playNext'),
|
||||
key: 'playNext',
|
||||
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
key: 'd1'
|
||||
},
|
||||
{
|
||||
label: t('songItem.menu.download'),
|
||||
key: 'download',
|
||||
icon: () => h('i', { class: 'iconfont ri-download-line' })
|
||||
},
|
||||
{
|
||||
label: t('songItem.menu.addToPlaylist'),
|
||||
key: 'addToPlaylist',
|
||||
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
|
||||
},
|
||||
{
|
||||
label: isFavorite.value ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
|
||||
key: 'favorite',
|
||||
icon: () =>
|
||||
h('i', {
|
||||
class: `iconfont ${isFavorite.value ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
if (props.canRemove) {
|
||||
options.push(
|
||||
{
|
||||
type: 'divider',
|
||||
key: 'd2'
|
||||
},
|
||||
{
|
||||
label: t('songItem.menu.removeFromPlaylist'),
|
||||
key: 'remove',
|
||||
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
|
||||
}
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -152,40 +262,53 @@ const handleSelect = (key: string | number) => {
|
||||
showDropdown.value = false;
|
||||
if (key === 'download') {
|
||||
downloadMusic();
|
||||
} else if (key === 'playNext') {
|
||||
handlePlayNext();
|
||||
} else if (key === 'addToPlaylist') {
|
||||
openPlaylistDrawer?.(props.item.id);
|
||||
} else if (key === 'favorite') {
|
||||
toggleFavorite(new Event('click'));
|
||||
} else if (key === 'play') {
|
||||
playMusicEvent(props.item);
|
||||
} else if (key === 'remove') {
|
||||
emits('remove-song', props.item.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载音乐
|
||||
const downloadMusic = async () => {
|
||||
if (isDownloading.value) {
|
||||
message.warning('正在下载中,请稍候...');
|
||||
message.warning(t('songItem.message.downloading'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
throw new Error('获取音乐下载地址失败');
|
||||
throw new Error(t('songItem.message.getUrlFailed'));
|
||||
}
|
||||
|
||||
// 构建文件名
|
||||
const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(',');
|
||||
const filename = `${props.item.name} - ${artistNames}`;
|
||||
console.log('props.item', props.item);
|
||||
|
||||
const songData = cloneDeep(props.item);
|
||||
songData.ar = songData.ar || songData.song?.artists;
|
||||
// 发送下载请求
|
||||
window.electron.ipcRenderer.send('download-music', {
|
||||
url: data.url,
|
||||
type: data.type,
|
||||
filename,
|
||||
songInfo: {
|
||||
...cloneDeep(props.item),
|
||||
...songData,
|
||||
downloadTime: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
message.success('已加入下载队列');
|
||||
message.success(t('songItem.message.downloadQueued'));
|
||||
|
||||
// 监听下载完成事件
|
||||
const handleDownloadComplete = (_, result) => {
|
||||
@@ -218,11 +341,11 @@ const downloadMusic = async () => {
|
||||
} catch (error: any) {
|
||||
console.error('Download error:', error);
|
||||
isDownloading.value = false;
|
||||
message.error(error.message || '下载失败');
|
||||
message.error(error.message || t('songItem.message.downloadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const emits = defineEmits(['play', 'select']);
|
||||
const emits = defineEmits(['play', 'select', 'remove-song']);
|
||||
const songImageRef = useTemplateRef('songImg');
|
||||
|
||||
const imageLoad = async () => {
|
||||
@@ -238,33 +361,48 @@ const imageLoad = async () => {
|
||||
|
||||
// 播放音乐 设置音乐详情 打开音乐底栏
|
||||
const playMusicEvent = async (item: SongResult) => {
|
||||
// 如果是当前正在播放的音乐,则切换播放/暂停状态
|
||||
if (playMusic.value.id === item.id) {
|
||||
if (play.value) {
|
||||
store.commit('setPlayMusic', false);
|
||||
playerStore.setPlayMusic(false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
playerStore.setPlayMusic(true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await store.commit('setPlay', item);
|
||||
store.commit('setIsPlay', true);
|
||||
emits('play', item);
|
||||
|
||||
try {
|
||||
// 使用store的setPlay方法,该方法已经包含了B站视频URL处理逻辑
|
||||
const result = await playerStore.setPlay(item);
|
||||
if (!result) {
|
||||
throw new Error('播放失败');
|
||||
}
|
||||
playerStore.isPlay = true;
|
||||
emits('play', item);
|
||||
} catch (error) {
|
||||
console.error('播放出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否已收藏
|
||||
const isFavorite = computed(() => {
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
// 将id转换为number,兼容B站视频ID
|
||||
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
|
||||
|
||||
if (isFavorite.value) {
|
||||
store.commit('removeFromFavorite', props.item.id);
|
||||
playerStore.removeFromFavorite(numericId);
|
||||
} else {
|
||||
store.commit('addToFavorite', props.item.id);
|
||||
playerStore.addToFavorite(numericId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,13 +412,19 @@ const toggleSelect = () => {
|
||||
};
|
||||
|
||||
const handleArtistClick = (id: number) => {
|
||||
store.commit('setCurrentArtistId', id);
|
||||
navigateToArtist(id);
|
||||
};
|
||||
|
||||
// 获取歌手列表(最多显示5个)
|
||||
const artists = computed(() => {
|
||||
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
|
||||
});
|
||||
|
||||
// 添加到下一首播放
|
||||
const handlePlayNext = () => {
|
||||
playerStore.addToNextPlay(props.item);
|
||||
message.success(t('songItem.message.addedToNextPlay'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -450,4 +594,56 @@ const artists = computed(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-dropdown-menu) {
|
||||
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
|
||||
|
||||
.n-dropdown-option {
|
||||
@apply h-9 text-sm;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.n-dropdown-option-body {
|
||||
@apply h-full;
|
||||
|
||||
.n-dropdown-option-body__prefix {
|
||||
@apply w-8 flex justify-center items-center;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.n-dropdown-divider {
|
||||
@apply my-1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.song-preview) {
|
||||
@apply flex items-center gap-3 p-3 border-b dark:border-gray-800;
|
||||
|
||||
.n-image {
|
||||
@apply w-12 h-12 rounded-lg flex-shrink-0;
|
||||
}
|
||||
|
||||
.song-preview-info {
|
||||
@apply flex-1 min-w-0 py-1;
|
||||
|
||||
.song-preview-name {
|
||||
@apply text-sm font-medium truncate mb-1;
|
||||
}
|
||||
|
||||
.song-preview-artist {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-dropdown-option-body--render) {
|
||||
@apply p-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
v-model:show="showModal"
|
||||
preset="dialog"
|
||||
:show-icon="false"
|
||||
:mask-closable="true"
|
||||
:mask-closable="!downloading"
|
||||
:closable="!downloading"
|
||||
class="update-app-modal"
|
||||
style="width: 800px; max-width: 90vw"
|
||||
>
|
||||
@@ -13,9 +14,10 @@
|
||||
<img src="@/assets/logo.png" alt="App Icon" />
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<h2 class="app-name">发现新版本 {{ updateInfo.latestVersion }}</h2>
|
||||
<p class="app-desc mb-2">当前版本 {{ updateInfo.currentVersion }}</p>
|
||||
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
|
||||
<h2 class="app-name">{{ t('comp.update.title') }} {{ updateInfo.latestVersion }}</h2>
|
||||
<p class="app-desc mb-2">
|
||||
{{ t('comp.update.currentVersion') }} {{ updateInfo.currentVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-info">
|
||||
@@ -23,20 +25,43 @@
|
||||
<div class="update-body" v-html="parsedReleaseNotes"></div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<n-button class="cancel-btn" @click="closeModal">暂不更新</n-button>
|
||||
<n-button type="primary" class="update-btn" @click="handleUpdate">立即更新</n-button>
|
||||
<div v-if="downloading" class="download-status mt-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-500">{{ downloadStatus }}</span>
|
||||
<span class="text-sm font-medium">{{ downloadProgress }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar" :style="{ width: `${downloadProgress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-desc mt-4 text-center">
|
||||
<div class="modal-actions" :class="{ 'mt-6': !downloading }">
|
||||
<n-button class="cancel-btn" :disabled="downloading" @click="closeModal">
|
||||
{{ t('comp.update.cancel') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!downloading"
|
||||
type="primary"
|
||||
class="update-btn"
|
||||
:disabled="downloading"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
{{ downloadBtnText }}
|
||||
</n-button>
|
||||
<!-- 后台下载 -->
|
||||
<n-button v-else class="update-btn" type="primary" @click="closeModal">
|
||||
{{ t('comp.update.backgroundDownload') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="!downloading" class="modal-desc mt-4 text-center">
|
||||
<p class="text-xs text-gray-400">
|
||||
下载遇到问题?去
|
||||
{{ t('comp.installApp.downloadProblem') }}
|
||||
<a
|
||||
class="text-green-500"
|
||||
target="_blank"
|
||||
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
|
||||
>GitHub</a
|
||||
>
|
||||
下载最新版本
|
||||
{{ t('comp.installApp.downloadProblemLinkText') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,21 +70,31 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { computed, h, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { checkUpdate, UpdateResult } from '@/utils/update';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { checkUpdate, getProxyNodes, UpdateResult } from '@/utils/update';
|
||||
|
||||
import config from '../../../../package.json';
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialog = useDialog();
|
||||
const message = useMessage();
|
||||
|
||||
// 配置 marked
|
||||
marked.setOptions({
|
||||
breaks: true, // 支持 GitHub 风格的换行
|
||||
gfm: true // 启用 GitHub 风格的 Markdown
|
||||
});
|
||||
|
||||
const showModal = ref(false);
|
||||
const noPrompt = ref(false);
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const showModal = computed({
|
||||
get: () => settingsStore.showUpdateModal,
|
||||
set: (val) => settingsStore.setShowUpdateModal(val)
|
||||
});
|
||||
|
||||
const updateInfo = ref<UpdateResult>({
|
||||
hasUpdate: false,
|
||||
latestVersion: '',
|
||||
@@ -67,28 +102,6 @@ const updateInfo = ref<UpdateResult>({
|
||||
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
|
||||
const parsedReleaseNotes = computed(() => {
|
||||
if (!updateInfo.value.releaseInfo?.body) return '';
|
||||
@@ -102,9 +115,6 @@ const parsedReleaseNotes = computed(() => {
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
if (noPrompt.value) {
|
||||
localStorage.setItem('updatePromptDismissed', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
@@ -112,21 +122,117 @@ const checkForUpdates = async () => {
|
||||
const result = await checkUpdate(config.version);
|
||||
if (result) {
|
||||
updateInfo.value = result;
|
||||
if (localStorage.getItem('updatePromptDismissed') !== 'true') {
|
||||
showModal.value = true;
|
||||
}
|
||||
showModal.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const downloading = ref(false);
|
||||
const downloadProgress = ref(0);
|
||||
const downloadStatus = ref(t('comp.update.prepareDownload'));
|
||||
const downloadBtnText = computed(() => {
|
||||
if (downloading.value) return t('comp.update.downloading');
|
||||
return t('comp.update.nowUpdate');
|
||||
});
|
||||
|
||||
// 下载完成后的文件路径
|
||||
const downloadedFilePath = ref('');
|
||||
// 防止对话框重复弹出
|
||||
const isDialogShown = ref(false);
|
||||
|
||||
// 处理下载状态更新
|
||||
const handleDownloadProgress = (_event: any, progress: number, status: string) => {
|
||||
downloadProgress.value = progress;
|
||||
downloadStatus.value = status;
|
||||
};
|
||||
|
||||
// 处理下载完成
|
||||
const handleDownloadComplete = (_event: any, success: boolean, filePath: string) => {
|
||||
downloading.value = false;
|
||||
closeModal();
|
||||
|
||||
if (success && !isDialogShown.value) {
|
||||
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(() => {
|
||||
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-complete', handleDownloadComplete);
|
||||
});
|
||||
|
||||
// 清理事件监听
|
||||
onUnmounted(() => {
|
||||
window.electron.ipcRenderer.removeListener('download-progress', handleDownloadProgress);
|
||||
window.electron.ipcRenderer.removeListener('download-complete', handleDownloadComplete);
|
||||
isDialogShown.value = false;
|
||||
});
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const assets = updateInfo.value.releaseInfo?.assets || [];
|
||||
const { platform } = window.electron.process;
|
||||
const arch = window.electron.ipcRenderer.sendSync('get-arch');
|
||||
console.log('arch', arch);
|
||||
console.log('platform', platform);
|
||||
const version = updateInfo.value.latestVersion;
|
||||
const downUrls = {
|
||||
win32: {
|
||||
@@ -170,16 +276,27 @@ const handleUpdate = async () => {
|
||||
}
|
||||
|
||||
if (downloadUrl) {
|
||||
window.open(`https://www.ghproxy.cn/${downloadUrl}`, '_blank');
|
||||
try {
|
||||
downloading.value = true;
|
||||
downloadStatus.value = t('comp.update.prepareDownload');
|
||||
isDialogShown.value = false;
|
||||
|
||||
// 获取代理节点列表
|
||||
const proxyHosts = await getProxyNodes();
|
||||
const proxyDownloadUrl = `${proxyHosts[0]}/${downloadUrl}`;
|
||||
|
||||
// 发送所有可能的下载地址到主进程
|
||||
window.electron.ipcRenderer.send('start-download', proxyDownloadUrl);
|
||||
} catch (error) {
|
||||
downloading.value = false;
|
||||
message.error(t('comp.update.startFailed'));
|
||||
console.error('下载失败:', error);
|
||||
}
|
||||
} else {
|
||||
// 如果没有找到对应的安装包,跳转到 release 页面
|
||||
message.error(t('comp.update.noDownloadUrl'));
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -266,8 +383,18 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
.download-status {
|
||||
@apply p-2;
|
||||
.progress-bar-wrapper {
|
||||
@apply w-full h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden;
|
||||
.progress-bar {
|
||||
@apply h-full bg-green-500 rounded-full transition-all duration-300 ease-out;
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal-actions {
|
||||
@apply flex gap-4 mt-6;
|
||||
@apply flex gap-4;
|
||||
.n-button {
|
||||
@apply flex-1 text-base py-2;
|
||||
}
|
||||
@@ -276,12 +403,125 @@ onMounted(() => {
|
||||
&:hover {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
&:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
.update-btn {
|
||||
@apply bg-green-600 border-none;
|
||||
&:hover {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
&:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<!-- 歌单分类列表 -->
|
||||
<div class="play-list-type">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
{{ t('comp.playlistType.title') }}
|
||||
</div>
|
||||
<div>
|
||||
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
|
||||
<span
|
||||
@@ -34,7 +36,9 @@
|
||||
"
|
||||
@click="handleToggleShowAllPlaylistCategory"
|
||||
>
|
||||
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
|
||||
{{
|
||||
!isShowAllPlaylistCategory ? t('comp.playlistType.showAll') : t('comp.playlistType.hide')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,11 +46,14 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
import type { IPlayListSort } from '@/type/playlist';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
// 歌单分类
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
// 是否显示全部歌单分类
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="recommend-album">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">最新专辑</div>
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">
|
||||
{{ t('comp.recommendAlbum.title') }}
|
||||
</div>
|
||||
<div class="recommend-album-list">
|
||||
<template v-for="(item, index) in albumData?.albums" :key="item.id">
|
||||
<div
|
||||
@@ -24,7 +26,7 @@
|
||||
v-model:show="showMusic"
|
||||
:name="albumName"
|
||||
:song-list="songList"
|
||||
:cover="false"
|
||||
:cover="true"
|
||||
:loading="loadingList"
|
||||
:list-info="albumInfo"
|
||||
/>
|
||||
@@ -33,6 +35,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getNewAlbum } from '@/api/home';
|
||||
import { getAlbum } from '@/api/list';
|
||||
@@ -40,6 +43,7 @@ import MusicList from '@/components/MusicList.vue';
|
||||
import type { IAlbumNew } from '@/type/album';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const albumData = ref<IAlbumNew>();
|
||||
const loadAlbumList = async () => {
|
||||
const { data } = await getNewAlbum();
|
||||
@@ -58,17 +62,19 @@ const handleClick = async (item: any) => {
|
||||
loadingList.value = true;
|
||||
showMusic.value = true;
|
||||
const res = await getAlbum(item.id);
|
||||
songList.value = res.data.songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || item.picUrl;
|
||||
const { songs, album } = res.data;
|
||||
songList.value = songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || album.picUrl;
|
||||
song.picUrl = song.al.picUrl || album.picUrl || song.picUrl;
|
||||
return song;
|
||||
});
|
||||
albumInfo.value = {
|
||||
...res.data.album,
|
||||
...album,
|
||||
creator: {
|
||||
avatarUrl: res.data.album.artist.img1v1Url,
|
||||
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
|
||||
avatarUrl: album.artist.img1v1Url,
|
||||
nickname: `${album.artist.name} - ${album.company}`
|
||||
},
|
||||
description: res.data.album.description
|
||||
description: album.description
|
||||
};
|
||||
loadingList.value = false;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="recommend-music">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
{{ t('comp.recommendSonglist.title') }}
|
||||
</div>
|
||||
<div
|
||||
v-show="recommendMusic?.result"
|
||||
v-loading="loading"
|
||||
@@ -21,15 +23,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
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 { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
import SongItem from './common/SongItem.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const playerStore = usePlayerStore();
|
||||
// 推荐歌曲
|
||||
const recommendMusic = ref<IRecommendMusic>();
|
||||
const loading = ref(false);
|
||||
@@ -48,7 +51,9 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
store.commit('setPlayList', recommendMusic.value?.result);
|
||||
if (recommendMusic.value?.result) {
|
||||
playerStore.setPlayList(recommendMusic.value.result);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
566
src/renderer/components/home/TopBanner.vue
Normal file
566
src/renderer/components/home/TopBanner.vue
Normal file
@@ -0,0 +1,566 @@
|
||||
<template>
|
||||
<div class="recommend-singer">
|
||||
<div class="recommend-singer-list">
|
||||
<n-carousel
|
||||
v-if="hotSingerData?.artists.length"
|
||||
slides-per-view="auto"
|
||||
:show-dots="false"
|
||||
:space-between="20"
|
||||
draggable
|
||||
show-arrow
|
||||
:autoplay="false"
|
||||
>
|
||||
<n-carousel-item
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="getCarouselItemStyle(0, 100, 6)"
|
||||
>
|
||||
<div v-if="dayRecommendData" class="recommend-singer-item relative">
|
||||
<div
|
||||
:style="
|
||||
setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '500y500'))
|
||||
"
|
||||
class="recommend-singer-item-bg"
|
||||
></div>
|
||||
<div
|
||||
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
|
||||
@click="showMusic = true"
|
||||
>
|
||||
<div class="font-bold text-lg">
|
||||
{{ t('comp.recommendSinger.title') }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<p
|
||||
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)"
|
||||
:key="item.id"
|
||||
class="text-el"
|
||||
>
|
||||
{{ item.name }}
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-carousel-item>
|
||||
|
||||
<n-carousel-item
|
||||
v-if="userStore.user && userPlaylist.length"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="getCarouselItemStyleForPlaylist(userPlaylist.length)"
|
||||
>
|
||||
<div class="user-play">
|
||||
<div class="user-play-title mb-3">
|
||||
{{ t('comp.userPlayList.title', { name: userStore.user?.nickname }) }}
|
||||
</div>
|
||||
<div class="user-play-list" :class="getPlaylistGridClass(userPlaylist.length)">
|
||||
<div
|
||||
v-for="item in userPlaylist"
|
||||
:key="item.id"
|
||||
class="user-play-item"
|
||||
@click="toPlaylist(item.id)"
|
||||
>
|
||||
<div class="user-play-item-img">
|
||||
<img :src="getImgUrl(item.coverImgUrl, '200y200')" alt="" />
|
||||
<div class="user-play-item-title">
|
||||
<div class="user-play-item-title-name">{{ item.name }}</div>
|
||||
|
||||
<div class="user-play-item-list">
|
||||
<div
|
||||
v-for="song in item.tracks"
|
||||
:key="song.id"
|
||||
class="user-play-item-list-name"
|
||||
>
|
||||
{{ song.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-play-item-count">
|
||||
<div class="user-play-item-count-tag">
|
||||
{{ t('common.songCount', { count: item.trackCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-play-item-direct-play" @click.stop="handlePlayPlaylist(item.id)">
|
||||
<i class="iconfont icon-playfill text-xl text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-carousel-item>
|
||||
<n-carousel-item
|
||||
v-for="(item, index) in hotSingerData?.artists"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="getCarouselItemStyle(index + 1, 100, 6)"
|
||||
>
|
||||
<div
|
||||
class="recommend-singer-item relative"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="setAnimationDelay(index + 2, 100)"
|
||||
@click="handleArtistClick(item.id)"
|
||||
>
|
||||
<div
|
||||
:style="setBackgroundImg(getImgUrl(item.picUrl, '500y500'))"
|
||||
class="recommend-singer-item-bg"
|
||||
></div>
|
||||
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
|
||||
{{ t('common.songCount', { count: item.musicSize }) }}
|
||||
</div>
|
||||
<div class="recommend-singer-item-info z-10">
|
||||
<div class="recommend-singer-item-info-name text-el text-right line-clamp-1">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 播放按钮(hover时显示) -->
|
||||
<div
|
||||
class="recommend-singer-item-play-overlay"
|
||||
@click.stop="handleArtistClick(item.id)"
|
||||
>
|
||||
<div class="recommend-singer-item-play-btn">
|
||||
<i class="iconfont icon-playfill text-4xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-carousel-item>
|
||||
</n-carousel>
|
||||
</div>
|
||||
|
||||
<music-list
|
||||
v-if="dayRecommendData?.dailySongs.length"
|
||||
v-model:show="showMusic"
|
||||
:name="t('comp.recommendSinger.songlist')"
|
||||
:song-list="dayRecommendData?.dailySongs"
|
||||
:cover="false"
|
||||
/>
|
||||
|
||||
<!-- 添加用户歌单弹窗 -->
|
||||
<music-list
|
||||
v-model:show="showPlaylist"
|
||||
v-model:loading="playlistLoading"
|
||||
:name="playlistItem?.name || ''"
|
||||
:song-list="playlistDetail?.playlist?.tracks || []"
|
||||
:list-info="playlistDetail?.playlist"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getDayRecommend, getHotSinger } from '@/api/home';
|
||||
import { getListDetail } from '@/api/list';
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import { getUserPlaylist } from '@/api/user';
|
||||
import MusicList from '@/components/MusicList.vue';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { usePlayerStore, useUserStore } from '@/store';
|
||||
import { IDayRecommend } from '@/type/day_recommend';
|
||||
import { Playlist } from '@/type/list';
|
||||
import type { IListDetail } from '@/type/listDetail';
|
||||
import { SongResult } from '@/type/music';
|
||||
import type { IHotSinger } from '@/type/singer';
|
||||
import {
|
||||
getImgUrl,
|
||||
isMobile,
|
||||
setAnimationClass,
|
||||
setAnimationDelay,
|
||||
setBackgroundImg
|
||||
} from '@/utils';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 歌手信息
|
||||
const hotSingerData = ref<IHotSinger>();
|
||||
const dayRecommendData = ref<IDayRecommend>();
|
||||
const showMusic = ref(false);
|
||||
const userPlaylist = ref<Playlist[]>([]);
|
||||
|
||||
// 为歌单弹窗添加的状态
|
||||
const showPlaylist = ref(false);
|
||||
const playlistLoading = ref(false);
|
||||
const playlistItem = ref<Playlist | null>(null);
|
||||
const playlistDetail = ref<IListDetail | null>(null);
|
||||
|
||||
const { navigateToArtist } = useArtist();
|
||||
|
||||
/**
|
||||
* 获取轮播项的样式
|
||||
* @param index 项目索引(用于动画延迟)
|
||||
* @param delayStep 动画延迟的步长(毫秒)
|
||||
* @param totalItems 总共分成几等分(默认为5)
|
||||
* @param maxWidth 最大宽度(可选,单位为px)
|
||||
* @returns 样式字符串
|
||||
*/
|
||||
const getCarouselItemStyle = (
|
||||
index: number,
|
||||
delayStep: number,
|
||||
totalItems: number,
|
||||
maxWidth?: number
|
||||
) => {
|
||||
if (isMobile.value) {
|
||||
return 'width: 30%;';
|
||||
}
|
||||
const animationDelay = setAnimationDelay(index, delayStep);
|
||||
const width = `calc((100% / ${totalItems}) - 16px)`;
|
||||
const maxWidthStyle = maxWidth ? `max-width: ${maxWidth}px;` : '';
|
||||
|
||||
return `${animationDelay}; width: ${width}; ${maxWidthStyle}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据歌单数量获取轮播项的样式
|
||||
* @param playlistCount 歌单数量
|
||||
* @returns 样式字符串
|
||||
*/
|
||||
const getCarouselItemStyleForPlaylist = (playlistCount: number) => {
|
||||
if (isMobile.value) {
|
||||
return 'width: 100%;';
|
||||
}
|
||||
const animationDelay = setAnimationDelay(1, 100);
|
||||
let width = '';
|
||||
let maxWidth = '';
|
||||
|
||||
switch (playlistCount) {
|
||||
case 1:
|
||||
width = 'calc(100% / 4 - 16px)';
|
||||
maxWidth = 'max-width: 180px;';
|
||||
break;
|
||||
case 2:
|
||||
width = 'calc(100% / 3 - 16px)';
|
||||
maxWidth = 'max-width: 380px;';
|
||||
break;
|
||||
case 3:
|
||||
width = 'calc(100% / 2 - 16px)';
|
||||
maxWidth = 'max-width: 520px;';
|
||||
break;
|
||||
default:
|
||||
width = 'calc(100% / 1 - 16px)';
|
||||
maxWidth = 'max-width: 656px;';
|
||||
}
|
||||
|
||||
return `${animationDelay}; width: ${width}; ${maxWidth}`;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 获取每日推荐
|
||||
try {
|
||||
const {
|
||||
data: { data: dayRecommend }
|
||||
} = await getDayRecommend();
|
||||
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
|
||||
if (userStore.user) {
|
||||
const { data: playlistData } = await getUserPlaylist(userStore.user?.userId);
|
||||
// 确保最多只显示4个歌单,并按播放次数排序
|
||||
userPlaylist.value = (playlistData.playlist as Playlist[])
|
||||
.sort((a, b) => b.playCount - a.playCount)
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
// 获取热门歌手
|
||||
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
|
||||
hotSingerData.value = singerData;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArtistClick = (id: number) => {
|
||||
navigateToArtist(id);
|
||||
};
|
||||
|
||||
const toPlaylist = async (id: number) => {
|
||||
playlistLoading.value = true;
|
||||
playlistItem.value = null;
|
||||
playlistDetail.value = null;
|
||||
showPlaylist.value = true;
|
||||
|
||||
// 设置当前点击的歌单信息
|
||||
const selectedPlaylist = userPlaylist.value.find((item) => item.id === id);
|
||||
if (selectedPlaylist) {
|
||||
playlistItem.value = selectedPlaylist;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取歌单详情
|
||||
const { data } = await getListDetail(id);
|
||||
playlistDetail.value = data;
|
||||
} catch (error) {
|
||||
console.error('获取歌单详情失败:', error);
|
||||
} finally {
|
||||
playlistLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 添加直接播放歌单的方法
|
||||
const handlePlayPlaylist = async (id: number) => {
|
||||
try {
|
||||
// 先显示加载状态
|
||||
playlistLoading.value = true;
|
||||
|
||||
// 获取歌单详情
|
||||
const { data } = await getListDetail(id);
|
||||
|
||||
if (data?.playlist) {
|
||||
// 先使用已有的tracks开始播放(这些是已经在歌单详情中返回的前几首歌曲)
|
||||
if (data.playlist.tracks?.length > 0) {
|
||||
// 格式化歌曲列表
|
||||
const initialSongs = data.playlist.tracks.map((track) => ({
|
||||
...track,
|
||||
source: 'netease',
|
||||
picUrl: track.al.picUrl
|
||||
})) as unknown as SongResult[];
|
||||
|
||||
// 设置播放列表
|
||||
playerStore.setPlayList(initialSongs);
|
||||
|
||||
// 开始播放第一首
|
||||
await playerStore.setPlay(initialSongs[0]);
|
||||
|
||||
// 如果有trackIds,异步加载完整歌单
|
||||
if (data.playlist.trackIds?.length > initialSongs.length) {
|
||||
loadFullPlaylist(data.playlist.trackIds, initialSongs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭加载状态
|
||||
playlistLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error('播放歌单失败:', error);
|
||||
playlistLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 异步加载完整歌单
|
||||
const loadFullPlaylist = async (trackIds: { id: number }[], initialSongs: SongResult[]) => {
|
||||
try {
|
||||
// 获取已加载歌曲的ID集合,避免重复加载
|
||||
const loadedIds = new Set(initialSongs.map((song) => song.id));
|
||||
|
||||
// 筛选出未加载的ID
|
||||
const unloadedTrackIds = trackIds
|
||||
.filter((item) => !loadedIds.has(item.id as number))
|
||||
.map((item) => item.id);
|
||||
|
||||
if (unloadedTrackIds.length === 0) return;
|
||||
|
||||
// 分批获取歌曲详情,每批最多获取500首
|
||||
const batchSize = 500;
|
||||
const allSongs = [...initialSongs];
|
||||
|
||||
for (let i = 0; i < unloadedTrackIds.length; i += batchSize) {
|
||||
const batchIds = unloadedTrackIds.slice(i, i + batchSize);
|
||||
if (batchIds.length > 0) {
|
||||
try {
|
||||
const { data: songsData } = await getMusicDetail(batchIds);
|
||||
if (songsData?.songs?.length) {
|
||||
const formattedSongs = songsData.songs.map((item) => ({
|
||||
...item,
|
||||
source: 'netease',
|
||||
picUrl: item.al.picUrl
|
||||
})) as unknown as SongResult[];
|
||||
|
||||
allSongs.push(...formattedSongs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取批次歌曲详情失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新完整的播放列表但保持当前播放的歌曲不变
|
||||
if (allSongs.length > initialSongs.length) {
|
||||
console.log('更新播放列表,总歌曲数:', allSongs.length);
|
||||
playerStore.setPlayList(allSongs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载完整歌单失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听登录状态
|
||||
watchEffect(() => {
|
||||
if (userStore.user) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
const getPlaylistGridClass = (length: number) => {
|
||||
switch (length) {
|
||||
case 1:
|
||||
return 'one-column';
|
||||
case 2:
|
||||
return 'two-columns';
|
||||
case 3:
|
||||
return 'three-columns';
|
||||
default:
|
||||
return 'four-columns';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recommend-singer {
|
||||
&-list {
|
||||
@apply flex;
|
||||
height: 220px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
&-item {
|
||||
@apply flex-1 h-full rounded-3xl p-5 flex flex-col justify-between overflow-hidden relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
&-bg {
|
||||
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
|
||||
filter: brightness(60%);
|
||||
}
|
||||
|
||||
&-info {
|
||||
@apply flex flex-col p-2;
|
||||
&-name {
|
||||
@apply text-gray-100 dark:text-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
&-count {
|
||||
@apply text-gray-100 dark:text-gray-100;
|
||||
}
|
||||
|
||||
&-play {
|
||||
&-overlay {
|
||||
@apply absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-black/20 z-20 opacity-0 transition-all duration-300 flex items-center justify-center;
|
||||
backdrop-filter: blur(1px);
|
||||
|
||||
.recommend-singer-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-btn {
|
||||
@apply w-20 h-20 bg-transparent flex justify-center items-center text-white;
|
||||
transform: translateY(50px) scale(0.8);
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
.recommend-singer-item:hover & {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-play {
|
||||
@apply bg-light-300 dark:bg-dark-300 rounded-3xl px-4 py-3 h-full;
|
||||
backdrop-filter: blur(20px);
|
||||
&-title {
|
||||
@apply text-gray-900 dark:text-gray-100 font-bold text-lg line-clamp-1;
|
||||
}
|
||||
&-list {
|
||||
@apply grid gap-3 h-full;
|
||||
&.one-column {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
&.two-columns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
&.three-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
&.four-columns {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
.user-play-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-item {
|
||||
@apply rounded-2xl overflow-hidden flex flex-col;
|
||||
height: 176px;
|
||||
|
||||
&-img {
|
||||
@apply relative cursor-pointer transition-all duration-300;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
padding-bottom: 100%; /* 确保宽高比为1:1,即正方形 */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
img {
|
||||
@apply absolute inset-0 w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
&-title {
|
||||
@apply absolute top-0 left-0 right-0 p-2 bg-gradient-to-b from-black/70 to-transparent z-10;
|
||||
&-name {
|
||||
@apply text-white font-medium text-sm line-clamp-3;
|
||||
}
|
||||
}
|
||||
&-count {
|
||||
@apply absolute bottom-2 left-2 z-10;
|
||||
&-tag {
|
||||
@apply px-2 py-0.5 text-xs text-white bg-black/50 backdrop-blur-sm rounded-full;
|
||||
}
|
||||
}
|
||||
&-direct-play {
|
||||
@apply absolute bottom-2 right-2 z-20 w-10 h-10 rounded-full bg-green-600 hover:bg-green-700 flex items-center justify-center cursor-pointer transform scale-90 hover:scale-100 transition-all;
|
||||
&:hover {
|
||||
@apply shadow-lg;
|
||||
}
|
||||
}
|
||||
&-play-btn {
|
||||
@apply flex items-center justify-center;
|
||||
transform: scale(0.8);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
.user-play-item:hover & {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.mobile {
|
||||
.recommend-singer {
|
||||
&-list {
|
||||
height: 180px;
|
||||
@apply ml-4;
|
||||
}
|
||||
&-item {
|
||||
@apply p-2 rounded-xl;
|
||||
&-bg {
|
||||
@apply rounded-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
256
src/renderer/components/lyric/LyricSettings.vue
Normal file
256
src/renderer/components/lyric/LyricSettings.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="settings-panel transparent-popover">
|
||||
<div class="settings-title">{{ t('settings.lyricSettings.title') }}</div>
|
||||
<div class="settings-content">
|
||||
<n-tabs type="line" animated size="small">
|
||||
<!-- 显示设置 -->
|
||||
<n-tab-pane :name="'display'" :tab="t('settings.lyricSettings.tabs.display')">
|
||||
<div class="tab-content">
|
||||
<div class="settings-grid">
|
||||
<div class="settings-item">
|
||||
<span>{{ t('settings.lyricSettings.pureMode') }}</span>
|
||||
<n-switch v-model:value="config.pureModeEnabled" />
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<span>{{ t('settings.lyricSettings.hideCover') }}</span>
|
||||
<n-switch v-model:value="config.hideCover" />
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<span>{{ t('settings.lyricSettings.centerDisplay') }}</span>
|
||||
<n-switch v-model:value="config.centerLyrics" />
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<span>{{ t('settings.lyricSettings.showTranslation') }}</span>
|
||||
<n-switch v-model:value="config.showTranslation" />
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<span>{{ t('settings.lyricSettings.hideLyrics') }}</span>
|
||||
<n-switch v-model:value="config.hideLyrics" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 界面设置 -->
|
||||
<n-tab-pane :name="'interface'" :tab="t('settings.lyricSettings.tabs.interface')">
|
||||
<div class="tab-content">
|
||||
<div class="settings-grid">
|
||||
<div class="settings-item">
|
||||
<span>{{ t('settings.lyricSettings.hidePlayBar') }}</span>
|
||||
<n-switch v-model:value="config.hidePlayBar" />
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<span>{{ t('settings.lyricSettings.hideMiniPlayBar') }}</span>
|
||||
<n-switch v-model:value="config.hideMiniPlayBar" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-section">
|
||||
<div class="section-title">{{ t('settings.lyricSettings.backgroundTheme') }}</div>
|
||||
<n-radio-group v-model:value="config.theme" name="theme" class="theme-radio-group">
|
||||
<n-space>
|
||||
<n-radio value="default">{{
|
||||
t('settings.lyricSettings.themeOptions.default')
|
||||
}}</n-radio>
|
||||
<n-radio value="light">{{
|
||||
t('settings.lyricSettings.themeOptions.light')
|
||||
}}</n-radio>
|
||||
<n-radio value="dark">{{
|
||||
t('settings.lyricSettings.themeOptions.dark')
|
||||
}}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 文字设置 -->
|
||||
<n-tab-pane :name="'typography'" :tab="t('settings.lyricSettings.tabs.typography')">
|
||||
<div class="tab-content">
|
||||
<div class="slider-section">
|
||||
<div class="slider-item">
|
||||
<span>{{ t('settings.lyricSettings.fontSize') }}</span>
|
||||
<n-slider
|
||||
v-model:value="config.fontSize"
|
||||
:step="1"
|
||||
:min="12"
|
||||
:max="32"
|
||||
:marks="{
|
||||
12: t('settings.lyricSettings.fontSizeMarks.small'),
|
||||
22: t('settings.lyricSettings.fontSizeMarks.medium'),
|
||||
32: t('settings.lyricSettings.fontSizeMarks.large')
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="slider-item">
|
||||
<span>{{ t('settings.lyricSettings.letterSpacing') }}</span>
|
||||
<n-slider
|
||||
v-model:value="config.letterSpacing"
|
||||
:step="0.2"
|
||||
:min="-2"
|
||||
:max="10"
|
||||
:marks="{
|
||||
'-2': t('settings.lyricSettings.letterSpacingMarks.compact'),
|
||||
0: t('settings.lyricSettings.letterSpacingMarks.default'),
|
||||
10: t('settings.lyricSettings.letterSpacingMarks.loose')
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="slider-item">
|
||||
<span>{{ t('settings.lyricSettings.lineHeight') }}</span>
|
||||
<n-slider
|
||||
v-model:value="config.lineHeight"
|
||||
:step="0.1"
|
||||
:min="1"
|
||||
:max="3"
|
||||
:marks="{
|
||||
1: t('settings.lyricSettings.lineHeightMarks.compact'),
|
||||
1.5: t('settings.lyricSettings.lineHeightMarks.default'),
|
||||
3: t('settings.lyricSettings.lineHeightMarks.loose')
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
|
||||
|
||||
const { t } = useI18n();
|
||||
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
||||
const emit = defineEmits(['themeChange']);
|
||||
|
||||
watch(
|
||||
() => config.value,
|
||||
(newConfig) => {
|
||||
updateCSSVariables(newConfig);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => config.value.theme,
|
||||
(newTheme) => {
|
||||
emit('themeChange', newTheme);
|
||||
}
|
||||
);
|
||||
|
||||
const updateCSSVariables = (config: LyricConfig) => {
|
||||
document.documentElement.style.setProperty('--lyric-font-size', `${config.fontSize}px`);
|
||||
document.documentElement.style.setProperty('--lyric-letter-spacing', `${config.letterSpacing}px`);
|
||||
document.documentElement.style.setProperty('--lyric-line-height', config.lineHeight.toString());
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const savedConfig = localStorage.getItem('music-full-config');
|
||||
if (savedConfig) {
|
||||
config.value = { ...config.value, ...JSON.parse(savedConfig) };
|
||||
updateCSSVariables(config.value);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
config
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-panel {
|
||||
@apply p-4 w-80 rounded-lg relative overflow-hidden backdrop-blur-lg bg-black/10;
|
||||
|
||||
.settings-title {
|
||||
@apply text-base font-bold mb-4;
|
||||
color: var(--text-color-active);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
:deep(.n-tabs-nav) {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
:deep(.n-tab-pane) {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
:deep(.n-tabs-tab) {
|
||||
@apply text-xs;
|
||||
color: var(--text-color-primary);
|
||||
|
||||
&.n-tabs-tab--active {
|
||||
color: var(--text-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-tabs-tab-wrapper) {
|
||||
@apply pb-0;
|
||||
}
|
||||
|
||||
:deep(.n-tabs-pane-wrapper) {
|
||||
@apply px-2;
|
||||
}
|
||||
|
||||
:deep(.n-tabs-bar) {
|
||||
background-color: var(--text-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
@apply grid grid-cols-1 gap-3;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
@apply flex items-center justify-between;
|
||||
span {
|
||||
@apply text-sm;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-sm font-medium mb-2;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
|
||||
.theme-section {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.slider-section {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.slider-item {
|
||||
@apply space-y-2 mb-10 !important;
|
||||
span {
|
||||
@apply text-sm;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-radio-group {
|
||||
@apply flex;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-slider-mark) {
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.n-radio__label) {
|
||||
color: var(--text-color-active) !important;
|
||||
@apply text-xs;
|
||||
}
|
||||
</style>
|
||||
600
src/renderer/components/player/MiniPlayBar.vue
Normal file
600
src/renderer/components/player/MiniPlayBar.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<template>
|
||||
<div
|
||||
class="mini-play-bar"
|
||||
:class="{ 'pure-mode': pureModeEnabled, 'mini-mode': settingsStore.isMiniMode }"
|
||||
>
|
||||
<div class="mini-bar-container">
|
||||
<!-- 专辑封面 -->
|
||||
<div class="album-cover" @click="setMusicFull">
|
||||
<n-image
|
||||
:src="getImgUrl(playMusic?.picUrl, '100y100')"
|
||||
fallback-src="/placeholder.png"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="song-info" @click="setMusicFull">
|
||||
<div class="song-title">{{ playMusic?.name || '未播放' }}</div>
|
||||
<div class="song-artist">
|
||||
<span
|
||||
v-for="(artists, artistsindex) in artistList"
|
||||
:key="artistsindex"
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click.stop="handleArtistClick(artists.id)"
|
||||
>
|
||||
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮区域 -->
|
||||
<div class="control-buttons">
|
||||
<button class="control-button previous" @click="handlePrev">
|
||||
<i class="iconfont icon-prev"></i>
|
||||
</button>
|
||||
<button class="control-button play" @click="playMusicEvent">
|
||||
<i class="iconfont" :class="play ? 'icon-stop' : 'icon-play'"></i>
|
||||
</button>
|
||||
<button class="control-button next" @click="handleNext">
|
||||
<i class="iconfont icon-next"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧功能按钮 -->
|
||||
<div class="function-buttons">
|
||||
<button class="function-button">
|
||||
<i
|
||||
class="iconfont icon-likefill"
|
||||
:class="{ 'like-active': isFavorite }"
|
||||
@click="toggleFavorite"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<n-popover trigger="click" :z-index="99999999" placement="top" :show-arrow="false">
|
||||
<template #trigger>
|
||||
<button class="function-button" @click="mute">
|
||||
<i class="iconfont" :class="getVolumeIcon"></i>
|
||||
</button>
|
||||
</template>
|
||||
<div class="volume-slider-wrapper">
|
||||
<n-slider
|
||||
v-model:value="volumeSlider"
|
||||
:step="0.01"
|
||||
:tooltip="false"
|
||||
vertical
|
||||
></n-slider>
|
||||
</div>
|
||||
</n-popover>
|
||||
|
||||
<!-- 播放列表按钮 -->
|
||||
<button v-if="!component" class="function-button" @click="togglePlaylist">
|
||||
<i class="iconfont icon-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<button v-if="!component" class="close-button" @click="handleClose">
|
||||
<i class="iconfont ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="progress-bar"
|
||||
@click="handleProgressClick"
|
||||
@mousemove="handleProgressHover"
|
||||
@mouseleave="handleProgressLeave"
|
||||
>
|
||||
<div class="progress-track"></div>
|
||||
<div class="progress-fill" :style="{ width: `${(nowTime / allTime) * 100}%` }"></div>
|
||||
</div>
|
||||
|
||||
<!-- 播放列表 - 单独放在外层,不再使用 popover -->
|
||||
<div
|
||||
v-if="!component"
|
||||
v-show="isPlaylistOpen"
|
||||
class="playlist-container"
|
||||
:class="{ 'mini-mode-list': settingsStore.isMiniMode }"
|
||||
>
|
||||
<n-scrollbar ref="palyListRef" class="playlist-scrollbar">
|
||||
<div class="playlist-items">
|
||||
<div v-for="item in playList" :key="item.id" class="music-play-list-content">
|
||||
<div class="flex items-center justify-between">
|
||||
<song-item :key="item.id" class="flex-1" :item="item" mini></song-item>
|
||||
<div class="delete-btn" @click.stop="handleDeleteSong(item)">
|
||||
<i
|
||||
class="iconfont ri-delete-bin-line text-gray-400 hover:text-red-500 transition-colors"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore, useSettingsStore } from '@/store';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { navigateToArtist } = useArtist();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
pureModeEnabled?: boolean;
|
||||
component?: boolean;
|
||||
}>(),
|
||||
{
|
||||
component: false
|
||||
}
|
||||
);
|
||||
|
||||
// 处理关闭按钮点击
|
||||
const handleClose = () => {
|
||||
if (settingsStore.isMiniMode) {
|
||||
window.api.restore();
|
||||
}
|
||||
};
|
||||
|
||||
// 是否播放
|
||||
const play = computed(() => playerStore.play as boolean);
|
||||
// 播放列表
|
||||
const playList = computed(() => playerStore.playList as SongResult[]);
|
||||
|
||||
// 音量控制
|
||||
const audioVolume = ref(
|
||||
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
|
||||
);
|
||||
|
||||
const volumeSlider = computed({
|
||||
get: () => audioVolume.value * 100,
|
||||
set: (value) => {
|
||||
localStorage.setItem('volume', (value / 100).toString());
|
||||
audioService.setVolume(value / 100);
|
||||
audioVolume.value = value / 100;
|
||||
}
|
||||
});
|
||||
|
||||
// 音量图标
|
||||
const getVolumeIcon = computed(() => {
|
||||
if (audioVolume.value === 0) return 'ri-volume-mute-line';
|
||||
if (audioVolume.value <= 0.5) return 'ri-volume-down-line';
|
||||
return 'ri-volume-up-line';
|
||||
});
|
||||
|
||||
// 静音
|
||||
const mute = () => {
|
||||
if (volumeSlider.value === 0) {
|
||||
volumeSlider.value = 30;
|
||||
} else {
|
||||
volumeSlider.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 收藏相关
|
||||
const isFavorite = computed(() => {
|
||||
const numericId =
|
||||
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
|
||||
return playerStore.favoriteList.includes(numericId);
|
||||
});
|
||||
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const numericId =
|
||||
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
|
||||
|
||||
if (isFavorite.value) {
|
||||
playerStore.removeFromFavorite(numericId);
|
||||
} else {
|
||||
playerStore.addToFavorite(numericId);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放列表相关
|
||||
const palyListRef = useTemplateRef('palyListRef') as any;
|
||||
const isPlaylistOpen = ref(false);
|
||||
|
||||
// 提供 openPlaylistDrawer 给子组件
|
||||
provide('openPlaylistDrawer', (songId: number) => {
|
||||
console.log('打开歌单抽屉', songId);
|
||||
// 由于在迷你模式不处理这个功能,所以只记录日志
|
||||
});
|
||||
|
||||
// 切换播放列表显示/隐藏
|
||||
const togglePlaylist = () => {
|
||||
isPlaylistOpen.value = !isPlaylistOpen.value;
|
||||
console.log('切换播放列表状态', isPlaylistOpen.value);
|
||||
|
||||
// 调整窗口大小
|
||||
if (settingsStore.isMiniMode) {
|
||||
try {
|
||||
if (isPlaylistOpen.value) {
|
||||
// 打开播放列表时调整DOM
|
||||
document.body.style.height = 'auto';
|
||||
document.body.style.overflow = 'visible';
|
||||
|
||||
// 使用新的专用 API 调整窗口大小
|
||||
if (window.api && typeof window.api.resizeMiniWindow === 'function') {
|
||||
window.api.resizeMiniWindow(true);
|
||||
}
|
||||
} else {
|
||||
// 关闭播放列表时强制调整DOM
|
||||
document.body.style.height = '64px';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 使用新的专用 API 调整窗口大小
|
||||
if (window.api && typeof window.api.resizeMiniWindow === 'function') {
|
||||
window.api.resizeMiniWindow(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调整窗口大小失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果打开列表,滚动到当前播放歌曲
|
||||
if (isPlaylistOpen.value) {
|
||||
scrollToPlayList();
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPlayList = () => {
|
||||
setTimeout(() => {
|
||||
const currentIndex = playerStore.playListIndex;
|
||||
const itemHeight = 69; // 每个列表项的高度
|
||||
palyListRef.value?.scrollTo({
|
||||
top: currentIndex * itemHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleDeleteSong = (song: SongResult) => {
|
||||
if (song.id === playMusic.value.id) {
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
playerStore.removeFromPlayList(song.id as number);
|
||||
};
|
||||
|
||||
// 艺术家点击
|
||||
const handleArtistClick = (id: number) => {
|
||||
navigateToArtist(id);
|
||||
};
|
||||
|
||||
// 进度条相关
|
||||
const handleProgressClick = (e: MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
audioService.seek(allTime.value * percent);
|
||||
nowTime.value = allTime.value * percent;
|
||||
};
|
||||
|
||||
const hoverTime = ref(0);
|
||||
const isHovering = ref(false);
|
||||
|
||||
const handleProgressHover = (e: MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
hoverTime.value = allTime.value * percent;
|
||||
isHovering.value = true;
|
||||
};
|
||||
|
||||
const handleProgressLeave = () => {
|
||||
isHovering.value = false;
|
||||
};
|
||||
|
||||
// 播放控制
|
||||
const handlePrev = () => playerStore.prevPlay();
|
||||
const handleNext = () => playerStore.nextPlay();
|
||||
|
||||
const playMusicEvent = async () => {
|
||||
try {
|
||||
if (!playerStore.playMusic?.id || !playerStore.playMusicUrl) {
|
||||
console.warn('No valid music or URL available');
|
||||
playerStore.setPlay(playerStore.playMusic);
|
||||
return;
|
||||
}
|
||||
|
||||
if (play.value) {
|
||||
if (audioService.getCurrentSound()) {
|
||||
audioService.pause();
|
||||
playerStore.setPlayMusic(false);
|
||||
}
|
||||
} else {
|
||||
if (audioService.getCurrentSound()) {
|
||||
audioService.play();
|
||||
} else {
|
||||
await audioService.play(playerStore.playMusicUrl, playerStore.playMusic);
|
||||
}
|
||||
playerStore.setPlayMusic(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放出错:', error);
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到完整播放器
|
||||
const setMusicFull = () => {
|
||||
playerStore.setMusicFull(true);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mini-play-bar {
|
||||
@apply w-full flex flex-col bg-light-200 dark:bg-dark-200 shadow-md bg-opacity-60 backdrop-blur dark:bg-opacity-60;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
|
||||
&.mini-mode {
|
||||
@apply shadow-lg;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
.mini-bar-container {
|
||||
@apply px-2;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
width: 120px;
|
||||
|
||||
.song-title {
|
||||
@apply text-xs font-medium;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
@apply text-xs opacity-50;
|
||||
}
|
||||
}
|
||||
|
||||
.function-buttons {
|
||||
-webkit-app-region: no-drag;
|
||||
@apply space-x-1 ml-1;
|
||||
|
||||
.function-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
@apply mx-1 space-x-0.5;
|
||||
-webkit-app-region: no-drag;
|
||||
.control-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
-webkit-app-region: no-drag;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
@apply flex-shrink-0 mr-2;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 2px !important;
|
||||
|
||||
&:hover {
|
||||
height: 3px !important;
|
||||
|
||||
.progress-track,
|
||||
.progress-fill {
|
||||
height: 3px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mini-bar-container {
|
||||
@apply flex items-center px-3 h-full relative;
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
@apply flex-shrink-0 mr-3 cursor-pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
.cover-img {
|
||||
@apply w-full h-full rounded-md object-cover pointer-events-none;
|
||||
}
|
||||
}
|
||||
|
||||
.song-info {
|
||||
@apply flex flex-col justify-center min-w-0 flex-shrink mr-4 cursor-pointer;
|
||||
width: 200px;
|
||||
|
||||
.song-title {
|
||||
@apply text-sm font-medium truncate;
|
||||
color: var(--text-color-1, #000);
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
@apply text-xs truncate mt-0.5 opacity-60;
|
||||
color: var(--text-color-2, #666);
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
@apply flex items-center space-x-1 mx-4;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer text-gray-600;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 dark:bg-dark-300;
|
||||
}
|
||||
|
||||
&.play {
|
||||
@apply bg-primary text-white;
|
||||
&:hover {
|
||||
@apply bg-green-800;
|
||||
}
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.function-buttons {
|
||||
@apply flex items-center ml-auto space-x-2;
|
||||
}
|
||||
|
||||
.function-button {
|
||||
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-color-2, #666);
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 dark:bg-dark-300;
|
||||
color: var(--text-color-1, #000);
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply flex items-center justify-center rounded-full transition-all duration-200 border-0 bg-transparent cursor-pointer ml-2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-color-2, #666);
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 dark:bg-dark-300;
|
||||
color: var(--text-color-1, #000);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply relative w-full cursor-pointer;
|
||||
height: 2px;
|
||||
|
||||
&:hover {
|
||||
height: 4px;
|
||||
|
||||
.progress-track,
|
||||
.progress-fill {
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
@apply absolute inset-x-0 bottom-0 transition-all duration-200;
|
||||
height: 2px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply absolute bottom-0 left-0 transition-all duration-200;
|
||||
height: 2px;
|
||||
background: var(--primary-color, #18a058);
|
||||
}
|
||||
|
||||
.like-active {
|
||||
@apply text-red-500 hover:text-red-600 !important;
|
||||
}
|
||||
|
||||
.volume-slider-wrapper {
|
||||
@apply p-4 rounded-xl bg-white dark:bg-dark-100 shadow-lg;
|
||||
width: 40px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
// 播放列表样式
|
||||
.playlist-container {
|
||||
@apply fixed left-0 right-0 bg-white dark:bg-dark-100 overflow-hidden;
|
||||
top: 64px;
|
||||
height: 330px;
|
||||
max-height: 330px;
|
||||
|
||||
&.mini-mode-list {
|
||||
width: 340px;
|
||||
@apply bg-opacity-90 dark:bg-opacity-90;
|
||||
}
|
||||
}
|
||||
|
||||
// 播放列表内容样式
|
||||
.music-play-list-content {
|
||||
@apply px-2 py-1;
|
||||
|
||||
.delete-btn {
|
||||
@apply p-2 rounded-full transition-colors duration-200 cursor-pointer;
|
||||
@apply hover:bg-red-50 dark:hover:bg-red-900/20;
|
||||
|
||||
.iconfont {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.playlist-scrollbar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.playlist-items {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.song-info {
|
||||
.song-title {
|
||||
color: var(--text-color-1, #fff);
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
color: var(--text-color-2, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
504
src/renderer/components/player/MobilePlayBar.vue
Normal file
504
src/renderer/components/player/MobilePlayBar.vue
Normal 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>
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<!-- 展开全屏 -->
|
||||
<!-- 底部播放栏 -->
|
||||
|
||||
<div
|
||||
class="music-play-bar"
|
||||
:class="
|
||||
setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')
|
||||
"
|
||||
:class="[
|
||||
setAnimationClass('animate__bounceInUp'),
|
||||
musicFullVisible ? 'play-bar-opcity' : '',
|
||||
musicFullVisible && MusicFullRef?.config?.hidePlayBar
|
||||
? 'animate__animated animate__slideOutDown'
|
||||
: ''
|
||||
]"
|
||||
:style="{
|
||||
color: musicFullVisible
|
||||
? textColors.theme === 'dark'
|
||||
? '#000000'
|
||||
: '#ffffff'
|
||||
: store.state.theme === 'dark'
|
||||
: settingsStore.theme === 'dark'
|
||||
? '#ffffff'
|
||||
: '#000000'
|
||||
}"
|
||||
@@ -24,11 +25,16 @@
|
||||
:max="allTime"
|
||||
:min="0"
|
||||
:format-tooltip="formatTooltip"
|
||||
:show-tooltip="showSliderTooltip"
|
||||
@mouseenter="showSliderTooltip = true"
|
||||
@mouseleave="showSliderTooltip = false"
|
||||
@dragstart="handleSliderDragStart"
|
||||
@dragend="handleSliderDragEnd"
|
||||
></n-slider>
|
||||
</div>
|
||||
<div class="play-bar-img-wrapper" @click="setMusicFull">
|
||||
<n-image
|
||||
:src="getImgUrl(playMusic?.picUrl, '500y500')"
|
||||
:src="getImgUrl(playMusic?.picUrl, '100y100')"
|
||||
class="play-bar-img"
|
||||
lazy
|
||||
preview-disabled
|
||||
@@ -40,7 +46,9 @@
|
||||
class="text-3xl"
|
||||
:class="musicFullVisible ? 'ri-arrow-down-s-line' : 'ri-arrow-up-s-line'"
|
||||
></i>
|
||||
<span class="hover-text">{{ musicFullVisible ? '收起' : '展开' }}歌词</span>
|
||||
<span class="hover-text">{{
|
||||
musicFullVisible ? t('player.playBar.collapse') : t('player.playBar.expand')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,13 +68,12 @@
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-for="(artists, artistsindex) in playMusic.ar || playMusic.song.artists"
|
||||
v-for="(artists, artistsindex) in artistList"
|
||||
:key="artistsindex"
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click="handleArtistClick(artists.id)"
|
||||
>
|
||||
{{ artists.name
|
||||
}}{{ artistsindex < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}
|
||||
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
@@ -105,18 +112,38 @@
|
||||
@click="toggleFavorite"
|
||||
></i>
|
||||
</template>
|
||||
喜欢
|
||||
{{ t('player.playBar.like') }}
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i
|
||||
class="iconfont ri-netease-cloud-music-line"
|
||||
:class="{ 'text-green-500': isLyricWindowOpen }"
|
||||
@click="openLyricWindow"
|
||||
:class="{ 'text-green-500': isLyricWindowOpen, 'disabled-icon': !playMusic.id }"
|
||||
@click="playMusic.id && openLyricWindow()"
|
||||
></i>
|
||||
</template>
|
||||
歌词
|
||||
{{ playMusic.id ? t('player.playBar.lyric') : t('player.playBar.noSongPlaying') }}
|
||||
</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
|
||||
trigger="click"
|
||||
:z-index="99999999"
|
||||
@@ -132,7 +159,7 @@
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-list"></i>
|
||||
</template>
|
||||
播放列表
|
||||
{{ t('player.playBar.playList') }}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<div class="music-play-list">
|
||||
@@ -140,7 +167,14 @@
|
||||
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
|
||||
<template #default="{ item }">
|
||||
<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>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
@@ -154,55 +188,91 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import EqControl from '@/components/EQControl.vue';
|
||||
import {
|
||||
allTime,
|
||||
artistList,
|
||||
isLyricWindowOpen,
|
||||
nowTime,
|
||||
openLyric,
|
||||
sound,
|
||||
playMusic,
|
||||
textColors
|
||||
} from '@/hooks/MusicHook';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
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, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils';
|
||||
import { showShortcutToast } from '@/utils/shortcutToast';
|
||||
|
||||
import MusicFull from './MusicFull.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// 播放的音乐信息
|
||||
const playMusic = computed(() => store.state.playMusic as SongResult);
|
||||
const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
// 是否播放
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
const playList = computed(() => store.state.playList as SongResult[]);
|
||||
|
||||
const play = computed(() => playerStore.isPlay);
|
||||
// 播放列表
|
||||
const playList = computed(() => playerStore.playList as SongResult[]);
|
||||
// 背景颜色
|
||||
const background = ref('#000');
|
||||
|
||||
watch(
|
||||
() => store.state.playMusic,
|
||||
() => playerStore.playMusic,
|
||||
async () => {
|
||||
background.value = playMusic.value.backgroundColor as string;
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 使用 useThrottleFn 创建节流版本的 seek 函数
|
||||
// 节流版本的 seek 函数
|
||||
const throttledSeek = useThrottleFn((value: number) => {
|
||||
if (!sound.value) return;
|
||||
sound.value.seek(value);
|
||||
audioService.seek(value);
|
||||
nowTime.value = value;
|
||||
}, 50); // 50ms 的节流延迟
|
||||
|
||||
// 拖动时的临时值,避免频繁更新 nowTime 触发重渲染
|
||||
const dragValue = ref(0);
|
||||
|
||||
// 为滑块拖动添加状态跟踪
|
||||
const isDragging = ref(false);
|
||||
|
||||
// 修改 timeSlider 计算属性
|
||||
const timeSlider = computed({
|
||||
get: () => nowTime.value,
|
||||
set: throttledSeek
|
||||
get: () => (isDragging.value ? dragValue.value : nowTime.value),
|
||||
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) => {
|
||||
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
|
||||
};
|
||||
@@ -225,9 +295,8 @@ const getVolumeIcon = computed(() => {
|
||||
const volumeSlider = computed({
|
||||
get: () => audioVolume.value * 100,
|
||||
set: (value) => {
|
||||
if (!sound.value) return;
|
||||
localStorage.setItem('volume', (value / 100).toString());
|
||||
sound.value.volume(value / 100);
|
||||
audioService.setVolume(value / 100);
|
||||
audioVolume.value = value / 100;
|
||||
}
|
||||
});
|
||||
@@ -242,7 +311,7 @@ const mute = () => {
|
||||
};
|
||||
|
||||
// 播放模式
|
||||
const playMode = computed(() => store.state.playMode);
|
||||
const playMode = computed(() => playerStore.playMode);
|
||||
const playModeIcon = computed(() => {
|
||||
switch (playMode.value) {
|
||||
case 0:
|
||||
@@ -258,43 +327,72 @@ const playModeIcon = computed(() => {
|
||||
const playModeText = computed(() => {
|
||||
switch (playMode.value) {
|
||||
case 0:
|
||||
return '列表循环';
|
||||
return t('player.playBar.playMode.sequence');
|
||||
case 1:
|
||||
return '单曲循环';
|
||||
return t('player.playBar.playMode.loop');
|
||||
case 2:
|
||||
return '随机播放';
|
||||
return t('player.playBar.playMode.random');
|
||||
default:
|
||||
return '列表循环';
|
||||
return t('player.playBar.playMode.sequence');
|
||||
}
|
||||
});
|
||||
|
||||
// 切换播放模式
|
||||
const togglePlayMode = () => {
|
||||
store.commit('togglePlayMode');
|
||||
playerStore.togglePlayMode();
|
||||
};
|
||||
|
||||
function handleNext() {
|
||||
store.commit('nextPlay');
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
store.commit('prevPlay');
|
||||
playerStore.prevPlay();
|
||||
}
|
||||
|
||||
const MusicFullRef = ref<any>(null);
|
||||
const showSliderTooltip = ref(false);
|
||||
|
||||
// 播放暂停按钮事件
|
||||
const playMusicEvent = async () => {
|
||||
if (play.value) {
|
||||
if (sound.value) {
|
||||
sound.value.pause();
|
||||
try {
|
||||
// 检查是否有有效的音乐对象
|
||||
if (!playMusic.value?.id) {
|
||||
console.warn('没有有效的播放对象');
|
||||
return;
|
||||
}
|
||||
store.commit('setPlayMusic', false);
|
||||
} else {
|
||||
if (sound.value) {
|
||||
sound.value.play();
|
||||
|
||||
// 当前处于播放状态 -> 暂停
|
||||
if (play.value) {
|
||||
if (audioService.getCurrentSound()) {
|
||||
audioService.pause();
|
||||
playerStore.setPlayMusic(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
store.commit('setPlayMusic', true);
|
||||
|
||||
// 当前处于暂停状态 -> 播放
|
||||
// 有音频实例,直接播放
|
||||
if (audioService.getCurrentSound()) {
|
||||
audioService.play();
|
||||
playerStore.setPlayMusic(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有音频实例,重新获取并播放(包括重新获取B站视频URL)
|
||||
try {
|
||||
// 复用当前播放对象,但强制重新获取URL
|
||||
const result = await playerStore.setPlay({ ...playMusic.value, playMusicUrl: undefined });
|
||||
if (result) {
|
||||
playerStore.setPlayMusic(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重新获取播放链接失败:', error);
|
||||
message.error(t('player.playFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放出错:', error);
|
||||
message.error(t('player.playFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -303,31 +401,38 @@ const musicFullVisible = ref(false);
|
||||
// 设置musicFull
|
||||
const setMusicFull = () => {
|
||||
musicFullVisible.value = !musicFullVisible.value;
|
||||
store.commit('setMusicFull', musicFullVisible.value);
|
||||
playerStore.setMusicFull(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) => {
|
||||
if (!val) return;
|
||||
setTimeout(() => {
|
||||
palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 });
|
||||
palyListRef.value?.scrollTo({ top: playerStore.playListIndex * 62 });
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
return store.state.favoriteList.includes(playMusic.value.id);
|
||||
// 将id转换为number,兼容B站视频ID
|
||||
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();
|
||||
// 将id转换为number,兼容B站视频ID
|
||||
const numericId =
|
||||
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
|
||||
|
||||
if (isFavorite.value) {
|
||||
store.commit('removeFromFavorite', playMusic.value.id);
|
||||
playerStore.removeFromFavorite(numericId);
|
||||
} else {
|
||||
store.commit('addToFavorite', playMusic.value.id);
|
||||
playerStore.addToFavorite(numericId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,56 +440,33 @@ const openLyricWindow = () => {
|
||||
openLyric();
|
||||
};
|
||||
|
||||
const { navigateToArtist } = useArtist();
|
||||
|
||||
const handleArtistClick = (id: number) => {
|
||||
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 ? '开始播放' : '暂停播放',
|
||||
store.state.play ? 'ri-pause-circle-line' : 'ri-play-circle-line'
|
||||
);
|
||||
break;
|
||||
case 'prevPlay':
|
||||
handlePrev();
|
||||
showShortcutToast('上一首', 'ri-skip-back-line');
|
||||
break;
|
||||
case 'nextPlay':
|
||||
handleNext();
|
||||
showShortcutToast('下一首', 'ri-skip-forward-line');
|
||||
break;
|
||||
case 'volumeUp':
|
||||
if (volumeSlider.value < 100) {
|
||||
volumeSlider.value = Math.min(volumeSlider.value + 10, 100);
|
||||
showShortcutToast(`音量${volumeSlider.value}%`, 'ri-volume-up-line');
|
||||
}
|
||||
break;
|
||||
case 'volumeDown':
|
||||
if (volumeSlider.value > 0) {
|
||||
volumeSlider.value = Math.max(volumeSlider.value - 10, 0);
|
||||
showShortcutToast(`音量${volumeSlider.value}%`, 'ri-volume-down-line');
|
||||
}
|
||||
break;
|
||||
case 'toggleFavorite':
|
||||
toggleFavorite(new Event('click'));
|
||||
showShortcutToast(
|
||||
isFavorite.value ? `已收藏${playMusic.value.name}` : `已取消收藏${playMusic.value.name}`,
|
||||
isFavorite.value ? 'ri-heart-fill' : 'ri-heart-line'
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.log('未知的快捷键动作:', action);
|
||||
break;
|
||||
// 监听播放栏显示状态
|
||||
watch(
|
||||
() => MusicFullRef.value?.config?.hidePlayBar,
|
||||
(newVal) => {
|
||||
if (newVal && musicFullVisible.value) {
|
||||
// 使用 animate.css 动画,不需要手动设置样式
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const isEQVisible = ref(false);
|
||||
|
||||
// 在 script setup 部分添加删除歌曲的处理函数
|
||||
const handleDeleteSong = (song: SongResult) => {
|
||||
// 如果删除的是当前播放的歌曲,先切换到下一首
|
||||
if (song.id === playMusic.value.id) {
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
playerStore.removeFromPlayList(song.id as number);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -398,6 +480,16 @@ if (isElectron) {
|
||||
z-index: 9999;
|
||||
animation-duration: 0.5s !important;
|
||||
|
||||
&.play-bar-opcity {
|
||||
@apply bg-transparent !important;
|
||||
box-shadow: 0 0 20px 5px #0000001d;
|
||||
}
|
||||
|
||||
&.animate__slideOutDown {
|
||||
animation-duration: 0.3s !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.music-content {
|
||||
width: 160px;
|
||||
@apply ml-4;
|
||||
@@ -412,11 +504,6 @@ if (isElectron) {
|
||||
}
|
||||
}
|
||||
|
||||
.play-bar-opcity {
|
||||
@apply bg-transparent !important;
|
||||
box-shadow: 0 0 20px 5px #0000001d;
|
||||
}
|
||||
|
||||
.play-bar-img {
|
||||
@apply w-14 h-14 rounded-2xl;
|
||||
}
|
||||
@@ -496,7 +583,7 @@ if (isElectron) {
|
||||
|
||||
.mobile {
|
||||
.music-play-bar {
|
||||
@apply px-4 bottom-[70px] transition-all duration-300;
|
||||
@apply px-4 bottom-[56px] transition-all duration-300;
|
||||
}
|
||||
.music-time {
|
||||
display: none;
|
||||
@@ -564,8 +651,16 @@ if (isElectron) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .n-slider-handle {
|
||||
opacity: 1;
|
||||
&:hover {
|
||||
.n-slider-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保悬停时提示样式正确
|
||||
.n-slider-tooltip {
|
||||
@apply bg-gray-800 text-white text-xs py-1 px-2 rounded;
|
||||
z-index: 999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,6 +704,13 @@ if (isElectron) {
|
||||
@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-single-loop {
|
||||
font-size: 1.5rem;
|
||||
@@ -621,4 +723,23 @@ if (isElectron) {
|
||||
padding: 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>
|
||||
@@ -2,7 +2,7 @@
|
||||
<n-modal
|
||||
v-model:show="visible"
|
||||
preset="dialog"
|
||||
title="快捷键设置"
|
||||
:title="t('settings.shortcutSettings.title')"
|
||||
:show-icon="false"
|
||||
style="width: 600px"
|
||||
@after-leave="handleAfterLeave"
|
||||
@@ -16,24 +16,54 @@
|
||||
<div class="shortcut-info">
|
||||
<span class="shortcut-label">{{ getShortcutLabel(key) }}</span>
|
||||
</div>
|
||||
<div class="shortcut-input">
|
||||
<n-input
|
||||
:value="formatShortcut(shortcut)"
|
||||
:status="duplicateKeys[key] ? 'error' : undefined"
|
||||
placeholder="点击输入快捷键"
|
||||
readonly
|
||||
@keydown="(e) => handleKeyDown(e, key)"
|
||||
@focus="() => startRecording(key)"
|
||||
@blur="stopRecording"
|
||||
/>
|
||||
<n-tooltip v-if="duplicateKeys[key]" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-icon class="error-icon" size="18">
|
||||
<i class="ri-error-warning-line"></i>
|
||||
</n-icon>
|
||||
</template>
|
||||
快捷键冲突
|
||||
</n-tooltip>
|
||||
<div class="shortcut-controls">
|
||||
<div class="shortcut-input">
|
||||
<n-input
|
||||
:value="formatShortcut(shortcut.key)"
|
||||
:status="duplicateKeys[key] ? 'error' : undefined"
|
||||
:placeholder="t('settings.shortcutSettings.inputPlaceholder')"
|
||||
:disabled="!shortcut.enabled"
|
||||
readonly
|
||||
@keydown="(e) => handleKeyDown(e, key)"
|
||||
@focus="() => startRecording(key)"
|
||||
@blur="stopRecording"
|
||||
/>
|
||||
<n-tooltip v-if="duplicateKeys[key]" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-icon class="error-icon" size="18">
|
||||
<i class="ri-error-warning-line"></i>
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('settings.shortcutSettings.shortcutConflict') }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
<div class="shortcut-options">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-switch v-model:value="shortcut.enabled" size="small" />
|
||||
</template>
|
||||
{{
|
||||
shortcut.enabled
|
||||
? t('settings.shortcutSettings.enabled')
|
||||
: t('settings.shortcutSettings.disabled')
|
||||
}}
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="shortcut.enabled" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-select
|
||||
v-model:value="shortcut.scope"
|
||||
:options="scopeOptions"
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
/>
|
||||
</template>
|
||||
{{
|
||||
shortcut.scope === 'global'
|
||||
? t('settings.shortcutSettings.scopeGlobal')
|
||||
: t('settings.shortcutSettings.scopeApp')
|
||||
}}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
@@ -42,10 +72,18 @@
|
||||
|
||||
<div class="shortcut-footer">
|
||||
<n-space justify="end">
|
||||
<n-button size="small" @click="handleCancel">取消</n-button>
|
||||
<n-button size="small" @click="resetShortcuts">恢复默认</n-button>
|
||||
<n-button size="small" @click="handleCancel">{{ t('common.cancel') }}</n-button>
|
||||
<n-button size="small" @click="resetShortcuts">{{
|
||||
t('settings.shortcutSettings.resetShortcuts')
|
||||
}}</n-button>
|
||||
<n-button size="small" type="warning" @click="disableAllShortcuts">{{
|
||||
t('settings.shortcutSettings.disableAll')
|
||||
}}</n-button>
|
||||
<n-button size="small" type="success" @click="enableAllShortcuts">{{
|
||||
t('settings.shortcutSettings.enableAll')
|
||||
}}</n-button>
|
||||
<n-button type="primary" size="small" :disabled="hasConflict" @click="handleSave">
|
||||
保存
|
||||
{{ t('common.save') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
@@ -58,29 +96,43 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { isElectron } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface ShortcutConfig {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
scope: 'global' | 'app';
|
||||
}
|
||||
|
||||
interface Shortcuts {
|
||||
togglePlay: string;
|
||||
prevPlay: string;
|
||||
nextPlay: string;
|
||||
volumeUp: string;
|
||||
volumeDown: string;
|
||||
toggleFavorite: string;
|
||||
toggleWindow: string;
|
||||
togglePlay: ShortcutConfig;
|
||||
prevPlay: ShortcutConfig;
|
||||
nextPlay: ShortcutConfig;
|
||||
volumeUp: ShortcutConfig;
|
||||
volumeDown: ShortcutConfig;
|
||||
toggleFavorite: ShortcutConfig;
|
||||
toggleWindow: ShortcutConfig;
|
||||
}
|
||||
|
||||
const defaultShortcuts: Shortcuts = {
|
||||
togglePlay: 'CommandOrControl+Alt+P',
|
||||
prevPlay: 'Alt+Left',
|
||||
nextPlay: 'Alt+Right',
|
||||
volumeUp: 'Alt+Up',
|
||||
volumeDown: 'Alt+Down',
|
||||
toggleFavorite: 'CommandOrControl+Alt+L',
|
||||
toggleWindow: 'CommandOrControl+Alt+Shift+M'
|
||||
togglePlay: { key: 'CommandOrControl+Alt+P', enabled: true, scope: 'global' },
|
||||
prevPlay: { key: 'Alt+Left', enabled: true, scope: 'global' },
|
||||
nextPlay: { key: 'Alt+Right', enabled: true, scope: 'global' },
|
||||
volumeUp: { key: 'Alt+Up', enabled: true, scope: 'app' },
|
||||
volumeDown: { key: 'Alt+Down', enabled: true, scope: 'app' },
|
||||
toggleFavorite: { key: 'CommandOrControl+Alt+L', enabled: true, scope: 'app' },
|
||||
toggleWindow: { key: 'CommandOrControl+Alt+Shift+M', enabled: true, scope: 'global' }
|
||||
};
|
||||
|
||||
const scopeOptions = [
|
||||
{ label: t('settings.shortcutSettings.scopeGlobal'), value: 'global' },
|
||||
{ label: t('settings.shortcutSettings.scopeApp'), value: 'app' }
|
||||
];
|
||||
|
||||
const shortcuts = ref<Shortcuts>(
|
||||
isElectron
|
||||
? window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts') || defaultShortcuts
|
||||
@@ -88,7 +140,7 @@ const shortcuts = ref<Shortcuts>(
|
||||
);
|
||||
|
||||
// 临时存储编辑中的快捷键
|
||||
const tempShortcuts = ref<Shortcuts>({ ...shortcuts.value });
|
||||
const tempShortcuts = ref<Shortcuts>(cloneDeep(shortcuts.value));
|
||||
|
||||
// 监听快捷键更新
|
||||
if (isElectron) {
|
||||
@@ -96,7 +148,7 @@ if (isElectron) {
|
||||
const newShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
|
||||
if (newShortcuts) {
|
||||
shortcuts.value = newShortcuts;
|
||||
tempShortcuts.value = { ...newShortcuts };
|
||||
tempShortcuts.value = cloneDeep(newShortcuts);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -111,23 +163,38 @@ onMounted(() => {
|
||||
console.log('storedShortcuts', storedShortcuts);
|
||||
if (storedShortcuts) {
|
||||
shortcuts.value = storedShortcuts;
|
||||
tempShortcuts.value = { ...storedShortcuts };
|
||||
tempShortcuts.value = cloneDeep(storedShortcuts);
|
||||
} else {
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
tempShortcuts.value = { ...defaultShortcuts };
|
||||
tempShortcuts.value = cloneDeep(defaultShortcuts);
|
||||
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', defaultShortcuts);
|
||||
}
|
||||
|
||||
// 转换旧格式的快捷键数据到新格式
|
||||
if (storedShortcuts && typeof storedShortcuts.togglePlay === 'string') {
|
||||
const convertedShortcuts = {} as Shortcuts;
|
||||
Object.entries(storedShortcuts).forEach(([key, value]) => {
|
||||
convertedShortcuts[key as keyof Shortcuts] = {
|
||||
key: value as string,
|
||||
enabled: true,
|
||||
scope: ['volumeUp', 'volumeDown', 'toggleFavorite'].includes(key) ? 'app' : 'global'
|
||||
};
|
||||
});
|
||||
shortcuts.value = convertedShortcuts;
|
||||
tempShortcuts.value = cloneDeep(convertedShortcuts);
|
||||
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', convertedShortcuts);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const shortcutLabels: Record<keyof Shortcuts, string> = {
|
||||
togglePlay: '播放/暂停',
|
||||
prevPlay: '上一首',
|
||||
nextPlay: '下一首',
|
||||
volumeUp: '音量增加',
|
||||
volumeDown: '音量减少',
|
||||
toggleFavorite: '收藏/取消收藏',
|
||||
toggleWindow: '显示/隐藏窗口'
|
||||
togglePlay: t('settings.shortcutSettings.togglePlay'),
|
||||
prevPlay: t('settings.shortcutSettings.prevPlay'),
|
||||
nextPlay: t('settings.shortcutSettings.nextPlay'),
|
||||
volumeUp: t('settings.shortcutSettings.volumeUp'),
|
||||
volumeDown: t('settings.shortcutSettings.volumeDown'),
|
||||
toggleFavorite: t('settings.shortcutSettings.toggleFavorite'),
|
||||
toggleWindow: t('settings.shortcutSettings.toggleWindow')
|
||||
};
|
||||
|
||||
const getShortcutLabel = (key: keyof Shortcuts) => shortcutLabels[key];
|
||||
@@ -139,13 +206,21 @@ const message = useMessage();
|
||||
// 检查快捷键冲突
|
||||
const duplicateKeys = computed(() => {
|
||||
const result: Record<string, boolean> = {};
|
||||
const usedShortcuts = new Set<string>();
|
||||
const usedShortcuts = new Map<string, string>();
|
||||
|
||||
Object.entries(tempShortcuts.value).forEach(([key, shortcut]) => {
|
||||
if (usedShortcuts.has(shortcut)) {
|
||||
result[key] = true;
|
||||
// 只检查启用的快捷键
|
||||
if (!shortcut.enabled) return;
|
||||
|
||||
const conflictKey = usedShortcuts.get(shortcut.key);
|
||||
if (conflictKey) {
|
||||
// 只有相同作用域的快捷键才会被认为冲突
|
||||
const conflictScope = tempShortcuts.value[conflictKey as keyof Shortcuts].scope;
|
||||
if (shortcut.scope === conflictScope) {
|
||||
result[key] = true;
|
||||
}
|
||||
} else {
|
||||
usedShortcuts.add(shortcut);
|
||||
usedShortcuts.set(shortcut.key, key);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,6 +231,8 @@ const duplicateKeys = computed(() => {
|
||||
const hasConflict = computed(() => Object.keys(duplicateKeys.value).length > 0);
|
||||
|
||||
const startRecording = (key: keyof Shortcuts) => {
|
||||
if (!tempShortcuts.value[key].enabled) return;
|
||||
|
||||
isRecording.value = true;
|
||||
currentKey.value = key;
|
||||
// 禁用全局快捷键
|
||||
@@ -215,18 +292,18 @@ const handleKeyDown = (e: KeyboardEvent, key: keyof Shortcuts) => {
|
||||
}
|
||||
|
||||
if (!['Control', 'Alt', 'Shift', 'Meta', 'Command'].includes(keyName)) {
|
||||
tempShortcuts.value[key] = [...modifiers, keyName].join('+');
|
||||
tempShortcuts.value[key].key = [...modifiers, keyName].join('+');
|
||||
}
|
||||
};
|
||||
|
||||
const resetShortcuts = () => {
|
||||
tempShortcuts.value = { ...defaultShortcuts };
|
||||
message.success('已恢复默认快捷键,请记得保存');
|
||||
tempShortcuts.value = cloneDeep(defaultShortcuts);
|
||||
message.success(t('settings.shortcutSettings.messages.resetSuccess'));
|
||||
};
|
||||
|
||||
const saveShortcuts = () => {
|
||||
if (hasConflict.value) {
|
||||
message.error('存在冲突的快捷键,请重新设置');
|
||||
message.error(t('settings.shortcutSettings.messages.conflict'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,18 +317,18 @@ const saveShortcuts = () => {
|
||||
// 先保存到 store
|
||||
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', shortcutsToSave);
|
||||
// 然后更新快捷键
|
||||
window.electron.ipcRenderer.send('update-shortcuts');
|
||||
message.success('快捷键设置已保存');
|
||||
window.electron.ipcRenderer.send('update-shortcuts', shortcutsToSave);
|
||||
message.success(t('settings.shortcutSettings.messages.saveSuccess'));
|
||||
} catch (error) {
|
||||
console.error('保存快捷键失败:', error);
|
||||
message.error('保存快捷键失败,请重试');
|
||||
message.error(t('settings.shortcutSettings.messages.saveError'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
tempShortcuts.value = { ...shortcuts.value };
|
||||
message.info('已取消修改');
|
||||
tempShortcuts.value = cloneDeep(shortcuts.value);
|
||||
message.info(t('settings.shortcutSettings.messages.cancelEdit'));
|
||||
emit('update:show', false);
|
||||
};
|
||||
|
||||
@@ -304,7 +381,7 @@ watch(visible, (newVal) => {
|
||||
// 处理弹窗关闭后的事件
|
||||
const handleAfterLeave = () => {
|
||||
// 重置临时数据
|
||||
tempShortcuts.value = { ...shortcuts.value };
|
||||
tempShortcuts.value = cloneDeep(shortcuts.value);
|
||||
};
|
||||
|
||||
// 处理取消按钮点击
|
||||
@@ -319,6 +396,22 @@ const handleSave = () => {
|
||||
visible.value = false;
|
||||
emit('change', shortcuts.value);
|
||||
};
|
||||
|
||||
// 全部禁用快捷键
|
||||
const disableAllShortcuts = () => {
|
||||
Object.keys(tempShortcuts.value).forEach((key) => {
|
||||
tempShortcuts.value[key as keyof Shortcuts].enabled = false;
|
||||
});
|
||||
message.info(t('settings.shortcutSettings.messages.disableAll'));
|
||||
};
|
||||
|
||||
// 全部启用快捷键
|
||||
const enableAllShortcuts = () => {
|
||||
Object.keys(tempShortcuts.value).forEach((key) => {
|
||||
tempShortcuts.value[key as keyof Shortcuts].enabled = true;
|
||||
});
|
||||
message.info(t('settings.shortcutSettings.messages.enableAll'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -354,25 +447,32 @@ const handleSave = () => {
|
||||
}
|
||||
|
||||
.shortcut-info {
|
||||
@apply flex flex-col;
|
||||
@apply flex flex-col min-w-[150px];
|
||||
|
||||
.shortcut-label {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
@apply flex items-center gap-2;
|
||||
min-width: 200px;
|
||||
.shortcut-controls {
|
||||
@apply flex items-center gap-3 flex-1;
|
||||
|
||||
:deep(.n-input) {
|
||||
.n-input__input-el {
|
||||
@apply text-center font-mono;
|
||||
.shortcut-input {
|
||||
@apply flex items-center gap-2 flex-1;
|
||||
|
||||
:deep(.n-input) {
|
||||
.n-input__input-el {
|
||||
@apply text-center font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
@apply text-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
@apply text-red-500;
|
||||
.shortcut-options {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ export const SEARCH_TYPES = [
|
||||
{
|
||||
label: 'MV',
|
||||
key: 1004
|
||||
},
|
||||
{
|
||||
label: 'B站',
|
||||
key: 2000
|
||||
}
|
||||
// {
|
||||
// label: '歌词',
|
||||
@@ -63,3 +67,12 @@ export const SEARCH_TYPES = [
|
||||
// key: 1018,
|
||||
// },
|
||||
];
|
||||
|
||||
export const SEARCH_TYPE = {
|
||||
MUSIC: 1, // 单曲
|
||||
ALBUM: 10, // 专辑
|
||||
ARTIST: 100, // 歌手
|
||||
PLAYLIST: 1000, // 歌单
|
||||
MV: 1004, // MV
|
||||
BILIBILI: 2000 // B站视频
|
||||
} as const;
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 定义表配置的泛型接口
|
||||
export interface StoreConfig<T extends string> {
|
||||
name: T;
|
||||
keyPath?: string;
|
||||
}
|
||||
|
||||
// 创建一个使用 IndexedDB 的组合函数
|
||||
const useIndexedDB = () => {
|
||||
const db = ref<IDBDatabase | null>(null); // 数据库引用
|
||||
const useIndexedDB = async <T extends string, S extends Record<T, Record<string, any>>>(
|
||||
dbName: string,
|
||||
stores: StoreConfig<T>[],
|
||||
version: number = 1
|
||||
) => {
|
||||
const db = ref<IDBDatabase | null>(null);
|
||||
|
||||
// 打开数据库并创建表
|
||||
const initDB = (
|
||||
dbName: string,
|
||||
version: number,
|
||||
stores: { name: string; keyPath?: string }[]
|
||||
) => {
|
||||
const initDB = () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, version); // 打开数据库请求
|
||||
const request = indexedDB.open(dbName, version);
|
||||
|
||||
request.onupgradeneeded = (event: any) => {
|
||||
const db = event.target.result; // 获取数据库实例
|
||||
const db = event.target.result;
|
||||
stores.forEach((store) => {
|
||||
if (!db.objectStoreNames.contains(store.name)) {
|
||||
// 确保对象存储(表)创建
|
||||
db.createObjectStore(store.name, {
|
||||
keyPath: store.keyPath || 'id',
|
||||
autoIncrement: true
|
||||
@@ -28,39 +33,41 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
request.onsuccess = (event: any) => {
|
||||
db.value = event.target.result; // 保存数据库实例
|
||||
resolve(); // 成功时解析 Promise
|
||||
db.value = event.target.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = (event: any) => {
|
||||
reject(event.target.error); // 失败时拒绝 Promise
|
||||
reject(event.target.error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 通用新增数据
|
||||
const addData = (storeName: string, value: any) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化'); // 检查数据库是否已初始化
|
||||
const tx = db.value.transaction(storeName, 'readwrite'); // 创建事务
|
||||
const store = tx.objectStore(storeName); // 获取对象存储
|
||||
await initDB();
|
||||
|
||||
const request = store.add(value); // 添加数据请求
|
||||
// 通用新增数据
|
||||
const addData = <K extends T>(storeName: K, value: S[K]) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
const store = tx.objectStore(storeName);
|
||||
|
||||
const request = store.add(value);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('成功'); // 成功时输出
|
||||
resolve(); // 解析 Promise
|
||||
console.log('成功');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
console.error('新增失败:', (event.target as IDBRequest).error); // 输出错误
|
||||
reject((event.target as IDBRequest).error); // 拒绝 Promise
|
||||
console.error('新增失败:', (event.target as IDBRequest).error);
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 通用保存数据(新增或更新)
|
||||
const saveData = (storeName: string, value: any) => {
|
||||
const saveData = <K extends T>(storeName: K, value: S[K]) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
@@ -79,8 +86,8 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 通用获取数据
|
||||
const getData = (storeName: string, key: string | number) => {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const getData = <K extends T>(storeName: K, key: string | number) => {
|
||||
return new Promise<S[K]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
@@ -101,7 +108,7 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const deleteData = (storeName: string, key: string | number) => {
|
||||
const deleteData = <K extends T>(storeName: K, key: string | number) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
@@ -120,8 +127,8 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 查询所有数据
|
||||
const getAllData = (storeName: string) => {
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
const getAllData = <K extends T>(storeName: K) => {
|
||||
return new Promise<S[K][]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
@@ -142,29 +149,29 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 分页查询数据
|
||||
const getDataWithPagination = (storeName: string, page: number, pageSize: number) => {
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
const getDataWithPagination = <K extends T>(storeName: K, page: number, pageSize: number) => {
|
||||
return new Promise<S[K][]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = store.openCursor(); // 打开游标请求
|
||||
const results: any[] = []; // 存储结果的数组
|
||||
let index = 0; // 当前索引
|
||||
const skip = (page - 1) * pageSize; // 计算跳过的数量
|
||||
const request = store.openCursor();
|
||||
const results: S[K][] = [];
|
||||
let index = 0;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
request.onsuccess = (event: any) => {
|
||||
const cursor = event.target.result; // 获取游标
|
||||
const cursor = event.target.result;
|
||||
if (!cursor) {
|
||||
resolve(results); // 如果没有更多数据,解析结果
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
|
||||
if (index >= skip && results.length < pageSize) {
|
||||
results.push(cursor.value); // 添加当前游标值到结果
|
||||
results.push(cursor.value);
|
||||
}
|
||||
|
||||
index++; // 增加索引
|
||||
cursor.continue(); // 继续游标
|
||||
index++;
|
||||
cursor.continue();
|
||||
};
|
||||
|
||||
request.onerror = (event: any) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// musicHistoryHooks
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
import { recordPlay } from '@/api/stats';
|
||||
import type { SongResult } from '@/type/music';
|
||||
|
||||
export const useMusicHistory = () => {
|
||||
@@ -14,6 +15,25 @@ export const useMusicHistory = () => {
|
||||
} else {
|
||||
musicHistory.value.unshift({ ...music, count: 1 });
|
||||
}
|
||||
|
||||
// 记录播放统计
|
||||
if (music?.id && music?.name) {
|
||||
// 获取艺术家名称
|
||||
let artistName = '未知艺术家';
|
||||
|
||||
if (music.ar) {
|
||||
artistName = music.ar.map((artist) => artist.name).join('/');
|
||||
} else if (music.song?.artists && music.song.artists.length > 0) {
|
||||
artistName = music.song.artists.map((artist) => artist.name).join('/');
|
||||
} else if (music.artists) {
|
||||
artistName = music.artists.map((artist) => artist.name).join('/');
|
||||
}
|
||||
|
||||
// 发送播放统计
|
||||
recordPlay(music.id, music.name, artistName).catch((error) =>
|
||||
console.error('记录播放统计失败:', error)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const delMusic = (music: SongResult) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user