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