mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-28 02:47:22 +08:00
feat: 添加 lx 音源导入
This commit is contained in:
@@ -310,6 +310,47 @@ export function initializeFileManager() {
|
|||||||
throw new Error(`文件读取或解析失败: ${error.message}`);
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -24,7 +24,7 @@
|
|||||||
"alwaysShowDownloadButton": false,
|
"alwaysShowDownloadButton": false,
|
||||||
"unlimitedDownload": false,
|
"unlimitedDownload": false,
|
||||||
"enableMusicUnblock": true,
|
"enableMusicUnblock": true,
|
||||||
"enabledMusicSources": ["migu", "kugou", "pyncmd", "bilibili"],
|
"enabledMusicSources": ["migu", "kugou", "pyncmd"],
|
||||||
"showTopAction": false,
|
"showTopAction": false,
|
||||||
"contentZoomFactor": 1,
|
"contentZoomFactor": 1,
|
||||||
"autoTheme": false,
|
"autoTheme": false,
|
||||||
@@ -32,5 +32,7 @@
|
|||||||
"isMenuExpanded": false,
|
"isMenuExpanded": false,
|
||||||
"customApiPlugin": "",
|
"customApiPlugin": "",
|
||||||
"customApiPluginName": "",
|
"customApiPluginName": "",
|
||||||
|
"lxMusicScripts": [],
|
||||||
|
"activeLxMusicApiId": null,
|
||||||
"enableGpuAcceleration": true
|
"enableGpuAcceleration": true
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -22,6 +22,7 @@ interface API {
|
|||||||
onLanguageChanged: (callback: (locale: string) => void) => void;
|
onLanguageChanged: (callback: (locale: string) => void) => void;
|
||||||
removeDownloadListeners: () => void;
|
removeDownloadListeners: () => void;
|
||||||
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
|
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
|
||||||
|
importLxMusicScript: () => 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>;
|
getSearchSuggestions: (keyword: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const api = {
|
|||||||
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'),
|
importCustomApiPlugin: () => ipcRenderer.invoke('import-custom-api-plugin'),
|
||||||
|
importLxMusicScript: () => ipcRenderer.invoke('import-lx-music-script'),
|
||||||
// 歌词窗口关闭事件
|
// 歌词窗口关闭事件
|
||||||
onLyricWindowClosed: (callback: () => void) => {
|
onLyricWindowClosed: (callback: () => void) => {
|
||||||
ipcRenderer.on('lyric-window-closed', () => callback());
|
ipcRenderer.on('lyric-window-closed', () => callback());
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 落雪音乐 (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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 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 script = settingsStore?.setData?.lxMusicScript;
|
||||||
|
return Boolean(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();
|
||||||
|
const script = settingsStore.setData?.lxMusicScript;
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
console.log('[LxMusicStrategy] 未导入落雪音源脚本');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取或初始化执行器
|
||||||
|
let runner = getLxMusicRunner();
|
||||||
|
if (!runner || !runner.isInitialized()) {
|
||||||
|
console.log('[LxMusicStrategy] 初始化落雪音源执行器...');
|
||||||
|
runner = await initLxMusicRunner(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 url = await runner.getMusicUrl(bestSource, lxMusicInfo, lxQuality);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.log('[LxMusicStrategy] 获取 URL 失败');
|
||||||
|
CacheManager.addFailedCache(id, this.name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[LxMusicStrategy] 解析成功:', url.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
url,
|
||||||
|
source: `lx-${bestSource}`,
|
||||||
|
quality: lxQuality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LxMusicStrategy] 解析失败:', error);
|
||||||
|
CacheManager.addFailedCache(id, this.name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import requestMusic from '@/utils/request_music';
|
|||||||
import { searchAndGetBilibiliAudioUrl } from './bilibili';
|
import { searchAndGetBilibiliAudioUrl } from './bilibili';
|
||||||
import type { ParsedMusicResult } from './gdmusic';
|
import type { ParsedMusicResult } from './gdmusic';
|
||||||
import { parseFromGDMusic } from './gdmusic';
|
import { parseFromGDMusic } from './gdmusic';
|
||||||
|
import { LxMusicStrategy } from './lxMusicStrategy';
|
||||||
import { parseFromCustomApi } from './parseFromCustomApi';
|
import { parseFromCustomApi } from './parseFromCustomApi';
|
||||||
|
|
||||||
const { saveData, getData, deleteData } = musicDB;
|
const { saveData, getData, deleteData } = musicDB;
|
||||||
@@ -499,6 +500,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
|||||||
*/
|
*/
|
||||||
class MusicSourceStrategyFactory {
|
class MusicSourceStrategyFactory {
|
||||||
private static strategies: MusicSourceStrategy[] = [
|
private static strategies: MusicSourceStrategy[] = [
|
||||||
|
new LxMusicStrategy(),
|
||||||
new CustomApiStrategy(),
|
new CustomApiStrategy(),
|
||||||
new BilibiliStrategy(),
|
new BilibiliStrategy(),
|
||||||
new GDMusicStrategy(),
|
new GDMusicStrategy(),
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ const getSourceIcon = (source: Platform) => {
|
|||||||
pyncmd: 'ri-netease-cloud-music-fill',
|
pyncmd: 'ri-netease-cloud-music-fill',
|
||||||
bilibili: 'ri-bilibili-fill',
|
bilibili: 'ri-bilibili-fill',
|
||||||
gdmusic: 'ri-google-fill',
|
gdmusic: 'ri-google-fill',
|
||||||
kuwo: 'ri-music-fill'
|
kuwo: 'ri-music-fill',
|
||||||
|
lxMusic: 'ri-leaf-fill'
|
||||||
};
|
};
|
||||||
|
|
||||||
return iconMap[source] || 'ri-music-2-fill';
|
return iconMap[source] || 'ri-music-2-fill';
|
||||||
|
|||||||
@@ -10,107 +10,262 @@
|
|||||||
@negative-click="handleCancel"
|
@negative-click="handleCancel"
|
||||||
style="width: 800px; max-width: 90vw"
|
style="width: 800px; max-width: 90vw"
|
||||||
>
|
>
|
||||||
<n-space vertical :size="20">
|
<div class="h-[400px]">
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<n-tabs type="segment" animated class="h-full flex flex-col">
|
||||||
{{ t('settings.playback.musicSourcesDesc') }}
|
<!-- Tab 1: 音源选择 -->
|
||||||
</p>
|
<n-tab-pane name="sources" tab="音源选择" class="h-full overflow-y-auto">
|
||||||
|
<n-space vertical :size="20" class="pt-4 pr-2">
|
||||||
<!-- 音源卡片列表 -->
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div class="music-sources-grid">
|
{{ t('settings.playback.musicSourcesDesc') }}
|
||||||
<div
|
|
||||||
v-for="source in MUSIC_SOURCES"
|
|
||||||
:key="source.key"
|
|
||||||
class="source-card"
|
|
||||||
:class="{
|
|
||||||
'source-card--selected': isSourceSelected(source.key),
|
|
||||||
'source-card--disabled': source.disabled && !isSourceSelected(source.key)
|
|
||||||
}"
|
|
||||||
:style="{ '--source-color': source.color }"
|
|
||||||
@click="toggleSource(source.key)"
|
|
||||||
>
|
|
||||||
<div class="source-card__indicator"></div>
|
|
||||||
<div class="source-card__content">
|
|
||||||
<div class="source-card__header">
|
|
||||||
<span class="source-card__name">{{ source.key }}</span>
|
|
||||||
<n-icon v-if="isSourceSelected(source.key)" size="18" class="source-card__check">
|
|
||||||
<i class="ri-checkbox-circle-fill"></i>
|
|
||||||
</n-icon>
|
|
||||||
</div>
|
|
||||||
<p v-if="source.description" class="source-card__description">
|
|
||||||
{{ source.description }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自定义API卡片 -->
|
<!-- 音源卡片列表 -->
|
||||||
<div
|
<div class="music-sources-grid">
|
||||||
class="source-card source-card--custom"
|
<div
|
||||||
:class="{
|
v-for="source in MUSIC_SOURCES"
|
||||||
'source-card--selected': isSourceSelected('custom'),
|
:key="source.key"
|
||||||
'source-card--disabled': !settingsStore.setData.customApiPlugin
|
class="source-card"
|
||||||
}"
|
:class="{
|
||||||
style="--source-color: #8b5cf6"
|
'source-card--selected': isSourceSelected(source.key),
|
||||||
@click="toggleSource('custom')"
|
'source-card--disabled': source.disabled && !isSourceSelected(source.key)
|
||||||
>
|
}"
|
||||||
<div class="source-card__indicator"></div>
|
:style="{ '--source-color': source.color }"
|
||||||
<div class="source-card__content">
|
@click="toggleSource(source.key)"
|
||||||
<div class="source-card__header">
|
>
|
||||||
<span class="source-card__name">{{
|
<div class="source-card__indicator"></div>
|
||||||
t('settings.playback.sourceLabels.custom')
|
<div class="source-card__content">
|
||||||
}}</span>
|
<div class="source-card__header">
|
||||||
<n-icon v-if="isSourceSelected('custom')" size="18" class="source-card__check">
|
<span class="source-card__name">{{ source.key }}</span>
|
||||||
<i class="ri-checkbox-circle-fill"></i>
|
<n-icon
|
||||||
</n-icon>
|
v-if="isSourceSelected(source.key)"
|
||||||
|
size="18"
|
||||||
|
class="source-card__check"
|
||||||
|
>
|
||||||
|
<i class="ri-checkbox-circle-fill"></i>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<p v-if="source.description" class="source-card__description">
|
||||||
|
{{ source.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 落雪音源卡片 (仅开关) -->
|
||||||
|
<div
|
||||||
|
class="source-card source-card--lxmusic"
|
||||||
|
:class="{
|
||||||
|
'source-card--selected': isSourceSelected('lxMusic'),
|
||||||
|
'source-card--disabled': !settingsStore.setData.lxMusicScript
|
||||||
|
}"
|
||||||
|
style="--source-color: #10b981"
|
||||||
|
@click="toggleSource('lxMusic')"
|
||||||
|
>
|
||||||
|
<div class="source-card__indicator"></div>
|
||||||
|
<div class="source-card__content">
|
||||||
|
<div class="source-card__header">
|
||||||
|
<span class="source-card__name">落雪音源</span>
|
||||||
|
<n-icon v-if="isSourceSelected('lxMusic')" size="18" class="source-card__check">
|
||||||
|
<i class="ri-checkbox-circle-fill"></i>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<p class="source-card__description">
|
||||||
|
{{
|
||||||
|
settingsStore.setData.lxMusicScript
|
||||||
|
? lxMusicScriptInfo?.name || '已导入'
|
||||||
|
: '未导入 (请去落雪音源Tab配置)'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义API卡片 (仅开关) -->
|
||||||
|
<div
|
||||||
|
class="source-card source-card--custom"
|
||||||
|
:class="{
|
||||||
|
'source-card--selected': isSourceSelected('custom'),
|
||||||
|
'source-card--disabled': !settingsStore.setData.customApiPlugin
|
||||||
|
}"
|
||||||
|
style="--source-color: #8b5cf6"
|
||||||
|
@click="toggleSource('custom')"
|
||||||
|
>
|
||||||
|
<div class="source-card__indicator"></div>
|
||||||
|
<div class="source-card__content">
|
||||||
|
<div class="source-card__header">
|
||||||
|
<span class="source-card__name">{{
|
||||||
|
t('settings.playback.sourceLabels.custom')
|
||||||
|
}}</span>
|
||||||
|
<n-icon v-if="isSourceSelected('custom')" size="18" class="source-card__check">
|
||||||
|
<i class="ri-checkbox-circle-fill"></i>
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<p class="source-card__description">
|
||||||
|
{{
|
||||||
|
settingsStore.setData.customApiPlugin
|
||||||
|
? t('settings.playback.customApi.status.imported')
|
||||||
|
: t('settings.playback.customApi.status.notImported')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- Tab 2: 落雪音源管理 -->
|
||||||
|
<n-tab-pane name="lxMusic" tab="落雪音源" class="h-full overflow-y-auto">
|
||||||
|
<div class="pt-4 pr-2">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-base font-medium">已导入的音源脚本</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<n-button @click="importLxMusicScript" size="small" secondary type="success">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><i class="ri-upload-line"></i></n-icon>
|
||||||
|
</template>
|
||||||
|
本地导入
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已导入的音源列表 -->
|
||||||
|
<div v-if="lxMusicApis.length > 0" class="lx-api-list mb-4">
|
||||||
|
<div
|
||||||
|
v-for="api in lxMusicApis"
|
||||||
|
:key="api.id"
|
||||||
|
class="lx-api-item"
|
||||||
|
:class="{ 'lx-api-item--active': activeLxApiId === api.id }"
|
||||||
|
>
|
||||||
|
<div class="lx-api-item__radio">
|
||||||
|
<n-radio
|
||||||
|
:checked="activeLxApiId === api.id"
|
||||||
|
@update:checked="() => setActiveLxApi(api.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="lx-api-item__info">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="lx-api-item__name" v-if="editingScriptId !== api.id">{{
|
||||||
|
api.name
|
||||||
|
}}</span>
|
||||||
|
<n-input
|
||||||
|
v-else
|
||||||
|
v-model:value="editingName"
|
||||||
|
size="tiny"
|
||||||
|
class="w-32"
|
||||||
|
ref="renameInputRef"
|
||||||
|
@blur="saveScriptName(api.id)"
|
||||||
|
@keyup.enter="saveScriptName(api.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n-button
|
||||||
|
v-if="editingScriptId !== api.id"
|
||||||
|
text
|
||||||
|
size="tiny"
|
||||||
|
@click="startRenaming(api)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon class="text-gray-400 hover:text-primary"
|
||||||
|
><i class="ri-edit-line"></i
|
||||||
|
></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<span v-if="api.info.version" class="lx-api-item__version"
|
||||||
|
>v{{ api.info.version }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="lx-api-item__actions">
|
||||||
|
<n-button text size="tiny" type="error" @click="removeLxApi(api.id)">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><i class="ri-close-line"></i></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<n-empty description="暂无已导入的落雪音源" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL 导入区域 -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-sm font-medium mb-2 text-gray-600 dark:text-gray-400">在线导入</h4>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<n-input
|
||||||
|
v-model:value="lxScriptUrl"
|
||||||
|
placeholder="输入落雪音源脚本 URL"
|
||||||
|
size="small"
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="isImportingFromUrl"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
@click="importLxMusicScriptFromUrl"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="isImportingFromUrl"
|
||||||
|
:disabled="!lxScriptUrl.trim()"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><i class="ri-download-line"></i></n-icon>
|
||||||
|
</template>
|
||||||
|
导入
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="source-card__description">
|
|
||||||
{{
|
|
||||||
settingsStore.setData.customApiPlugin
|
|
||||||
? t('settings.playback.customApi.status.imported')
|
|
||||||
: t('settings.playback.customApi.status.notImported')
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</n-tab-pane>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分割线 -->
|
<!-- Tab 3: 自定义API管理 -->
|
||||||
<div class="divider"></div>
|
<n-tab-pane name="customApi" tab="自定义API" class="h-full overflow-y-auto">
|
||||||
|
<div class="pt-4 flex flex-col items-center justify-center h-full gap-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-lg font-medium mb-2">
|
||||||
|
{{ t('settings.playback.customApi.sectionTitle') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500 text-sm mb-4">导入兼容的自定义 API 插件以扩展音源</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 自定义API导入区域 -->
|
<div class="flex flex-col items-center gap-2">
|
||||||
<div class="custom-api-section">
|
<n-button @click="importPlugin" type="primary" secondary>
|
||||||
<h3 class="custom-api-section__title">
|
<template #icon>
|
||||||
{{ t('settings.playback.customApi.sectionTitle') }}
|
<n-icon><i class="ri-upload-line"></i></n-icon>
|
||||||
</h3>
|
</template>
|
||||||
<div class="custom-api-section__content">
|
{{ t('settings.playback.customApi.importConfig') }}
|
||||||
<n-button @click="importPlugin" size="small" secondary>
|
</n-button>
|
||||||
<template #icon>
|
|
||||||
<n-icon><i class="ri-upload-line"></i></n-icon>
|
<p
|
||||||
</template>
|
v-if="settingsStore.setData.customApiPluginName"
|
||||||
{{ t('settings.playback.customApi.importConfig') }}
|
class="text-green-600 text-sm mt-2 flex items-center gap-1"
|
||||||
</n-button>
|
>
|
||||||
<p v-if="settingsStore.setData.customApiPluginName" class="custom-api-section__status">
|
<i class="ri-check-circle-line"></i>
|
||||||
{{ t('settings.playback.customApi.currentSource') }}:
|
{{ t('settings.playback.customApi.currentSource') }}:
|
||||||
<span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span>
|
<span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="custom-api-section__status custom-api-section__status--empty">
|
<p v-else class="text-gray-400 text-sm mt-2">
|
||||||
{{ t('settings.playback.customApi.notImported') }}
|
{{ t('settings.playback.customApi.notImported') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-space>
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMessage } from 'naive-ui';
|
import { useMessage } from 'naive-ui';
|
||||||
import { ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import {
|
||||||
|
initLxMusicRunner,
|
||||||
|
parseScriptInfo,
|
||||||
|
setLxMusicRunner
|
||||||
|
} from '@/services/LxMusicSourceRunner';
|
||||||
import { useSettingsStore } from '@/store';
|
import { useSettingsStore } from '@/store';
|
||||||
|
import type { LxMusicScriptConfig, LxScriptInfo, LxSourceKey } from '@/types/lxMusic';
|
||||||
import { type Platform } from '@/types/music';
|
import { type Platform } from '@/types/music';
|
||||||
|
|
||||||
// ==================== 类型定义 ====================
|
// ==================== 类型定义 ====================
|
||||||
type ExtendedPlatform = Platform | 'custom';
|
type ExtendedPlatform = Platform | 'custom' | 'lxMusic';
|
||||||
|
|
||||||
interface MusicSourceConfig {
|
interface MusicSourceConfig {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -149,6 +304,37 @@ const message = useMessage();
|
|||||||
const visible = ref(props.show);
|
const visible = ref(props.show);
|
||||||
const selectedSources = ref<ExtendedPlatform[]>([...props.sources]);
|
const selectedSources = ref<ExtendedPlatform[]>([...props.sources]);
|
||||||
|
|
||||||
|
// 落雪音源列表(从 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) => {
|
||||||
|
settingsStore.setSetData({ activeLxMusicApiId: id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 落雪音源脚本信息(保持向后兼容)
|
||||||
|
const lxMusicScriptInfo = computed<LxScriptInfo | null>(() => {
|
||||||
|
const activeId = activeLxApiId.value;
|
||||||
|
if (!activeId) return null;
|
||||||
|
const activeApi = lxMusicApis.value.find((api) => 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 => {
|
const isSourceSelected = (sourceKey: string): boolean => {
|
||||||
return selectedSources.value.includes(sourceKey as ExtendedPlatform);
|
return selectedSources.value.includes(sourceKey as ExtendedPlatform);
|
||||||
@@ -165,6 +351,12 @@ const toggleSource = (sourceKey: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否是落雪音源且未导入
|
||||||
|
if (sourceKey === 'lxMusic' && !settingsStore.setData.lxMusicScript) {
|
||||||
|
message.warning('请先导入落雪音源脚本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform);
|
const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
// 至少保留一个音源
|
// 至少保留一个音源
|
||||||
@@ -198,6 +390,224 @@ const importPlugin = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入落雪音源脚本
|
||||||
|
*/
|
||||||
|
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(`导入失败:${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(`音源脚本导入成功:${scriptInfo.name},支持 ${sourceKeys.length} 个音源`);
|
||||||
|
|
||||||
|
// 导入成功后自动勾选
|
||||||
|
if (!selectedSources.value.includes('lxMusic')) {
|
||||||
|
selectedSources.value.push('lxMusic');
|
||||||
|
}
|
||||||
|
} catch (initError: any) {
|
||||||
|
console.error('落雪音源脚本初始化失败:', initError);
|
||||||
|
message.error(`脚本初始化失败:${initError.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置激活的落雪音源
|
||||||
|
*/
|
||||||
|
const setActiveLxApi = async (apiId: string) => {
|
||||||
|
const api = lxMusicApis.value.find((a) => a.id === apiId);
|
||||||
|
if (!api) {
|
||||||
|
message.error('音源不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 初始化选中的脚本
|
||||||
|
await initLxMusicRunner(api.script);
|
||||||
|
|
||||||
|
// 更新激活的音源 ID
|
||||||
|
activeLxApiId.value = apiId;
|
||||||
|
|
||||||
|
// 确保 lxMusic 在已选音源中
|
||||||
|
if (!selectedSources.value.includes('lxMusic')) {
|
||||||
|
selectedSources.value.push('lxMusic');
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(`已切换到音源: ${api.name}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('切换落雪音源失败:', error);
|
||||||
|
message.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(`已删除音源: ${removedScript.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 导入落雪音源脚本
|
||||||
|
*/
|
||||||
|
const importLxMusicScriptFromUrl = async () => {
|
||||||
|
const url = lxScriptUrl.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
message.warning('请输入脚本 URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 URL 格式
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch {
|
||||||
|
message.error('无效的 URL 格式');
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 验证脚本格式
|
||||||
|
if (
|
||||||
|
!content.includes('globalThis.lx') &&
|
||||||
|
!content.includes('lx.on') &&
|
||||||
|
!content.includes('EVENT_NAMES')
|
||||||
|
) {
|
||||||
|
throw new Error('无效的落雪音源脚本,未找到 globalThis.lx 相关代码');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用统一的添加方法
|
||||||
|
await addLxMusicScript(content);
|
||||||
|
|
||||||
|
// 清空 URL 输入框
|
||||||
|
lxScriptUrl.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('从 URL 导入落雪音源脚本失败:', error);
|
||||||
|
message.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('名称不能为空');
|
||||||
|
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('重命名成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
editingScriptId.value = null;
|
||||||
|
editingName.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认选择
|
* 确认选择
|
||||||
*/
|
*/
|
||||||
@@ -392,52 +802,83 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.lx-api-list {
|
||||||
height: 1px;
|
display: flex;
|
||||||
background: linear-gradient(90deg, transparent, #e5e5e5 50%, transparent);
|
flex-direction: column;
|
||||||
margin: 8px 0;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .divider {
|
.lx-api-item {
|
||||||
background: linear-gradient(90deg, transparent, #333 50%, transparent);
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.custom-api-section {
|
&--active {
|
||||||
&__title {
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.08));
|
||||||
font-size: 14px;
|
border-color: rgba(16, 185, 129, 0.3);
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&__radio {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__status {
|
&__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #666;
|
font-weight: 500;
|
||||||
margin: 0;
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
&--empty {
|
&__version {
|
||||||
color: #999;
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover &__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) {
|
||||||
|
.lx-api-item {
|
||||||
|
background: #2a2a2a;
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
color: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__version {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .custom-api-section {
|
.empty-state {
|
||||||
&__title {
|
padding: 32px 0;
|
||||||
color: #e5e5e5;
|
display: flex;
|
||||||
}
|
justify-content: center;
|
||||||
|
|
||||||
&__status {
|
|
||||||
color: #999;
|
|
||||||
|
|
||||||
&--empty {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Object.keys(directives).forEach((key: string) => {
|
|||||||
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(i18n);
|
app.use(i18n as any);
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|
||||||
// 初始化应用内快捷键
|
// 初始化应用内快捷键
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
/**
|
||||||
|
* 落雪音乐 (LX Music) 音源脚本执行器
|
||||||
|
*
|
||||||
|
* 核心职责:
|
||||||
|
* 1. 解析脚本元信息
|
||||||
|
* 2. 在隔离环境中执行用户脚本
|
||||||
|
* 3. 模拟 globalThis.lx API
|
||||||
|
* 4. 处理初始化和音乐解析请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LxInitedData,
|
||||||
|
LxLyricResult,
|
||||||
|
LxMusicInfo,
|
||||||
|
LxQuality,
|
||||||
|
LxScriptInfo,
|
||||||
|
LxSourceConfig,
|
||||||
|
LxSourceKey
|
||||||
|
} from '@/types/lxMusic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析脚本头部注释中的元信息
|
||||||
|
*/
|
||||||
|
export const parseScriptInfo = (script: string): LxScriptInfo => {
|
||||||
|
const info: LxScriptInfo = {
|
||||||
|
name: '未知音源',
|
||||||
|
rawScript: script
|
||||||
|
};
|
||||||
|
|
||||||
|
// 匹配头部注释块
|
||||||
|
const headerMatch = script.match(/^\/\*\*[\s\S]*?\*\//);
|
||||||
|
if (!headerMatch) return info;
|
||||||
|
|
||||||
|
const header = headerMatch[0];
|
||||||
|
|
||||||
|
// 解析各个字段
|
||||||
|
const nameMatch = header.match(/@name\s+(.+?)(?:\r?\n|\*\/)/);
|
||||||
|
if (nameMatch) info.name = nameMatch[1].trim();
|
||||||
|
|
||||||
|
const descMatch = header.match(/@description\s+(.+?)(?:\r?\n|\*\/)/);
|
||||||
|
if (descMatch) info.description = descMatch[1].trim();
|
||||||
|
|
||||||
|
const versionMatch = header.match(/@version\s+(.+?)(?:\r?\n|\*\/)/);
|
||||||
|
if (versionMatch) info.version = versionMatch[1].trim();
|
||||||
|
|
||||||
|
const authorMatch = header.match(/@author\s+(.+?)(?:\r?\n|\*\/)/);
|
||||||
|
if (authorMatch) info.author = authorMatch[1].trim();
|
||||||
|
|
||||||
|
const homepageMatch = header.match(/@homepage\s+(.+?)(?:\r?\n|\*\/)/);
|
||||||
|
if (homepageMatch) info.homepage = homepageMatch[1].trim();
|
||||||
|
|
||||||
|
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: (str: string) => {
|
||||||
|
// 简化的 MD5 实现,实际使用可能需要引入完整库
|
||||||
|
console.warn('[LxMusicRunner] MD5 暂未完整实现');
|
||||||
|
return str;
|
||||||
|
},
|
||||||
|
randomBytes: (size: number) => {
|
||||||
|
const array = new Uint8Array(size);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
},
|
||||||
|
aesEncrypt: (buffer: any, _mode: string, _key: any, _iv: any) => {
|
||||||
|
console.warn('[LxMusicRunner] AES 加密暂未实现');
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
rsaEncrypt: (buffer: any, _key: string) => {
|
||||||
|
console.warn('[LxMusicRunner] RSA 加密暂未实现');
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 请求
|
||||||
|
*/
|
||||||
|
private handleHttpRequest(
|
||||||
|
url: string,
|
||||||
|
options: any,
|
||||||
|
callback: (err: Error | null, resp: any, body: any) => void
|
||||||
|
): () => void {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
console.log(`[LxMusicRunner] HTTP 请求: ${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,
|
||||||
|
// 使用 cors 模式尝试跨域请求
|
||||||
|
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 timeout = options.timeout || 30000;
|
||||||
|
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,如果是 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);
|
||||||
|
// 如果响应中包含 URL,缓存下来以备后用
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cloneDeep, merge } from 'lodash';
|
import { cloneDeep, isArray, mergeWith } from 'lodash';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
@@ -55,8 +55,16 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
? window.electron.ipcRenderer.sendSync('get-store-value', 'set')
|
? window.electron.ipcRenderer.sendSync('get-store-value', 'set')
|
||||||
: JSON.parse(localStorage.getItem('appSettings') || '{}');
|
: 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);
|
setSetData(mergedSettings);
|
||||||
|
|||||||
Vendored
+2
@@ -8,6 +8,8 @@ export interface IElectronAPI {
|
|||||||
openLyric: () => void;
|
openLyric: () => void;
|
||||||
sendLyric: (_data: string) => void;
|
sendLyric: (_data: string) => void;
|
||||||
unblockMusic: (_id: number) => Promise<string>;
|
unblockMusic: (_id: number) => Promise<string>;
|
||||||
|
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
|
||||||
|
importLxMusicScript: () => Promise<{ name: string; content: string } | null>;
|
||||||
onLanguageChanged: (_callback: (_locale: string) => void) => void;
|
onLanguageChanged: (_callback: (_locale: string) => void) => void;
|
||||||
store: {
|
store: {
|
||||||
get: (_key: string) => Promise<any>;
|
get: (_key: string) => Promise<any>;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -7,10 +7,18 @@ export type Platform =
|
|||||||
| 'pyncmd'
|
| 'pyncmd'
|
||||||
| 'joox'
|
| 'joox'
|
||||||
| 'bilibili'
|
| 'bilibili'
|
||||||
| 'gdmusic';
|
| 'gdmusic'
|
||||||
|
| 'lxMusic';
|
||||||
|
|
||||||
// 默认平台列表
|
// 默认平台列表
|
||||||
export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
|
export const DEFAULT_PLATFORMS: Platform[] = [
|
||||||
|
'lxMusic',
|
||||||
|
'migu',
|
||||||
|
'kugou',
|
||||||
|
'kuwo',
|
||||||
|
'pyncmd',
|
||||||
|
'bilibili'
|
||||||
|
];
|
||||||
|
|
||||||
export interface IRecommendMusic {
|
export interface IRecommendMusic {
|
||||||
code: number;
|
code: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user