feat: 优化音源解析

This commit is contained in:
alger
2026-02-10 09:06:25 +08:00
parent 16b2a1cece
commit bb2dbc3f00
21 changed files with 351 additions and 244 deletions
+193 -72
View File
@@ -22,37 +22,58 @@
<div class="reparse-popover bg-light-100 dark:bg-dark-100 p-4 rounded-xl max-w-60">
<div class="text-base font-medium mb-2">{{ t('player.reparse.title') }}</div>
<div class="text-sm opacity-70 mb-3">{{ t('player.reparse.desc') }}</div>
<div class="mb-3">
<div class="mb-3 max-h-80 overflow-y-auto">
<div class="flex flex-col space-y-2">
<div
v-for="source in musicSourceOptions"
:key="source.value"
class="source-button flex items-center p-2 rounded-lg cursor-pointer transition-all duration-200 bg-light-200 dark:bg-dark-200 hover:bg-light-300 dark:hover:bg-dark-300"
:class="{
'bg-green-50 dark:bg-green-900/20 text-green-500': isCurrentSource(source.value),
'opacity-50 cursor-not-allowed': isReparsing
}"
@click="directReparseMusic(source.value)"
>
<div class="flex items-center justify-center w-6 h-6 mr-3 text-lg">
<i :class="getSourceIcon(source.value)"></i>
</div>
<div class="flex-1 text-sm whitespace-nowrap overflow-hidden text-ellipsis">
{{ source.label }}
</div>
<template v-for="(group, groupIndex) in groupedSources" :key="group.key">
<!-- 分组分隔线 -->
<div
v-if="isReparsing && currentReparsingSource === source.value"
class="w-5 h-5 flex items-center justify-center"
>
<i class="ri-loader-4-line animate-spin"></i>
</div>
v-if="groupIndex > 0"
class="border-t border-gray-200 dark:border-gray-700 my-1"
></div>
<div
v-else-if="isCurrentSource(source.value)"
class="w-5 h-5 flex items-center justify-center"
v-for="source in group.sources"
:key="source.id"
class="source-button flex items-center p-2 rounded-lg transition-all duration-200"
:class="[
source.available
? 'cursor-pointer bg-light-200 dark:bg-dark-200 hover:bg-light-300 dark:hover:bg-dark-300'
: 'opacity-40 cursor-not-allowed bg-light-200 dark:bg-dark-200',
{
'bg-green-50 dark:bg-green-900/20 text-green-500': isCurrentSource(source.id),
'opacity-50 cursor-not-allowed': isReparsing && source.available
}
]"
@click="source.available && handleSourceClick(source)"
>
<i class="ri-check-line"></i>
<div
class="flex items-center justify-center w-6 h-6 mr-3 text-lg"
:style="{ color: source.color }"
>
<i :class="source.icon"></i>
</div>
<div class="flex-1 text-sm whitespace-nowrap overflow-hidden text-ellipsis">
<span>{{ source.label }}</span>
<n-tooltip v-if="!source.available && source.configHint" trigger="hover">
<template #trigger>
<i class="ri-information-line text-xs ml-1 opacity-60"></i>
</template>
{{ t(source.configHint) }}
</n-tooltip>
</div>
<div
v-if="isReparsing && currentReparsingId === source.id"
class="w-5 h-5 flex items-center justify-center"
>
<i class="ri-loader-4-line animate-spin"></i>
</div>
<div
v-else-if="isCurrentSource(source.id)"
class="w-5 h-5 flex items-center justify-center"
>
<i class="ri-check-line"></i>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 清除自定义音源 -->
@@ -78,50 +99,101 @@ import { useI18n } from 'vue-i18n';
import { CacheManager } from '@/api/musicParser';
import { playMusic } from '@/hooks/MusicHook';
import { initLxMusicRunner, setLxMusicRunner } from '@/services/LxMusicSourceRunner';
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import { useSettingsStore } from '@/store';
import { usePlayerStore } from '@/store/modules/player';
import type { LxMusicScriptConfig } from '@/types/lxMusic';
import type { Platform } from '@/types/music';
import { type MusicSourceGroup, useMusicSources } from '@/utils/musicSourceConfig';
type ReparseSourceItem = {
id: string;
platform: Platform;
label: string;
icon: string;
color: string;
group: MusicSourceGroup;
available: boolean;
configHint?: string;
lxScriptId?: string;
};
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const { t } = useI18n();
const message = useMessage();
const { allSources } = useMusicSources();
// 音源重新解析状态
const isReparsing = ref(false);
const currentReparsingSource = ref<Platform | null>(null);
const currentReparsingId = ref<string | null>(null);
// 实际存储选中音源的值
const selectedSourcesValue = ref<Platform[]>([]);
// 当前选中音源条目 id(唯一标识,区分不同 lxMusic 脚本)
const selectedSourceId = ref<string | null>(null);
const isReparse = computed(() => selectedSourcesValue.value.length > 0);
const isReparse = computed(() => selectedSourceId.value !== null);
// 可选音源列表
const musicSourceOptions = ref([
{ label: 'MiGu', value: 'migu' as Platform },
{ label: 'KuGou', value: 'kugou' as Platform },
{ label: 'pyncmd', value: 'pyncmd' as Platform },
{ label: 'GdMuisc', value: 'gdmusic' as Platform }
]);
// 构建重解析音源列表:将 lxMusic 展开为每个导入的脚本
const reparseSourceList = computed<ReparseSourceItem[]>(() => {
const result: ReparseSourceItem[] = [];
for (const source of allSources.value) {
if (source.key === 'lxMusic') {
const scripts: LxMusicScriptConfig[] = settingsStore.setData.lxMusicScripts || [];
for (const script of scripts) {
result.push({
id: `lxMusic:${script.id}`,
platform: 'lxMusic',
label: script.name,
icon: source.icon,
color: source.color,
group: source.group,
available: true,
lxScriptId: script.id
});
}
// 没有导入任何脚本时显示占位
if (scripts.length === 0) {
result.push({
id: 'lxMusic',
platform: 'lxMusic',
label: 'lxMusic',
icon: source.icon,
color: source.color,
group: source.group,
available: false,
configHint: 'settings.playback.lxMusic.scripts.notConfigured'
});
}
} else {
result.push({
id: source.key,
platform: source.key,
label: source.key,
icon: source.icon,
color: source.color,
group: source.group,
available: source.available,
configHint: source.configHint
});
}
}
return result;
});
// 检查音源是否被选中
const isCurrentSource = (source: Platform) => {
return selectedSourcesValue.value.includes(source);
};
// 按分组排列音源
const GROUP_ORDER: MusicSourceGroup[] = ['unblock', 'extended', 'plugin'];
// 获取音源图标
const getSourceIcon = (source: Platform) => {
const iconMap: Record<Platform, string> = {
migu: 'ri-music-2-fill',
kugou: 'ri-music-fill',
qq: 'ri-qq-fill',
joox: 'ri-disc-fill',
pyncmd: 'ri-netease-cloud-music-fill',
gdmusic: 'ri-google-fill',
kuwo: 'ri-music-fill',
lxMusic: 'ri-leaf-fill'
};
const groupedSources = computed(() => {
return GROUP_ORDER.map((groupKey) => ({
key: groupKey,
sources: reparseSourceList.value.filter((s) => s.group === groupKey)
})).filter((g) => g.sources.length > 0);
});
return iconMap[source] || 'ri-music-2-fill';
// 检查音源条目是否被选中
const isCurrentSource = (sourceId: string) => {
return selectedSourceId.value === sourceId;
};
// 初始化选中的音源
@@ -129,40 +201,59 @@ const initSelectedSources = () => {
const songId = playMusic.value.id;
const config = SongSourceConfigManager.getConfig(songId);
if (config) {
selectedSourcesValue.value = config.sources;
if (config && config.sources.length > 0) {
const platform = config.sources[0];
if (platform === 'lxMusic') {
// lxMusic 需要结合当前激活的脚本 id 来定位
const activeId = settingsStore.setData.activeLxMusicApiId;
selectedSourceId.value = activeId ? `lxMusic:${activeId}` : null;
} else {
selectedSourceId.value = platform;
}
} else {
selectedSourcesValue.value = [];
selectedSourceId.value = null;
}
};
// 清除自定义音源
const clearCustomSource = () => {
SongSourceConfigManager.clearConfig(playMusic.value.id);
selectedSourcesValue.value = [];
selectedSourceId.value = null;
};
// 直接重新解析当前歌曲
const directReparseMusic = async (source: Platform) => {
if (isReparsing.value) {
return;
// 点击音源条目
const handleSourceClick = async (source: ReparseSourceItem) => {
if (source.lxScriptId) {
await reparseWithLxScript(source);
} else {
await directReparseMusic(source);
}
};
// 使用指定 lxMusic 脚本重新解析
const reparseWithLxScript = async (source: ReparseSourceItem) => {
if (isReparsing.value || !source.lxScriptId) return;
const scripts: LxMusicScriptConfig[] = settingsStore.setData.lxMusicScripts || [];
const script = scripts.find((s) => s.id === source.lxScriptId);
if (!script) return;
try {
isReparsing.value = true;
currentReparsingSource.value = source;
currentReparsingId.value = source.id;
// 激活该脚本的 runner
setLxMusicRunner(null);
await initLxMusicRunner(script.script);
settingsStore.setSetData({ activeLxMusicApiId: script.id });
const songId = Number(playMusic.value.id);
await CacheManager.clearMusicCache(songId);
// 更新选中的音源值为当前点击的音源
selectedSourcesValue.value = [source];
selectedSourceId.value = source.id;
SongSourceConfigManager.setConfig(songId, ['lxMusic'], 'manual');
// 使用 SongSourceConfigManager 保存配置(手动选择)
SongSourceConfigManager.setConfig(songId, [source], 'manual');
const success = await playerStore.reparseCurrentSong(source, false);
const success = await playerStore.reparseCurrentSong('lxMusic', false);
if (success) {
message.success(t('player.reparse.success'));
@@ -174,7 +265,37 @@ const directReparseMusic = async (source: Platform) => {
message.error(t('player.reparse.failed'));
} finally {
isReparsing.value = false;
currentReparsingSource.value = null;
currentReparsingId.value = null;
}
};
// 直接重新解析当前歌曲(非 lxMusic)
const directReparseMusic = async (source: ReparseSourceItem) => {
if (isReparsing.value) return;
try {
isReparsing.value = true;
currentReparsingId.value = source.id;
const songId = Number(playMusic.value.id);
await CacheManager.clearMusicCache(songId);
selectedSourceId.value = source.id;
SongSourceConfigManager.setConfig(songId, [source.platform], 'manual');
const success = await playerStore.reparseCurrentSong(source.platform, false);
if (success) {
message.success(t('player.reparse.success'));
} else {
message.error(t('player.reparse.failed'));
}
} catch (error) {
console.error('解析失败:', error);
message.error(t('player.reparse.failed'));
} finally {
isReparsing.value = false;
currentReparsingId.value = null;
}
};
@@ -209,7 +330,7 @@ watch(
}
.source-button {
&:hover:not(.opacity-50) {
&:hover:not(.opacity-50):not(.opacity-40) {
@apply transform -translate-y-0.5 shadow-sm;
}
}
@@ -33,15 +33,15 @@
</p>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
<!-- Standard Sources -->
<div
v-for="source in MUSIC_SOURCES"
v-for="source in allSources"
:key="source.key"
class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
:class="[
isSourceSelected(source.key)
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10'
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10',
{ 'opacity-60 cursor-not-allowed': !source.available }
]"
@click="toggleSource(source.key)"
>
@@ -53,7 +53,7 @@
}"
:class="{ 'bg-gray-100 dark:bg-white/10': !isSourceSelected(source.key) }"
>
<i class="ri-music-2-fill text-base"></i>
<i :class="source.icon" class="text-base"></i>
</div>
<div class="flex-1 min-w-0">
@@ -75,102 +75,22 @@
></i>
</div>
</div>
</div>
</div>
<!-- LX Music Source -->
<div
class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
:class="[
isSourceSelected('lxMusic')
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10',
{ 'opacity-60 cursor-not-allowed': !activeLxApiId || lxMusicApis.length === 0 }
]"
@click="toggleSource('lxMusic')"
>
<div
class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
:class="[
isSourceSelected('lxMusic')
? 'bg-emerald-500 text-white'
: 'bg-gray-100 dark:bg-white/10 text-emerald-500'
]"
>
<i class="ri-netease-cloud-music-fill text-base"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-900 dark:text-white text-sm truncate"
>落雪音源</span
>
<div
class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
:class="[
isSourceSelected('lxMusic')
? 'bg-emerald-500 border-emerald-500'
: 'border-gray-300 dark:border-gray-600'
]"
>
<i
v-if="isSourceSelected('lxMusic')"
class="ri-check-line text-white text-xs scale-75"
></i>
</div>
</div>
<p class="text-[10px] text-gray-500 mt-0.5 truncate">
<!-- lxMusic 子描述 -->
<p
v-if="source.key === 'lxMusic'"
class="text-[10px] text-gray-500 mt-0.5 truncate"
>
{{
activeLxApiId && lxMusicScriptInfo
? lxMusicScriptInfo.name
: t('settings.playback.lxMusic.scripts.notConfigured')
}}
</p>
</div>
</div>
<!-- Custom API Source -->
<div
class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
:class="[
isSourceSelected('custom')
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10',
{ 'opacity-60 cursor-not-allowed': !settingsStore.setData.customApiPlugin }
]"
@click="toggleSource('custom')"
>
<div
class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
:class="[
isSourceSelected('custom')
? 'bg-violet-500 text-white'
: 'bg-gray-100 dark:bg-white/10 text-violet-500'
]"
>
<i class="ri-plug-fill text-base"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-900 dark:text-white text-sm truncate">{{
t('settings.playback.sourceLabels.custom')
}}</span>
<div
class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
:class="[
isSourceSelected('custom')
? 'bg-emerald-500 border-emerald-500'
: 'border-gray-300 dark:border-gray-600'
]"
>
<i
v-if="isSourceSelected('custom')"
class="ri-check-line text-white text-xs scale-75"
></i>
</div>
</div>
<p class="text-[10px] text-gray-500 mt-0.5 truncate">
<!-- custom 子描述 -->
<p
v-else-if="source.key === 'custom'"
class="text-[10px] text-gray-500 mt-0.5 truncate"
>
{{
settingsStore.setData.customApiPlugin
? t('settings.playback.customApi.status.imported')
@@ -377,24 +297,7 @@ import {
import { useSettingsStore } from '@/store';
import type { LxMusicScriptConfig, LxScriptInfo, LxSourceKey } from '@/types/lxMusic';
import { type Platform } from '@/types/music';
// ==================== 类型定义 ====================
type ExtendedPlatform = Platform | 'custom' | 'lxMusic';
interface MusicSourceConfig {
key: string;
description?: string;
color: string;
disabled?: boolean;
}
// ==================== 音源配置 ====================
const MUSIC_SOURCES: MusicSourceConfig[] = [
{ key: 'migu', color: '#ff6600' },
{ key: 'kugou', color: '#2979ff' },
{ key: 'kuwo', color: '#ff8c00' },
{ key: 'pyncmd', color: '#ec4141' }
];
import { useMusicSources } from '@/utils/musicSourceConfig';
// ==================== Props & Emits ====================
const props = defineProps({
@@ -403,8 +306,8 @@ const props = defineProps({
default: false
},
sources: {
type: Array as () => ExtendedPlatform[],
default: () => ['migu', 'kugou', 'kuwo', 'pyncmd'] as ExtendedPlatform[]
type: Array as () => Platform[],
default: () => ['migu', 'kugou', 'kuwo', 'pyncmd'] as Platform[]
}
});
@@ -415,8 +318,9 @@ const { t } = useI18n();
const settingsStore = useSettingsStore();
const message = useMessage();
const visible = ref(props.show);
const selectedSources = ref<ExtendedPlatform[]>([...props.sources]);
const selectedSources = ref<Platform[]>([...props.sources]);
const activeTab = ref('sources');
const { allSources } = useMusicSources();
const tabs = computed(() => [
{ key: 'sources', label: t('settings.playback.lxMusic.tabs.sources') },
@@ -459,7 +363,7 @@ const renameInputRef = ref<HTMLInputElement | null>(null);
// ==================== 计算属性 ====================
const isSourceSelected = (sourceKey: string): boolean => {
return selectedSources.value.includes(sourceKey as ExtendedPlatform);
return selectedSources.value.includes(sourceKey as Platform);
};
// ==================== 方法 ====================
@@ -488,7 +392,7 @@ const toggleSource = (sourceKey: string) => {
}
}
const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform);
const index = selectedSources.value.indexOf(sourceKey as Platform);
if (index > -1) {
// 至少保留一个音源
if (selectedSources.value.length <= 1) {
@@ -497,7 +401,7 @@ const toggleSource = (sourceKey: string) => {
}
selectedSources.value.splice(index, 1);
} else {
selectedSources.value.push(sourceKey as ExtendedPlatform);
selectedSources.value.push(sourceKey as Platform);
}
};
@@ -749,7 +653,7 @@ const saveScriptName = (apiId: string) => {
* 确认选择
*/
const handleConfirm = () => {
const defaultPlatforms: ExtendedPlatform[] = ['migu', 'kugou', 'kuwo', 'pyncmd'];
const defaultPlatforms: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd'];
const valuesToEmit =
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
emit('update:sources', valuesToEmit);
@@ -812,7 +716,7 @@ watch(
// 同步外部sources属性变化
watch(
() => props.sources,
(newVal: ExtendedPlatform[]) => {
(newVal: Platform[]) => {
selectedSources.value = [...newVal];
},
{ deep: true }