mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-29 03:17:22 +08:00
Merge branch 'main' into feat/dislike-improvement
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
## 🎵 自定义音源API配置
|
||||||
|
|
||||||
|
现在支持通过导入一个简单的 JSON 配置文件来对接第三方的音乐解析 API。这将提供极大的灵活性,可以接入任意第三方音源。
|
||||||
|
|
||||||
|
### 如何使用
|
||||||
|
|
||||||
|
1. 前往 **设置 -> 播放设置 -> 音源设置**。
|
||||||
|
2. 在 **自定义 API 设置** 区域,点击 **“导入 JSON 配置”** 按钮。
|
||||||
|
3. 选择你已经编写好的 `xxx.json` 配置文件。
|
||||||
|
4. 导入成功后,程序将优先使用你的自定义 API 进行解析。
|
||||||
|
|
||||||
|
### JSON 配置文件格式说明
|
||||||
|
|
||||||
|
导入的配置文件必须是一个合法的 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"`)。
|
||||||
|
|
||||||
|
#### 音质值列表
|
||||||
|
|
||||||
|
应用内部使用的音质值如下,你可以在 `qualityMapping` 中使用它们作为**键**:
|
||||||
|
`standard`, `higher`, `exhigh`, `lossless`, `hires`, `jyeffect`, `sky`, `dolby`, `jymaster`
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
假设有一个 API 如下:
|
||||||
|
`https://api.example.com/music?song_id=12345&bitrate=320000`
|
||||||
|
它返回的 JSON 是:
|
||||||
|
`{ "code": 200, "data": { "play_url": "http://..." } }`
|
||||||
|
|
||||||
|
那么对应的 JSON 配置文件应该是:
|
||||||
|
|
||||||
|
```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"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -32,12 +32,14 @@
|
|||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"file-type": "^21.0.0",
|
"file-type": "^21.0.0",
|
||||||
|
"flac-tagger": "^1.0.7",
|
||||||
"font-list": "^1.5.1",
|
"font-list": "^1.5.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"music-metadata": "^11.2.3",
|
"music-metadata": "^11.2.3",
|
||||||
"netease-cloud-music-api-alger": "^4.26.1",
|
"netease-cloud-music-api-alger": "^4.26.1",
|
||||||
"node-id3": "^0.2.9",
|
"node-id3": "^0.2.9",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
"vue-i18n": "^11.1.3"
|
"vue-i18n": "^11.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export default {
|
|||||||
warning: 'Please select a music source',
|
warning: 'Please select a music source',
|
||||||
bilibiliNotSupported: 'Bilibili videos do not support reparsing',
|
bilibiliNotSupported: 'Bilibili videos do not support reparsing',
|
||||||
processing: 'Processing...',
|
processing: 'Processing...',
|
||||||
clear: 'Clear Custom Source'
|
clear: 'Clear Custom Source',
|
||||||
|
customApiFailed: 'Custom API parsing failed, trying built-in sources...',
|
||||||
|
customApiError: 'Custom API request error, trying built-in sources...'
|
||||||
},
|
},
|
||||||
playBar: {
|
playBar: {
|
||||||
expand: 'Expand Lyrics',
|
expand: 'Expand Lyrics',
|
||||||
|
|||||||
@@ -80,7 +80,31 @@ export default {
|
|||||||
autoPlayDesc: 'Auto resume playback when reopening the app',
|
autoPlayDesc: 'Auto resume playback when reopening the app',
|
||||||
showStatusBar: 'Show Status Bar',
|
showStatusBar: 'Show Status Bar',
|
||||||
showStatusBarContent:
|
showStatusBarContent:
|
||||||
'You can display the music control function in your mac status bar (effective after a restart)'
|
'You can display the music control function in your mac status bar (effective after a restart)',
|
||||||
|
fallbackParser: 'Fallback Parser (GD Music)',
|
||||||
|
fallbackParserDesc: 'When "GD Music" is checked and regular sources fail, this service will be used.',
|
||||||
|
parserGD: 'GD Music (Built-in)',
|
||||||
|
parserCustom: 'Custom API',
|
||||||
|
|
||||||
|
// Source labels
|
||||||
|
sourceLabels: {
|
||||||
|
migu: 'Migu',
|
||||||
|
kugou: 'Kugou',
|
||||||
|
pyncmd: 'NetEase (Built-in)',
|
||||||
|
bilibili: 'Bilibili',
|
||||||
|
gdmusic: 'GD Music',
|
||||||
|
custom: 'Custom API'
|
||||||
|
},
|
||||||
|
|
||||||
|
customApi: {
|
||||||
|
sectionTitle: 'Custom API Settings',
|
||||||
|
importConfig: 'Import JSON Config',
|
||||||
|
currentSource: 'Current Source',
|
||||||
|
notImported: 'No custom source imported yet.',
|
||||||
|
importSuccess: 'Successfully imported source: {name}',
|
||||||
|
importFailed: 'Import failed: {message}',
|
||||||
|
enableHint: 'Import a JSON config file to enable',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
application: {
|
application: {
|
||||||
closeAction: 'Close Action',
|
closeAction: 'Close Action',
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export default {
|
|||||||
warning: '音源を選択してください',
|
warning: '音源を選択してください',
|
||||||
bilibiliNotSupported: 'Bilibili動画は再解析をサポートしていません',
|
bilibiliNotSupported: 'Bilibili動画は再解析をサポートしていません',
|
||||||
processing: '解析中...',
|
processing: '解析中...',
|
||||||
clear: 'カスタム音源をクリア'
|
clear: 'カスタム音源をクリア',
|
||||||
|
customApiFailed: 'カスタムAPIの解析に失敗しました。内蔵音源を試しています...',
|
||||||
|
customApiError: 'カスタムAPIのリクエストでエラーが発生しました。内蔵音源を試しています...'
|
||||||
},
|
},
|
||||||
playBar: {
|
playBar: {
|
||||||
expand: '歌詞を展開',
|
expand: '歌詞を展開',
|
||||||
|
|||||||
@@ -78,7 +78,28 @@ export default {
|
|||||||
autoPlay: '自動再生',
|
autoPlay: '自動再生',
|
||||||
autoPlayDesc: 'アプリを再起動した際に自動的に再生を継続するかどうか',
|
autoPlayDesc: 'アプリを再起動した際に自動的に再生を継続するかどうか',
|
||||||
showStatusBar: 'ステータスバーコントロール機能を表示するかどうか',
|
showStatusBar: 'ステータスバーコントロール機能を表示するかどうか',
|
||||||
showStatusBarContent: 'Macのステータスバーに音楽コントロール機能を表示できます(再起動後に有効)'
|
showStatusBarContent: 'Macのステータスバーに音楽コントロール機能を表示できます(再起動後に有効)',
|
||||||
|
fallbackParser: '代替解析サービス (GD音楽台)',
|
||||||
|
fallbackParserDesc: '「GD音楽台」にチェックが入っていて、通常の音源で再生できない場合、このサービスが使用されます。',
|
||||||
|
parserGD: 'GD 音楽台 (内蔵)',
|
||||||
|
parserCustom: 'カスタム API',
|
||||||
|
sourceLabels: {
|
||||||
|
migu: 'Migu',
|
||||||
|
kugou: 'Kugou',
|
||||||
|
pyncmd: 'NetEase (内蔵)',
|
||||||
|
bilibili: 'Bilibili',
|
||||||
|
gdmusic: 'GD 音楽台',
|
||||||
|
custom: 'カスタム API'
|
||||||
|
},
|
||||||
|
customApi: {
|
||||||
|
sectionTitle: 'カスタム API 設定',
|
||||||
|
enableHint: 'カスタム API を有効にするには、まずカスタム API をインポートする必要があります。',
|
||||||
|
importConfig: 'JSON設定をインポート',
|
||||||
|
currentSource: '現在の音源',
|
||||||
|
notImported: 'カスタム音源はまだインポートされていません。',
|
||||||
|
importSuccess: '音源のインポートに成功しました: {name}',
|
||||||
|
importFailed: 'インポートに失敗しました: {message}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
application: {
|
application: {
|
||||||
closeAction: '閉じる動作',
|
closeAction: '閉じる動作',
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export default {
|
|||||||
warning: '음원을 선택해주세요',
|
warning: '음원을 선택해주세요',
|
||||||
bilibiliNotSupported: 'B站 비디오는 재분석을 지원하지 않습니다',
|
bilibiliNotSupported: 'B站 비디오는 재분석을 지원하지 않습니다',
|
||||||
processing: '분석 중...',
|
processing: '분석 중...',
|
||||||
clear: '사용자 정의 음원 지우기'
|
clear: '사용자 정의 음원 지우기',
|
||||||
|
customApiFailed: '사용자 정의 API 분석 실패, 기본 음원을 시도합니다...',
|
||||||
|
customApiError: '사용자 정의 API 요청 오류, 기본 음원을 시도합니다...'
|
||||||
},
|
},
|
||||||
playBar: {
|
playBar: {
|
||||||
expand: '가사 펼치기',
|
expand: '가사 펼치기',
|
||||||
|
|||||||
@@ -78,7 +78,31 @@ export default {
|
|||||||
autoPlay: '자동 재생',
|
autoPlay: '자동 재생',
|
||||||
autoPlayDesc: '앱을 다시 열 때 자동으로 재생을 계속할지 여부',
|
autoPlayDesc: '앱을 다시 열 때 자동으로 재생을 계속할지 여부',
|
||||||
showStatusBar: '상태바 제어 기능 표시 여부',
|
showStatusBar: '상태바 제어 기능 표시 여부',
|
||||||
showStatusBarContent: 'Mac 상태바에 음악 제어 기능을 표시할 수 있습니다 (재시작 후 적용)'
|
showStatusBarContent: 'Mac 상태바에 음악 제어 기능을 표시할 수 있습니다 (재시작 후 적용)',
|
||||||
|
fallbackParser: '대체 분석 서비스 (GD Music)',
|
||||||
|
fallbackParserDesc: '"GD Music"을 선택하고 일반 음원을 사용할 수 없을 때 이 서비스를 사용합니다.',
|
||||||
|
parserGD: 'GD Music (내장)',
|
||||||
|
parserCustom: '사용자 지정 API',
|
||||||
|
|
||||||
|
// 음원 라벨
|
||||||
|
sourceLabels: {
|
||||||
|
migu: 'Migu',
|
||||||
|
kugou: 'Kugou',
|
||||||
|
pyncmd: 'NetEase (내장)',
|
||||||
|
bilibili: 'Bilibili',
|
||||||
|
gdmusic: 'GD Music',
|
||||||
|
custom: '사용자 지정 API'
|
||||||
|
},
|
||||||
|
|
||||||
|
customApi: {
|
||||||
|
sectionTitle: '사용자 지정 API 설정',
|
||||||
|
importConfig: 'JSON 설정 가져오기',
|
||||||
|
currentSource: '현재 음원',
|
||||||
|
notImported: '아직 사용자 지정 음원을 가져오지 않았습니다.',
|
||||||
|
importSuccess: '음원 가져오기 성공: {name}',
|
||||||
|
importFailed: '가져오기 실패: {message}',
|
||||||
|
enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
application: {
|
application: {
|
||||||
closeAction: '닫기 동작',
|
closeAction: '닫기 동작',
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export default {
|
|||||||
warning: '请选择一个音源',
|
warning: '请选择一个音源',
|
||||||
bilibiliNotSupported: 'B站视频不支持重新解析',
|
bilibiliNotSupported: 'B站视频不支持重新解析',
|
||||||
processing: '解析中...',
|
processing: '解析中...',
|
||||||
clear: '清除自定义音源'
|
clear: '清除自定义音源',
|
||||||
|
customApiFailed: '自定义API解析失败,正在尝试使用内置音源...',
|
||||||
|
customApiError: '自定义API请求出错,正在尝试使用内置音源...'
|
||||||
},
|
},
|
||||||
playBar: {
|
playBar: {
|
||||||
expand: '展开歌词',
|
expand: '展开歌词',
|
||||||
|
|||||||
@@ -78,7 +78,33 @@ export default {
|
|||||||
autoPlay: '自动播放',
|
autoPlay: '自动播放',
|
||||||
autoPlayDesc: '重新打开应用时是否自动继续播放',
|
autoPlayDesc: '重新打开应用时是否自动继续播放',
|
||||||
showStatusBar: '是否显示状态栏控制功能',
|
showStatusBar: '是否显示状态栏控制功能',
|
||||||
showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)'
|
showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)',
|
||||||
|
|
||||||
|
fallbackParser: 'GD音乐台(music.gdstudio.xyz)设置',
|
||||||
|
fallbackParserDesc: 'GD音乐台将自动尝试多个音乐平台进行解析,无需额外配置。优先级高于其他解析方式,但是请求可能较慢。感谢(music.gdstudio.xyz)\n',
|
||||||
|
parserGD: 'GD 音乐台 (内置)',
|
||||||
|
parserCustom: '自定义 API',
|
||||||
|
|
||||||
|
// 音源标签
|
||||||
|
sourceLabels: {
|
||||||
|
migu: '咪咕音乐',
|
||||||
|
kugou: '酷狗音乐',
|
||||||
|
pyncmd: '网易云(内置)',
|
||||||
|
bilibili: 'Bilibili',
|
||||||
|
gdmusic: 'GD音乐台',
|
||||||
|
custom: '自定义 API'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 自定义API相关的提示
|
||||||
|
customApi: {
|
||||||
|
sectionTitle: '自定义 API 设置',
|
||||||
|
importConfig: '导入 JSON 配置',
|
||||||
|
currentSource: '当前音源',
|
||||||
|
notImported: '尚未导入自定义音源。',
|
||||||
|
importSuccess: '成功导入音源: {name}',
|
||||||
|
importFailed: '导入失败: {message}',
|
||||||
|
enableHint: '请先导入 JSON 配置文件才能启用',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
application: {
|
application: {
|
||||||
closeAction: '关闭行为',
|
closeAction: '关闭行为',
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export default {
|
|||||||
warning: '請選擇一個音源',
|
warning: '請選擇一個音源',
|
||||||
bilibiliNotSupported: 'B站影片不支援重新解析',
|
bilibiliNotSupported: 'B站影片不支援重新解析',
|
||||||
processing: '解析中...',
|
processing: '解析中...',
|
||||||
clear: '清除自訂音源'
|
clear: '清除自訂音源',
|
||||||
|
customApiFailed: '自定義API解析失敗,正在嘗試使用內置音源...',
|
||||||
|
customApiError: '自定義API請求出錯,正在嘗試使用內置音源...'
|
||||||
},
|
},
|
||||||
playBar: {
|
playBar: {
|
||||||
expand: '展開歌詞',
|
expand: '展開歌詞',
|
||||||
|
|||||||
@@ -78,7 +78,31 @@ export default {
|
|||||||
autoPlay: '自動播放',
|
autoPlay: '自動播放',
|
||||||
autoPlayDesc: '重新開啟應用程式時是否自動繼續播放',
|
autoPlayDesc: '重新開啟應用程式時是否自動繼續播放',
|
||||||
showStatusBar: '是否顯示狀態列控制功能',
|
showStatusBar: '是否顯示狀態列控制功能',
|
||||||
showStatusBarContent: '可以在您的mac狀態列顯示音樂控制功能(重啟後生效)'
|
showStatusBarContent: '可以在您的mac狀態列顯示音樂控制功能(重啟後生效)',
|
||||||
|
fallbackParser: '備用解析服務 (GD音樂台)',
|
||||||
|
fallbackParserDesc: '當勾選「GD音樂台」且常規音源無法播放時,將使用此服務嘗試解析。',
|
||||||
|
parserGD: 'GD 音樂台 (內建)',
|
||||||
|
parserCustom: '自訂 API',
|
||||||
|
|
||||||
|
// 音源標籤
|
||||||
|
sourceLabels: {
|
||||||
|
migu: '咪咕音樂',
|
||||||
|
kugou: '酷狗音樂',
|
||||||
|
pyncmd: '網易雲(內建)',
|
||||||
|
bilibili: 'Bilibili',
|
||||||
|
gdmusic: 'GD音樂台',
|
||||||
|
custom: '自訂 API'
|
||||||
|
},
|
||||||
|
|
||||||
|
customApi: {
|
||||||
|
sectionTitle: '自訂 API 設定',
|
||||||
|
importConfig: '匯入 JSON 設定',
|
||||||
|
currentSource: '目前音源',
|
||||||
|
notImported: '尚未匯入自訂音源。',
|
||||||
|
importSuccess: '成功匯入音源:{name}',
|
||||||
|
importFailed: '匯入失敗:{message}',
|
||||||
|
enableHint: '請先匯入 JSON 設定檔才能啟用',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
application: {
|
application: {
|
||||||
closeAction: '關閉行為',
|
closeAction: '關閉行為',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { setupUpdateHandlers } from './modules/update';
|
|||||||
import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window';
|
import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window';
|
||||||
import { initWindowSizeManager } from './modules/window-size';
|
import { initWindowSizeManager } from './modules/window-size';
|
||||||
import { startMusicApi } from './server';
|
import { startMusicApi } from './server';
|
||||||
|
import { initializeOtherApi } from './modules/otherApi';
|
||||||
|
|
||||||
// 导入所有图标
|
// 导入所有图标
|
||||||
const iconPath = join(__dirname, '../../resources');
|
const iconPath = join(__dirname, '../../resources');
|
||||||
@@ -38,6 +39,8 @@ function initialize() {
|
|||||||
|
|
||||||
// 初始化文件管理
|
// 初始化文件管理
|
||||||
initializeFileManager();
|
initializeFileManager();
|
||||||
|
// 初始化其他 API (搜索建议等)
|
||||||
|
initializeOtherApi();
|
||||||
// 初始化窗口管理
|
// 初始化窗口管理
|
||||||
initializeWindowManager();
|
initializeWindowManager();
|
||||||
// 初始化字体管理
|
// 初始化字体管理
|
||||||
|
|||||||
+101
-11
@@ -9,7 +9,8 @@ import * as mm from 'music-metadata';
|
|||||||
import * as NodeID3 from 'node-id3';
|
import * as NodeID3 from 'node-id3';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { FlacTagMap, writeFlacTags } from 'flac-tagger';
|
||||||
|
import sharp from 'sharp';
|
||||||
import { getStore } from './config';
|
import { getStore } from './config';
|
||||||
|
|
||||||
const MAX_CONCURRENT_DOWNLOADS = 3;
|
const MAX_CONCURRENT_DOWNLOADS = 3;
|
||||||
@@ -275,6 +276,39 @@ export function initializeFileManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 处理导入自定义API插件的请求
|
||||||
|
ipcMain.handle('import-custom-api-plugin', async () => {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
title: '选择自定义音源配置文件',
|
||||||
|
filters: [{ name: 'JSON Files', extensions: ['json'] }],
|
||||||
|
properties: ['openFile']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = result.filePaths[0];
|
||||||
|
try {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
// 基础验证,确保它是个合法的JSON并且包含关键字段
|
||||||
|
const pluginData = JSON.parse(fileContent);
|
||||||
|
if (!pluginData.name || !pluginData.apiUrl) {
|
||||||
|
throw new Error('无效的插件文件,缺少 name 或 apiUrl 字段。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: pluginData.name,
|
||||||
|
content: fileContent // 返回完整的JSON字符串
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('读取或解析插件文件失败:', error);
|
||||||
|
// 向渲染进程抛出错误,以便UI可以显示提示
|
||||||
|
throw new Error(`文件读取或解析失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,7 +420,7 @@ async function downloadMusic(
|
|||||||
let formattedFilename = filename;
|
let formattedFilename = filename;
|
||||||
if (songInfo) {
|
if (songInfo) {
|
||||||
// 准备替换变量
|
// 准备替换变量
|
||||||
const artistName = songInfo.ar?.map((a: any) => a.name).join('/') || '未知艺术家';
|
const artistName = songInfo.ar?.map((a: any) => a.name).join('、') || '未知艺术家';
|
||||||
const songName = songInfo.name || filename;
|
const songName = songInfo.name || filename;
|
||||||
const albumName = songInfo.al?.name || '未知专辑';
|
const albumName = songInfo.al?.name || '未知专辑';
|
||||||
|
|
||||||
@@ -576,8 +610,39 @@ async function downloadMusic(
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取封面图片的buffer
|
const originalCoverBuffer = Buffer.from(coverResponse.data);
|
||||||
coverImageBuffer = Buffer.from(coverResponse.data);
|
const TWO_MB = 2 * 1024 * 1024;
|
||||||
|
// 检查图片大小是否超过2MB
|
||||||
|
if (originalCoverBuffer.length > TWO_MB) {
|
||||||
|
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
|
||||||
|
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
|
||||||
|
try {
|
||||||
|
// 使用 sharp 进行压缩
|
||||||
|
coverImageBuffer = await sharp(originalCoverBuffer)
|
||||||
|
.resize({
|
||||||
|
width: 1600,
|
||||||
|
height: 1600,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.jpeg({
|
||||||
|
quality: 80,
|
||||||
|
mozjpeg: true
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
|
||||||
|
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
|
||||||
|
|
||||||
|
} catch (compressionError) {
|
||||||
|
console.error('封面图压缩失败,将使用原图:', compressionError);
|
||||||
|
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果图片不大于2MB,直接使用原图
|
||||||
|
coverImageBuffer = originalCoverBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('封面已准备好,将写入元数据');
|
console.log('封面已准备好,将写入元数据');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -588,7 +653,7 @@ async function downloadMusic(
|
|||||||
|
|
||||||
const fileFormat = fileExtension.toLowerCase();
|
const fileFormat = fileExtension.toLowerCase();
|
||||||
const artistNames =
|
const artistNames =
|
||||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家';
|
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') || '未知艺术家';
|
||||||
|
|
||||||
// 根据文件类型处理元数据
|
// 根据文件类型处理元数据
|
||||||
if (['.mp3'].includes(fileFormat)) {
|
if (['.mp3'].includes(fileFormat)) {
|
||||||
@@ -598,7 +663,7 @@ async function downloadMusic(
|
|||||||
NodeID3.removeTags(finalFilePath);
|
NodeID3.removeTags(finalFilePath);
|
||||||
|
|
||||||
const tags = {
|
const tags = {
|
||||||
title: filename,
|
title: songInfo?.name,
|
||||||
artist: artistNames,
|
artist: artistNames,
|
||||||
TPE1: artistNames,
|
TPE1: artistNames,
|
||||||
TPE2: artistNames,
|
TPE2: artistNames,
|
||||||
@@ -634,10 +699,35 @@ async function downloadMusic(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error writing ID3 tags:', err);
|
console.error('Error writing ID3 tags:', err);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (['.flac'].includes(fileFormat)) {
|
||||||
// 对于非MP3文件,使用music-metadata来写入元数据可能需要专门的库
|
try {
|
||||||
// 或者根据不同文件类型使用专用工具,暂时只记录但不处理
|
const tagMap: FlacTagMap = {
|
||||||
console.log(`文件类型 ${fileFormat} 不支持使用NodeID3写入标签,跳过元数据写入`);
|
TITLE: songInfo?.name,
|
||||||
|
ARTIST: artistNames,
|
||||||
|
ALBUM: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
|
||||||
|
LYRICS: lyricsContent || '',
|
||||||
|
TRACKNUMBER: songInfo?.no ? String(songInfo.no) : undefined,
|
||||||
|
DATE: songInfo?.publishTime
|
||||||
|
? new Date(songInfo.publishTime).getFullYear().toString()
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFlacTags(
|
||||||
|
{
|
||||||
|
tagMap,
|
||||||
|
picture: coverImageBuffer
|
||||||
|
? {
|
||||||
|
buffer: coverImageBuffer,
|
||||||
|
mime: 'image/jpeg'
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
finalFilePath
|
||||||
|
);
|
||||||
|
console.log('FLAC tags written successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error writing FLAC tags:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存下载信息
|
// 保存下载信息
|
||||||
@@ -683,7 +773,7 @@ async function downloadMusic(
|
|||||||
// 发送桌面通知
|
// 发送桌面通知
|
||||||
try {
|
try {
|
||||||
const artistNames =
|
const artistNames =
|
||||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/') ||
|
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') ||
|
||||||
'未知艺术家';
|
'未知艺术家';
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
title: '下载完成',
|
title: '下载完成',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化其他杂项 API(如搜索建议等)
|
||||||
|
*/
|
||||||
|
export function initializeOtherApi() {
|
||||||
|
// 搜索建议(从酷狗获取)
|
||||||
|
ipcMain.handle('get-search-suggestions', async (_, keyword: string) => {
|
||||||
|
if (!keyword || !keyword.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log(`[Main Process Proxy] Forwarding suggestion request for: ${keyword}`);
|
||||||
|
const response = await axios.get('http://msearchcdn.kugou.com/new/app/i/search.php', {
|
||||||
|
params: {
|
||||||
|
cmd: 302,
|
||||||
|
keyword: keyword
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Main Process Proxy] Failed to fetch search suggestions:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+3
-1
@@ -27,5 +27,7 @@
|
|||||||
"showTopAction": false,
|
"showTopAction": false,
|
||||||
"contentZoomFactor": 1,
|
"contentZoomFactor": 1,
|
||||||
"autoTheme": false,
|
"autoTheme": false,
|
||||||
"manualTheme": "light"
|
"manualTheme": "light",
|
||||||
|
"customApiPlugin": "",
|
||||||
|
"customApiPluginName": ""
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -21,7 +21,9 @@ interface API {
|
|||||||
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
|
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
|
||||||
onLanguageChanged: (callback: (locale: string) => void) => void;
|
onLanguageChanged: (callback: (locale: string) => void) => void;
|
||||||
removeDownloadListeners: () => void;
|
removeDownloadListeners: () => void;
|
||||||
|
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
|
||||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||||
|
getSearchSuggestions: (keyword: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义IPC渲染进程通信接口
|
// 自定义IPC渲染进程通信接口
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const api = {
|
|||||||
sendSong: (data) => ipcRenderer.send('update-current-song', data),
|
sendSong: (data) => ipcRenderer.send('update-current-song', data),
|
||||||
unblockMusic: (id, data, enabledSources) =>
|
unblockMusic: (id, data, enabledSources) =>
|
||||||
ipcRenderer.invoke('unblock-music', id, data, enabledSources),
|
ipcRenderer.invoke('unblock-music', id, data, enabledSources),
|
||||||
|
importCustomApiPlugin: () => ipcRenderer.invoke('import-custom-api-plugin'),
|
||||||
// 歌词窗口关闭事件
|
// 歌词窗口关闭事件
|
||||||
onLyricWindowClosed: (callback: () => void) => {
|
onLyricWindowClosed: (callback: () => void) => {
|
||||||
ipcRenderer.on('lyric-window-closed', () => callback());
|
ipcRenderer.on('lyric-window-closed', () => callback());
|
||||||
@@ -54,7 +55,9 @@ const api = {
|
|||||||
return ipcRenderer.invoke(channel, ...args);
|
return ipcRenderer.invoke(channel, ...args);
|
||||||
}
|
}
|
||||||
return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`));
|
return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`));
|
||||||
}
|
},
|
||||||
|
// 搜索建议
|
||||||
|
getSearchSuggestions: (keyword: string) => ipcRenderer.invoke('get-search-suggestions', keyword),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建带类型的ipcRenderer对象,暴露给渲染进程
|
// 创建带类型的ipcRenderer对象,暴露给渲染进程
|
||||||
|
|||||||
+117
-47
@@ -9,7 +9,9 @@ import request from '@/utils/request';
|
|||||||
import requestMusic from '@/utils/request_music';
|
import requestMusic from '@/utils/request_music';
|
||||||
|
|
||||||
import { searchAndGetBilibiliAudioUrl } from './bilibili';
|
import { searchAndGetBilibiliAudioUrl } from './bilibili';
|
||||||
|
import type { ParsedMusicResult } from './gdmusic';
|
||||||
import { parseFromGDMusic } from './gdmusic';
|
import { parseFromGDMusic } from './gdmusic';
|
||||||
|
import { parseFromCustomApi } from './parseFromCustomApi';
|
||||||
|
|
||||||
const { addData, getData, deleteData } = musicDB;
|
const { addData, getData, deleteData } = musicDB;
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ export const getMusicUrl = async (id: number, isDownloaded: boolean = false) =>
|
|||||||
params: {
|
params: {
|
||||||
id,
|
id,
|
||||||
level: settingStore.setData.musicQuality || 'higher',
|
level: settingStore.setData.musicQuality || 'higher',
|
||||||
|
encodeType: settingStore.setData.musicQuality == 'lossless' ? 'aac' : 'flac',
|
||||||
|
// level为lossless时,encodeType=flac时网易云会返回hires音质,encodeType=aac时网易云会返回lossless音质
|
||||||
cookie: `${localStorage.getItem('token')} os=pc;`
|
cookie: `${localStorage.getItem('token')} os=pc;`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -45,7 +49,8 @@ export const getMusicUrl = async (id: number, isDownloaded: boolean = false) =>
|
|||||||
return await request.get('/song/url/v1', {
|
return await request.get('/song/url/v1', {
|
||||||
params: {
|
params: {
|
||||||
id,
|
id,
|
||||||
level: settingStore.setData.musicQuality || 'higher'
|
level: settingStore.setData.musicQuality || 'higher',
|
||||||
|
encodeType: settingStore.setData.musicQuality == 'lossless' ? 'aac' : 'flac'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -114,7 +119,8 @@ const getBilibiliAudio = async (data: SongResult) => {
|
|||||||
* @param data 歌曲数据
|
* @param data 歌曲数据
|
||||||
* @returns 解析结果,失败时返回null
|
* @returns 解析结果,失败时返回null
|
||||||
*/
|
*/
|
||||||
const getGDMusicAudio = async (id: number, data: SongResult) => {
|
const getGDMusicAudio = async (id: number, data: SongResult): Promise<ParsedMusicResult | null> => {
|
||||||
|
// <-- 在这里明确声明返回类型
|
||||||
try {
|
try {
|
||||||
const gdResult = await parseFromGDMusic(id, data, '999');
|
const gdResult = await parseFromGDMusic(id, data, '999');
|
||||||
if (gdResult) {
|
if (gdResult) {
|
||||||
@@ -146,59 +152,123 @@ const getUnblockMusicAudio = (id: number, data: SongResult, sources: any[]) => {
|
|||||||
* @returns 解析结果
|
* @returns 解析结果
|
||||||
*/
|
*/
|
||||||
export const getParsingMusicUrl = async (id: number, data: SongResult) => {
|
export const getParsingMusicUrl = async (id: number, data: SongResult) => {
|
||||||
if(isElectron){
|
try {
|
||||||
const settingStore = useSettingsStore();
|
if (isElectron) {
|
||||||
|
let musicSources: any[] = [];
|
||||||
|
let quality: string = 'higher';
|
||||||
|
try {
|
||||||
|
const settingStore = useSettingsStore();
|
||||||
|
const enableMusicUnblock = settingStore?.setData?.enableMusicUnblock;
|
||||||
|
|
||||||
// 如果禁用了音乐解析功能,则直接返回空结果
|
// 如果禁用了音乐解析功能,则直接返回空结果
|
||||||
if (!settingStore.setData.enableMusicUnblock) {
|
if (!enableMusicUnblock) {
|
||||||
return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } });
|
return Promise.resolve({ data: { code: 404, message: '音乐解析功能已禁用' } });
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 确定使用的音源列表(自定义或全局)
|
|
||||||
const songId = String(id);
|
|
||||||
const savedSourceStr = localStorage.getItem(`song_source_${songId}`);
|
|
||||||
let musicSources: any[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (savedSourceStr) {
|
|
||||||
// 使用自定义音源
|
|
||||||
musicSources = JSON.parse(savedSourceStr);
|
|
||||||
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
|
|
||||||
} else {
|
|
||||||
// 使用全局音源设置
|
|
||||||
musicSources = settingStore.setData.enabledMusicSources || [];
|
|
||||||
console.log(`使用全局音源设置:`, musicSources);
|
|
||||||
if (musicSources.length > 0) {
|
|
||||||
return getUnblockMusicAudio(id, data, musicSources);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. 确定使用的音源列表(自定义或全局)
|
||||||
|
const songId = String(id);
|
||||||
|
const savedSourceStr = (() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(`song_source_${songId}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('读取本地存储失败,忽略自定义音源', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (savedSourceStr) {
|
||||||
|
try {
|
||||||
|
musicSources = JSON.parse(savedSourceStr);
|
||||||
|
console.log(`使用歌曲 ${id} 自定义音源:`, musicSources);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析音源设置失败,回退到默认全局设置', e);
|
||||||
|
musicSources = settingStore?.setData?.enabledMusicSources || [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 使用全局音源设置
|
||||||
|
musicSources = settingStore?.setData?.enabledMusicSources || [];
|
||||||
|
console.log(`使用全局音源设置:`, musicSources);
|
||||||
|
}
|
||||||
|
|
||||||
|
quality = settingStore?.setData?.musicQuality || 'higher';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('读取设置失败,使用默认配置', e);
|
||||||
|
musicSources = [];
|
||||||
|
quality = 'higher';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('解析音源设置失败,使用全局设置', e);
|
|
||||||
musicSources = settingStore.setData.enabledMusicSources || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 按优先级解析
|
// 优先级 1: 自定义 API
|
||||||
|
try {
|
||||||
|
const hasCustom = Array.isArray(musicSources) && musicSources.includes('custom');
|
||||||
|
const customEnabled = (() => {
|
||||||
|
try {
|
||||||
|
const st = useSettingsStore();
|
||||||
|
return Boolean(st?.setData?.customApiPlugin);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (hasCustom && customEnabled) {
|
||||||
|
console.log('尝试使用 自定义API 解析...');
|
||||||
|
const customResult = await parseFromCustomApi(id, data, quality);
|
||||||
|
if (customResult) {
|
||||||
|
return customResult; // 成功则直接返回
|
||||||
|
}
|
||||||
|
console.log('自定义API解析失败,继续尝试其他音源...');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('自定义API解析发生异常,继续尝试其他音源', e);
|
||||||
|
}
|
||||||
|
|
||||||
// 2.1 Bilibili解析(优先级最高)
|
// 优先级 2: Bilibili
|
||||||
if (musicSources.includes('bilibili')) {
|
try {
|
||||||
return await getBilibiliAudio(data);
|
if (Array.isArray(musicSources) && musicSources.includes('bilibili')) {
|
||||||
}
|
console.log('尝试使用 Bilibili 解析...');
|
||||||
|
const bilibiliResult = await getBilibiliAudio(data);
|
||||||
|
if (bilibiliResult?.data?.data?.url) {
|
||||||
|
return bilibiliResult;
|
||||||
|
}
|
||||||
|
console.log('Bilibili解析失败,继续尝试其他音源...');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Bilibili解析发生异常,继续尝试其他音源', e);
|
||||||
|
}
|
||||||
|
|
||||||
// 2.2 GD音乐台解析
|
// 优先级 3: GD 音乐台
|
||||||
if (musicSources.includes('gdmusic')) {
|
try {
|
||||||
const gdResult = await getGDMusicAudio(id, data);
|
if (Array.isArray(musicSources) && musicSources.includes('gdmusic')) {
|
||||||
if (gdResult) return gdResult;
|
console.log('尝试使用 GD音乐台 解析...');
|
||||||
// GD解析失败,继续下一步
|
const gdResult = await getGDMusicAudio(id, data);
|
||||||
console.log('GD音乐台解析失败,尝试使用其他音源');
|
if (gdResult) {
|
||||||
}
|
return gdResult;
|
||||||
console.log('musicSources', musicSources);
|
}
|
||||||
// 2.3 使用unblockMusic解析其他音源
|
console.log('GD音乐台解析失败,继续尝试其他音源...');
|
||||||
if (musicSources.length > 0) {
|
}
|
||||||
return getUnblockMusicAudio(id, data, musicSources);
|
} catch (e) {
|
||||||
|
console.error('GD音乐台解析发生异常,继续尝试其他音源', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级 4: UnblockMusic (migu, kugou, pyncmd)
|
||||||
|
try {
|
||||||
|
const unblockSources = (Array.isArray(musicSources) ? musicSources : []).filter(
|
||||||
|
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
|
||||||
|
);
|
||||||
|
if (unblockSources.length > 0) {
|
||||||
|
console.log('尝试使用 UnblockMusic 解析:', unblockSources);
|
||||||
|
// 捕获内部可能的异常
|
||||||
|
return await getUnblockMusicAudio(id, data, unblockSources);
|
||||||
|
} else {
|
||||||
|
console.warn('UnblockMusic API 不可用,跳过此解析方式');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('UnblockMusic 解析发生异常,继续后备方案', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getParsingMusicUrl 执行异常,将使用后备方案:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 后备方案:使用API请求
|
// 后备方案:使用API请求
|
||||||
console.log('无可用音源或不在Electron环境中,使用API请求');
|
console.log('无可用音源或不在Electron环境中,使用API请求');
|
||||||
return requestMusic.get<any>('/music', { params: { id } });
|
return requestMusic.get<any>('/music', { params: { id } });
|
||||||
};
|
};
|
||||||
@@ -211,7 +281,7 @@ export const likeSong = (id: number, like: boolean = true) => {
|
|||||||
// 将每日推荐中的歌曲标记为不感兴趣,并获取一首新歌
|
// 将每日推荐中的歌曲标记为不感兴趣,并获取一首新歌
|
||||||
export const dislikeRecommendedSong = (id: number | string) => {
|
export const dislikeRecommendedSong = (id: number | string) => {
|
||||||
return request.get('/recommend/songs/dislike', {
|
return request.get('/recommend/songs/dislike', {
|
||||||
params: { id }
|
params: { id }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// 获取用户喜欢的音乐列表
|
// 获取用户喜欢的音乐列表
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { get } from 'lodash';
|
||||||
|
|
||||||
|
import { useSettingsStore } from '@/store';
|
||||||
|
|
||||||
|
import type { ParsedMusicResult } from './gdmusic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义自定义API JSON插件的结构
|
||||||
|
*/
|
||||||
|
interface CustomApiPlugin {
|
||||||
|
name: string;
|
||||||
|
apiUrl: string;
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
params: Record<string, string>;
|
||||||
|
qualityMapping?: Record<string, string>;
|
||||||
|
responseUrlPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从用户导入的自定义API JSON配置中解析音乐URL
|
||||||
|
*/
|
||||||
|
export const parseFromCustomApi = async (
|
||||||
|
id: number,
|
||||||
|
_songData: any,
|
||||||
|
quality: string = 'higher',
|
||||||
|
timeout: number = 10000
|
||||||
|
): Promise<ParsedMusicResult | null> => {
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const pluginString = settingsStore.setData.customApiPlugin;
|
||||||
|
|
||||||
|
if (!pluginString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin: CustomApiPlugin;
|
||||||
|
try {
|
||||||
|
plugin = JSON.parse(pluginString);
|
||||||
|
if (!plugin.apiUrl || !plugin.params || !plugin.responseUrlPath) {
|
||||||
|
console.error('自定义API:JSON配置文件格式不正确。');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('自定义API:解析JSON配置文件失败。', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`自定义API:正在使用插件 [${plugin.name}] 进行解析...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 准备请求参数,替换占位符
|
||||||
|
const finalParams: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(plugin.params)) {
|
||||||
|
if (value === '{songId}') {
|
||||||
|
finalParams[key] = String(id);
|
||||||
|
} else if (value === '{quality}') {
|
||||||
|
// 使用 qualityMapping (如果存在) 进行音质翻译,否则直接使用原quality
|
||||||
|
finalParams[key] = plugin.qualityMapping?.[quality] ?? quality;
|
||||||
|
} else {
|
||||||
|
// 固定值参数
|
||||||
|
finalParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 判断请求方法,默认为GET
|
||||||
|
const method = plugin.method?.toUpperCase() === 'POST' ? 'POST' : 'GET';
|
||||||
|
let response;
|
||||||
|
|
||||||
|
// 3. 根据方法发送不同的请求
|
||||||
|
if (method === 'POST') {
|
||||||
|
console.log('自定义API:发送 POST 请求到:', plugin.apiUrl, '参数:', finalParams);
|
||||||
|
response = await axios.post(plugin.apiUrl, finalParams, { timeout });
|
||||||
|
} else {
|
||||||
|
// 默认为 GET
|
||||||
|
const finalUrl = `${plugin.apiUrl}?${new URLSearchParams(finalParams).toString()}`;
|
||||||
|
console.log('自定义API:发送 GET 请求到:', finalUrl);
|
||||||
|
response = await axios.get(finalUrl, { timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 使用 lodash.get 安全地从响应数据中提取URL
|
||||||
|
const musicUrl = get(response.data, plugin.responseUrlPath);
|
||||||
|
|
||||||
|
if (musicUrl && typeof musicUrl === 'string') {
|
||||||
|
console.log('自定义API:成功获取URL!');
|
||||||
|
// 5. 组装成应用所需的标准格式并返回
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
url: musicUrl,
|
||||||
|
br: parseInt(quality) * 1000,
|
||||||
|
size: 0,
|
||||||
|
md5: '',
|
||||||
|
platform: plugin.name.toLowerCase().replace(/\s/g, ''),
|
||||||
|
gain: 0
|
||||||
|
},
|
||||||
|
params: { id, type: 'song' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.error('自定义API:根据路径未能从响应中找到URL:', plugin.responseUrlPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`自定义API [${plugin.name}] 执行失败:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isElectron } from '@/utils';
|
||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
|
|
||||||
interface IParams {
|
interface IParams {
|
||||||
@@ -12,3 +13,74 @@ export const getSearch = (params: IParams) => {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索建议接口返回的数据结构
|
||||||
|
*/
|
||||||
|
interface Suggestion {
|
||||||
|
keyword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KugouSuggestionResponse {
|
||||||
|
data: Suggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网易云搜索建议返回的数据结构(部分字段)
|
||||||
|
interface NeteaseSuggestResult {
|
||||||
|
result?: {
|
||||||
|
songs?: Array<{ name: string }>;
|
||||||
|
artists?: Array<{ name: string }>;
|
||||||
|
albums?: Array<{ name: string }>;
|
||||||
|
};
|
||||||
|
code?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从酷狗获取搜索建议
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
*/
|
||||||
|
export const getSearchSuggestions = async (keyword: string) => {
|
||||||
|
console.log('[API] getSearchSuggestions: 开始执行');
|
||||||
|
|
||||||
|
if (!keyword || !keyword.trim()) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[API] getSearchSuggestions: 准备请求,关键词: "${keyword}"`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let responseData: KugouSuggestionResponse;
|
||||||
|
if (isElectron) {
|
||||||
|
console.log('[API] Running in Electron, using IPC proxy.');
|
||||||
|
responseData = await window.api.getSearchSuggestions(keyword);
|
||||||
|
} else {
|
||||||
|
// 非 Electron 环境下,使用网易云接口
|
||||||
|
const res = await request.get<NeteaseSuggestResult>('/search/suggest', {
|
||||||
|
params: { keywords: keyword }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = res?.data?.result || {};
|
||||||
|
const names: string[] = [];
|
||||||
|
if (Array.isArray(result.songs)) names.push(...result.songs.map((s) => s.name));
|
||||||
|
if (Array.isArray(result.artists)) names.push(...result.artists.map((a) => a.name));
|
||||||
|
if (Array.isArray(result.albums)) names.push(...result.albums.map((al) => al.name));
|
||||||
|
|
||||||
|
// 去重并截取前10个
|
||||||
|
const unique = Array.from(new Set(names)).slice(0, 10);
|
||||||
|
console.log('[API] getSearchSuggestions: 网易云建议解析成功:', unique);
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData && Array.isArray(responseData.data)) {
|
||||||
|
const suggestions = responseData.data.map((item) => item.keyword).slice(0, 10);
|
||||||
|
console.log('[API] getSearchSuggestions: 成功解析建议:', suggestions);
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[API] getSearchSuggestions: 响应数据格式不正确,返回空数组。');
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] getSearchSuggestions: 请求失败,错误信息:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="coverContainer"
|
||||||
|
class="cover-3d-container relative cursor-pointer"
|
||||||
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
>
|
||||||
|
<div ref="coverImage" class="cover-wrapper" :style="coverTransformStyle">
|
||||||
|
<n-image :src="src" class="cover-image" lazy preview-disabled :object-fit="objectFit" />
|
||||||
|
<div class="cover-shine" :style="shineStyle"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="loading-overlay">
|
||||||
|
<i class="ri-loader-4-line loading-icon"></i>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
loading?: boolean;
|
||||||
|
maxTilt?: number;
|
||||||
|
scale?: number;
|
||||||
|
shineIntensity?: number;
|
||||||
|
objectFit?: 'cover' | 'contain' | 'fill' | 'scale-down' | 'none';
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
maxTilt: 12,
|
||||||
|
scale: 1.03,
|
||||||
|
shineIntensity: 0.25,
|
||||||
|
objectFit: 'cover',
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3D视差效果相关
|
||||||
|
const coverContainer = ref<HTMLElement | null>(null);
|
||||||
|
const coverImage = ref<HTMLElement | null>(null);
|
||||||
|
const mouseX = ref(0.5);
|
||||||
|
const mouseY = ref(0.5);
|
||||||
|
const isHovering = ref(false);
|
||||||
|
const rafId = ref<number | null>(null);
|
||||||
|
|
||||||
|
// 3D视差效果计算
|
||||||
|
const coverTransformStyle = computed(() => {
|
||||||
|
if (!isHovering.value || props.disabled) {
|
||||||
|
return {
|
||||||
|
transform: 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale(1)',
|
||||||
|
transition: 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiltX = Math.round((mouseY.value - 0.5) * props.maxTilt * 100) / 100;
|
||||||
|
const tiltY = Math.round((mouseX.value - 0.5) * -props.maxTilt * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `perspective(1000px) rotateX(${tiltX}deg) rotateY(${tiltY}deg) scale(${props.scale})`,
|
||||||
|
transition: 'none'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 光泽效果计算
|
||||||
|
const shineStyle = computed(() => {
|
||||||
|
if (!isHovering.value || props.disabled) {
|
||||||
|
return {
|
||||||
|
opacity: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'opacity 0.3s ease-out'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shineX = Math.round(mouseX.value * 100);
|
||||||
|
const shineY = Math.round(mouseY.value * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
opacity: props.shineIntensity,
|
||||||
|
background: `radial-gradient(200px circle at ${shineX}% ${shineY}%, rgba(255,255,255,0.3), transparent 50%)`,
|
||||||
|
transition: 'none'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 优化鼠标事件
|
||||||
|
const updateMousePosition = (x: number, y: number) => {
|
||||||
|
if (rafId.value) {
|
||||||
|
cancelAnimationFrame(rafId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId.value = requestAnimationFrame(() => {
|
||||||
|
// 只在位置有显著变化时更新,减少不必要的重绘
|
||||||
|
const deltaX = Math.abs(mouseX.value - x);
|
||||||
|
const deltaY = Math.abs(mouseY.value - y);
|
||||||
|
|
||||||
|
if (deltaX > 0.01 || deltaY > 0.01) {
|
||||||
|
mouseX.value = x;
|
||||||
|
mouseY.value = y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3D视差效果的鼠标事件处理
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!coverContainer.value || !isHovering.value || props.disabled) return;
|
||||||
|
|
||||||
|
const rect = coverContainer.value.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
|
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
||||||
|
|
||||||
|
updateMousePosition(x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!props.disabled) {
|
||||||
|
isHovering.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
isHovering.value = false;
|
||||||
|
if (rafId.value) {
|
||||||
|
cancelAnimationFrame(rafId.value);
|
||||||
|
rafId.value = null;
|
||||||
|
}
|
||||||
|
// 平滑回到中心位置
|
||||||
|
updateMousePosition(0.5, 0.5);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (rafId.value) {
|
||||||
|
cancelAnimationFrame(rafId.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.cover-3d-container {
|
||||||
|
@apply w-full h-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3D视差效果样式 */
|
||||||
|
.cover-wrapper {
|
||||||
|
@apply relative w-full h-full rounded-xl overflow-hidden;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
will-change: transform;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform: translateZ(0); /* 强制硬件加速 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
@apply w-full h-full;
|
||||||
|
border-radius: inherit;
|
||||||
|
transform: translateZ(0); /* 强制硬件加速 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-shine {
|
||||||
|
@apply absolute inset-0 pointer-events-none rounded-xl;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
z-index: 1;
|
||||||
|
will-change: background, opacity;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 为封面容器添加阴影效果 */
|
||||||
|
.cover-3d-container:hover .cover-wrapper {
|
||||||
|
filter: drop-shadow(0 15px 30px rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
@apply absolute inset-0 flex items-center justify-center rounded-xl;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: white;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端禁用3D效果 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cover-wrapper {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-shine {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,24 +9,22 @@
|
|||||||
>
|
>
|
||||||
<div id="drawer-target" :class="[config.theme]">
|
<div id="drawer-target" :class="[config.theme]">
|
||||||
<div
|
<div
|
||||||
class="control-btn absolute top-8 left-8"
|
class="control-buttons-container absolute top-8 left-8 right-8"
|
||||||
:class="{ 'pure-mode': config.pureModeEnabled }"
|
:class="{ 'pure-mode': config.pureModeEnabled }"
|
||||||
@click="closeMusicFull"
|
|
||||||
>
|
>
|
||||||
<i class="ri-arrow-down-s-line"></i>
|
<div class="control-btn" @click="closeMusicFull">
|
||||||
</div>
|
<i class="ri-arrow-down-s-line"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-popover trigger="click" placement="bottom">
|
<n-popover trigger="click" placement="bottom">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div
|
<div class="control-btn">
|
||||||
class="control-btn absolute top-8 right-8"
|
<i class="ri-settings-3-line"></i>
|
||||||
:class="{ 'pure-mode': config.pureModeEnabled }"
|
</div>
|
||||||
>
|
</template>
|
||||||
<i class="ri-settings-3-line"></i>
|
<lyric-settings ref="lyricSettingsRef" />
|
||||||
</div>
|
</n-popover>
|
||||||
</template>
|
</div>
|
||||||
<lyric-settings ref="lyricSettingsRef" />
|
|
||||||
</n-popover>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!config.hideCover"
|
v-if="!config.hideCover"
|
||||||
@@ -34,17 +32,15 @@
|
|||||||
:class="{ 'only-cover': config.hideLyrics }"
|
:class="{ 'only-cover': config.hideLyrics }"
|
||||||
:style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }"
|
:style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }"
|
||||||
>
|
>
|
||||||
<div class="img-container relative">
|
<div class="img-container">
|
||||||
<n-image
|
<cover3-d
|
||||||
ref="PicImgRef"
|
ref="PicImgRef"
|
||||||
:src="getImgUrl(playMusic?.picUrl, '500y500')"
|
:src="getImgUrl(playMusic?.picUrl, '500y500')"
|
||||||
class="img"
|
:loading="playMusic?.playLoading"
|
||||||
lazy
|
:max-tilt="12"
|
||||||
preview-disabled
|
:scale="1.03"
|
||||||
|
:shine-intensity="0.25"
|
||||||
/>
|
/>
|
||||||
<div v-if="playMusic?.playLoading" class="loading-overlay">
|
|
||||||
<i class="ri-loader-4-line loading-icon"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="music-info">
|
<div class="music-info">
|
||||||
<div class="music-content-name">{{ playMusic.name }}</div>
|
<div class="music-content-name">{{ playMusic.name }}</div>
|
||||||
@@ -151,6 +147,7 @@ import { useDebounceFn } from '@vueuse/core';
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Cover3D from '@/components/cover/Cover3D.vue';
|
||||||
import LyricCorrectionControl from '@/components/lyric/LyricCorrectionControl.vue';
|
import LyricCorrectionControl from '@/components/lyric/LyricCorrectionControl.vue';
|
||||||
import LyricSettings from '@/components/lyric/LyricSettings.vue';
|
import LyricSettings from '@/components/lyric/LyricSettings.vue';
|
||||||
import SimplePlayBar from '@/components/player/SimplePlayBar.vue';
|
import SimplePlayBar from '@/components/player/SimplePlayBar.vue';
|
||||||
@@ -183,10 +180,8 @@ const isDark = ref(false);
|
|||||||
const showStickyHeader = ref(false);
|
const showStickyHeader = ref(false);
|
||||||
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
|
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
|
||||||
|
|
||||||
// 移除 computed 配置
|
|
||||||
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
||||||
|
|
||||||
// 监听设置组件的配置变化
|
|
||||||
watch(
|
watch(
|
||||||
() => lyricSettingsRef.value?.config,
|
() => lyricSettingsRef.value?.config,
|
||||||
(newConfig) => {
|
(newConfig) => {
|
||||||
@@ -525,10 +520,12 @@ defineExpose({
|
|||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-back {
|
.drawer-back {
|
||||||
@apply absolute bg-cover bg-center;
|
@apply absolute bg-cover bg-center;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
@@ -561,10 +558,6 @@ defineExpose({
|
|||||||
@apply w-[50vh] h-[50vh] mb-8;
|
@apply w-[50vh] h-[50vh] mb-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img {
|
|
||||||
@apply w-full h-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-info {
|
.music-info {
|
||||||
@apply text-center w-[600px];
|
@apply text-center w-[600px];
|
||||||
|
|
||||||
@@ -584,10 +577,6 @@ defineExpose({
|
|||||||
@apply relative w-full h-full;
|
@apply relative w-full h-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img {
|
|
||||||
@apply rounded-xl w-full h-full shadow-2xl transition-all duration-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-info {
|
.music-info {
|
||||||
@apply w-full mt-4;
|
@apply w-full mt-4;
|
||||||
|
|
||||||
@@ -610,9 +599,11 @@ defineExpose({
|
|||||||
|
|
||||||
&.center {
|
&.center {
|
||||||
@apply w-auto;
|
@apply w-auto;
|
||||||
|
|
||||||
.music-lrc {
|
.music-lrc {
|
||||||
@apply w-full max-w-3xl mx-auto;
|
@apply w-full max-w-3xl mx-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-lrc-text {
|
.music-lrc-text {
|
||||||
@apply text-center;
|
@apply text-center;
|
||||||
}
|
}
|
||||||
@@ -697,24 +688,30 @@ defineExpose({
|
|||||||
.mobile {
|
.mobile {
|
||||||
#drawer-target {
|
#drawer-target {
|
||||||
@apply flex-col p-4 pt-8 justify-start;
|
@apply flex-col p-4 pt-8 justify-start;
|
||||||
|
|
||||||
.music-img {
|
.music-img {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-lrc {
|
.music-lrc {
|
||||||
height: calc(100vh - 260px) !important;
|
height: calc(100vh - 260px) !important;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
padding-right: 0px !important;
|
padding-right: 0px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-text {
|
.hover-text {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-lrc-text {
|
.music-lrc-text {
|
||||||
@apply text-xl text-center;
|
@apply text-xl text-center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-content {
|
.music-content {
|
||||||
@apply h-[calc(100vh-120px)];
|
@apply h-[calc(100vh-120px)];
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
@@ -751,8 +748,30 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-buttons-container {
|
||||||
|
@apply flex justify-between items-start z-[9999];
|
||||||
|
|
||||||
|
&.pure-mode {
|
||||||
|
@apply pointer-events-auto; /* 容器需要能接收hover事件 */
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
@apply opacity-0 transition-all duration-300;
|
||||||
|
pointer-events: none; /* 按钮隐藏时不接收事件 */
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .control-btn {
|
||||||
|
@apply opacity-100;
|
||||||
|
pointer-events: auto; /* hover时按钮可以点击 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.pure-mode) .control-btn {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
@apply w-9 h-9 flex items-center justify-center rounded cursor-pointer transition-all duration-300 z-[9999];
|
@apply w-9 h-9 flex items-center justify-center rounded cursor-pointer transition-all duration-300;
|
||||||
background: rgba(142, 142, 142, 0.192);
|
background: rgba(142, 142, 142, 0.192);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
|
|
||||||
@@ -761,48 +780,16 @@ defineExpose({
|
|||||||
color: var(--text-color-active);
|
color: var(--text-color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.pure-mode {
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: none;
|
|
||||||
|
|
||||||
&:not(:hover) {
|
|
||||||
i {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(126, 121, 121, 0.2);
|
background: rgba(126, 121, 121, 0.2);
|
||||||
|
|
||||||
i {
|
i {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-overlay {
|
|
||||||
@apply absolute inset-0 flex items-center justify-center rounded-xl;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
color: white;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyric-correction {
|
.lyric-correction {
|
||||||
/* 仅在 hover 歌词区域时显示 */
|
|
||||||
.music-lrc:hover & {
|
.music-lrc:hover & {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
|
|||||||
@@ -10,56 +10,84 @@
|
|||||||
>
|
>
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
<p>{{ t('settings.playback.musicSourcesDesc') }}</p>
|
<p>{{ t('settings.playback.musicSourcesDesc') }}</p>
|
||||||
|
|
||||||
<n-checkbox-group v-model:value="selectedSources">
|
<n-checkbox-group v-model:value="selectedSources">
|
||||||
<n-grid :cols="2" :x-gap="12" :y-gap="8">
|
<n-grid :cols="2" :x-gap="12" :y-gap="8">
|
||||||
<n-grid-item v-for="source in musicSourceOptions" :key="source.value">
|
<!-- 遍历常规音源 -->
|
||||||
|
<n-grid-item v-for="source in regularMusicSources" :key="source.value">
|
||||||
<n-checkbox :value="source.value">
|
<n-checkbox :value="source.value">
|
||||||
{{ source.label }}
|
{{ t('settings.playback.sourceLabels.' + source.value) }}
|
||||||
<template v-if="source.value === 'gdmusic'">
|
<n-tooltip v-if="source.value === 'gdmusic'">
|
||||||
<n-tooltip>
|
<template #trigger>
|
||||||
<template #trigger>
|
<n-icon size="16" class="ml-1 text-blue-500 cursor-help">
|
||||||
<n-icon size="16" class="ml-1 text-blue-500 cursor-help">
|
<i class="ri-information-line"></i>
|
||||||
<i class="ri-information-line"></i>
|
</n-icon>
|
||||||
</n-icon>
|
</template>
|
||||||
</template>
|
{{ t('settings.playback.gdmusicInfo') }}
|
||||||
{{ t('settings.playback.gdmusicInfo') }}
|
</n-tooltip>
|
||||||
</n-tooltip>
|
</n-checkbox>
|
||||||
</template>
|
</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-checkbox>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-checkbox-group>
|
</n-checkbox-group>
|
||||||
<div v-if="selectedSources.length === 0" class="text-red-500 text-sm">
|
|
||||||
{{ t('settings.playback.musicSourcesWarning') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- GD音乐台设置 -->
|
<!-- 分割线 -->
|
||||||
<div
|
<div class="mt-4 border-t pt-4 border-gray-200 dark:border-gray-700"></div>
|
||||||
v-if="selectedSources.includes('gdmusic')"
|
|
||||||
class="mt-4 border-t pt-4 border-gray-200 dark:border-gray-700"
|
<!-- 自定义API导入区域 -->
|
||||||
>
|
<div>
|
||||||
<h3 class="text-base font-medium mb-2">GD音乐台(music.gdstudio.xyz)设置</h3>
|
<h3 class="text-base font-medium mb-2">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
{{ t('settings.playback.customApi.sectionTitle') }}
|
||||||
GD音乐台将自动尝试多个音乐平台进行解析,无需额外配置。优先级高于其他解析方式,但是请求可能较慢。感谢(music.gdstudio.xyz)
|
</h3>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</n-space>
|
</n-space>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineEmits, defineProps, ref, watch } from 'vue';
|
import { useMessage } from 'naive-ui';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { useSettingsStore } from '@/store';
|
||||||
import { type Platform } from '@/types/music';
|
import { type Platform } from '@/types/music';
|
||||||
|
|
||||||
|
// 扩展 Platform 类型以包含 'custom'
|
||||||
|
type ExtendedPlatform = Platform | 'custom';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
sources: {
|
sources: {
|
||||||
type: Array as () => Platform[],
|
type: Array as () => ExtendedPlatform[],
|
||||||
default: () => ['migu', 'kugou', 'pyncmd', 'bilibili']
|
default: () => ['migu', 'kugou', 'pyncmd', 'bilibili']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -67,17 +95,49 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['update:show', 'update:sources']);
|
const emit = defineEmits(['update:show', 'update:sources']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const message = useMessage();
|
||||||
const visible = ref(props.show);
|
const visible = ref(props.show);
|
||||||
const selectedSources = ref<Platform[]>(props.sources);
|
const selectedSources = ref<ExtendedPlatform[]>(props.sources);
|
||||||
|
|
||||||
const musicSourceOptions = ref([
|
// 将常规音源和自定义音源分开定义
|
||||||
{ label: 'MG', value: 'migu' },
|
const regularMusicSources = ref([
|
||||||
{ label: 'KG', value: 'kugou' },
|
{ value: 'migu' },
|
||||||
{ label: 'pyncmd', value: 'pyncmd' },
|
{ value: 'kugou' },
|
||||||
{ label: 'Bilibili', value: 'bilibili' },
|
{ value: 'pyncmd' },
|
||||||
{ label: 'GD音乐台', value: 'gdmusic' }
|
{ value: 'bilibili' },
|
||||||
|
{ value: 'gdmusic' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(t('settings.playback.customApi.importFailed', { message: error.message }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听自定义插件内容的变化。如果用户清除了插件,要确保 'custom' 选项被取消勾选
|
||||||
|
watch(
|
||||||
|
() => settingsStore.setData.customApiPlugin,
|
||||||
|
(newPluginContent) => {
|
||||||
|
if (!newPluginContent) {
|
||||||
|
const index = selectedSources.value.indexOf('custom');
|
||||||
|
if (index > -1) {
|
||||||
|
selectedSources.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 同步外部show属性变化
|
// 同步外部show属性变化
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
@@ -108,11 +168,9 @@ const handleConfirm = () => {
|
|||||||
const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||||
const valuesToEmit =
|
const valuesToEmit =
|
||||||
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
|
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
|
||||||
|
|
||||||
emit('update:sources', valuesToEmit);
|
emit('update:sources', valuesToEmit);
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
// 取消时还原为props传入的初始值
|
// 取消时还原为props传入的初始值
|
||||||
selectedSources.value = [...props.sources];
|
selectedSources.value = [...props.sources];
|
||||||
|
|||||||
@@ -3,29 +3,65 @@
|
|||||||
<div v-if="showBackButton" class="back-button" @click="goBack">
|
<div v-if="showBackButton" class="back-button" @click="goBack">
|
||||||
<i class="ri-arrow-left-line"></i>
|
<i class="ri-arrow-left-line"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-box-input flex-1">
|
<div class="search-box-input flex-1 relative">
|
||||||
<n-input
|
<n-popover
|
||||||
v-model:value="searchValue"
|
trigger="manual"
|
||||||
size="medium"
|
placement="bottom-start"
|
||||||
round
|
:show="showSuggestions"
|
||||||
:placeholder="hotSearchKeyword"
|
:show-arrow="false"
|
||||||
class="border dark:border-gray-600 border-gray-200"
|
style="width: 100%; margin-top: 4px"
|
||||||
@keydown.enter="search"
|
content-style="padding: 0; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"
|
||||||
|
raw
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #trigger>
|
||||||
<i class="iconfont icon-search"></i>
|
<n-input
|
||||||
|
v-model:value="searchValue"
|
||||||
|
size="medium"
|
||||||
|
round
|
||||||
|
:placeholder="hotSearchKeyword"
|
||||||
|
class="border dark:border-gray-600 border-gray-200"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@blur="handleBlur"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<i class="iconfont icon-search"></i>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
|
||||||
|
<div class="w-20 px-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
searchTypeOptions.find((item) => item.key === searchStore.searchType)?.label
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||||
|
</div>
|
||||||
|
</n-dropdown>
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
</template>
|
</template>
|
||||||
<template #suffix>
|
<!-- ==================== 搜索建议列表 ==================== -->
|
||||||
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
|
<div class="search-suggestions-panel">
|
||||||
<div class="w-20 px-3 flex justify-between items-center">
|
<n-scrollbar style="max-height: 300px">
|
||||||
<div>
|
<div v-if="suggestionsLoading" class="suggestion-item loading">
|
||||||
{{ searchTypeOptions.find((item) => item.key === searchStore.searchType)?.label }}
|
<n-spin size="small" />
|
||||||
</div>
|
|
||||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</n-dropdown>
|
<div
|
||||||
</template>
|
v-for="(suggestion, index) in suggestions"
|
||||||
</n-input>
|
:key="index"
|
||||||
|
class="suggestion-item"
|
||||||
|
:class="{ highlighted: index === highlightedIndex }"
|
||||||
|
@mousedown.prevent="selectSuggestion(suggestion)"
|
||||||
|
@mouseenter="highlightedIndex = index"
|
||||||
|
>
|
||||||
|
<i class="ri-search-line suggestion-icon"></i>
|
||||||
|
<span>{{ suggestion }}</span>
|
||||||
|
</div>
|
||||||
|
</n-scrollbar>
|
||||||
|
</div>
|
||||||
|
</n-popover>
|
||||||
</div>
|
</div>
|
||||||
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
|
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -128,12 +164,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
|
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { getSearchKeyword } from '@/api/home';
|
import { getSearchKeyword } from '@/api/home';
|
||||||
import { getUserDetail } from '@/api/login';
|
import { getUserDetail } from '@/api/login';
|
||||||
|
import { getSearchSuggestions } from '@/api/search';
|
||||||
import alipay from '@/assets/alipay.png';
|
import alipay from '@/assets/alipay.png';
|
||||||
import wechat from '@/assets/wechat.png';
|
import wechat from '@/assets/wechat.png';
|
||||||
import Coffee from '@/components/Coffee.vue';
|
import Coffee from '@/components/Coffee.vue';
|
||||||
@@ -250,6 +288,9 @@ const search = () => {
|
|||||||
type: searchStore.searchType
|
type: searchStore.searchType
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[UI] 执行搜索,关键词: "${searchValue.value}"`); // <--- 日志 K
|
||||||
|
showSuggestions.value = false; // 搜索后强制隐藏
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectSearchType = (key: number) => {
|
const selectSearchType = (key: number) => {
|
||||||
@@ -330,6 +371,84 @@ const toGithubRelease = () => {
|
|||||||
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
|
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 搜索建议相关的状态和方法 ====================
|
||||||
|
const suggestions = ref<string[]>([]);
|
||||||
|
const showSuggestions = ref(false);
|
||||||
|
const suggestionsLoading = ref(false);
|
||||||
|
const highlightedIndex = ref(-1); // -1 表示没有高亮项
|
||||||
|
// 使用防抖函数来避免频繁请求API
|
||||||
|
const debouncedGetSuggestions = useDebounceFn(async (keyword: string) => {
|
||||||
|
if (!keyword.trim()) {
|
||||||
|
suggestions.value = [];
|
||||||
|
showSuggestions.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestionsLoading.value = true;
|
||||||
|
suggestions.value = await getSearchSuggestions(keyword);
|
||||||
|
suggestionsLoading.value = false;
|
||||||
|
// 只有当有建议时才显示面板
|
||||||
|
showSuggestions.value = suggestions.value.length > 0;
|
||||||
|
highlightedIndex.value = -1;
|
||||||
|
}, 300); // 300ms延迟
|
||||||
|
|
||||||
|
const handleInput = (value: string) => {
|
||||||
|
debouncedGetSuggestions(value);
|
||||||
|
};
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (searchValue.value && suggestions.value.length > 0) {
|
||||||
|
showSuggestions.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
showSuggestions.value = false;
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSuggestion = (suggestion: string) => {
|
||||||
|
searchValue.value = suggestion;
|
||||||
|
showSuggestions.value = false;
|
||||||
|
search();
|
||||||
|
};
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
// 如果建议列表不显示,则不处理上下键
|
||||||
|
if (!showSuggestions.value || suggestions.value.length === 0) {
|
||||||
|
// 如果是回车键,则正常执行搜索
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault(); // 阻止光标移动到末尾
|
||||||
|
highlightedIndex.value = (highlightedIndex.value + 1) % suggestions.value.length;
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault(); // 阻止光标移动到开头
|
||||||
|
highlightedIndex.value =
|
||||||
|
(highlightedIndex.value - 1 + suggestions.value.length) % suggestions.value.length;
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault(); // 阻止表单默认提交行为
|
||||||
|
if (highlightedIndex.value !== -1) {
|
||||||
|
// 如果有高亮项,就选择它
|
||||||
|
selectSuggestion(suggestions.value[highlightedIndex.value]);
|
||||||
|
} else {
|
||||||
|
// 否则,执行默认搜索
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
showSuggestions.value = false; // 按 Esc 隐藏建议
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -437,4 +556,22 @@ const toGithubRelease = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-suggestions-panel {
|
||||||
|
@apply bg-light dark:bg-dark-100 rounded-lg overflow-hidden;
|
||||||
|
.suggestion-item {
|
||||||
|
@apply flex items-center px-4 py-2 cursor-pointer;
|
||||||
|
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800;
|
||||||
|
&.highlighted {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
&.loading {
|
||||||
|
@apply justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-icon {
|
||||||
|
@apply mr-2 text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,10 +78,14 @@ export const isBilibiliIdMatch = (id1: string | number, id2: string | number): b
|
|||||||
// 提取公共函数:获取B站视频URL
|
// 提取公共函数:获取B站视频URL
|
||||||
|
|
||||||
export const getSongUrl = async (
|
export const getSongUrl = async (
|
||||||
id: string | number,
|
id: string | number,
|
||||||
songData: SongResult,
|
songData: SongResult,
|
||||||
isDownloaded: boolean = false
|
isDownloaded: boolean = false
|
||||||
) => {
|
) => {
|
||||||
|
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const { message } = createDiscreteApi(['message']); // 引入 message API 用于提示
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (songData.playMusicUrl) {
|
if (songData.playMusicUrl) {
|
||||||
return songData.playMusicUrl;
|
return songData.playMusicUrl;
|
||||||
@@ -92,8 +96,8 @@ export const getSongUrl = async (
|
|||||||
if (!songData.playMusicUrl && songData.bilibiliData.bvid && songData.bilibiliData.cid) {
|
if (!songData.playMusicUrl && songData.bilibiliData.bvid && songData.bilibiliData.cid) {
|
||||||
try {
|
try {
|
||||||
songData.playMusicUrl = await getBilibiliAudioUrl(
|
songData.playMusicUrl = await getBilibiliAudioUrl(
|
||||||
songData.bilibiliData.bvid,
|
songData.bilibiliData.bvid,
|
||||||
songData.bilibiliData.cid
|
songData.bilibiliData.cid
|
||||||
);
|
);
|
||||||
return songData.playMusicUrl;
|
return songData.playMusicUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -104,14 +108,50 @@ export const getSongUrl = async (
|
|||||||
return songData.playMusicUrl || '';
|
return songData.playMusicUrl || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
|
||||||
|
|
||||||
// 检查是否有自定义音源设置
|
// ==================== 自定义API最优先 ====================
|
||||||
|
// 检查用户是否在全局设置中启用了 'custom' 音源
|
||||||
|
const globalSources = settingsStore.setData.enabledMusicSources || [];
|
||||||
|
const useCustomApiGlobally = globalSources.includes('custom');
|
||||||
|
|
||||||
|
// 检查歌曲是否有专属的 'custom' 音源设置
|
||||||
const songId = String(id);
|
const songId = String(id);
|
||||||
const savedSource = localStorage.getItem(`song_source_${songId}`);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果全局或歌曲专属设置中启用了自定义API,则最优先尝试
|
||||||
|
if ( (useCustomApiGlobally || useCustomApiForSong) && settingsStore.setData.customApiPlugin) {
|
||||||
|
console.log(`优先级 1: 尝试使用自定义API解析歌曲 ${id}...`);
|
||||||
|
try {
|
||||||
|
// 直接从 api 目录导入 parseFromCustomApi 函数
|
||||||
|
const { parseFromCustomApi } = await import('@/api/parseFromCustomApi');
|
||||||
|
const customResult = await parseFromCustomApi(numericId, cloneDeep(songData), settingsStore.setData.musicQuality || 'higher');
|
||||||
|
|
||||||
|
if (customResult && customResult.data && customResult.data.data && customResult.data.data.url) {
|
||||||
|
console.log('自定义API解析成功!');
|
||||||
|
if (isDownloaded) return customResult.data.data as any;
|
||||||
|
return customResult.data.data.url;
|
||||||
|
} else {
|
||||||
|
// 自定义API失败,给出提示,然后继续走默认流程
|
||||||
|
console.log('自定义API解析失败,将使用默认降级流程...');
|
||||||
|
message.warning(i18n.global.t('player.reparse.customApiFailed')); // 给用户一个提示
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('调用自定义API时发生错误:', error);
|
||||||
|
message.error(i18n.global.t('player.reparse.customApiError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果自定义API失败或未启用,则执行【原有】的解析流程
|
||||||
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
||||||
if (savedSource && songData.source !== 'bilibili') {
|
if (savedSourceStr && songData.source !== 'bilibili') {
|
||||||
try {
|
try {
|
||||||
console.log(`使用自定义音源解析歌曲 ID: ${songId}`);
|
console.log(`使用自定义音源解析歌曲 ID: ${songId}`);
|
||||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||||
@@ -129,28 +169,33 @@ export const getSongUrl = async (
|
|||||||
|
|
||||||
// 正常获取URL流程
|
// 正常获取URL流程
|
||||||
const { data } = await getMusicUrl(numericId, isDownloaded);
|
const { data } = await getMusicUrl(numericId, isDownloaded);
|
||||||
let url = '';
|
if (data && data.data && data.data[0]) {
|
||||||
let songDetail = null;
|
const songDetail = data.data[0];
|
||||||
try {
|
const hasNoUrl = !songDetail.url;
|
||||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
const isTrial = !!songDetail.freeTrialInfo;
|
||||||
|
|
||||||
|
if (hasNoUrl || isTrial) {
|
||||||
|
console.log(`官方URL无效 (无URL: ${hasNoUrl}, 试听: ${isTrial}),进入内置备用解析...`);
|
||||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||||
url = res.data.data.url;
|
if (isDownloaded) return res?.data?.data as any;
|
||||||
songDetail = res.data.data;
|
return res?.data?.data?.url || null;
|
||||||
} else {
|
|
||||||
songDetail = data.data[0] as any;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('error', error);
|
console.log('官方API解析成功!');
|
||||||
url = data.data[0].url || '';
|
if (isDownloaded) return songDetail as any;
|
||||||
|
return songDetail.url;
|
||||||
}
|
}
|
||||||
if (isDownloaded) {
|
|
||||||
return songDetail;
|
console.log('官方API返回数据结构异常,进入内置备用解析...');
|
||||||
}
|
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||||
url = url || data.data[0].url;
|
if (isDownloaded) return res?.data?.data as any;
|
||||||
return url;
|
return res?.data?.data?.url || null;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('error', error);
|
console.error('官方API请求失败,进入内置备用解析流程:', error);
|
||||||
return null;
|
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||||
|
if (isDownloaded) return res?.data?.data as any;
|
||||||
|
return res?.data?.data?.url || null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
// 初始化 setData
|
// 初始化 setData
|
||||||
setData.value = getInitialSettings();
|
setData.value = getInitialSettings();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存导入的自定义API插件
|
||||||
|
* @param plugin 包含name和content的对象
|
||||||
|
*/
|
||||||
|
const setCustomApiPlugin = (plugin: { name: string; content: string }) => {
|
||||||
|
setSetData({
|
||||||
|
customApiPlugin: plugin.content,
|
||||||
|
customApiPluginName: plugin.name
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
if (setData.value.autoTheme) {
|
if (setData.value.autoTheme) {
|
||||||
// 如果是自动模式,切换到手动模式并设置相反的主题
|
// 如果是自动模式,切换到手动模式并设置相反的主题
|
||||||
@@ -208,6 +219,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
initializeSettings,
|
initializeSettings,
|
||||||
initializeTheme,
|
initializeTheme,
|
||||||
initializeSystemFonts
|
initializeSystemFonts,
|
||||||
|
setCustomApiPlugin,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user