mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 06:30:49 +08:00
Compare commits
23 Commits
56adac0d4e
...
v5.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
939dc85d7d | ||
|
|
c4831966c1 | ||
|
|
50aebcf8de | ||
|
|
75d1225b40 | ||
|
|
c251ec9dcf | ||
|
|
00a251b5b6 | ||
|
|
7e59cfee05 | ||
|
|
c3dd03cc13 | ||
|
|
999cd6526b | ||
|
|
77bb06c0d6 | ||
|
|
85302c611a | ||
|
|
0f42bfc6cb | ||
|
|
5bcef29f10 | ||
|
|
a9fb487332 | ||
|
|
8e1259d2aa | ||
|
|
70f1044dd9 | ||
|
|
e2ebbe12e4 | ||
|
|
af9117ee5f | ||
|
|
6bc168c5bd | ||
|
|
89c6b11110 | ||
|
|
b9287e1c36 | ||
|
|
1a0e449e13 | ||
|
|
07f6152c56 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ android/app/release
|
||||
|
||||
.cursor
|
||||
.windsurf
|
||||
.agent
|
||||
|
||||
|
||||
.auto-imports.d.ts
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,38 +1,48 @@
|
||||
# 更新日志
|
||||
|
||||
## v4.9.0
|
||||
## v5.0.0
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- 重新设计pc端歌词页面Mini播放栏
|
||||
- 添加清除歌曲自定义解析功能
|
||||
- 添加Cookie登录功能及自动获取等相关管理设置 ([16aeaf2](https://github.com/algerkong/AlgerMusicPlayer/commit/16aeaf2)) - 支持通过Cookie方式登录,提供更便捷的登录体验
|
||||
- 添加UID登录功能,优化登录流程 ([daa8e75](https://github.com/algerkong/AlgerMusicPlayer/commit/daa8e75)) - 新增用户ID直接登录方式
|
||||
- 添加主题根据系统切换功能 ([d5ba218](https://github.com/algerkong/AlgerMusicPlayer/commit/d5ba218)) - 支持跟随系统主题自动切换明暗模式
|
||||
- 桌面歌词添加主题颜色面板组件 ([d1f5c8a](https://github.com/algerkong/AlgerMusicPlayer/commit/d1f5c8a)) - 为桌面歌词提供丰富的主题颜色自定义选项
|
||||
- 增强播放速度控制,添加滑块控制并改善播放安全性 ([8fb382e](https://github.com/algerkong/AlgerMusicPlayer/commit/8fb382e)) 感谢[Qumo](https://github.com/Hellodwadawd12312312)的pr
|
||||
- 添加日语和韩语国际化支持,并优化语言相关代码 ([3062156](https://github.com/algerkong/AlgerMusicPlayer/commit/3062156))
|
||||
- 添加繁体中文本地化支持 ([2cc03cb](https://github.com/algerkong/AlgerMusicPlayer/commit/2cc03cb)) 感谢[dongguacute](https://github.com/dongguacute)的pr
|
||||
- 播放速度设置弹窗标题添加速度显示 ([aeb7f03](https://github.com/algerkong/AlgerMusicPlayer/commit/aeb7f03))
|
||||
- LX Music 音源脚本导入
|
||||
- 逐字歌词,支持全屏歌词和桌面歌词同步显示
|
||||
- 心动模式播放
|
||||
- 移动设备整体页面风格和效果优化
|
||||
- 移动端添加平板模式设置
|
||||
- 歌词页面样式控制优化 支持背景、宽度、字体粗细等个性化设置
|
||||
- 历史日推查看
|
||||
- 播放记录热力图
|
||||
- 历史记录支持本地和云端记录
|
||||
- 用户页面收藏专辑展示
|
||||
- 添加 GPU 硬件加速设置
|
||||
- 菜单展开状态保存 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
|
||||
- 搜索建议 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
|
||||
- 歌词繁体中文翻译模块,集成 OpenCC 引擎 - 感谢 [Leko](https://github.com/lekoOwO) 的贡献
|
||||
- 自定义 API源 支持 [自定义源文档](https://github.com/algerkong/AlgerMusicPlayer/blob/main/docs/custom-api-readme.md) - 感谢 [harenchi](https://github.com/souvenp) 的贡献
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
- 修复mac快捷键关闭窗口报错的问题 ([67ef4d7](https://github.com/algerkong/AlgerMusicPlayer/commit/67ef4d7))
|
||||
- 修复mini窗口恢复时导致的应用窗口变小问题 ([9b3019d](https://github.com/algerkong/AlgerMusicPlayer/commit/9b3019d))
|
||||
- 修复歌单列表页面翻页类型问题 ([e489ab4](https://github.com/algerkong/AlgerMusicPlayer/commit/e489ab4))
|
||||
- 修复歌曲初始化问题 ([b7a58a0](https://github.com/algerkong/AlgerMusicPlayer/commit/b7a58a0))
|
||||
- 修复音量调整不同步的问题 ([679089e](https://github.com/algerkong/AlgerMusicPlayer/commit/679089e))
|
||||
- 修复菜单显示不全的问题,添加滚动条 ([09ccd9f](https://github.com/algerkong/AlgerMusicPlayer/commit/09ccd9f))
|
||||
- 修复随机播放顺序异常
|
||||
- 修复音源解析错误处理
|
||||
- 修复 Mini 播放栏主题颜色问题
|
||||
- 修复桌面歌词透明模式标题栏显示
|
||||
- 修复逐字歌词字间距
|
||||
- 修复远程控制设置无法保存
|
||||
- 修复下载无损格式返回 HiRes 音质 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
|
||||
- 兼容 pnpm 包管理器 - 感谢 [Leko](https://github.com/lekoOwO) 的贡献
|
||||
|
||||
### 🎨 优化
|
||||
|
||||
- 更新 eslint 和 prettier 配置,格式化代码 ([c08c2cb](https://github.com/algerkong/AlgerMusicPlayer/commit/c08c2cb))
|
||||
- 优化类型处理和登录功能 ([3ba85f3](https://github.com/algerkong/AlgerMusicPlayer/commit/3ba85f3))
|
||||
- 优化Cookie相关文字描述 ([1597fbf](https://github.com/algerkong/AlgerMusicPlayer/commit/1597fbf))
|
||||
- 音源解析缓存
|
||||
- 完善多语言国际化
|
||||
- 优化播放检测和错误处理
|
||||
- FLAC 元数据和封面图片处理 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
|
||||
- 日推不感兴趣调用官方接口 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
|
||||
- 代码提交流程优化,添加 lint-staged
|
||||
|
||||
## 赞赏支持☕️
|
||||
|
||||
[赞赏列表](http://donate.alger.fun/)
|
||||
[赞赏列表](https://donate.alger.fun/donate)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
@@ -16,5 +16,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -13,21 +13,21 @@
|
||||
|
||||
导入的配置文件必须是一个合法的 JSON 文件,并包含以下字段:
|
||||
|
||||
| 字段名 | 类型 | 是否必须 | 描述 |
|
||||
| ---------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | `string` | 是 | API 名称,将显示在应用的 UI 界面上。 |
|
||||
| `apiUrl` | `string` | 是 | API 的基础请求地址。 |
|
||||
| `method` | `string` | 否 | HTTP 请求方法。可以是 `"GET"` 或 `"POST"`。**如果省略,默认为 "GET"**。 |
|
||||
| `params` | `object` | 是 | 请求时需要发送的参数。对于 `GET` 请求,它们会作为查询字符串;对于 `POST` 请求,它们会作为请求体。 |
|
||||
| `qualityMapping` | `object` | 否 | **音质映射表**。用于将应用内部的音质值(如 `"lossless"`)翻译成你的 API 需要的特定值。如果省略,则直接使用应用内部值。 |
|
||||
| `responseUrlPath`| `string` | 是 | **URL提取路径**。用于从 API 返回的 JSON 响应中找到最终可播放的音乐链接。支持点 `.` 和方括号 `[]` 语法来访问嵌套对象和数组。 |
|
||||
| 字段名 | 类型 | 是否必须 | 描述 |
|
||||
| ----------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | `string` | 是 | API 名称,将显示在应用的 UI 界面上。 |
|
||||
| `apiUrl` | `string` | 是 | API 的基础请求地址。 |
|
||||
| `method` | `string` | 否 | HTTP 请求方法。可以是 `"GET"` 或 `"POST"`。**如果省略,默认为 "GET"**。 |
|
||||
| `params` | `object` | 是 | 请求时需要发送的参数。对于 `GET` 请求,它们会作为查询字符串;对于 `POST` 请求,它们会作为请求体。 |
|
||||
| `qualityMapping` | `object` | 否 | **音质映射表**。用于将应用内部的音质值(如 `"lossless"`)翻译成你的 API 需要的特定值。如果省略,则直接使用应用内部值。 |
|
||||
| `responseUrlPath` | `string` | 是 | **URL提取路径**。用于从 API 返回的 JSON 响应中找到最终可播放的音乐链接。支持点 `.` 和方括号 `[]` 语法来访问嵌套对象和数组。 |
|
||||
|
||||
#### 占位符
|
||||
|
||||
在 `params` 对象的值中,你可以使用以下占位符,程序在请求时会自动替换它们:
|
||||
|
||||
* `{songId}`: 将被替换为当前歌曲的 ID。
|
||||
* `{quality}`: 将被替换为当前用户设置的音质字符串 (例如, `"higher"`, `"lossless"`)。
|
||||
- `{songId}`: 将被替换为当前歌曲的 ID。
|
||||
- `{quality}`: 将被替换为当前用户设置的音质字符串 (例如, `"higher"`, `"lossless"`)。
|
||||
|
||||
#### 音质值列表
|
||||
|
||||
@@ -45,18 +45,18 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Example API",
|
||||
"apiUrl": "https://api.example.com/music",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"song_id": "{songId}",
|
||||
"bitrate": "{quality}"
|
||||
},
|
||||
"qualityMapping": {
|
||||
"higher": "128000",
|
||||
"exhigh": "320000",
|
||||
"lossless": "999000"
|
||||
},
|
||||
"responseUrlPath": "data.play_url"
|
||||
"name": "Example API",
|
||||
"apiUrl": "https://api.example.com/music",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"song_id": "{songId}",
|
||||
"bitrate": "{quality}"
|
||||
},
|
||||
"qualityMapping": {
|
||||
"higher": "128000",
|
||||
"exhigh": "320000",
|
||||
"lossless": "999000"
|
||||
},
|
||||
"responseUrlPath": "data.play_url"
|
||||
}
|
||||
```
|
||||
```
|
||||
BIN
docs/image6.png
BIN
docs/image6.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 2.1 MiB |
@@ -1,44 +0,0 @@
|
||||
appId: com.electron.app
|
||||
productName: electron-lan-file
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: electron-lan-file
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
@@ -1,5 +1,5 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
import { defineConfig } from 'electron-vite';
|
||||
import { resolve } from 'path';
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
|
||||
@@ -8,12 +8,8 @@ import viteCompression from 'vite-plugin-compression';
|
||||
import VueDevTools from 'vite-plugin-vue-devtools';
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
main: {},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
107
package.json
107
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "AlgerMusicPlayer",
|
||||
"version": "4.9.0",
|
||||
"version": "5.0.0",
|
||||
"description": "Alger Music Player",
|
||||
"author": "Alger <algerkc@qq.com>",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -33,81 +33,86 @@
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@unblockneteasemusic/server": "^0.27.8-patch.1",
|
||||
"@unblockneteasemusic/server": "^0.27.10",
|
||||
"cors": "^2.8.5",
|
||||
"electron-store": "^8.1.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"express": "^4.18.2",
|
||||
"file-type": "^21.0.0",
|
||||
"express": "^4.22.1",
|
||||
"file-type": "^21.1.1",
|
||||
"flac-tagger": "^1.0.7",
|
||||
"font-list": "^1.5.1",
|
||||
"font-list": "^1.6.0",
|
||||
"form-data": "^4.0.5",
|
||||
"husky": "^9.1.7",
|
||||
"music-metadata": "^11.2.3",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"music-metadata": "^11.10.3",
|
||||
"netease-cloud-music-api-alger": "^4.26.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-id3": "^0.2.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"sharp": "^0.34.3",
|
||||
"vue-i18n": "^11.1.3"
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"sharp": "^0.34.5",
|
||||
"vue-i18n": "^11.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^2.1.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@rushstack/eslint-patch": "^1.15.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/node": "^20.19.26",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/compiler-sfc": "^3.5.25",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"@vue/runtime-core": "^3.5.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/runtime-core": "^3.5.25",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"@vueuse/electron": "^13.8.0",
|
||||
"@vueuse/electron": "^13.9.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.7",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^38.1.2",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^4.0.0",
|
||||
"eslint": "^9.34.0",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-vue": "^10.3.0",
|
||||
"eslint-plugin-vue-scoped-css": "^2.11.0",
|
||||
"globals": "^16.3.0",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"eslint-plugin-vue-scoped-css": "^2.12.0",
|
||||
"globals": "^16.5.0",
|
||||
"howler": "^2.2.4",
|
||||
"lint-staged": "^15.2.10",
|
||||
"lint-staged": "^15.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^15.0.4",
|
||||
"naive-ui": "^2.41.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinyin-match": "^1.2.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.6.2",
|
||||
"remixicon": "^4.6.0",
|
||||
"sass": "^1.86.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"marked": "^15.0.12",
|
||||
"naive-ui": "^2.43.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pinyin-match": "^1.2.10",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.4",
|
||||
"remixicon": "^4.7.0",
|
||||
"sass": "^1.96.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tunajs": "^1.0.15",
|
||||
"typescript": "^5.5.2",
|
||||
"unplugin-auto-import": "^19.1.1",
|
||||
"unplugin-vue-components": "^28.4.1",
|
||||
"vite": "^6.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "7.7.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue": "^3.5.25",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-tsc": "^2.0.22"
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-tsc": "^2.2.12"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.alger.music",
|
||||
@@ -144,6 +149,12 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSMicrophoneUsageDescription": "AlgerMusicPlayer needs access to the microphone for audio visualization.",
|
||||
"NSCameraUsageDescription": "Application requests access to the device's camera.",
|
||||
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
|
||||
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
|
||||
},
|
||||
"notarize": false,
|
||||
"identity": null,
|
||||
"type": "distribution",
|
||||
@@ -203,7 +214,9 @@
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "AlgerMusicPlayer",
|
||||
"include": "build/installer.nsh"
|
||||
"include": "build/installer.nsh",
|
||||
"deleteAppDataOnUninstall": true,
|
||||
"uninstallDisplayName": "AlgerMusicPlayer"
|
||||
}
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -40,9 +40,13 @@ export default {
|
||||
},
|
||||
viewMore: 'View More',
|
||||
noMore: 'No more',
|
||||
selectAll: 'Select All',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
songCount: '{count} songs',
|
||||
language: 'Language',
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
tray: {
|
||||
show: 'Show',
|
||||
quit: 'Quit',
|
||||
@@ -52,6 +56,5 @@ export default {
|
||||
pause: 'Pause',
|
||||
play: 'Play',
|
||||
favorite: 'Favorite'
|
||||
},
|
||||
language: 'Language'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,6 +52,31 @@ export default {
|
||||
copyFailed: 'Copy failed',
|
||||
backgroundDownload: 'Background Download'
|
||||
},
|
||||
disclaimer: {
|
||||
title: 'Terms of Use',
|
||||
warning:
|
||||
'This application is a development test version. Functions are not yet perfect, and there may be many problems and bugs. It is for learning and exchange only.',
|
||||
item1:
|
||||
'This application is for personal learning, research and technical exchange only. Please do not use it for any commercial purposes.',
|
||||
item2:
|
||||
'Please delete it within 24 hours after downloading. If you need to use it for a long time, please support the genuine music service.',
|
||||
item3:
|
||||
'By using this application, you understand and assume the relevant risks. The developer is not responsible for any loss.',
|
||||
agree: 'I have read and agree',
|
||||
disagree: 'Disagree and Exit'
|
||||
},
|
||||
donate: {
|
||||
title: 'Support Developer',
|
||||
subtitle: 'Your support is my motivation',
|
||||
tip: 'Donation is completely voluntary. All functions can be used normally without donation. Thank you for your understanding and support!',
|
||||
wechat: 'WeChat',
|
||||
alipay: 'Alipay',
|
||||
wechatQR: 'WeChat QR Code',
|
||||
alipayQR: 'Alipay QR Code',
|
||||
scanTip: 'Please use your phone to scan the QR code above to donate',
|
||||
enterApp: 'Enter App',
|
||||
noForce: 'No forced donation, click to enter'
|
||||
},
|
||||
coffee: {
|
||||
title: 'Buy me a coffee',
|
||||
alipay: 'Alipay',
|
||||
@@ -119,7 +144,11 @@ export default {
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
addToPlaylistSuccess: 'Add to Playlist Success',
|
||||
operationFailed: 'Operation Failed',
|
||||
songsAlreadyInPlaylist: 'Songs already in playlist'
|
||||
songsAlreadyInPlaylist: 'Songs already in playlist',
|
||||
historyRecommend: 'Daily History',
|
||||
fetchDatesFailed: 'Failed to fetch dates',
|
||||
fetchSongsFailed: 'Failed to fetch songs',
|
||||
noSongs: 'No songs'
|
||||
},
|
||||
playlist: {
|
||||
import: {
|
||||
|
||||
@@ -14,6 +14,9 @@ export default {
|
||||
addCorrection: 'Add {num} seconds',
|
||||
subtractCorrection: 'Subtract {num} seconds',
|
||||
playFailed: 'Play Failed, Play Next Song',
|
||||
parseFailedPlayNext: 'Song parsing failed, playing next',
|
||||
consecutiveFailsError:
|
||||
'Playback error, possibly due to network issues or invalid source. Please switch playlist or try again later',
|
||||
playMode: {
|
||||
sequence: 'Sequence',
|
||||
loop: 'Loop',
|
||||
@@ -53,6 +56,7 @@ export default {
|
||||
eq: 'Equalizer',
|
||||
playList: 'Play List',
|
||||
reparse: 'Reparse',
|
||||
miniPlayBar: 'Mini Play Bar',
|
||||
playMode: {
|
||||
sequence: 'Sequence',
|
||||
loop: 'Loop',
|
||||
@@ -102,6 +106,11 @@ export default {
|
||||
custom: 'Custom'
|
||||
}
|
||||
},
|
||||
// Playback settings
|
||||
settings: {
|
||||
title: 'Playback Settings',
|
||||
playbackSpeed: 'Playback Speed'
|
||||
},
|
||||
// Sleep timer related
|
||||
sleepTimer: {
|
||||
title: 'Sleep Timer',
|
||||
@@ -122,7 +131,10 @@ export default {
|
||||
timerEnded: 'Sleep timer ended',
|
||||
playbackStopped: 'Music playback stopped',
|
||||
minutesRemaining: '{minutes} min remaining',
|
||||
songsRemaining: '{count} songs remaining'
|
||||
songsRemaining: '{count} songs remaining',
|
||||
activeTime: 'Timer Active',
|
||||
activeSongs: 'Counting Songs',
|
||||
activeEnd: 'End After List'
|
||||
},
|
||||
playList: {
|
||||
clearAll: 'Clear Playlist',
|
||||
|
||||
@@ -11,7 +11,8 @@ export default {
|
||||
},
|
||||
loading: {
|
||||
more: 'Loading...',
|
||||
failed: 'Search failed'
|
||||
failed: 'Search failed',
|
||||
searching: 'Searching...'
|
||||
},
|
||||
noMore: 'No more results',
|
||||
error: {
|
||||
@@ -23,5 +24,8 @@ export default {
|
||||
playlist: 'Playlist',
|
||||
mv: 'MV',
|
||||
bilibili: 'Bilibili'
|
||||
}
|
||||
},
|
||||
history: 'Search History',
|
||||
hot: 'Hot Searches',
|
||||
suggestions: 'Search Suggestions'
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: 'Network Settings',
|
||||
system: 'System Management',
|
||||
donation: 'Donation',
|
||||
regard: 'About'
|
||||
about: 'About'
|
||||
},
|
||||
basic: {
|
||||
themeMode: 'Theme Mode',
|
||||
@@ -114,7 +114,38 @@ export default {
|
||||
notImported: 'No custom source imported yet.',
|
||||
importSuccess: 'Successfully imported source: {name}',
|
||||
importFailed: 'Import failed: {message}',
|
||||
enableHint: 'Import a JSON config file to enable'
|
||||
enableHint: 'Import a JSON config file to enable',
|
||||
status: {
|
||||
imported: 'Custom Source Imported',
|
||||
notImported: 'Not Imported'
|
||||
}
|
||||
},
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: 'Source Selection',
|
||||
lxMusic: 'LX Music',
|
||||
customApi: 'Custom API'
|
||||
},
|
||||
scripts: {
|
||||
title: 'Imported Scripts',
|
||||
importLocal: 'Import Local',
|
||||
importOnline: 'Import Online',
|
||||
urlPlaceholder: 'Enter LX Music Script URL',
|
||||
importBtn: 'Import',
|
||||
empty: 'No imported LX Music scripts',
|
||||
notConfigured: 'Not configured (Configure in LX Music Tab)',
|
||||
importHint: 'Import compatible custom API plugins to extend sources',
|
||||
noScriptWarning: 'Please import LX Music script first',
|
||||
noSelectionWarning: 'Please select an LX Music source first',
|
||||
notFound: 'Source not found',
|
||||
switched: 'Switched to source: {name}',
|
||||
deleted: 'Deleted source: {name}',
|
||||
enterUrl: 'Please enter script URL',
|
||||
invalidUrl: 'Invalid URL format',
|
||||
invalidScript: 'Invalid LX Music script, globalThis.lx code not found',
|
||||
nameRequired: 'Name cannot be empty',
|
||||
renameSuccess: 'Rename successful'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
@@ -219,6 +250,7 @@ export default {
|
||||
display: 'Display',
|
||||
interface: 'Interface',
|
||||
typography: 'Typography',
|
||||
background: 'Background',
|
||||
mobile: 'Mobile'
|
||||
},
|
||||
pureMode: 'Pure Mode',
|
||||
@@ -241,6 +273,12 @@ export default {
|
||||
medium: 'Medium',
|
||||
large: 'Large'
|
||||
},
|
||||
fontWeight: 'Font Weight',
|
||||
fontWeightMarks: {
|
||||
thin: 'Thin',
|
||||
normal: 'Normal',
|
||||
bold: 'Bold'
|
||||
},
|
||||
letterSpacing: 'Letter Spacing',
|
||||
letterSpacingMarks: {
|
||||
compact: 'Compact',
|
||||
@@ -253,6 +291,7 @@ export default {
|
||||
default: 'Default',
|
||||
loose: 'Loose'
|
||||
},
|
||||
contentWidth: 'Content Width',
|
||||
mobileLayout: 'Mobile Layout',
|
||||
layoutOptions: {
|
||||
default: 'Default',
|
||||
@@ -266,7 +305,46 @@ export default {
|
||||
full: 'Full Screen'
|
||||
},
|
||||
lyricLines: 'Lyric Lines',
|
||||
mobileUnavailable: 'This setting is only available on mobile devices'
|
||||
mobileUnavailable: 'This setting is only available on mobile devices',
|
||||
// Background settings
|
||||
background: {
|
||||
useCustomBackground: 'Use Custom Background',
|
||||
backgroundMode: 'Background Mode',
|
||||
modeOptions: {
|
||||
solid: 'Solid',
|
||||
gradient: 'Gradient',
|
||||
image: 'Image',
|
||||
css: 'CSS'
|
||||
},
|
||||
solidColor: 'Select Color',
|
||||
presetColors: 'Preset Colors',
|
||||
customColor: 'Custom Color',
|
||||
gradientEditor: 'Gradient Editor',
|
||||
gradientColors: 'Gradient Colors',
|
||||
gradientDirection: 'Gradient Direction',
|
||||
directionOptions: {
|
||||
toBottom: 'Top to Bottom',
|
||||
toRight: 'Left to Right',
|
||||
toBottomRight: 'Top Left to Bottom Right',
|
||||
angle45: '45 Degrees',
|
||||
toTop: 'Bottom to Top',
|
||||
toLeft: 'Right to Left'
|
||||
},
|
||||
addColor: 'Add Color',
|
||||
removeColor: 'Remove Color',
|
||||
imageUpload: 'Upload Image',
|
||||
imagePreview: 'Image Preview',
|
||||
clearImage: 'Clear Image',
|
||||
imageBlur: 'Blur',
|
||||
imageBrightness: 'Brightness',
|
||||
customCss: 'Custom CSS Style',
|
||||
customCssPlaceholder: 'Enter CSS style, e.g.: background: linear-gradient(...)',
|
||||
customCssHelp: 'Supports any CSS background property',
|
||||
reset: 'Reset to Default',
|
||||
fileSizeLimit: 'Image size limit: 20MB',
|
||||
invalidImageFormat: 'Invalid image format',
|
||||
imageTooLarge: 'Image too large, please select an image smaller than 20MB'
|
||||
}
|
||||
},
|
||||
translationEngine: 'Lyric Translation Engine',
|
||||
translationEngineOptions: {
|
||||
|
||||
@@ -43,6 +43,8 @@ export default {
|
||||
collapse: '折りたたみ',
|
||||
songCount: '{count}曲',
|
||||
language: '言語',
|
||||
today: '今日',
|
||||
yesterday: '昨日',
|
||||
tray: {
|
||||
show: '表示',
|
||||
quit: '終了',
|
||||
|
||||
@@ -52,6 +52,31 @@ export default {
|
||||
copyFailed: 'コピーに失敗しました',
|
||||
backgroundDownload: 'バックグラウンドダウンロード'
|
||||
},
|
||||
disclaimer: {
|
||||
title: '使用上の注意',
|
||||
warning:
|
||||
'このアプリは開発テスト版であり、機能が不完全で、多くの問題やバグが存在する可能性があります。学習と交流のみを目的としています。',
|
||||
item1:
|
||||
'このアプリは個人の学習、研究、技術交流のみを目的としています。商業目的で使用しないでください。',
|
||||
item2:
|
||||
'ダウンロード後24時間以内に削除してください。長期使用を希望される場合は、正規の音楽サービスをサポートしてください。',
|
||||
item3:
|
||||
'このアプリを使用することで、関連するリスクを理解し、負担するものとします。開発者は一切の損失に対して責任を負いません。',
|
||||
agree: '以上の内容を読み、同意します',
|
||||
disagree: '同意せずに終了'
|
||||
},
|
||||
donate: {
|
||||
title: '開発者を支援',
|
||||
subtitle: '皆様のサポートが私の原動力です',
|
||||
tip: '寄付は完全に任意です。寄付しなくてもすべての機能を通常通り使用できます。ご理解とご支援に感謝します!',
|
||||
wechat: 'WeChat',
|
||||
alipay: 'Alipay',
|
||||
wechatQR: 'WeChat 受取コード',
|
||||
alipayQR: 'Alipay 受取コード',
|
||||
scanTip: 'スマートフォンのアプリで上記のQRコードをスキャンして寄付してください',
|
||||
enterApp: 'アプリに入る',
|
||||
noForce: '寄付は強制ではありません。クリックして入れます'
|
||||
},
|
||||
coffee: {
|
||||
title: 'コーヒーをおごる',
|
||||
alipay: 'Alipay',
|
||||
@@ -119,7 +144,11 @@ export default {
|
||||
cancelCollect: 'お気に入りから削除',
|
||||
addToPlaylist: 'プレイリストに追加',
|
||||
addToPlaylistSuccess: 'プレイリストに追加しました',
|
||||
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します'
|
||||
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
|
||||
historyRecommend: '履歴の日次推薦',
|
||||
fetchDatesFailed: '日付リストの取得に失敗しました',
|
||||
fetchSongsFailed: '楽曲リストの取得に失敗しました',
|
||||
noSongs: '楽曲がありません'
|
||||
},
|
||||
playlist: {
|
||||
import: {
|
||||
|
||||
@@ -8,6 +8,13 @@ export default {
|
||||
local: 'ローカル記録',
|
||||
cloud: 'クラウド記録'
|
||||
},
|
||||
categoryTabs: {
|
||||
songs: '楽曲',
|
||||
playlists: 'プレイリスト',
|
||||
albums: 'アルバム'
|
||||
},
|
||||
noDescription: '説明なし',
|
||||
noData: '記録なし',
|
||||
getCloudRecordFailed: 'クラウド記録の取得に失敗しました',
|
||||
needLogin: 'cookieを使用してログインしてクラウド記録を表示できます',
|
||||
merging: '記録を統合中...',
|
||||
|
||||
@@ -42,7 +42,22 @@ export default {
|
||||
autoGetCookieSuccess: 'Cookie自動取得成功',
|
||||
autoGetCookieFailed: 'Cookie自動取得失敗',
|
||||
autoGetCookieTip:
|
||||
'NetEase Cloud Musicのログインページを開きます。ログイン完了後、ウィンドウを閉じてください'
|
||||
'NetEase Cloud Musicのログインページを開きます。ログイン完了後、ウィンドウを閉じてください',
|
||||
loginFailed: 'ログイン失敗',
|
||||
phoneRequired: '電話番号を入力してください',
|
||||
passwordRequired: 'パスワードを入力してください',
|
||||
phoneLoginFailed:
|
||||
'電話番号でのログインに失敗しました。電話番号とパスワードが正しいか確認してください',
|
||||
qrCheckFailed: 'QRコードの状態確認に失敗しました。リフレッシュして再試行してください',
|
||||
qrLoading: 'QRコードを読み込み中...',
|
||||
qrExpired: 'QRコードの期限が切れました。クリックしてリフレッシュしてください',
|
||||
qrExpiredShort: 'QRコード期限切れ',
|
||||
qrExpiredWarning: 'QRコードの期限が切れました。クリックして新しいQRコードを取得してください',
|
||||
qrScanned: 'QRコードがスキャンされました。スマートフォンでログインを確認してください',
|
||||
qrScannedShort: 'スキャン済み',
|
||||
qrScannedInfo: 'QRコードがスキャンされました。スマートフォンでログインを確認してください',
|
||||
qrConfirmed: 'ログイン成功、リダイレクト中...',
|
||||
qrGenerating: 'QRコードを生成中...'
|
||||
},
|
||||
qrTitle: 'NetEase Cloud Music QRコードログイン',
|
||||
uidWarning:
|
||||
|
||||
@@ -14,6 +14,9 @@ export default {
|
||||
addCorrection: '{num}秒早める',
|
||||
subtractCorrection: '{num}秒遅らせる',
|
||||
playFailed: '現在の楽曲の再生に失敗しました。次の曲を再生します',
|
||||
parseFailedPlayNext: '楽曲の解析に失敗しました。次の曲を再生します',
|
||||
consecutiveFailsError:
|
||||
'再生エラーが発生しました。ネットワークの問題または無効な音源の可能性があります。プレイリストを切り替えるか、後でもう一度お試しください',
|
||||
playMode: {
|
||||
sequence: '順次再生',
|
||||
loop: 'リピート再生',
|
||||
@@ -103,6 +106,11 @@ export default {
|
||||
custom: 'カスタム'
|
||||
}
|
||||
},
|
||||
// プレイヤー設定
|
||||
settings: {
|
||||
title: '再生設定',
|
||||
playbackSpeed: '再生速度'
|
||||
},
|
||||
// タイマー機能関連
|
||||
sleepTimer: {
|
||||
title: 'スリープタイマー',
|
||||
|
||||
@@ -11,7 +11,8 @@ export default {
|
||||
},
|
||||
loading: {
|
||||
more: '読み込み中...',
|
||||
failed: '検索に失敗しました'
|
||||
failed: '検索に失敗しました',
|
||||
searching: '検索中...'
|
||||
},
|
||||
noMore: 'これ以上ありません',
|
||||
error: {
|
||||
@@ -23,5 +24,8 @@ export default {
|
||||
playlist: 'プレイリスト',
|
||||
mv: 'MV',
|
||||
bilibili: 'Bilibili'
|
||||
}
|
||||
},
|
||||
history: '検索履歴',
|
||||
hot: '人気検索',
|
||||
suggestions: '検索候補'
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: 'ネットワーク設定',
|
||||
system: 'システム管理',
|
||||
donation: '寄付サポート',
|
||||
regard: 'について'
|
||||
about: 'について'
|
||||
},
|
||||
basic: {
|
||||
themeMode: 'テーマモード',
|
||||
@@ -111,7 +111,38 @@ export default {
|
||||
currentSource: '現在の音源',
|
||||
notImported: 'カスタム音源はまだインポートされていません。',
|
||||
importSuccess: '音源のインポートに成功しました: {name}',
|
||||
importFailed: 'インポートに失敗しました: {message}'
|
||||
importFailed: 'インポートに失敗しました: {message}',
|
||||
status: {
|
||||
imported: 'カスタム音源インポート済み',
|
||||
notImported: '未インポート'
|
||||
}
|
||||
},
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: '音源選択',
|
||||
lxMusic: '落雪音源',
|
||||
customApi: 'カスタムAPI'
|
||||
},
|
||||
scripts: {
|
||||
title: 'インポート済みのスクリプト',
|
||||
importLocal: 'ローカルインポート',
|
||||
importOnline: 'オンラインインポート',
|
||||
urlPlaceholder: '落雪音源スクリプトのURLを入力',
|
||||
importBtn: 'インポート',
|
||||
empty: 'インポート済みの落雪音源はありません',
|
||||
notConfigured: '未設定(落雪音源タブで設定してください)',
|
||||
importHint: '互換性のあるカスタムAPIプラグインをインポートして音源を拡張します',
|
||||
noScriptWarning: '先に落雪音源スクリプトをインポートしてください',
|
||||
noSelectionWarning: '先に落雪音源を選択してください',
|
||||
notFound: '音源が存在しません',
|
||||
switched: '音源を切り替えました: {name}',
|
||||
deleted: '音源を削除しました: {name}',
|
||||
enterUrl: 'スクリプトURLを入力してください',
|
||||
invalidUrl: '無効なURL形式',
|
||||
invalidScript: '無効な落雪音源スクリプトです(globalThis.lxが見つかりません)',
|
||||
nameRequired: '名前を空にすることはできません',
|
||||
renameSuccess: '名前を変更しました'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
@@ -218,6 +249,7 @@ export default {
|
||||
display: '表示',
|
||||
interface: 'インターフェース',
|
||||
typography: 'テキスト',
|
||||
background: '背景',
|
||||
mobile: 'モバイル'
|
||||
},
|
||||
pureMode: 'ピュアモード',
|
||||
@@ -240,6 +272,12 @@ export default {
|
||||
medium: '中',
|
||||
large: '大'
|
||||
},
|
||||
fontWeight: 'フォントの太さ',
|
||||
fontWeightMarks: {
|
||||
thin: '細い',
|
||||
normal: '通常',
|
||||
bold: '太い'
|
||||
},
|
||||
letterSpacing: '文字間隔',
|
||||
letterSpacingMarks: {
|
||||
compact: 'コンパクト',
|
||||
@@ -252,6 +290,7 @@ export default {
|
||||
default: 'デフォルト',
|
||||
loose: 'ゆったり'
|
||||
},
|
||||
contentWidth: 'コンテンツ幅',
|
||||
mobileLayout: 'モバイルレイアウト',
|
||||
layoutOptions: {
|
||||
default: 'デフォルト',
|
||||
@@ -265,7 +304,46 @@ export default {
|
||||
full: 'フルスクリーン'
|
||||
},
|
||||
lyricLines: '歌詞行数',
|
||||
mobileUnavailable: 'この設定はモバイルでのみ利用可能です'
|
||||
mobileUnavailable: 'この設定はモバイルでのみ利用可能です',
|
||||
// 背景設定
|
||||
background: {
|
||||
useCustomBackground: 'カスタム背景を使用',
|
||||
backgroundMode: '背景モード',
|
||||
modeOptions: {
|
||||
solid: '単色',
|
||||
gradient: 'グラデーション',
|
||||
image: '画像',
|
||||
css: 'CSS'
|
||||
},
|
||||
solidColor: '色を選択',
|
||||
presetColors: 'プリセットカラー',
|
||||
customColor: 'カスタムカラー',
|
||||
gradientEditor: 'グラデーションエディター',
|
||||
gradientColors: 'グラデーションカラー',
|
||||
gradientDirection: 'グラデーション方向',
|
||||
directionOptions: {
|
||||
toBottom: '上から下',
|
||||
toRight: '左から右',
|
||||
toBottomRight: '左上から右下',
|
||||
angle45: '45度',
|
||||
toTop: '下から上',
|
||||
toLeft: '右から左'
|
||||
},
|
||||
addColor: '色を追加',
|
||||
removeColor: '色を削除',
|
||||
imageUpload: '画像をアップロード',
|
||||
imagePreview: '画像プレビュー',
|
||||
clearImage: '画像をクリア',
|
||||
imageBlur: 'ぼかし',
|
||||
imageBrightness: '明るさ',
|
||||
customCss: 'カスタム CSS スタイル',
|
||||
customCssPlaceholder: 'CSSスタイルを入力、例: background: linear-gradient(...)',
|
||||
customCssHelp: '任意のCSS background プロパティをサポート',
|
||||
reset: 'デフォルトにリセット',
|
||||
fileSizeLimit: '画像サイズ制限: 20MB',
|
||||
invalidImageFormat: '無効な画像形式',
|
||||
imageTooLarge: '画像が大きすぎます。20MB未満の画像を選択してください'
|
||||
}
|
||||
},
|
||||
translationEngine: '歌詞翻訳エンジン',
|
||||
translationEngineOptions: {
|
||||
|
||||
@@ -43,6 +43,8 @@ export default {
|
||||
collapse: '접기',
|
||||
songCount: '{count}곡',
|
||||
language: '언어',
|
||||
today: '오늘',
|
||||
yesterday: '어제',
|
||||
tray: {
|
||||
show: '표시',
|
||||
quit: '종료',
|
||||
|
||||
@@ -51,6 +51,31 @@ export default {
|
||||
copyFailed: '복사 실패',
|
||||
backgroundDownload: '백그라운드 다운로드'
|
||||
},
|
||||
disclaimer: {
|
||||
title: '이용 안내',
|
||||
warning:
|
||||
'본 앱은 개발 테스트 버전으로 기능이 아직 미흡하며, 다수의 문제와 버그가 존재할 수 있습니다. 학습 및 교류 목적으로만 사용하십시오.',
|
||||
item1:
|
||||
'본 앱은 개인의 학습, 연구 및 기술 교류 목적으로만 사용되며, 상업적 용도로 사용하지 마십시오.',
|
||||
item2:
|
||||
'다운로드 후 24시간 이내에 삭제해 주십시오. 장기 사용을 원하시면 정품 음악 서비스를 이용해 주십시오.',
|
||||
item3:
|
||||
'본 앱을 사용함으로써 관련 위험을 이해하고 감수하는 것으로 간주합니다. 개발자는 어떠한 손실에 대해서도 책임을 지지 않습니다.',
|
||||
agree: '숙지하였으며 이에 동의합니다',
|
||||
disagree: '동의하지 않음 및 정지'
|
||||
},
|
||||
donate: {
|
||||
title: '개발자 지원',
|
||||
subtitle: '여러분의 지원이 저의 원동력입니다',
|
||||
tip: '후원은 완전히 자율적입니다. 후원하지 않더라도 모든 기능을 정상적으로 사용할 수 있습니다. 이해와 지원에 감사드립니다!',
|
||||
wechat: 'WeChat',
|
||||
alipay: 'Alipay',
|
||||
wechatQR: 'WeChat 결제 코드',
|
||||
alipayQR: 'Alipay 결제 코드',
|
||||
scanTip: '휴대전화로 위 QR 코드를 스캔하여 후원해 주세요',
|
||||
enterApp: '앱 시작하기',
|
||||
noForce: '후원은 강제가 아닙니다. 클릭하여 시작할 수 있습니다'
|
||||
},
|
||||
coffee: {
|
||||
title: '커피 한 잔 사주세요',
|
||||
alipay: '알리페이',
|
||||
@@ -118,7 +143,11 @@ export default {
|
||||
cancelCollect: '수집 취소',
|
||||
addToPlaylist: '재생 목록에 추가',
|
||||
addToPlaylistSuccess: '재생 목록에 추가 성공',
|
||||
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다'
|
||||
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
|
||||
historyRecommend: '일일 기록 권장',
|
||||
fetchDatesFailed: '날짜를 가져오지 못했습니다',
|
||||
fetchSongsFailed: '곡을 가져오지 못했습니다',
|
||||
noSongs: '노래 없음'
|
||||
},
|
||||
playlist: {
|
||||
import: {
|
||||
|
||||
@@ -8,6 +8,13 @@ export default {
|
||||
local: '로컬 기록',
|
||||
cloud: '클라우드 기록'
|
||||
},
|
||||
categoryTabs: {
|
||||
songs: '곡',
|
||||
playlists: '플레이리스트',
|
||||
albums: '앨범'
|
||||
},
|
||||
noDescription: '설명 없음',
|
||||
noData: '기록 없음',
|
||||
getCloudRecordFailed: '클라우드 기록 가져오기 실패',
|
||||
needLogin: 'cookie를 사용하여 로그인하여 클라우드 기록을 볼 수 있습니다',
|
||||
merging: '기록 병합 중...',
|
||||
|
||||
@@ -42,7 +42,21 @@ export default {
|
||||
autoGetCookieSuccess: 'Cookie 자동 가져오기 성공',
|
||||
autoGetCookieFailed: 'Cookie 자동 가져오기 실패',
|
||||
autoGetCookieTip:
|
||||
'넷이즈 클라우드 뮤직 로그인 페이지를 열겠습니다. 로그인 완료 후 창을 닫아주세요'
|
||||
'넷이즈 클라우드 뮤직 로그인 페이지를 열겠습니다. 로그인 완료 후 창을 닫아주세요',
|
||||
loginFailed: '로그인 실패',
|
||||
phoneRequired: '휴대폰 번호를 입력하세요',
|
||||
passwordRequired: '비밀번호를 입력하세요',
|
||||
phoneLoginFailed: '휴대폰 번호 로그인 실패, 휴대폰 번호와 비밀번호가 올바른지 확인하세요',
|
||||
qrCheckFailed: 'QR코드 상태 확인 실패, 새로고침하여 다시 시도하세요',
|
||||
qrLoading: 'QR코드 로딩 중...',
|
||||
qrExpired: 'QR코드가 만료되었습니다. 클릭하여 새로고침하세요',
|
||||
qrExpiredShort: 'QR코드 만료됨',
|
||||
qrExpiredWarning: 'QR코드가 만료되었습니다. 클릭하여 새로운 QR코드를 받으세요',
|
||||
qrScanned: 'QR코드가 스캔되었습니다. 휴대폰에서 로그인을 확인하세요',
|
||||
qrScannedShort: '스캔됨',
|
||||
qrScannedInfo: 'QR코드가 스캔되었습니다. 휴대폰에서 로그인을 확인하세요',
|
||||
qrConfirmed: '로그인 성공, 리다이렉트 중...',
|
||||
qrGenerating: 'QR코드를 생성 중...'
|
||||
},
|
||||
qrTitle: '넷이즈 클라우드 뮤직 QR코드 로그인',
|
||||
uidWarning:
|
||||
|
||||
@@ -14,6 +14,9 @@ export default {
|
||||
addCorrection: '{num}초 앞당기기',
|
||||
subtractCorrection: '{num}초 지연',
|
||||
playFailed: '현재 곡 재생 실패, 다음 곡 재생',
|
||||
parseFailedPlayNext: '곡 분석 실패, 다음 곡 재생',
|
||||
consecutiveFailsError:
|
||||
'재생 오류가 발생했습니다. 네트워크 문제 또는 유효하지 않은 음원일 수 있습니다. 재생 목록을 변경하거나 나중에 다시 시도하세요',
|
||||
playMode: {
|
||||
sequence: '순차 재생',
|
||||
loop: '한 곡 반복',
|
||||
@@ -103,6 +106,11 @@ export default {
|
||||
custom: '사용자 정의'
|
||||
}
|
||||
},
|
||||
// 플레이어 설정
|
||||
settings: {
|
||||
title: '재생 설정',
|
||||
playbackSpeed: '재생 속도'
|
||||
},
|
||||
sleepTimer: {
|
||||
title: '타이머 종료',
|
||||
cancel: '타이머 취소',
|
||||
|
||||
@@ -11,7 +11,8 @@ export default {
|
||||
},
|
||||
loading: {
|
||||
more: '로딩 중...',
|
||||
failed: '검색 실패'
|
||||
failed: '검색 실패',
|
||||
searching: '검색 중...'
|
||||
},
|
||||
noMore: '더 이상 없음',
|
||||
error: {
|
||||
@@ -23,5 +24,8 @@ export default {
|
||||
playlist: '플레이리스트',
|
||||
mv: 'MV',
|
||||
bilibili: 'B站'
|
||||
}
|
||||
},
|
||||
history: '검색 기록',
|
||||
hot: '인기 검색',
|
||||
suggestions: '검색 제안'
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: '네트워크 설정',
|
||||
system: '시스템 관리',
|
||||
donation: '후원 지원',
|
||||
regard: '정보'
|
||||
about: '정보'
|
||||
},
|
||||
basic: {
|
||||
themeMode: '테마 모드',
|
||||
@@ -112,7 +112,38 @@ export default {
|
||||
notImported: '아직 사용자 지정 음원을 가져오지 않았습니다.',
|
||||
importSuccess: '음원 가져오기 성공: {name}',
|
||||
importFailed: '가져오기 실패: {message}',
|
||||
enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요'
|
||||
enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요',
|
||||
status: {
|
||||
imported: '사용자 지정 음원 가져옴',
|
||||
notImported: '가져오지 않음'
|
||||
}
|
||||
},
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: '음원 선택',
|
||||
lxMusic: '낙설 음원',
|
||||
customApi: '사용자 정의 API'
|
||||
},
|
||||
scripts: {
|
||||
title: '가져온 스크립트',
|
||||
importLocal: '로컬 가져오기',
|
||||
importOnline: '온라인 가져오기',
|
||||
urlPlaceholder: '낙설 음원 스크립트 URL 입력',
|
||||
importBtn: '가져오기',
|
||||
empty: '가져온 낙설 음원이 없습니다',
|
||||
notConfigured: '설정되지 않음 (낙설 음원 탭에서 설정하세요)',
|
||||
importHint: '소스 확장을 위해 호환되는 사용자 정의 API 플러그인을 가져옵니다',
|
||||
noScriptWarning: '먼저 낙설 음원 스크립트를 가져오세요',
|
||||
noSelectionWarning: '먼저 낙설 음원 소스를 선택하세요',
|
||||
notFound: '음원이 존재하지 않습니다',
|
||||
switched: '음원으로 전환되었습니다: {name}',
|
||||
deleted: '음원이 삭제되었습니다: {name}',
|
||||
enterUrl: '스크립트 URL을 입력하세요',
|
||||
invalidUrl: '유효하지 않은 URL 형식',
|
||||
invalidScript: '유효하지 않은 낙설 음원 스크립트입니다 (globalThis.lx 코드를 찾을 수 없음)',
|
||||
nameRequired: '이름은 비워둘 수 없습니다',
|
||||
renameSuccess: '이름이 변경되었습니다'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
@@ -219,6 +250,7 @@ export default {
|
||||
display: '표시',
|
||||
interface: '인터페이스',
|
||||
typography: '텍스트',
|
||||
background: '배경',
|
||||
mobile: '모바일'
|
||||
},
|
||||
pureMode: '순수 모드',
|
||||
@@ -241,6 +273,12 @@ export default {
|
||||
medium: '중간',
|
||||
large: '큼'
|
||||
},
|
||||
fontWeight: '글꼴 두께',
|
||||
fontWeightMarks: {
|
||||
thin: '가늘게',
|
||||
normal: '보통',
|
||||
bold: '굵게'
|
||||
},
|
||||
letterSpacing: '글자 간격',
|
||||
letterSpacingMarks: {
|
||||
compact: '좁음',
|
||||
@@ -253,6 +291,7 @@ export default {
|
||||
default: '기본',
|
||||
loose: '넓음'
|
||||
},
|
||||
contentWidth: '콘텐츠 너비',
|
||||
mobileLayout: '모바일 레이아웃',
|
||||
layoutOptions: {
|
||||
default: '기본',
|
||||
@@ -266,7 +305,46 @@ export default {
|
||||
full: '전체화면'
|
||||
},
|
||||
lyricLines: '가사 줄 수',
|
||||
mobileUnavailable: '이 설정은 모바일에서만 사용 가능합니다'
|
||||
mobileUnavailable: '이 설정은 모바일에서만 사용 가능합니다',
|
||||
// 배경 설정
|
||||
background: {
|
||||
useCustomBackground: '사용자 정의 배경 사용',
|
||||
backgroundMode: '배경 모드',
|
||||
modeOptions: {
|
||||
solid: '단색',
|
||||
gradient: '그라데이션',
|
||||
image: '이미지',
|
||||
css: 'CSS'
|
||||
},
|
||||
solidColor: '색상 선택',
|
||||
presetColors: '프리셋 색상',
|
||||
customColor: '사용자 정의 색상',
|
||||
gradientEditor: '그라데이션 편집기',
|
||||
gradientColors: '그라데이션 색상',
|
||||
gradientDirection: '그라데이션 방향',
|
||||
directionOptions: {
|
||||
toBottom: '위에서 아래로',
|
||||
toRight: '왼쪽에서 오른쪽으로',
|
||||
toBottomRight: '왼쪽 위에서 오른쪽 아래로',
|
||||
angle45: '45도',
|
||||
toTop: '아래에서 위로',
|
||||
toLeft: '오른쪽에서 왼쪽으로'
|
||||
},
|
||||
addColor: '색상 추가',
|
||||
removeColor: '색상 제거',
|
||||
imageUpload: '이미지 업로드',
|
||||
imagePreview: '이미지 미리보기',
|
||||
clearImage: '이미지 지우기',
|
||||
imageBlur: '흐림',
|
||||
imageBrightness: '밝기',
|
||||
customCss: '사용자 정의 CSS 스타일',
|
||||
customCssPlaceholder: 'CSS 스타일 입력, 예: background: linear-gradient(...)',
|
||||
customCssHelp: '모든 CSS background 속성 지원',
|
||||
reset: '기본값으로 재설정',
|
||||
fileSizeLimit: '이미지 크기 제한: 20MB',
|
||||
invalidImageFormat: '잘못된 이미지 형식',
|
||||
imageTooLarge: '이미지가 너무 큽니다. 20MB 미만의 이미지를 선택하세요'
|
||||
}
|
||||
},
|
||||
translationEngine: '가사 번역 엔진',
|
||||
translationEngineOptions: {
|
||||
|
||||
@@ -50,6 +50,27 @@ export default {
|
||||
copyFailed: '复制失败',
|
||||
backgroundDownload: '后台下载'
|
||||
},
|
||||
disclaimer: {
|
||||
title: '使用须知',
|
||||
warning: '本应用为开发测试版本,功能尚不完善,可能存在较多问题和 Bug,仅供学习交流使用。',
|
||||
item1: '本应用仅供个人学习、研究和技术交流使用,请勿用于任何商业用途。',
|
||||
item2: '请在下载后 24 小时内删除,如需长期使用请支持正版音乐服务。',
|
||||
item3: '使用本应用即表示您理解并承担相关风险,开发者不对任何损失负责。',
|
||||
agree: '我已阅读并同意',
|
||||
disagree: '不同意并退出'
|
||||
},
|
||||
donate: {
|
||||
title: '支持开发者',
|
||||
subtitle: '您的支持是我前进的动力',
|
||||
tip: '捐赠完全自愿,不捐赠也可以正常使用所有功能,感谢您的理解与支持!',
|
||||
wechat: '微信',
|
||||
alipay: '支付宝',
|
||||
wechatQR: '微信收款码',
|
||||
alipayQR: '支付宝收款码',
|
||||
scanTip: '请使用手机扫描上方二维码进行捐赠',
|
||||
enterApp: '进入应用',
|
||||
noForce: '不强制捐赠,点击即可进入'
|
||||
},
|
||||
coffee: {
|
||||
title: '请我喝咖啡',
|
||||
alipay: '支付宝',
|
||||
|
||||
@@ -14,6 +14,8 @@ export default {
|
||||
addCorrection: '提前 {num} 秒',
|
||||
subtractCorrection: '延迟 {num} 秒',
|
||||
playFailed: '当前歌曲播放失败,播放下一首',
|
||||
parseFailedPlayNext: '歌曲解析失败,播放下一首',
|
||||
consecutiveFailsError: '播放遇到错误,可能是网络波动或解析源失效,请切换播放列表或稍后重试',
|
||||
playMode: {
|
||||
sequence: '顺序播放',
|
||||
loop: '单曲循环',
|
||||
@@ -103,6 +105,11 @@ export default {
|
||||
custom: '自定义'
|
||||
}
|
||||
},
|
||||
// 播放器设置
|
||||
settings: {
|
||||
title: '播放设置',
|
||||
playbackSpeed: '播放速度'
|
||||
},
|
||||
// 定时关闭功能相关
|
||||
sleepTimer: {
|
||||
title: '定时关闭',
|
||||
|
||||
@@ -11,7 +11,8 @@ export default {
|
||||
},
|
||||
loading: {
|
||||
more: '加载中...',
|
||||
failed: '搜索失败'
|
||||
failed: '搜索失败',
|
||||
searching: '搜索中...'
|
||||
},
|
||||
noMore: '没有更多了',
|
||||
error: {
|
||||
@@ -23,5 +24,8 @@ export default {
|
||||
playlist: '歌单',
|
||||
mv: 'MV',
|
||||
bilibili: 'B站'
|
||||
}
|
||||
},
|
||||
history: '搜索历史',
|
||||
hot: '热门搜索',
|
||||
suggestions: '搜索建议'
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: '网络设置',
|
||||
system: '系统管理',
|
||||
donation: '捐赠支持',
|
||||
regard: '关于'
|
||||
about: '关于'
|
||||
},
|
||||
basic: {
|
||||
themeMode: '主题模式',
|
||||
@@ -111,7 +111,38 @@ export default {
|
||||
notImported: '尚未导入自定义音源。',
|
||||
importSuccess: '成功导入音源: {name}',
|
||||
importFailed: '导入失败: {message}',
|
||||
enableHint: '请先导入 JSON 配置文件才能启用'
|
||||
enableHint: '请先导入 JSON 配置文件才能启用',
|
||||
status: {
|
||||
imported: '已导入自定义音源',
|
||||
notImported: '未导入'
|
||||
}
|
||||
},
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: '音源选择',
|
||||
lxMusic: '落雪音源',
|
||||
customApi: '自定义API'
|
||||
},
|
||||
scripts: {
|
||||
title: '已导入的音源脚本',
|
||||
importLocal: '本地导入',
|
||||
importOnline: '在线导入',
|
||||
urlPlaceholder: '输入落雪音源脚本 URL',
|
||||
importBtn: '导入',
|
||||
empty: '暂无已导入的落雪音源',
|
||||
notConfigured: '未配置 (请去落雪音源Tab配置)',
|
||||
importHint: '导入兼容的自定义 API 插件以扩展音源',
|
||||
noScriptWarning: '请先导入落雪音源脚本',
|
||||
noSelectionWarning: '请先选择一个落雪音源',
|
||||
notFound: '音源不存在',
|
||||
switched: '已切换到音源: {name}',
|
||||
deleted: '已删除音源: {name}',
|
||||
enterUrl: '请输入脚本 URL',
|
||||
invalidUrl: '无效的 URL 格式',
|
||||
invalidScript: '无效的落雪音源脚本,未找到 globalThis.lx 相关代码',
|
||||
nameRequired: '名称不能为空',
|
||||
renameSuccess: '重命名成功'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
@@ -216,6 +247,7 @@ export default {
|
||||
display: '显示',
|
||||
interface: '界面',
|
||||
typography: '文字',
|
||||
background: '背景',
|
||||
mobile: '移动端'
|
||||
},
|
||||
pureMode: '纯净模式',
|
||||
@@ -238,6 +270,12 @@ export default {
|
||||
medium: '中',
|
||||
large: '大'
|
||||
},
|
||||
fontWeight: '字体粗细',
|
||||
fontWeightMarks: {
|
||||
thin: '细',
|
||||
normal: '常规',
|
||||
bold: '粗'
|
||||
},
|
||||
letterSpacing: '字间距',
|
||||
letterSpacingMarks: {
|
||||
compact: '紧凑',
|
||||
@@ -250,6 +288,7 @@ export default {
|
||||
default: '默认',
|
||||
loose: '宽松'
|
||||
},
|
||||
contentWidth: '内容区宽度',
|
||||
mobileLayout: '移动端布局',
|
||||
layoutOptions: {
|
||||
default: '默认',
|
||||
@@ -263,7 +302,46 @@ export default {
|
||||
full: '全屏'
|
||||
},
|
||||
lyricLines: '歌词行数',
|
||||
mobileUnavailable: '此设置仅在移动端可用'
|
||||
mobileUnavailable: '此设置仅在移动端可用',
|
||||
// 背景设置
|
||||
background: {
|
||||
useCustomBackground: '使用自定义背景',
|
||||
backgroundMode: '背景模式',
|
||||
modeOptions: {
|
||||
solid: '纯色',
|
||||
gradient: '渐变',
|
||||
image: '图片',
|
||||
css: 'CSS'
|
||||
},
|
||||
solidColor: '选择颜色',
|
||||
presetColors: '预设颜色',
|
||||
customColor: '自定义颜色',
|
||||
gradientEditor: '渐变编辑器',
|
||||
gradientColors: '渐变颜色',
|
||||
gradientDirection: '渐变方向',
|
||||
directionOptions: {
|
||||
toBottom: '上到下',
|
||||
toRight: '左到右',
|
||||
toBottomRight: '左上到右下',
|
||||
angle45: '45度',
|
||||
toTop: '下到上',
|
||||
toLeft: '右到左'
|
||||
},
|
||||
addColor: '添加颜色',
|
||||
removeColor: '移除颜色',
|
||||
imageUpload: '上传图片',
|
||||
imagePreview: '图片预览',
|
||||
clearImage: '清除图片',
|
||||
imageBlur: '模糊度',
|
||||
imageBrightness: '明暗度',
|
||||
customCss: '自定义 CSS 样式',
|
||||
customCssPlaceholder: '输入 CSS 样式,如: background: linear-gradient(...)',
|
||||
customCssHelp: '支持任意 CSS background 属性',
|
||||
reset: '重置为默认',
|
||||
fileSizeLimit: '图片大小限制: 20MB',
|
||||
invalidImageFormat: '无效的图片格式',
|
||||
imageTooLarge: '图片过大,请选择小于 20MB 的图片'
|
||||
}
|
||||
},
|
||||
translationEngine: '歌詞翻譯引擎',
|
||||
translationEngineOptions: {
|
||||
|
||||
@@ -43,6 +43,8 @@ export default {
|
||||
collapse: '收合',
|
||||
songCount: '{count}首',
|
||||
language: '語言',
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
tray: {
|
||||
show: '顯示',
|
||||
quit: '退出',
|
||||
|
||||
@@ -50,6 +50,27 @@ export default {
|
||||
copyFailed: '複製失敗',
|
||||
backgroundDownload: '背景下載'
|
||||
},
|
||||
disclaimer: {
|
||||
title: '使用說明',
|
||||
warning: '本程式為開發測試版本,功能尚未完善,可能存在諸多問題及臭蟲,僅供學習交流使用。',
|
||||
item1: '本程式僅供個人學習、研究及技術交流之目的,不得用於任何商業用途。',
|
||||
item2: '請在下載後 24 小時內刪除,若對您有所幫助,請支持正版音樂。',
|
||||
item3: '使用本程式即代表您已了解並同意相關風險,開發者對任何損失概不負責。',
|
||||
agree: '我已了解並同意',
|
||||
disagree: '不同意並退出'
|
||||
},
|
||||
donate: {
|
||||
title: '支援開發者',
|
||||
subtitle: '您的支援是我持續更新的動力',
|
||||
tip: '捐贈完全採自願原則。即使不捐贈,您依然可以正常使用所有功能。感謝您的理解與支援!',
|
||||
wechat: '微信支付',
|
||||
alipay: '支付寶',
|
||||
wechatQR: '微信收款碼',
|
||||
alipayQR: '支付寶收款碼',
|
||||
scanTip: '請使用手機 App 掃描 QR Code 進行捐贈',
|
||||
enterApp: '進入程式',
|
||||
noForce: '捐贈並非強制,您可以點擊按鈕直接進入'
|
||||
},
|
||||
coffee: {
|
||||
title: '請我喝杯咖啡',
|
||||
alipay: '支付寶',
|
||||
@@ -117,7 +138,11 @@ export default {
|
||||
cancelCollect: '取消收藏',
|
||||
addToPlaylist: '新增至播放清單',
|
||||
addToPlaylistSuccess: '新增至播放清單成功',
|
||||
songsAlreadyInPlaylist: '歌曲已存在於播放清單中'
|
||||
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
|
||||
historyRecommend: '歷史日推',
|
||||
fetchDatesFailed: '獲取日期列表失敗',
|
||||
fetchSongsFailed: '獲取歌曲列表失敗',
|
||||
noSongs: '暫無歌曲'
|
||||
},
|
||||
playlist: {
|
||||
import: {
|
||||
|
||||
@@ -41,7 +41,21 @@ export default {
|
||||
uidLoginFailed: 'UID登入失敗,請檢查使用者ID是否正確',
|
||||
autoGetCookieSuccess: '自動取得Cookie成功',
|
||||
autoGetCookieFailed: '自動取得Cookie失敗',
|
||||
autoGetCookieTip: '將開啟網易雲音樂登入頁面,請完成登入後關閉視窗'
|
||||
autoGetCookieTip: '將開啟網易雲音樂登入頁面,請完成登入後關閉視窗',
|
||||
loginFailed: '登入失敗',
|
||||
phoneRequired: '請輸入手機號',
|
||||
passwordRequired: '請輸入密碼',
|
||||
phoneLoginFailed: '手機號登入失敗,請檢查手機號和密碼是否正確',
|
||||
qrCheckFailed: '檢查二維碼狀態失敗,請刷新重試',
|
||||
qrLoading: '正在載入二維碼...',
|
||||
qrExpired: '二維碼已過期,請點擊刷新',
|
||||
qrExpiredShort: '二維碼已過期',
|
||||
qrExpiredWarning: '二維碼已過期,請點擊刷新獲取新的二維碼',
|
||||
qrScanned: '已掃碼,請在手機上確認登入',
|
||||
qrScannedShort: '已掃碼',
|
||||
qrScannedInfo: '已扫码,请在手机上确认登录',
|
||||
qrConfirmed: '登入成功,正在跳轉...',
|
||||
qrGenerating: '正在生成二維碼...'
|
||||
},
|
||||
qrTitle: '掃碼登入網易雲音樂',
|
||||
uidWarning: '注意:UID登入僅用於查看使用者公開資訊,無法訪問需要登入權限的功能'
|
||||
|
||||
@@ -14,6 +14,8 @@ export default {
|
||||
addCorrection: '提前 {num} 秒',
|
||||
subtractCorrection: '延遲 {num} 秒',
|
||||
playFailed: '目前歌曲播放失敗,播放下一首',
|
||||
parseFailedPlayNext: '歌曲解析失敗,播放下一首',
|
||||
consecutiveFailsError: '播放遇到錯誤,可能是網路波動或解析源失效,請切換播放清單或稍後重試',
|
||||
playMode: {
|
||||
sequence: '順序播放',
|
||||
loop: '單曲循環',
|
||||
@@ -103,6 +105,11 @@ export default {
|
||||
custom: '自訂'
|
||||
}
|
||||
},
|
||||
// 播放器設定
|
||||
settings: {
|
||||
title: '播放設定',
|
||||
playbackSpeed: '播放速度'
|
||||
},
|
||||
// 定時關閉功能相關
|
||||
sleepTimer: {
|
||||
title: '定時關閉',
|
||||
|
||||
@@ -11,7 +11,8 @@ export default {
|
||||
},
|
||||
loading: {
|
||||
more: '載入中...',
|
||||
failed: '搜尋失敗'
|
||||
failed: '搜尋失敗',
|
||||
searching: '搜尋中...'
|
||||
},
|
||||
noMore: '沒有更多了',
|
||||
error: {
|
||||
@@ -23,5 +24,8 @@ export default {
|
||||
playlist: '歌單',
|
||||
mv: 'MV',
|
||||
bilibili: 'B站'
|
||||
}
|
||||
},
|
||||
history: '搜尋歷史',
|
||||
hot: '熱門搜尋',
|
||||
suggestions: '搜尋建議'
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: '網路設定',
|
||||
system: '系統管理',
|
||||
donation: '捐贈支持',
|
||||
regard: '關於'
|
||||
about: '關於'
|
||||
},
|
||||
basic: {
|
||||
themeMode: '主題模式',
|
||||
@@ -24,6 +24,7 @@ export default {
|
||||
tokenStatus: '目前Cookie狀態',
|
||||
tokenSet: '已設定',
|
||||
tokenNotSet: '未設定',
|
||||
setToken: '設定Cookie',
|
||||
setCookie: '設定Cookie',
|
||||
modifyToken: '修改Cookie',
|
||||
clearToken: '清除Cookie',
|
||||
@@ -108,7 +109,38 @@ export default {
|
||||
notImported: '尚未匯入自訂音源。',
|
||||
importSuccess: '成功匯入音源:{name}',
|
||||
importFailed: '匯入失敗:{message}',
|
||||
enableHint: '請先匯入 JSON 設定檔才能啟用'
|
||||
enableHint: '請先匯入 JSON 設定檔才能啟用',
|
||||
status: {
|
||||
imported: '已匯入自訂音源',
|
||||
notImported: '未匯入'
|
||||
}
|
||||
},
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: '音源選擇',
|
||||
lxMusic: '落雪音源',
|
||||
customApi: '自訂API'
|
||||
},
|
||||
scripts: {
|
||||
title: '已匯入的音源腳本',
|
||||
importLocal: '本機匯入',
|
||||
importOnline: '線上匯入',
|
||||
urlPlaceholder: '輸入落雪音源腳本 URL',
|
||||
importBtn: '匯入',
|
||||
empty: '暫無已匯入的落雪音源',
|
||||
notConfigured: '未設定 (請至落雪音源分頁設定)',
|
||||
importHint: '匯入相容的自訂 API 外掛以擴充音源',
|
||||
noScriptWarning: '請先匯入落雪音源腳本',
|
||||
noSelectionWarning: '請先選擇一個落雪音源',
|
||||
notFound: '音源不存在',
|
||||
switched: '已切換到音源: {name}',
|
||||
deleted: '已刪除音源: {name}',
|
||||
enterUrl: '請輸入腳本 URL',
|
||||
invalidUrl: '無效的 URL 格式',
|
||||
invalidScript: '無效的落雪音源腳本,未找到 globalThis.lx 相關程式碼',
|
||||
nameRequired: '名稱不能為空',
|
||||
renameSuccess: '重新命名成功'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
@@ -213,6 +245,7 @@ export default {
|
||||
display: '顯示',
|
||||
interface: '介面',
|
||||
typography: '文字',
|
||||
background: '背景',
|
||||
mobile: '行動端'
|
||||
},
|
||||
pureMode: '純淨模式',
|
||||
@@ -235,11 +268,77 @@ export default {
|
||||
medium: '中',
|
||||
large: '大'
|
||||
},
|
||||
fontWeight: '字體粗細',
|
||||
fontWeightMarks: {
|
||||
thin: '細',
|
||||
normal: '常規',
|
||||
bold: '粗'
|
||||
},
|
||||
letterSpacing: '字間距',
|
||||
letterSpacingMarks: {
|
||||
compact: '緊湊',
|
||||
default: '預設',
|
||||
loose: '寬鬆'
|
||||
},
|
||||
lineHeight: '行高',
|
||||
lineHeightMarks: {
|
||||
compact: '緊湊',
|
||||
default: '預設',
|
||||
loose: '寬鬆'
|
||||
},
|
||||
contentWidth: '內容區寬度',
|
||||
mobileLayout: '行動端佈局',
|
||||
layoutOptions: {
|
||||
default: '預設',
|
||||
ios: 'iOS 風格',
|
||||
android: 'Android 風格'
|
||||
},
|
||||
mobileCoverStyle: '封面風格',
|
||||
coverOptions: {
|
||||
record: '唱片',
|
||||
square: '方形',
|
||||
full: '全螢幕'
|
||||
},
|
||||
lyricLines: '歌詞行數',
|
||||
mobileUnavailable: '此設定僅在行動端可用',
|
||||
// 背景設定
|
||||
background: {
|
||||
useCustomBackground: '使用自訂背景',
|
||||
backgroundMode: '背景模式',
|
||||
modeOptions: {
|
||||
solid: '純色',
|
||||
gradient: '漸層',
|
||||
image: '圖片',
|
||||
css: 'CSS'
|
||||
},
|
||||
solidColor: '選擇顏色',
|
||||
presetColors: '預設顏色',
|
||||
customColor: '自訂顏色',
|
||||
gradientEditor: '漸層編輯器',
|
||||
gradientColors: '漸層顏色',
|
||||
gradientDirection: '漸層方向',
|
||||
directionOptions: {
|
||||
toBottom: '上到下',
|
||||
toRight: '左到右',
|
||||
toBottomRight: '左上到右下',
|
||||
angle45: '45度',
|
||||
toTop: '下到上',
|
||||
toLeft: '右到左'
|
||||
},
|
||||
addColor: '新增顏色',
|
||||
removeColor: '移除顏色',
|
||||
imageUpload: '上傳圖片',
|
||||
imagePreview: '圖片預覽',
|
||||
clearImage: '清除圖片',
|
||||
imageBlur: '模糊度',
|
||||
imageBrightness: '明暗度',
|
||||
customCss: '自訂 CSS 樣式',
|
||||
customCssPlaceholder: '輸入 CSS 樣式,如: background: linear-gradient(...)',
|
||||
customCssHelp: '支援任意 CSS background 屬性',
|
||||
reset: '重設為預設',
|
||||
fileSizeLimit: '圖片大小限制: 20MB',
|
||||
invalidImageFormat: '無效的圖片格式',
|
||||
imageTooLarge: '圖片過大,請選擇小於 20MB 的圖片'
|
||||
}
|
||||
},
|
||||
themeColor: {
|
||||
@@ -267,6 +366,46 @@ export default {
|
||||
none: '關閉',
|
||||
opencc: 'OpenCC 繁化'
|
||||
},
|
||||
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: '已啟用所有快捷鍵,請記得儲存'
|
||||
}
|
||||
},
|
||||
remoteControl: {
|
||||
title: '遠端控制',
|
||||
enable: '啟用遠端控制',
|
||||
port: '服務連接埠',
|
||||
allowedIps: '允許的 IP 位址',
|
||||
addIp: '新增 IP',
|
||||
emptyListHint: '空白清單表示允許所有 IP 存取',
|
||||
saveSuccess: '遠端控制設定已儲存',
|
||||
accessInfo: '遠端控制存取位址:'
|
||||
},
|
||||
cookie: {
|
||||
title: 'Cookie設定',
|
||||
description: '請輸入網易雲音樂的Cookie:',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { setupUpdateHandlers } from './modules/update';
|
||||
import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window';
|
||||
import { initWindowSizeManager } from './modules/window-size';
|
||||
import { startMusicApi } from './server';
|
||||
import { initLxMusicHttp } from './modules/lxMusicHttp';
|
||||
|
||||
// 导入所有图标
|
||||
const iconPath = join(__dirname, '../../resources');
|
||||
@@ -57,6 +58,9 @@ function initialize(configStore: any) {
|
||||
// 启动音乐API
|
||||
startMusicApi();
|
||||
|
||||
// 初始化落雪音乐 HTTP 请求处理
|
||||
initLxMusicHttp();
|
||||
|
||||
// 加载歌词窗口
|
||||
loadLyricWindow(ipcMain, mainWindow);
|
||||
|
||||
|
||||
@@ -310,6 +310,47 @@ export function initializeFileManager() {
|
||||
throw new Error(`文件读取或解析失败: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理导入落雪音源脚本的请求
|
||||
ipcMain.handle('import-lx-music-script', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: '选择落雪音源脚本文件',
|
||||
filters: [{ name: 'JavaScript Files', extensions: ['js'] }],
|
||||
properties: ['openFile']
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = result.filePaths[0];
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// 验证脚本格式:检查是否包含落雪音源特征
|
||||
if (
|
||||
!fileContent.includes('globalThis.lx') &&
|
||||
!fileContent.includes('lx.on') &&
|
||||
!fileContent.includes('EVENT_NAMES')
|
||||
) {
|
||||
throw new Error('无效的落雪音源脚本,未找到 globalThis.lx 相关代码。');
|
||||
}
|
||||
|
||||
// 检查是否包含必要的元信息注释
|
||||
const hasMetaComment = fileContent.includes('@name');
|
||||
if (!hasMetaComment) {
|
||||
console.warn('警告: 脚本缺少 @name 元信息注释');
|
||||
}
|
||||
|
||||
return {
|
||||
name: path.basename(filePath, '.js'),
|
||||
content: fileContent
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('读取落雪音源脚本失败:', error);
|
||||
throw new Error(`脚本读取失败: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
153
src/main/modules/lxMusicHttp.ts
Normal file
153
src/main/modules/lxMusicHttp.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 落雪音乐 HTTP 请求处理(主进程)
|
||||
* 绕过渲染进程的 CORS 限制
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import fetch, { type RequestInit } from 'node-fetch';
|
||||
|
||||
interface LxHttpRequest {
|
||||
url: string;
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
form?: Record<string, string>;
|
||||
formData?: Record<string, string>;
|
||||
timeout?: number;
|
||||
};
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
interface LxHttpResponse {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[]>;
|
||||
body: any;
|
||||
}
|
||||
|
||||
// 取消控制器映射
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
* 初始化 HTTP 请求处理
|
||||
*/
|
||||
export const initLxMusicHttp = () => {
|
||||
// 处理 HTTP 请求
|
||||
ipcMain.handle(
|
||||
'lx-music-http-request',
|
||||
async (_, request: LxHttpRequest): Promise<LxHttpResponse> => {
|
||||
const { url, options, requestId } = request;
|
||||
const controller = new AbortController();
|
||||
|
||||
// 保存取消控制器
|
||||
abortControllers.set(requestId, controller);
|
||||
|
||||
try {
|
||||
console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`);
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
...(options.headers || {})
|
||||
},
|
||||
signal: controller.signal
|
||||
};
|
||||
|
||||
// 处理请求体
|
||||
if (options.body) {
|
||||
fetchOptions.body = options.body;
|
||||
} else if (options.form) {
|
||||
const formData = new URLSearchParams(options.form);
|
||||
fetchOptions.body = formData.toString();
|
||||
fetchOptions.headers = {
|
||||
...fetchOptions.headers,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
} else if (options.formData) {
|
||||
// node-fetch 的 FormData 需要特殊处理
|
||||
const FormData = (await import('form-data')).default;
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(options.formData)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
fetchOptions.body = formData as any;
|
||||
// FormData 会自动设置 Content-Type
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
const timeout = options.timeout || 30000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn(`[LxMusicHttp] 请求超时: ${url}`);
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`);
|
||||
|
||||
// 读取响应体
|
||||
const rawBody = await response.text();
|
||||
|
||||
// 尝试解析 JSON
|
||||
let parsedBody: any = rawBody;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (
|
||||
contentType.includes('application/json') ||
|
||||
rawBody.startsWith('{') ||
|
||||
rawBody.startsWith('[')
|
||||
) {
|
||||
try {
|
||||
parsedBody = JSON.parse(rawBody);
|
||||
} catch {
|
||||
// 解析失败则使用原始字符串
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 headers 为普通对象
|
||||
const headers: Record<string, string | string[]> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
const result: LxHttpResponse = {
|
||||
statusCode: response.status,
|
||||
headers,
|
||||
body: parsedBody
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
// 清理取消控制器
|
||||
abortControllers.delete(requestId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 处理请求取消
|
||||
ipcMain.handle('lx-music-http-cancel', (_, requestId: string) => {
|
||||
const controller = abortControllers.get(requestId);
|
||||
if (controller) {
|
||||
console.log(`[LxMusicHttp] 取消请求: ${requestId}`);
|
||||
controller.abort();
|
||||
abortControllers.delete(requestId);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[LxMusicHttp] HTTP 请求处理已初始化');
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理所有正在进行的请求
|
||||
*/
|
||||
export const cleanupLxMusicHttp = () => {
|
||||
for (const [requestId, controller] of abortControllers.entries()) {
|
||||
console.log(`[LxMusicHttp] 清理请求: ${requestId}`);
|
||||
controller.abort();
|
||||
}
|
||||
abortControllers.clear();
|
||||
};
|
||||
@@ -143,6 +143,12 @@ export function initializeWindowManager() {
|
||||
}
|
||||
});
|
||||
|
||||
// 强制退出应用(用于免责声明拒绝等场景)
|
||||
ipcMain.on('quit-app', () => {
|
||||
setAppQuitting(true);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.on('mini-tray', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) {
|
||||
|
||||
@@ -23,14 +23,57 @@ ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) =>
|
||||
}
|
||||
});
|
||||
|
||||
async function startMusicApi(): Promise<void> {
|
||||
console.log('MUSIC API STARTED');
|
||||
|
||||
const port = (store.get('set') as any).musicApiPort || 30488;
|
||||
|
||||
await server.serveNcmApi({
|
||||
port
|
||||
/**
|
||||
* 检查端口是否可用
|
||||
*/
|
||||
function checkPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const net = require('net');
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once('error', () => {
|
||||
resolve(false);
|
||||
})
|
||||
.once('listening', () => {
|
||||
tester.close(() => resolve(true));
|
||||
})
|
||||
.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
async function startMusicApi(): Promise<void> {
|
||||
console.log('MUSIC API STARTING...');
|
||||
|
||||
const settings = store.get('set') as any;
|
||||
let port = settings?.musicApiPort || 30488;
|
||||
const maxRetries = 10;
|
||||
|
||||
// 检查端口是否可用,如果不可用则尝试下一个端口
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const isAvailable = await checkPortAvailable(port);
|
||||
if (isAvailable) {
|
||||
break;
|
||||
}
|
||||
console.log(`端口 ${port} 被占用,尝试切换到端口 ${port + 1}`);
|
||||
port++;
|
||||
}
|
||||
|
||||
// 如果端口发生变化,保存新端口到配置
|
||||
const originalPort = settings?.musicApiPort || 30488;
|
||||
if (port !== originalPort) {
|
||||
console.log(`端口从 ${originalPort} 切换到 ${port}`);
|
||||
store.set('set', { ...settings, musicApiPort: port });
|
||||
}
|
||||
|
||||
try {
|
||||
await server.serveNcmApi({
|
||||
port
|
||||
});
|
||||
console.log(`MUSIC API STARTED on port ${port}`);
|
||||
} catch (error) {
|
||||
console.error(`MUSIC API 启动失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { startMusicApi };
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"alwaysShowDownloadButton": false,
|
||||
"unlimitedDownload": false,
|
||||
"enableMusicUnblock": true,
|
||||
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili"],
|
||||
"enabledMusicSources": ["migu", "kugou", "pyncmd"],
|
||||
"showTopAction": false,
|
||||
"contentZoomFactor": 1,
|
||||
"autoTheme": false,
|
||||
@@ -32,5 +32,7 @@
|
||||
"isMenuExpanded": false,
|
||||
"customApiPlugin": "",
|
||||
"customApiPluginName": "",
|
||||
"lxMusicScripts": [],
|
||||
"activeLxMusicApiId": null,
|
||||
"enableGpuAcceleration": true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import match from '@unblockneteasemusic/server';
|
||||
|
||||
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili';
|
||||
type Platform = 'qq' | 'migu' | 'kugou' | 'kuwo' | 'pyncmd' | 'joox' | 'bilibili';
|
||||
|
||||
interface SongData {
|
||||
name: string;
|
||||
@@ -30,7 +30,7 @@ interface UnblockResult {
|
||||
}
|
||||
|
||||
// 所有可用平台
|
||||
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
|
||||
|
||||
/**
|
||||
* 确保对象数据结构完整,处理null或undefined的情况
|
||||
|
||||
4
src/preload/index.d.ts
vendored
4
src/preload/index.d.ts
vendored
@@ -4,6 +4,7 @@ interface API {
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
quitApp: () => void;
|
||||
dragStart: (data: any) => void;
|
||||
miniTray: () => void;
|
||||
miniWindow: () => void;
|
||||
@@ -22,8 +23,11 @@ interface API {
|
||||
onLanguageChanged: (callback: (locale: string) => void) => void;
|
||||
removeDownloadListeners: () => void;
|
||||
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
|
||||
importLxMusicScript: () => Promise<{ name: string; content: string } | null>;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
getSearchSuggestions: (keyword: string) => Promise<any>;
|
||||
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) => Promise<any>;
|
||||
lxMusicHttpCancel: (requestId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// 自定义IPC渲染进程通信接口
|
||||
|
||||
@@ -6,6 +6,7 @@ const api = {
|
||||
minimize: () => ipcRenderer.send('minimize-window'),
|
||||
maximize: () => ipcRenderer.send('maximize-window'),
|
||||
close: () => ipcRenderer.send('close-window'),
|
||||
quitApp: () => ipcRenderer.send('quit-app'),
|
||||
dragStart: (data) => ipcRenderer.send('drag-start', data),
|
||||
miniTray: () => ipcRenderer.send('mini-tray'),
|
||||
miniWindow: () => ipcRenderer.send('mini-window'),
|
||||
@@ -19,6 +20,7 @@ const api = {
|
||||
unblockMusic: (id, data, enabledSources) =>
|
||||
ipcRenderer.invoke('unblock-music', id, data, enabledSources),
|
||||
importCustomApiPlugin: () => ipcRenderer.invoke('import-custom-api-plugin'),
|
||||
importLxMusicScript: () => ipcRenderer.invoke('import-lx-music-script'),
|
||||
// 歌词窗口关闭事件
|
||||
onLyricWindowClosed: (callback: () => void) => {
|
||||
ipcRenderer.on('lyric-window-closed', () => callback());
|
||||
@@ -57,7 +59,13 @@ const api = {
|
||||
return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`));
|
||||
},
|
||||
// 搜索建议
|
||||
getSearchSuggestions: (keyword: string) => ipcRenderer.invoke('get-search-suggestions', keyword)
|
||||
getSearchSuggestions: (keyword: string) => ipcRenderer.invoke('get-search-suggestions', keyword),
|
||||
|
||||
// 落雪音乐 HTTP 请求(绕过 CORS)
|
||||
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) =>
|
||||
ipcRenderer.invoke('lx-music-http-request', request),
|
||||
|
||||
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId)
|
||||
};
|
||||
|
||||
// 创建带类型的ipcRenderer对象,暴露给渲染进程
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<n-message-provider>
|
||||
<router-view></router-view>
|
||||
<traffic-warning-drawer v-if="!isElectron"></traffic-warning-drawer>
|
||||
<disclaimer-modal></disclaimer-modal>
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-config-provider>
|
||||
@@ -18,6 +19,7 @@ import { computed, nextTick, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import DisclaimerModal from '@/components/common/DisclaimerModal.vue';
|
||||
import TrafficWarningDrawer from '@/components/TrafficWarningDrawer.vue';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
@@ -27,6 +29,7 @@ import { checkLoginStatus } from '@/utils/auth';
|
||||
|
||||
import { initAudioListeners, initMusicHook } from './hooks/MusicHook';
|
||||
import { audioService } from './services/audioService';
|
||||
import { initLxMusicRunner } from './services/LxMusicSourceRunner';
|
||||
import { isMobile } from './utils';
|
||||
import { useAppShortcuts } from './utils/appShortcuts';
|
||||
|
||||
@@ -116,6 +119,7 @@ if (isElectron) {
|
||||
useAppShortcuts();
|
||||
|
||||
onMounted(async () => {
|
||||
playerStore.setIsPlay(false);
|
||||
if (isLyricWindow.value) {
|
||||
return;
|
||||
}
|
||||
@@ -124,6 +128,21 @@ onMounted(async () => {
|
||||
// 初始化播放状态
|
||||
await playerStore.initializePlayState();
|
||||
|
||||
// 初始化落雪音源(如果有激活的音源)
|
||||
const activeLxApiId = settingsStore.setData?.activeLxMusicApiId;
|
||||
if (activeLxApiId) {
|
||||
const lxMusicScripts = settingsStore.setData?.lxMusicScripts || [];
|
||||
const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId);
|
||||
if (activeScript && activeScript.script) {
|
||||
try {
|
||||
console.log('[App] 初始化激活的落雪音源:', activeScript.name);
|
||||
await initLxMusicRunner(activeScript.script);
|
||||
} catch (error) {
|
||||
console.error('[App] 初始化落雪音源失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有正在播放的音乐,则初始化音频监听器
|
||||
if (playerStore.playMusic && playerStore.playMusic.id) {
|
||||
// 使用 nextTick 确保 DOM 更新后再初始化
|
||||
|
||||
273
src/renderer/api/lxMusicStrategy.ts
Normal file
273
src/renderer/api/lxMusicStrategy.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 落雪音乐 (LX Music) 音源解析策略
|
||||
*
|
||||
* 实现 MusicSourceStrategy 接口,作为落雪音源的解析入口
|
||||
*/
|
||||
|
||||
import { getLxMusicRunner, initLxMusicRunner } from '@/services/LxMusicSourceRunner';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import type { LxMusicInfo, LxQuality, LxSourceKey } from '@/types/lxMusic';
|
||||
import { LX_SOURCE_NAMES, QUALITY_TO_LX } from '@/types/lxMusic';
|
||||
import type { SongResult } from '@/types/music';
|
||||
|
||||
import type { MusicParseResult } from './musicParser';
|
||||
import { CacheManager } from './musicParser';
|
||||
|
||||
/**
|
||||
* 解析可能是 API 端点的 URL,获取真实音频 URL
|
||||
* 一些音源脚本返回的是 API 端点,需要额外请求才能获取真实音频 URL
|
||||
*/
|
||||
const resolveAudioUrl = async (url: string): Promise<string> => {
|
||||
try {
|
||||
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
|
||||
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
|
||||
|
||||
if (!isApiEndpoint) {
|
||||
// 看起来像直接的音频 URL,直接返回
|
||||
return url;
|
||||
}
|
||||
|
||||
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
|
||||
|
||||
// 尝试获取真实 URL
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'manual' // 不自动跟随重定向
|
||||
});
|
||||
|
||||
// 检查是否是重定向
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
console.log('[LxMusicStrategy] API 返回重定向 URL:', location);
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 HEAD 请求没有重定向,尝试 GET 请求
|
||||
const getResponse = await fetch(url, {
|
||||
redirect: 'follow'
|
||||
});
|
||||
|
||||
// 检查 Content-Type
|
||||
const contentType = getResponse.headers.get('Content-Type') || '';
|
||||
|
||||
// 如果是音频类型,返回最终 URL
|
||||
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
|
||||
console.log('[LxMusicStrategy] 解析到音频 URL:', getResponse.url);
|
||||
return getResponse.url;
|
||||
}
|
||||
|
||||
// 如果是 JSON,尝试解析
|
||||
if (contentType.includes('application/json') || contentType.includes('text/json')) {
|
||||
const json = await getResponse.json();
|
||||
console.log('[LxMusicStrategy] API 返回 JSON:', json);
|
||||
|
||||
// 尝试从 JSON 中提取 URL(常见字段)
|
||||
const audioUrl = json.url || json.data?.url || json.audio_url || json.link || json.src;
|
||||
if (audioUrl && typeof audioUrl === 'string') {
|
||||
console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl);
|
||||
return audioUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都不是,返回原始 URL(可能直接可用)
|
||||
console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL');
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error('[LxMusicStrategy] URL 解析失败:', error);
|
||||
// 解析失败时返回原始 URL
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 SongResult 转换为 LxMusicInfo 格式
|
||||
*/
|
||||
const convertToLxMusicInfo = (songResult: SongResult): LxMusicInfo => {
|
||||
const artistName =
|
||||
songResult.ar && songResult.ar.length > 0
|
||||
? songResult.ar.map((a) => a.name).join('、')
|
||||
: songResult.artists && songResult.artists.length > 0
|
||||
? songResult.artists.map((a) => a.name).join('、')
|
||||
: '';
|
||||
|
||||
const albumName = songResult.al?.name || (songResult.album as any)?.name || '';
|
||||
|
||||
const albumId = songResult.al?.id || (songResult.album as any)?.id || '';
|
||||
|
||||
// 计算时长(秒转分钟:秒格式)
|
||||
const duration = songResult.dt || songResult.duration || 0;
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
const interval = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
return {
|
||||
songmid: songResult.id,
|
||||
name: songResult.name,
|
||||
singer: artistName,
|
||||
album: albumName,
|
||||
albumId,
|
||||
source: 'wy', // 默认使用网易云作为源,因为我们的数据来自网易云
|
||||
interval,
|
||||
img: songResult.picUrl || songResult.al?.picUrl || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取最佳匹配的落雪音源
|
||||
* 因为我们的数据来自网易云,优先尝试 wy 音源
|
||||
*/
|
||||
const getBestMatchingSource = (
|
||||
availableSources: LxSourceKey[],
|
||||
_songSource?: string
|
||||
): LxSourceKey | null => {
|
||||
// 优先级顺序:网易云 > 酷我 > 咪咕 > 酷狗 > QQ音乐
|
||||
const priority: LxSourceKey[] = ['wy', 'kw', 'mg', 'kg', 'tx'];
|
||||
|
||||
for (const source of priority) {
|
||||
if (availableSources.includes(source)) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
return availableSources[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 落雪音乐解析策略
|
||||
*/
|
||||
export class LxMusicStrategy {
|
||||
name = 'lxMusic';
|
||||
priority = 0; // 最高优先级
|
||||
|
||||
/**
|
||||
* 检查是否可以处理
|
||||
*/
|
||||
canHandle(sources: string[], settingsStore?: any): boolean {
|
||||
// 检查是否启用了落雪音源
|
||||
if (!sources.includes('lxMusic')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有激活的音源
|
||||
const activeLxApiId = settingsStore?.setData?.activeLxMusicApiId;
|
||||
if (!activeLxApiId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查音源列表中是否存在该 ID
|
||||
const lxMusicScripts = settingsStore?.setData?.lxMusicScripts || [];
|
||||
const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId);
|
||||
|
||||
return Boolean(activeScript && activeScript.script);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析音乐 URL
|
||||
*/
|
||||
async parse(
|
||||
id: number,
|
||||
data: SongResult,
|
||||
quality?: string,
|
||||
_sources?: string[]
|
||||
): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 获取激活的音源 ID
|
||||
const activeLxApiId = settingsStore.setData?.activeLxMusicApiId;
|
||||
if (!activeLxApiId) {
|
||||
console.log('[LxMusicStrategy] 未选择激活的落雪音源');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从音源列表中获取激活的脚本
|
||||
const lxMusicScripts = settingsStore.setData?.lxMusicScripts || [];
|
||||
const activeScript = lxMusicScripts.find((script: any) => script.id === activeLxApiId);
|
||||
|
||||
if (!activeScript || !activeScript.script) {
|
||||
console.log('[LxMusicStrategy] 未找到激活的落雪音源脚本');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[LxMusicStrategy] 使用激活的音源: ${activeScript.name} (ID: ${activeScript.id})`);
|
||||
|
||||
// 获取或初始化执行器
|
||||
let runner = getLxMusicRunner();
|
||||
if (!runner || !runner.isInitialized()) {
|
||||
console.log('[LxMusicStrategy] 初始化落雪音源执行器...');
|
||||
runner = await initLxMusicRunner(activeScript.script);
|
||||
}
|
||||
|
||||
// 获取可用音源
|
||||
const sources = runner.getSources();
|
||||
const availableSourceKeys = Object.keys(sources) as LxSourceKey[];
|
||||
|
||||
if (availableSourceKeys.length === 0) {
|
||||
console.log('[LxMusicStrategy] 没有可用的落雪音源');
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 选择最佳音源
|
||||
const bestSource = getBestMatchingSource(availableSourceKeys);
|
||||
if (!bestSource) {
|
||||
console.log('[LxMusicStrategy] 无法找到匹配的音源');
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[LxMusicStrategy] 使用音源: ${LX_SOURCE_NAMES[bestSource]} (${bestSource})`);
|
||||
|
||||
// 转换歌曲信息
|
||||
const lxMusicInfo = convertToLxMusicInfo(data);
|
||||
|
||||
// 转换音质
|
||||
const lxQuality: LxQuality = QUALITY_TO_LX[quality || 'higher'] || '320k';
|
||||
|
||||
// 获取音乐 URL
|
||||
const rawUrl = await runner.getMusicUrl(bestSource, lxMusicInfo, lxQuality);
|
||||
|
||||
if (!rawUrl) {
|
||||
console.log('[LxMusicStrategy] 获取 URL 失败');
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[LxMusicStrategy] 脚本返回 URL:', rawUrl.substring(0, 80) + '...');
|
||||
|
||||
// 解析可能是 API 端点的 URL
|
||||
const resolvedUrl = await resolveAudioUrl(rawUrl);
|
||||
|
||||
if (!resolvedUrl) {
|
||||
console.log('[LxMusicStrategy] URL 解析失败');
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[LxMusicStrategy] 最终音频 URL:', resolvedUrl.substring(0, 80) + '...');
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
url: resolvedUrl,
|
||||
source: `lx-${bestSource}`,
|
||||
quality: lxQuality
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[LxMusicStrategy] 解析失败:', error);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { musicDB } from '@/hooks/MusicHook';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { isElectron } from '@/utils';
|
||||
@@ -9,6 +10,7 @@ import requestMusic from '@/utils/request_music';
|
||||
import { searchAndGetBilibiliAudioUrl } from './bilibili';
|
||||
import type { ParsedMusicResult } from './gdmusic';
|
||||
import { parseFromGDMusic } from './gdmusic';
|
||||
import { LxMusicStrategy } from './lxMusicStrategy';
|
||||
import { parseFromCustomApi } from './parseFromCustomApi';
|
||||
|
||||
const { saveData, getData, deleteData } = musicDB;
|
||||
@@ -33,13 +35,18 @@ export interface MusicParseResult {
|
||||
const CACHE_CONFIG = {
|
||||
// 音乐URL缓存时间:30分钟
|
||||
MUSIC_URL_CACHE_TIME: 30 * 60 * 1000,
|
||||
// 失败缓存时间:5分钟
|
||||
FAILED_CACHE_TIME: 5 * 60 * 1000,
|
||||
// 失败缓存时间:1分钟(减少到 1 分钟以便更快恢复)
|
||||
FAILED_CACHE_TIME: 1 * 60 * 1000,
|
||||
// 重试配置
|
||||
MAX_RETRY_COUNT: 2,
|
||||
RETRY_DELAY: 1000
|
||||
};
|
||||
|
||||
/**
|
||||
* 内存失败缓存(替代 IndexedDB,更轻量且应用重启后自动失效)
|
||||
*/
|
||||
const failedCacheMap = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 缓存管理器
|
||||
*/
|
||||
@@ -104,39 +111,46 @@ export class CacheManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在失败缓存期内
|
||||
* 检查是否在失败缓存期内(使用内存缓存)
|
||||
*/
|
||||
static async isInFailedCache(id: number, strategyName: string): Promise<boolean> {
|
||||
try {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
const cached = await getData('music_failed_cache', cacheKey);
|
||||
if (cached?.createTime && Date.now() - cached.createTime < CACHE_CONFIG.FAILED_CACHE_TIME) {
|
||||
console.log(`策略 ${strategyName} 在失败缓存期内,跳过`);
|
||||
return true;
|
||||
}
|
||||
// 清理过期缓存
|
||||
if (cached) {
|
||||
await deleteData('music_failed_cache', cacheKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('检查失败缓存失败:', error);
|
||||
static isInFailedCache(id: number, strategyName: string): boolean {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
const cachedTime = failedCacheMap.get(cacheKey);
|
||||
if (cachedTime && Date.now() - cachedTime < CACHE_CONFIG.FAILED_CACHE_TIME) {
|
||||
console.log(`策略 ${strategyName} 在失败缓存期内,跳过`);
|
||||
return true;
|
||||
}
|
||||
// 清理过期缓存
|
||||
if (cachedTime) {
|
||||
failedCacheMap.delete(cacheKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加失败缓存
|
||||
* 添加失败缓存(使用内存缓存)
|
||||
*/
|
||||
static async addFailedCache(id: number, strategyName: string): Promise<void> {
|
||||
try {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
await saveData('music_failed_cache', {
|
||||
id: cacheKey,
|
||||
createTime: Date.now()
|
||||
});
|
||||
console.log(`添加失败缓存成功: ${strategyName}`);
|
||||
} catch (error) {
|
||||
console.error('添加失败缓存失败:', error);
|
||||
static addFailedCache(id: number, strategyName: string): void {
|
||||
const cacheKey = `${id}_${strategyName}`;
|
||||
failedCacheMap.set(cacheKey, Date.now());
|
||||
console.log(
|
||||
`添加失败缓存成功: ${strategyName} (缓存时间: ${CACHE_CONFIG.FAILED_CACHE_TIME / 1000}秒)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定歌曲的失败缓存
|
||||
*/
|
||||
static clearFailedCache(id: number): void {
|
||||
const keysToDelete: string[] = [];
|
||||
failedCacheMap.forEach((_, key) => {
|
||||
if (key.startsWith(`${id}_`)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
keysToDelete.forEach((key) => failedCacheMap.delete(key));
|
||||
if (keysToDelete.length > 0) {
|
||||
console.log(`清除歌曲 ${id} 的失败缓存: ${keysToDelete.length} 项`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +336,7 @@ class CustomApiStrategy implements MusicSourceStrategy {
|
||||
|
||||
async parse(id: number, data: SongResult, quality = 'higher'): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -339,11 +353,11 @@ class CustomApiStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('自定义API解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -362,7 +376,7 @@ class BilibiliStrategy implements MusicSourceStrategy {
|
||||
|
||||
async parse(id: number, data: SongResult): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -379,11 +393,11 @@ class BilibiliStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Bilibili解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -402,7 +416,7 @@ class GDMusicStrategy implements MusicSourceStrategy {
|
||||
|
||||
async parse(id: number, data: SongResult): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -419,11 +433,11 @@ class GDMusicStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('GD音乐台解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -450,7 +464,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
||||
sources?: string[]
|
||||
): Promise<MusicParseResult | null> {
|
||||
// 检查失败缓存
|
||||
if (await CacheManager.isInFailedCache(id, this.name)) {
|
||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -471,11 +485,11 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
||||
}
|
||||
|
||||
// 解析失败,添加失败缓存
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('UnblockMusic解析失败:', error);
|
||||
await CacheManager.addFailedCache(id, this.name);
|
||||
CacheManager.addFailedCache(id, this.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -486,6 +500,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
||||
*/
|
||||
class MusicSourceStrategyFactory {
|
||||
private static strategies: MusicSourceStrategy[] = [
|
||||
new LxMusicStrategy(),
|
||||
new CustomApiStrategy(),
|
||||
new BilibiliStrategy(),
|
||||
new GDMusicStrategy(),
|
||||
@@ -512,23 +527,15 @@ class MusicSourceStrategyFactory {
|
||||
* @returns 音源列表和音质设置
|
||||
*/
|
||||
const getMusicConfig = (id: number, settingsStore?: any) => {
|
||||
const songId = String(id);
|
||||
let musicSources: string[] = [];
|
||||
let quality = 'higher';
|
||||
|
||||
try {
|
||||
// 尝试获取歌曲自定义音源
|
||||
const savedSourceStr = localStorage.getItem(`song_source_${songId}`);
|
||||
if (savedSourceStr) {
|
||||
try {
|
||||
const customSources = JSON.parse(savedSourceStr);
|
||||
if (Array.isArray(customSources)) {
|
||||
musicSources = customSources;
|
||||
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析自定义音源设置失败:', error);
|
||||
}
|
||||
// 尝试获取歌曲自定义音源(使用 SongSourceConfigManager)
|
||||
const songConfig = SongSourceConfigManager.getConfig(id);
|
||||
if (songConfig && songConfig.sources.length > 0) {
|
||||
musicSources = songConfig.sources;
|
||||
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
|
||||
}
|
||||
|
||||
// 如果没有自定义音源,使用全局设置
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<template>
|
||||
<div class="traffic-warning-trigger">
|
||||
<n-button circle secondary class="mac-style-button" @click="showDrawer = true">
|
||||
<template #icon>
|
||||
<i class="iconfont ri-information-line"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<n-drawer
|
||||
v-model:show="showDrawer"
|
||||
:width="isMobile ? '100%' : '800px'"
|
||||
|
||||
356
src/renderer/components/common/DisclaimerModal.vue
Normal file
356
src/renderer/components/common/DisclaimerModal.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="disclaimer-modal">
|
||||
<!-- 免责声明页面 -->
|
||||
<div
|
||||
v-if="showDisclaimer"
|
||||
class="fixed inset-0 z-[999999] flex items-center justify-center bg-black/60 backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md mx-4 bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-2xl"
|
||||
>
|
||||
<!-- 顶部渐变装饰 -->
|
||||
<div class="h-2 bg-gradient-to-r from-amber-400 via-orange-500 to-red-500"></div>
|
||||
<!-- 标题 -->
|
||||
<h2 class="text-2xl font-bold text-center text-gray-900 dark:text-white px-6 mt-10">
|
||||
{{ t('comp.disclaimer.title') }}
|
||||
</h2>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="px-6 py-6">
|
||||
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
<!-- 警告框 -->
|
||||
<div
|
||||
class="p-4 rounded-2xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="ri-alert-line text-amber-500 text-xl flex-shrink-0 mt-0.5"></i>
|
||||
<p class="text-amber-700 dark:text-amber-300">
|
||||
{{ t('comp.disclaimer.warning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 免责条款列表 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<i class="ri-book-2-line text-blue-500 text-sm"></i>
|
||||
</div>
|
||||
<p>{{ t('comp.disclaimer.item1') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<i class="ri-time-line text-green-500 text-sm"></i>
|
||||
</div>
|
||||
<p>{{ t('comp.disclaimer.item2') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<i class="ri-shield-check-line text-purple-500 text-sm"></i>
|
||||
</div>
|
||||
<p>{{ t('comp.disclaimer.item3') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="px-6 pb-8 space-y-3">
|
||||
<button
|
||||
@click="handleAgree"
|
||||
class="w-full py-4 rounded-2xl text-base font-medium text-white bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 active:scale-[0.98] transition-all duration-200 shadow-lg shadow-green-500/25"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<i class="ri-check-line text-lg"></i>
|
||||
{{ t('comp.disclaimer.agree') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="handleDisagree"
|
||||
class="w-full py-3 rounded-2xl text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{{ t('comp.disclaimer.disagree') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 捐赠页面 -->
|
||||
<Transition name="donate-modal">
|
||||
<div
|
||||
v-if="showDonate"
|
||||
class="fixed inset-0 z-[999999] flex items-center justify-center bg-black/60 backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md mx-4 bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-2xl"
|
||||
>
|
||||
<!-- 顶部渐变装饰 -->
|
||||
<div class="h-2 bg-gradient-to-r from-pink-400 via-rose-500 to-red-500"></div>
|
||||
|
||||
<!-- 图标区域 -->
|
||||
<div class="flex justify-center pt-8 pb-4">
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl bg-gradient-to-br from-pink-400 to-rose-500 flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<i class="ri-heart-3-fill text-4xl text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h2 class="text-2xl font-bold text-center text-gray-900 dark:text-white px-6">
|
||||
{{ t('comp.donate.title') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center mt-2 px-6">
|
||||
{{ t('comp.donate.subtitle') }}
|
||||
</p>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="px-6 py-6">
|
||||
<!-- 提示信息 -->
|
||||
<div
|
||||
class="p-4 rounded-2xl bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 mb-6"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="ri-gift-line text-rose-500 text-xl flex-shrink-0 mt-0.5"></i>
|
||||
<p class="text-rose-700 dark:text-rose-300 text-sm">
|
||||
{{ t('comp.donate.tip') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 捐赠方式 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
@click="openDonateLink('wechat')"
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-2xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-green-500 flex items-center justify-center">
|
||||
<i class="ri-wechat-fill text-2xl text-white"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-green-700 dark:text-green-300">{{
|
||||
t('comp.donate.wechat')
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="openDonateLink('alipay')"
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-2xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center">
|
||||
<i class="ri-alipay-fill text-2xl text-white"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">{{
|
||||
t('comp.donate.alipay')
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进入应用按钮 -->
|
||||
<div class="px-6 pb-8">
|
||||
<button
|
||||
@click="handleEnterApp"
|
||||
class="w-full py-4 rounded-2xl text-base font-medium text-white bg-gradient-to-r from-gray-700 to-gray-900 dark:from-gray-600 dark:to-gray-800 hover:from-gray-800 hover:to-gray-950 active:scale-[0.98] transition-all duration-200 shadow-lg"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<i class="ri-arrow-right-line text-lg"></i>
|
||||
{{ t('comp.donate.enterApp') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
|
||||
{{ t('comp.donate.noForce') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 收款码弹窗 -->
|
||||
<Transition name="qrcode-modal">
|
||||
<div
|
||||
v-if="showQRCode"
|
||||
class="fixed inset-0 z-[9999999] flex items-center justify-center bg-black/70 backdrop-blur-md"
|
||||
@click.self="closeQRCode"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm mx-4 bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-2xl"
|
||||
>
|
||||
<!-- 顶部渐变装饰 -->
|
||||
<div
|
||||
class="h-2"
|
||||
:class="
|
||||
qrcodeType === 'wechat'
|
||||
? 'bg-gradient-to-r from-green-400 to-green-600'
|
||||
: 'bg-gradient-to-r from-blue-400 to-blue-600'
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ qrcodeType === 'wechat' ? t('comp.donate.wechatQR') : t('comp.donate.alipayQR') }}
|
||||
</h3>
|
||||
<button
|
||||
@click="closeQRCode"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<i class="ri-close-line text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 二维码图片 -->
|
||||
<div class="px-6 pb-6">
|
||||
<div class="bg-white p-4 rounded-2xl">
|
||||
<img
|
||||
:src="qrcodeType === 'wechat' ? wechatQRCode : alipayQRCode"
|
||||
:alt="qrcodeType === 'wechat' ? 'WeChat QR Code' : 'Alipay QR Code'"
|
||||
class="w-full rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center mt-4">
|
||||
{{ t('comp.donate.scanTip') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// 导入收款码图片
|
||||
import alipayQRCode from '@/assets/alipay.png';
|
||||
import wechatQRCode from '@/assets/wechat.png';
|
||||
import { isElectron, isLyricWindow } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 缓存键
|
||||
const DISCLAIMER_AGREED_KEY = 'disclaimer_agreed_timestamp';
|
||||
|
||||
const showDisclaimer = ref(false);
|
||||
const showDonate = ref(false);
|
||||
const showQRCode = ref(false);
|
||||
const qrcodeType = ref<'wechat' | 'alipay'>('wechat');
|
||||
const isTransitioning = ref(false); // 防止用户点击过快
|
||||
|
||||
// 检查是否需要显示免责声明
|
||||
const shouldShowDisclaimer = () => {
|
||||
return !localStorage.getItem(DISCLAIMER_AGREED_KEY);
|
||||
};
|
||||
|
||||
// 处理同意
|
||||
const handleAgree = () => {
|
||||
if (isTransitioning.value) return;
|
||||
isTransitioning.value = true;
|
||||
|
||||
showDisclaimer.value = false;
|
||||
setTimeout(() => {
|
||||
showDonate.value = true;
|
||||
isTransitioning.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 处理不同意 - 退出应用
|
||||
const handleDisagree = () => {
|
||||
if (isTransitioning.value) return;
|
||||
isTransitioning.value = true;
|
||||
|
||||
if (isElectron) {
|
||||
// Electron 环境下强制退出应用
|
||||
window.api?.quitApp?.();
|
||||
} else {
|
||||
// Web 环境下尝试关闭窗口
|
||||
window.close();
|
||||
}
|
||||
isTransitioning.value = false;
|
||||
};
|
||||
|
||||
// 打开捐赠链接
|
||||
const openDonateLink = (type: 'wechat' | 'alipay') => {
|
||||
if (isTransitioning.value) return;
|
||||
|
||||
qrcodeType.value = type;
|
||||
showQRCode.value = true;
|
||||
};
|
||||
|
||||
// 关闭二维码弹窗
|
||||
const closeQRCode = () => {
|
||||
showQRCode.value = false;
|
||||
};
|
||||
|
||||
// 进入应用
|
||||
const handleEnterApp = () => {
|
||||
if (isTransitioning.value) return;
|
||||
isTransitioning.value = true;
|
||||
|
||||
// 记录同意时间
|
||||
localStorage.setItem(DISCLAIMER_AGREED_KEY, Date.now().toString());
|
||||
showDonate.value = false;
|
||||
|
||||
setTimeout(() => {
|
||||
isTransitioning.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 歌词窗口不显示免责声明
|
||||
if (isLyricWindow.value) return;
|
||||
|
||||
// 检查是否需要显示免责声明
|
||||
if (shouldShowDisclaimer()) {
|
||||
showDisclaimer.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 免责声明弹窗动画 */
|
||||
.disclaimer-modal-enter-active,
|
||||
.disclaimer-modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.disclaimer-modal-enter-from,
|
||||
.disclaimer-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 捐赠弹窗动画 */
|
||||
.donate-modal-enter-active,
|
||||
.donate-modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.donate-modal-enter-from,
|
||||
.donate-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 二维码弹窗动画 */
|
||||
.qrcode-modal-enter-active,
|
||||
.qrcode-modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.qrcode-modal-enter-from,
|
||||
.qrcode-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
280
src/renderer/components/common/MobileUpdateModal.vue
Normal file
280
src/renderer/components/common/MobileUpdateModal.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="update-modal">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-[999999] flex items-end justify-center bg-black/50 backdrop-blur-sm"
|
||||
>
|
||||
<!-- 弹窗内容 -->
|
||||
<div
|
||||
class="w-full max-w-lg bg-white dark:bg-gray-900 rounded-t-3xl overflow-hidden animate-slide-up"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1 bg-gradient-to-r from-green-400 via-green-500 to-emerald-600"></div>
|
||||
|
||||
<!-- 关闭条 -->
|
||||
<div class="flex justify-center pt-3 pb-2">
|
||||
<div class="w-10 h-1 rounded-full bg-gray-300 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- 头部信息 -->
|
||||
<div class="px-6 pb-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 应用图标 -->
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl overflow-hidden shadow-lg flex-shrink-0 ring-2 ring-green-500/20"
|
||||
>
|
||||
<img src="@/assets/logo.png" alt="App Icon" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- 版本信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="px-3 py-1 text-xs font-medium text-white bg-gradient-to-r from-green-500 to-emerald-600 rounded-full"
|
||||
>
|
||||
{{ t('comp.update.newVersion') }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white truncate">
|
||||
v{{ updateInfo.latestVersion }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ t('comp.update.currentVersion') }}: v{{ updateInfo.currentVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更新内容 -->
|
||||
<div
|
||||
class="mx-6 mb-6 max-h-80 overflow-y-auto rounded-2xl bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<div
|
||||
class="p-5 text-sm text-gray-600 dark:text-gray-300 leading-relaxed"
|
||||
v-html="parsedReleaseNotes"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div
|
||||
class="px-6 pb-8 flex gap-3"
|
||||
:style="{ paddingBottom: `calc(32px + var(--safe-area-inset-bottom, 0px))` }"
|
||||
>
|
||||
<button
|
||||
@click="handleLater"
|
||||
class="flex-1 py-4 px-4 rounded-2xl text-base font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 active:scale-[0.98] transition-all duration-200"
|
||||
>
|
||||
{{ t('comp.update.later') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
class="flex-1 py-4 px-4 rounded-2xl text-base font-medium text-white bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 active:scale-[0.98] transition-all duration-200 shadow-lg shadow-green-500/25"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<i class="ri-download-2-line text-lg"></i>
|
||||
{{ t('comp.update.updateNow') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { checkUpdate, getProxyNodes, UpdateResult } from '@/utils/update';
|
||||
|
||||
import config from '../../../../package.json';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 缓存键:记录用户点击"稍后提醒"的时间
|
||||
const REMIND_LATER_KEY = 'update_remind_later_timestamp';
|
||||
|
||||
// 配置 marked
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
const showModal = ref(false);
|
||||
|
||||
const updateInfo = ref<UpdateResult>({
|
||||
hasUpdate: false,
|
||||
latestVersion: '',
|
||||
currentVersion: config.version,
|
||||
releaseInfo: null
|
||||
});
|
||||
|
||||
// 解析 Markdown
|
||||
const parsedReleaseNotes = computed(() => {
|
||||
if (!updateInfo.value.releaseInfo?.body) return '';
|
||||
try {
|
||||
return marked.parse(updateInfo.value.releaseInfo.body);
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error);
|
||||
return updateInfo.value.releaseInfo.body;
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否应该显示更新提醒
|
||||
const shouldShowReminder = (): boolean => {
|
||||
const remindLaterTime = localStorage.getItem(REMIND_LATER_KEY);
|
||||
if (!remindLaterTime) return true;
|
||||
|
||||
const savedTime = parseInt(remindLaterTime, 10);
|
||||
const now = Date.now();
|
||||
const oneDayInMs = 24 * 60 * 60 * 1000; // 24小时
|
||||
|
||||
// 如果距离上次点击"稍后提醒"超过24小时,则显示
|
||||
return now - savedTime >= oneDayInMs;
|
||||
};
|
||||
|
||||
// 处理"稍后提醒"
|
||||
const handleLater = () => {
|
||||
// 记录当前时间
|
||||
localStorage.setItem(REMIND_LATER_KEY, Date.now().toString());
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
// 检查是否应该显示提醒
|
||||
if (!shouldShowReminder()) {
|
||||
console.log('更新提醒被延迟,等待24小时后再提醒');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkUpdate(config.version);
|
||||
if (result && result.hasUpdate) {
|
||||
updateInfo.value = result;
|
||||
showModal.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const version = updateInfo.value.latestVersion;
|
||||
|
||||
// Android APK 下载地址
|
||||
const downloadUrl = `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}.apk`;
|
||||
|
||||
try {
|
||||
// 获取代理节点
|
||||
const proxyHosts = await getProxyNodes();
|
||||
const proxyDownloadUrl = `${proxyHosts[0]}/${downloadUrl}`;
|
||||
|
||||
// 清除"稍后提醒"记录(用户选择更新后,下次应该正常提醒)
|
||||
localStorage.removeItem(REMIND_LATER_KEY);
|
||||
|
||||
// 使用系统浏览器打开下载链接
|
||||
window.open(proxyDownloadUrl, '_blank');
|
||||
|
||||
// 关闭弹窗
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('打开下载链接失败:', error);
|
||||
// 回退到直接打开 GitHub Releases
|
||||
const releaseUrl =
|
||||
updateInfo.value.releaseInfo?.html_url ||
|
||||
'https://github.com/algerkong/AlgerMusicPlayer/releases/latest';
|
||||
window.open(releaseUrl, '_blank');
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 延迟检查更新,确保应用完全加载
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 2000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 动画 */
|
||||
.update-modal-enter-active,
|
||||
.update-modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.update-modal-enter-from,
|
||||
.update-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.update-modal-enter-active .animate-slide-up,
|
||||
.update-modal-leave-active .animate-slide-up {
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.update-modal-enter-from .animate-slide-up {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.update-modal-leave-to .animate-slide-up {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
/* 更新内容样式 */
|
||||
:deep(h1) {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(h2) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(h3) {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: #22c55e;
|
||||
}
|
||||
</style>
|
||||
147
src/renderer/components/common/ResponsiveModal.vue
Normal file
147
src/renderer/components/common/ResponsiveModal.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[1000] flex items-center justify-center md:items-center items-end"
|
||||
@click="handleMaskClick"
|
||||
>
|
||||
<!-- Overlay -->
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<Transition :name="isMobile ? 'slide-up' : 'scale-fade'">
|
||||
<div
|
||||
v-if="show"
|
||||
class="relative z-10 w-full bg-white dark:bg-[#1c1c1e] shadow-2xl overflow-hidden flex flex-col max-h-[85vh]"
|
||||
:class="[
|
||||
isMobile
|
||||
? 'rounded-t-[20px] pb-safe'
|
||||
: 'md:max-w-[720px] md:rounded-2xl'
|
||||
]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-white/5 shrink-0"
|
||||
>
|
||||
<h3 class="text-[15px] font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
class="p-1 -mr-1 rounded-full text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<i class="ri-close-line text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="px-4 py-3 border-t border-gray-100 dark:border-white/5 shrink-0 bg-gray-50/50 dark:bg-white/5 backdrop-blur-xl"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
const isMobile = ref(false);
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleMaskClick = () => {
|
||||
close();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
});
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
watch(show, (val) => {
|
||||
if (val) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* PC Scale Fade Transition */
|
||||
.scale-fade-enter-active,
|
||||
.scale-fade-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.scale-fade-enter-from,
|
||||
.scale-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Mobile Slide Up Transition */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -241,7 +241,8 @@ const handleUpdate = async () => {
|
||||
ia32: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-win-ia32.exe`
|
||||
},
|
||||
darwin: {
|
||||
all: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}AlgerMusicPlayer-${version}-mac-universal.dmg`
|
||||
x64: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-x64.dmg`,
|
||||
arm64: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-arm64.dmg`
|
||||
},
|
||||
linux: {
|
||||
AppImage: `https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusicPlayer-${version}-linux-x64.AppImage`,
|
||||
@@ -253,9 +254,12 @@ const handleUpdate = async () => {
|
||||
|
||||
// 根据平台和架构选择对应的安装包
|
||||
if (platform === 'darwin') {
|
||||
// macOS
|
||||
const macAsset = assets.find((asset) => asset.name.includes('mac'));
|
||||
downloadUrl = macAsset?.browser_download_url || downUrls.darwin.all || '';
|
||||
// macOS - 根据芯片架构选择对应的 DMG
|
||||
const macArch = arch === 'arm64' ? 'arm64' : 'x64';
|
||||
const macAsset = assets.find(
|
||||
(asset) => asset.name.includes('mac') && asset.name.includes(macArch)
|
||||
);
|
||||
downloadUrl = macAsset?.browser_download_url || downUrls.darwin[macArch] || '';
|
||||
} else if (platform === 'win32') {
|
||||
// Windows
|
||||
const winAsset = assets.find(
|
||||
|
||||
@@ -1,116 +1,379 @@
|
||||
<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
|
||||
class="w-80 rounded-2xl bg-black/30 backdrop-blur-3xl border border-white/10 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="px-6 py-4 border-b border-white/5">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-white/90">
|
||||
{{ t('settings.lyricSettings.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- 标签页导航 -->
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<div class="flex gap-1 p-1 bg-black/20 rounded-xl">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200',
|
||||
activeTab === tab.key
|
||||
? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/30'
|
||||
: 'hover:bg-white/5'
|
||||
]"
|
||||
:style="activeTab !== tab.key ? 'color: rgba(255, 255, 255, 0.7);' : ''"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
class="px-3 pb-3 max-h-[450px] overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
|
||||
>
|
||||
<!-- 显示设置 -->
|
||||
<div v-show="activeTab === 'display'" class="space-y-2 pt-2">
|
||||
<div class="setting-item">
|
||||
<span>{{ t('settings.lyricSettings.pureMode') }}</span>
|
||||
<input type="checkbox" v-model="config.pureModeEnabled" class="toggle-switch" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<span>{{ t('settings.lyricSettings.hideCover') }}</span>
|
||||
<input type="checkbox" v-model="config.hideCover" class="toggle-switch" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<span>{{ t('settings.lyricSettings.centerDisplay') }}</span>
|
||||
<input type="checkbox" v-model="config.centerLyrics" class="toggle-switch" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<span>{{ t('settings.lyricSettings.showTranslation') }}</span>
|
||||
<input type="checkbox" v-model="config.showTranslation" class="toggle-switch" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<span>{{ t('settings.lyricSettings.hideLyrics') }}</span>
|
||||
<input type="checkbox" v-model="config.hideLyrics" class="toggle-switch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 界面设置 -->
|
||||
<div v-show="activeTab === 'interface'" class="space-y-4 pt-3">
|
||||
<div class="setting-item">
|
||||
<span>{{ t('settings.lyricSettings.showMiniPlayBar') }}</span>
|
||||
<input type="checkbox" v-model="showMiniPlayBar" class="toggle-switch" />
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label class="slider-label">{{ t('settings.lyricSettings.contentWidth') }}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="config.contentWidth"
|
||||
min="50"
|
||||
max="100"
|
||||
step="5"
|
||||
class="slider-emerald"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>50%</span>
|
||||
<span>75%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字设置 -->
|
||||
<div v-show="activeTab === 'typography'" class="space-y-4 pt-3">
|
||||
<div class="slider-group">
|
||||
<label class="slider-label">{{ t('settings.lyricSettings.fontSize') }}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="config.fontSize"
|
||||
min="12"
|
||||
max="32"
|
||||
step="1"
|
||||
class="slider-emerald"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>{{ t('settings.lyricSettings.fontSizeMarks.small') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.fontSizeMarks.medium') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.fontSizeMarks.large') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label class="slider-label">{{ t('settings.lyricSettings.letterSpacing') }}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="config.letterSpacing"
|
||||
min="-2"
|
||||
max="10"
|
||||
step="0.2"
|
||||
class="slider-emerald"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>{{ t('settings.lyricSettings.letterSpacingMarks.compact') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.letterSpacingMarks.default') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.letterSpacingMarks.loose') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label class="slider-label">{{ t('settings.lyricSettings.fontWeight') }}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="config.fontWeight"
|
||||
min="100"
|
||||
max="900"
|
||||
step="100"
|
||||
class="slider-emerald"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>{{ t('settings.lyricSettings.fontWeightMarks.thin') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.fontWeightMarks.normal') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.fontWeightMarks.bold') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label class="slider-label">{{ t('settings.lyricSettings.lineHeight') }}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="config.lineHeight"
|
||||
min="1"
|
||||
max="3"
|
||||
step="0.1"
|
||||
class="slider-emerald"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>{{ t('settings.lyricSettings.lineHeightMarks.compact') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.lineHeightMarks.default') }}</span>
|
||||
<span>{{ t('settings.lyricSettings.lineHeightMarks.loose') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背景设置 -->
|
||||
<div v-show="activeTab === 'background'" class="space-y-4 pt-3">
|
||||
<div class="setting-item">
|
||||
<span>{{ t('settings.lyricSettings.background.useCustomBackground') }}</span>
|
||||
<input type="checkbox" v-model="config.useCustomBackground" class="toggle-switch" />
|
||||
</div>
|
||||
|
||||
<!-- 主题选择 -->
|
||||
<div v-if="!config.useCustomBackground" class="radio-group">
|
||||
<label class="radio-label">{{ t('settings.lyricSettings.backgroundTheme') }}</label>
|
||||
<div class="space-y-2">
|
||||
<label class="radio-item">
|
||||
<input type="radio" v-model="config.theme" value="default" class="radio-input" />
|
||||
<span>{{ t('settings.lyricSettings.themeOptions.default') }}</span>
|
||||
</label>
|
||||
<label class="radio-item">
|
||||
<input type="radio" v-model="config.theme" value="light" class="radio-input" />
|
||||
<span>{{ t('settings.lyricSettings.themeOptions.light') }}</span>
|
||||
</label>
|
||||
<label class="radio-item">
|
||||
<input type="radio" v-model="config.theme" value="dark" class="radio-input" />
|
||||
<span>{{ t('settings.lyricSettings.themeOptions.dark') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背景模式选择 -->
|
||||
<div v-if="config.useCustomBackground" class="radio-group">
|
||||
<label class="radio-label">{{
|
||||
t('settings.lyricSettings.background.backgroundMode')
|
||||
}}</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="radio-item-compact">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="config.backgroundMode"
|
||||
value="solid"
|
||||
class="radio-input"
|
||||
/>
|
||||
<span>{{ t('settings.lyricSettings.background.modeOptions.solid') }}</span>
|
||||
</label>
|
||||
<label class="radio-item-compact">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="config.backgroundMode"
|
||||
value="gradient"
|
||||
class="radio-input"
|
||||
/>
|
||||
<span>{{ t('settings.lyricSettings.background.modeOptions.gradient') }}</span>
|
||||
</label>
|
||||
<label class="radio-item-compact">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="config.backgroundMode"
|
||||
value="image"
|
||||
class="radio-input"
|
||||
/>
|
||||
<span>{{ t('settings.lyricSettings.background.modeOptions.image') }}</span>
|
||||
</label>
|
||||
<label class="radio-item-compact">
|
||||
<input type="radio" v-model="config.backgroundMode" value="css" class="radio-input" />
|
||||
<span>{{ t('settings.lyricSettings.background.modeOptions.css') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 纯色模式 -->
|
||||
<div
|
||||
v-if="config.useCustomBackground && config.backgroundMode === 'solid'"
|
||||
class="color-picker-group"
|
||||
>
|
||||
<label class="color-picker-label">{{
|
||||
t('settings.lyricSettings.background.solidColor')
|
||||
}}</label>
|
||||
<input type="color" v-model="config.solidColor" class="color-picker" />
|
||||
</div>
|
||||
|
||||
<!-- 渐变模式 -->
|
||||
<div
|
||||
v-if="config.useCustomBackground && config.backgroundMode === 'gradient'"
|
||||
class="space-y-3"
|
||||
>
|
||||
<label class="color-picker-label">{{
|
||||
t('settings.lyricSettings.background.gradientEditor')
|
||||
}}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="(_, index) in config.gradientColors.colors" :key="index" class="relative">
|
||||
<input
|
||||
type="color"
|
||||
v-model="config.gradientColors.colors[index]"
|
||||
class="color-picker-small"
|
||||
/>
|
||||
<button
|
||||
v-if="config.gradientColors.colors.length > 2"
|
||||
@click="removeGradientColor(index)"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center rounded-full bg-red-500 text-white text-xs hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="config.gradientColors.colors.length < 5"
|
||||
@click="addGradientColor"
|
||||
class="w-full py-2 px-4 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 text-white/90"
|
||||
>
|
||||
<i class="ri-add-line"></i>
|
||||
{{ t('settings.lyricSettings.background.addColor') }}
|
||||
</button>
|
||||
|
||||
<div class="select-group">
|
||||
<label class="select-label">{{
|
||||
t('settings.lyricSettings.background.gradientDirection')
|
||||
}}</label>
|
||||
<select v-model="config.gradientColors.direction" class="select-input">
|
||||
<option v-for="opt in gradientDirectionOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片模式 -->
|
||||
<div
|
||||
v-if="config.useCustomBackground && config.backgroundMode === 'image'"
|
||||
class="space-y-3"
|
||||
>
|
||||
<label class="color-picker-label">{{
|
||||
t('settings.lyricSettings.background.imageUpload')
|
||||
}}</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleImageChange"
|
||||
class="hidden"
|
||||
ref="fileInput"
|
||||
/>
|
||||
<button
|
||||
@click="fileInput?.click()"
|
||||
class="w-full py-2 px-4 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 text-white/90"
|
||||
>
|
||||
<i class="ri-image-add-line"></i>
|
||||
{{ t('settings.lyricSettings.background.imageUpload') }}
|
||||
</button>
|
||||
|
||||
<div v-if="config.backgroundImage" class="space-y-3">
|
||||
<div class="relative rounded-lg overflow-hidden border border-white/10">
|
||||
<img
|
||||
:src="config.backgroundImage"
|
||||
class="w-full max-h-40 object-cover"
|
||||
alt="Preview"
|
||||
/>
|
||||
<button
|
||||
@click="clearBackgroundImage"
|
||||
class="absolute top-2 right-2 p-2 rounded-lg bg-red-500/80 text-white hover:bg-red-500 transition-colors"
|
||||
>
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label class="slider-label">{{
|
||||
t('settings.lyricSettings.background.imageBlur')
|
||||
}}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="config.imageBlur"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
class="slider-emerald"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>0</span>
|
||||
<span>10</span>
|
||||
<span>20px</span>
|
||||
</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 class="slider-group">
|
||||
<label class="slider-label">{{
|
||||
t('settings.lyricSettings.background.imageBrightness')
|
||||
}}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="config.imageBrightness"
|
||||
min="0"
|
||||
max="200"
|
||||
step="5"
|
||||
class="slider-emerald"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>暗</span>
|
||||
<span>正常</span>
|
||||
<span>亮</span>
|
||||
</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.showMiniPlayBar') }}</span>
|
||||
<n-switch v-model:value="showMiniPlayBar" />
|
||||
</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>
|
||||
<p class="text-xs text-white/50">
|
||||
{{ t('settings.lyricSettings.background.fileSizeLimit') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 文字设置 -->
|
||||
<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>
|
||||
<!-- CSS 模式 -->
|
||||
<div v-if="config.useCustomBackground && config.backgroundMode === 'css'" class="space-y-2">
|
||||
<label class="color-picker-label">{{
|
||||
t('settings.lyricSettings.background.customCss')
|
||||
}}</label>
|
||||
<textarea
|
||||
v-model="config.customCss"
|
||||
:placeholder="t('settings.lyricSettings.background.customCssPlaceholder')"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 bg-black/20 border border-white/10 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/50 font-mono text-white/90"
|
||||
></textarea>
|
||||
<p class="text-xs text-white/50">
|
||||
{{ t('settings.lyricSettings.background.customCssHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -124,26 +387,82 @@ import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
|
||||
const { t } = useI18n();
|
||||
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
||||
const emit = defineEmits(['themeChange']);
|
||||
const message = window.$message;
|
||||
const activeTab = ref('display');
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'display', label: t('settings.lyricSettings.tabs.display') },
|
||||
{ key: 'interface', label: t('settings.lyricSettings.tabs.interface') },
|
||||
{ key: 'typography', label: t('settings.lyricSettings.tabs.typography') },
|
||||
{ key: 'background', label: t('settings.lyricSettings.tabs.background') }
|
||||
]);
|
||||
|
||||
// 显示mini播放栏开关
|
||||
const showMiniPlayBar = computed({
|
||||
get: () => !config.value.hideMiniPlayBar,
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
// 显示mini播放栏,隐藏普通播放栏
|
||||
config.value.hideMiniPlayBar = false;
|
||||
config.value.hidePlayBar = true;
|
||||
} else {
|
||||
// 显示普通播放栏,隐藏mini播放栏
|
||||
config.value.hideMiniPlayBar = true;
|
||||
config.value.hidePlayBar = false;
|
||||
}
|
||||
config.value.hideMiniPlayBar = !value;
|
||||
config.value.hidePlayBar = value;
|
||||
}
|
||||
});
|
||||
|
||||
const gradientDirectionOptions = computed(() => [
|
||||
{ label: t('settings.lyricSettings.background.directionOptions.toBottom'), value: 'to bottom' },
|
||||
{ label: t('settings.lyricSettings.background.directionOptions.toTop'), value: 'to top' },
|
||||
{ label: t('settings.lyricSettings.background.directionOptions.toRight'), value: 'to right' },
|
||||
{ label: t('settings.lyricSettings.background.directionOptions.toLeft'), value: 'to left' },
|
||||
{
|
||||
label: t('settings.lyricSettings.background.directionOptions.toBottomRight'),
|
||||
value: 'to bottom right'
|
||||
},
|
||||
{ label: t('settings.lyricSettings.background.directionOptions.angle45'), value: '45deg' }
|
||||
]);
|
||||
|
||||
const addGradientColor = () => {
|
||||
if (config.value.gradientColors.colors.length < 5) {
|
||||
config.value.gradientColors.colors.push('#666666');
|
||||
}
|
||||
};
|
||||
|
||||
const removeGradientColor = (index: number) => {
|
||||
if (config.value.gradientColors.colors.length > 2) {
|
||||
config.value.gradientColors.colors.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message?.error(t('settings.lyricSettings.background.invalidImageFormat'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
message?.error(t('settings.lyricSettings.background.imageTooLarge'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
config.value.backgroundImage = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const clearBackgroundImage = () => {
|
||||
config.value.backgroundImage = undefined;
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => config.value,
|
||||
(newConfig) => {
|
||||
localStorage.setItem('music-full-config', JSON.stringify(newConfig));
|
||||
updateCSSVariables(newConfig);
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -159,6 +478,10 @@ watch(
|
||||
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-font-weight',
|
||||
config.fontWeight?.toString() || '400'
|
||||
);
|
||||
document.documentElement.style.setProperty('--lyric-line-height', config.lineHeight.toString());
|
||||
};
|
||||
|
||||
@@ -175,98 +498,304 @@ defineExpose({
|
||||
});
|
||||
</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;
|
||||
}
|
||||
<style scoped>
|
||||
/* 设置项 */
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
:deep(.n-slider-mark) {
|
||||
color: var(--text-color-primary) !important;
|
||||
.setting-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:deep(.n-radio__label) {
|
||||
color: var(--text-color-active) !important;
|
||||
@apply text-xs;
|
||||
/* 切换开关 */
|
||||
.toggle-switch {
|
||||
appearance: none;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.mobile-unavailable {
|
||||
@apply text-center py-4 text-gray-500 text-sm;
|
||||
.toggle-switch::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-switch:checked {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.toggle-switch:checked::before {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
/* 滑块组 */
|
||||
.slider-group {
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.slider-emerald {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.slider-emerald::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.slider-emerald::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.slider-marks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 单选框组 */
|
||||
.radio-group {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.radio-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 紧凑版单选项(用于横向布局) */
|
||||
.radio-item-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.radio-item-compact:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.radio-input {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--text-color-primary);
|
||||
opacity: 0.4;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radio-input:checked {
|
||||
border-color: #10b981;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.radio-input:checked::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
/* 颜色选择器 */
|
||||
.color-picker-group {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.color-picker-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.color-picker::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-picker::-webkit-color-swatch {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 小尺寸颜色选择器(用于渐变) */
|
||||
.color-picker-small {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.color-picker-small::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-picker-small::-webkit-color-swatch {
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 下拉选择 */
|
||||
.select-group {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,20 +3,35 @@
|
||||
v-model:show="isVisible"
|
||||
height="100%"
|
||||
placement="bottom"
|
||||
:style="{ background: currentBackground || background }"
|
||||
:style="drawerBaseStyle"
|
||||
:to="`#layout-main`"
|
||||
:z-index="9998"
|
||||
>
|
||||
<div id="drawer-target" :class="[config.theme]">
|
||||
<!-- 背景层(用于图片模糊和明暗效果) -->
|
||||
<div
|
||||
v-if="
|
||||
config.useCustomBackground && config.backgroundMode === 'image' && config.backgroundImage
|
||||
"
|
||||
class="background-layer"
|
||||
:style="backgroundImageStyle"
|
||||
></div>
|
||||
<div id="drawer-target" :class="[config.theme]" class="relative z-10">
|
||||
<!-- 左侧关闭按钮 -->
|
||||
<div
|
||||
class="control-buttons-container absolute top-8 left-8 right-8"
|
||||
class="control-left absolute top-8 left-8 z-[9999]"
|
||||
:class="{ 'pure-mode': config.pureModeEnabled }"
|
||||
>
|
||||
<div class="control-btn" @click="closeMusicFull">
|
||||
<i class="ri-arrow-down-s-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-popover trigger="click" placement="bottom">
|
||||
<!-- 右侧功能按钮组 -->
|
||||
<div
|
||||
class="control-right absolute top-8 right-8 z-[9999]"
|
||||
:class="{ 'pure-mode': config.pureModeEnabled }"
|
||||
>
|
||||
<n-popover trigger="click" placement="bottom" raw>
|
||||
<template #trigger>
|
||||
<div class="control-btn">
|
||||
<i class="ri-settings-3-line"></i>
|
||||
@@ -24,82 +39,40 @@
|
||||
</template>
|
||||
<lyric-settings ref="lyricSettingsRef" />
|
||||
</n-popover>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!config.hideCover"
|
||||
class="music-img"
|
||||
:class="{ 'only-cover': config.hideLyrics }"
|
||||
:style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }"
|
||||
>
|
||||
<div class="img-container">
|
||||
<cover3-d
|
||||
ref="PicImgRef"
|
||||
:src="getImgUrl(playMusic?.picUrl, '500y500')"
|
||||
:loading="playMusic?.playLoading"
|
||||
:max-tilt="12"
|
||||
:scale="1.03"
|
||||
:shine-intensity="0.25"
|
||||
/>
|
||||
</div>
|
||||
<div class="music-info">
|
||||
<div class="music-content-name" v-html="playMusic.name"></div>
|
||||
<div class="music-content-singer">
|
||||
<n-ellipsis
|
||||
class="text-ellipsis"
|
||||
line-clamp="2"
|
||||
:tooltip="{
|
||||
contentStyle: { maxWidth: '600px' },
|
||||
zIndex: 99999
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-for="(item, index) in artistList"
|
||||
:key="index"
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click="handleArtistClick(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
{{ index < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<simple-play-bar
|
||||
v-if="!config.hideMiniPlayBar"
|
||||
class="mt-4"
|
||||
:pure-mode-enabled="config.pureModeEnabled"
|
||||
:isDark="textColors.theme === 'dark'"
|
||||
/>
|
||||
<div class="control-btn" @click="toggleFullScreen">
|
||||
<i :class="isFullScreen ? 'ri-fullscreen-exit-line' : 'ri-fullscreen-line'"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="music-content"
|
||||
:class="{
|
||||
center: config.centerLyrics,
|
||||
hide: config.hideLyrics
|
||||
}"
|
||||
>
|
||||
<n-layout
|
||||
ref="lrcSider"
|
||||
class="music-lrc"
|
||||
:style="{
|
||||
height: config.hidePlayBar ? '85vh' : '65vh',
|
||||
width: isMobile ? '100vw' : config.hideCover ? '50vw' : '500px'
|
||||
}"
|
||||
:native-scrollbar="false"
|
||||
@mouseover="mouseOverLayout"
|
||||
@mouseleave="mouseLeaveLayout"
|
||||
<div class="content-wrapper" :style="{ width: `${config.contentWidth}%` }">
|
||||
<!-- 左侧:封面区域 -->
|
||||
<div
|
||||
v-if="!config.hideCover"
|
||||
class="left-side"
|
||||
:class="{ 'only-cover': config.hideLyrics }"
|
||||
>
|
||||
<!-- 歌曲信息 -->
|
||||
<div ref="lrcContainer" class="music-lrc-container">
|
||||
<div
|
||||
v-if="config.hideCover"
|
||||
class="music-info-header"
|
||||
:style="{ textAlign: config.centerLyrics ? 'center' : 'left' }"
|
||||
>
|
||||
<div class="music-info-name" v-html="playMusic.name"></div>
|
||||
<div class="music-info-singer">
|
||||
<div class="img-container">
|
||||
<cover3-d
|
||||
ref="PicImgRef"
|
||||
:src="getImgUrl(playMusic?.picUrl, '500y500')"
|
||||
:loading="playMusic?.playLoading"
|
||||
:max-tilt="12"
|
||||
:scale="1.03"
|
||||
:shine-intensity="0.25"
|
||||
/>
|
||||
</div>
|
||||
<div class="music-info">
|
||||
<div class="music-content-name" v-html="playMusic.name"></div>
|
||||
<div class="music-content-singer">
|
||||
<n-ellipsis
|
||||
class="text-ellipsis"
|
||||
line-clamp="2"
|
||||
:tooltip="{
|
||||
contentStyle: { maxWidth: '600px' },
|
||||
zIndex: 99999
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-for="(item, index) in artistList"
|
||||
:key="index"
|
||||
@@ -109,53 +82,99 @@
|
||||
{{ item.name }}
|
||||
{{ index < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无时间戳歌词提示 -->
|
||||
<div v-if="!supportAutoScroll" class="music-lrc-text no-scroll-tip">
|
||||
<span>{{ t('player.lrc.noAutoScroll') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in lrcArray"
|
||||
:id="`music-lrc-text-${index}`"
|
||||
:key="index"
|
||||
class="music-lrc-text"
|
||||
:class="{
|
||||
'now-text': index === nowIndex,
|
||||
'hover-text': item.text && item.startTime !== -1
|
||||
}"
|
||||
@click="item.startTime !== -1 ? setAudioTime(index) : null"
|
||||
>
|
||||
<!-- 逐字歌词显示 -->
|
||||
<div
|
||||
v-if="item.hasWordByWord && item.words && item.words.length > 0"
|
||||
class="word-by-word-lyric"
|
||||
>
|
||||
<template v-for="(word, wordIndex) in item.words" :key="wordIndex">
|
||||
<span class="lyric-word" :style="getWordStyle(index, wordIndex, word)">
|
||||
{{ word.text }} </span
|
||||
><span class="lyric-word" v-if="word.space"> </span></template
|
||||
>
|
||||
</div>
|
||||
<!-- 普通歌词显示 -->
|
||||
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||
<div v-show="config.showTranslation" class="music-lrc-text-tr">
|
||||
{{ item.trText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无歌词 -->
|
||||
<div v-if="!lrcArray.length" class="music-lrc-text">
|
||||
<span>{{ t('player.lrc.noLrc') }}</span>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<simple-play-bar
|
||||
v-if="!config.hideMiniPlayBar"
|
||||
class="mt-4"
|
||||
:pure-mode-enabled="config.pureModeEnabled"
|
||||
:isDark="textColors.theme === 'dark'"
|
||||
/>
|
||||
</div>
|
||||
<!-- 歌词右下角矫正按钮组件 -->
|
||||
<lyric-correction-control
|
||||
v-if="!isMobile"
|
||||
:correction-time="correctionTime"
|
||||
@adjust="adjustCorrectionTime"
|
||||
/>
|
||||
</n-layout>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:歌词区域 -->
|
||||
<div
|
||||
class="right-side"
|
||||
:class="{
|
||||
center: config.centerLyrics,
|
||||
hide: config.hideLyrics,
|
||||
'full-width': config.hideCover
|
||||
}"
|
||||
>
|
||||
<n-layout
|
||||
ref="lrcSider"
|
||||
class="music-lrc"
|
||||
:native-scrollbar="false"
|
||||
@mouseover="mouseOverLayout"
|
||||
@mouseleave="mouseLeaveLayout"
|
||||
>
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="music-lrc-container">
|
||||
<div
|
||||
v-if="config.hideCover"
|
||||
class="music-info-header"
|
||||
:style="{ textAlign: config.centerLyrics ? 'center' : 'left' }"
|
||||
>
|
||||
<div class="music-info-name" v-html="playMusic.name"></div>
|
||||
<div class="music-info-singer">
|
||||
<span
|
||||
v-for="(item, index) in artistList"
|
||||
:key="index"
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click="handleArtistClick(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
{{ index < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无时间戳歌词提示 -->
|
||||
<div v-if="!supportAutoScroll" class="music-lrc-text no-scroll-tip">
|
||||
<span>{{ t('player.lrc.noAutoScroll') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in lrcArray"
|
||||
:id="`music-lrc-text-${index}`"
|
||||
:key="index"
|
||||
class="music-lrc-text"
|
||||
:class="{
|
||||
'now-text': index === nowIndex,
|
||||
'hover-text': item.text && item.startTime !== -1
|
||||
}"
|
||||
@click="item.startTime !== -1 ? setAudioTime(index) : null"
|
||||
>
|
||||
<!-- 逐字歌词显示 -->
|
||||
<div
|
||||
v-if="item.hasWordByWord && item.words && item.words.length > 0"
|
||||
class="word-by-word-lyric"
|
||||
>
|
||||
<template v-for="(word, wordIndex) in item.words" :key="wordIndex">
|
||||
<span class="lyric-word" :style="getWordStyle(index, wordIndex, word)">
|
||||
{{ word.text }} </span
|
||||
><span class="lyric-word" v-if="word.space"> </span></template
|
||||
>
|
||||
</div>
|
||||
<!-- 普通歌词显示 -->
|
||||
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||
<div v-show="config.showTranslation" class="music-lrc-text-tr">
|
||||
{{ item.trText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无歌词 -->
|
||||
<div v-if="!lrcArray.length" class="music-lrc-text">
|
||||
<span>{{ t('player.lrc.noLrc') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 歌词右下角矫正按钮组件 -->
|
||||
<lyric-correction-control
|
||||
v-if="!isMobile"
|
||||
:correction-time="correctionTime"
|
||||
@adjust="adjustCorrectionTime"
|
||||
/>
|
||||
</n-layout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-drawer>
|
||||
@@ -193,13 +212,60 @@ const { t } = useI18n();
|
||||
// 定义 refs
|
||||
const lrcSider = ref<any>(null);
|
||||
const isMouse = ref(false);
|
||||
const lrcContainer = ref<HTMLElement | null>(null);
|
||||
const currentBackground = ref('');
|
||||
const animationFrame = ref<number | null>(null);
|
||||
const isDark = ref(false);
|
||||
|
||||
// 计算自定义背景样式
|
||||
const customBackgroundStyle = computed(() => {
|
||||
if (!config.value.useCustomBackground) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (config.value.backgroundMode) {
|
||||
case 'solid':
|
||||
return config.value.solidColor;
|
||||
case 'gradient': {
|
||||
const { colors, direction } = config.value.gradientColors;
|
||||
return `linear-gradient(${direction}, ${colors.join(', ')})`;
|
||||
}
|
||||
case 'image':
|
||||
if (!config.value.backgroundImage) return null;
|
||||
// 构建完整的背景样式,包括滤镜效果
|
||||
return config.value.backgroundImage;
|
||||
case 'css':
|
||||
return config.value.customCss || null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// drawer 基础样式(非图片模式)
|
||||
const drawerBaseStyle = computed(() => {
|
||||
// 图片模式时不设置背景,使用单独的背景层
|
||||
if (config.value.useCustomBackground && config.value.backgroundMode === 'image') {
|
||||
return { background: 'transparent' };
|
||||
}
|
||||
// 其他模式正常设置背景
|
||||
if (config.value.useCustomBackground && customBackgroundStyle.value) {
|
||||
return { background: customBackgroundStyle.value };
|
||||
}
|
||||
return { background: currentBackground.value || props.background };
|
||||
});
|
||||
|
||||
// 背景图片层样式(只在图片模式下使用)
|
||||
const backgroundImageStyle = computed(() => {
|
||||
const blur = config.value.imageBlur || 0;
|
||||
const brightness = config.value.imageBrightness || 100;
|
||||
return {
|
||||
backgroundImage: `url(${config.value.backgroundImage})`,
|
||||
filter: `blur(${blur}px) brightness(${brightness}%)`
|
||||
};
|
||||
});
|
||||
const showStickyHeader = ref(false);
|
||||
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
|
||||
const isSongChanging = ref(false);
|
||||
const isFullScreen = ref(false);
|
||||
|
||||
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
||||
|
||||
@@ -351,14 +417,24 @@ const setTextColors = (background: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听背景变化
|
||||
const targetBackground = computed(() => {
|
||||
if (config.value.useCustomBackground && customBackgroundStyle.value) {
|
||||
if (typeof customBackgroundStyle.value === 'string') {
|
||||
return customBackgroundStyle.value;
|
||||
}
|
||||
}
|
||||
if (config.value.theme !== 'default') {
|
||||
return themeMusic[config.value.theme] || props.background;
|
||||
}
|
||||
return props.background;
|
||||
});
|
||||
|
||||
// 监听目标背景变化并更新文字颜色
|
||||
watch(
|
||||
() => props.background,
|
||||
targetBackground,
|
||||
(newBg) => {
|
||||
if (config.value.theme === 'default') {
|
||||
if (newBg) {
|
||||
setTextColors(newBg);
|
||||
} else {
|
||||
setTextColors(themeMusic[config.value.theme] || props.background);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -500,15 +576,6 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听配置变化并保存到本地存储
|
||||
watch(
|
||||
() => config.value,
|
||||
(newConfig) => {
|
||||
localStorage.setItem('music-full-config', JSON.stringify(newConfig));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
if (!lrcSider.value || !config.value.hideCover) return;
|
||||
@@ -519,18 +586,45 @@ const handleScroll = () => {
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const closeMusicFull = () => {
|
||||
// 退出全屏模式
|
||||
if (isFullScreen.value && document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
isVisible.value = false;
|
||||
playerStore.setMusicFull(false);
|
||||
};
|
||||
|
||||
// 添加滚动监听
|
||||
// 全屏切换方法
|
||||
const toggleFullScreen = async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
// 进入全屏
|
||||
await document.documentElement.requestFullscreen();
|
||||
isFullScreen.value = true;
|
||||
} else {
|
||||
// 退出全屏
|
||||
await document.exitFullscreen();
|
||||
isFullScreen.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('全屏切换失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听全屏状态变化
|
||||
const handleFullScreenChange = () => {
|
||||
isFullScreen.value = !!document.fullscreenElement;
|
||||
};
|
||||
|
||||
// 添加滚动监听和全屏状态监听
|
||||
onMounted(() => {
|
||||
if (lrcSider.value?.$el) {
|
||||
lrcSider.value.$el.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
document.addEventListener('fullscreenchange', handleFullScreenChange);
|
||||
});
|
||||
|
||||
// 移除滚动监听
|
||||
// 移除滚动监听和全屏状态监听
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
@@ -538,6 +632,11 @@ onBeforeUnmount(() => {
|
||||
if (lrcSider.value?.$el) {
|
||||
lrcSider.value.$el.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
document.removeEventListener('fullscreenchange', handleFullScreenChange);
|
||||
// 退出全屏模式
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听字体大小变化
|
||||
@@ -548,14 +647,12 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// 监听主题变化
|
||||
// 监听字体粗细变化
|
||||
watch(
|
||||
() => config.value.theme,
|
||||
(newTheme) => {
|
||||
const newBackground = themeMusic[newTheme] || props.background;
|
||||
setTextColors(newBackground);
|
||||
},
|
||||
{ immediate: true }
|
||||
() => config.value.fontWeight,
|
||||
(newWeight) => {
|
||||
document.documentElement.style.setProperty('--lyric-font-weight', newWeight.toString());
|
||||
}
|
||||
);
|
||||
|
||||
// 添加文字间距监听
|
||||
@@ -621,6 +718,18 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.background-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.drawer-back {
|
||||
@apply absolute bg-cover bg-center;
|
||||
z-index: -1;
|
||||
@@ -635,127 +744,139 @@ defineExpose({
|
||||
}
|
||||
|
||||
#drawer-target {
|
||||
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full py-8;
|
||||
@apply top-0 left-0 absolute overflow-hidden rounded w-full h-full;
|
||||
animation-duration: 300ms;
|
||||
|
||||
.music-img {
|
||||
@apply flex-1 flex justify-center mr-16 flex-col items-center;
|
||||
max-width: 360px;
|
||||
max-height: 360px;
|
||||
.content-wrapper {
|
||||
@apply grid items-center mx-auto h-full;
|
||||
grid-template-columns: minmax(300px, 40%) 1fr;
|
||||
gap: 4rem;
|
||||
max-width: 1600px;
|
||||
padding: 2rem;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.left-side {
|
||||
@apply flex flex-col items-center justify-center h-full;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.only-cover {
|
||||
@apply mr-0 flex-initial;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
@apply col-span-2;
|
||||
|
||||
.img-container {
|
||||
@apply w-[50vh] h-[50vh] mb-8;
|
||||
@apply w-[60vh] aspect-square;
|
||||
}
|
||||
|
||||
.music-info {
|
||||
@apply text-center w-[600px];
|
||||
|
||||
.music-content-name {
|
||||
@apply text-4xl mb-4 line-clamp-2;
|
||||
color: var(--text-color-active);
|
||||
}
|
||||
|
||||
.music-content-singer {
|
||||
@apply text-xl mb-8 opacity-80;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
@apply max-w-[800px];
|
||||
}
|
||||
}
|
||||
|
||||
.img-container {
|
||||
@apply relative w-full h-full;
|
||||
@apply relative w-[45vh] mb-8 aspect-square;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.music-info {
|
||||
@apply w-full mt-4;
|
||||
@apply w-full text-center max-w-[400px];
|
||||
|
||||
.music-content-name {
|
||||
@apply text-2xl font-bold;
|
||||
@apply text-3xl font-bold mb-2 line-clamp-2;
|
||||
color: var(--text-color-active);
|
||||
}
|
||||
|
||||
.music-content-singer {
|
||||
@apply text-base mt-2 opacity-80;
|
||||
@apply text-lg opacity-80;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-content {
|
||||
@apply flex flex-col justify-center items-center relative;
|
||||
width: 500px;
|
||||
transition: all 0.3s ease;
|
||||
.right-side {
|
||||
@apply flex flex-col justify-center h-full relative overflow-hidden;
|
||||
|
||||
&.full-width {
|
||||
@apply col-span-2;
|
||||
}
|
||||
|
||||
&.center {
|
||||
@apply w-auto;
|
||||
|
||||
.music-lrc {
|
||||
@apply w-full max-w-3xl mx-auto;
|
||||
@apply w-full mx-auto text-center;
|
||||
}
|
||||
|
||||
.music-lrc-text {
|
||||
@apply text-center;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.word-by-word-lyric {
|
||||
@apply justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
&.hide {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.music-content-time {
|
||||
display: none;
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
.music-lrc {
|
||||
@apply w-full h-full bg-transparent;
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black 15%,
|
||||
black 85%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black 15%,
|
||||
black 85%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
.music-lrc-container {
|
||||
padding-top: 30vh;
|
||||
.music-lrc-text:last-child {
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
}
|
||||
.music-info-header {
|
||||
@apply mb-8;
|
||||
|
||||
.music-lrc {
|
||||
background-color: inherit;
|
||||
width: 500px;
|
||||
height: 550px;
|
||||
position: relative;
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black 10%,
|
||||
black 90%,
|
||||
transparent 100%
|
||||
);
|
||||
.music-info-name {
|
||||
@apply text-4xl font-bold mb-2 line-clamp-2;
|
||||
color: var(--text-color-active);
|
||||
}
|
||||
|
||||
.music-info-header {
|
||||
@apply mb-8;
|
||||
|
||||
.music-info-name {
|
||||
@apply text-4xl font-bold mb-2 line-clamp-2;
|
||||
color: var(--text-color-active);
|
||||
}
|
||||
|
||||
.music-info-singer {
|
||||
@apply text-base;
|
||||
color: var(--text-color-primary);
|
||||
.music-info-singer {
|
||||
@apply text-xl opacity-80;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-text {
|
||||
@apply text-2xl cursor-pointer font-bold px-2 py-4;
|
||||
.music-lrc-container {
|
||||
padding: 50vh 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.music-lrc-text {
|
||||
@apply text-2xl cursor-pointer font-bold px-4 py-3;
|
||||
font-family: var(--current-font-family);
|
||||
font-weight: var(--lyric-font-weight, bold) !important;
|
||||
transition: all 0.3s ease;
|
||||
background-color: transparent;
|
||||
font-size: var(--lyric-font-size, 22px) !important;
|
||||
letter-spacing: var(--lyric-letter-spacing, 0) !important;
|
||||
line-height: var(--lyric-line-height, 2) !important;
|
||||
opacity: 0.6;
|
||||
transform-origin: left center;
|
||||
|
||||
&.now-text {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.no-scroll-tip {
|
||||
@apply text-base opacity-60 cursor-default py-2;
|
||||
@@ -819,7 +940,11 @@ defineExpose({
|
||||
|
||||
.mobile {
|
||||
#drawer-target {
|
||||
@apply flex-col p-4 pt-8 justify-start;
|
||||
@apply p-4 pt-8;
|
||||
|
||||
.content-wrapper {
|
||||
@apply flex-col justify-start p-0;
|
||||
}
|
||||
|
||||
.music-img {
|
||||
display: none;
|
||||
@@ -856,21 +981,13 @@ defineExpose({
|
||||
}
|
||||
|
||||
// 添加全局字体样式
|
||||
// 字体设置已移至上方或不再需要单独的 drawer-target 块
|
||||
:root {
|
||||
--current-font-family:
|
||||
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
#drawer-target {
|
||||
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full py-8;
|
||||
animation-duration: 300ms;
|
||||
|
||||
.music-lrc-text {
|
||||
font-family: var(--current-font-family);
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.3s ease;
|
||||
@@ -880,20 +997,19 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons-container {
|
||||
@apply flex justify-between items-start z-[9999];
|
||||
|
||||
.control-left,
|
||||
.control-right {
|
||||
&.pure-mode {
|
||||
@apply pointer-events-auto; /* 容器需要能接收hover事件 */
|
||||
@apply pointer-events-auto;
|
||||
|
||||
.control-btn {
|
||||
@apply opacity-0 transition-all duration-300;
|
||||
pointer-events: none; /* 按钮隐藏时不接收事件 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover .control-btn {
|
||||
@apply opacity-100;
|
||||
pointer-events: auto; /* hover时按钮可以点击 */
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,6 +1018,10 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.control-right {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
@apply w-9 h-9 flex items-center justify-center rounded cursor-pointer transition-all duration-300;
|
||||
background: rgba(142, 142, 142, 0.192);
|
||||
|
||||
@@ -21,13 +21,38 @@
|
||||
<i class="ri-loader-4-line loading-icon"></i>
|
||||
</div>
|
||||
<div
|
||||
class="control-btn absolute top-5 left-5"
|
||||
class="control-btn absolute left-5"
|
||||
:class="{ 'pure-mode': config.pureModeEnabled }"
|
||||
@click="closeMusicFull"
|
||||
>
|
||||
<i class="ri-arrow-down-s-line"></i>
|
||||
</div>
|
||||
|
||||
<!-- 右上角设置按钮 -->
|
||||
<div
|
||||
class="control-btn absolute right-5 flex items-center gap-2"
|
||||
:class="[
|
||||
{ 'pure-mode': config.pureModeEnabled },
|
||||
hasSleepTimerActive ? '!w-auto !px-2' : ''
|
||||
]"
|
||||
>
|
||||
<!-- 定时器倒计时显示 -->
|
||||
<div
|
||||
v-if="hasSleepTimerActive"
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-full bg-black/30 backdrop-blur-sm text-xs text-white/90"
|
||||
@click="showPlayerSettings = true"
|
||||
>
|
||||
<i class="ri-timer-line text-green-400"></i>
|
||||
<span class="font-medium tabular-nums">{{ sleepTimerDisplayText }}</span>
|
||||
</div>
|
||||
<div @click="showPlayerSettings = true">
|
||||
<i class="ri-more-2-fill"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放设置弹窗 -->
|
||||
<mobile-player-settings v-model:visible="showPlayerSettings" />
|
||||
|
||||
<!-- 全屏歌词页面 - 竖屏模式下 -->
|
||||
<transition name="fade">
|
||||
<div v-if="showFullLyrics && !isLandscape" class="fullscreen-lyrics" :class="config.theme">
|
||||
@@ -368,6 +393,7 @@ import { useWindowSize } from '@vueuse/core';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MobilePlayerSettings from '@/components/player/MobilePlayerSettings.vue';
|
||||
import {
|
||||
allTime,
|
||||
artistList,
|
||||
@@ -396,6 +422,55 @@ const playerStore = usePlayerStore();
|
||||
const play = computed(() => playerStore.isPlay);
|
||||
const playIcon = computed(() => (play.value ? 'ri-pause-fill' : 'ri-play-fill'));
|
||||
|
||||
// 播放设置弹窗
|
||||
const showPlayerSettings = ref(false);
|
||||
|
||||
// 定时器相关
|
||||
const sleepTimerRefresh = ref(0);
|
||||
let sleepTimerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const hasSleepTimerActive = computed(() => playerStore.hasSleepTimerActive);
|
||||
|
||||
const sleepTimerDisplayText = computed(() => {
|
||||
void sleepTimerRefresh.value; // 触发响应式更新
|
||||
|
||||
const timer = playerStore.sleepTimer;
|
||||
if (timer.type === 'time' && timer.endTime) {
|
||||
const remaining = Math.max(0, timer.endTime - Date.now());
|
||||
const totalSeconds = Math.floor(remaining / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
if (timer.type === 'songs' && timer.remainingSongs) {
|
||||
return `${timer.remainingSongs}首`;
|
||||
}
|
||||
if (timer.type === 'end') {
|
||||
return '列表结束';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// 启动/停止定时器刷新
|
||||
watch(
|
||||
hasSleepTimerActive,
|
||||
(active) => {
|
||||
if (active && playerStore.sleepTimer.type === 'time') {
|
||||
if (!sleepTimerInterval) {
|
||||
sleepTimerInterval = setInterval(() => {
|
||||
sleepTimerRefresh.value = Date.now();
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
if (sleepTimerInterval) {
|
||||
clearInterval(sleepTimerInterval);
|
||||
sleepTimerInterval = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 播放模式
|
||||
const { playMode, playModeIcon, playModeText, togglePlayMode: togglePlayModeBase } = usePlayMode();
|
||||
// 打开播放列表
|
||||
@@ -443,9 +518,11 @@ watch(isLandscape, (newVal) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 显示全屏歌词
|
||||
// 显示全屏歌词
|
||||
const showFullLyricScreen = () => {
|
||||
showFullLyrics.value = true;
|
||||
|
||||
// 使用多次延迟尝试滚动,确保能够滚动到当前歌词
|
||||
nextTick(() => {
|
||||
scrollToCurrentLyric(true);
|
||||
@@ -902,14 +979,19 @@ const setTextColors = (background: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听背景变化
|
||||
const targetBackground = computed(() => {
|
||||
if (config.value.theme !== 'default') {
|
||||
return themeMusic[config.value.theme] || props.background;
|
||||
}
|
||||
return props.background;
|
||||
});
|
||||
|
||||
// 监听目标背景变化并更新文字颜色
|
||||
watch(
|
||||
() => props.background,
|
||||
targetBackground,
|
||||
(newBg) => {
|
||||
if (config.value.theme === 'default') {
|
||||
if (newBg) {
|
||||
setTextColors(newBg);
|
||||
} else {
|
||||
setTextColors(themeMusic[config.value.theme] || props.background);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -963,16 +1045,6 @@ const closeMusicFull = () => {
|
||||
playerStore.setMusicFull(false);
|
||||
};
|
||||
|
||||
// 监听主题变化
|
||||
watch(
|
||||
() => config.value.theme,
|
||||
(newTheme) => {
|
||||
const newBackground = themeMusic[newTheme] || props.background;
|
||||
setTextColors(newBackground);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 添加对 playMusic.id 的监听,歌曲切换时滚动到顶部
|
||||
watch(
|
||||
() => playMusic.value.id,
|
||||
@@ -1039,7 +1111,9 @@ onMounted(() => {
|
||||
watch(isVisible, (newVal) => {
|
||||
if (newVal) {
|
||||
// 播放器显示时,重新设置背景颜色
|
||||
setTextColors(props.background);
|
||||
if (targetBackground.value) {
|
||||
setTextColors(targetBackground.value);
|
||||
}
|
||||
} else {
|
||||
showFullLyrics.value = false;
|
||||
if (autoScrollTimer.value) {
|
||||
@@ -1754,9 +1828,8 @@ const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => {
|
||||
}
|
||||
|
||||
.fullscreen-header {
|
||||
@apply pt-8 pb-4 px-6 flex flex-col items-center fixed top-0 left-0 w-full z-10;
|
||||
@apply pt-16 pb-4 px-6 flex flex-col items-center fixed top-0 left-0 w-full z-10;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
height: 100px;
|
||||
pointer-events: auto;
|
||||
|
||||
.song-title {
|
||||
@@ -1817,6 +1890,7 @@ const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => {
|
||||
@apply w-9 h-9 flex items-center justify-center rounded cursor-pointer transition-all duration-300 z-[9999];
|
||||
background: rgba(142, 142, 142, 0.192);
|
||||
backdrop-filter: blur(12px);
|
||||
top: calc(var(--safe-area-inset-top, 0) + 20px);
|
||||
|
||||
i {
|
||||
@apply text-xl;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
ref="playBarRef"
|
||||
class="mobile-play-bar"
|
||||
:class="[
|
||||
setAnimationClass('animate__fadeInUp'),
|
||||
musicFullVisible ? 'play-bar-expanded' : 'play-bar-mini',
|
||||
!shouldShowMobileMenu ? 'mobile-play-bar-no-menu' : ''
|
||||
playerStore.musicFull ? 'play-bar-expanded' : 'play-bar-mini',
|
||||
shouldShowMobileMenu ? 'is-menu-show' : 'is-menu-hide'
|
||||
]"
|
||||
:style="{
|
||||
color: musicFullVisible
|
||||
color: playerStore.musicFull
|
||||
? textColors.theme === 'dark'
|
||||
? '#ffffff'
|
||||
: '#ffffff'
|
||||
@@ -16,63 +17,8 @@
|
||||
: '#000000'
|
||||
}"
|
||||
>
|
||||
<!-- 完整模式 - 在musicFullVisible为true时显示 -->
|
||||
<template v-if="false">
|
||||
<!-- 顶部信息区域 -->
|
||||
<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>
|
||||
<div class="control-btn list" @click="openPlayListDrawer">
|
||||
<i class="iconfont ri-menu-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 定时关闭按钮 -->
|
||||
<!-- <SleepTimerPopover mode="mobile" /> -->
|
||||
</template>
|
||||
|
||||
<!-- Mini模式 - 在musicFullVisible为false时显示 -->
|
||||
<div v-if="!musicFullVisible" class="mobile-mini-controls">
|
||||
<div v-if="!playerStore.musicFull" class="mobile-mini-controls">
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="mini-song-info" @click="setMusicFull">
|
||||
<n-image
|
||||
@@ -85,16 +31,17 @@
|
||||
<n-ellipsis line-clamp="1">
|
||||
<span class="mini-song-title">{{ playMusic.name }}</span>
|
||||
<span class="mx-2 text-gray-500 dark:text-gray-400">-</span>
|
||||
<span class="mini-song-artist">
|
||||
<span v-for="(artists, artistsindex) in artistList" :key="artistsindex">
|
||||
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
<span
|
||||
class="mini-song-artist"
|
||||
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>
|
||||
@@ -104,21 +51,26 @@
|
||||
</div>
|
||||
|
||||
<!-- 全屏播放器 -->
|
||||
<music-full-wrapper ref="MusicFullRef" v-model="musicFullVisible" :background="background" />
|
||||
<music-full-wrapper
|
||||
ref="MusicFullRef"
|
||||
v-model="playerStore.musicFull"
|
||||
:background="background"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useSwipe } from '@vueuse/core';
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import MusicFullWrapper from '@/components/lyric/MusicFullWrapper.vue';
|
||||
import { allTime, artistList, nowTime, playMusic, sound, textColors } from '@/hooks/MusicHook';
|
||||
import { artistList, playMusic, textColors } from '@/hooks/MusicHook';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
|
||||
import { getImgUrl, setAnimationClass } from '@/utils';
|
||||
|
||||
const shouldShowMobileMenu = inject('shouldShowMobileMenu');
|
||||
const shouldShowMobileMenu = inject('shouldShowMobileMenu') as Ref<boolean>;
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -128,18 +80,6 @@ const play = computed(() => playerStore.isPlay);
|
||||
// 背景颜色
|
||||
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();
|
||||
@@ -151,36 +91,27 @@ function handlePrev() {
|
||||
|
||||
// 全屏播放器
|
||||
const MusicFullRef = ref<any>(null);
|
||||
const musicFullVisible = ref(false);
|
||||
|
||||
// 设置musicFull
|
||||
const setMusicFull = () => {
|
||||
musicFullVisible.value = !musicFullVisible.value;
|
||||
playerStore.setMusicFull(musicFullVisible.value);
|
||||
if (musicFullVisible.value) {
|
||||
playerStore.setMusicFull(!playerStore.musicFull);
|
||||
if (playerStore.musicFull) {
|
||||
settingsStore.showArtistDrawer = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => playerStore.musicFull,
|
||||
(_newVal) => {
|
||||
// 状态栏样式更新已在 Web 环境下禁用
|
||||
}
|
||||
);
|
||||
|
||||
// 打开播放列表抽屉
|
||||
const openPlayListDrawer = () => {
|
||||
playerStore.setPlayListDrawerVisible(true);
|
||||
};
|
||||
|
||||
// 收藏功能
|
||||
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 {
|
||||
@@ -191,6 +122,20 @@ const playMusicEvent = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 滑动切歌
|
||||
const playBarRef = ref<HTMLElement | null>(null);
|
||||
onMounted(() => {
|
||||
if (playBarRef.value) {
|
||||
const { direction } = useSwipe(playBarRef, {
|
||||
onSwipeEnd: () => {
|
||||
if (direction.value === 'left') handleNext();
|
||||
if (direction.value === 'right') handlePrev();
|
||||
},
|
||||
threshold: 30
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => playerStore.playMusic,
|
||||
async () => {
|
||||
@@ -207,8 +152,11 @@ watch(
|
||||
animation-duration: 0.3s !important;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.mobile-play-bar-no-menu {
|
||||
@apply bottom-[10px];
|
||||
&.is-menu-show {
|
||||
bottom: calc(var(--safe-area-inset-bottom, 0) + 66px);
|
||||
}
|
||||
&.is-menu-hide {
|
||||
bottom: calc(var(--safe-area-inset-bottom, 0) + 10px);
|
||||
}
|
||||
|
||||
&.play-bar-expanded {
|
||||
@@ -222,66 +170,12 @@ watch(
|
||||
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;
|
||||
}
|
||||
|
||||
// 顶部信息区域
|
||||
.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;
|
||||
@@ -388,7 +282,7 @@ watch(
|
||||
}
|
||||
|
||||
.mini-song-text {
|
||||
@apply ml-3 min-w-0 flex-1;
|
||||
@apply ml-3 min-w-0 flex-1 flex items-center;
|
||||
|
||||
.mini-song-title {
|
||||
@apply text-sm font-medium;
|
||||
|
||||
363
src/renderer/components/player/MobilePlayerSettings.vue
Normal file
363
src/renderer/components/player/MobilePlayerSettings.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="settings-drawer">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[99999] flex items-end justify-center"
|
||||
@click.self="close"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div class="absolute inset-0 bg-black/50" @click="close"></div>
|
||||
|
||||
<!-- 弹窗内容 - 磨砂玻璃效果 -->
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-gray-900/70 backdrop-blur-2xl rounded-t-3xl overflow-hidden max-h-[85vh] flex flex-col border-t border-white/10 shadow-2xl"
|
||||
>
|
||||
<!-- 顶部拖拽条 -->
|
||||
<div class="flex justify-center pt-3 pb-2 flex-shrink-0">
|
||||
<div class="w-10 h-1 rounded-full bg-white/30"></div>
|
||||
</div>
|
||||
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between px-5 pb-4 flex-shrink-0">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
{{ t('player.settings.title') }}
|
||||
</h2>
|
||||
<button
|
||||
@click="close"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-white/60 hover:bg-white/10"
|
||||
>
|
||||
<i class="ri-close-line text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-5 pb-6"
|
||||
:style="{ paddingBottom: `calc(24px + var(--safe-area-inset-bottom, 0px))` }"
|
||||
>
|
||||
<!-- 播放速度 -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-medium text-white/80">
|
||||
{{ t('player.settings.playbackSpeed') }}
|
||||
</span>
|
||||
<span class="text-sm text-green-400 font-medium">{{ playbackRate }}x</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in speedOptions"
|
||||
:key="option"
|
||||
@click="setSpeed(option)"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
:class="
|
||||
playbackRate === option
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-white/10 text-white/70 hover:bg-white/15'
|
||||
"
|
||||
>
|
||||
{{ option }}x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-px bg-white/10 my-5"></div>
|
||||
|
||||
<!-- 定时关闭 -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-medium text-white/80">
|
||||
{{ t('player.sleepTimer.title') }}
|
||||
</span>
|
||||
<span v-if="hasTimerActive" class="text-sm text-green-400 font-medium">
|
||||
{{ timerStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 已激活状态 -->
|
||||
<div v-if="hasTimerActive" class="space-y-3">
|
||||
<div class="p-4 rounded-2xl bg-green-500/15 border border-green-500/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="ri-timer-line text-green-400 text-xl"></i>
|
||||
<span class="text-green-400">
|
||||
{{ timerDisplayText }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="cancelTimer"
|
||||
class="px-3 py-1 rounded-full text-sm bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
>
|
||||
{{ t('player.sleepTimer.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未激活状态 - 设置选项 -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 按时间 -->
|
||||
<div>
|
||||
<p class="text-xs text-white/50 mb-2">
|
||||
{{ t('player.sleepTimer.timeMode') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="minutes in [15, 30, 60, 90]"
|
||||
:key="minutes"
|
||||
@click="setTimeTimer(minutes)"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium bg-white/10 text-white/70 hover:bg-white/15"
|
||||
>
|
||||
{{ minutes }}{{ t('player.sleepTimer.minutes') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 自定义时间 -->
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<div class="flex items-center flex-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<button
|
||||
@click="decreaseMinutes"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/70 hover:bg-white/10 active:bg-white/20"
|
||||
>
|
||||
<i class="ri-subtract-line text-lg"></i>
|
||||
</button>
|
||||
<input
|
||||
v-model="customMinutes"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="分钟"
|
||||
class="flex-1 px-2 py-2 text-sm text-center bg-transparent text-white/80 border-0 outline-none placeholder-white/40"
|
||||
@input="handleMinutesInput"
|
||||
/>
|
||||
<button
|
||||
@click="increaseMinutes"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/70 hover:bg-white/10 active:bg-white/20"
|
||||
>
|
||||
<i class="ri-add-line text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="setCustomTimeTimer"
|
||||
:disabled="!customMinutes || Number(customMinutes) < 1"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium bg-green-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ t('player.sleepTimer.set') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按歌曲数 -->
|
||||
<div>
|
||||
<p class="text-xs text-white/50 mb-2">
|
||||
{{ t('player.sleepTimer.songsMode') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="songs in [1, 3, 5, 10]"
|
||||
:key="songs"
|
||||
@click="setSongsTimer(songs)"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium bg-white/10 text-white/70 hover:bg-white/15"
|
||||
>
|
||||
{{ songs }}{{ t('player.sleepTimer.songs') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放列表结束 -->
|
||||
<button
|
||||
@click="setPlaylistEndTimer"
|
||||
class="w-full py-3 rounded-2xl text-sm font-medium bg-white/10 text-white/70 hover:bg-white/15"
|
||||
>
|
||||
{{ t('player.sleepTimer.playlistEnd') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
|
||||
const { t } = useI18n();
|
||||
const playerStore = usePlayerStore();
|
||||
const { sleepTimer, playbackRate } = storeToRefs(playerStore);
|
||||
|
||||
// Props & Emits
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
}>();
|
||||
|
||||
// 播放速度选项
|
||||
const speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
// 自定义时间
|
||||
const customMinutes = ref<number | string>(30);
|
||||
|
||||
// 定时器相关
|
||||
const refreshTrigger = ref(0);
|
||||
let timerInterval: number | null = null;
|
||||
|
||||
const hasTimerActive = computed(() => playerStore.hasSleepTimerActive);
|
||||
|
||||
const timerStatusText = computed(() => {
|
||||
if (sleepTimer.value.type === 'time') return t('player.sleepTimer.activeTime');
|
||||
if (sleepTimer.value.type === 'songs') return t('player.sleepTimer.activeSongs');
|
||||
if (sleepTimer.value.type === 'end') return t('player.sleepTimer.activeEnd');
|
||||
return '';
|
||||
});
|
||||
|
||||
const timerDisplayText = computed(() => {
|
||||
void refreshTrigger.value;
|
||||
|
||||
if (sleepTimer.value.type === 'time' && sleepTimer.value.endTime) {
|
||||
const remaining = Math.max(0, sleepTimer.value.endTime - Date.now());
|
||||
const totalSeconds = Math.floor(remaining / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
if (sleepTimer.value.type === 'songs') {
|
||||
return t('player.sleepTimer.songsRemaining', { count: sleepTimer.value.remainingSongs || 0 });
|
||||
}
|
||||
|
||||
if (sleepTimer.value.type === 'end') {
|
||||
return t('player.sleepTimer.afterPlaylist');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
// 方法
|
||||
const close = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const setSpeed = (speed: number) => {
|
||||
playerStore.setPlaybackRate(speed);
|
||||
};
|
||||
|
||||
const setTimeTimer = (minutes: number) => {
|
||||
playerStore.setSleepTimerByTime(minutes);
|
||||
};
|
||||
|
||||
const setCustomTimeTimer = () => {
|
||||
const minutes =
|
||||
typeof customMinutes.value === 'number'
|
||||
? customMinutes.value
|
||||
: parseInt(String(customMinutes.value) || '0', 10);
|
||||
if (minutes >= 1) {
|
||||
playerStore.setSleepTimerByTime(minutes);
|
||||
customMinutes.value = 30;
|
||||
}
|
||||
};
|
||||
|
||||
const increaseMinutes = () => {
|
||||
const current = Number(customMinutes.value) || 0;
|
||||
customMinutes.value = Math.min(300, current + 1);
|
||||
};
|
||||
|
||||
const decreaseMinutes = () => {
|
||||
const current = Number(customMinutes.value) || 0;
|
||||
customMinutes.value = Math.max(1, current - 1);
|
||||
};
|
||||
|
||||
const handleMinutesInput = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const value = input.value.replace(/[^0-9]/g, '');
|
||||
if (value) {
|
||||
customMinutes.value = Math.min(300, Math.max(1, parseInt(value, 10)));
|
||||
} else {
|
||||
customMinutes.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const setSongsTimer = (songs: number) => {
|
||||
playerStore.setSleepTimerBySongs(songs);
|
||||
};
|
||||
|
||||
const setPlaylistEndTimer = () => {
|
||||
playerStore.setSleepTimerAtPlaylistEnd();
|
||||
};
|
||||
|
||||
const cancelTimer = () => {
|
||||
playerStore.clearSleepTimer();
|
||||
};
|
||||
|
||||
// 定时刷新倒计时
|
||||
const startTimerUpdate = () => {
|
||||
if (timerInterval) return;
|
||||
timerInterval = window.setInterval(() => {
|
||||
refreshTrigger.value = Date.now();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const stopTimerUpdate = () => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [hasTimerActive.value, sleepTimer.value.type],
|
||||
([active, type]) => {
|
||||
if (active && type === 'time') {
|
||||
startTimerUpdate();
|
||||
} else {
|
||||
stopTimerUpdate();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (hasTimerActive.value && sleepTimer.value.type === 'time') {
|
||||
startTimerUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimerUpdate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 弹窗动画 */
|
||||
.settings-drawer-enter-active,
|
||||
.settings-drawer-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.settings-drawer-enter-active > div:last-child,
|
||||
.settings-drawer-leave-active > div:last-child {
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.settings-drawer-enter-from,
|
||||
.settings-drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-drawer-enter-from > div:last-child,
|
||||
.settings-drawer-leave-to > div:last-child {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -76,12 +76,12 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { CacheManager } from '@/api/musicParser';
|
||||
import { playMusic } from '@/hooks/MusicHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import type { Platform } from '@/types/music';
|
||||
|
||||
@@ -96,7 +96,7 @@ const currentReparsingSource = ref<Platform | null>(null);
|
||||
// 实际存储选中音源的值
|
||||
const selectedSourcesValue = ref<Platform[]>([]);
|
||||
|
||||
const isReparse = ref(localStorage.getItem(`song_source_${String(playMusic.value.id)}`) !== null);
|
||||
const isReparse = computed(() => selectedSourcesValue.value.length > 0);
|
||||
|
||||
// 可选音源列表
|
||||
const musicSourceOptions = ref([
|
||||
@@ -121,7 +121,9 @@ const getSourceIcon = (source: Platform) => {
|
||||
joox: 'ri-disc-fill',
|
||||
pyncmd: 'ri-netease-cloud-music-fill',
|
||||
bilibili: 'ri-bilibili-fill',
|
||||
gdmusic: 'ri-google-fill'
|
||||
gdmusic: 'ri-google-fill',
|
||||
kuwo: 'ri-music-fill',
|
||||
lxMusic: 'ri-leaf-fill'
|
||||
};
|
||||
|
||||
return iconMap[source] || 'ri-music-2-fill';
|
||||
@@ -129,16 +131,11 @@ const getSourceIcon = (source: Platform) => {
|
||||
|
||||
// 初始化选中的音源
|
||||
const initSelectedSources = () => {
|
||||
const songId = String(playMusic.value.id);
|
||||
const savedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
const songId = playMusic.value.id;
|
||||
const config = SongSourceConfigManager.getConfig(songId);
|
||||
|
||||
if (savedSource) {
|
||||
try {
|
||||
selectedSourcesValue.value = JSON.parse(savedSource);
|
||||
} catch (e) {
|
||||
console.error('解析保存的音源设置失败:', e);
|
||||
selectedSourcesValue.value = [];
|
||||
}
|
||||
if (config) {
|
||||
selectedSourcesValue.value = config.sources;
|
||||
} else {
|
||||
selectedSourcesValue.value = [];
|
||||
}
|
||||
@@ -146,9 +143,8 @@ const initSelectedSources = () => {
|
||||
|
||||
// 清除自定义音源
|
||||
const clearCustomSource = () => {
|
||||
localStorage.removeItem(`song_source_${String(playMusic.value.id)}`);
|
||||
SongSourceConfigManager.clearConfig(playMusic.value.id);
|
||||
selectedSourcesValue.value = [];
|
||||
isReparse.value = false;
|
||||
};
|
||||
|
||||
// 直接重新解析当前歌曲
|
||||
@@ -168,13 +164,10 @@ const directReparseMusic = async (source: Platform) => {
|
||||
// 更新选中的音源值为当前点击的音源
|
||||
selectedSourcesValue.value = [source];
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem(
|
||||
`song_source_${String(songId)}`,
|
||||
JSON.stringify(selectedSourcesValue.value)
|
||||
);
|
||||
// 使用 SongSourceConfigManager 保存配置(手动选择)
|
||||
SongSourceConfigManager.setConfig(songId, [source], 'manual');
|
||||
|
||||
const success = await playerStore.reparseCurrentSong(source);
|
||||
const success = await playerStore.reparseCurrentSong(source, false);
|
||||
|
||||
if (success) {
|
||||
message.success(t('player.reparse.success'));
|
||||
@@ -200,46 +193,6 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听歌曲变化,检查是否有自定义音源
|
||||
watch(
|
||||
() => playMusic.value.id,
|
||||
async (newId) => {
|
||||
if (newId) {
|
||||
const songId = String(newId);
|
||||
const savedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
|
||||
// 如果有保存的音源设置但当前不是使用自定义解析的播放,尝试应用
|
||||
if (savedSource && playMusic.value.source !== 'bilibili') {
|
||||
try {
|
||||
const sources = JSON.parse(savedSource) as Platform[];
|
||||
console.log(`检测到歌曲ID ${songId} 有自定义音源设置:`, sources);
|
||||
|
||||
// 当URL加载失败或过期时,自动应用自定义音源重新加载
|
||||
audioService.on('url_expired', async (trackInfo) => {
|
||||
if (trackInfo && trackInfo.id === playMusic.value.id) {
|
||||
console.log('URL已过期,自动应用自定义音源重新加载');
|
||||
try {
|
||||
isReparsing.value = true;
|
||||
const success = await playerStore.reparseCurrentSong(sources[0]);
|
||||
if (!success) {
|
||||
message.error(t('player.reparse.failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('自动重新解析失败:', e);
|
||||
message.error(t('player.reparse.failed'));
|
||||
} finally {
|
||||
isReparsing.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('解析保存的音源设置失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
<!-- 顶部进度条和时间 -->
|
||||
<div class="top-section">
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-bar" @click="handleProgressClick">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="{ 'is-dragging': isDragging }"
|
||||
@mousedown="handleProgressMouseDown"
|
||||
@click.stop="handleProgressClick"
|
||||
>
|
||||
<div class="progress-track"></div>
|
||||
<div class="progress-fill" :style="{ width: `${(nowTime / allTime) * 100}%` }"></div>
|
||||
<div class="progress-fill" :style="{ width: `${progressPercentage}%` }"></div>
|
||||
</div>
|
||||
|
||||
<!-- 时间显示 -->
|
||||
<div class="time-display">
|
||||
<span class="current-time">{{ formatTime(nowTime) }}</span>
|
||||
<span class="current-time">{{ formatTime(displayTime) }}</span>
|
||||
<span class="total-time">{{ formatTime(allTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,11 +155,81 @@ const playMusicEvent = async () => {
|
||||
};
|
||||
|
||||
// 进度条控制
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0); // 拖拽时的预览进度 (0-100)
|
||||
|
||||
// 计算当前显示的进度百分比
|
||||
const progressPercentage = computed(() => {
|
||||
if (isDragging.value) {
|
||||
return dragProgress.value;
|
||||
}
|
||||
if (allTime.value === 0) return 0;
|
||||
return (nowTime.value / allTime.value) * 100;
|
||||
});
|
||||
|
||||
// 计算显示的时间
|
||||
const displayTime = computed(() => {
|
||||
if (isDragging.value) {
|
||||
return (dragProgress.value / 100) * allTime.value;
|
||||
}
|
||||
return nowTime.value;
|
||||
});
|
||||
|
||||
// 计算进度百分比的辅助函数
|
||||
const calculateProgress = (clientX: number, element: HTMLElement): number => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return percent * 100;
|
||||
};
|
||||
|
||||
// 更新音频进度
|
||||
const seekToProgress = (percentage: number) => {
|
||||
const targetTime = (percentage / 100) * allTime.value;
|
||||
audioService.seek(targetTime);
|
||||
// 不立即更新 nowTime,让音频服务的回调来更新,避免不同步
|
||||
};
|
||||
|
||||
// 鼠标按下开始拖拽
|
||||
const handleProgressMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return; // 只响应左键
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
isDragging.value = true;
|
||||
dragProgress.value = calculateProgress(e.clientX, target);
|
||||
|
||||
// 添加全局鼠标移动和释放监听
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (isDragging.value) {
|
||||
dragProgress.value = calculateProgress(moveEvent.clientX, target);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging.value) {
|
||||
// 拖拽结束,执行跳转
|
||||
seekToProgress(dragProgress.value);
|
||||
isDragging.value = false;
|
||||
}
|
||||
// 移除事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// 防止文本选择
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// 点击进度条跳转
|
||||
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;
|
||||
// 如果正在拖拽,不处理点击事件
|
||||
if (isDragging.value) return;
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const percentage = calculateProgress(e.clientX, target);
|
||||
seekToProgress(percentage);
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
@@ -348,6 +423,7 @@ onMounted(() => {
|
||||
|
||||
.progress-bar {
|
||||
@apply relative cursor-pointer h-2 mb-2 w-full;
|
||||
user-select: none;
|
||||
|
||||
.progress-track {
|
||||
@apply absolute inset-0 rounded-full transition-all duration-150;
|
||||
@@ -364,10 +440,6 @@ onMounted(() => {
|
||||
.progress-track {
|
||||
background-color: var(--track-color-hover);
|
||||
}
|
||||
.progress-track,
|
||||
.progress-fill {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
box-shadow: 0 0 12px var(--fill-color-transparent);
|
||||
|
||||
@@ -1,86 +1,354 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="visible"
|
||||
preset="dialog"
|
||||
<ResponsiveModal
|
||||
v-model="visible"
|
||||
:title="t('settings.playback.musicSources')"
|
||||
:positive-text="t('common.confirm')"
|
||||
:negative-text="t('common.cancel')"
|
||||
@positive-click="handleConfirm"
|
||||
@negative-click="handleCancel"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>{{ t('settings.playback.musicSourcesDesc') }}</p>
|
||||
|
||||
<n-checkbox-group v-model:value="selectedSources">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="8">
|
||||
<!-- 遍历常规音源 -->
|
||||
<n-grid-item v-for="source in regularMusicSources" :key="source.value">
|
||||
<n-checkbox :value="source.value">
|
||||
{{ t('settings.playback.sourceLabels.' + source.value) }}
|
||||
<n-tooltip v-if="source.value === 'gdmusic'">
|
||||
<template #trigger>
|
||||
<n-icon size="16" class="ml-1 text-blue-500 cursor-help">
|
||||
<i class="ri-information-line"></i>
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('settings.playback.gdmusicInfo') }}
|
||||
</n-tooltip>
|
||||
</n-checkbox>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 单独处理自定义API选项 -->
|
||||
<n-grid-item>
|
||||
<n-checkbox value="custom" :disabled="!settingsStore.setData.customApiPlugin">
|
||||
{{ t('settings.playback.sourceLabels.custom') }}
|
||||
<n-tooltip v-if="!settingsStore.setData.customApiPlugin">
|
||||
<template #trigger>
|
||||
<n-icon size="16" class="ml-1 text-gray-400 cursor-help">
|
||||
<i class="ri-question-line"></i>
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('settings.playback.customApi.enableHint') }}
|
||||
</n-tooltip>
|
||||
</n-checkbox>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-checkbox-group>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="mt-4 border-t pt-4 border-gray-200 dark:border-gray-700"></div>
|
||||
|
||||
<!-- 自定义API导入区域 -->
|
||||
<div>
|
||||
<h3 class="text-base font-medium mb-2">
|
||||
{{ t('settings.playback.customApi.sectionTitle') }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<n-button @click="importPlugin" size="small">{{
|
||||
t('settings.playback.customApi.importConfig')
|
||||
}}</n-button>
|
||||
<p v-if="settingsStore.setData.customApiPluginName" class="text-sm">
|
||||
{{ t('settings.playback.customApi.currentSource') }}:
|
||||
<span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span>
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-500">
|
||||
{{ t('settings.playback.customApi.notImported') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Tabs Header -->
|
||||
<div class="flex p-0.5 mb-3 bg-gray-100 dark:bg-white/5 rounded-lg shrink-0">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="flex-1 py-1 text-xs font-medium rounded-md transition-all duration-200"
|
||||
:class="[
|
||||
activeTab === tab.key
|
||||
? 'bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="h-[400px] relative shrink-0">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div :key="activeTab" class="h-full overflow-y-auto overscroll-contain">
|
||||
<!-- Sources Tab -->
|
||||
<div v-if="activeTab === 'sources'" class="space-y-3 pb-2">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 px-1">
|
||||
{{ t('settings.playback.musicSourcesDesc') }}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<!-- Standard Sources -->
|
||||
<div
|
||||
v-for="source in MUSIC_SOURCES"
|
||||
:key="source.key"
|
||||
class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
|
||||
:class="[
|
||||
isSourceSelected(source.key)
|
||||
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
|
||||
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
]"
|
||||
@click="toggleSource(source.key)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
|
||||
:style="{
|
||||
backgroundColor: isSourceSelected(source.key) ? source.color : 'transparent',
|
||||
color: isSourceSelected(source.key) ? '#fff' : source.color
|
||||
}"
|
||||
:class="{ 'bg-gray-100 dark:bg-white/10': !isSourceSelected(source.key) }"
|
||||
>
|
||||
<i class="ri-music-2-fill text-base"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-gray-900 dark:text-white text-sm truncate">{{ source.key }}</span>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
|
||||
:class="[
|
||||
isSourceSelected(source.key)
|
||||
? 'bg-emerald-500 border-emerald-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
]"
|
||||
>
|
||||
<i v-if="isSourceSelected(source.key)" class="ri-check-line text-white text-xs scale-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LX Music Source -->
|
||||
<div
|
||||
class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
|
||||
:class="[
|
||||
isSourceSelected('lxMusic')
|
||||
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
|
||||
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10',
|
||||
{ 'opacity-60 cursor-not-allowed': !activeLxApiId || lxMusicApis.length === 0 }
|
||||
]"
|
||||
@click="toggleSource('lxMusic')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
|
||||
:class="[
|
||||
isSourceSelected('lxMusic')
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-white/10 text-emerald-500'
|
||||
]"
|
||||
>
|
||||
<i class="ri-netease-cloud-music-fill text-base"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-gray-900 dark:text-white text-sm truncate">落雪音源</span>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
|
||||
:class="[
|
||||
isSourceSelected('lxMusic')
|
||||
? 'bg-emerald-500 border-emerald-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
]"
|
||||
>
|
||||
<i v-if="isSourceSelected('lxMusic')" class="ri-check-line text-white text-xs scale-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500 mt-0.5 truncate">
|
||||
{{ activeLxApiId && lxMusicScriptInfo ? lxMusicScriptInfo.name : t('settings.playback.lxMusic.scripts.notConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom API Source -->
|
||||
<div
|
||||
class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
|
||||
:class="[
|
||||
isSourceSelected('custom')
|
||||
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
|
||||
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10',
|
||||
{ 'opacity-60 cursor-not-allowed': !settingsStore.setData.customApiPlugin }
|
||||
]"
|
||||
@click="toggleSource('custom')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
|
||||
:class="[
|
||||
isSourceSelected('custom')
|
||||
? 'bg-violet-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-white/10 text-violet-500'
|
||||
]"
|
||||
>
|
||||
<i class="ri-plug-fill text-base"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-gray-900 dark:text-white text-sm truncate">{{ t('settings.playback.sourceLabels.custom') }}</span>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
|
||||
:class="[
|
||||
isSourceSelected('custom')
|
||||
? 'bg-emerald-500 border-emerald-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
]"
|
||||
>
|
||||
<i v-if="isSourceSelected('custom')" class="ri-check-line text-white text-xs scale-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500 mt-0.5 truncate">
|
||||
{{ settingsStore.setData.customApiPlugin ? t('settings.playback.customApi.status.imported') : t('settings.playback.customApi.status.notImported') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LX Music Management Tab -->
|
||||
<div v-else-if="activeTab === 'lxMusic'" class="space-y-3 pb-2">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('settings.playback.lxMusic.scripts.title') }}</h3>
|
||||
<button
|
||||
@click="importLxMusicScript"
|
||||
class="flex items-center gap-1 px-2.5 py-1 bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<i class="ri-upload-line"></i>
|
||||
{{ t('settings.playback.lxMusic.scripts.importLocal') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Script List -->
|
||||
<div v-if="lxMusicApis.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="api in lxMusicApis"
|
||||
:key="api.id"
|
||||
class="flex items-center p-2.5 rounded-xl border transition-all duration-200"
|
||||
:class="[
|
||||
activeLxApiId === api.id
|
||||
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
|
||||
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5'
|
||||
]"
|
||||
>
|
||||
<div class="relative flex items-center justify-center w-4 h-4 mr-3">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="activeLxApiId === api.id"
|
||||
class="peer appearance-none w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600 checked:border-emerald-500 checked:bg-emerald-500 transition-colors cursor-pointer"
|
||||
@change="setActiveLxApi(api.id)"
|
||||
/>
|
||||
<i class="ri-check-line absolute text-white text-[10px] pointer-events-none opacity-0 peer-checked:opacity-100 transition-opacity"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 mr-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="editingScriptId !== api.id" class="font-medium text-sm text-gray-900 dark:text-white truncate">
|
||||
{{ api.name }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
v-model="editingName"
|
||||
ref="renameInputRef"
|
||||
class="w-full px-2 py-0.5 text-sm bg-white dark:bg-black/20 border border-emerald-500 rounded focus:outline-none"
|
||||
@blur="saveScriptName(api.id)"
|
||||
@keyup.enter="saveScriptName(api.id)"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="editingScriptId !== api.id"
|
||||
class="text-gray-400 hover:text-emerald-500 transition-colors"
|
||||
@click="startRenaming(api)"
|
||||
>
|
||||
<i class="ri-edit-line text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span v-if="api.info.version" class="text-[10px] text-gray-500 bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">
|
||||
v{{ api.info.version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
@click="removeLxApi(api.id)"
|
||||
>
|
||||
<i class="ri-delete-bin-line text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-6 text-center text-xs text-gray-400 bg-gray-50 dark:bg-white/5 rounded-xl border border-dashed border-gray-200 dark:border-white/10">
|
||||
<p>{{ t('settings.playback.lxMusic.scripts.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL Import -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-white/5">
|
||||
<h4 class="text-xs font-medium mb-2 text-gray-900 dark:text-white">{{ t('settings.playback.lxMusic.scripts.importOnline') }}</h4>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="lxScriptUrl"
|
||||
:placeholder="t('settings.playback.lxMusic.scripts.urlPlaceholder')"
|
||||
class="flex-1 px-3 py-1.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-xs focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
:disabled="isImportingFromUrl"
|
||||
/>
|
||||
<button
|
||||
@click="importLxMusicScriptFromUrl"
|
||||
class="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded-xl transition-colors flex items-center gap-1"
|
||||
:disabled="!lxScriptUrl.trim() || isImportingFromUrl"
|
||||
>
|
||||
<i v-if="isImportingFromUrl" class="ri-loader-4-line animate-spin"></i>
|
||||
<i v-else class="ri-download-line"></i>
|
||||
{{ t('settings.playback.lxMusic.scripts.importBtn') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom API Tab -->
|
||||
<div v-else-if="activeTab === 'customApi'" class="flex flex-col items-center justify-center py-6 text-center h-full">
|
||||
<div class="w-12 h-12 bg-violet-100 dark:bg-violet-500/20 text-violet-500 rounded-xl flex items-center justify-center mb-3">
|
||||
<i class="ri-plug-fill text-2xl"></i>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{{ t('settings.playback.customApi.sectionTitle') }}
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs mb-4 max-w-xs mx-auto">
|
||||
{{ t('settings.playback.lxMusic.scripts.importHint') }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="importPlugin"
|
||||
class="px-5 py-2 bg-violet-500 hover:bg-violet-600 text-white text-sm font-medium rounded-xl transition-colors flex items-center gap-2 shadow-lg shadow-violet-500/20"
|
||||
>
|
||||
<i class="ri-upload-line"></i>
|
||||
{{ t('settings.playback.customApi.importConfig') }}
|
||||
</button>
|
||||
|
||||
<div v-if="settingsStore.setData.customApiPluginName" class="mt-4 flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg text-xs">
|
||||
<i class="ri-check-circle-fill"></i>
|
||||
<span>{{ t('settings.playback.customApi.currentSource') }}: <b>{{ settingsStore.setData.customApiPluginName }}</b></span>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 text-xs text-gray-400">
|
||||
{{ t('settings.playback.customApi.notImported') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ResponsiveModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import ResponsiveModal from '@/components/common/ResponsiveModal.vue';
|
||||
import {
|
||||
initLxMusicRunner,
|
||||
parseScriptInfo,
|
||||
setLxMusicRunner
|
||||
} from '@/services/LxMusicSourceRunner';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import type { LxMusicScriptConfig, LxScriptInfo, LxSourceKey } from '@/types/lxMusic';
|
||||
import { type Platform } from '@/types/music';
|
||||
|
||||
// 扩展 Platform 类型以包含 'custom'
|
||||
type ExtendedPlatform = Platform | 'custom';
|
||||
// ==================== 类型定义 ====================
|
||||
type ExtendedPlatform = Platform | 'custom' | 'lxMusic';
|
||||
|
||||
interface MusicSourceConfig {
|
||||
key: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 音源配置 ====================
|
||||
const MUSIC_SOURCES: MusicSourceConfig[] = [
|
||||
{ key: 'migu', color: '#ff6600' },
|
||||
{ key: 'kugou', color: '#2979ff' },
|
||||
{ key: 'kuwo', color: '#ff8c00' },
|
||||
{ key: 'pyncmd', color: '#ec4141' },
|
||||
{ key: 'bilibili', color: '#00a1d6' }
|
||||
];
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -88,34 +356,114 @@ const props = defineProps({
|
||||
},
|
||||
sources: {
|
||||
type: Array as () => ExtendedPlatform[],
|
||||
default: () => ['migu', 'kugou', 'pyncmd', 'bilibili']
|
||||
default: () => ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'] as ExtendedPlatform[]
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'update:sources']);
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const message = useMessage();
|
||||
const visible = ref(props.show);
|
||||
const selectedSources = ref<ExtendedPlatform[]>(props.sources);
|
||||
const selectedSources = ref<ExtendedPlatform[]>([...props.sources]);
|
||||
const activeTab = ref('sources');
|
||||
|
||||
// 将常规音源和自定义音源分开定义
|
||||
const regularMusicSources = ref([
|
||||
{ value: 'migu' },
|
||||
{ value: 'kugou' },
|
||||
{ value: 'pyncmd' },
|
||||
{ value: 'bilibili' },
|
||||
{ value: 'gdmusic' }
|
||||
const tabs = computed(() => [
|
||||
{ key: 'sources', label: t('settings.playback.lxMusic.tabs.sources') },
|
||||
{ key: 'lxMusic', label: t('settings.playback.lxMusic.tabs.lxMusic') },
|
||||
{ key: 'customApi', label: t('settings.playback.lxMusic.tabs.customApi') }
|
||||
]);
|
||||
|
||||
// 落雪音源列表(从 store 中的脚本解析)
|
||||
const lxMusicApis = computed<LxMusicScriptConfig[]>(() => {
|
||||
const scripts = settingsStore.setData.lxMusicScripts || [];
|
||||
return scripts;
|
||||
});
|
||||
|
||||
// 当前激活的音源 ID
|
||||
const activeLxApiId = computed<string | null>({
|
||||
get: () => settingsStore.setData.activeLxMusicApiId || null,
|
||||
set: (id: string | null) => {
|
||||
settingsStore.setSetData({ activeLxMusicApiId: id });
|
||||
}
|
||||
});
|
||||
|
||||
// 落雪音源脚本信息(保持向后兼容)
|
||||
const lxMusicScriptInfo = computed<LxScriptInfo | null>(() => {
|
||||
const activeId = activeLxApiId.value;
|
||||
if (!activeId) {
|
||||
return null;
|
||||
}
|
||||
const activeApi = lxMusicApis.value.find((api: LxMusicScriptConfig) => api.id === activeId);
|
||||
return activeApi?.info || null;
|
||||
});
|
||||
|
||||
// URL 导入相关状态
|
||||
const lxScriptUrl = ref('');
|
||||
const isImportingFromUrl = ref(false);
|
||||
|
||||
// 重命名相关状态
|
||||
const editingScriptId = ref<string | null>(null);
|
||||
const editingName = ref('');
|
||||
const renameInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const isSourceSelected = (sourceKey: string): boolean => {
|
||||
return selectedSources.value.includes(sourceKey as ExtendedPlatform);
|
||||
};
|
||||
|
||||
// ==================== 方法 ====================
|
||||
/**
|
||||
* 切换音源选择状态
|
||||
*/
|
||||
const toggleSource = (sourceKey: string) => {
|
||||
// 检查是否是自定义API且未导入
|
||||
if (sourceKey === 'custom' && !settingsStore.setData.customApiPlugin) {
|
||||
message.warning(t('settings.playback.customApi.enableHint'));
|
||||
activeTab.value = 'customApi';
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是落雪音源且未配置
|
||||
if (sourceKey === 'lxMusic') {
|
||||
if (lxMusicApis.value.length === 0) {
|
||||
message.warning(t('settings.playback.lxMusic.scripts.noScriptWarning'));
|
||||
activeTab.value = 'lxMusic';
|
||||
return;
|
||||
}
|
||||
if (!activeLxApiId.value) {
|
||||
message.warning(t('settings.playback.lxMusic.scripts.noSelectionWarning'));
|
||||
activeTab.value = 'lxMusic';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform);
|
||||
if (index > -1) {
|
||||
// 至少保留一个音源
|
||||
if (selectedSources.value.length <= 1) {
|
||||
message.warning(t('settings.playback.musicSourcesMinWarning'));
|
||||
return;
|
||||
}
|
||||
selectedSources.value.splice(index, 1);
|
||||
} else {
|
||||
selectedSources.value.push(sourceKey as ExtendedPlatform);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入自定义API插件
|
||||
*/
|
||||
const importPlugin = async () => {
|
||||
try {
|
||||
const result = await window.api.importCustomApiPlugin();
|
||||
if (result && result.name && result.content) {
|
||||
settingsStore.setCustomApiPlugin(result);
|
||||
message.success(t('settings.playback.customApi.importSuccess', { name: result.name }));
|
||||
// 导入成功后,如果用户还没勾选,则自动勾选上
|
||||
|
||||
// 导入成功后自动勾选
|
||||
if (!selectedSources.value.includes('custom')) {
|
||||
selectedSources.value.push('custom');
|
||||
}
|
||||
@@ -125,10 +473,252 @@ const importPlugin = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听自定义插件内容的变化。如果用户清除了插件,要确保 'custom' 选项被取消勾选
|
||||
/**
|
||||
* 导入落雪音源脚本
|
||||
*/
|
||||
const importLxMusicScript = async () => {
|
||||
try {
|
||||
const result = await window.api.importLxMusicScript();
|
||||
if (result && result.content) {
|
||||
await addLxMusicScript(result.content);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('导入落雪音源脚本失败:', error);
|
||||
message.error(`${t('common.error')}:${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加落雪音源脚本到列表
|
||||
*/
|
||||
const addLxMusicScript = async (scriptContent: string) => {
|
||||
// 解析脚本信息
|
||||
const scriptInfo = parseScriptInfo(scriptContent);
|
||||
|
||||
// 尝试初始化执行器以验证脚本
|
||||
try {
|
||||
const runner = await initLxMusicRunner(scriptContent);
|
||||
const sources = runner.getSources();
|
||||
const sourceKeys = Object.keys(sources) as LxSourceKey[];
|
||||
|
||||
// 生成唯一 ID
|
||||
const id = `lx_api_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 创建新的脚本配置
|
||||
const newApiConfig: LxMusicScriptConfig = {
|
||||
id,
|
||||
name: scriptInfo.name,
|
||||
script: scriptContent,
|
||||
info: scriptInfo,
|
||||
sources: sourceKeys,
|
||||
enabled: true,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
// 添加到列表
|
||||
const scripts = [...(settingsStore.setData.lxMusicScripts || []), newApiConfig];
|
||||
|
||||
settingsStore.setSetData({
|
||||
lxMusicScripts: scripts,
|
||||
activeLxMusicApiId: id // 自动激活新添加的音源
|
||||
});
|
||||
|
||||
message.success(`${t('common.success')}:${scriptInfo.name}`);
|
||||
|
||||
// 导入成功后自动勾选
|
||||
if (!selectedSources.value.includes('lxMusic')) {
|
||||
selectedSources.value.push('lxMusic');
|
||||
}
|
||||
} catch (initError: any) {
|
||||
console.error('[MusicSourceSettings] 落雪音源脚本初始化失败:', initError);
|
||||
message.error(`${t('common.error')}:${initError.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置激活的落雪音源
|
||||
*/
|
||||
const setActiveLxApi = async (apiId: string) => {
|
||||
const api = lxMusicApis.value.find((a: LxMusicScriptConfig) => a.id === apiId);
|
||||
if (!api) {
|
||||
message.error(t('settings.playback.lxMusic.scripts.notFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 清除旧的 runner
|
||||
setLxMusicRunner(null);
|
||||
|
||||
// 初始化新选中的脚本
|
||||
await initLxMusicRunner(api.script);
|
||||
|
||||
// 更新激活的音源 ID
|
||||
activeLxApiId.value = apiId;
|
||||
|
||||
// 确保 lxMusic 在已选音源中
|
||||
if (!selectedSources.value.includes('lxMusic')) {
|
||||
selectedSources.value.push('lxMusic');
|
||||
}
|
||||
|
||||
message.success(t('settings.playback.lxMusic.scripts.switched', { name: api.name }));
|
||||
} catch (error: any) {
|
||||
console.error('[MusicSourceSettings] 切换落雪音源失败:', error);
|
||||
message.error(`${t('common.error')}:${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除落雪音源
|
||||
*/
|
||||
const removeLxApi = (apiId: string) => {
|
||||
const scripts = [...(settingsStore.setData.lxMusicScripts || [])];
|
||||
const index = scripts.findIndex((s) => s.id === apiId);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
const removedScript = scripts[index];
|
||||
scripts.splice(index, 1);
|
||||
|
||||
// 更新 store
|
||||
settingsStore.setSetData({
|
||||
lxMusicScripts: scripts
|
||||
});
|
||||
|
||||
// 如果删除的是当前激活的音源
|
||||
if (activeLxApiId.value === apiId) {
|
||||
// 自动选择下一个可用音源,或者清空
|
||||
if (scripts.length > 0) {
|
||||
setActiveLxApi(scripts[0].id);
|
||||
} else {
|
||||
setLxMusicRunner(null);
|
||||
settingsStore.setSetData({ activeLxMusicApiId: null });
|
||||
// 从已选音源中移除 lxMusic
|
||||
const srcIndex = selectedSources.value.indexOf('lxMusic');
|
||||
if (srcIndex > -1) {
|
||||
selectedSources.value.splice(srcIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message.success(t('settings.playback.lxMusic.scripts.deleted', { name: removedScript.name }));
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 URL 导入落雪音源脚本
|
||||
*/
|
||||
const importLxMusicScriptFromUrl = async () => {
|
||||
const url = lxScriptUrl.value.trim();
|
||||
if (!url) {
|
||||
message.warning(t('settings.playback.lxMusic.scripts.enterUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 URL 格式
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
message.error(t('settings.playback.lxMusic.scripts.invalidUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
isImportingFromUrl.value = true;
|
||||
|
||||
try {
|
||||
// 下载脚本内容
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
// 验证脚本格式 - 检查是否包含 lx-music 脚本的特征
|
||||
// 1. 检查是否有头部注释块(包含 @name、@version 等)
|
||||
const hasHeaderComment = /^\/\*+[\s\S]*?@name[\s\S]*?\*\//.test(content);
|
||||
// 2. 检查是否使用 lx API(lx.on 或 lx.send)
|
||||
const hasLxApi = content.includes('lx.on(') || content.includes('lx.send(');
|
||||
|
||||
if (!hasHeaderComment && !hasLxApi) {
|
||||
throw new Error(t('settings.playback.lxMusic.scripts.invalidScript'));
|
||||
}
|
||||
|
||||
// 使用统一的添加方法
|
||||
await addLxMusicScript(content);
|
||||
|
||||
// 清空 URL 输入框
|
||||
lxScriptUrl.value = '';
|
||||
} catch (error: any) {
|
||||
console.error('从 URL 导入落雪音源脚本失败:', error);
|
||||
message.error(`${t('settings.playback.lxMusic.scripts.importOnline')} ${t('common.error')}:${error.message}`);
|
||||
} finally {
|
||||
isImportingFromUrl.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始重命名
|
||||
*/
|
||||
const startRenaming = (api: LxMusicScriptConfig) => {
|
||||
editingScriptId.value = api.id;
|
||||
editingName.value = api.name;
|
||||
nextTick(() => {
|
||||
renameInputRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存脚本名称
|
||||
*/
|
||||
const saveScriptName = (apiId: string) => {
|
||||
if (!editingName.value.trim()) {
|
||||
message.warning(t('settings.playback.lxMusic.scripts.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const scripts = [...(settingsStore.setData.lxMusicScripts || [])];
|
||||
const index = scripts.findIndex((s) => s.id === apiId);
|
||||
|
||||
if (index > -1) {
|
||||
scripts[index] = {
|
||||
...scripts[index],
|
||||
name: editingName.value.trim()
|
||||
};
|
||||
|
||||
settingsStore.setSetData({
|
||||
lxMusicScripts: scripts
|
||||
});
|
||||
|
||||
message.success(t('settings.playback.lxMusic.scripts.renameSuccess'));
|
||||
}
|
||||
|
||||
editingScriptId.value = null;
|
||||
editingName.value = '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认选择
|
||||
*/
|
||||
const handleConfirm = () => {
|
||||
const defaultPlatforms: ExtendedPlatform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
|
||||
const valuesToEmit =
|
||||
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
|
||||
emit('update:sources', valuesToEmit);
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消选择
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
selectedSources.value = [...props.sources];
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
// 监听自定义插件内容变化
|
||||
watch(
|
||||
() => settingsStore.setData.customApiPlugin,
|
||||
(newPluginContent) => {
|
||||
(newPluginContent: any) => {
|
||||
if (!newPluginContent) {
|
||||
const index = selectedSources.value.indexOf('custom');
|
||||
if (index > -1) {
|
||||
@@ -138,10 +728,25 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// 监听落雪音源列表变化
|
||||
watch(
|
||||
[() => lxMusicApis.value.length, () => activeLxApiId.value],
|
||||
([apiCount, activeId]) => {
|
||||
// 如果没有音源或没有激活的音源,自动从已选音源中移除 lxMusic
|
||||
if (apiCount === 0 || !activeId) {
|
||||
const index = selectedSources.value.indexOf('lxMusic');
|
||||
if (index > -1) {
|
||||
selectedSources.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 同步外部show属性变化
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
(newVal: boolean) => {
|
||||
visible.value = newVal;
|
||||
}
|
||||
);
|
||||
@@ -149,7 +754,7 @@ watch(
|
||||
// 同步内部visible变化
|
||||
watch(
|
||||
() => visible.value,
|
||||
(newVal) => {
|
||||
(newVal: boolean) => {
|
||||
emit('update:show', newVal);
|
||||
}
|
||||
);
|
||||
@@ -157,23 +762,21 @@ watch(
|
||||
// 同步外部sources属性变化
|
||||
watch(
|
||||
() => props.sources,
|
||||
(newVal) => {
|
||||
(newVal: ExtendedPlatform[]) => {
|
||||
selectedSources.value = [...newVal];
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
// 确保至少选择一个音源
|
||||
const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||
const valuesToEmit =
|
||||
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
|
||||
emit('update:sources', valuesToEmit);
|
||||
visible.value = false;
|
||||
};
|
||||
const handleCancel = () => {
|
||||
// 取消时还原为props传入的初始值
|
||||
selectedSources.value = [...props.sources];
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,9 @@ import { cloneDeep } from 'lodash';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { computed, type ComputedRef, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import useIndexedDB from '@/hooks/IndexDBHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import type { usePlayerStore } from '@/store';
|
||||
import { getSongUrl } from '@/store/modules/player';
|
||||
import type { Artist, ILyricText, SongResult } from '@/types/music';
|
||||
import { isElectron } from '@/utils';
|
||||
import { getTextColors } from '@/utils/linearColor';
|
||||
@@ -574,7 +572,12 @@ const setupAudioListeners = () => {
|
||||
};
|
||||
|
||||
export const play = () => {
|
||||
audioService.getCurrentSound()?.play();
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
currentSound.play();
|
||||
// 在播放时也进行状态检测,防止URL已过期导致无声
|
||||
getPlayerStore().checkPlaybackState(getPlayerStore().playMusic);
|
||||
}
|
||||
};
|
||||
|
||||
export const pause = () => {
|
||||
@@ -1042,86 +1045,17 @@ audioService.on('url_expired', async (expiredTrack) => {
|
||||
console.log('检测到URL过期事件,准备重新获取URL', expiredTrack.name);
|
||||
|
||||
try {
|
||||
const currentPosition = nowTime.value; // 保存当前播放进度
|
||||
console.log('保存当前播放进度:', currentPosition);
|
||||
// 使用 handlePlayMusic 重新播放,它会自动处理 URL 获取和状态跟踪
|
||||
// 我们将 isFirstPlay 设为 true 以强制获取新 URL
|
||||
const trackToPlay = {
|
||||
...expiredTrack,
|
||||
isFirstPlay: true,
|
||||
playMusicUrl: undefined
|
||||
};
|
||||
|
||||
// 处理B站视频
|
||||
if (expiredTrack.source === 'bilibili' && expiredTrack.bilibiliData) {
|
||||
console.log('重新获取B站视频URL');
|
||||
try {
|
||||
// 使用API中的函数获取B站音频URL
|
||||
const newUrl = await getBilibiliAudioUrl(
|
||||
expiredTrack.bilibiliData.bvid,
|
||||
expiredTrack.bilibiliData.cid
|
||||
);
|
||||
await getPlayerStore().handlePlayMusic(trackToPlay, getPlayerStore().play);
|
||||
|
||||
console.log('成功获取新的B站URL:', newUrl);
|
||||
|
||||
// 更新存储
|
||||
(expiredTrack as any).playMusicUrl = newUrl;
|
||||
getPlayerStore().playMusicUrl = newUrl;
|
||||
|
||||
// 重新播放并设置进度
|
||||
const newSound = await audioService.play(newUrl, expiredTrack);
|
||||
sound.value = newSound as Howl;
|
||||
|
||||
// 恢复播放进度
|
||||
if (currentPosition > 0) {
|
||||
newSound.seek(currentPosition);
|
||||
nowTime.value = currentPosition;
|
||||
console.log('恢复播放进度:', currentPosition);
|
||||
}
|
||||
|
||||
// 如果之前是播放状态,继续播放
|
||||
if (getPlayerStore().play) {
|
||||
newSound.play();
|
||||
getPlayerStore().setIsPlay(true);
|
||||
}
|
||||
|
||||
message.success('已自动恢复播放');
|
||||
} catch (error) {
|
||||
console.error('重新获取B站URL失败:', error);
|
||||
message.error('重新获取音频地址失败,请手动点击播放');
|
||||
}
|
||||
} else if (expiredTrack.source === 'netease') {
|
||||
// 处理网易云音乐,重新获取URL
|
||||
console.log('重新获取网易云音乐URL');
|
||||
try {
|
||||
const newUrl = await getSongUrl(expiredTrack.id, expiredTrack as any);
|
||||
|
||||
if (newUrl) {
|
||||
console.log('成功获取新的网易云URL:', newUrl);
|
||||
|
||||
// 更新存储
|
||||
(expiredTrack as any).playMusicUrl = newUrl;
|
||||
getPlayerStore().playMusicUrl = newUrl;
|
||||
|
||||
// 重新播放并设置进度
|
||||
const newSound = await audioService.play(newUrl, expiredTrack);
|
||||
sound.value = newSound as Howl;
|
||||
|
||||
// 恢复播放进度
|
||||
if (currentPosition > 0) {
|
||||
newSound.seek(currentPosition);
|
||||
nowTime.value = currentPosition;
|
||||
console.log('恢复播放进度:', currentPosition);
|
||||
}
|
||||
|
||||
// 如果之前是播放状态,继续播放
|
||||
if (getPlayerStore().play) {
|
||||
newSound.play();
|
||||
getPlayerStore().setIsPlay(true);
|
||||
}
|
||||
|
||||
message.success('已自动恢复播放');
|
||||
} else {
|
||||
throw new Error('获取URL失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重新获取网易云URL失败:', error);
|
||||
message.error('重新获取音频地址失败,请手动点击播放');
|
||||
}
|
||||
}
|
||||
message.success('已自动恢复播放');
|
||||
} catch (error) {
|
||||
console.error('处理URL过期事件失败:', error);
|
||||
message.error('恢复播放失败,请手动点击播放');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
@@ -12,16 +13,14 @@ import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
// 预加载的音频实例
|
||||
export const preloadingSounds = ref<Howl[]>([]);
|
||||
|
||||
/**
|
||||
* 获取歌曲播放URL(独立函数)
|
||||
*/
|
||||
export const getSongUrl = async (
|
||||
id: string | number,
|
||||
songData: SongResult,
|
||||
isDownloaded: boolean = false
|
||||
isDownloaded: boolean = false,
|
||||
requestId?: string
|
||||
) => {
|
||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
|
||||
@@ -30,6 +29,12 @@ export const getSongUrl = async (
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
try {
|
||||
// 在开始处理前验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (songData.playMusicUrl) {
|
||||
return songData.playMusicUrl;
|
||||
}
|
||||
@@ -42,6 +47,11 @@ export const getSongUrl = async (
|
||||
songData.bilibiliData.bvid,
|
||||
songData.bilibiliData.cid
|
||||
);
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 获取B站URL后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
return songData.playMusicUrl;
|
||||
} catch (error) {
|
||||
console.error('重启后获取B站音频URL失败:', error);
|
||||
@@ -55,17 +65,8 @@ export const getSongUrl = async (
|
||||
const globalSources = settingsStore.setData.enabledMusicSources || [];
|
||||
const useCustomApiGlobally = globalSources.includes('custom');
|
||||
|
||||
const songId = String(id);
|
||||
const savedSourceStr = localStorage.getItem(`song_source_${songId}`);
|
||||
let useCustomApiForSong = false;
|
||||
if (savedSourceStr) {
|
||||
try {
|
||||
const songSources = JSON.parse(savedSourceStr);
|
||||
useCustomApiForSong = songSources.includes('custom');
|
||||
} catch (e) {
|
||||
console.error('解析歌曲音源设置失败:', e);
|
||||
}
|
||||
}
|
||||
const songConfig = SongSourceConfigManager.getConfig(id);
|
||||
const useCustomApiForSong = songConfig?.sources.includes('custom' as any) ?? false;
|
||||
|
||||
// 如果全局或歌曲专属设置中启用了自定义API,则最优先尝试
|
||||
if ((useCustomApiGlobally || useCustomApiForSong) && settingsStore.setData.customApiPlugin) {
|
||||
@@ -78,6 +79,12 @@ export const getSongUrl = async (
|
||||
settingsStore.setData.musicQuality || 'higher'
|
||||
);
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 自定义API解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (
|
||||
customResult &&
|
||||
customResult.data &&
|
||||
@@ -93,28 +100,48 @@ export const getSongUrl = async (
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调用自定义API时发生错误:', error);
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
message.error(i18n.global.t('player.reparse.customApiError'));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
||||
if (savedSourceStr && songData.source !== 'bilibili') {
|
||||
if (songConfig && songData.source !== 'bilibili') {
|
||||
try {
|
||||
console.log(`使用自定义音源解析歌曲 ID: ${songId}`);
|
||||
console.log(`使用自定义音源解析歌曲 ID: ${id}`);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
console.log('res', res);
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 自定义音源解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
return res.data.data.url;
|
||||
}
|
||||
console.warn('自定义音源解析失败,使用默认音源');
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
console.error('自定义音源解析出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 正常获取URL流程
|
||||
const { data } = await getMusicUrl(numericId, isDownloaded);
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 获取官方URL后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (data && data.data && data.data[0]) {
|
||||
const songDetail = data.data[0];
|
||||
const hasNoUrl = !songDetail.url;
|
||||
@@ -123,6 +150,11 @@ export const getSongUrl = async (
|
||||
if (hasNoUrl || isTrial) {
|
||||
console.log(`官方URL无效 (无URL: ${hasNoUrl}, 试听: ${isTrial}),进入内置备用解析...`);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 备用解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
return res?.data?.data?.url || null;
|
||||
}
|
||||
@@ -134,9 +166,17 @@ export const getSongUrl = async (
|
||||
|
||||
console.log('官方API返回数据结构异常,进入内置备用解析...');
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 备用解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
return res?.data?.data?.url || null;
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
console.error('官方API请求失败,进入内置备用解析流程:', error);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
@@ -299,7 +339,13 @@ export const useLyrics = () => {
|
||||
export const useSongDetail = () => {
|
||||
const { getSongUrl } = useSongUrl();
|
||||
|
||||
const getSongDetail = async (playMusic: SongResult) => {
|
||||
const getSongDetail = async (playMusic: SongResult, requestId?: string) => {
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] 请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (playMusic.source === 'bilibili') {
|
||||
try {
|
||||
if (!playMusic.playMusicUrl && playMusic.bilibiliData) {
|
||||
@@ -309,6 +355,12 @@ export const useSongDetail = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] B站URL获取后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic } as SongResult;
|
||||
} catch (error) {
|
||||
@@ -324,7 +376,15 @@ export const useSongDetail = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const playMusicUrl = playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic));
|
||||
const playMusicUrl =
|
||||
playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic, false, requestId));
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] URL获取后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
playMusic.createdAt = Date.now();
|
||||
// 半小时后过期
|
||||
playMusic.expiredAt = playMusic.createdAt + 1800000;
|
||||
@@ -333,9 +393,18 @@ export const useSongDetail = () => {
|
||||
? playMusic
|
||||
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] 背景色获取后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult;
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
console.error('获取音频URL失败:', error);
|
||||
playMusic.playLoading = false;
|
||||
throw error;
|
||||
@@ -344,60 +413,3 @@ export const useSongDetail = () => {
|
||||
|
||||
return { getSongDetail };
|
||||
};
|
||||
|
||||
/**
|
||||
* 预加载下一首歌曲音频
|
||||
*/
|
||||
export const preloadNextSong = (nextSongUrl: string): Howl | null => {
|
||||
try {
|
||||
// 清理多余的预加载实例,确保最多只有2个预加载音频
|
||||
while (preloadingSounds.value.length >= 2) {
|
||||
const oldestSound = preloadingSounds.value.shift();
|
||||
if (oldestSound) {
|
||||
try {
|
||||
oldestSound.stop();
|
||||
oldestSound.unload();
|
||||
} catch (e) {
|
||||
console.error('清理预加载音频实例失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查这个URL是否已经在预加载列表中
|
||||
const existingPreload = preloadingSounds.value.find(
|
||||
(sound) => (sound as any)._src === nextSongUrl
|
||||
);
|
||||
if (existingPreload) {
|
||||
console.log('该音频已在预加载列表中,跳过:', nextSongUrl);
|
||||
return existingPreload;
|
||||
}
|
||||
|
||||
const sound = new Howl({
|
||||
src: [nextSongUrl],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false
|
||||
});
|
||||
|
||||
preloadingSounds.value.push(sound);
|
||||
|
||||
sound.on('loaderror', () => {
|
||||
console.error('预加载音频失败:', nextSongUrl);
|
||||
const index = preloadingSounds.value.indexOf(sound);
|
||||
if (index > -1) {
|
||||
preloadingSounds.value.splice(index, 1);
|
||||
}
|
||||
try {
|
||||
sound.stop();
|
||||
sound.unload();
|
||||
} catch (e) {
|
||||
console.error('卸载预加载音频失败:', e);
|
||||
}
|
||||
});
|
||||
|
||||
return sound;
|
||||
} catch (error) {
|
||||
console.error('预加载音频出错:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
|
||||
:root {
|
||||
--text-color: #000000dd;
|
||||
--safe-area-inset-top: 0px;
|
||||
--safe-area-inset-right: 0px;
|
||||
--safe-area-inset-bottom: 10px;
|
||||
--safe-area-inset-left: 0px;
|
||||
}
|
||||
|
||||
:root[class='dark'] {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="layout-page">
|
||||
<!-- 移动端使用专用布局(平板模式下使用 PC 布局) -->
|
||||
<mobile-layout v-if="isPhone && !settingsStore.setData?.tabletMode" :is-phone="isPhone" />
|
||||
|
||||
<!-- PC 端 / 浏览器移动端 / 平板模式 保持原有布局 -->
|
||||
<div v-else class="layout-page" :class="{ mobile: settingsStore.isMobile }">
|
||||
<div id="layout-main" class="layout-main">
|
||||
<title-bar />
|
||||
<div class="layout-main-page">
|
||||
@@ -7,7 +11,7 @@
|
||||
<app-menu v-if="!settingsStore.isMobile" class="menu" :menus="menuStore.menus" />
|
||||
<div class="main">
|
||||
<!-- 搜索栏 -->
|
||||
<search-bar />
|
||||
<search-bar class="search-bar" />
|
||||
<!-- 主页面路由 -->
|
||||
<div
|
||||
class="main-content"
|
||||
@@ -25,7 +29,8 @@
|
||||
</router-view>
|
||||
</div>
|
||||
<play-bottom />
|
||||
<app-menu v-if="shouldShowMobileMenu" class="menu" :menus="menuStore.menus" />
|
||||
<!-- 移动端底部菜单(浏览器模拟移动端时使用) -->
|
||||
<app-menu v-if="shouldShowMobileMenu" class="menu mobile-menu" :menus="menuStore.menus" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部音乐播放 -->
|
||||
@@ -42,7 +47,6 @@
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<install-app-modal v-if="!isElectron"></install-app-modal>
|
||||
<update-modal v-if="isElectron" />
|
||||
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
|
||||
<sleep-timer-top v-if="!settingsStore.isMobile" />
|
||||
@@ -65,7 +69,6 @@ import { computed, defineAsyncComponent, onMounted, provide, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import DownloadDrawer from '@/components/common/DownloadDrawer.vue';
|
||||
import InstallAppModal from '@/components/common/InstallAppModal.vue';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import UpdateModal from '@/components/common/UpdateModal.vue';
|
||||
import SleepTimerTop from '@/components/player/SleepTimerTop.vue';
|
||||
@@ -76,6 +79,9 @@ import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { isElectron } from '@/utils';
|
||||
|
||||
// 移动端专用布局
|
||||
import MobileLayout from './MobileLayout.vue';
|
||||
|
||||
const keepAliveInclude = computed(() => {
|
||||
const allRoutes = [...homeRouter, ...otherRouter];
|
||||
|
||||
@@ -118,6 +124,9 @@ const shouldShowMobileMenu = computed(() => {
|
||||
|
||||
provide('shouldShowMobileMenu', shouldShowMobileMenu);
|
||||
|
||||
// 使用 settingsStore.isMobile 进行移动端检测而不是 Capacitor 设备检测
|
||||
const isPhone = computed(() => settingsStore.isMobile);
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.initializeSettings();
|
||||
settingsStore.initializeTheme();
|
||||
@@ -144,7 +153,7 @@ provide('openPlaylistDrawer', openPlaylistDrawer);
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
@apply w-full h-full relative text-gray-900 dark:text-white;
|
||||
@apply w-full h-full relative text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.layout-main-page {
|
||||
@@ -173,10 +182,12 @@ provide('openPlaylistDrawer', openPlaylistDrawer);
|
||||
overflow: auto;
|
||||
display: block;
|
||||
flex: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
height: calc(100vh - 75px);
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
133
src/renderer/layout/MobileLayout.vue
Normal file
133
src/renderer/layout/MobileLayout.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div id="layout-main" class="mobile-layout mobile" :class="{ 'has-safe-area': isPhone }">
|
||||
<!-- 顶部头部 -->
|
||||
<mobile-header />
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div
|
||||
class="mobile-content"
|
||||
:class="{ 'has-bottom-menu': shouldShowBottomMenu, 'has-player': isPlay }"
|
||||
>
|
||||
<router-view v-slot="{ Component }" class="mobile-page">
|
||||
<keep-alive :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 底部播放条 -->
|
||||
<mobile-play-bar v-if="isPlay" />
|
||||
|
||||
<!-- 底部导航菜单 -->
|
||||
<div v-if="shouldShowBottomMenu" class="mobile-bottom-menu">
|
||||
<app-menu class="mobile-menu" :menus="menuStore.menus" />
|
||||
</div>
|
||||
<!-- 其他弹窗/抽屉 -->
|
||||
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
|
||||
<playing-list-drawer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, provide, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import homeRouter from '@/router/home';
|
||||
import otherRouter from '@/router/other';
|
||||
import { useMenuStore } from '@/store/modules/menu';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
|
||||
import MobileHeader from './components/MobileHeader.vue';
|
||||
|
||||
const AppMenu = defineAsyncComponent(() => import('./components/AppMenu.vue'));
|
||||
const MobilePlayBar = defineAsyncComponent(() => import('@/components/player/MobilePlayBar.vue'));
|
||||
const PlayingListDrawer = defineAsyncComponent(
|
||||
() => import('@/components/player/PlayingListDrawer.vue')
|
||||
);
|
||||
const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue'));
|
||||
|
||||
const props = defineProps<{
|
||||
isPhone: boolean;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
// 提供是否有安全区域
|
||||
provide('hasSafeArea', props.isPhone);
|
||||
|
||||
// 是否有播放的歌曲
|
||||
const isPlay = computed(() => playerStore.playMusic && playerStore.playMusic.id);
|
||||
|
||||
// 是否显示底部菜单
|
||||
const shouldShowBottomMenu = computed(() => {
|
||||
const menuPaths = menuStore.menus.map((item: any) => item.path);
|
||||
return menuPaths.includes(route.path) && !playerStore.musicFull;
|
||||
});
|
||||
|
||||
// 提供给 MobilePlayBar 使用,用于调整播放栏位置
|
||||
provide('shouldShowMobileMenu', shouldShowBottomMenu);
|
||||
|
||||
// Keep-alive 配置
|
||||
const keepAliveInclude = computed(() => {
|
||||
const allRoutes = [...homeRouter, ...otherRouter];
|
||||
return allRoutes
|
||||
.filter((item) => item.meta?.keepAlive)
|
||||
.map((item) =>
|
||||
typeof item.name === 'string' ? item.name.charAt(0).toUpperCase() + item.name.slice(1) : ''
|
||||
)
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
// 歌单抽屉
|
||||
const showPlaylistDrawer = ref(false);
|
||||
const currentSongId = ref<number | undefined>();
|
||||
|
||||
// 提供打开歌单抽屉的方法
|
||||
const openPlaylistDrawer = (songId: number, isOpen: boolean = true) => {
|
||||
currentSongId.value = songId;
|
||||
showPlaylistDrawer.value = isOpen;
|
||||
playerStore.setMusicFull(false);
|
||||
playerStore.setPlayListDrawerVisible(!isOpen);
|
||||
};
|
||||
|
||||
provide('openPlaylistDrawer', openPlaylistDrawer);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-layout {
|
||||
@apply w-screen h-screen flex flex-col;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply overflow-hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
@apply flex-1 overflow-auto;
|
||||
|
||||
// // 只有底部菜单
|
||||
// &.has-bottom-menu:not(.has-player) {
|
||||
// padding-bottom: calc(60px + var(--safe-area-inset-bottom, 0px));
|
||||
// }
|
||||
|
||||
// // 只有播放栏
|
||||
// &.has-player:not(.has-bottom-menu) {
|
||||
// padding-bottom: calc(70px + var(--safe-area-inset-bottom, 0px));
|
||||
// }
|
||||
}
|
||||
|
||||
.mobile-page {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
// 底部菜单固定在底部
|
||||
.mobile-bottom-menu {
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply border-t border-gray-200 dark:border-gray-800;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
@apply w-full;
|
||||
}
|
||||
</style>
|
||||
@@ -172,9 +172,11 @@ const toggleMenu = () => {
|
||||
.app-menu {
|
||||
max-width: 100%;
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 99;
|
||||
@apply bg-light dark:bg-black border-t border-gray-200 dark:border-gray-700;
|
||||
z-index: 99999;
|
||||
@apply bg-light dark:bg-black border-none border-gray-200 dark:border-gray-700;
|
||||
|
||||
|
||||
113
src/renderer/layout/components/MobileHeader.vue
Normal file
113
src/renderer/layout/components/MobileHeader.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="mobile-header" :class="{ 'safe-area-top': hasSafeArea }">
|
||||
<!-- 左侧区域 -->
|
||||
<div class="header-left">
|
||||
<div v-if="showBack" class="header-btn" @click="goBack">
|
||||
<i class="ri-arrow-left-s-line"></i>
|
||||
</div>
|
||||
<div v-else class="header-logo">
|
||||
<span class="logo-text">Alger</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间标题 -->
|
||||
<div class="header-title">
|
||||
<span v-if="title">{{ t(title) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧区域 -->
|
||||
<div class="header-right">
|
||||
<div class="header-btn" @click="openSearch">
|
||||
<i class="ri-search-line"></i>
|
||||
</div>
|
||||
<div class="header-btn" @click="openSettings">
|
||||
<i class="ri-settings-3-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 注入是否有安全区域
|
||||
const hasSafeArea = inject('hasSafeArea', false);
|
||||
|
||||
// 是否显示返回按钮
|
||||
const showBack = computed(() => {
|
||||
return route.meta.back === true;
|
||||
});
|
||||
|
||||
// 页面标题
|
||||
const title = computed(() => {
|
||||
return (route.meta.title as string) || '';
|
||||
});
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 打开搜索
|
||||
const openSearch = () => {
|
||||
router.push('/mobile-search');
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
router.push('/set');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-header {
|
||||
@apply flex items-center justify-between px-4 py-3;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
min-height: 56px;
|
||||
|
||||
&.safe-area-top {
|
||||
padding-top: calc(var(--safe-area-inset-top, 0px) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@apply flex items-center;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
@apply flex items-center;
|
||||
|
||||
.logo-text {
|
||||
@apply text-lg font-bold text-green-500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
@apply flex-1 text-center;
|
||||
|
||||
span {
|
||||
@apply text-base font-medium text-gray-900 dark:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@apply flex items-center gap-2;
|
||||
min-width: 80px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full;
|
||||
@apply text-xl text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-100 dark:active:bg-gray-800;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="search-box flex">
|
||||
<div class="search-box flex search-bar">
|
||||
<div v-if="showBackButton" class="back-button" @click="goBack">
|
||||
<i class="ri-arrow-left-line"></i>
|
||||
</div>
|
||||
@@ -364,14 +364,9 @@ const checkForUpdates = async () => {
|
||||
};
|
||||
|
||||
const toGithubRelease = () => {
|
||||
if (updateInfo.value.hasUpdate) {
|
||||
settingsStore.showUpdateModal = true;
|
||||
} else {
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
|
||||
}
|
||||
window.location.href = 'https://donate.alger.fun/download';
|
||||
};
|
||||
|
||||
// ==================== 搜索建议相关的状态和方法 ====================
|
||||
const suggestions = ref<string[]>([]);
|
||||
const showSuggestions = ref(false);
|
||||
const suggestionsLoading = ref(false);
|
||||
@@ -446,8 +441,6 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -20,7 +20,7 @@ Object.keys(directives).forEach((key: string) => {
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
app.use(i18n as any);
|
||||
app.mount('#app');
|
||||
|
||||
// 初始化应用内快捷键
|
||||
|
||||
@@ -17,8 +17,7 @@ const layoutRouter = [
|
||||
title: 'comp.search',
|
||||
noScroll: true,
|
||||
icon: 'icon-Search',
|
||||
keepAlive: true,
|
||||
isMobile: true
|
||||
keepAlive: true
|
||||
},
|
||||
component: () => import('@/views/search/index.vue')
|
||||
},
|
||||
@@ -62,7 +61,8 @@ const layoutRouter = [
|
||||
meta: {
|
||||
title: 'comp.history',
|
||||
icon: 'icon-a-TicketStar',
|
||||
keepAlive: true
|
||||
keepAlive: true,
|
||||
isMobile: true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -108,6 +108,28 @@ const otherRouter = [
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/music/HistoryRecommend.vue')
|
||||
},
|
||||
{
|
||||
path: '/mobile-search',
|
||||
name: 'mobileSearch',
|
||||
meta: {
|
||||
title: '搜索',
|
||||
keepAlive: false,
|
||||
showInMenu: false,
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/mobile-search/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/mobile-search-result',
|
||||
name: 'mobileSearchResult',
|
||||
meta: {
|
||||
title: '搜索结果',
|
||||
keepAlive: false,
|
||||
showInMenu: false,
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/mobile-search-result/index.vue')
|
||||
}
|
||||
];
|
||||
export default otherRouter;
|
||||
|
||||
668
src/renderer/services/LxMusicSourceRunner.ts
Normal file
668
src/renderer/services/LxMusicSourceRunner.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* 落雪音乐 (LX Music) 音源脚本执行器
|
||||
*
|
||||
* 核心职责:
|
||||
* 1. 解析脚本元信息
|
||||
* 2. 在隔离环境中执行用户脚本
|
||||
* 3. 模拟 globalThis.lx API
|
||||
* 4. 处理初始化和音乐解析请求
|
||||
*/
|
||||
|
||||
import type {
|
||||
LxInitedData,
|
||||
LxLyricResult,
|
||||
LxMusicInfo,
|
||||
LxQuality,
|
||||
LxScriptInfo,
|
||||
LxSourceConfig,
|
||||
LxSourceKey
|
||||
} from '@/types/lxMusic';
|
||||
import * as lxCrypto from '@/utils/lxCrypto';
|
||||
|
||||
/**
|
||||
* 解析脚本头部注释中的元信息
|
||||
*/
|
||||
export const parseScriptInfo = (script: string): LxScriptInfo => {
|
||||
const info: LxScriptInfo = {
|
||||
name: '未知音源',
|
||||
rawScript: script
|
||||
};
|
||||
|
||||
// 尝试匹配不同格式的头部注释块
|
||||
// 支持 /** ... */ 和 /* ... */ 格式
|
||||
const headerMatch = script.match(/^\/\*+[\s\S]*?\*\//);
|
||||
if (!headerMatch) {
|
||||
console.warn('[parseScriptInfo] 未找到脚本头部注释块');
|
||||
return info;
|
||||
}
|
||||
|
||||
const header = headerMatch[0];
|
||||
console.log('[parseScriptInfo] 解析脚本头部:', header.substring(0, 200));
|
||||
|
||||
// 解析各个字段(支持 * 前缀和无前缀两种格式)
|
||||
const nameMatch = header.match(/@name\s+(.+?)(?:\r?\n|\*\/)/);
|
||||
if (nameMatch) {
|
||||
info.name = nameMatch[1].trim().replace(/^\*\s*/, '');
|
||||
console.log('[parseScriptInfo] 解析到名称:', info.name);
|
||||
} else {
|
||||
console.warn('[parseScriptInfo] 未找到 @name 标签');
|
||||
}
|
||||
|
||||
const descMatch = header.match(/@description\s+(.+?)(?:\r?\n|\*\/)/);
|
||||
if (descMatch) {
|
||||
info.description = descMatch[1].trim().replace(/^\*\s*/, '');
|
||||
}
|
||||
|
||||
const versionMatch = header.match(/@version\s+(.+?)(?:\r?\n|\*\/)/);
|
||||
if (versionMatch) {
|
||||
info.version = versionMatch[1].trim().replace(/^\*\s*/, '');
|
||||
console.log('[parseScriptInfo] 解析到版本:', info.version);
|
||||
}
|
||||
|
||||
const authorMatch = header.match(/@author\s+(.+?)(?:\r?\n|\*\/)/);
|
||||
if (authorMatch) {
|
||||
info.author = authorMatch[1].trim().replace(/^\*\s*/, '');
|
||||
}
|
||||
|
||||
const homepageMatch = header.match(/@homepage\s+(.+?)(?:\r?\n|\*\/)/);
|
||||
if (homepageMatch) {
|
||||
info.homepage = homepageMatch[1].trim().replace(/^\*\s*/, '');
|
||||
}
|
||||
|
||||
return info;
|
||||
};
|
||||
|
||||
/**
|
||||
* 落雪音源脚本执行器
|
||||
* 使用 Worker 或 iframe 隔离执行用户脚本
|
||||
*/
|
||||
export class LxMusicSourceRunner {
|
||||
private script: string;
|
||||
private scriptInfo: LxScriptInfo;
|
||||
private sources: Partial<Record<LxSourceKey, LxSourceConfig>> = {};
|
||||
private requestHandler: ((data: any) => Promise<any>) | null = null;
|
||||
private initialized = false;
|
||||
private initPromise: Promise<LxInitedData> | null = null;
|
||||
// 临时存储最后一次 HTTP 请求返回的音乐 URL(用于脚本返回 undefined 时的后备)
|
||||
private lastMusicUrl: string | null = null;
|
||||
|
||||
constructor(script: string) {
|
||||
this.script = script;
|
||||
this.scriptInfo = parseScriptInfo(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取脚本信息
|
||||
*/
|
||||
getScriptInfo(): LxScriptInfo {
|
||||
return this.scriptInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的音源列表
|
||||
*/
|
||||
getSources(): Partial<Record<LxSourceKey, LxSourceConfig>> {
|
||||
return this.sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化执行器
|
||||
*/
|
||||
async initialize(): Promise<LxInitedData> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
this.initPromise = new Promise<LxInitedData>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('脚本初始化超时'));
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
// 创建沙盒环境并执行脚本
|
||||
this.executeSandboxed(
|
||||
(initedData) => {
|
||||
clearTimeout(timeout);
|
||||
this.sources = initedData.sources;
|
||||
this.initialized = true;
|
||||
console.log('[LxMusicRunner] 初始化成功:', initedData.sources);
|
||||
resolve(initedData);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在沙盒中执行脚本
|
||||
*/
|
||||
private executeSandboxed(
|
||||
onInited: (data: LxInitedData) => void,
|
||||
onError: (error: Error) => void
|
||||
): void {
|
||||
// 构建沙盒执行环境
|
||||
const sandbox = this.createSandbox(onInited, onError);
|
||||
|
||||
try {
|
||||
// 使用 Function 构造器在受限环境中执行
|
||||
// 注意:不能使用 const/let 声明 globalThis,因为它是保留标识符
|
||||
const sandboxedScript = `
|
||||
(function() {
|
||||
${sandbox.apiSetup}
|
||||
${this.script}
|
||||
}).call(this);
|
||||
`;
|
||||
|
||||
// 创建执行上下文
|
||||
const context = sandbox.context;
|
||||
const executor = new Function(sandboxedScript);
|
||||
|
||||
// 在隔离上下文中执行,context 将作为 this
|
||||
executor.call(context);
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建沙盒环境
|
||||
*/
|
||||
private createSandbox(
|
||||
onInited: (data: LxInitedData) => void,
|
||||
_onError: (error: Error) => void
|
||||
): { apiSetup: string; context: any } {
|
||||
const self = this;
|
||||
|
||||
// 创建 globalThis.lx 对象
|
||||
// 版本号使用落雪音乐最新版本以通过脚本版本检测
|
||||
const context = {
|
||||
lx: {
|
||||
version: '2.8.0',
|
||||
env: 'desktop',
|
||||
appInfo: {
|
||||
version: '2.8.0',
|
||||
versionNum: 208,
|
||||
locale: 'zh-cn'
|
||||
},
|
||||
currentScriptInfo: this.scriptInfo,
|
||||
EVENT_NAMES: {
|
||||
inited: 'inited',
|
||||
request: 'request',
|
||||
updateAlert: 'updateAlert'
|
||||
},
|
||||
on: (eventName: string, handler: (data: any) => Promise<any>) => {
|
||||
if (eventName === 'request') {
|
||||
self.requestHandler = handler;
|
||||
}
|
||||
},
|
||||
send: (eventName: string, data: any) => {
|
||||
if (eventName === 'inited') {
|
||||
onInited(data as LxInitedData);
|
||||
} else if (eventName === 'updateAlert') {
|
||||
console.log('[LxMusicRunner] 更新提醒:', data);
|
||||
}
|
||||
},
|
||||
request: (
|
||||
url: string,
|
||||
options: any,
|
||||
callback: (err: Error | null, resp: any, body: any) => void
|
||||
) => {
|
||||
return self.handleHttpRequest(url, options, callback);
|
||||
},
|
||||
utils: {
|
||||
buffer: {
|
||||
from: (data: any, _encoding?: string) => {
|
||||
if (typeof data === 'string') {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
return new Uint8Array(data);
|
||||
},
|
||||
bufToString: (buffer: Uint8Array, encoding?: string) => {
|
||||
return new TextDecoder(encoding || 'utf-8').decode(buffer);
|
||||
}
|
||||
},
|
||||
crypto: {
|
||||
md5: lxCrypto.md5,
|
||||
sha1: lxCrypto.sha1,
|
||||
sha256: lxCrypto.sha256,
|
||||
randomBytes: lxCrypto.randomBytes,
|
||||
aesEncrypt: lxCrypto.aesEncrypt,
|
||||
aesDecrypt: lxCrypto.aesDecrypt,
|
||||
rsaEncrypt: lxCrypto.rsaEncrypt,
|
||||
rsaDecrypt: lxCrypto.rsaDecrypt,
|
||||
base64Encode: lxCrypto.base64Encode,
|
||||
base64Decode: lxCrypto.base64Decode
|
||||
},
|
||||
zlib: {
|
||||
inflate: async (buffer: ArrayBuffer) => {
|
||||
try {
|
||||
const ds = new DecompressionStream('deflate');
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(buffer);
|
||||
writer.close();
|
||||
const reader = ds.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
if (result.value) chunks.push(result.value);
|
||||
}
|
||||
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result.buffer;
|
||||
} catch {
|
||||
return buffer;
|
||||
}
|
||||
},
|
||||
deflate: async (buffer: ArrayBuffer) => {
|
||||
try {
|
||||
const cs = new CompressionStream('deflate');
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(buffer);
|
||||
writer.close();
|
||||
const reader = cs.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
if (result.value) chunks.push(result.value);
|
||||
}
|
||||
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result.buffer;
|
||||
} catch {
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
console: {
|
||||
log: (...args: any[]) => console.log('[LxScript]', ...args),
|
||||
error: (...args: any[]) => console.error('[LxScript]', ...args),
|
||||
warn: (...args: any[]) => console.warn('[LxScript]', ...args),
|
||||
info: (...args: any[]) => console.info('[LxScript]', ...args)
|
||||
},
|
||||
setTimeout,
|
||||
setInterval,
|
||||
clearTimeout,
|
||||
clearInterval,
|
||||
Promise,
|
||||
JSON,
|
||||
Object,
|
||||
Array,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Date,
|
||||
Math,
|
||||
RegExp,
|
||||
Error,
|
||||
Map,
|
||||
Set,
|
||||
WeakMap,
|
||||
WeakSet,
|
||||
Symbol,
|
||||
Proxy,
|
||||
Reflect,
|
||||
encodeURIComponent,
|
||||
decodeURIComponent,
|
||||
encodeURI,
|
||||
decodeURI,
|
||||
atob,
|
||||
btoa,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
Uint8Array,
|
||||
ArrayBuffer,
|
||||
crypto
|
||||
};
|
||||
|
||||
// 只设置 lx 和 globalThis,不解构变量避免与脚本内部声明冲突
|
||||
const apiSetup = `
|
||||
var lx = this.lx;
|
||||
var globalThis = this;
|
||||
`;
|
||||
|
||||
return { apiSetup, context };
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求(优先使用主进程,绕过 CORS 限制)
|
||||
*/
|
||||
private handleHttpRequest(
|
||||
url: string,
|
||||
options: any,
|
||||
callback: (err: Error | null, resp: any, body: any) => void
|
||||
): () => void {
|
||||
console.log(`[LxMusicRunner] HTTP 请求: ${options.method || 'GET'} ${url}`);
|
||||
|
||||
const timeout = options.timeout || 30000;
|
||||
const requestId = `lx_http_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// 尝试使用主进程 HTTP 请求(如果可用)
|
||||
const hasMainProcessHttp = typeof window.api?.lxMusicHttpRequest === 'function';
|
||||
|
||||
if (hasMainProcessHttp) {
|
||||
// 使用主进程 HTTP 请求(绕过 CORS)
|
||||
console.log(`[LxMusicRunner] 使用主进程 HTTP 请求`);
|
||||
|
||||
window.api
|
||||
.lxMusicHttpRequest({
|
||||
url,
|
||||
options: {
|
||||
...options,
|
||||
timeout
|
||||
},
|
||||
requestId
|
||||
})
|
||||
.then((response: any) => {
|
||||
console.log(`[LxMusicRunner] HTTP 响应: ${response.statusCode} ${url}`);
|
||||
|
||||
// 如果响应中包含 URL,缓存下来以备后用
|
||||
if (response.body && response.body.url && typeof response.body.url === 'string') {
|
||||
this.lastMusicUrl = response.body.url;
|
||||
}
|
||||
|
||||
callback(null, response, response.body);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message);
|
||||
callback(error, null, null);
|
||||
});
|
||||
|
||||
// 返回取消函数
|
||||
return () => {
|
||||
void window.api?.lxMusicHttpCancel?.(requestId);
|
||||
};
|
||||
} else {
|
||||
// 回退到渲染进程 fetch(可能受 CORS 限制)
|
||||
console.log(`[LxMusicRunner] 主进程 HTTP 不可用,使用渲染进程 fetch`);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
...options.headers
|
||||
},
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'omit'
|
||||
};
|
||||
|
||||
if (options.body) {
|
||||
fetchOptions.body = options.body;
|
||||
} else if (options.form) {
|
||||
fetchOptions.body = new URLSearchParams(options.form);
|
||||
fetchOptions.headers = {
|
||||
...fetchOptions.headers,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
} else if (options.formData) {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(options.formData)) {
|
||||
formData.append(key, value as string);
|
||||
}
|
||||
fetchOptions.body = formData;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn(`[LxMusicRunner] HTTP 请求超时: ${url}`);
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
fetch(url, fetchOptions)
|
||||
.then(async (response) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.log(`[LxMusicRunner] HTTP 响应: ${response.status} ${url}`);
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
// 尝试解析 JSON
|
||||
let parsedBody: any = rawBody;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (
|
||||
contentType.includes('application/json') ||
|
||||
rawBody.startsWith('{') ||
|
||||
rawBody.startsWith('[')
|
||||
) {
|
||||
try {
|
||||
parsedBody = JSON.parse(rawBody);
|
||||
if (parsedBody && parsedBody.url && typeof parsedBody.url === 'string') {
|
||||
this.lastMusicUrl = parsedBody.url;
|
||||
}
|
||||
} catch {
|
||||
// 解析失败则使用原始字符串
|
||||
}
|
||||
}
|
||||
|
||||
callback(
|
||||
null,
|
||||
{
|
||||
statusCode: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: parsedBody
|
||||
},
|
||||
parsedBody
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.error(`[LxMusicRunner] HTTP 请求失败: ${url}`, error.message);
|
||||
callback(error, null, null);
|
||||
});
|
||||
|
||||
// 返回取消函数
|
||||
return () => controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音乐 URL
|
||||
*/
|
||||
async getMusicUrl(
|
||||
source: LxSourceKey,
|
||||
musicInfo: LxMusicInfo,
|
||||
quality: LxQuality
|
||||
): Promise<string> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.requestHandler) {
|
||||
throw new Error('脚本未注册请求处理器');
|
||||
}
|
||||
|
||||
const sourceConfig = this.sources[source];
|
||||
if (!sourceConfig) {
|
||||
throw new Error(`脚本不支持音源: ${source}`);
|
||||
}
|
||||
|
||||
if (!sourceConfig.actions.includes('musicUrl')) {
|
||||
throw new Error(`音源 ${source} 不支持获取音乐 URL`);
|
||||
}
|
||||
|
||||
// 选择最佳音质
|
||||
let targetQuality = quality;
|
||||
if (!sourceConfig.qualitys.includes(quality)) {
|
||||
// 按优先级选择可用音质
|
||||
const qualityPriority: LxQuality[] = ['flac24bit', 'flac', '320k', '128k'];
|
||||
for (const q of qualityPriority) {
|
||||
if (sourceConfig.qualitys.includes(q)) {
|
||||
targetQuality = q;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[LxMusicRunner] 请求音乐 URL: 音源=${source}, 音质=${targetQuality}`);
|
||||
|
||||
try {
|
||||
const result = await this.requestHandler({
|
||||
source,
|
||||
action: 'musicUrl',
|
||||
info: {
|
||||
type: targetQuality,
|
||||
musicInfo
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[LxMusicRunner] 脚本返回结果:`, result, typeof result);
|
||||
|
||||
// 脚本可能返回对象或字符串
|
||||
let url: string | undefined;
|
||||
if (typeof result === 'string') {
|
||||
url = result;
|
||||
} else if (result && typeof result === 'object') {
|
||||
// 某些脚本可能返回 { url: '...' } 格式
|
||||
url = result.url || result.data || result;
|
||||
}
|
||||
|
||||
if (typeof url !== 'string' || !url) {
|
||||
// 如果脚本返回 undefined,尝试使用缓存的 URL
|
||||
if (this.lastMusicUrl) {
|
||||
console.log('[LxMusicRunner] 脚本返回 undefined,使用缓存的 URL');
|
||||
url = this.lastMusicUrl;
|
||||
this.lastMusicUrl = null; // 清除缓存
|
||||
} else {
|
||||
console.error('[LxMusicRunner] 无效的返回值:', result);
|
||||
throw new Error(result?.message || result?.msg || '获取音乐 URL 失败');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[LxMusicRunner] 获取到 URL:', url.substring(0, 80) + '...');
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error('[LxMusicRunner] 获取音乐 URL 失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌词
|
||||
*/
|
||||
async getLyric(source: LxSourceKey, musicInfo: LxMusicInfo): Promise<LxLyricResult | null> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.requestHandler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceConfig = this.sources[source];
|
||||
if (!sourceConfig || !sourceConfig.actions.includes('lyric')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.requestHandler({
|
||||
source,
|
||||
action: 'lyric',
|
||||
info: {
|
||||
type: null,
|
||||
musicInfo
|
||||
}
|
||||
});
|
||||
|
||||
return result as LxLyricResult;
|
||||
} catch (error) {
|
||||
console.error('[LxMusicRunner] 获取歌词失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取封面图
|
||||
*/
|
||||
async getPic(source: LxSourceKey, musicInfo: LxMusicInfo): Promise<string | null> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.requestHandler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceConfig = this.sources[source];
|
||||
if (!sourceConfig || !sourceConfig.actions.includes('pic')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await this.requestHandler({
|
||||
source,
|
||||
action: 'pic',
|
||||
info: {
|
||||
type: null,
|
||||
musicInfo
|
||||
}
|
||||
});
|
||||
|
||||
return typeof url === 'string' ? url : null;
|
||||
} catch (error) {
|
||||
console.error('[LxMusicRunner] 获取封面失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
let runnerInstance: LxMusicSourceRunner | null = null;
|
||||
|
||||
/**
|
||||
* 获取落雪音源执行器实例
|
||||
*/
|
||||
export const getLxMusicRunner = (): LxMusicSourceRunner | null => {
|
||||
return runnerInstance;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置落雪音源执行器实例
|
||||
*/
|
||||
export const setLxMusicRunner = (runner: LxMusicSourceRunner | null): void => {
|
||||
runnerInstance = runner;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化落雪音源执行器(从脚本内容)
|
||||
*/
|
||||
export const initLxMusicRunner = async (script: string): Promise<LxMusicSourceRunner> => {
|
||||
// 销毁旧实例
|
||||
runnerInstance = null;
|
||||
|
||||
// 创建新实例
|
||||
const runner = new LxMusicSourceRunner(script);
|
||||
await runner.initialize();
|
||||
|
||||
runnerInstance = runner;
|
||||
return runner;
|
||||
};
|
||||
198
src/renderer/services/SongSourceConfigManager.ts
Normal file
198
src/renderer/services/SongSourceConfigManager.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 歌曲音源配置管理器
|
||||
*
|
||||
* 职责:
|
||||
* 1. 统一管理每首歌曲的自定义音源配置
|
||||
* 2. 提供清晰的读取/写入/清除 API
|
||||
* 3. 区分"手动"和"自动"设置的音源
|
||||
* 4. 管理已尝试的音源列表(按歌曲隔离)
|
||||
*/
|
||||
|
||||
import type { Platform } from '@/types/music';
|
||||
|
||||
// 歌曲音源配置类型
|
||||
export type SongSourceConfig = {
|
||||
sources: Platform[];
|
||||
type: 'manual' | 'auto';
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
// 内存中缓存已尝试的音源(按歌曲隔离)
|
||||
const triedSourcesMap = new Map<string, Set<string>>();
|
||||
const triedSourceDiffsMap = new Map<string, Map<string, number>>();
|
||||
|
||||
// localStorage key 前缀
|
||||
const STORAGE_KEY_PREFIX = 'song_source_';
|
||||
const STORAGE_TYPE_KEY_PREFIX = 'song_source_type_';
|
||||
|
||||
/**
|
||||
* 歌曲音源配置管理器
|
||||
*/
|
||||
export class SongSourceConfigManager {
|
||||
/**
|
||||
* 获取歌曲的自定义音源配置
|
||||
*/
|
||||
static getConfig(songId: number | string): SongSourceConfig | null {
|
||||
const id = String(songId);
|
||||
const sourcesStr = localStorage.getItem(`${STORAGE_KEY_PREFIX}${id}`);
|
||||
const typeStr = localStorage.getItem(`${STORAGE_TYPE_KEY_PREFIX}${id}`);
|
||||
|
||||
if (!sourcesStr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sources = JSON.parse(sourcesStr) as Platform[];
|
||||
if (!Array.isArray(sources) || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sources,
|
||||
type: typeStr === 'auto' ? 'auto' : 'manual',
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[SongSourceConfigManager] 解析歌曲 ${id} 配置失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置歌曲的自定义音源配置
|
||||
*/
|
||||
static setConfig(
|
||||
songId: number | string,
|
||||
sources: Platform[],
|
||||
type: 'manual' | 'auto' = 'manual'
|
||||
): void {
|
||||
const id = String(songId);
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
this.clearConfig(songId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_KEY_PREFIX}${id}`, JSON.stringify(sources));
|
||||
localStorage.setItem(`${STORAGE_TYPE_KEY_PREFIX}${id}`, type);
|
||||
console.log(`[SongSourceConfigManager] 设置歌曲 ${id} 音源: ${sources.join(', ')} (${type})`);
|
||||
} catch (error) {
|
||||
console.error(`[SongSourceConfigManager] 保存歌曲 ${id} 配置失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除歌曲的自定义配置
|
||||
*/
|
||||
static clearConfig(songId: number | string): void {
|
||||
const id = String(songId);
|
||||
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${id}`);
|
||||
localStorage.removeItem(`${STORAGE_TYPE_KEY_PREFIX}${id}`);
|
||||
// 同时清除内存中的已尝试音源
|
||||
this.clearTriedSources(songId);
|
||||
console.log(`[SongSourceConfigManager] 清除歌曲 ${id} 配置`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查歌曲是否有自定义配置
|
||||
*/
|
||||
static hasConfig(songId: number | string): boolean {
|
||||
return this.getConfig(songId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置类型是否为手动设置
|
||||
*/
|
||||
static isManualConfig(songId: number | string): boolean {
|
||||
const config = this.getConfig(songId);
|
||||
return config?.type === 'manual';
|
||||
}
|
||||
|
||||
// ==================== 已尝试音源管理 ====================
|
||||
|
||||
/**
|
||||
* 获取歌曲已尝试的音源列表
|
||||
*/
|
||||
static getTriedSources(songId: number | string): Set<string> {
|
||||
const id = String(songId);
|
||||
if (!triedSourcesMap.has(id)) {
|
||||
triedSourcesMap.set(id, new Set());
|
||||
}
|
||||
return triedSourcesMap.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加已尝试的音源
|
||||
*/
|
||||
static addTriedSource(songId: number | string, source: string): void {
|
||||
const id = String(songId);
|
||||
const tried = this.getTriedSources(id);
|
||||
tried.add(source);
|
||||
console.log(`[SongSourceConfigManager] 歌曲 ${id} 添加已尝试音源: ${source}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除歌曲的已尝试音源
|
||||
*/
|
||||
static clearTriedSources(songId: number | string): void {
|
||||
const id = String(songId);
|
||||
triedSourcesMap.delete(id);
|
||||
triedSourceDiffsMap.delete(id);
|
||||
console.log(`[SongSourceConfigManager] 清除歌曲 ${id} 已尝试音源`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌曲已尝试音源的时长差异
|
||||
*/
|
||||
static getTriedSourceDiffs(songId: number | string): Map<string, number> {
|
||||
const id = String(songId);
|
||||
if (!triedSourceDiffsMap.has(id)) {
|
||||
triedSourceDiffsMap.set(id, new Map());
|
||||
}
|
||||
return triedSourceDiffsMap.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音源的时长差异
|
||||
*/
|
||||
static setTriedSourceDiff(songId: number | string, source: string, diff: number): void {
|
||||
const id = String(songId);
|
||||
const diffs = this.getTriedSourceDiffs(id);
|
||||
diffs.set(source, diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找最佳匹配的音源(时长差异最小)
|
||||
*/
|
||||
static findBestMatchingSource(songId: number | string): { source: string; diff: number } | null {
|
||||
const diffs = this.getTriedSourceDiffs(songId);
|
||||
if (diffs.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestSource = '';
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const [source, diff] of diffs.entries()) {
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
bestSource = source;
|
||||
}
|
||||
}
|
||||
|
||||
return bestSource ? { source: bestSource, diff: minDiff } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有内存缓存(用于测试或重置)
|
||||
*/
|
||||
static clearAllMemoryCache(): void {
|
||||
triedSourcesMap.clear();
|
||||
triedSourceDiffsMap.clear();
|
||||
console.log('[SongSourceConfigManager] 清除所有内存缓存');
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例方便使用
|
||||
export const songSourceConfig = SongSourceConfigManager;
|
||||
@@ -5,6 +5,7 @@ import { isElectron } from '@/utils'; // 导入isElectron常量
|
||||
|
||||
class AudioService {
|
||||
private currentSound: Howl | null = null;
|
||||
private pendingSound: Howl | null = null;
|
||||
|
||||
private currentTrack: SongResult | null = null;
|
||||
|
||||
@@ -470,11 +471,12 @@ class AudioService {
|
||||
}
|
||||
|
||||
// 播放控制相关
|
||||
play(
|
||||
url?: string,
|
||||
track?: SongResult,
|
||||
public play(
|
||||
url: string,
|
||||
track: SongResult,
|
||||
isPlay: boolean = true,
|
||||
seekTime: number = 0
|
||||
seekTime: number = 0,
|
||||
existingSound?: Howl
|
||||
): Promise<Howl> {
|
||||
// 每次调用play方法时,尝试强制重置锁(注意:仅在页面刷新后的第一次播放时应用)
|
||||
if (!this.currentSound) {
|
||||
@@ -482,6 +484,17 @@ class AudioService {
|
||||
this.forceResetOperationLock();
|
||||
}
|
||||
|
||||
// 如果有操作锁,且不是同一个 track 的操作,则等待
|
||||
if (this.operationLock) {
|
||||
console.log('audioService: 操作锁激活中,等待...');
|
||||
return Promise.reject(new Error('操作锁激活中'));
|
||||
}
|
||||
|
||||
if (!this.setOperationLock()) {
|
||||
console.log('audioService: 获取操作锁失败');
|
||||
return Promise.reject(new Error('操作锁激活中'));
|
||||
}
|
||||
|
||||
// 如果操作锁已激活,但持续时间超过安全阈值,强制重置
|
||||
if (this.operationLock) {
|
||||
const currentTime = Date.now();
|
||||
@@ -531,10 +544,25 @@ class AudioService {
|
||||
return Promise.reject(new Error('缺少必要参数: url和track'));
|
||||
}
|
||||
|
||||
// 检查是否是同一首歌曲的无缝切换(Hot-Swap)
|
||||
const isHotSwap =
|
||||
this.currentTrack && track && this.currentTrack.id === track.id && this.currentSound;
|
||||
|
||||
if (isHotSwap) {
|
||||
console.log('audioService: 检测到同一首歌曲的源切换,启用无缝切换模式');
|
||||
}
|
||||
|
||||
return new Promise<Howl>((resolve, reject) => {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 1;
|
||||
|
||||
// 如果有正在加载的 pendingSound,先清理掉
|
||||
if (this.pendingSound) {
|
||||
console.log('audioService: 清理正在加载的 pendingSound');
|
||||
this.pendingSound.unload();
|
||||
this.pendingSound = null;
|
||||
}
|
||||
|
||||
const tryPlay = async () => {
|
||||
try {
|
||||
console.log('audioService: 开始创建音频对象');
|
||||
@@ -560,8 +588,8 @@ class AudioService {
|
||||
await Howler.ctx.resume();
|
||||
}
|
||||
|
||||
// 先停止并清理现有的音频实例
|
||||
if (this.currentSound) {
|
||||
// 非热切换模式下,先停止并清理现有的音频实例
|
||||
if (!isHotSwap && this.currentSound) {
|
||||
console.log('audioService: 停止并清理现有的音频实例');
|
||||
// 确保任何进行中的seek操作被取消
|
||||
if (this.seekLock && this.seekDebounceTimer) {
|
||||
@@ -573,49 +601,127 @@ class AudioService {
|
||||
this.currentSound = null;
|
||||
}
|
||||
|
||||
// 清理 EQ 但保持上下文
|
||||
console.log('audioService: 清理 EQ');
|
||||
await this.disposeEQ(true);
|
||||
// 清理 EQ 但保持上下文 (热切换时暂时不清理,等切换完成后再处理)
|
||||
if (!isHotSwap) {
|
||||
console.log('audioService: 清理 EQ');
|
||||
await this.disposeEQ(true);
|
||||
}
|
||||
|
||||
this.currentTrack = track;
|
||||
console.log('audioService: 创建新的 Howl 对象');
|
||||
this.currentSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: false,
|
||||
volume: 1, // 禁用 Howler.js 音量控制
|
||||
rate: this.playbackRate,
|
||||
format: ['mp3', 'aac'],
|
||||
onloaderror: (_, error) => {
|
||||
// 如果不是热切换,立即更新 currentTrack
|
||||
if (!isHotSwap) {
|
||||
this.currentTrack = track;
|
||||
}
|
||||
|
||||
// 如果不是热切换,立即更新 currentTrack
|
||||
if (!isHotSwap) {
|
||||
this.currentTrack = track;
|
||||
}
|
||||
|
||||
let newSound: Howl;
|
||||
|
||||
if (existingSound) {
|
||||
console.log('audioService: 使用预加载的 Howl 对象');
|
||||
newSound = existingSound;
|
||||
// 确保 volume 和 rate 正确
|
||||
newSound.volume(1); // 内部 volume 设为 1,由 Howler.masterGain 控制实际音量
|
||||
newSound.rate(this.playbackRate);
|
||||
|
||||
// 重新绑定事件监听器,因为 PreloadService 可能没有绑定这些
|
||||
// 注意:Howler 允许重复绑定,但最好先清理(如果无法清理,就直接绑定,Howler 是 EventEmitter)
|
||||
// 这里我们假设 existingSound 是干净的或者我们只绑定我们需要关心的
|
||||
} else {
|
||||
console.log('audioService: 创建新的 Howl 对象');
|
||||
newSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: false,
|
||||
volume: 1, // 禁用 Howler.js 音量控制
|
||||
rate: this.playbackRate,
|
||||
format: ['mp3', 'aac']
|
||||
});
|
||||
}
|
||||
|
||||
// 统一设置事件处理
|
||||
const setupEvents = () => {
|
||||
newSound.off('loaderror');
|
||||
newSound.off('playerror');
|
||||
newSound.off('load');
|
||||
|
||||
newSound.on('loaderror', (_, error) => {
|
||||
console.error('Audio load error:', error);
|
||||
if (retryCount < maxRetries) {
|
||||
if (retryCount < maxRetries && !existingSound) {
|
||||
// 预加载的音频通常已经 loaded,不应重试
|
||||
retryCount++;
|
||||
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
||||
setTimeout(tryPlay, 1000 * retryCount);
|
||||
} else {
|
||||
// 发送URL过期事件,通知外部需要重新获取URL
|
||||
this.emit('url_expired', this.currentTrack);
|
||||
this.emit('url_expired', track);
|
||||
this.releaseOperationLock();
|
||||
if (isHotSwap) this.pendingSound = null;
|
||||
reject(new Error('音频加载失败,请尝试切换其他歌曲'));
|
||||
}
|
||||
},
|
||||
onplayerror: (_, error) => {
|
||||
});
|
||||
|
||||
newSound.on('playerror', (_, error) => {
|
||||
console.error('Audio play error:', error);
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
||||
setTimeout(tryPlay, 1000 * retryCount);
|
||||
} else {
|
||||
// 发送URL过期事件,通知外部需要重新获取URL
|
||||
this.emit('url_expired', this.currentTrack);
|
||||
this.emit('url_expired', track);
|
||||
this.releaseOperationLock();
|
||||
if (isHotSwap) this.pendingSound = null;
|
||||
reject(new Error('音频播放失败,请尝试切换其他歌曲'));
|
||||
}
|
||||
},
|
||||
onload: async () => {
|
||||
});
|
||||
|
||||
const onLoaded = async () => {
|
||||
try {
|
||||
// 初始化音频管道
|
||||
await this.setupEQ(this.currentSound!);
|
||||
// 如果是热切换,现在执行切换逻辑
|
||||
if (isHotSwap) {
|
||||
console.log('audioService: 执行无缝切换');
|
||||
|
||||
// 1. 获取当前播放进度或使用指定的 seekTime
|
||||
let targetPos = 0;
|
||||
if (seekTime > 0) {
|
||||
// 如果有指定的 seekTime(如恢复播放进度),优先使用
|
||||
targetPos = seekTime;
|
||||
console.log(`audioService: 使用指定的 seekTime: ${seekTime}s`);
|
||||
} else if (this.currentSound) {
|
||||
// 否则同步当前进度
|
||||
targetPos = this.currentSound.seek() as number;
|
||||
}
|
||||
|
||||
// 2. 同步新音频进度
|
||||
newSound.seek(targetPos);
|
||||
|
||||
// 3. 初始化新音频的 EQ
|
||||
await this.disposeEQ(true);
|
||||
await this.setupEQ(newSound);
|
||||
|
||||
// 4. 播放新音频
|
||||
if (isPlay) {
|
||||
newSound.play();
|
||||
}
|
||||
|
||||
// 5. 停止旧音频
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.unload();
|
||||
}
|
||||
|
||||
// 6. 更新引用
|
||||
this.currentSound = newSound;
|
||||
this.currentTrack = track;
|
||||
this.pendingSound = null;
|
||||
|
||||
console.log(`audioService: 无缝切换完成,进度同步至 ${targetPos}s`);
|
||||
} else {
|
||||
// 普通加载逻辑
|
||||
await this.setupEQ(newSound);
|
||||
this.currentSound = newSound;
|
||||
}
|
||||
|
||||
// 重新应用已保存的音量
|
||||
const savedVolume = localStorage.getItem('volume');
|
||||
@@ -623,22 +729,23 @@ class AudioService {
|
||||
this.applyVolume(parseFloat(savedVolume));
|
||||
}
|
||||
|
||||
// 音频加载成功后设置 EQ 和更新媒体会话
|
||||
if (this.currentSound) {
|
||||
try {
|
||||
if (seekTime > 0) {
|
||||
if (!isHotSwap && seekTime > 0) {
|
||||
this.currentSound.seek(seekTime);
|
||||
}
|
||||
|
||||
console.log('audioService: 音频加载成功,设置 EQ');
|
||||
this.updateMediaSessionMetadata(track);
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('load');
|
||||
|
||||
// 此时音频已完全初始化,根据 isPlay 参数决定是否播放
|
||||
console.log('audioService: 音频完全初始化,isPlay =', isPlay);
|
||||
if (isPlay) {
|
||||
console.log('audioService: 开始播放');
|
||||
this.currentSound.play();
|
||||
if (!isHotSwap) {
|
||||
console.log('audioService: 音频完全初始化,isPlay =', isPlay);
|
||||
if (isPlay) {
|
||||
console.log('audioService: 开始播放');
|
||||
this.currentSound.play();
|
||||
}
|
||||
}
|
||||
|
||||
resolve(this.currentSound);
|
||||
@@ -651,28 +758,58 @@ class AudioService {
|
||||
console.error('Audio initialization failed:', error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (newSound.state() === 'loaded') {
|
||||
onLoaded();
|
||||
} else {
|
||||
newSound.once('load', onLoaded);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 设置音频事件监听
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('play', () => {
|
||||
this.updateMediaSessionState(true);
|
||||
this.emit('play');
|
||||
setupEvents();
|
||||
|
||||
if (isHotSwap) {
|
||||
this.pendingSound = newSound;
|
||||
} else {
|
||||
this.currentSound = newSound;
|
||||
}
|
||||
|
||||
// 设置音频事件监听 (play, pause, end, seek)
|
||||
// ... (保持原有的事件监听逻辑不变,但需要确保绑定到 newSound)
|
||||
const soundInstance = newSound;
|
||||
if (soundInstance) {
|
||||
// 清除旧的监听器以防重复
|
||||
soundInstance.off('play');
|
||||
soundInstance.off('pause');
|
||||
soundInstance.off('end');
|
||||
soundInstance.off('seek');
|
||||
|
||||
soundInstance.on('play', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.updateMediaSessionState(true);
|
||||
this.emit('play');
|
||||
}
|
||||
});
|
||||
|
||||
this.currentSound.on('pause', () => {
|
||||
this.updateMediaSessionState(false);
|
||||
this.emit('pause');
|
||||
soundInstance.on('pause', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.updateMediaSessionState(false);
|
||||
this.emit('pause');
|
||||
}
|
||||
});
|
||||
|
||||
this.currentSound.on('end', () => {
|
||||
this.emit('end');
|
||||
soundInstance.on('end', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.emit('end');
|
||||
}
|
||||
});
|
||||
|
||||
this.currentSound.on('seek', () => {
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('seek');
|
||||
soundInstance.on('seek', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('seek');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
294
src/renderer/services/playbackRequestManager.ts
Normal file
294
src/renderer/services/playbackRequestManager.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 播放请求管理器
|
||||
* 负责管理播放请求的队列、取消、状态跟踪,防止竞态条件
|
||||
*/
|
||||
|
||||
import type { SongResult } from '@/types/music';
|
||||
|
||||
/**
|
||||
* 请求状态枚举
|
||||
*/
|
||||
export enum RequestStatus {
|
||||
PENDING = 'pending',
|
||||
ACTIVE = 'active',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
FAILED = 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放请求接口
|
||||
*/
|
||||
export interface PlaybackRequest {
|
||||
id: string;
|
||||
song: SongResult;
|
||||
status: RequestStatus;
|
||||
timestamp: number;
|
||||
abortController?: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放请求管理器类
|
||||
*/
|
||||
class PlaybackRequestManager {
|
||||
private currentRequestId: string | null = null;
|
||||
private requestMap: Map<string, PlaybackRequest> = new Map();
|
||||
private requestCounter = 0;
|
||||
|
||||
/**
|
||||
* 生成唯一的请求ID
|
||||
*/
|
||||
private generateRequestId(): string {
|
||||
return `playback_${Date.now()}_${++this.requestCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的播放请求
|
||||
* @param song 要播放的歌曲
|
||||
* @returns 新请求的ID
|
||||
*/
|
||||
createRequest(song: SongResult): string {
|
||||
// 取消所有之前的请求
|
||||
this.cancelAllRequests();
|
||||
|
||||
const requestId = this.generateRequestId();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const request: PlaybackRequest = {
|
||||
id: requestId,
|
||||
song,
|
||||
status: RequestStatus.PENDING,
|
||||
timestamp: Date.now(),
|
||||
abortController
|
||||
};
|
||||
|
||||
this.requestMap.set(requestId, request);
|
||||
this.currentRequestId = requestId;
|
||||
|
||||
console.log(`[PlaybackRequestManager] 创建新请求: ${requestId}, 歌曲: ${song.name}`);
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活请求(标记为正在处理)
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
activateRequest(requestId: string): boolean {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.status === RequestStatus.CANCELLED) {
|
||||
console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
request.status = RequestStatus.ACTIVE;
|
||||
console.log(`[PlaybackRequestManager] 激活请求: ${requestId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成请求
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
completeRequest(requestId: string): void {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.status = RequestStatus.COMPLETED;
|
||||
console.log(`[PlaybackRequestManager] 完成请求: ${requestId}`);
|
||||
|
||||
// 清理旧请求(保留最近3个)
|
||||
this.cleanupOldRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记请求失败
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
failRequest(requestId: string): void {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.status = RequestStatus.FAILED;
|
||||
console.log(`[PlaybackRequestManager] 请求失败: ${requestId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消指定请求
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
cancelRequest(requestId: string): void {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.status === RequestStatus.CANCELLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消AbortController
|
||||
if (request.abortController && !request.abortController.signal.aborted) {
|
||||
request.abortController.abort();
|
||||
}
|
||||
|
||||
request.status = RequestStatus.CANCELLED;
|
||||
console.log(`[PlaybackRequestManager] 取消请求: ${requestId}, 歌曲: ${request.song.name}`);
|
||||
|
||||
// 如果是当前请求,清除当前请求ID
|
||||
if (this.currentRequestId === requestId) {
|
||||
this.currentRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
cancelAllRequests(): void {
|
||||
console.log(`[PlaybackRequestManager] 取消所有请求,当前请求数: ${this.requestMap.size}`);
|
||||
|
||||
this.requestMap.forEach((request) => {
|
||||
if (
|
||||
request.status !== RequestStatus.COMPLETED &&
|
||||
request.status !== RequestStatus.CANCELLED
|
||||
) {
|
||||
this.cancelRequest(request.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求是否仍然有效(是当前活动请求)
|
||||
* @param requestId 请求ID
|
||||
* @returns 是否有效
|
||||
*/
|
||||
isRequestValid(requestId: string): boolean {
|
||||
// 检查是否是当前请求
|
||||
if (this.currentRequestId !== requestId) {
|
||||
console.warn(
|
||||
`[PlaybackRequestManager] 请求已过期: ${requestId}, 当前请求: ${this.currentRequestId}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查请求状态
|
||||
if (request.status === RequestStatus.CANCELLED) {
|
||||
console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求是否应该中止(用于 AbortController)
|
||||
* @param requestId 请求ID
|
||||
* @returns AbortSignal 或 undefined
|
||||
*/
|
||||
getAbortSignal(requestId: string): AbortSignal | undefined {
|
||||
const request = this.requestMap.get(requestId);
|
||||
return request?.abortController?.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求ID
|
||||
*/
|
||||
getCurrentRequestId(): string | null {
|
||||
return this.currentRequestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求信息
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
getRequest(requestId: string): PlaybackRequest | undefined {
|
||||
return this.requestMap.get(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧请求(保留最近3个)
|
||||
*/
|
||||
private cleanupOldRequests(): void {
|
||||
if (this.requestMap.size <= 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按时间戳排序,保留最新的3个
|
||||
const sortedRequests = Array.from(this.requestMap.values()).sort(
|
||||
(a, b) => b.timestamp - a.timestamp
|
||||
);
|
||||
|
||||
const toKeep = new Set(sortedRequests.slice(0, 3).map((r) => r.id));
|
||||
const toDelete: string[] = [];
|
||||
|
||||
this.requestMap.forEach((_, id) => {
|
||||
if (!toKeep.has(id)) {
|
||||
toDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
toDelete.forEach((id) => {
|
||||
this.requestMap.delete(id);
|
||||
});
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
console.log(`[PlaybackRequestManager] 清理了 ${toDelete.length} 个旧请求`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置管理器(用于调试或特殊情况)
|
||||
*/
|
||||
reset(): void {
|
||||
console.log('[PlaybackRequestManager] 重置管理器');
|
||||
this.cancelAllRequests();
|
||||
this.requestMap.clear();
|
||||
this.currentRequestId = null;
|
||||
this.requestCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取调试信息
|
||||
*/
|
||||
getDebugInfo(): {
|
||||
currentRequestId: string | null;
|
||||
totalRequests: number;
|
||||
requestsByStatus: Record<string, number>;
|
||||
} {
|
||||
const requestsByStatus: Record<string, number> = {
|
||||
[RequestStatus.PENDING]: 0,
|
||||
[RequestStatus.ACTIVE]: 0,
|
||||
[RequestStatus.COMPLETED]: 0,
|
||||
[RequestStatus.CANCELLED]: 0,
|
||||
[RequestStatus.FAILED]: 0
|
||||
};
|
||||
|
||||
this.requestMap.forEach((request) => {
|
||||
requestsByStatus[request.status]++;
|
||||
});
|
||||
|
||||
return {
|
||||
currentRequestId: this.currentRequestId,
|
||||
totalRequests: this.requestMap.size,
|
||||
requestsByStatus
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const playbackRequestManager = new PlaybackRequestManager();
|
||||
141
src/renderer/services/preloadService.ts
Normal file
141
src/renderer/services/preloadService.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import type { SongResult } from '@/types/music';
|
||||
|
||||
class PreloadService {
|
||||
private loadingPromises: Map<string | number, Promise<Howl>> = new Map();
|
||||
private preloadedSounds: Map<string | number, Howl> = new Map();
|
||||
|
||||
/**
|
||||
* 加载并验证音频
|
||||
* 如果已经在加载中,返回现有的 Promise
|
||||
* 如果已经加载完成,返回缓存的 Howl 实例
|
||||
*/
|
||||
public async load(song: SongResult): Promise<Howl> {
|
||||
if (!song || !song.id) {
|
||||
throw new Error('无效的歌曲对象');
|
||||
}
|
||||
|
||||
// 1. 检查是否有正在进行的加载
|
||||
if (this.loadingPromises.has(song.id)) {
|
||||
console.log(`[PreloadService] 歌曲 ${song.name} 正在加载中,复用现有请求`);
|
||||
return this.loadingPromises.get(song.id)!;
|
||||
}
|
||||
|
||||
// 2. 检查是否有已完成的缓存
|
||||
if (this.preloadedSounds.has(song.id)) {
|
||||
const sound = this.preloadedSounds.get(song.id)!;
|
||||
if (sound.state() === 'loaded') {
|
||||
console.log(`[PreloadService] 歌曲 ${song.name} 已预加载完成,直接使用`);
|
||||
return sound;
|
||||
} else {
|
||||
// 如果缓存的音频状态不正常,清理并重新加载
|
||||
this.preloadedSounds.delete(song.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 开始新的加载过程
|
||||
const loadPromise = this._performLoad(song);
|
||||
this.loadingPromises.set(song.id, loadPromise);
|
||||
|
||||
try {
|
||||
const sound = await loadPromise;
|
||||
this.preloadedSounds.set(song.id, sound);
|
||||
return sound;
|
||||
} finally {
|
||||
this.loadingPromises.delete(song.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际的加载和验证逻辑
|
||||
*/
|
||||
private async _performLoad(song: SongResult): Promise<Howl> {
|
||||
console.log(`[PreloadService] 开始加载歌曲: ${song.name}`);
|
||||
|
||||
if (!song.playMusicUrl) {
|
||||
throw new Error('歌曲没有 URL');
|
||||
}
|
||||
|
||||
// 创建初始音频实例
|
||||
const sound = await this._createSound(song.playMusicUrl);
|
||||
|
||||
// 检查时长
|
||||
const duration = sound.duration();
|
||||
const expectedDuration = (song.dt || 0) / 1000;
|
||||
|
||||
// 时长差异只记录警告,不自动触发重新解析
|
||||
// 用户可以通过 ReparsePopover 手动选择正确的音源
|
||||
if (
|
||||
expectedDuration > 0 &&
|
||||
Math.abs(duration - expectedDuration) > 5 &&
|
||||
song.source !== 'bilibili'
|
||||
) {
|
||||
console.warn(
|
||||
`[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})`
|
||||
);
|
||||
}
|
||||
|
||||
return sound;
|
||||
}
|
||||
|
||||
private _createSound(url: string): Promise<Howl> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false,
|
||||
onload: () => resolve(sound),
|
||||
onloaderror: (_, err) => reject(err)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消特定歌曲的预加载(如果可能)
|
||||
* 注意:Promise 无法真正取消,但我们可以清理结果
|
||||
*/
|
||||
public cancel(songId: string | number) {
|
||||
if (this.preloadedSounds.has(songId)) {
|
||||
const sound = this.preloadedSounds.get(songId)!;
|
||||
sound.unload();
|
||||
this.preloadedSounds.delete(songId);
|
||||
}
|
||||
// loadingPromises 中的任务会继续执行,但因为 preloadedSounds 中没有记录,
|
||||
// 下次请求时会重新加载(或者我们可以让 _performLoad 检查一个取消标记,但这增加了复杂性)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已预加载的音频实例(如果存在)
|
||||
*/
|
||||
public getPreloadedSound(songId: string | number): Howl | undefined {
|
||||
return this.preloadedSounds.get(songId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消耗(使用)已预加载的音频
|
||||
* 从缓存中移除但不 unload(由调用方管理生命周期)
|
||||
* @returns 预加载的 Howl 实例,如果没有则返回 undefined
|
||||
*/
|
||||
public consume(songId: string | number): Howl | undefined {
|
||||
const sound = this.preloadedSounds.get(songId);
|
||||
if (sound) {
|
||||
this.preloadedSounds.delete(songId);
|
||||
console.log(`[PreloadService] 消耗预加载的歌曲: ${songId}`);
|
||||
return sound;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有预加载资源
|
||||
*/
|
||||
public clearAll() {
|
||||
this.preloadedSounds.forEach((sound) => sound.unload());
|
||||
this.preloadedSounds.clear();
|
||||
this.loadingPromises.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const preloadService = new PreloadService();
|
||||
@@ -9,6 +9,9 @@ import { getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import { preloadService } from '@/services/preloadService';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import type { Platform, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
@@ -31,7 +34,7 @@ export const usePlayerCoreStore = defineStore(
|
||||
const musicFull = ref(false);
|
||||
const playbackRate = ref(1.0);
|
||||
const volume = ref(1);
|
||||
const userPlayIntent = ref(true);
|
||||
const userPlayIntent = ref(false); // 用户是否想要播放
|
||||
|
||||
let checkPlayTime: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -100,25 +103,35 @@ export const usePlayerCoreStore = defineStore(
|
||||
/**
|
||||
* 播放状态检测
|
||||
*/
|
||||
const checkPlaybackState = (song: SongResult, timeout: number = 4000) => {
|
||||
const checkPlaybackState = (song: SongResult, requestId?: string, timeout: number = 4000) => {
|
||||
if (checkPlayTime) {
|
||||
clearTimeout(checkPlayTime);
|
||||
}
|
||||
const sound = audioService.getCurrentSound();
|
||||
if (!sound) return;
|
||||
|
||||
// 如果没有提供 requestId,创建一个临时标识
|
||||
const actualRequestId = requestId || `check_${Date.now()}`;
|
||||
|
||||
const onPlayHandler = () => {
|
||||
console.log('播放事件触发,歌曲成功开始播放');
|
||||
console.log(`[${actualRequestId}] 播放事件触发,歌曲成功开始播放`);
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
};
|
||||
|
||||
const onPlayErrorHandler = async () => {
|
||||
console.log('播放错误事件触发,尝试重新获取URL');
|
||||
console.log('播放错误事件触发,检查是否需要重新获取URL');
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
|
||||
// 如果有 requestId,验证其有效性
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log('请求已过期,跳过重试');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userPlayIntent.value && play.value) {
|
||||
console.log('播放失败,尝试刷新URL并重新播放');
|
||||
playMusic.value.playMusicUrl = undefined;
|
||||
const refreshedSong = { ...song, isFirstPlay: true };
|
||||
await handlePlayMusic(refreshedSong, true);
|
||||
@@ -129,6 +142,14 @@ export const usePlayerCoreStore = defineStore(
|
||||
audioService.on('playerror', onPlayErrorHandler);
|
||||
|
||||
checkPlayTime = setTimeout(() => {
|
||||
// 如果有 requestId,验证其有效性
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log('请求已过期,跳过超时重试');
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!audioService.isActuallyPlaying() && userPlayIntent.value && play.value) {
|
||||
console.log(`${timeout}ms后歌曲未真正播放且用户仍希望播放,尝试重新获取URL`);
|
||||
audioService.off('play', onPlayHandler);
|
||||
@@ -147,6 +168,15 @@ export const usePlayerCoreStore = defineStore(
|
||||
* 核心播放处理函数
|
||||
*/
|
||||
const handlePlayMusic = async (music: SongResult, isPlay: boolean = true) => {
|
||||
// 如果是新歌曲,重置已尝试的音源(使用 SongSourceConfigManager 按歌曲隔离)
|
||||
if (music.id !== playMusic.value.id) {
|
||||
SongSourceConfigManager.clearTriedSources(music.id);
|
||||
}
|
||||
|
||||
// 创建新的播放请求并取消之前的所有请求
|
||||
const requestId = playbackRequestManager.createRequest(music);
|
||||
console.log(`[handlePlayMusic] 开始处理歌曲: ${music.name}, 请求ID: ${requestId}`);
|
||||
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
console.log('主动停止并卸载当前音频实例');
|
||||
@@ -154,6 +184,18 @@ export const usePlayerCoreStore = defineStore(
|
||||
currentSound.unload();
|
||||
}
|
||||
|
||||
// 验证请求是否仍然有效
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[handlePlayMusic] 请求已失效: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 激活请求
|
||||
if (!playbackRequestManager.activateRequest(requestId)) {
|
||||
console.log(`[handlePlayMusic] 无法激活请求: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const originalMusic = { ...music };
|
||||
const { loadLrc } = useLyrics();
|
||||
const { getSongDetail } = useSongDetail();
|
||||
@@ -174,6 +216,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
})()
|
||||
]);
|
||||
|
||||
// 在更新状态前再次验证请求
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[handlePlayMusic] 加载歌词/背景色后请求已失效: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置歌词和背景色
|
||||
music.lyric = lyrics;
|
||||
music.backgroundColor = backgroundColor;
|
||||
@@ -201,13 +249,42 @@ export const usePlayerCoreStore = defineStore(
|
||||
musicHistory.addMusic(music);
|
||||
|
||||
// 获取歌曲详情
|
||||
const updatedPlayMusic = await getSongDetail(originalMusic);
|
||||
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
|
||||
|
||||
// 在获取详情后再次验证请求
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[handlePlayMusic] 获取歌曲详情后请求已失效: ${requestId}`);
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
return false;
|
||||
}
|
||||
|
||||
updatedPlayMusic.lyric = lyrics;
|
||||
|
||||
playMusic.value = updatedPlayMusic;
|
||||
playMusicUrl.value = updatedPlayMusic.playMusicUrl as string;
|
||||
music.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
|
||||
|
||||
// 在拆分后补充:触发预加载下一首/下下首(与 playlist store 保持一致)
|
||||
try {
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
// 基于当前歌曲在播放列表中的位置来预加载
|
||||
const list = playlistStore.playList;
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
const idx = list.findIndex(
|
||||
(item: SongResult) =>
|
||||
item.id === updatedPlayMusic.id && item.source === updatedPlayMusic.source
|
||||
);
|
||||
if (idx !== -1) {
|
||||
setTimeout(() => {
|
||||
playlistStore.preloadNextSongs(idx);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('预加载触发失败(可能是依赖未加载或循环依赖),已忽略:', e);
|
||||
}
|
||||
|
||||
let playInProgress = false;
|
||||
|
||||
try {
|
||||
@@ -217,12 +294,20 @@ export const usePlayerCoreStore = defineStore(
|
||||
}
|
||||
|
||||
playInProgress = true;
|
||||
const result = await playAudio();
|
||||
const result = await playAudio(requestId);
|
||||
playInProgress = false;
|
||||
return !!result;
|
||||
|
||||
if (result) {
|
||||
playbackRequestManager.completeRequest(requestId);
|
||||
return true;
|
||||
} else {
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动播放音频失败:', error);
|
||||
playInProgress = false;
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -231,6 +316,8 @@ export const usePlayerCoreStore = defineStore(
|
||||
if (playMusic.value) {
|
||||
playMusic.value.playLoading = false;
|
||||
}
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -238,9 +325,15 @@ export const usePlayerCoreStore = defineStore(
|
||||
/**
|
||||
* 播放音频
|
||||
*/
|
||||
const playAudio = async () => {
|
||||
const playAudio = async (requestId?: string) => {
|
||||
if (!playMusicUrl.value || !playMusic.value) return null;
|
||||
|
||||
// 如果提供了 requestId,验证请求是否仍然有效
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[playAudio] 请求已失效: ${requestId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldPlay = play.value;
|
||||
console.log('播放音频,当前播放状态:', shouldPlay ? '播放' : '暂停');
|
||||
@@ -248,8 +341,15 @@ export const usePlayerCoreStore = defineStore(
|
||||
// 检查保存的进度
|
||||
let initialPosition = 0;
|
||||
const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}');
|
||||
console.log(
|
||||
'[playAudio] 读取保存的进度:',
|
||||
savedProgress,
|
||||
'当前歌曲ID:',
|
||||
playMusic.value.id
|
||||
);
|
||||
if (savedProgress.songId === playMusic.value.id) {
|
||||
initialPosition = savedProgress.progress;
|
||||
console.log('[playAudio] 恢复播放进度:', initialPosition);
|
||||
}
|
||||
|
||||
// B站视频URL检查
|
||||
@@ -266,6 +366,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
playMusic.value.bilibiliData.cid
|
||||
);
|
||||
|
||||
// 再次验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[playAudio] 获取B站URL后请求已失效: ${requestId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
(playMusic.value as any).playMusicUrl = proxyUrl;
|
||||
playMusicUrl.value = proxyUrl;
|
||||
} catch (error) {
|
||||
@@ -276,17 +382,48 @@ export const usePlayerCoreStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
// 播放新音频
|
||||
// 使用 PreloadService 获取音频
|
||||
// 优先使用已预加载的 sound(通过 consume 获取并从缓存中移除)
|
||||
// 如果没有预加载,则进行加载
|
||||
let sound: Howl;
|
||||
try {
|
||||
// 先尝试消耗预加载的 sound
|
||||
const preloadedSound = preloadService.consume(playMusic.value.id);
|
||||
if (preloadedSound && preloadedSound.state() === 'loaded') {
|
||||
console.log(`[playAudio] 使用预加载的音频: ${playMusic.value.name}`);
|
||||
sound = preloadedSound;
|
||||
} else {
|
||||
// 没有预加载或预加载状态不正常,需要加载
|
||||
console.log(`[playAudio] 没有预加载,开始加载: ${playMusic.value.name}`);
|
||||
sound = await preloadService.load(playMusic.value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PreloadService 加载失败:', error);
|
||||
// 如果 PreloadService 失败,尝试直接播放作为回退
|
||||
// 但通常 PreloadService 失败意味着 URL 问题
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 播放新音频,传入已加载的 sound 实例
|
||||
const newSound = await audioService.play(
|
||||
playMusicUrl.value,
|
||||
playMusic.value,
|
||||
shouldPlay,
|
||||
initialPosition || 0
|
||||
initialPosition || 0,
|
||||
sound
|
||||
);
|
||||
|
||||
// 播放后再次验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[playAudio] 播放后请求已失效: ${requestId}`);
|
||||
newSound.stop();
|
||||
newSound.unload();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 添加播放状态检测
|
||||
if (shouldPlay) {
|
||||
checkPlaybackState(playMusic.value);
|
||||
if (shouldPlay && requestId) {
|
||||
checkPlaybackState(playMusic.value, requestId);
|
||||
}
|
||||
|
||||
// 发布音频就绪事件
|
||||
@@ -294,6 +431,8 @@ export const usePlayerCoreStore = defineStore(
|
||||
new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } })
|
||||
);
|
||||
|
||||
// 时长检查已在 preloadService.ts 中完成
|
||||
|
||||
return newSound;
|
||||
} catch (error) {
|
||||
console.error('播放音频失败:', error);
|
||||
@@ -313,12 +452,19 @@ export const usePlayerCoreStore = defineStore(
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// 验证请求是否仍然有效再重试
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log('重试时请求已失效,跳过重试');
|
||||
return;
|
||||
}
|
||||
if (userPlayIntent.value && play.value) {
|
||||
playAudio().catch((e) => {
|
||||
playAudio(requestId).catch((e) => {
|
||||
console.error('重试播放失败:', e);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
console.warn('播放音频失败(非操作锁错误),由调用方处理重试');
|
||||
}
|
||||
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
@@ -360,7 +506,7 @@ export const usePlayerCoreStore = defineStore(
|
||||
/**
|
||||
* 使用指定音源重新解析当前歌曲
|
||||
*/
|
||||
const reparseCurrentSong = async (sourcePlatform: Platform) => {
|
||||
const reparseCurrentSong = async (sourcePlatform: Platform, isAuto: boolean = false) => {
|
||||
try {
|
||||
const currentSong = playMusic.value;
|
||||
if (!currentSong || !currentSong.id) {
|
||||
@@ -373,8 +519,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
return false;
|
||||
}
|
||||
|
||||
const songId = String(currentSong.id);
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([sourcePlatform]));
|
||||
// 使用 SongSourceConfigManager 保存配置
|
||||
SongSourceConfigManager.setConfig(
|
||||
currentSong.id,
|
||||
[sourcePlatform],
|
||||
isAuto ? 'auto' : 'manual'
|
||||
);
|
||||
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
@@ -400,6 +550,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
};
|
||||
|
||||
await handlePlayMusic(updatedMusic, true);
|
||||
|
||||
// 更新播放列表中的歌曲信息
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
playlistStore.updateSong(updatedMusic);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`使用音源 ${sourcePlatform} 解析失败`);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { defineStore, storeToRefs } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { preloadNextSong, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { preloadService } from '@/services/preloadService';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { performShuffle, preloadCoverImage } from '@/utils/playerUtils';
|
||||
@@ -30,6 +31,11 @@ export const usePlaylistStore = defineStore(
|
||||
const originalPlayList = shallowRef<SongResult[]>([]);
|
||||
const playListDrawerVisible = ref(false);
|
||||
|
||||
// 连续失败计数器(用于防止无限循环)
|
||||
const consecutiveFailCount = ref(0);
|
||||
const MAX_CONSECUTIVE_FAILS = 5; // 最大连续失败次数
|
||||
const SINGLE_TRACK_MAX_RETRIES = 3; // 单曲最大重试次数
|
||||
|
||||
// ==================== Computed ====================
|
||||
const currentPlayList = computed(() => playList.value);
|
||||
const currentPlayListIndex = computed(() => playListIndex.value);
|
||||
@@ -81,7 +87,7 @@ export const usePlaylistStore = defineStore(
|
||||
// 预加载下一首歌曲的音频和封面
|
||||
if (nextSong) {
|
||||
if (nextSong.playMusicUrl) {
|
||||
preloadNextSong(nextSong.playMusicUrl);
|
||||
preloadService.load(nextSong);
|
||||
}
|
||||
if (nextSong.picUrl) {
|
||||
preloadCoverImage(nextSong.picUrl, getImgUrl);
|
||||
@@ -133,37 +139,49 @@ export const usePlaylistStore = defineStore(
|
||||
* 应用随机播放
|
||||
*/
|
||||
const shufflePlayList = () => {
|
||||
if (playList.value.length <= 1) return;
|
||||
console.log('[PlaylistStore] shufflePlayList called');
|
||||
if (playList.value.length === 0) return;
|
||||
|
||||
// 保存原始播放列表
|
||||
// 保存原始列表
|
||||
if (originalPlayList.value.length === 0) {
|
||||
console.log('[PlaylistStore] Saving original list, length:', playList.value.length);
|
||||
originalPlayList.value = [...playList.value];
|
||||
}
|
||||
|
||||
const currentSong = playList.value[playListIndex.value];
|
||||
const shuffledList = performShuffle(playList.value, currentSong);
|
||||
console.log('[PlaylistStore] Current song before shuffle:', currentSong?.name);
|
||||
|
||||
playList.value = shuffledList;
|
||||
// 执行洗牌
|
||||
const shuffled = performShuffle([...playList.value], currentSong);
|
||||
// 确保触发 shallowRef 的响应式
|
||||
playList.value = [...shuffled];
|
||||
playListIndex.value = 0;
|
||||
// pinia-plugin-persistedstate 会自动保存状态
|
||||
|
||||
console.log('[PlaylistStore] List shuffled, new length:', playList.value.length);
|
||||
console.log('[PlaylistStore] New first song:', playList.value[0]?.name);
|
||||
};
|
||||
|
||||
/**
|
||||
* 恢复原始播放列表顺序
|
||||
*/
|
||||
const restoreOriginalOrder = () => {
|
||||
console.log('[PlaylistStore] restoreOriginalOrder called');
|
||||
if (originalPlayList.value.length === 0) return;
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const { playMusic } = storeToRefs(playerCore);
|
||||
const currentSong = playMusic.value;
|
||||
const originalIndex = originalPlayList.value.findIndex((song) => song.id === currentSong.id);
|
||||
const currentSong = playList.value[playListIndex.value];
|
||||
console.log('[PlaylistStore] Current song before restore:', currentSong?.name);
|
||||
|
||||
playList.value = [...originalPlayList.value];
|
||||
playListIndex.value = Math.max(0, originalIndex);
|
||||
|
||||
originalPlayList.value = [];
|
||||
// pinia-plugin-persistedstate 会自动保存状态
|
||||
|
||||
// 找到当前歌曲在原始列表中的索引
|
||||
if (currentSong) {
|
||||
const index = playList.value.findIndex((s) => s.id === currentSong.id);
|
||||
if (index !== -1) {
|
||||
playListIndex.value = index;
|
||||
}
|
||||
}
|
||||
console.log('[PlaylistStore] Original order restored, new index:', playListIndex.value);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -297,21 +315,22 @@ export const usePlaylistStore = defineStore(
|
||||
const togglePlayMode = async () => {
|
||||
const { useUserStore } = await import('./user');
|
||||
const userStore = useUserStore();
|
||||
const wasIntelligence = playMode.value === 3;
|
||||
const newMode = (playMode.value + 1) % 4;
|
||||
const wasRandom = playMode.value === 2;
|
||||
const wasIntelligence = playMode.value === 3;
|
||||
|
||||
let newMode = (playMode.value + 1) % 4;
|
||||
|
||||
// 如果要切换到心动模式,但用户未使用cookie登录,则跳过
|
||||
if (newMode === 3 && (!userStore.user || userStore.loginType !== 'cookie')) {
|
||||
console.log('跳过心动模式:需要cookie登录');
|
||||
newMode = 0;
|
||||
}
|
||||
|
||||
const isRandom = newMode === 2;
|
||||
const isIntelligence = newMode === 3;
|
||||
|
||||
// 如果要切换到心动模式,但用户未使用cookie登录,则跳过
|
||||
if (isIntelligence && (!userStore.user || userStore.loginType !== 'cookie')) {
|
||||
console.log('跳过心动模式:需要cookie登录');
|
||||
playMode.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[PlaylistStore] togglePlayMode: ${playMode.value} -> ${newMode}`);
|
||||
playMode.value = newMode;
|
||||
// pinia-plugin-persistedstate 会自动保存状态
|
||||
|
||||
// 切换到随机模式时洗牌
|
||||
if (isRandom && !wasRandom && playList.value.length > 0) {
|
||||
@@ -320,7 +339,7 @@ export const usePlaylistStore = defineStore(
|
||||
}
|
||||
|
||||
// 从随机模式切换出去时恢复原始顺序
|
||||
if (!isRandom && wasRandom && !isIntelligence) {
|
||||
if (!isRandom && wasRandom) {
|
||||
restoreOriginalOrder();
|
||||
console.log('切换出随机模式,恢复原始顺序');
|
||||
}
|
||||
@@ -342,8 +361,9 @@ export const usePlaylistStore = defineStore(
|
||||
|
||||
/**
|
||||
* 下一首
|
||||
* @param singleTrackRetryCount 单曲重试次数(同一首歌的重试)
|
||||
*/
|
||||
const _nextPlay = async () => {
|
||||
const _nextPlay = async (singleTrackRetryCount: number = 0) => {
|
||||
try {
|
||||
if (playList.value.length === 0) {
|
||||
return;
|
||||
@@ -352,6 +372,15 @@ export const usePlaylistStore = defineStore(
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const sleepTimerStore = useSleepTimerStore();
|
||||
|
||||
// 检查是否超过最大连续失败次数
|
||||
if (consecutiveFailCount.value >= MAX_CONSECUTIVE_FAILS) {
|
||||
console.error(`[nextPlay] 连续${MAX_CONSECUTIVE_FAILS}首歌曲播放失败,停止播放`);
|
||||
message.warning(i18n.global.t('player.consecutiveFailsError'));
|
||||
consecutiveFailCount.value = 0; // 重置计数器
|
||||
playerCore.setIsPlay(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是播放列表的最后一首且设置了播放列表结束定时
|
||||
if (
|
||||
playMode.value === 0 &&
|
||||
@@ -366,17 +395,63 @@ export const usePlaylistStore = defineStore(
|
||||
const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
|
||||
const nextSong = { ...playList.value[nowPlayListIndex] };
|
||||
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
console.log(
|
||||
`[nextPlay] 尝试播放: ${nextSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}, 单曲重试: ${singleTrackRetryCount}/${SINGLE_TRACK_MAX_RETRIES}, 连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}`
|
||||
);
|
||||
console.log(
|
||||
'[nextPlay] Current mode:',
|
||||
playMode.value,
|
||||
'Playlist length:',
|
||||
playList.value.length
|
||||
);
|
||||
|
||||
// 先尝试播放歌曲
|
||||
const success = await playerCore.handlePlayMusic(nextSong, true);
|
||||
|
||||
if (success) {
|
||||
// 播放成功,重置所有计数器并更新索引
|
||||
consecutiveFailCount.value = 0;
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
console.log(`[nextPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`);
|
||||
console.log(
|
||||
'[nextPlay] New current song in list:',
|
||||
playList.value[playListIndex.value]?.name
|
||||
);
|
||||
sleepTimerStore.handleSongChange();
|
||||
} else {
|
||||
console.error('播放下一首失败');
|
||||
playListIndex.value = currentIndex;
|
||||
playerCore.setIsPlay(false);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
console.error(`[nextPlay] 播放失败: ${nextSong.name}`);
|
||||
|
||||
// 单曲重试逻辑
|
||||
if (singleTrackRetryCount < SINGLE_TRACK_MAX_RETRIES) {
|
||||
console.log(
|
||||
`[nextPlay] 单曲重试 ${singleTrackRetryCount + 1}/${SINGLE_TRACK_MAX_RETRIES}`
|
||||
);
|
||||
// 不更新索引,重试同一首歌
|
||||
setTimeout(() => {
|
||||
_nextPlay(singleTrackRetryCount + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
// 单曲重试次数用尽,递增连续失败计数,尝试下一首
|
||||
consecutiveFailCount.value++;
|
||||
console.log(
|
||||
`[nextPlay] 单曲重试用尽,连续失败计数: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}`
|
||||
);
|
||||
|
||||
if (playList.value.length > 1) {
|
||||
// 更新索引到失败的歌曲位置,这样下次递归调用会继续往下
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
message.warning(i18n.global.t('player.parseFailedPlayNext'));
|
||||
|
||||
// 延迟后尝试下一首(重置单曲重试计数)
|
||||
setTimeout(() => {
|
||||
_nextPlay(0);
|
||||
}, 500);
|
||||
} else {
|
||||
// 只有一首歌且失败
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
playerCore.setIsPlay(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换下一首出错:', error);
|
||||
@@ -400,12 +475,16 @@ export const usePlaylistStore = defineStore(
|
||||
(playListIndex.value - 1 + playList.value.length) % playList.value.length;
|
||||
|
||||
const prevSong = { ...playList.value[nowPlayListIndex] };
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
|
||||
console.log(
|
||||
`[prevPlay] 尝试播放上一首: ${prevSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}`
|
||||
);
|
||||
|
||||
let success = false;
|
||||
let retryCount = 0;
|
||||
const maxRetries = 2;
|
||||
|
||||
// 先尝试播放歌曲,成功后再更新索引
|
||||
while (!success && retryCount < maxRetries) {
|
||||
success = await playerCore.handlePlayMusic(prevSong);
|
||||
|
||||
@@ -442,9 +521,12 @@ export const usePlaylistStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.error('所有尝试都失败,无法播放上一首歌曲');
|
||||
playListIndex.value = currentIndex;
|
||||
if (success) {
|
||||
// 播放成功,更新索引
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
console.log(`[prevPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`);
|
||||
} else {
|
||||
console.error(`[prevPlay] 播放上一首失败,保持当前索引: ${currentIndex}`);
|
||||
playerCore.setIsPlay(false);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
}
|
||||
@@ -494,6 +576,7 @@ export const usePlaylistStore = defineStore(
|
||||
const sound = audioService.getCurrentSound();
|
||||
if (sound) {
|
||||
sound.play();
|
||||
// 在恢复播放时也进行状态检测,防止URL已过期导致无声
|
||||
playerCore.checkPlaybackState(playerCore.playMusic);
|
||||
}
|
||||
}
|
||||
@@ -579,7 +662,17 @@ export const usePlaylistStore = defineStore(
|
||||
setPlayListDrawerVisible,
|
||||
setPlay,
|
||||
initializePlaylist,
|
||||
fetchSongs
|
||||
fetchSongs,
|
||||
updateSong: (song: SongResult) => {
|
||||
const index = playList.value.findIndex(
|
||||
(item) => item.id === song.id && item.source === song.source
|
||||
);
|
||||
if (index !== -1) {
|
||||
playList.value[index] = song;
|
||||
// 触发响应式更新
|
||||
playList.value = [...playList.value];
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cloneDeep, merge } from 'lodash';
|
||||
import { cloneDeep, isArray, mergeWith } from 'lodash';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
@@ -55,8 +55,16 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
? window.electron.ipcRenderer.sendSync('get-store-value', 'set')
|
||||
: JSON.parse(localStorage.getItem('appSettings') || '{}');
|
||||
|
||||
// 自定义合并策略:如果是数组,直接使用源数组(覆盖默认值)
|
||||
const customizer = (_objValue: any, srcValue: any) => {
|
||||
if (isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 合并默认设置和保存的设置
|
||||
const mergedSettings = merge({}, setDataDefault, savedSettings);
|
||||
const mergedSettings = mergeWith({}, setDataDefault, savedSettings, customizer);
|
||||
|
||||
// 更新设置并返回
|
||||
setSetData(mergedSettings);
|
||||
|
||||
@@ -88,14 +88,14 @@ export const useSleepTimerStore = defineStore('sleepTimer', () => {
|
||||
/**
|
||||
* 按歌曲数设置定时关闭
|
||||
*/
|
||||
const setSleepTimerBySongs = (songs: number) => {
|
||||
const setSleepTimerBySongs = async (songs: number) => {
|
||||
clearSleepTimer();
|
||||
|
||||
if (songs <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { usePlaylistStore } = require('./playlist');
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
sleepTimer.value = {
|
||||
|
||||
2
src/renderer/types/electron.d.ts
vendored
2
src/renderer/types/electron.d.ts
vendored
@@ -8,6 +8,8 @@ export interface IElectronAPI {
|
||||
openLyric: () => void;
|
||||
sendLyric: (_data: string) => void;
|
||||
unblockMusic: (_id: number) => Promise<string>;
|
||||
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
|
||||
importLxMusicScript: () => Promise<{ name: string; content: string } | null>;
|
||||
onLanguageChanged: (_callback: (_locale: string) => void) => void;
|
||||
store: {
|
||||
get: (_key: string) => Promise<any>;
|
||||
|
||||
146
src/renderer/types/lxMusic.ts
Normal file
146
src/renderer/types/lxMusic.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 落雪音乐 (LX Music) 自定义源类型定义
|
||||
*
|
||||
* 参考文档: https://lxmusic.toside.cn/desktop/custom-source
|
||||
*/
|
||||
|
||||
/**
|
||||
* 脚本元信息(从注释头解析)
|
||||
*/
|
||||
export type LxScriptInfo = {
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
rawScript: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 支持的音质类型
|
||||
*/
|
||||
export type LxQuality = '128k' | '320k' | 'flac' | 'flac24bit';
|
||||
|
||||
/**
|
||||
* 支持的音源 key
|
||||
* - kw: 酷我
|
||||
* - kg: 酷狗
|
||||
* - tx: QQ音乐
|
||||
* - wy: 网易云
|
||||
* - mg: 咪咕
|
||||
* - local: 本地音乐
|
||||
*/
|
||||
export type LxSourceKey = 'kw' | 'kg' | 'tx' | 'wy' | 'mg' | 'local';
|
||||
|
||||
/**
|
||||
* 音源配置
|
||||
*/
|
||||
export type LxSourceConfig = {
|
||||
name: string;
|
||||
type: 'music';
|
||||
actions: ('musicUrl' | 'lyric' | 'pic')[];
|
||||
qualitys: LxQuality[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化事件数据
|
||||
*/
|
||||
export type LxInitedData = {
|
||||
openDevTools?: boolean;
|
||||
sources: Partial<Record<LxSourceKey, LxSourceConfig>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求事件数据
|
||||
*/
|
||||
export type LxRequestData = {
|
||||
source: LxSourceKey;
|
||||
action: 'musicUrl' | 'lyric' | 'pic';
|
||||
info: {
|
||||
type: LxQuality | null;
|
||||
musicInfo: LxMusicInfo;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 落雪音乐信息格式
|
||||
* 需要从 SongResult 转换而来
|
||||
*/
|
||||
export type LxMusicInfo = {
|
||||
songmid: string | number;
|
||||
name: string;
|
||||
singer: string;
|
||||
album?: string;
|
||||
albumId?: string | number;
|
||||
source?: string;
|
||||
interval?: string;
|
||||
img?: string;
|
||||
types?: { type: LxQuality; size?: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 歌词返回格式
|
||||
*/
|
||||
export type LxLyricResult = {
|
||||
lyric: string;
|
||||
tlyric?: string | null;
|
||||
rlyric?: string | null;
|
||||
lxlyric?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 存储在 settings 中的单个落雪音源配置
|
||||
*/
|
||||
export type LxMusicScriptConfig = {
|
||||
id: string; // 唯一标识
|
||||
name: string; // 用户自定义名称,可编辑
|
||||
script: string; // 脚本内容
|
||||
info: LxScriptInfo; // 解析的脚本元信息
|
||||
sources: LxSourceKey[];
|
||||
enabled: boolean; // 是否启用
|
||||
createdAt: number; // 创建时间戳
|
||||
};
|
||||
|
||||
/**
|
||||
* 存储在 settings 中的落雪音源列表
|
||||
*/
|
||||
export type LxMusicApiList = {
|
||||
apis: LxMusicScriptConfig[];
|
||||
activeId: string | null; // 当前激活的音源 ID
|
||||
};
|
||||
|
||||
/**
|
||||
* globalThis.lx API 的事件名称
|
||||
*/
|
||||
export const LX_EVENT_NAMES = {
|
||||
inited: 'inited',
|
||||
request: 'request',
|
||||
updateAlert: 'updateAlert'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 落雪音源 key 到平台名称的映射
|
||||
*/
|
||||
export const LX_SOURCE_NAMES: Record<LxSourceKey, string> = {
|
||||
kw: '酷我',
|
||||
kg: '酷狗',
|
||||
tx: 'QQ音乐',
|
||||
wy: '网易云',
|
||||
mg: '咪咕',
|
||||
local: '本地'
|
||||
};
|
||||
|
||||
/**
|
||||
* 本项目音质到落雪音质的映射
|
||||
*/
|
||||
export const QUALITY_TO_LX: Record<string, LxQuality> = {
|
||||
standard: '128k',
|
||||
higher: '320k',
|
||||
exhigh: '320k',
|
||||
lossless: 'flac',
|
||||
hires: 'flac24bit',
|
||||
jyeffect: 'flac',
|
||||
sky: 'flac',
|
||||
dolby: 'flac',
|
||||
jymaster: 'flac24bit'
|
||||
};
|
||||
@@ -3,6 +3,7 @@ export interface LyricConfig {
|
||||
centerLyrics: boolean;
|
||||
fontSize: number;
|
||||
letterSpacing: number;
|
||||
fontWeight: number;
|
||||
lineHeight: number;
|
||||
showTranslation: boolean;
|
||||
theme: 'default' | 'light' | 'dark';
|
||||
@@ -11,10 +12,23 @@ export interface LyricConfig {
|
||||
pureModeEnabled: boolean;
|
||||
hideMiniPlayBar: boolean;
|
||||
hideLyrics: boolean;
|
||||
contentWidth: number; // 内容区域宽度百分比
|
||||
// 移动端配置
|
||||
mobileLayout: 'default' | 'ios' | 'android';
|
||||
mobileCoverStyle: 'record' | 'square' | 'full';
|
||||
mobileShowLyricLines: number;
|
||||
// 背景自定义功能
|
||||
useCustomBackground: boolean; // 是否使用自定义背景
|
||||
backgroundMode: 'solid' | 'gradient' | 'image' | 'css'; // 背景模式
|
||||
solidColor: string; // 纯色背景颜色值
|
||||
gradientColors: {
|
||||
colors: string[]; // 渐变颜色数组
|
||||
direction: string; // 渐变方向
|
||||
};
|
||||
backgroundImage?: string; // 图片背景 (Base64 或 URL)
|
||||
imageBlur: number; // 图片模糊度 (0-20px)
|
||||
imageBrightness: number; // 图片明暗度 (0-200%, 100为正常)
|
||||
customCss?: string; // 自定义 CSS 样式
|
||||
}
|
||||
|
||||
export const DEFAULT_LYRIC_CONFIG: LyricConfig = {
|
||||
@@ -22,6 +36,7 @@ export const DEFAULT_LYRIC_CONFIG: LyricConfig = {
|
||||
centerLyrics: false,
|
||||
fontSize: 22,
|
||||
letterSpacing: 0,
|
||||
fontWeight: 500,
|
||||
lineHeight: 2,
|
||||
showTranslation: true,
|
||||
theme: 'default',
|
||||
@@ -29,12 +44,25 @@ export const DEFAULT_LYRIC_CONFIG: LyricConfig = {
|
||||
hideMiniPlayBar: false,
|
||||
pureModeEnabled: false,
|
||||
hideLyrics: false,
|
||||
contentWidth: 75, // 默认100%宽度
|
||||
// 移动端默认配置
|
||||
mobileLayout: 'ios',
|
||||
mobileCoverStyle: 'full',
|
||||
mobileShowLyricLines: 3,
|
||||
// 翻译引擎: 'none' or 'opencc'
|
||||
translationEngine: 'none'
|
||||
translationEngine: 'none',
|
||||
// 背景自定义功能默认值
|
||||
useCustomBackground: false,
|
||||
backgroundMode: 'solid',
|
||||
solidColor: '#1a1a1a',
|
||||
gradientColors: {
|
||||
colors: ['#1a1a1a', '#000000'],
|
||||
direction: 'to bottom'
|
||||
},
|
||||
backgroundImage: undefined,
|
||||
imageBlur: 0,
|
||||
imageBrightness: 100,
|
||||
customCss: undefined
|
||||
};
|
||||
|
||||
export interface ILyric {
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
// 音乐平台类型
|
||||
export type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili' | 'gdmusic';
|
||||
export type Platform =
|
||||
| 'qq'
|
||||
| 'migu'
|
||||
| 'kugou'
|
||||
| 'kuwo'
|
||||
| 'pyncmd'
|
||||
| 'joox'
|
||||
| 'bilibili'
|
||||
| 'gdmusic'
|
||||
| 'lxMusic';
|
||||
|
||||
// 默认平台列表
|
||||
export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||
export const DEFAULT_PLATFORMS: Platform[] = [
|
||||
'lxMusic',
|
||||
'migu',
|
||||
'kugou',
|
||||
'kuwo',
|
||||
'pyncmd',
|
||||
'bilibili'
|
||||
];
|
||||
|
||||
export interface IRecommendMusic {
|
||||
code: number;
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function handleShortcutAction(action: string) {
|
||||
await audioService.pause();
|
||||
showToast(t('player.playBar.pause'), 'ri-pause-circle-line');
|
||||
} else {
|
||||
await audioService.play();
|
||||
await audioService.getCurrentSound()?.play();
|
||||
showToast(t('player.playBar.play'), 'ri-play-circle-line');
|
||||
}
|
||||
break;
|
||||
|
||||
284
src/renderer/utils/lxCrypto.ts
Normal file
284
src/renderer/utils/lxCrypto.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 落雪音乐加密工具
|
||||
* 实现 lx.utils.crypto API
|
||||
*
|
||||
* 提供 MD5、AES、RSA 等加密功能
|
||||
*/
|
||||
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { JSEncrypt } from 'jsencrypt';
|
||||
|
||||
/**
|
||||
* MD5 哈希
|
||||
*/
|
||||
export const md5 = (str: string): string => {
|
||||
return CryptoJS.MD5(str).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成随机字节(返回16进制字符串)
|
||||
*/
|
||||
export const randomBytes = (size: number): string => {
|
||||
const array = new Uint8Array(size);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
};
|
||||
|
||||
/**
|
||||
* AES 加密
|
||||
*
|
||||
* @param buffer - 要加密的数据(字符串或 Buffer)
|
||||
* @param mode - 加密模式(如 'cbc')
|
||||
* @param key - 密钥(字符串或 WordArray)
|
||||
* @param iv - 初始化向量(字符串或 WordArray)
|
||||
* @returns 加密后的 Buffer(Uint8Array)
|
||||
*/
|
||||
export const aesEncrypt = (
|
||||
buffer: string | Uint8Array,
|
||||
mode: string,
|
||||
key: string | CryptoJS.lib.WordArray,
|
||||
iv: string | CryptoJS.lib.WordArray
|
||||
): Uint8Array => {
|
||||
try {
|
||||
// 将输入转换为 WordArray
|
||||
let wordArray: CryptoJS.lib.WordArray;
|
||||
if (typeof buffer === 'string') {
|
||||
wordArray = CryptoJS.enc.Utf8.parse(buffer);
|
||||
} else {
|
||||
// Uint8Array 转 WordArray
|
||||
const words: number[] = [];
|
||||
for (let i = 0; i < buffer.length; i += 4) {
|
||||
words.push(
|
||||
((buffer[i] || 0) << 24) |
|
||||
((buffer[i + 1] || 0) << 16) |
|
||||
((buffer[i + 2] || 0) << 8) |
|
||||
(buffer[i + 3] || 0)
|
||||
);
|
||||
}
|
||||
wordArray = CryptoJS.lib.WordArray.create(words, buffer.length);
|
||||
}
|
||||
|
||||
// 处理密钥和 IV
|
||||
const keyWordArray = typeof key === 'string' ? CryptoJS.enc.Utf8.parse(key) : key;
|
||||
const ivWordArray = typeof iv === 'string' ? CryptoJS.enc.Utf8.parse(iv) : iv;
|
||||
|
||||
// 根据模式选择加密方式
|
||||
const modeObj = getModeFromString(mode);
|
||||
|
||||
// 执行加密
|
||||
const encrypted = CryptoJS.AES.encrypt(wordArray, keyWordArray, {
|
||||
iv: ivWordArray,
|
||||
mode: modeObj,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
|
||||
// 将结果转换为 Uint8Array
|
||||
const ciphertext = encrypted.ciphertext;
|
||||
const result = new Uint8Array(ciphertext.words.length * 4);
|
||||
for (let i = 0; i < ciphertext.words.length; i++) {
|
||||
const word = ciphertext.words[i];
|
||||
result[i * 4] = (word >>> 24) & 0xff;
|
||||
result[i * 4 + 1] = (word >>> 16) & 0xff;
|
||||
result[i * 4 + 2] = (word >>> 8) & 0xff;
|
||||
result[i * 4 + 3] = word & 0xff;
|
||||
}
|
||||
|
||||
return result.slice(0, ciphertext.sigBytes);
|
||||
} catch (error) {
|
||||
console.error('[lxCrypto] AES 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* AES 解密
|
||||
*/
|
||||
export const aesDecrypt = (
|
||||
buffer: Uint8Array,
|
||||
mode: string,
|
||||
key: string | CryptoJS.lib.WordArray,
|
||||
iv: string | CryptoJS.lib.WordArray
|
||||
): Uint8Array => {
|
||||
try {
|
||||
// Uint8Array 转 WordArray
|
||||
const words: number[] = [];
|
||||
for (let i = 0; i < buffer.length; i += 4) {
|
||||
words.push(
|
||||
((buffer[i] || 0) << 24) |
|
||||
((buffer[i + 1] || 0) << 16) |
|
||||
((buffer[i + 2] || 0) << 8) |
|
||||
(buffer[i + 3] || 0)
|
||||
);
|
||||
}
|
||||
const ciphertext = CryptoJS.lib.WordArray.create(words, buffer.length);
|
||||
|
||||
// 处理密钥和 IV
|
||||
const keyWordArray = typeof key === 'string' ? CryptoJS.enc.Utf8.parse(key) : key;
|
||||
const ivWordArray = typeof iv === 'string' ? CryptoJS.enc.Utf8.parse(iv) : iv;
|
||||
|
||||
// 根据模式选择解密方式
|
||||
const modeObj = getModeFromString(mode);
|
||||
|
||||
// 构造加密对象
|
||||
const cipherParams = CryptoJS.lib.CipherParams.create({
|
||||
ciphertext
|
||||
});
|
||||
|
||||
// 执行解密
|
||||
const decrypted = CryptoJS.AES.decrypt(cipherParams, keyWordArray, {
|
||||
iv: ivWordArray,
|
||||
mode: modeObj,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
|
||||
// 转换为 Uint8Array
|
||||
const result = new Uint8Array(decrypted.words.length * 4);
|
||||
for (let i = 0; i < decrypted.words.length; i++) {
|
||||
const word = decrypted.words[i];
|
||||
result[i * 4] = (word >>> 24) & 0xff;
|
||||
result[i * 4 + 1] = (word >>> 16) & 0xff;
|
||||
result[i * 4 + 2] = (word >>> 8) & 0xff;
|
||||
result[i * 4 + 3] = word & 0xff;
|
||||
}
|
||||
|
||||
return result.slice(0, decrypted.sigBytes);
|
||||
} catch (error) {
|
||||
console.error('[lxCrypto] AES 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* RSA 加密
|
||||
*
|
||||
* @param buffer - 要加密的数据
|
||||
* @param publicKey - RSA 公钥(PEM 格式)
|
||||
* @returns 加密后的数据(Uint8Array)
|
||||
*/
|
||||
export const rsaEncrypt = (buffer: string | Uint8Array, publicKey: string): Uint8Array => {
|
||||
try {
|
||||
const encrypt = new JSEncrypt();
|
||||
encrypt.setPublicKey(publicKey);
|
||||
|
||||
// 转换输入为字符串
|
||||
let input: string;
|
||||
if (typeof buffer === 'string') {
|
||||
input = buffer;
|
||||
} else {
|
||||
// Uint8Array 转字符串
|
||||
input = new TextDecoder().decode(buffer);
|
||||
}
|
||||
|
||||
// 执行加密(返回 base64)
|
||||
const encrypted = encrypt.encrypt(input);
|
||||
if (!encrypted) {
|
||||
throw new Error('RSA encryption failed');
|
||||
}
|
||||
|
||||
// Base64 解码为 Uint8Array
|
||||
const binaryString = atob(encrypted);
|
||||
const result = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
result[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[lxCrypto] RSA 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* RSA 解密
|
||||
*/
|
||||
export const rsaDecrypt = (buffer: Uint8Array, privateKey: string): Uint8Array => {
|
||||
try {
|
||||
const decrypt = new JSEncrypt();
|
||||
decrypt.setPrivateKey(privateKey);
|
||||
|
||||
// Uint8Array 转 Base64
|
||||
let binaryString = '';
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
binaryString += String.fromCharCode(buffer[i]);
|
||||
}
|
||||
const base64 = btoa(binaryString);
|
||||
|
||||
// 执行解密
|
||||
const decrypted = decrypt.decrypt(base64);
|
||||
if (!decrypted) {
|
||||
throw new Error('RSA decryption failed');
|
||||
}
|
||||
|
||||
// 字符串转 Uint8Array
|
||||
return new TextEncoder().encode(decrypted);
|
||||
} catch (error) {
|
||||
console.error('[lxCrypto] RSA 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从字符串获取加密模式
|
||||
*/
|
||||
const getModeFromString = (mode: string): CryptoJS.lib.Mode => {
|
||||
const modeStr = mode.toLowerCase();
|
||||
switch (modeStr) {
|
||||
case 'cbc':
|
||||
return CryptoJS.mode.CBC;
|
||||
case 'cfb':
|
||||
return CryptoJS.mode.CFB;
|
||||
case 'ctr':
|
||||
return CryptoJS.mode.CTR;
|
||||
case 'ofb':
|
||||
return CryptoJS.mode.OFB;
|
||||
case 'ecb':
|
||||
return CryptoJS.mode.ECB;
|
||||
default:
|
||||
console.warn(`[lxCrypto] 未知的加密模式: ${mode}, 使用 CBC`);
|
||||
return CryptoJS.mode.CBC;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SHA1 哈希
|
||||
*/
|
||||
export const sha1 = (str: string): string => {
|
||||
return CryptoJS.SHA1(str).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* SHA256 哈希
|
||||
*/
|
||||
export const sha256 = (str: string): string => {
|
||||
return CryptoJS.SHA256(str).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Base64 编码
|
||||
*/
|
||||
export const base64Encode = (data: string | Uint8Array): string => {
|
||||
if (typeof data === 'string') {
|
||||
return btoa(data);
|
||||
} else {
|
||||
let binary = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
binary += String.fromCharCode(data[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Base64 解码
|
||||
*/
|
||||
export const base64Decode = (str: string): Uint8Array => {
|
||||
const binaryString = atob(str);
|
||||
const result = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
result[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -272,9 +272,6 @@ const parseWordByWordLine = (line: string): ParseResult<LyricLine> => {
|
||||
currentPos = wordEndPos;
|
||||
}
|
||||
|
||||
console.log('fullText', fullText);
|
||||
console.log('words', words);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<div class="title-wrapper" :class="setAnimationClass('animate__fadeInRight')">
|
||||
<div class="title-wrapper" :class="setAnimationClass('animate__fadeInRight')" v-if="!isMobile">
|
||||
<div class="title">{{ t('history.title') }}</div>
|
||||
<n-button
|
||||
secondary
|
||||
@@ -54,12 +54,14 @@
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
>
|
||||
<song-item class="history-item-content" :item="item" @play="handlePlay" />
|
||||
<div class="history-item-count min-w-[60px]" v-show="currentTab === 'local'">
|
||||
{{ t('history.playCount', { count: item.count }) }}
|
||||
</div>
|
||||
<div class="history-item-delete" v-show="currentTab === 'local'">
|
||||
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
|
||||
</div>
|
||||
<template v-if="!isMobile">
|
||||
<div class="history-item-count min-w-[60px]" v-show="currentTab === 'local'">
|
||||
{{ t('history.playCount', { count: item.count }) }}
|
||||
</div>
|
||||
<div class="history-item-delete" v-show="currentTab === 'local'">
|
||||
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -130,7 +132,7 @@ import { usePlaylistHistory } from '@/hooks/PlaylistHistoryHook';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
// 扩展历史记录类型以包含 playTime
|
||||
interface HistoryRecord extends Partial<SongResult> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex gap-4 h-full pb-4">
|
||||
<favorite class="flex-item" />
|
||||
<favorite class="flex-item" v-if="!isMobile" />
|
||||
<history-list class="flex-item" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ defineOptions({
|
||||
name: 'History'
|
||||
});
|
||||
|
||||
import { isMobile } from '@/utils';
|
||||
import Favorite from '@/views/favorite/index.vue';
|
||||
import HistoryList from '@/views/history/index.vue';
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,7 @@ defineOptions({
|
||||
|
||||
.mobile {
|
||||
.main-content {
|
||||
@apply flex-col mx-4;
|
||||
@apply flex-col mx-4 mb-40;
|
||||
}
|
||||
:deep(.favorite-page) {
|
||||
@apply p-0 mx-4 h-full;
|
||||
|
||||
464
src/renderer/views/mobile-search-result/index.vue
Normal file
464
src/renderer/views/mobile-search-result/index.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<div class="mobile-search-result">
|
||||
<!-- 搜索结果头部 -->
|
||||
<div class="result-header" :class="{ 'safe-area-top': hasSafeArea }">
|
||||
<div class="header-back" @click="goBack">
|
||||
<i class="ri-arrow-left-s-line"></i>
|
||||
</div>
|
||||
<div class="header-keyword">{{ keyword }}</div>
|
||||
<div class="header-actions">
|
||||
<div class="action-btn" @click="openSearch">
|
||||
<i class="ri-search-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索类型标签 -->
|
||||
<div class="search-types">
|
||||
<div
|
||||
v-for="type in searchTypes"
|
||||
:key="type.key"
|
||||
class="type-tag"
|
||||
:class="{ active: searchType === type.key }"
|
||||
@click="selectType(type.key)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div class="result-content" @scroll="handleScroll">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading && !results.length" class="loading-state">
|
||||
<n-spin size="medium" />
|
||||
<span class="ml-2">{{ t('search.loading.searching') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-else-if="results.length" class="result-list">
|
||||
<!-- B站视频 -->
|
||||
<template v-if="searchType === SEARCH_TYPE.BILIBILI">
|
||||
<bilibili-item
|
||||
v-for="item in results"
|
||||
:key="item.bvid"
|
||||
:item="item"
|
||||
@play="handlePlayBilibili"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 歌曲搜索 -->
|
||||
<template v-else-if="searchType === SEARCH_TYPE.MUSIC">
|
||||
<song-item
|
||||
v-for="item in results"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:is-next="true"
|
||||
@play="handlePlay"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 专辑/歌单/MV 搜索 -->
|
||||
<template v-else>
|
||||
<search-item v-for="item in results" :key="item.id" :item="item" class="mb-3" />
|
||||
</template>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2">{{ t('search.loading.more') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 没有更多 -->
|
||||
<div v-if="!hasMore && results.length" class="no-more">
|
||||
{{ t('search.noMore') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无结果 -->
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ t('search.noResult') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
createSimpleBilibiliSong,
|
||||
getBilibiliAudioUrl,
|
||||
getBilibiliProxyUrl,
|
||||
getBilibiliVideoDetail,
|
||||
searchBilibili
|
||||
} from '@/api/bilibili';
|
||||
import { getSearch } from '@/api/search';
|
||||
import BilibiliItem from '@/components/common/BilibiliItem.vue';
|
||||
import SearchItem from '@/components/common/SearchItem.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { SEARCH_TYPE, SEARCH_TYPES } from '@/const/bar-const';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSearchStore } from '@/store/modules/search';
|
||||
import type { IBilibiliSearchResult } from '@/types/bilibili';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const playerStore = usePlayerStore();
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
// 注入是否有安全区域
|
||||
const hasSafeArea = inject('hasSafeArea', false);
|
||||
|
||||
// 搜索关键词
|
||||
const keyword = ref((route.query.keyword as string) || '');
|
||||
|
||||
// 搜索类型
|
||||
const searchType = ref(Number(route.query.type) || searchStore.searchType || 1);
|
||||
const searchTypes = computed(() => {
|
||||
locale.value;
|
||||
return SEARCH_TYPES.map((type) => ({
|
||||
label: t(type.label),
|
||||
key: type.key
|
||||
}));
|
||||
});
|
||||
|
||||
// 搜索结果
|
||||
const results = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 分页
|
||||
const ITEMS_PER_PAGE = 30;
|
||||
const page = ref(1);
|
||||
const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
// 执行搜索
|
||||
const performSearch = async (isLoadMore = false) => {
|
||||
if (!keyword.value) return;
|
||||
|
||||
if (isLoadMore) {
|
||||
if (!hasMore.value || isLoadingMore.value) return;
|
||||
isLoadingMore.value = true;
|
||||
} else {
|
||||
loading.value = true;
|
||||
results.value = [];
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// B站搜索
|
||||
if (searchType.value === SEARCH_TYPE.BILIBILI) {
|
||||
const response = await searchBilibili({
|
||||
keyword: keyword.value,
|
||||
page: page.value,
|
||||
pagesize: ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const bilibiliVideos = response.data.data.result.map((item: any) => ({
|
||||
id: item.aid,
|
||||
bvid: item.bvid,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
pic: getBilibiliProxyUrl(item.pic),
|
||||
duration: item.duration,
|
||||
pubdate: item.pubdate,
|
||||
description: item.description,
|
||||
view: item.play,
|
||||
danmaku: item.video_review
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...bilibiliVideos];
|
||||
} else {
|
||||
results.value = bilibiliVideos;
|
||||
}
|
||||
|
||||
hasMore.value = bilibiliVideos.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// 歌曲搜索
|
||||
else if (searchType.value === SEARCH_TYPE.MUSIC) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const songs = (data.result.songs || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.al?.picUrl,
|
||||
artists: item.ar
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...songs];
|
||||
} else {
|
||||
results.value = songs;
|
||||
}
|
||||
|
||||
hasMore.value = songs.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// 专辑搜索
|
||||
else if (searchType.value === SEARCH_TYPE.ALBUM) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const albums = (data.result.albums || []).map((item: any) => ({
|
||||
...item,
|
||||
desc: `${item.artist?.name || ''} ${item.company || ''}`,
|
||||
type: 'album'
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...albums];
|
||||
} else {
|
||||
results.value = albums;
|
||||
}
|
||||
|
||||
hasMore.value = albums.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// 歌单搜索
|
||||
else if (searchType.value === SEARCH_TYPE.PLAYLIST) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const playlists = (data.result.playlists || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.coverImgUrl,
|
||||
playCount: item.playCount,
|
||||
desc: item.creator?.nickname || '',
|
||||
type: 'playlist'
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...playlists];
|
||||
} else {
|
||||
results.value = playlists;
|
||||
}
|
||||
|
||||
hasMore.value = playlists.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
// MV 搜索
|
||||
else if (searchType.value === SEARCH_TYPE.MV) {
|
||||
const { data } = await getSearch({
|
||||
keywords: keyword.value,
|
||||
type: searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page.value - 1) * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
const mvs = (data.result.mvs || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.cover,
|
||||
playCount: item.playCount,
|
||||
desc: item.artists?.map((artist: any) => artist.name).join('/') || '',
|
||||
type: 'mv'
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
results.value = [...results.value, ...mvs];
|
||||
} else {
|
||||
results.value = mvs;
|
||||
}
|
||||
|
||||
hasMore.value = mvs.length === ITEMS_PER_PAGE;
|
||||
}
|
||||
|
||||
page.value++;
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 选择搜索类型
|
||||
const selectType = (type: number) => {
|
||||
if (searchType.value === type) return;
|
||||
|
||||
searchType.value = type;
|
||||
searchStore.searchType = type;
|
||||
|
||||
// 更新路由查询参数
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
type: type.toString()
|
||||
}
|
||||
});
|
||||
|
||||
performSearch();
|
||||
};
|
||||
|
||||
// 滚动加载更多
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
performSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放音乐
|
||||
const handlePlay = (item: any) => {
|
||||
playerStore.addToNextPlay(item);
|
||||
};
|
||||
|
||||
// 播放B站视频
|
||||
const handlePlayBilibili = async (item: IBilibiliSearchResult) => {
|
||||
try {
|
||||
const videoDetail = await getBilibiliVideoDetail(item.bvid);
|
||||
const pages = videoDetail.data.pages;
|
||||
|
||||
if (pages && pages.length === 1) {
|
||||
const audioUrl = await getBilibiliAudioUrl(item.bvid, pages[0].cid);
|
||||
const playItem = createSimpleBilibiliSong(item, audioUrl);
|
||||
playItem.bilibiliData = {
|
||||
bvid: item.bvid,
|
||||
cid: pages[0].cid
|
||||
};
|
||||
playerStore.setPlay(playItem);
|
||||
} else {
|
||||
router.push(`/bilibili/${item.bvid}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放B站视频失败:', error);
|
||||
router.push(`/bilibili/${item.bvid}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 打开搜索
|
||||
const openSearch = () => {
|
||||
router.push('/mobile-search');
|
||||
};
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
if (route.path === '/mobile-search-result' && query.keyword) {
|
||||
keyword.value = query.keyword as string;
|
||||
searchType.value = Number(query.type) || searchStore.searchType || 1;
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (keyword.value) {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-search-result {
|
||||
@apply fixed inset-0;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
@apply flex items-center gap-3 px-4 py-3;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&.safe-area-top {
|
||||
padding-top: calc(var(--safe-area-inset-top, 0px) + 12px);
|
||||
}
|
||||
}
|
||||
|
||||
.header-back {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full text-xl;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-100 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.header-keyword {
|
||||
@apply flex-1 text-base font-medium;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full text-xl;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-100 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.search-types {
|
||||
@apply flex gap-2 px-4 py-3 overflow-x-auto;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
@apply px-4 py-1.5 rounded-full text-sm whitespace-nowrap;
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300;
|
||||
@apply transition-colors duration-200;
|
||||
|
||||
&.active {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.result-content {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
@apply flex flex-col items-center justify-center py-20;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
@apply pb-20;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply flex justify-center items-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center py-20;
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
|
||||
i {
|
||||
@apply text-6xl mb-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
392
src/renderer/views/mobile-search/index.vue
Normal file
392
src/renderer/views/mobile-search/index.vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="mobile-search-page">
|
||||
<!-- 搜索头部 -->
|
||||
<div class="search-header" :class="{ 'safe-area-top': hasSafeArea }">
|
||||
<div class="header-back" @click="goBack">
|
||||
<i class="ri-arrow-left-s-line"></i>
|
||||
</div>
|
||||
<div class="search-input-wrapper">
|
||||
<i class="ri-search-line search-icon"></i>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchValue"
|
||||
type="text"
|
||||
class="search-input"
|
||||
:placeholder="hotSearchKeyword"
|
||||
@input="handleInput"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
<i v-if="searchValue" class="ri-close-circle-fill clear-icon" @click="clearSearch"></i>
|
||||
</div>
|
||||
<div class="search-button" @click="handleSearch">
|
||||
{{ t('common.search') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索类型标签 -->
|
||||
<div class="search-types">
|
||||
<div
|
||||
v-for="type in searchTypes"
|
||||
:key="type.key"
|
||||
class="type-tag"
|
||||
:class="{ active: searchType === type.key }"
|
||||
@click="selectType(type.key)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索内容区域 -->
|
||||
<div class="search-content">
|
||||
<!-- 搜索建议 -->
|
||||
<div v-if="suggestions.length > 0" class="search-section">
|
||||
<div class="section-title">{{ t('search.suggestions') }}</div>
|
||||
<div class="suggestion-list">
|
||||
<div
|
||||
v-for="(item, index) in suggestions"
|
||||
:key="index"
|
||||
class="suggestion-item"
|
||||
@click="selectSuggestion(item)"
|
||||
>
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div v-else-if="searchHistory.length > 0" class="search-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ t('search.history') }}</span>
|
||||
<span class="clear-history" @click="clearHistory">{{ t('common.clear') }}</span>
|
||||
</div>
|
||||
<div class="history-tags">
|
||||
<div
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
class="history-tag"
|
||||
@click="selectSuggestion(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门搜索 -->
|
||||
<div v-if="hotSearchList.length > 0 && !searchValue" class="search-section">
|
||||
<div class="section-title">{{ t('search.hot') }}</div>
|
||||
<div class="hot-list">
|
||||
<div
|
||||
v-for="(item, index) in hotSearchList"
|
||||
:key="index"
|
||||
class="hot-item"
|
||||
@click="selectSuggestion(item.searchWord)"
|
||||
>
|
||||
<span class="hot-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
|
||||
<span class="hot-word">{{ item.searchWord }}</span>
|
||||
<span v-if="item.iconUrl" class="hot-icon">
|
||||
<img :src="item.iconUrl" alt="" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { computed, inject, nextTick, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getHotSearch, getSearchKeyword } from '@/api/home';
|
||||
import { getSearchSuggestions } from '@/api/search';
|
||||
import { SEARCH_TYPES } from '@/const/bar-const';
|
||||
import { useSearchStore } from '@/store/modules/search';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const searchStore = useSearchStore();
|
||||
|
||||
// 注入是否有安全区域
|
||||
const hasSafeArea = inject('hasSafeArea', false);
|
||||
|
||||
// 搜索值
|
||||
const searchValue = ref('');
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// 热门搜索关键词占位符
|
||||
const hotSearchKeyword = ref('搜索音乐、歌手、歌单');
|
||||
|
||||
// 搜索类型
|
||||
const searchType = ref(searchStore.searchType || 1);
|
||||
const searchTypes = computed(() => {
|
||||
locale.value;
|
||||
return SEARCH_TYPES.map((type) => ({
|
||||
label: t(type.label),
|
||||
key: type.key
|
||||
}));
|
||||
});
|
||||
|
||||
// 搜索建议
|
||||
const suggestions = ref<string[]>([]);
|
||||
|
||||
// 搜索历史
|
||||
const HISTORY_KEY = 'mobile_search_history';
|
||||
const searchHistory = ref<string[]>([]);
|
||||
|
||||
// 热门搜索
|
||||
const hotSearchList = ref<any[]>([]);
|
||||
|
||||
// 加载热门搜索关键词
|
||||
const loadHotSearchKeyword = async () => {
|
||||
try {
|
||||
const { data } = await getSearchKeyword();
|
||||
hotSearchKeyword.value = data.data.showKeyword;
|
||||
} catch (e) {
|
||||
console.error('加载热门搜索关键词失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载热门搜索列表
|
||||
const loadHotSearchList = async () => {
|
||||
try {
|
||||
const { data } = await getHotSearch();
|
||||
hotSearchList.value = data.data || [];
|
||||
} catch (e) {
|
||||
console.error('加载热门搜索失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载搜索历史
|
||||
const loadSearchHistory = () => {
|
||||
try {
|
||||
const history = localStorage.getItem(HISTORY_KEY);
|
||||
searchHistory.value = history ? JSON.parse(history) : [];
|
||||
} catch (e) {
|
||||
console.error('加载搜索历史失败:', e);
|
||||
searchHistory.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 保存搜索历史
|
||||
const saveSearchHistory = (keyword: string) => {
|
||||
if (!keyword.trim()) return;
|
||||
|
||||
// 移除重复项并添加到开头
|
||||
const history = searchHistory.value.filter((item) => item !== keyword);
|
||||
history.unshift(keyword);
|
||||
|
||||
// 最多保存20条
|
||||
searchHistory.value = history.slice(0, 20);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory.value));
|
||||
};
|
||||
|
||||
// 清除搜索历史
|
||||
const clearHistory = () => {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
};
|
||||
|
||||
// 获取搜索建议(防抖)
|
||||
const debouncedGetSuggestions = useDebounceFn(async (keyword: string) => {
|
||||
if (!keyword.trim()) {
|
||||
suggestions.value = [];
|
||||
return;
|
||||
}
|
||||
suggestions.value = await getSearchSuggestions(keyword);
|
||||
}, 300);
|
||||
|
||||
// 处理输入
|
||||
const handleInput = () => {
|
||||
debouncedGetSuggestions(searchValue.value);
|
||||
};
|
||||
|
||||
// 清除搜索
|
||||
const clearSearch = () => {
|
||||
searchValue.value = '';
|
||||
suggestions.value = [];
|
||||
};
|
||||
|
||||
// 选择搜索类型
|
||||
const selectType = (type: number) => {
|
||||
searchType.value = type;
|
||||
searchStore.searchType = type;
|
||||
};
|
||||
|
||||
// 选择建议
|
||||
const selectSuggestion = (keyword: string) => {
|
||||
searchValue.value = keyword;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
// 执行搜索
|
||||
const handleSearch = () => {
|
||||
const keyword = searchValue.value.trim();
|
||||
if (!keyword) return;
|
||||
|
||||
// 保存搜索历史
|
||||
saveSearchHistory(keyword);
|
||||
|
||||
// 跳转到搜索结果页
|
||||
router.push({
|
||||
path: '/mobile-search-result',
|
||||
query: {
|
||||
keyword,
|
||||
type: searchType.value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadHotSearchKeyword();
|
||||
loadHotSearchList();
|
||||
loadSearchHistory();
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-search-page {
|
||||
@apply fixed inset-0 z-50;
|
||||
@apply bg-light dark:bg-black;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
@apply flex items-center gap-3 pl-1 pr-3 py-3;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&.safe-area-top {
|
||||
padding-top: calc(var(--safe-area-inset-top, 0px) + 12px);
|
||||
}
|
||||
}
|
||||
|
||||
.header-back {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-8 h-8 rounded-full text-2xl;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-100 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
@apply flex-1 flex items-center gap-2;
|
||||
@apply bg-gray-100 dark:bg-gray-800 rounded-full;
|
||||
@apply px-4 py-1;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
@apply text-gray-400 text-lg;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply flex-1 bg-transparent border-none outline-none;
|
||||
@apply text-gray-900 dark:text-white text-base;
|
||||
|
||||
&::placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
@apply text-gray-400 text-lg cursor-pointer;
|
||||
}
|
||||
|
||||
.search-types {
|
||||
@apply flex gap-2 px-4 py-3 overflow-x-auto;
|
||||
@apply border-b border-gray-100 dark:border-gray-800;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
@apply px-4 py-1.5 rounded-full text-sm whitespace-nowrap;
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300;
|
||||
@apply transition-colors duration-200;
|
||||
|
||||
&.active {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.search-content {
|
||||
@apply flex-1 overflow-y-auto px-4 py-3;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply flex items-center justify-between mb-3;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-sm font-medium text-gray-500 dark:text-gray-400 mb-3;
|
||||
}
|
||||
|
||||
.clear-history {
|
||||
@apply text-sm text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
|
||||
.suggestion-list {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
@apply flex items-center gap-3 py-3;
|
||||
@apply text-gray-700 dark:text-gray-200;
|
||||
@apply active:bg-gray-50 dark:active:bg-gray-800;
|
||||
|
||||
i {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.history-tags {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
@apply px-3 py-1.5 rounded-full text-sm;
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300;
|
||||
@apply active:bg-gray-200 dark:active:bg-gray-700;
|
||||
}
|
||||
|
||||
.hot-list {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.hot-item {
|
||||
@apply flex items-center gap-3 py-2.5;
|
||||
@apply active:bg-gray-50 dark:active:bg-gray-800;
|
||||
}
|
||||
|
||||
.hot-rank {
|
||||
@apply w-5 text-center text-sm font-medium text-gray-400;
|
||||
|
||||
&.top {
|
||||
@apply text-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.hot-word {
|
||||
@apply flex-1 text-gray-700 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.hot-icon {
|
||||
img {
|
||||
@apply h-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
src/renderer/views/set/SettingItem.vue
Normal file
79
src/renderer/views/set/SettingItem.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between p-4 rounded-lg transition-all bg-light dark:bg-dark text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700 hover:bg-gray-50 hover:dark:bg-gray-800"
|
||||
:class="[
|
||||
// 移动端垂直布局
|
||||
{ 'max-md:flex-col max-md:items-start max-md:gap-3 max-md:p-3': !inline },
|
||||
// 可点击样式
|
||||
{
|
||||
'cursor-pointer hover:text-green-500 hover:!bg-green-50 hover:dark:!bg-green-900/30':
|
||||
clickable
|
||||
},
|
||||
customClass
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- 左侧:标题和描述 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-base font-medium mb-1">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="description || $slots.description"
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</div>
|
||||
<!-- 额外内容插槽 -->
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
<div
|
||||
v-if="$slots.action || $slots.default"
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
:class="{ 'max-md:w-full max-md:justify-end': !inline }"
|
||||
>
|
||||
<slot name="action">
|
||||
<slot></slot>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'SettingItem'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** 设置项标题 */
|
||||
title?: string;
|
||||
/** 设置项描述 */
|
||||
description?: string;
|
||||
/** 是否可点击 */
|
||||
clickable?: boolean;
|
||||
/** 是否保持水平布局(不响应移动端) */
|
||||
inline?: boolean;
|
||||
/** 自定义类名 */
|
||||
customClass?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
description: '',
|
||||
clickable: false,
|
||||
inline: false,
|
||||
customClass: ''
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (props.clickable) {
|
||||
emit('click', event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
47
src/renderer/views/set/SettingNav.vue
Normal file
47
src/renderer/views/set/SettingNav.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-32 h-full flex-shrink-0 border-r border-gray-200 dark:border-gray-700 bg-light dark:bg-dark"
|
||||
>
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.id"
|
||||
class="px-4 py-2.5 cursor-pointer text-sm transition-colors duration-200 border-l-2"
|
||||
:class="[
|
||||
currentSection === section.id
|
||||
? 'text-primary dark:text-white bg-gray-50 dark:bg-dark-100 !border-primary font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400 border-transparent hover:text-primary hover:dark:text-white hover:bg-gray-50 hover:dark:bg-dark-100 hover:border-gray-300'
|
||||
]"
|
||||
@click="handleClick(section.id)"
|
||||
>
|
||||
{{ section.title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'SettingNav'
|
||||
});
|
||||
|
||||
export interface NavSection {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 导航项列表 */
|
||||
sections: NavSection[];
|
||||
/** 当前激活的分组 ID */
|
||||
currentSection: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [sectionId: string];
|
||||
}>();
|
||||
|
||||
const handleClick = (sectionId: string) => {
|
||||
emit('navigate', sectionId);
|
||||
};
|
||||
</script>
|
||||
42
src/renderer/views/set/SettingSection.vue
Normal file
42
src/renderer/views/set/SettingSection.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div :id="id" :ref="setRef" class="mb-6 scroll-mt-4">
|
||||
<!-- 分组标题 -->
|
||||
<div class="text-base font-medium mb-4 text-gray-600 dark:text-white">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</div>
|
||||
|
||||
<!-- 设置项列表 -->
|
||||
<div class="space-y-4 max-md:space-y-3">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type ComponentPublicInstance } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'SettingSection'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** 分组 ID,用于导航定位 */
|
||||
id?: string;
|
||||
/** 分组标题 */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
id: '',
|
||||
title: ''
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
ref: [el: Element | null];
|
||||
}>();
|
||||
|
||||
// 暴露 ref 给父组件
|
||||
const setRef = (el: Element | ComponentPublicInstance | null) => {
|
||||
emit('ref', el as Element | null);
|
||||
};
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,11 +28,6 @@
|
||||
<div class="toplist-item-desc">{{ item.updateFrequency || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2">加载中...</span>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -164,11 +159,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply flex justify-center items-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.toplist-list {
|
||||
@apply px-4 gap-4;
|
||||
|
||||
Reference in New Issue
Block a user