feat: 插件系统完全兼容PC/Android版格式
This commit is contained in:
@@ -133,10 +133,13 @@
|
|||||||
<div class="plugin-info">
|
<div class="plugin-info">
|
||||||
<div class="plugin-name">{{ plugin.name }}</div>
|
<div class="plugin-name">{{ plugin.name }}</div>
|
||||||
<div class="plugin-desc">{{ plugin.description || '暂无描述' }}</div>
|
<div class="plugin-desc">{{ plugin.description || '暂无描述' }}</div>
|
||||||
<div class="plugin-meta" v-if="plugin.version">
|
<div class="plugin-meta">
|
||||||
<span class="version">v{{ plugin.version }}</span>
|
<span class="version" v-if="plugin.version">v{{ plugin.version }}</span>
|
||||||
<span class="author" v-if="plugin.author">by {{ plugin.author }}</span>
|
<span class="author" v-if="plugin.author">by {{ plugin.author }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="quality-tags" v-if="plugin.quality && plugin.quality.length > 0">
|
||||||
|
<span class="quality-tag" v-for="q in plugin.quality" :key="q.id">{{ q.ui }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="plugin-actions">
|
<div class="plugin-actions">
|
||||||
<button
|
<button
|
||||||
@@ -192,12 +195,12 @@
|
|||||||
<Icon icon="lucide:upload" />
|
<Icon icon="lucide:upload" />
|
||||||
本地文件
|
本地文件
|
||||||
</div>
|
</div>
|
||||||
<div class="method-desc">上传音源插件的JSON配置文件</div>
|
<div class="method-desc">上传 PC/Android 版的音源插件文件(.js)</div>
|
||||||
<div class="file-upload">
|
<div class="file-upload">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
accept=".json"
|
accept=".js,.json"
|
||||||
@change="handleFileUpload"
|
@change="handleFileUpload"
|
||||||
class="file-input"
|
class="file-input"
|
||||||
/>
|
/>
|
||||||
@@ -215,12 +218,20 @@
|
|||||||
<Icon icon="lucide:code" />
|
<Icon icon="lucide:code" />
|
||||||
代码导入
|
代码导入
|
||||||
</div>
|
</div>
|
||||||
<div class="method-desc">直接粘贴音源插件的JSON代码</div>
|
<div class="method-desc">粘贴 PC/Android 版插件的 JS 代码(module.exports 格式)</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="pluginCode"
|
v-model="pluginCode"
|
||||||
placeholder='{"id": "my-plugin", "name": "我的音源", "searchFunction": "return {list: [], total: 0};"}'
|
placeholder="module.exports = {
|
||||||
|
pluginInfo: {
|
||||||
|
info: { id: 'my-plugin', name: '我的音源', ... },
|
||||||
|
quality: [...]
|
||||||
|
},
|
||||||
|
musicSearch: { search: (query, page, limit) => {...} },
|
||||||
|
getUrl: (id, quality) => '...',
|
||||||
|
getLyric: (id) => '...'
|
||||||
|
}"
|
||||||
class="code-textarea"
|
class="code-textarea"
|
||||||
rows="6"
|
rows="8"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button class="add-btn full-width" @click="importFromCode" :disabled="!pluginCode">
|
<button class="add-btn full-width" @click="importFromCode" :disabled="!pluginCode">
|
||||||
<Icon icon="lucide:play" />
|
<Icon icon="lucide:play" />
|
||||||
@@ -252,7 +263,7 @@ import { ref, reactive, onBeforeMount, nextTick } from 'vue';
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { pluginManager } from '../plugins/index';
|
import { pluginManager } from '../plugins/index';
|
||||||
import type { PluginInfo } from '../types';
|
import type { PluginFullInfo } from '../types';
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
@@ -286,7 +297,7 @@ const appearance = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 插件管理相关
|
// 插件管理相关
|
||||||
const installedPlugins = ref<PluginInfo[]>([]);
|
const installedPlugins = ref<PluginFullInfo[]>([]);
|
||||||
const activePluginId = ref('');
|
const activePluginId = ref('');
|
||||||
const pluginUrl = ref('');
|
const pluginUrl = ref('');
|
||||||
const pluginCode = ref('');
|
const pluginCode = ref('');
|
||||||
@@ -305,6 +316,7 @@ const activatePlugin = (id: string) => {
|
|||||||
|
|
||||||
const removePlugin = (id: string) => {
|
const removePlugin = (id: string) => {
|
||||||
if (confirm('确定要删除这个音源吗?')) {
|
if (confirm('确定要删除这个音源吗?')) {
|
||||||
|
pluginManager.removeUserPlugin(id);
|
||||||
pluginManager.unregister(id);
|
pluginManager.unregister(id);
|
||||||
refreshPlugins();
|
refreshPlugins();
|
||||||
}
|
}
|
||||||
@@ -314,8 +326,8 @@ const importFromUrl = async () => {
|
|||||||
if (!pluginUrl.value) return;
|
if (!pluginUrl.value) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(pluginUrl.value);
|
const response = await fetch(pluginUrl.value);
|
||||||
const data = await response.json();
|
const code = await response.text();
|
||||||
await importPluginData(data);
|
importPluginCode(code);
|
||||||
pluginUrl.value = '';
|
pluginUrl.value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('导入失败:' + (e as Error).message);
|
alert('导入失败:' + (e as Error).message);
|
||||||
@@ -334,78 +346,36 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
selectedFile.value = file;
|
selectedFile.value = file;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const code = await file.text();
|
||||||
const data = JSON.parse(text);
|
importPluginCode(code);
|
||||||
await importPluginData(data);
|
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
target.value = '';
|
target.value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('文件解析失败:' + (e as Error).message);
|
alert('文件读取失败:' + (e as Error).message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFromCode = async () => {
|
const importFromCode = () => {
|
||||||
if (!pluginCode.value) return;
|
if (!pluginCode.value) return;
|
||||||
try {
|
importPluginCode(pluginCode.value);
|
||||||
const data = JSON.parse(pluginCode.value);
|
pluginCode.value = '';
|
||||||
await importPluginData(data);
|
|
||||||
pluginCode.value = '';
|
|
||||||
} catch (e) {
|
|
||||||
alert('代码解析失败:' + (e as Error).message);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const importPluginData = async (data: any) => {
|
/** 导入插件代码(兼容 PC/Android 版 CommonJS module.exports 格式) */
|
||||||
// 验证必要字段
|
const importPluginCode = (code: string) => {
|
||||||
if (!data.id || !data.name) {
|
const module = pluginManager.loadFromCode(code, 'user');
|
||||||
alert('插件数据缺少必要字段:id 和 name');
|
if (!module) {
|
||||||
|
alert('插件加载失败,请检查代码格式是否正确(需要 module.exports = {...})');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记为用户插件
|
// 保存到 localStorage
|
||||||
data.source = 'user';
|
pluginManager.saveUserPlugin(code);
|
||||||
|
|
||||||
// 创建插件模块
|
|
||||||
const module = {
|
|
||||||
info: {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description || '',
|
|
||||||
version: data.version || '1.0.0',
|
|
||||||
author: data.author || '未知',
|
|
||||||
source: 'user' as const,
|
|
||||||
},
|
|
||||||
search: async (query: string, page: number, limit: number) => {
|
|
||||||
if (data.searchFunction) {
|
|
||||||
try {
|
|
||||||
const fn = new Function('query', 'page', 'limit', data.searchFunction);
|
|
||||||
return await fn(query, page, limit);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Search function error:', e);
|
|
||||||
return { list: [], total: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { list: [], total: 0 };
|
|
||||||
},
|
|
||||||
getSongUrl: data.getSongUrlFunction ? async (songId: string) => {
|
|
||||||
const fn = new Function('songId', data.getSongUrlFunction);
|
|
||||||
return await fn(songId);
|
|
||||||
} : undefined,
|
|
||||||
getLyric: data.getLyricFunction ? async (songId: string) => {
|
|
||||||
const fn = new Function('songId', data.getLyricFunction);
|
|
||||||
return await fn(songId);
|
|
||||||
} : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
pluginManager.registerModule(module);
|
|
||||||
|
|
||||||
// 保存到localStorage
|
|
||||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
|
||||||
saved.push(data);
|
|
||||||
localStorage.setItem('qz-user-plugins', JSON.stringify(saved));
|
|
||||||
|
|
||||||
refreshPlugins();
|
refreshPlugins();
|
||||||
alert(`音源 "${data.name}" 导入成功!`);
|
|
||||||
|
const info = module.pluginInfo?.info || module.info;
|
||||||
|
alert(`音源 "${info?.name || '未知'}" 导入成功!`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyTheme = (theme: 'dark' | 'light') => {
|
const applyTheme = (theme: 'dark' | 'light') => {
|
||||||
@@ -871,6 +841,23 @@ onBeforeMount(async () => {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-actions {
|
.plugin-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -1,52 +1,65 @@
|
|||||||
import type { PluginModule, PluginSearchResult } from '../../types/plugin';
|
import type { PluginModule } from '../../types/plugin';
|
||||||
|
|
||||||
const mockSongs = [
|
/**
|
||||||
{ id: '1', name: '晴天', artist: '周杰伦', albumName: '叶惠美', duration: '04:29', picUrl: 'https://picsum.photos/200/200?random=1' },
|
* 默认音源插件(PC 原版格式)
|
||||||
{ id: '2', name: '夜曲', artist: '周杰伦', albumName: '十一月的萧邦', duration: '04:23', picUrl: 'https://picsum.photos/200/200?random=2' },
|
* 使用 module.exports 格式,与 PC/Android 版完全一致
|
||||||
{ id: '3', name: '稻香', artist: '周杰伦', albumName: '魔杰座', duration: '03:43', picUrl: 'https://picsum.photos/200/200?random=3' },
|
*/
|
||||||
{ id: '4', name: '七里香', artist: '周杰伦', albumName: '七里香', duration: '04:59', picUrl: 'https://picsum.photos/200/200?random=4' },
|
const defaultPluginCode = `
|
||||||
{ id: '5', name: '告白气球', artist: '周杰伦', albumName: '周杰伦的床边故事', duration: '03:35', picUrl: 'https://picsum.photos/200/200?random=5' },
|
module.exports = {
|
||||||
{ id: '6', name: '成都', artist: '赵雷', albumName: '无法长大', duration: '05:28', picUrl: 'https://picsum.photos/200/200?random=6' },
|
pluginInfo: {
|
||||||
{ id: '7', name: '理想', artist: '赵雷', albumName: '无法长大', duration: '04:26', picUrl: 'https://picsum.photos/200/200?random=7' },
|
info: {
|
||||||
{ id: '8', name: '南方姑娘', artist: '赵雷', albumName: '赵小雷', duration: '05:34', picUrl: 'https://picsum.photos/200/200?random=8' },
|
id: 'default',
|
||||||
{ id: '9', name: '平凡之路', artist: '朴树', albumName: '猎户星座', duration: '04:46', picUrl: 'https://picsum.photos/200/200?random=9' },
|
name: '默认音源',
|
||||||
{ id: '10', name: '那些花儿', artist: '朴树', albumName: '我去2000年', duration: '04:57', picUrl: 'https://picsum.photos/200/200?random=10' },
|
description: '内置音乐搜索插件(演示用)',
|
||||||
];
|
version: '1.0.0',
|
||||||
|
author: 'QZMusic'
|
||||||
const searchSongs = (query: string): typeof mockSongs => {
|
},
|
||||||
const q = query.toLowerCase();
|
quality: [
|
||||||
return mockSongs.filter(song =>
|
{ id: 'standard', name: '标准', ui: 'SQ' },
|
||||||
song.name.toLowerCase().includes(q) ||
|
{ id: 'exhigh', name: '极高', ui: 'HQ' },
|
||||||
song.artist.toLowerCase().includes(q) ||
|
{ id: 'hires', name: 'Hi-Res', ui: 'HR' }
|
||||||
song.albumName.toLowerCase().includes(q)
|
]
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultPlugin: PluginModule = {
|
|
||||||
info: {
|
|
||||||
id: 'default',
|
|
||||||
name: '默认音源',
|
|
||||||
description: '内置音乐搜索插件',
|
|
||||||
version: '1.0.0',
|
|
||||||
author: 'QZMusic',
|
|
||||||
source: 'built-in',
|
|
||||||
},
|
},
|
||||||
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
|
musicSearch: {
|
||||||
const results = searchSongs(query);
|
search: function(query, page, limit) {
|
||||||
const start = (page - 1) * limit;
|
var songs = [
|
||||||
const end = start + limit;
|
{ songmid: '1', name: '晴天', singer: '周杰伦', albumName: '叶惠美', interval: 269000, img: 'https://picsum.photos/200/200?random=1', source: 'default' },
|
||||||
return {
|
{ songmid: '2', name: '夜曲', singer: '周杰伦', albumName: '十一月的萧邦', interval: 263000, img: 'https://picsum.photos/200/200?random=2', source: 'default' },
|
||||||
list: results.slice(start, end),
|
{ songmid: '3', name: '稻香', singer: '周杰伦', albumName: '魔杰座', interval: 223000, img: 'https://picsum.photos/200/200?random=3', source: 'default' },
|
||||||
total: results.length,
|
{ songmid: '4', name: '七里香', singer: '周杰伦', albumName: '七里香', interval: 299000, img: 'https://picsum.photos/200/200?random=4', source: 'default' },
|
||||||
songCount: results.length,
|
{ songmid: '5', name: '告白气球', singer: '周杰伦', albumName: '周杰伦的床边故事', interval: 215000, img: 'https://picsum.photos/200/200?random=5', source: 'default' },
|
||||||
};
|
{ songmid: '6', name: '成都', singer: '赵雷', albumName: '无法长大', interval: 328000, img: 'https://picsum.photos/200/200?random=6', source: 'default' },
|
||||||
|
{ songmid: '7', name: '理想', singer: '赵雷', albumName: '无法长大', interval: 266000, img: 'https://picsum.photos/200/200?random=7', source: 'default' },
|
||||||
|
{ songmid: '8', name: '南方姑娘', singer: '赵雷', albumName: '赵小雷', interval: 334000, img: 'https://picsum.photos/200/200?random=8', source: 'default' },
|
||||||
|
{ songmid: '9', name: '平凡之路', singer: '朴树', albumName: '猎户星座', interval: 286000, img: 'https://picsum.photos/200/200?random=9', source: 'default' },
|
||||||
|
{ songmid: '10', name: '那些花儿', singer: '朴树', albumName: '我去2000年', interval: 297000, img: 'https://picsum.photos/200/200?random=10', source: 'default' }
|
||||||
|
];
|
||||||
|
var q = query.toLowerCase();
|
||||||
|
var filtered = songs.filter(function(s) {
|
||||||
|
return s.name.toLowerCase().indexOf(q) !== -1 || s.singer.toLowerCase().indexOf(q) !== -1;
|
||||||
|
});
|
||||||
|
var start = (page - 1) * limit;
|
||||||
|
var end = start + limit;
|
||||||
|
return {
|
||||||
|
list: filtered.slice(start, end),
|
||||||
|
songCount: filtered.length,
|
||||||
|
total: filtered.length
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async getSongUrl(_songId: string): Promise<string> {
|
getUrl: function(id, quality) {
|
||||||
return 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg';
|
return 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg';
|
||||||
},
|
},
|
||||||
async getLyric(_songId: string): Promise<string> {
|
getLyric: function(id) {
|
||||||
return `[00:00.00]歌曲歌词
|
return '[00:00.00]歌曲歌词\\n[00:05.00]暂无歌词数据\\n[00:10.00]---';
|
||||||
[00:05.00]暂无歌词数据
|
}
|
||||||
[00:10.00]---`;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const defaultPluginModule: PluginModule = (function() {
|
||||||
|
var module = { exports: {} };
|
||||||
|
eval(defaultPluginCode);
|
||||||
|
return module.exports;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export { defaultPluginCode };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { pluginManager } from './pluginManager';
|
export { pluginManager } from './pluginManager';
|
||||||
export { defaultPlugin } from './impl/defaultPlugin';
|
export { defaultPluginModule, defaultPluginCode } from './impl/defaultPlugin';
|
||||||
export * from '../types/plugin';
|
export * from '../types/plugin';
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { pluginManager, defaultPlugin } from './index';
|
import { pluginManager, defaultPluginModule } from './index';
|
||||||
|
|
||||||
export const initPlugins = async (): Promise<void> => {
|
export const initPlugins = async (): Promise<void> => {
|
||||||
await pluginManager.register(async () => defaultPlugin);
|
// 注册内置默认插件
|
||||||
|
pluginManager.registerModule(defaultPluginModule);
|
||||||
|
|
||||||
|
// 恢复用户保存的插件
|
||||||
|
pluginManager.loadUserPlugins();
|
||||||
|
|
||||||
|
// 恢复上次选择的插件
|
||||||
const savedPlugin = sessionStorage.getItem('qz-active-plugin');
|
const savedPlugin = sessionStorage.getItem('qz-active-plugin');
|
||||||
if (savedPlugin) {
|
if (savedPlugin && pluginManager.get(savedPlugin)) {
|
||||||
pluginManager.setActivePlugin(savedPlugin);
|
pluginManager.setActivePlugin(savedPlugin);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,56 +1,96 @@
|
|||||||
import type { PluginInfo, PluginModule, PluginSearchResult } from '../types/plugin';
|
import type { PluginFullInfo, PluginModule, PluginSearchResult, UrlResponse } from '../types/plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QZMusic Web 插件管理器
|
||||||
|
* 兼容 PC/Android 原版的 CommonJS 插件格式
|
||||||
|
*
|
||||||
|
* PC 原版插件格式:
|
||||||
|
* module.exports = {
|
||||||
|
* pluginInfo: { info: { id, name, ... }, quality: [...] },
|
||||||
|
* getUrl: (id, quality) => url,
|
||||||
|
* musicSearch: { search: (query, page, limit) => result },
|
||||||
|
* getLyric: (id) => lyric
|
||||||
|
* }
|
||||||
|
*/
|
||||||
class PluginManager {
|
class PluginManager {
|
||||||
private plugins: Map<string, PluginModule> = new Map();
|
private plugins: Map<string, PluginModule> = new Map();
|
||||||
private activePluginId: string = '';
|
private activePluginId: string = '';
|
||||||
|
|
||||||
async register(loader: () => Promise<PluginModule>): Promise<void> {
|
/** 注册插件模块(直接传入已解析的模块对象) */
|
||||||
try {
|
|
||||||
const module = await loader();
|
|
||||||
this.plugins.set(module.info.id, module);
|
|
||||||
if (!this.activePluginId) {
|
|
||||||
this.activePluginId = module.info.id;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load plugin:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接注册插件模块
|
|
||||||
registerModule(module: PluginModule): boolean {
|
registerModule(module: PluginModule): boolean {
|
||||||
if (!module.info || !module.info.id) {
|
const info = this.extractInfo(module);
|
||||||
console.error('Plugin module must have info.id');
|
if (!info || !info.id) {
|
||||||
|
console.error('[PluginManager] 插件缺少有效的 info 或 pluginInfo.info.id');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.plugins.set(module.info.id, module);
|
this.plugins.set(info.id, module);
|
||||||
if (!this.activePluginId) {
|
if (!this.activePluginId) {
|
||||||
this.activePluginId = module.info.id;
|
this.activePluginId = info.id;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 卸载插件
|
/** 从 JS 代码字符串加载插件(兼容 PC 版 CommonJS 格式) */
|
||||||
|
loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null {
|
||||||
|
try {
|
||||||
|
// 将 CommonJS module.exports 转换为可执行的函数
|
||||||
|
// 支持 module.exports = { ... } 和 exports.xxx = ... 语法
|
||||||
|
const wrappedCode = `
|
||||||
|
(function() {
|
||||||
|
var module = { exports: {} };
|
||||||
|
var exports = module.exports;
|
||||||
|
${code}
|
||||||
|
return module.exports;
|
||||||
|
})()
|
||||||
|
`;
|
||||||
|
const module = new Function(wrappedCode)() as PluginModule;
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
console.error('[PluginManager] 插件代码执行后返回空');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记来源
|
||||||
|
if (module.pluginInfo?.info) {
|
||||||
|
(module.pluginInfo.info as any).__source = source;
|
||||||
|
}
|
||||||
|
if (module.info) {
|
||||||
|
(module.info as any).__source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registerModule(module);
|
||||||
|
return module;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginManager] 加载插件代码失败:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卸载插件 */
|
||||||
unregister(id: string): boolean {
|
unregister(id: string): boolean {
|
||||||
if (this.activePluginId === id) {
|
if (this.activePluginId === id) {
|
||||||
// 如果卸载的是当前激活的插件,切换到第一个可用的
|
|
||||||
const remaining = this.getAll();
|
const remaining = this.getAll();
|
||||||
this.activePluginId = remaining.length > 0 && remaining[0].id !== id ? remaining[0].id : '';
|
this.activePluginId = remaining.length > 0 && remaining[0].id !== id ? remaining[0].id : '';
|
||||||
}
|
}
|
||||||
return this.plugins.delete(id);
|
return this.plugins.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(): PluginInfo[] {
|
/** 获取所有插件信息 */
|
||||||
return Array.from(this.plugins.values()).map(p => p.info);
|
getAll(): PluginFullInfo[] {
|
||||||
|
return Array.from(this.plugins.values()).map(p => this.extractInfo(p)!).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取插件模块 */
|
||||||
get(id: string): PluginModule | undefined {
|
get(id: string): PluginModule | undefined {
|
||||||
return this.plugins.get(id);
|
return this.plugins.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前激活的插件 */
|
||||||
getActivePlugin(): PluginModule | undefined {
|
getActivePlugin(): PluginModule | undefined {
|
||||||
return this.plugins.get(this.activePluginId);
|
return this.plugins.get(this.activePluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置激活插件 */
|
||||||
setActivePlugin(id: string): boolean {
|
setActivePlugin(id: string): boolean {
|
||||||
if (this.plugins.has(id)) {
|
if (this.plugins.has(id)) {
|
||||||
this.activePluginId = id;
|
this.activePluginId = id;
|
||||||
@@ -60,84 +100,124 @@ class PluginManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前激活插件ID */
|
||||||
getActivePluginId(): string {
|
getActivePluginId(): string {
|
||||||
return this.activePluginId;
|
return this.activePluginId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 搜索(兼容 PC 版 musicSearch.search 接口) */
|
||||||
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
|
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
|
||||||
const plugin = this.getActivePlugin();
|
const plugin = this.getActivePlugin();
|
||||||
if (!plugin) {
|
if (!plugin?.musicSearch?.search) {
|
||||||
return { list: [], total: 0 };
|
return { list: [], total: 0, error: '当前插件不支持搜索' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await plugin.musicSearch.search(query, page, limit);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginManager] 搜索失败:', e);
|
||||||
|
return { list: [], total: 0, error: (e as Error).message };
|
||||||
}
|
}
|
||||||
return plugin.search(query, page, limit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongUrl(songId: string): Promise<string> {
|
/** 获取歌曲URL(兼容 PC 版 getUrl 接口) */
|
||||||
|
async getSongUrl(id: string, quality: string = 'standard'): Promise<UrlResponse> {
|
||||||
const plugin = this.getActivePlugin();
|
const plugin = this.getActivePlugin();
|
||||||
if (!plugin || !plugin.getSongUrl) {
|
if (!plugin?.getUrl) {
|
||||||
throw new Error('No active plugin or plugin does not support getSongUrl');
|
return { success: false, error: '当前插件不支持获取URL' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await plugin.getUrl(id, quality);
|
||||||
|
if (typeof url !== 'string' || !url.startsWith('http')) {
|
||||||
|
return { success: false, error: '无效的URL' };
|
||||||
|
}
|
||||||
|
return { success: true, url };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: (e as Error).message || '插件错误' };
|
||||||
}
|
}
|
||||||
return plugin.getSongUrl(songId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLyric(songId: string): Promise<string> {
|
/** 获取歌词(兼容 PC 版 getLyric 接口) */
|
||||||
|
async getLyric(id: string): Promise<any> {
|
||||||
const plugin = this.getActivePlugin();
|
const plugin = this.getActivePlugin();
|
||||||
if (!plugin || !plugin.getLyric) {
|
if (!plugin?.getLyric) {
|
||||||
throw new Error('No active plugin or plugin does not support getLyric');
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await plugin.getLyric(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginManager] 获取歌词失败:', e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return plugin.getLyric(songId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPlugins(): boolean {
|
hasPlugins(): boolean {
|
||||||
return this.plugins.size > 0;
|
return this.plugins.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从localStorage加载用户添加的插件
|
/** 从 localStorage 恢复用户插件 */
|
||||||
loadUserPlugins(): void {
|
loadUserPlugins(): void {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('qz-user-plugins');
|
const saved = localStorage.getItem('qz-user-plugins');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const pluginList = JSON.parse(saved);
|
const pluginList: string[] = JSON.parse(saved);
|
||||||
pluginList.forEach((pluginData: any) => {
|
pluginList.forEach(code => {
|
||||||
this.loadPluginFromData(pluginData);
|
this.loadFromCode(code, 'user');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load user plugins:', e);
|
console.error('[PluginManager] 恢复用户插件失败:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存用户插件到localStorage
|
/** 保存用户插件到 localStorage */
|
||||||
saveUserPlugins(): void {
|
saveUserPlugin(code: string): void {
|
||||||
const userPlugins = this.getAll().filter(p => p.source === 'user');
|
const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||||
localStorage.setItem('qz-user-plugins', JSON.stringify(userPlugins));
|
saved.push(code);
|
||||||
|
localStorage.setItem('qz-user-plugins', JSON.stringify(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从数据加载插件(用于用户上传的插件)
|
/** 删除用户插件代码 */
|
||||||
private loadPluginFromData(data: any): void {
|
removeUserPlugin(id: string): void {
|
||||||
try {
|
const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||||
// 创建一个简单的插件模块
|
// 尝试从代码中提取id来匹配
|
||||||
const module: PluginModule = {
|
const filtered = saved.filter(code => {
|
||||||
info: {
|
try {
|
||||||
id: data.id,
|
const wrappedCode = `
|
||||||
name: data.name,
|
(function() {
|
||||||
description: data.description,
|
var module = { exports: {} };
|
||||||
version: data.version,
|
var exports = module.exports;
|
||||||
author: data.author,
|
${code}
|
||||||
},
|
return module.exports;
|
||||||
search: async (query: string, page: number, limit: number) => {
|
})()
|
||||||
// 如果插件有自定义搜索逻辑,使用eval执行
|
`;
|
||||||
if (data.searchFunction) {
|
const mod = new Function(wrappedCode)() as any;
|
||||||
const fn = new Function('query', 'page', 'limit', data.searchFunction);
|
const info = mod?.pluginInfo?.info || mod?.info;
|
||||||
return await fn(query, page, limit);
|
return info?.id !== id;
|
||||||
}
|
} catch {
|
||||||
return { list: [], total: 0 };
|
return true; // 解析失败的保留
|
||||||
},
|
}
|
||||||
|
});
|
||||||
|
localStorage.setItem('qz-user-plugins', JSON.stringify(filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取插件信息(兼容 PC 版两种格式) */
|
||||||
|
private extractInfo(module: PluginModule): PluginFullInfo | null {
|
||||||
|
// PC 原版格式:pluginInfo.info
|
||||||
|
if (module.pluginInfo?.info) {
|
||||||
|
return {
|
||||||
|
...module.pluginInfo.info,
|
||||||
|
quality: module.pluginInfo.quality,
|
||||||
|
source: (module.pluginInfo.info as any).__source || 'built-in',
|
||||||
};
|
};
|
||||||
this.registerModule(module);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load plugin from data:', e);
|
|
||||||
}
|
}
|
||||||
|
// 简化格式:info
|
||||||
|
if (module.info) {
|
||||||
|
return {
|
||||||
|
...module.info,
|
||||||
|
source: (module.info as any).__source || 'built-in',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
// 兼容 PC/Android 原版 QZMusic 插件系统的类型定义
|
||||||
|
|
||||||
|
/** 音质信息 */
|
||||||
|
export interface PluginQuality {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ui: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件元信息(兼容 PC 版 pluginInfo.info) */
|
||||||
export interface PluginInfo {
|
export interface PluginInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,24 +18,37 @@ export interface PluginInfo {
|
|||||||
source?: 'built-in' | 'user';
|
source?: 'built-in' | 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 插件完整信息(包含音质列表) */
|
||||||
|
export interface PluginFullInfo extends PluginInfo {
|
||||||
|
quality?: PluginQuality[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PC 原版插件模块接口 */
|
||||||
|
export interface PluginModule {
|
||||||
|
pluginInfo?: {
|
||||||
|
info: PluginInfo;
|
||||||
|
quality?: PluginQuality[];
|
||||||
|
};
|
||||||
|
info?: PluginInfo;
|
||||||
|
getUrl?: (id: string, quality: string) => Promise<string> | string;
|
||||||
|
musicSearch?: {
|
||||||
|
search: (query: string, page: number, limit: number) => Promise<PluginSearchResult> | PluginSearchResult;
|
||||||
|
};
|
||||||
|
getLyric?: (id: string) => Promise<string | object> | string | object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索结果 */
|
||||||
export interface PluginSearchResult {
|
export interface PluginSearchResult {
|
||||||
list: any[];
|
list: any[];
|
||||||
total?: number;
|
total?: number;
|
||||||
songCount?: number;
|
songCount?: number;
|
||||||
|
allPage?: number;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginAPI {
|
/** URL 响应 */
|
||||||
search: (pluginId: string, query: string, page: number, limit: number) => Promise<PluginSearchResult>;
|
export interface UrlResponse {
|
||||||
getSongUrl: (pluginId: string, songId: string) => Promise<string>;
|
success: boolean;
|
||||||
getLyric: (pluginId: string, songId: string) => Promise<string>;
|
url?: string;
|
||||||
getAll: () => Promise<PluginInfo[]>;
|
error?: string;
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginLoader = () => Promise<PluginModule>;
|
|
||||||
|
|
||||||
export interface PluginModule {
|
|
||||||
info: PluginInfo;
|
|
||||||
search: (query: string, page: number, limit: number) => Promise<PluginSearchResult>;
|
|
||||||
getSongUrl?: (songId: string) => Promise<string>;
|
|
||||||
getLyric?: (songId: string) => Promise<string>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Song } from '../types/song';
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
|
/** 将毫秒转换为 MM:SS 格式 */
|
||||||
export function formatDuration(ms: number): string {
|
export function formatDuration(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
@@ -7,19 +8,39 @@ export function formatDuration(ms: number): string {
|
|||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 PC 原版插件搜索结果转换为 Web 版 Song 格式
|
||||||
|
* 兼容 PC 版 songUtils.ts 的 transformSearchSong
|
||||||
|
*
|
||||||
|
* PC 原版搜索结果字段:
|
||||||
|
* - songmid: 歌曲 ID
|
||||||
|
* - name: 歌曲名
|
||||||
|
* - singer: 歌手名
|
||||||
|
* - img / m_img / s_img: 封面图
|
||||||
|
* - interval: 时长(毫秒)
|
||||||
|
* - source: 来源标识
|
||||||
|
* - albumId: 专辑 ID
|
||||||
|
* - albumName: 专辑名
|
||||||
|
* - types: 音质映射
|
||||||
|
*/
|
||||||
export function transformSearchSong(raw: any): Song {
|
export function transformSearchSong(raw: any): Song {
|
||||||
return {
|
return {
|
||||||
id: String(raw.songmid),
|
id: String(raw.songmid || raw.id || ''),
|
||||||
name: raw.name,
|
name: raw.name || '未知歌曲',
|
||||||
artist: raw.singer,
|
artist: raw.singer || raw.artist || '未知歌手',
|
||||||
picUrl: raw.img || raw.m_img || raw.s_img,
|
picUrl: raw.img || raw.m_img || raw.s_img || raw.picUrl || '',
|
||||||
url: '', // Empty initially
|
url: '',
|
||||||
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
|
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
|
||||||
source: raw.source,
|
source: raw.source || '',
|
||||||
albumId: raw.albumId ? String(raw.albumId) : null,
|
albumId: raw.albumId ? String(raw.albumId) : null,
|
||||||
albumName: raw.albumName,
|
albumName: raw.albumName || null,
|
||||||
type: 'Remote',
|
type: 'Remote',
|
||||||
quality: 'auto',
|
quality: 'auto',
|
||||||
types: raw.types // Store raw types for quality selection later
|
types: raw.types || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量转换搜索结果 */
|
||||||
|
export function transformSearchResults(results: any[]): Song[] {
|
||||||
|
return results.map(item => transformSearchSong(item));
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ import { useRoute } from 'vue-router';
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { pluginManager } from '../plugins/index';
|
import { pluginManager } from '../plugins/index';
|
||||||
import type { PluginInfo, Song } from '../types';
|
import { transformSearchResults } from '../utils/songUtils';
|
||||||
|
import type { PluginFullInfo, Song } from '../types';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
@@ -144,7 +145,7 @@ const loading = ref(false);
|
|||||||
const error = ref(false);
|
const error = ref(false);
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
|
|
||||||
const plugins = ref<PluginInfo[]>([]);
|
const plugins = ref<PluginFullInfo[]>([]);
|
||||||
const activePlugin = ref<string>('');
|
const activePlugin = ref<string>('');
|
||||||
const isDropdownOpen = ref(false);
|
const isDropdownOpen = ref(false);
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ const toggleDropdown = () => {
|
|||||||
isDropdownOpen.value = !isDropdownOpen.value;
|
isDropdownOpen.value = !isDropdownOpen.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectPlugin = (plugin: PluginInfo) => {
|
const selectPlugin = (plugin: PluginFullInfo) => {
|
||||||
if (activePlugin.value !== plugin.id) {
|
if (activePlugin.value !== plugin.id) {
|
||||||
activePlugin.value = plugin.id;
|
activePlugin.value = plugin.id;
|
||||||
pluginManager.setActivePlugin(plugin.id);
|
pluginManager.setActivePlugin(plugin.id);
|
||||||
@@ -224,17 +225,7 @@ const fetchData = async () => {
|
|||||||
const result = await pluginManager.search(query.value, currentPage.value, limit.value);
|
const result = await pluginManager.search(query.value, currentPage.value, limit.value);
|
||||||
|
|
||||||
if (result && result.list) {
|
if (result && result.list) {
|
||||||
songs.value = result.list.map((item: any) => ({
|
songs.value = transformSearchResults(result.list);
|
||||||
id: String(item.id),
|
|
||||||
name: item.name,
|
|
||||||
artist: item.artist,
|
|
||||||
albumName: item.albumName,
|
|
||||||
duration: item.duration,
|
|
||||||
url: item.url || '',
|
|
||||||
picUrl: item.picUrl || '',
|
|
||||||
source: activePlugin.value,
|
|
||||||
type: 'Remote' as const
|
|
||||||
}));
|
|
||||||
total.value = result.songCount || result.total || 0;
|
total.value = result.songCount || result.total || 0;
|
||||||
} else {
|
} else {
|
||||||
total.value = 0;
|
total.value = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user