23 Commits

Author SHA1 Message Date
alger
939dc85d7d fix: 修复 Windows 安装时 uninstallericon.ico 写入报错并优化 NSIS 配置 2025-12-21 10:43:00 +08:00
alger
c4831966c1 chore: bump version to 5.0.0 2025-12-20 20:04:15 +08:00
alger
50aebcf8de feat(update): 支持 macOS 分架构下载 (x64/arm64) 2025-12-20 20:01:39 +08:00
alger
75d1225b40 feat: v5.0.0 2025-12-20 19:47:38 +08:00
alger
c251ec9dcf fix: 修复榜单 loading 2025-12-20 19:45:41 +08:00
alger
00a251b5b6 feat: mac 添加权限 2025-12-20 18:32:14 +08:00
algerkong
7e59cfee05 feat: 补全国际化 2025-12-20 14:20:25 +08:00
algerkong
c3dd03cc13 feat: 优化歌词颜色检测逻辑 2025-12-20 14:18:27 +08:00
algerkong
999cd6526b feat: 优化播放检测逻辑 2025-12-20 14:16:32 +08:00
algerkong
77bb06c0d6 feat: 添加歌词字体粗细控制并修复 i18n 缺失 2025-12-20 14:09:57 +08:00
alger
85302c611a feat:优化音源配置 2025-12-20 02:30:09 +08:00
alger
0f42bfc6cb fix:修复随机播放问题 2025-12-20 02:29:43 +08:00
alger
5bcef29f10 feat:优化lx音源问题 2025-12-20 02:29:22 +08:00
alger
a9fb487332 feat:添加国际化 2025-12-19 00:24:26 +08:00
alger
8e1259d2aa feat:针对移动端优化 2025-12-19 00:23:24 +08:00
alger
70f1044dd9 feat: 优化设置页面 2025-12-19 00:22:22 +08:00
alger
e2ebbe12e4 feat:优化全屏歌词界面 添加背景和宽度设置 2025-12-19 00:14:24 +08:00
alger
af9117ee5f feat: 优化预加载逻辑和继续播放功能 2025-12-17 15:05:40 +08:00
alger
6bc168c5bd feat: 优化播放错误处理 2025-12-17 13:19:10 +08:00
alger
89c6b11110 feat: 添加 lx 音源导入 2025-12-13 15:00:38 +08:00
alger
b9287e1c36 fix: 修复音源解析致命性错误 2025-12-13 14:46:15 +08:00
alger
1a0e449e13 feat: 一系列播放优化 2025-12-13 11:31:49 +08:00
algerkong
07f6152c56 fix: 修复预加载问题 2025-12-13 11:31:49 +08:00
99 changed files with 9054 additions and 2240 deletions

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ android/app/release
.cursor
.windsurf
.agent
.auto-imports.d.ts

View File

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

View File

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

View File

@@ -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"
}
```
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -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/

View File

@@ -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: {

View File

@@ -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": {

View File

@@ -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'
}
};

View File

@@ -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: {

View File

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

View File

@@ -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'
};

View File

@@ -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: {

View File

@@ -43,6 +43,8 @@ export default {
collapse: '折りたたみ',
songCount: '{count}曲',
language: '言語',
today: '今日',
yesterday: '昨日',
tray: {
show: '表示',
quit: '終了',

View File

@@ -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: {

View File

@@ -8,6 +8,13 @@ export default {
local: 'ローカル記録',
cloud: 'クラウド記録'
},
categoryTabs: {
songs: '楽曲',
playlists: 'プレイリスト',
albums: 'アルバム'
},
noDescription: '説明なし',
noData: '記録なし',
getCloudRecordFailed: 'クラウド記録の取得に失敗しました',
needLogin: 'cookieを使用してログインしてクラウド記録を表示できます',
merging: '記録を統合中...',

View File

@@ -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:

View File

@@ -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: 'スリープタイマー',

View File

@@ -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: '検索候補'
};

View File

@@ -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: {

View File

@@ -43,6 +43,8 @@ export default {
collapse: '접기',
songCount: '{count}곡',
language: '언어',
today: '오늘',
yesterday: '어제',
tray: {
show: '표시',
quit: '종료',

View File

@@ -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: {

View File

@@ -8,6 +8,13 @@ export default {
local: '로컬 기록',
cloud: '클라우드 기록'
},
categoryTabs: {
songs: '곡',
playlists: '플레이리스트',
albums: '앨범'
},
noDescription: '설명 없음',
noData: '기록 없음',
getCloudRecordFailed: '클라우드 기록 가져오기 실패',
needLogin: 'cookie를 사용하여 로그인하여 클라우드 기록을 볼 수 있습니다',
merging: '기록 병합 중...',

View File

@@ -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:

View File

@@ -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: '타이머 취소',

View File

@@ -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: '검색 제안'
};

View File

@@ -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: {

View File

@@ -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: '支付宝',

View File

@@ -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: '定时关闭',

View File

@@ -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: '搜索建议'
};

View File

@@ -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: {

View File

@@ -43,6 +43,8 @@ export default {
collapse: '收合',
songCount: '{count}首',
language: '語言',
today: '今天',
yesterday: '昨天',
tray: {
show: '顯示',
quit: '退出',

View File

@@ -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: {

View File

@@ -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登入僅用於查看使用者公開資訊無法訪問需要登入權限的功能'

View File

@@ -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: '定時關閉',

View File

@@ -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: '搜尋建議'
};

View File

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

View File

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

View File

@@ -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}`);
}
});
}
/**

View 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();
};

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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
}

View File

@@ -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的情况

View File

@@ -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渲染进程通信接口

View File

@@ -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对象暴露给渲染进程

View File

@@ -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 更新后再初始化

View 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;
}
}
}

View File

@@ -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);
}
// 如果没有自定义音源,使用全局设置

View File

@@ -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'"

View 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>

View 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>

View 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>

View File

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

View File

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

View File

@@ -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">&nbsp;</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">&nbsp;</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);

View File

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

View File

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

View 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>

View File

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

View File

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

View File

@@ -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 APIlx.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>

View File

@@ -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('恢复播放失败,请手动点击播放');

View File

@@ -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;
}
};

View File

@@ -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'] {

View File

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

View 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>

View File

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

View 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>

View File

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

View File

@@ -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');
// 初始化应用内快捷键

View File

@@ -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
}
},
{

View File

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

View 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;
};

View 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;

View File

@@ -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) {

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

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

View File

@@ -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} 解析失败`);

View File

@@ -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];
}
}
};
},
{

View File

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

View File

@@ -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 = {

View File

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

View 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'
};

View File

@@ -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 {

View File

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

View File

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

View 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 加密后的 BufferUint8Array
*/
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;
};

View File

@@ -272,9 +272,6 @@ const parseWordByWordLine = (line: string): ParseResult<LyricLine> => {
currentPos = wordEndPos;
}
console.log('fullText', fullText);
console.log('words', words);
return {
success: true,
data: {

View File

@@ -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> {

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>

View 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

View File

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