mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-15 07:20:49 +08:00
feat: 一系列播放优化
This commit is contained in:
@@ -10,7 +10,7 @@ export default {
|
||||
network: 'Network Settings',
|
||||
system: 'System Management',
|
||||
donation: 'Donation',
|
||||
regard: 'About'
|
||||
about: 'About'
|
||||
},
|
||||
basic: {
|
||||
themeMode: 'Theme Mode',
|
||||
@@ -114,7 +114,11 @@ export default {
|
||||
notImported: 'No custom source imported yet.',
|
||||
importSuccess: 'Successfully imported source: {name}',
|
||||
importFailed: 'Import failed: {message}',
|
||||
enableHint: 'Import a JSON config file to enable'
|
||||
enableHint: 'Import a JSON config file to enable',
|
||||
status: {
|
||||
imported: 'Custom Source Imported',
|
||||
notImported: 'Not Imported'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: 'ネットワーク設定',
|
||||
system: 'システム管理',
|
||||
donation: '寄付サポート',
|
||||
regard: 'について'
|
||||
about: 'について'
|
||||
},
|
||||
basic: {
|
||||
themeMode: 'テーマモード',
|
||||
@@ -111,7 +111,11 @@ export default {
|
||||
currentSource: '現在の音源',
|
||||
notImported: 'カスタム音源はまだインポートされていません。',
|
||||
importSuccess: '音源のインポートに成功しました: {name}',
|
||||
importFailed: 'インポートに失敗しました: {message}'
|
||||
importFailed: 'インポートに失敗しました: {message}',
|
||||
status: {
|
||||
imported: 'カスタム音源インポート済み',
|
||||
notImported: '未インポート'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: '네트워크 설정',
|
||||
system: '시스템 관리',
|
||||
donation: '후원 지원',
|
||||
regard: '정보'
|
||||
about: '정보'
|
||||
},
|
||||
basic: {
|
||||
themeMode: '테마 모드',
|
||||
@@ -112,7 +112,11 @@ export default {
|
||||
notImported: '아직 사용자 지정 음원을 가져오지 않았습니다.',
|
||||
importSuccess: '음원 가져오기 성공: {name}',
|
||||
importFailed: '가져오기 실패: {message}',
|
||||
enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요'
|
||||
enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요',
|
||||
status: {
|
||||
imported: '사용자 지정 음원 가져옴',
|
||||
notImported: '가져오지 않음'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: '网络设置',
|
||||
system: '系统管理',
|
||||
donation: '捐赠支持',
|
||||
regard: '关于'
|
||||
about: '关于'
|
||||
},
|
||||
basic: {
|
||||
themeMode: '主题模式',
|
||||
@@ -111,7 +111,11 @@ export default {
|
||||
notImported: '尚未导入自定义音源。',
|
||||
importSuccess: '成功导入音源: {name}',
|
||||
importFailed: '导入失败: {message}',
|
||||
enableHint: '请先导入 JSON 配置文件才能启用'
|
||||
enableHint: '请先导入 JSON 配置文件才能启用',
|
||||
status: {
|
||||
imported: '已导入自定义音源',
|
||||
notImported: '未导入'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
network: '網路設定',
|
||||
system: '系統管理',
|
||||
donation: '捐贈支持',
|
||||
regard: '關於'
|
||||
about: '關於'
|
||||
},
|
||||
basic: {
|
||||
themeMode: '主題模式',
|
||||
@@ -108,7 +108,11 @@ export default {
|
||||
notImported: '尚未匯入自訂音源。',
|
||||
importSuccess: '成功匯入音源:{name}',
|
||||
importFailed: '匯入失敗:{message}',
|
||||
enableHint: '請先匯入 JSON 設定檔才能啟用'
|
||||
enableHint: '請先匯入 JSON 設定檔才能啟用',
|
||||
status: {
|
||||
imported: '已匯入自訂音源',
|
||||
notImported: '未匯入'
|
||||
}
|
||||
}
|
||||
},
|
||||
application: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import match from '@unblockneteasemusic/server';
|
||||
|
||||
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili';
|
||||
type Platform = 'qq' | 'migu' | 'kugou' | 'kuwo' | 'pyncmd' | 'joox' | 'bilibili';
|
||||
|
||||
interface SongData {
|
||||
name: string;
|
||||
@@ -30,7 +30,7 @@ interface UnblockResult {
|
||||
}
|
||||
|
||||
// 所有可用平台
|
||||
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
|
||||
|
||||
/**
|
||||
* 确保对象数据结构完整,处理null或undefined的情况
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { CacheManager } from '@/api/musicParser';
|
||||
@@ -96,7 +96,7 @@ const currentReparsingSource = ref<Platform | null>(null);
|
||||
// 实际存储选中音源的值
|
||||
const selectedSourcesValue = ref<Platform[]>([]);
|
||||
|
||||
const isReparse = ref(localStorage.getItem(`song_source_${String(playMusic.value.id)}`) !== null);
|
||||
const isReparse = computed(() => selectedSourcesValue.value.length > 0);
|
||||
|
||||
// 可选音源列表
|
||||
const musicSourceOptions = ref([
|
||||
@@ -121,7 +121,8 @@ const getSourceIcon = (source: Platform) => {
|
||||
joox: 'ri-disc-fill',
|
||||
pyncmd: 'ri-netease-cloud-music-fill',
|
||||
bilibili: 'ri-bilibili-fill',
|
||||
gdmusic: 'ri-google-fill'
|
||||
gdmusic: 'ri-google-fill',
|
||||
kuwo: 'ri-music-fill'
|
||||
};
|
||||
|
||||
return iconMap[source] || 'ri-music-2-fill';
|
||||
@@ -148,7 +149,6 @@ const initSelectedSources = () => {
|
||||
const clearCustomSource = () => {
|
||||
localStorage.removeItem(`song_source_${String(playMusic.value.id)}`);
|
||||
selectedSourcesValue.value = [];
|
||||
isReparse.value = false;
|
||||
};
|
||||
|
||||
// 直接重新解析当前歌曲
|
||||
@@ -174,7 +174,7 @@ const directReparseMusic = async (source: Platform) => {
|
||||
JSON.stringify(selectedSourcesValue.value)
|
||||
);
|
||||
|
||||
const success = await playerStore.reparseCurrentSong(source);
|
||||
const success = await playerStore.reparseCurrentSong(source, false);
|
||||
|
||||
if (success) {
|
||||
message.success(t('player.reparse.success'));
|
||||
@@ -221,7 +221,10 @@ watch(
|
||||
console.log('URL已过期,自动应用自定义音源重新加载');
|
||||
try {
|
||||
isReparsing.value = true;
|
||||
const success = await playerStore.reparseCurrentSong(sources[0]);
|
||||
const songId = String(playMusic.value.id);
|
||||
const sourceType = localStorage.getItem(`song_source_type_${songId}`);
|
||||
const isAuto = sourceType === 'auto';
|
||||
const success = await playerStore.reparseCurrentSong(sources[0], isAuto);
|
||||
if (!success) {
|
||||
message.error(t('player.reparse.failed'));
|
||||
}
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
<!-- 顶部进度条和时间 -->
|
||||
<div class="top-section">
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-bar" @click="handleProgressClick">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="{ 'is-dragging': isDragging }"
|
||||
@mousedown="handleProgressMouseDown"
|
||||
@click.stop="handleProgressClick"
|
||||
>
|
||||
<div class="progress-track"></div>
|
||||
<div class="progress-fill" :style="{ width: `${(nowTime / allTime) * 100}%` }"></div>
|
||||
<div class="progress-fill" :style="{ width: `${progressPercentage}%` }"></div>
|
||||
</div>
|
||||
|
||||
<!-- 时间显示 -->
|
||||
<div class="time-display">
|
||||
<span class="current-time">{{ formatTime(nowTime) }}</span>
|
||||
<span class="current-time">{{ formatTime(displayTime) }}</span>
|
||||
<span class="total-time">{{ formatTime(allTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,11 +155,81 @@ const playMusicEvent = async () => {
|
||||
};
|
||||
|
||||
// 进度条控制
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0); // 拖拽时的预览进度 (0-100)
|
||||
|
||||
// 计算当前显示的进度百分比
|
||||
const progressPercentage = computed(() => {
|
||||
if (isDragging.value) {
|
||||
return dragProgress.value;
|
||||
}
|
||||
if (allTime.value === 0) return 0;
|
||||
return (nowTime.value / allTime.value) * 100;
|
||||
});
|
||||
|
||||
// 计算显示的时间
|
||||
const displayTime = computed(() => {
|
||||
if (isDragging.value) {
|
||||
return (dragProgress.value / 100) * allTime.value;
|
||||
}
|
||||
return nowTime.value;
|
||||
});
|
||||
|
||||
// 计算进度百分比的辅助函数
|
||||
const calculateProgress = (clientX: number, element: HTMLElement): number => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return percent * 100;
|
||||
};
|
||||
|
||||
// 更新音频进度
|
||||
const seekToProgress = (percentage: number) => {
|
||||
const targetTime = (percentage / 100) * allTime.value;
|
||||
audioService.seek(targetTime);
|
||||
// 不立即更新 nowTime,让音频服务的回调来更新,避免不同步
|
||||
};
|
||||
|
||||
// 鼠标按下开始拖拽
|
||||
const handleProgressMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return; // 只响应左键
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
isDragging.value = true;
|
||||
dragProgress.value = calculateProgress(e.clientX, target);
|
||||
|
||||
// 添加全局鼠标移动和释放监听
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (isDragging.value) {
|
||||
dragProgress.value = calculateProgress(moveEvent.clientX, target);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging.value) {
|
||||
// 拖拽结束,执行跳转
|
||||
seekToProgress(dragProgress.value);
|
||||
isDragging.value = false;
|
||||
}
|
||||
// 移除事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// 防止文本选择
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// 点击进度条跳转
|
||||
const handleProgressClick = (e: MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
audioService.seek(allTime.value * percent);
|
||||
nowTime.value = allTime.value * percent;
|
||||
// 如果正在拖拽,不处理点击事件
|
||||
if (isDragging.value) return;
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const percentage = calculateProgress(e.clientX, target);
|
||||
seekToProgress(percentage);
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
@@ -348,6 +423,7 @@ onMounted(() => {
|
||||
|
||||
.progress-bar {
|
||||
@apply relative cursor-pointer h-2 mb-2 w-full;
|
||||
user-select: none;
|
||||
|
||||
.progress-track {
|
||||
@apply absolute inset-0 rounded-full transition-all duration-150;
|
||||
@@ -364,10 +440,6 @@ onMounted(() => {
|
||||
.progress-track {
|
||||
background-color: var(--track-color-hover);
|
||||
}
|
||||
.progress-track,
|
||||
.progress-fill {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
box-shadow: 0 0 12px var(--fill-color-transparent);
|
||||
|
||||
@@ -5,63 +5,94 @@
|
||||
:title="t('settings.playback.musicSources')"
|
||||
:positive-text="t('common.confirm')"
|
||||
:negative-text="t('common.cancel')"
|
||||
class="music-source-modal"
|
||||
@positive-click="handleConfirm"
|
||||
@negative-click="handleCancel"
|
||||
style="width: 800px; max-width: 90vw"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>{{ t('settings.playback.musicSourcesDesc') }}</p>
|
||||
<n-space vertical :size="20">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('settings.playback.musicSourcesDesc') }}
|
||||
</p>
|
||||
|
||||
<n-checkbox-group v-model:value="selectedSources">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="8">
|
||||
<!-- 遍历常规音源 -->
|
||||
<n-grid-item v-for="source in regularMusicSources" :key="source.value">
|
||||
<n-checkbox :value="source.value">
|
||||
{{ t('settings.playback.sourceLabels.' + source.value) }}
|
||||
<n-tooltip v-if="source.value === 'gdmusic'">
|
||||
<template #trigger>
|
||||
<n-icon size="16" class="ml-1 text-blue-500 cursor-help">
|
||||
<i class="ri-information-line"></i>
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('settings.playback.gdmusicInfo') }}
|
||||
</n-tooltip>
|
||||
</n-checkbox>
|
||||
</n-grid-item>
|
||||
<!-- 音源卡片列表 -->
|
||||
<div class="music-sources-grid">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单独处理自定义API选项 -->
|
||||
<n-grid-item>
|
||||
<n-checkbox value="custom" :disabled="!settingsStore.setData.customApiPlugin">
|
||||
{{ t('settings.playback.sourceLabels.custom') }}
|
||||
<n-tooltip v-if="!settingsStore.setData.customApiPlugin">
|
||||
<template #trigger>
|
||||
<n-icon size="16" class="ml-1 text-gray-400 cursor-help">
|
||||
<i class="ri-question-line"></i>
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('settings.playback.customApi.enableHint') }}
|
||||
</n-tooltip>
|
||||
</n-checkbox>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-checkbox-group>
|
||||
<!-- 自定义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>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="mt-4 border-t pt-4 border-gray-200 dark:border-gray-700"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- 自定义API导入区域 -->
|
||||
<div>
|
||||
<h3 class="text-base font-medium mb-2">
|
||||
<div class="custom-api-section">
|
||||
<h3 class="custom-api-section__title">
|
||||
{{ t('settings.playback.customApi.sectionTitle') }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<n-button @click="importPlugin" size="small">{{
|
||||
t('settings.playback.customApi.importConfig')
|
||||
}}</n-button>
|
||||
<p v-if="settingsStore.setData.customApiPluginName" class="text-sm">
|
||||
<div class="custom-api-section__content">
|
||||
<n-button @click="importPlugin" size="small" secondary>
|
||||
<template #icon>
|
||||
<n-icon><i class="ri-upload-line"></i></n-icon>
|
||||
</template>
|
||||
{{ t('settings.playback.customApi.importConfig') }}
|
||||
</n-button>
|
||||
<p v-if="settingsStore.setData.customApiPluginName" class="custom-api-section__status">
|
||||
{{ t('settings.playback.customApi.currentSource') }}:
|
||||
<span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span>
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-500">
|
||||
<p v-else class="custom-api-section__status custom-api-section__status--empty">
|
||||
{{ t('settings.playback.customApi.notImported') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -78,9 +109,26 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import { type Platform } from '@/types/music';
|
||||
|
||||
// 扩展 Platform 类型以包含 'custom'
|
||||
// ==================== 类型定义 ====================
|
||||
type ExtendedPlatform = Platform | 'custom';
|
||||
|
||||
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' },
|
||||
{ key: 'bilibili', color: '#00a1d6' }
|
||||
];
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -88,34 +136,59 @@ const props = defineProps({
|
||||
},
|
||||
sources: {
|
||||
type: Array as () => ExtendedPlatform[],
|
||||
default: () => ['migu', 'kugou', 'pyncmd', 'bilibili']
|
||||
default: () => ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'] as ExtendedPlatform[]
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'update:sources']);
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const message = useMessage();
|
||||
const visible = ref(props.show);
|
||||
const selectedSources = ref<ExtendedPlatform[]>(props.sources);
|
||||
const selectedSources = ref<ExtendedPlatform[]>([...props.sources]);
|
||||
|
||||
// 将常规音源和自定义音源分开定义
|
||||
const regularMusicSources = ref([
|
||||
{ value: 'migu' },
|
||||
{ value: 'kugou' },
|
||||
{ value: 'pyncmd' },
|
||||
{ value: 'bilibili' },
|
||||
{ value: 'gdmusic' }
|
||||
]);
|
||||
// ==================== 计算属性 ====================
|
||||
const isSourceSelected = (sourceKey: string): boolean => {
|
||||
return selectedSources.value.includes(sourceKey as ExtendedPlatform);
|
||||
};
|
||||
|
||||
// ==================== 方法 ====================
|
||||
/**
|
||||
* 切换音源选择状态
|
||||
*/
|
||||
const toggleSource = (sourceKey: string) => {
|
||||
// 检查是否是自定义API且未导入
|
||||
if (sourceKey === 'custom' && !settingsStore.setData.customApiPlugin) {
|
||||
message.warning(t('settings.playback.customApi.enableHint'));
|
||||
return;
|
||||
}
|
||||
|
||||
const index = selectedSources.value.indexOf(sourceKey as ExtendedPlatform);
|
||||
if (index > -1) {
|
||||
// 至少保留一个音源
|
||||
if (selectedSources.value.length <= 1) {
|
||||
message.warning(t('settings.playback.musicSourcesMinWarning'));
|
||||
return;
|
||||
}
|
||||
selectedSources.value.splice(index, 1);
|
||||
} else {
|
||||
selectedSources.value.push(sourceKey as ExtendedPlatform);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入自定义API插件
|
||||
*/
|
||||
const importPlugin = async () => {
|
||||
try {
|
||||
const result = await window.api.importCustomApiPlugin();
|
||||
if (result && result.name && result.content) {
|
||||
settingsStore.setCustomApiPlugin(result);
|
||||
message.success(t('settings.playback.customApi.importSuccess', { name: result.name }));
|
||||
// 导入成功后,如果用户还没勾选,则自动勾选上
|
||||
|
||||
// 导入成功后自动勾选
|
||||
if (!selectedSources.value.includes('custom')) {
|
||||
selectedSources.value.push('custom');
|
||||
}
|
||||
@@ -125,7 +198,27 @@ const importPlugin = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听自定义插件内容的变化。如果用户清除了插件,要确保 'custom' 选项被取消勾选
|
||||
/**
|
||||
* 确认选择
|
||||
*/
|
||||
const handleConfirm = () => {
|
||||
const defaultPlatforms: ExtendedPlatform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
|
||||
const valuesToEmit =
|
||||
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
|
||||
emit('update:sources', valuesToEmit);
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消选择
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
selectedSources.value = [...props.sources];
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
// 监听自定义插件内容变化
|
||||
watch(
|
||||
() => settingsStore.setData.customApiPlugin,
|
||||
(newPluginContent) => {
|
||||
@@ -162,18 +255,189 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
// 确保至少选择一个音源
|
||||
const defaultPlatforms = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||
const valuesToEmit =
|
||||
selectedSources.value.length > 0 ? [...new Set(selectedSources.value)] : defaultPlatforms;
|
||||
emit('update:sources', valuesToEmit);
|
||||
visible.value = false;
|
||||
};
|
||||
const handleCancel = () => {
|
||||
// 取消时还原为props传入的初始值
|
||||
selectedSources.value = [...props.sources];
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.music-sources-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background:
|
||||
linear-gradient(white, white) padding-box,
|
||||
linear-gradient(135deg, var(--source-color, #ddd) 0%, transparent 100%) border-box;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--source-color);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: var(--source-color);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&__check {
|
||||
color: var(--source-color);
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--source-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--source-color);
|
||||
background:
|
||||
linear-gradient(white, white) padding-box,
|
||||
var(--source-color) border-box;
|
||||
|
||||
.source-card__indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.source-card__check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
opacity: 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色模式适配
|
||||
:global(.dark) {
|
||||
.source-card {
|
||||
background:
|
||||
linear-gradient(#1f1f1f, #1f1f1f) padding-box,
|
||||
linear-gradient(135deg, var(--source-color, #555) 0%, transparent 100%) border-box;
|
||||
|
||||
&__name {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background:
|
||||
linear-gradient(#1f1f1f, #1f1f1f) padding-box,
|
||||
var(--source-color) border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #e5e5e5 50%, transparent);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:global(.dark) .divider {
|
||||
background: linear-gradient(90deg, transparent, #333 50%, transparent);
|
||||
}
|
||||
|
||||
.custom-api-section {
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
|
||||
&--empty {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) .custom-api-section {
|
||||
&__title {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
&__status {
|
||||
color: #999;
|
||||
|
||||
&--empty {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
@@ -12,16 +12,14 @@ import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
// 预加载的音频实例
|
||||
export const preloadingSounds = ref<Howl[]>([]);
|
||||
|
||||
/**
|
||||
* 获取歌曲播放URL(独立函数)
|
||||
*/
|
||||
export const getSongUrl = async (
|
||||
id: string | number,
|
||||
songData: SongResult,
|
||||
isDownloaded: boolean = false
|
||||
isDownloaded: boolean = false,
|
||||
requestId?: string
|
||||
) => {
|
||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
|
||||
@@ -30,6 +28,12 @@ export const getSongUrl = async (
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
try {
|
||||
// 在开始处理前验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (songData.playMusicUrl) {
|
||||
return songData.playMusicUrl;
|
||||
}
|
||||
@@ -42,6 +46,11 @@ export const getSongUrl = async (
|
||||
songData.bilibiliData.bvid,
|
||||
songData.bilibiliData.cid
|
||||
);
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 获取B站URL后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
return songData.playMusicUrl;
|
||||
} catch (error) {
|
||||
console.error('重启后获取B站音频URL失败:', error);
|
||||
@@ -78,6 +87,12 @@ export const getSongUrl = async (
|
||||
settingsStore.setData.musicQuality || 'higher'
|
||||
);
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 自定义API解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (
|
||||
customResult &&
|
||||
customResult.data &&
|
||||
@@ -93,6 +108,9 @@ export const getSongUrl = async (
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调用自定义API时发生错误:', error);
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
message.error(i18n.global.t('player.reparse.customApiError'));
|
||||
}
|
||||
}
|
||||
@@ -103,18 +121,35 @@ export const getSongUrl = async (
|
||||
console.log(`使用自定义音源解析歌曲 ID: ${songId}`);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
console.log('res', res);
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 自定义音源解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
return res.data.data.url;
|
||||
}
|
||||
console.warn('自定义音源解析失败,使用默认音源');
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
console.error('自定义音源解析出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 正常获取URL流程
|
||||
const { data } = await getMusicUrl(numericId, isDownloaded);
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 获取官方URL后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (data && data.data && data.data[0]) {
|
||||
const songDetail = data.data[0];
|
||||
const hasNoUrl = !songDetail.url;
|
||||
@@ -123,6 +158,11 @@ export const getSongUrl = async (
|
||||
if (hasNoUrl || isTrial) {
|
||||
console.log(`官方URL无效 (无URL: ${hasNoUrl}, 试听: ${isTrial}),进入内置备用解析...`);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 备用解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
return res?.data?.data?.url || null;
|
||||
}
|
||||
@@ -134,9 +174,17 @@ export const getSongUrl = async (
|
||||
|
||||
console.log('官方API返回数据结构异常,进入内置备用解析...');
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongUrl] 备用解析后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
return res?.data?.data?.url || null;
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
console.error('官方API请求失败,进入内置备用解析流程:', error);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
@@ -299,7 +347,13 @@ export const useLyrics = () => {
|
||||
export const useSongDetail = () => {
|
||||
const { getSongUrl } = useSongUrl();
|
||||
|
||||
const getSongDetail = async (playMusic: SongResult) => {
|
||||
const getSongDetail = async (playMusic: SongResult, requestId?: string) => {
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] 请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
if (playMusic.source === 'bilibili') {
|
||||
try {
|
||||
if (!playMusic.playMusicUrl && playMusic.bilibiliData) {
|
||||
@@ -309,6 +363,12 @@ export const useSongDetail = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] B站URL获取后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic } as SongResult;
|
||||
} catch (error) {
|
||||
@@ -324,7 +384,15 @@ export const useSongDetail = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const playMusicUrl = playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic));
|
||||
const playMusicUrl =
|
||||
playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic, false, requestId));
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] URL获取后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
playMusic.createdAt = Date.now();
|
||||
// 半小时后过期
|
||||
playMusic.expiredAt = playMusic.createdAt + 1800000;
|
||||
@@ -333,9 +401,18 @@ export const useSongDetail = () => {
|
||||
? playMusic
|
||||
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
|
||||
|
||||
// 验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[getSongDetail] 背景色获取后请求已失效: ${requestId}`);
|
||||
throw new Error('Request cancelled');
|
||||
}
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult;
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Request cancelled') {
|
||||
throw error;
|
||||
}
|
||||
console.error('获取音频URL失败:', error);
|
||||
playMusic.playLoading = false;
|
||||
throw error;
|
||||
@@ -344,60 +421,3 @@ export const useSongDetail = () => {
|
||||
|
||||
return { getSongDetail };
|
||||
};
|
||||
|
||||
/**
|
||||
* 预加载下一首歌曲音频
|
||||
*/
|
||||
export const preloadNextSong = (nextSongUrl: string): Howl | null => {
|
||||
try {
|
||||
// 清理多余的预加载实例,确保最多只有2个预加载音频
|
||||
while (preloadingSounds.value.length >= 2) {
|
||||
const oldestSound = preloadingSounds.value.shift();
|
||||
if (oldestSound) {
|
||||
try {
|
||||
oldestSound.stop();
|
||||
oldestSound.unload();
|
||||
} catch (e) {
|
||||
console.error('清理预加载音频实例失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查这个URL是否已经在预加载列表中
|
||||
const existingPreload = preloadingSounds.value.find(
|
||||
(sound) => (sound as any)._src === nextSongUrl
|
||||
);
|
||||
if (existingPreload) {
|
||||
console.log('该音频已在预加载列表中,跳过:', nextSongUrl);
|
||||
return existingPreload;
|
||||
}
|
||||
|
||||
const sound = new Howl({
|
||||
src: [nextSongUrl],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false
|
||||
});
|
||||
|
||||
preloadingSounds.value.push(sound);
|
||||
|
||||
sound.on('loaderror', () => {
|
||||
console.error('预加载音频失败:', nextSongUrl);
|
||||
const index = preloadingSounds.value.indexOf(sound);
|
||||
if (index > -1) {
|
||||
preloadingSounds.value.splice(index, 1);
|
||||
}
|
||||
try {
|
||||
sound.stop();
|
||||
sound.unload();
|
||||
} catch (e) {
|
||||
console.error('卸载预加载音频失败:', e);
|
||||
}
|
||||
});
|
||||
|
||||
return sound;
|
||||
} catch (error) {
|
||||
console.error('预加载音频出错:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isElectron } from '@/utils'; // 导入isElectron常量
|
||||
|
||||
class AudioService {
|
||||
private currentSound: Howl | null = null;
|
||||
private pendingSound: Howl | null = null;
|
||||
|
||||
private currentTrack: SongResult | null = null;
|
||||
|
||||
@@ -470,11 +471,12 @@ class AudioService {
|
||||
}
|
||||
|
||||
// 播放控制相关
|
||||
play(
|
||||
url?: string,
|
||||
track?: SongResult,
|
||||
public play(
|
||||
url: string,
|
||||
track: SongResult,
|
||||
isPlay: boolean = true,
|
||||
seekTime: number = 0
|
||||
seekTime: number = 0,
|
||||
existingSound?: Howl
|
||||
): Promise<Howl> {
|
||||
// 每次调用play方法时,尝试强制重置锁(注意:仅在页面刷新后的第一次播放时应用)
|
||||
if (!this.currentSound) {
|
||||
@@ -482,6 +484,17 @@ class AudioService {
|
||||
this.forceResetOperationLock();
|
||||
}
|
||||
|
||||
// 如果有操作锁,且不是同一个 track 的操作,则等待
|
||||
if (this.operationLock) {
|
||||
console.log('audioService: 操作锁激活中,等待...');
|
||||
return Promise.reject(new Error('操作锁激活中'));
|
||||
}
|
||||
|
||||
if (!this.setOperationLock()) {
|
||||
console.log('audioService: 获取操作锁失败');
|
||||
return Promise.reject(new Error('操作锁激活中'));
|
||||
}
|
||||
|
||||
// 如果操作锁已激活,但持续时间超过安全阈值,强制重置
|
||||
if (this.operationLock) {
|
||||
const currentTime = Date.now();
|
||||
@@ -531,10 +544,25 @@ class AudioService {
|
||||
return Promise.reject(new Error('缺少必要参数: url和track'));
|
||||
}
|
||||
|
||||
// 检查是否是同一首歌曲的无缝切换(Hot-Swap)
|
||||
const isHotSwap =
|
||||
this.currentTrack && track && this.currentTrack.id === track.id && this.currentSound;
|
||||
|
||||
if (isHotSwap) {
|
||||
console.log('audioService: 检测到同一首歌曲的源切换,启用无缝切换模式');
|
||||
}
|
||||
|
||||
return new Promise<Howl>((resolve, reject) => {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 1;
|
||||
|
||||
// 如果有正在加载的 pendingSound,先清理掉
|
||||
if (this.pendingSound) {
|
||||
console.log('audioService: 清理正在加载的 pendingSound');
|
||||
this.pendingSound.unload();
|
||||
this.pendingSound = null;
|
||||
}
|
||||
|
||||
const tryPlay = async () => {
|
||||
try {
|
||||
console.log('audioService: 开始创建音频对象');
|
||||
@@ -560,8 +588,8 @@ class AudioService {
|
||||
await Howler.ctx.resume();
|
||||
}
|
||||
|
||||
// 先停止并清理现有的音频实例
|
||||
if (this.currentSound) {
|
||||
// 非热切换模式下,先停止并清理现有的音频实例
|
||||
if (!isHotSwap && this.currentSound) {
|
||||
console.log('audioService: 停止并清理现有的音频实例');
|
||||
// 确保任何进行中的seek操作被取消
|
||||
if (this.seekLock && this.seekDebounceTimer) {
|
||||
@@ -573,49 +601,122 @@ class AudioService {
|
||||
this.currentSound = null;
|
||||
}
|
||||
|
||||
// 清理 EQ 但保持上下文
|
||||
console.log('audioService: 清理 EQ');
|
||||
await this.disposeEQ(true);
|
||||
// 清理 EQ 但保持上下文 (热切换时暂时不清理,等切换完成后再处理)
|
||||
if (!isHotSwap) {
|
||||
console.log('audioService: 清理 EQ');
|
||||
await this.disposeEQ(true);
|
||||
}
|
||||
|
||||
this.currentTrack = track;
|
||||
console.log('audioService: 创建新的 Howl 对象');
|
||||
this.currentSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: false,
|
||||
volume: 1, // 禁用 Howler.js 音量控制
|
||||
rate: this.playbackRate,
|
||||
format: ['mp3', 'aac'],
|
||||
onloaderror: (_, error) => {
|
||||
// 如果不是热切换,立即更新 currentTrack
|
||||
if (!isHotSwap) {
|
||||
this.currentTrack = track;
|
||||
}
|
||||
|
||||
// 如果不是热切换,立即更新 currentTrack
|
||||
if (!isHotSwap) {
|
||||
this.currentTrack = track;
|
||||
}
|
||||
|
||||
let newSound: Howl;
|
||||
|
||||
if (existingSound) {
|
||||
console.log('audioService: 使用预加载的 Howl 对象');
|
||||
newSound = existingSound;
|
||||
// 确保 volume 和 rate 正确
|
||||
newSound.volume(1); // 内部 volume 设为 1,由 Howler.masterGain 控制实际音量
|
||||
newSound.rate(this.playbackRate);
|
||||
|
||||
// 重新绑定事件监听器,因为 PreloadService 可能没有绑定这些
|
||||
// 注意:Howler 允许重复绑定,但最好先清理(如果无法清理,就直接绑定,Howler 是 EventEmitter)
|
||||
// 这里我们假设 existingSound 是干净的或者我们只绑定我们需要关心的
|
||||
} else {
|
||||
console.log('audioService: 创建新的 Howl 对象');
|
||||
newSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: false,
|
||||
volume: 1, // 禁用 Howler.js 音量控制
|
||||
rate: this.playbackRate,
|
||||
format: ['mp3', 'aac']
|
||||
});
|
||||
}
|
||||
|
||||
// 统一设置事件处理
|
||||
const setupEvents = () => {
|
||||
newSound.off('loaderror');
|
||||
newSound.off('playerror');
|
||||
newSound.off('load');
|
||||
|
||||
newSound.on('loaderror', (_, error) => {
|
||||
console.error('Audio load error:', error);
|
||||
if (retryCount < maxRetries) {
|
||||
if (retryCount < maxRetries && !existingSound) {
|
||||
// 预加载的音频通常已经 loaded,不应重试
|
||||
retryCount++;
|
||||
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
||||
setTimeout(tryPlay, 1000 * retryCount);
|
||||
} else {
|
||||
// 发送URL过期事件,通知外部需要重新获取URL
|
||||
this.emit('url_expired', this.currentTrack);
|
||||
this.emit('url_expired', track);
|
||||
this.releaseOperationLock();
|
||||
if (isHotSwap) this.pendingSound = null;
|
||||
reject(new Error('音频加载失败,请尝试切换其他歌曲'));
|
||||
}
|
||||
},
|
||||
onplayerror: (_, error) => {
|
||||
});
|
||||
|
||||
newSound.on('playerror', (_, error) => {
|
||||
console.error('Audio play error:', error);
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
||||
setTimeout(tryPlay, 1000 * retryCount);
|
||||
} else {
|
||||
// 发送URL过期事件,通知外部需要重新获取URL
|
||||
this.emit('url_expired', this.currentTrack);
|
||||
this.emit('url_expired', track);
|
||||
this.releaseOperationLock();
|
||||
if (isHotSwap) this.pendingSound = null;
|
||||
reject(new Error('音频播放失败,请尝试切换其他歌曲'));
|
||||
}
|
||||
},
|
||||
onload: async () => {
|
||||
});
|
||||
|
||||
const onLoaded = async () => {
|
||||
try {
|
||||
// 初始化音频管道
|
||||
await this.setupEQ(this.currentSound!);
|
||||
// 如果是热切换,现在执行切换逻辑
|
||||
if (isHotSwap) {
|
||||
console.log('audioService: 执行无缝切换');
|
||||
|
||||
// 1. 获取当前播放进度
|
||||
let currentPos = 0;
|
||||
if (this.currentSound) {
|
||||
currentPos = this.currentSound.seek() as number;
|
||||
}
|
||||
|
||||
// 2. 同步新音频进度
|
||||
newSound.seek(currentPos);
|
||||
|
||||
// 3. 初始化新音频的 EQ
|
||||
await this.disposeEQ(true);
|
||||
await this.setupEQ(newSound);
|
||||
|
||||
// 4. 播放新音频
|
||||
if (isPlay) {
|
||||
newSound.play();
|
||||
}
|
||||
|
||||
// 5. 停止旧音频
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.unload();
|
||||
}
|
||||
|
||||
// 6. 更新引用
|
||||
this.currentSound = newSound;
|
||||
this.currentTrack = track;
|
||||
this.pendingSound = null;
|
||||
|
||||
console.log(`audioService: 无缝切换完成,进度同步至 ${currentPos}s`);
|
||||
} else {
|
||||
// 普通加载逻辑
|
||||
await this.setupEQ(newSound);
|
||||
this.currentSound = newSound;
|
||||
}
|
||||
|
||||
// 重新应用已保存的音量
|
||||
const savedVolume = localStorage.getItem('volume');
|
||||
@@ -623,22 +724,23 @@ class AudioService {
|
||||
this.applyVolume(parseFloat(savedVolume));
|
||||
}
|
||||
|
||||
// 音频加载成功后设置 EQ 和更新媒体会话
|
||||
if (this.currentSound) {
|
||||
try {
|
||||
if (seekTime > 0) {
|
||||
if (!isHotSwap && seekTime > 0) {
|
||||
this.currentSound.seek(seekTime);
|
||||
}
|
||||
|
||||
console.log('audioService: 音频加载成功,设置 EQ');
|
||||
this.updateMediaSessionMetadata(track);
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('load');
|
||||
|
||||
// 此时音频已完全初始化,根据 isPlay 参数决定是否播放
|
||||
console.log('audioService: 音频完全初始化,isPlay =', isPlay);
|
||||
if (isPlay) {
|
||||
console.log('audioService: 开始播放');
|
||||
this.currentSound.play();
|
||||
if (!isHotSwap) {
|
||||
console.log('audioService: 音频完全初始化,isPlay =', isPlay);
|
||||
if (isPlay) {
|
||||
console.log('audioService: 开始播放');
|
||||
this.currentSound.play();
|
||||
}
|
||||
}
|
||||
|
||||
resolve(this.currentSound);
|
||||
@@ -651,28 +753,58 @@ class AudioService {
|
||||
console.error('Audio initialization failed:', error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (newSound.state() === 'loaded') {
|
||||
onLoaded();
|
||||
} else {
|
||||
newSound.once('load', onLoaded);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 设置音频事件监听
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('play', () => {
|
||||
this.updateMediaSessionState(true);
|
||||
this.emit('play');
|
||||
setupEvents();
|
||||
|
||||
if (isHotSwap) {
|
||||
this.pendingSound = newSound;
|
||||
} else {
|
||||
this.currentSound = newSound;
|
||||
}
|
||||
|
||||
// 设置音频事件监听 (play, pause, end, seek)
|
||||
// ... (保持原有的事件监听逻辑不变,但需要确保绑定到 newSound)
|
||||
const soundInstance = newSound;
|
||||
if (soundInstance) {
|
||||
// 清除旧的监听器以防重复
|
||||
soundInstance.off('play');
|
||||
soundInstance.off('pause');
|
||||
soundInstance.off('end');
|
||||
soundInstance.off('seek');
|
||||
|
||||
soundInstance.on('play', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.updateMediaSessionState(true);
|
||||
this.emit('play');
|
||||
}
|
||||
});
|
||||
|
||||
this.currentSound.on('pause', () => {
|
||||
this.updateMediaSessionState(false);
|
||||
this.emit('pause');
|
||||
soundInstance.on('pause', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.updateMediaSessionState(false);
|
||||
this.emit('pause');
|
||||
}
|
||||
});
|
||||
|
||||
this.currentSound.on('end', () => {
|
||||
this.emit('end');
|
||||
soundInstance.on('end', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.emit('end');
|
||||
}
|
||||
});
|
||||
|
||||
this.currentSound.on('seek', () => {
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('seek');
|
||||
soundInstance.on('seek', () => {
|
||||
if (this.currentSound === soundInstance) {
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('seek');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
294
src/renderer/services/playbackRequestManager.ts
Normal file
294
src/renderer/services/playbackRequestManager.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 播放请求管理器
|
||||
* 负责管理播放请求的队列、取消、状态跟踪,防止竞态条件
|
||||
*/
|
||||
|
||||
import type { SongResult } from '@/types/music';
|
||||
|
||||
/**
|
||||
* 请求状态枚举
|
||||
*/
|
||||
export enum RequestStatus {
|
||||
PENDING = 'pending',
|
||||
ACTIVE = 'active',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
FAILED = 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放请求接口
|
||||
*/
|
||||
export interface PlaybackRequest {
|
||||
id: string;
|
||||
song: SongResult;
|
||||
status: RequestStatus;
|
||||
timestamp: number;
|
||||
abortController?: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放请求管理器类
|
||||
*/
|
||||
class PlaybackRequestManager {
|
||||
private currentRequestId: string | null = null;
|
||||
private requestMap: Map<string, PlaybackRequest> = new Map();
|
||||
private requestCounter = 0;
|
||||
|
||||
/**
|
||||
* 生成唯一的请求ID
|
||||
*/
|
||||
private generateRequestId(): string {
|
||||
return `playback_${Date.now()}_${++this.requestCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的播放请求
|
||||
* @param song 要播放的歌曲
|
||||
* @returns 新请求的ID
|
||||
*/
|
||||
createRequest(song: SongResult): string {
|
||||
// 取消所有之前的请求
|
||||
this.cancelAllRequests();
|
||||
|
||||
const requestId = this.generateRequestId();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const request: PlaybackRequest = {
|
||||
id: requestId,
|
||||
song,
|
||||
status: RequestStatus.PENDING,
|
||||
timestamp: Date.now(),
|
||||
abortController
|
||||
};
|
||||
|
||||
this.requestMap.set(requestId, request);
|
||||
this.currentRequestId = requestId;
|
||||
|
||||
console.log(`[PlaybackRequestManager] 创建新请求: ${requestId}, 歌曲: ${song.name}`);
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活请求(标记为正在处理)
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
activateRequest(requestId: string): boolean {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.status === RequestStatus.CANCELLED) {
|
||||
console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
request.status = RequestStatus.ACTIVE;
|
||||
console.log(`[PlaybackRequestManager] 激活请求: ${requestId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成请求
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
completeRequest(requestId: string): void {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.status = RequestStatus.COMPLETED;
|
||||
console.log(`[PlaybackRequestManager] 完成请求: ${requestId}`);
|
||||
|
||||
// 清理旧请求(保留最近3个)
|
||||
this.cleanupOldRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记请求失败
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
failRequest(requestId: string): void {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.status = RequestStatus.FAILED;
|
||||
console.log(`[PlaybackRequestManager] 请求失败: ${requestId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消指定请求
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
cancelRequest(requestId: string): void {
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.status === RequestStatus.CANCELLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消AbortController
|
||||
if (request.abortController && !request.abortController.signal.aborted) {
|
||||
request.abortController.abort();
|
||||
}
|
||||
|
||||
request.status = RequestStatus.CANCELLED;
|
||||
console.log(`[PlaybackRequestManager] 取消请求: ${requestId}, 歌曲: ${request.song.name}`);
|
||||
|
||||
// 如果是当前请求,清除当前请求ID
|
||||
if (this.currentRequestId === requestId) {
|
||||
this.currentRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
cancelAllRequests(): void {
|
||||
console.log(`[PlaybackRequestManager] 取消所有请求,当前请求数: ${this.requestMap.size}`);
|
||||
|
||||
this.requestMap.forEach((request) => {
|
||||
if (
|
||||
request.status !== RequestStatus.COMPLETED &&
|
||||
request.status !== RequestStatus.CANCELLED
|
||||
) {
|
||||
this.cancelRequest(request.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求是否仍然有效(是当前活动请求)
|
||||
* @param requestId 请求ID
|
||||
* @returns 是否有效
|
||||
*/
|
||||
isRequestValid(requestId: string): boolean {
|
||||
// 检查是否是当前请求
|
||||
if (this.currentRequestId !== requestId) {
|
||||
console.warn(
|
||||
`[PlaybackRequestManager] 请求已过期: ${requestId}, 当前请求: ${this.currentRequestId}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const request = this.requestMap.get(requestId);
|
||||
if (!request) {
|
||||
console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查请求状态
|
||||
if (request.status === RequestStatus.CANCELLED) {
|
||||
console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求是否应该中止(用于 AbortController)
|
||||
* @param requestId 请求ID
|
||||
* @returns AbortSignal 或 undefined
|
||||
*/
|
||||
getAbortSignal(requestId: string): AbortSignal | undefined {
|
||||
const request = this.requestMap.get(requestId);
|
||||
return request?.abortController?.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求ID
|
||||
*/
|
||||
getCurrentRequestId(): string | null {
|
||||
return this.currentRequestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求信息
|
||||
* @param requestId 请求ID
|
||||
*/
|
||||
getRequest(requestId: string): PlaybackRequest | undefined {
|
||||
return this.requestMap.get(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧请求(保留最近3个)
|
||||
*/
|
||||
private cleanupOldRequests(): void {
|
||||
if (this.requestMap.size <= 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按时间戳排序,保留最新的3个
|
||||
const sortedRequests = Array.from(this.requestMap.values()).sort(
|
||||
(a, b) => b.timestamp - a.timestamp
|
||||
);
|
||||
|
||||
const toKeep = new Set(sortedRequests.slice(0, 3).map((r) => r.id));
|
||||
const toDelete: string[] = [];
|
||||
|
||||
this.requestMap.forEach((_, id) => {
|
||||
if (!toKeep.has(id)) {
|
||||
toDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
toDelete.forEach((id) => {
|
||||
this.requestMap.delete(id);
|
||||
});
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
console.log(`[PlaybackRequestManager] 清理了 ${toDelete.length} 个旧请求`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置管理器(用于调试或特殊情况)
|
||||
*/
|
||||
reset(): void {
|
||||
console.log('[PlaybackRequestManager] 重置管理器');
|
||||
this.cancelAllRequests();
|
||||
this.requestMap.clear();
|
||||
this.currentRequestId = null;
|
||||
this.requestCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取调试信息
|
||||
*/
|
||||
getDebugInfo(): {
|
||||
currentRequestId: string | null;
|
||||
totalRequests: number;
|
||||
requestsByStatus: Record<string, number>;
|
||||
} {
|
||||
const requestsByStatus: Record<string, number> = {
|
||||
[RequestStatus.PENDING]: 0,
|
||||
[RequestStatus.ACTIVE]: 0,
|
||||
[RequestStatus.COMPLETED]: 0,
|
||||
[RequestStatus.CANCELLED]: 0,
|
||||
[RequestStatus.FAILED]: 0
|
||||
};
|
||||
|
||||
this.requestMap.forEach((request) => {
|
||||
requestsByStatus[request.status]++;
|
||||
});
|
||||
|
||||
return {
|
||||
currentRequestId: this.currentRequestId,
|
||||
totalRequests: this.requestMap.size,
|
||||
requestsByStatus
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const playbackRequestManager = new PlaybackRequestManager();
|
||||
273
src/renderer/services/preloadService.ts
Normal file
273
src/renderer/services/preloadService.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Howl } from 'howler';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { getParsingMusicUrl } from '@/api/music';
|
||||
import type { SongResult } from '@/types/music';
|
||||
|
||||
class PreloadService {
|
||||
private loadingPromises: Map<string | number, Promise<Howl>> = new Map();
|
||||
private preloadedSounds: Map<string | number, Howl> = new Map();
|
||||
|
||||
/**
|
||||
* 加载并验证音频
|
||||
* 如果已经在加载中,返回现有的 Promise
|
||||
* 如果已经加载完成,返回缓存的 Howl 实例
|
||||
*/
|
||||
public async load(song: SongResult): Promise<Howl> {
|
||||
if (!song || !song.id) {
|
||||
throw new Error('无效的歌曲对象');
|
||||
}
|
||||
|
||||
// 1. 检查是否有正在进行的加载
|
||||
if (this.loadingPromises.has(song.id)) {
|
||||
console.log(`[PreloadService] 歌曲 ${song.name} 正在加载中,复用现有请求`);
|
||||
return this.loadingPromises.get(song.id)!;
|
||||
}
|
||||
|
||||
// 2. 检查是否有已完成的缓存
|
||||
if (this.preloadedSounds.has(song.id)) {
|
||||
const sound = this.preloadedSounds.get(song.id)!;
|
||||
if (sound.state() === 'loaded') {
|
||||
console.log(`[PreloadService] 歌曲 ${song.name} 已预加载完成,直接使用`);
|
||||
return sound;
|
||||
} else {
|
||||
// 如果缓存的音频状态不正常,清理并重新加载
|
||||
this.preloadedSounds.delete(song.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 开始新的加载过程
|
||||
const loadPromise = this._performLoad(song);
|
||||
this.loadingPromises.set(song.id, loadPromise);
|
||||
|
||||
try {
|
||||
const sound = await loadPromise;
|
||||
this.preloadedSounds.set(song.id, sound);
|
||||
return sound;
|
||||
} finally {
|
||||
this.loadingPromises.delete(song.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际的加载和验证逻辑
|
||||
*/
|
||||
private async _performLoad(song: SongResult): Promise<Howl> {
|
||||
console.log(`[PreloadService] 开始加载歌曲: ${song.name}`);
|
||||
|
||||
if (!song.playMusicUrl) {
|
||||
throw new Error('歌曲没有 URL');
|
||||
}
|
||||
|
||||
// 创建初始音频实例
|
||||
let sound = await this._createSound(song.playMusicUrl);
|
||||
|
||||
// 检查时长
|
||||
const duration = sound.duration();
|
||||
const expectedDuration = (song.dt || 0) / 1000;
|
||||
|
||||
// 如果时长差异超过5秒,且不是B站视频,且预期时长大于0
|
||||
if (
|
||||
expectedDuration > 0 &&
|
||||
Math.abs(duration - expectedDuration) > 5 &&
|
||||
song.source !== 'bilibili'
|
||||
) {
|
||||
const songId = String(song.id);
|
||||
const sourceType = localStorage.getItem(`song_source_type_${songId}`);
|
||||
|
||||
// 如果不是用户手动锁定的音源,尝试自动重新解析
|
||||
if (sourceType !== 'manual') {
|
||||
console.warn(
|
||||
`[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析`
|
||||
);
|
||||
|
||||
// 动态导入 store
|
||||
const { useSettingsStore } = await import('@/store/modules/settings');
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
const settingsStore = useSettingsStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const enabledSources = settingsStore.setData.enabledMusicSources || [
|
||||
'migu',
|
||||
'kugou',
|
||||
'pyncmd',
|
||||
'gdmusic'
|
||||
];
|
||||
const availableSources = enabledSources.filter((s: string) => s !== 'bilibili');
|
||||
|
||||
const triedSources = new Set<string>();
|
||||
const triedSourceDiffs = new Map<string, number>();
|
||||
|
||||
// 记录当前音源
|
||||
let currentSource = 'unknown';
|
||||
const currentSavedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
if (currentSavedSource) {
|
||||
try {
|
||||
const sources = JSON.parse(currentSavedSource);
|
||||
if (Array.isArray(sources) && sources.length > 0) {
|
||||
currentSource = sources[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
triedSources.add(currentSource);
|
||||
triedSourceDiffs.set(currentSource, Math.abs(duration - expectedDuration));
|
||||
|
||||
// 卸载当前不匹配的音频
|
||||
sound.unload();
|
||||
|
||||
// 尝试其他音源
|
||||
for (const source of availableSources) {
|
||||
if (triedSources.has(source)) continue;
|
||||
|
||||
console.log(`[PreloadService] 尝试音源: ${source}`);
|
||||
triedSources.add(source);
|
||||
|
||||
try {
|
||||
const songData = cloneDeep(song);
|
||||
// 临时保存设置以便 getParsingMusicUrl 使用
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([source]));
|
||||
|
||||
const res = await getParsingMusicUrl(
|
||||
typeof song.id === 'string' ? parseInt(song.id) : song.id,
|
||||
songData
|
||||
);
|
||||
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
const newUrl = res.data.data.url;
|
||||
const tempSound = await this._createSound(newUrl);
|
||||
const newDuration = tempSound.duration();
|
||||
const diff = Math.abs(newDuration - expectedDuration);
|
||||
|
||||
triedSourceDiffs.set(source, diff);
|
||||
|
||||
if (diff <= 5) {
|
||||
console.log(`[PreloadService] 找到匹配音源: ${source}, 更新歌曲信息`);
|
||||
|
||||
// 更新歌曲信息
|
||||
const updatedSong = {
|
||||
...song,
|
||||
playMusicUrl: newUrl,
|
||||
expiredAt: Date.now() + 1800000
|
||||
};
|
||||
|
||||
// 更新 store
|
||||
playlistStore.updateSong(updatedSong);
|
||||
|
||||
// 记录新的音源设置
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([source]));
|
||||
localStorage.setItem(`song_source_type_${songId}`, 'auto');
|
||||
|
||||
return tempSound;
|
||||
} else {
|
||||
tempSound.unload();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[PreloadService] 尝试音源 ${source} 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到完美匹配,使用最佳匹配
|
||||
console.warn('[PreloadService] 未找到完美匹配,寻找最佳匹配');
|
||||
let bestSource = '';
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const [source, diff] of triedSourceDiffs.entries()) {
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
bestSource = source;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSource && bestSource !== currentSource) {
|
||||
console.log(`[PreloadService] 使用最佳匹配音源: ${bestSource} (差异: ${minDiff}s)`);
|
||||
try {
|
||||
const songData = cloneDeep(song);
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([bestSource]));
|
||||
|
||||
const res = await getParsingMusicUrl(
|
||||
typeof song.id === 'string' ? parseInt(song.id) : song.id,
|
||||
songData
|
||||
);
|
||||
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
const newUrl = res.data.data.url;
|
||||
const bestSound = await this._createSound(newUrl);
|
||||
|
||||
const updatedSong = {
|
||||
...song,
|
||||
playMusicUrl: newUrl,
|
||||
expiredAt: Date.now() + 1800000
|
||||
};
|
||||
|
||||
playlistStore.updateSong(updatedSong);
|
||||
localStorage.setItem(`song_source_type_${songId}`, 'auto');
|
||||
|
||||
return bestSound;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[PreloadService] 获取最佳匹配音源失败:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不需要修复或修复失败,重新加载原始音频(因为上面可能unload了)
|
||||
if (sound.state() === 'unloaded') {
|
||||
sound = await this._createSound(song.playMusicUrl);
|
||||
}
|
||||
|
||||
return sound;
|
||||
}
|
||||
|
||||
private _createSound(url: string): Promise<Howl> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false,
|
||||
onload: () => resolve(sound),
|
||||
onloaderror: (_, err) => reject(err)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消特定歌曲的预加载(如果可能)
|
||||
* 注意:Promise 无法真正取消,但我们可以清理结果
|
||||
*/
|
||||
public cancel(songId: string | number) {
|
||||
if (this.preloadedSounds.has(songId)) {
|
||||
const sound = this.preloadedSounds.get(songId)!;
|
||||
sound.unload();
|
||||
this.preloadedSounds.delete(songId);
|
||||
}
|
||||
// loadingPromises 中的任务会继续执行,但因为 preloadedSounds 中没有记录,
|
||||
// 下次请求时会重新加载(或者我们可以让 _performLoad 检查一个取消标记,但这增加了复杂性)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已预加载的音频实例(如果存在)
|
||||
*/
|
||||
public getPreloadedSound(songId: string | number): Howl | undefined {
|
||||
return this.preloadedSounds.get(songId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有预加载资源
|
||||
*/
|
||||
public clearAll() {
|
||||
this.preloadedSounds.forEach((sound) => sound.unload());
|
||||
this.preloadedSounds.clear();
|
||||
this.loadingPromises.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const preloadService = new PreloadService();
|
||||
@@ -9,6 +9,8 @@ import { getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import { preloadService } from '@/services/preloadService';
|
||||
import type { Platform, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
@@ -28,10 +30,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
const isPlay = ref(false);
|
||||
const playMusic = ref<SongResult>({} as SongResult);
|
||||
const playMusicUrl = ref('');
|
||||
const triedSources = ref<Set<string>>(new Set());
|
||||
const triedSourceDiffs = ref<Map<string, number>>(new Map());
|
||||
const musicFull = ref(false);
|
||||
const playbackRate = ref(1.0);
|
||||
const volume = ref(1);
|
||||
const userPlayIntent = ref(true);
|
||||
const userPlayIntent = ref(false); // 用户是否想要播放
|
||||
|
||||
let checkPlayTime: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -100,7 +104,7 @@ export const usePlayerCoreStore = defineStore(
|
||||
/**
|
||||
* 播放状态检测
|
||||
*/
|
||||
const checkPlaybackState = (song: SongResult, timeout: number = 4000) => {
|
||||
const checkPlaybackState = (song: SongResult, requestId: string, timeout: number = 4000) => {
|
||||
if (checkPlayTime) {
|
||||
clearTimeout(checkPlayTime);
|
||||
}
|
||||
@@ -114,10 +118,16 @@ export const usePlayerCoreStore = defineStore(
|
||||
};
|
||||
|
||||
const onPlayErrorHandler = async () => {
|
||||
console.log('播放错误事件触发,尝试重新获取URL');
|
||||
console.log('播放错误事件触发,检查是否需要重新获取URL');
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
|
||||
// 验证请求是否仍然有效
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log('请求已过期,跳过重试');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userPlayIntent.value && play.value) {
|
||||
playMusic.value.playMusicUrl = undefined;
|
||||
const refreshedSong = { ...song, isFirstPlay: true };
|
||||
@@ -129,6 +139,14 @@ export const usePlayerCoreStore = defineStore(
|
||||
audioService.on('playerror', onPlayErrorHandler);
|
||||
|
||||
checkPlayTime = setTimeout(() => {
|
||||
// 验证请求是否仍然有效
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log('请求已过期,跳过超时重试');
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!audioService.isActuallyPlaying() && userPlayIntent.value && play.value) {
|
||||
console.log(`${timeout}ms后歌曲未真正播放且用户仍希望播放,尝试重新获取URL`);
|
||||
audioService.off('play', onPlayHandler);
|
||||
@@ -147,6 +165,16 @@ export const usePlayerCoreStore = defineStore(
|
||||
* 核心播放处理函数
|
||||
*/
|
||||
const handlePlayMusic = async (music: SongResult, isPlay: boolean = true) => {
|
||||
// 如果是新歌曲,重置已尝试的音源
|
||||
if (music.id !== playMusic.value.id) {
|
||||
triedSources.value.clear();
|
||||
triedSourceDiffs.value.clear();
|
||||
}
|
||||
|
||||
// 创建新的播放请求并取消之前的所有请求
|
||||
const requestId = playbackRequestManager.createRequest(music);
|
||||
console.log(`[handlePlayMusic] 开始处理歌曲: ${music.name}, 请求ID: ${requestId}`);
|
||||
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
console.log('主动停止并卸载当前音频实例');
|
||||
@@ -154,6 +182,18 @@ export const usePlayerCoreStore = defineStore(
|
||||
currentSound.unload();
|
||||
}
|
||||
|
||||
// 验证请求是否仍然有效
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[handlePlayMusic] 请求已失效: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 激活请求
|
||||
if (!playbackRequestManager.activateRequest(requestId)) {
|
||||
console.log(`[handlePlayMusic] 无法激活请求: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const originalMusic = { ...music };
|
||||
const { loadLrc } = useLyrics();
|
||||
const { getSongDetail } = useSongDetail();
|
||||
@@ -174,6 +214,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
})()
|
||||
]);
|
||||
|
||||
// 在更新状态前再次验证请求
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[handlePlayMusic] 加载歌词/背景色后请求已失效: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置歌词和背景色
|
||||
music.lyric = lyrics;
|
||||
music.backgroundColor = backgroundColor;
|
||||
@@ -201,7 +247,15 @@ export const usePlayerCoreStore = defineStore(
|
||||
musicHistory.addMusic(music);
|
||||
|
||||
// 获取歌曲详情
|
||||
const updatedPlayMusic = await getSongDetail(originalMusic);
|
||||
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
|
||||
|
||||
// 在获取详情后再次验证请求
|
||||
if (!playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[handlePlayMusic] 获取歌曲详情后请求已失效: ${requestId}`);
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
return false;
|
||||
}
|
||||
|
||||
updatedPlayMusic.lyric = lyrics;
|
||||
|
||||
playMusic.value = updatedPlayMusic;
|
||||
@@ -238,12 +292,20 @@ export const usePlayerCoreStore = defineStore(
|
||||
}
|
||||
|
||||
playInProgress = true;
|
||||
const result = await playAudio();
|
||||
const result = await playAudio(requestId);
|
||||
playInProgress = false;
|
||||
return !!result;
|
||||
|
||||
if (result) {
|
||||
playbackRequestManager.completeRequest(requestId);
|
||||
return true;
|
||||
} else {
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动播放音频失败:', error);
|
||||
playInProgress = false;
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -252,6 +314,22 @@ export const usePlayerCoreStore = defineStore(
|
||||
if (playMusic.value) {
|
||||
playMusic.value.playLoading = false;
|
||||
}
|
||||
playbackRequestManager.failRequest(requestId);
|
||||
|
||||
// 通知外部播放失败,需要跳到下一首
|
||||
try {
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
if (Array.isArray(playlistStore.playList) && playlistStore.playList.length > 1) {
|
||||
message.warning('歌曲解析失败 播放下一首');
|
||||
setTimeout(() => {
|
||||
playlistStore.nextPlay();
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('切换下一首时发生问题:', e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -259,9 +337,15 @@ export const usePlayerCoreStore = defineStore(
|
||||
/**
|
||||
* 播放音频
|
||||
*/
|
||||
const playAudio = async () => {
|
||||
const playAudio = async (requestId?: string) => {
|
||||
if (!playMusicUrl.value || !playMusic.value) return null;
|
||||
|
||||
// 如果提供了 requestId,验证请求是否仍然有效
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[playAudio] 请求已失效: ${requestId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldPlay = play.value;
|
||||
console.log('播放音频,当前播放状态:', shouldPlay ? '播放' : '暂停');
|
||||
@@ -287,6 +371,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
playMusic.value.bilibiliData.cid
|
||||
);
|
||||
|
||||
// 再次验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[playAudio] 获取B站URL后请求已失效: ${requestId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
(playMusic.value as any).playMusicUrl = proxyUrl;
|
||||
playMusicUrl.value = proxyUrl;
|
||||
} catch (error) {
|
||||
@@ -297,17 +387,39 @@ export const usePlayerCoreStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
// 播放新音频
|
||||
// 使用 PreloadService 加载音频
|
||||
// 这将确保如果正在进行预加载修复,我们会等待它完成
|
||||
// 同时也处理了时长检查和自动修复逻辑
|
||||
let sound: Howl;
|
||||
try {
|
||||
sound = await preloadService.load(playMusic.value);
|
||||
} catch (error) {
|
||||
console.error('PreloadService 加载失败:', error);
|
||||
// 如果 PreloadService 失败,尝试直接播放作为回退
|
||||
// 但通常 PreloadService 失败意味着 URL 问题
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 播放新音频,传入已加载的 sound 实例
|
||||
const newSound = await audioService.play(
|
||||
playMusicUrl.value,
|
||||
playMusic.value,
|
||||
shouldPlay,
|
||||
initialPosition || 0
|
||||
initialPosition || 0,
|
||||
sound
|
||||
);
|
||||
|
||||
// 播放后再次验证请求
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log(`[playAudio] 播放后请求已失效: ${requestId}`);
|
||||
newSound.stop();
|
||||
newSound.unload();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 添加播放状态检测
|
||||
if (shouldPlay) {
|
||||
checkPlaybackState(playMusic.value);
|
||||
if (shouldPlay && requestId) {
|
||||
checkPlaybackState(playMusic.value, requestId);
|
||||
}
|
||||
|
||||
// 发布音频就绪事件
|
||||
@@ -315,6 +427,111 @@ export const usePlayerCoreStore = defineStore(
|
||||
new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } })
|
||||
);
|
||||
|
||||
// 检查时长是否匹配,如果不匹配则尝试自动重新解析
|
||||
const duration = newSound.duration();
|
||||
const expectedDuration = (playMusic.value.dt || 0) / 1000;
|
||||
|
||||
// 如果时长差异超过5秒,且不是B站视频,且预期时长大于0
|
||||
if (
|
||||
expectedDuration > 0 &&
|
||||
Math.abs(duration - expectedDuration) > 5 &&
|
||||
playMusic.value.source !== 'bilibili' &&
|
||||
playMusic.value.id
|
||||
) {
|
||||
const songId = String(playMusic.value.id);
|
||||
const sourceType = localStorage.getItem(`song_source_type_${songId}`);
|
||||
|
||||
// 如果不是用户手动锁定的音源
|
||||
if (sourceType !== 'manual') {
|
||||
console.warn(
|
||||
`时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试自动切换音源`
|
||||
);
|
||||
|
||||
// 记录当前失败的音源
|
||||
// 注意:这里假设当前使用的音源是 playMusic.value.source,或者是刚刚解析出来的
|
||||
// 但实际上我们需要知道当前具体是用哪个平台解析成功的,这可能需要从 getSongUrl 的结果中获取
|
||||
// 暂时简单处理,将当前配置的来源加入已尝试列表
|
||||
|
||||
// 获取所有可用音源
|
||||
const { useSettingsStore } = await import('./settings');
|
||||
const settingsStore = useSettingsStore();
|
||||
const enabledSources = settingsStore.setData.enabledMusicSources || [
|
||||
'migu',
|
||||
'kugou',
|
||||
'pyncmd',
|
||||
'gdmusic'
|
||||
];
|
||||
const availableSources: Platform[] = enabledSources.filter(
|
||||
(s: string) => s !== 'bilibili'
|
||||
);
|
||||
|
||||
// 将当前正在使用的音源加入已尝试列表
|
||||
let currentSource = 'unknown';
|
||||
const currentSavedSource = localStorage.getItem(`song_source_${songId}`);
|
||||
if (currentSavedSource) {
|
||||
try {
|
||||
const sources = JSON.parse(currentSavedSource);
|
||||
if (Array.isArray(sources) && sources.length > 0) {
|
||||
currentSource = sources[0];
|
||||
triedSources.value.add(currentSource);
|
||||
}
|
||||
} catch {
|
||||
console.error(`解析当前音源失败: ${currentSource}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 找到下一个未尝试的音源
|
||||
const nextSource = availableSources.find((s) => !triedSources.value.has(s));
|
||||
|
||||
// 记录当前音源的时间差
|
||||
if (currentSource !== 'unknown') {
|
||||
triedSourceDiffs.value.set(currentSource, Math.abs(duration - expectedDuration));
|
||||
}
|
||||
|
||||
if (nextSource) {
|
||||
console.log(`自动切换到音源: ${nextSource}`);
|
||||
newSound.stop();
|
||||
newSound.unload();
|
||||
|
||||
// 递归调用 reparseCurrentSong
|
||||
// 注意:这里是异步调用,不会阻塞当前函数返回,但我们已经停止了播放
|
||||
const success = await reparseCurrentSong(nextSource, true);
|
||||
if (success) {
|
||||
return audioService.getCurrentSound();
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
console.warn('所有音源都已尝试,寻找最接近时长的版本');
|
||||
|
||||
// 找出时间差最小的音源
|
||||
let bestSource = '';
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const [source, diff] of triedSourceDiffs.value.entries()) {
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
bestSource = source;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了最佳音源,且不是当前正在播放的音源
|
||||
if (bestSource && bestSource !== currentSource) {
|
||||
console.log(`切换到最佳匹配音源: ${bestSource} (差异: ${minDiff}s)`);
|
||||
newSound.stop();
|
||||
newSound.unload();
|
||||
|
||||
const success = await reparseCurrentSong(bestSource as Platform, true);
|
||||
if (success) {
|
||||
return audioService.getCurrentSound();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`当前音源 ${currentSource} 已经是最佳匹配 (差异: ${minDiff}s),保留播放`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newSound;
|
||||
} catch (error) {
|
||||
console.error('播放音频失败:', error);
|
||||
@@ -334,21 +551,27 @@ export const usePlayerCoreStore = defineStore(
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// 验证请求是否仍然有效再重试
|
||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
||||
console.log('重试时请求已失效,跳过重试');
|
||||
return;
|
||||
}
|
||||
if (userPlayIntent.value && play.value) {
|
||||
playAudio().catch((e) => {
|
||||
playAudio(requestId).catch((e) => {
|
||||
console.error('重试播放失败:', e);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
// 非操作锁错误:尝试切到下一首,避免在解析失败时卡住
|
||||
message.warning('歌曲解析失败 播放下一首');
|
||||
try {
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
if (Array.isArray(playlistStore.playList) && playlistStore.playList.length > 1) {
|
||||
setTimeout(() => {
|
||||
playlistStore.nextPlay();
|
||||
}, 300);
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('播放失败回退到下一首时发生问题(可能依赖未加载):', e);
|
||||
@@ -394,7 +617,7 @@ export const usePlayerCoreStore = defineStore(
|
||||
/**
|
||||
* 使用指定音源重新解析当前歌曲
|
||||
*/
|
||||
const reparseCurrentSong = async (sourcePlatform: Platform) => {
|
||||
const reparseCurrentSong = async (sourcePlatform: Platform, isAuto: boolean = false) => {
|
||||
try {
|
||||
const currentSong = playMusic.value;
|
||||
if (!currentSong || !currentSong.id) {
|
||||
@@ -410,6 +633,9 @@ export const usePlayerCoreStore = defineStore(
|
||||
const songId = String(currentSong.id);
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([sourcePlatform]));
|
||||
|
||||
// 记录音源设置类型(自动/手动)
|
||||
localStorage.setItem(`song_source_type_${songId}`, isAuto ? 'auto' : 'manual');
|
||||
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
currentSound.pause();
|
||||
@@ -434,6 +660,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
};
|
||||
|
||||
await handlePlayMusic(updatedMusic, true);
|
||||
|
||||
// 更新播放列表中的歌曲信息
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
playlistStore.updateSong(updatedMusic);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`使用音源 ${sourcePlatform} 解析失败`);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { defineStore, storeToRefs } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { preloadNextSong, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { preloadService } from '@/services/preloadService';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { performShuffle, preloadCoverImage } from '@/utils/playerUtils';
|
||||
@@ -81,7 +82,7 @@ export const usePlaylistStore = defineStore(
|
||||
// 预加载下一首歌曲的音频和封面
|
||||
if (nextSong) {
|
||||
if (nextSong.playMusicUrl) {
|
||||
preloadNextSong(nextSong.playMusicUrl);
|
||||
preloadService.load(nextSong);
|
||||
}
|
||||
if (nextSong.picUrl) {
|
||||
preloadCoverImage(nextSong.picUrl, getImgUrl);
|
||||
@@ -343,7 +344,7 @@ export const usePlaylistStore = defineStore(
|
||||
/**
|
||||
* 下一首
|
||||
*/
|
||||
const _nextPlay = async () => {
|
||||
const _nextPlay = async (retryCount: number = 0, maxRetries: number = 3) => {
|
||||
try {
|
||||
if (playList.value.length === 0) {
|
||||
return;
|
||||
@@ -366,17 +367,44 @@ export const usePlaylistStore = defineStore(
|
||||
const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
|
||||
const nextSong = { ...playList.value[nowPlayListIndex] };
|
||||
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
console.log(
|
||||
`[nextPlay] 尝试播放下一首: ${nextSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}, 重试次数: ${retryCount}/${maxRetries}`
|
||||
);
|
||||
|
||||
// 先尝试播放歌曲,成功后再更新索引
|
||||
const success = await playerCore.handlePlayMusic(nextSong, true);
|
||||
|
||||
if (success) {
|
||||
// 播放成功,更新索引并重置重试计数
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
console.log(`[nextPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`);
|
||||
sleepTimerStore.handleSongChange();
|
||||
} else {
|
||||
console.error('播放下一首失败');
|
||||
playListIndex.value = currentIndex;
|
||||
playerCore.setIsPlay(false);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
console.error(`[nextPlay] 播放下一首失败,当前索引: ${currentIndex}`);
|
||||
|
||||
// 如果还有重试次数,先更新索引再重试下一首
|
||||
if (retryCount < maxRetries && playList.value.length > 1) {
|
||||
console.log(
|
||||
`[nextPlay] 跳过失败的歌曲,尝试播放下下首,重试 ${retryCount + 1}/${maxRetries}`
|
||||
);
|
||||
|
||||
// 更新索引到失败的歌曲位置,这样下次递归调用会继续往下
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
|
||||
// 延迟后递归调用,尝试播放下一首
|
||||
setTimeout(() => {
|
||||
_nextPlay(retryCount + 1, maxRetries);
|
||||
}, 500);
|
||||
} else {
|
||||
// 重试次数用尽或只有一首歌
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error(`[nextPlay] 连续${maxRetries}首歌曲播放失败,停止尝试`);
|
||||
message.error('连续多首歌曲播放失败,请检查网络或音源设置');
|
||||
} else {
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
}
|
||||
playerCore.setIsPlay(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换下一首出错:', error);
|
||||
@@ -400,12 +428,16 @@ export const usePlaylistStore = defineStore(
|
||||
(playListIndex.value - 1 + playList.value.length) % playList.value.length;
|
||||
|
||||
const prevSong = { ...playList.value[nowPlayListIndex] };
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
|
||||
console.log(
|
||||
`[prevPlay] 尝试播放上一首: ${prevSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}`
|
||||
);
|
||||
|
||||
let success = false;
|
||||
let retryCount = 0;
|
||||
const maxRetries = 2;
|
||||
|
||||
// 先尝试播放歌曲,成功后再更新索引
|
||||
while (!success && retryCount < maxRetries) {
|
||||
success = await playerCore.handlePlayMusic(prevSong);
|
||||
|
||||
@@ -442,9 +474,12 @@ export const usePlaylistStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.error('所有尝试都失败,无法播放上一首歌曲');
|
||||
playListIndex.value = currentIndex;
|
||||
if (success) {
|
||||
// 播放成功,更新索引
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
console.log(`[prevPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`);
|
||||
} else {
|
||||
console.error(`[prevPlay] 播放上一首失败,保持当前索引: ${currentIndex}`);
|
||||
playerCore.setIsPlay(false);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
}
|
||||
@@ -494,7 +529,7 @@ export const usePlaylistStore = defineStore(
|
||||
const sound = audioService.getCurrentSound();
|
||||
if (sound) {
|
||||
sound.play();
|
||||
playerCore.checkPlaybackState(playerCore.playMusic);
|
||||
// checkPlaybackState 已在 playAudio 中自动调用,无需在这里重复调用
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -579,7 +614,17 @@ export const usePlaylistStore = defineStore(
|
||||
setPlayListDrawerVisible,
|
||||
setPlay,
|
||||
initializePlaylist,
|
||||
fetchSongs
|
||||
fetchSongs,
|
||||
updateSong: (song: SongResult) => {
|
||||
const index = playList.value.findIndex(
|
||||
(item) => item.id === song.id && item.source === song.source
|
||||
);
|
||||
if (index !== -1) {
|
||||
playList.value[index] = song;
|
||||
// 触发响应式更新
|
||||
playList.value = [...playList.value];
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// 音乐平台类型
|
||||
export type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili' | 'gdmusic';
|
||||
export type Platform =
|
||||
| 'qq'
|
||||
| 'migu'
|
||||
| 'kugou'
|
||||
| 'kuwo'
|
||||
| 'pyncmd'
|
||||
| 'joox'
|
||||
| 'bilibili'
|
||||
| 'gdmusic';
|
||||
|
||||
// 默认平台列表
|
||||
export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||
export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
|
||||
|
||||
export interface IRecommendMusic {
|
||||
code: number;
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function handleShortcutAction(action: string) {
|
||||
await audioService.pause();
|
||||
showToast(t('player.playBar.pause'), 'ri-pause-circle-line');
|
||||
} else {
|
||||
await audioService.play();
|
||||
await audioService.getCurrentSound()?.play();
|
||||
showToast(t('player.playBar.play'), 'ri-play-circle-line');
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -272,9 +272,6 @@ const parseWordByWordLine = (line: string): ParseResult<LyricLine> => {
|
||||
currentPos = wordEndPos;
|
||||
}
|
||||
|
||||
console.log('fullText', fullText);
|
||||
console.log('words', words);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
|
||||
<!-- 关于 -->
|
||||
<div id="about" ref="aboutRef" class="settings-section">
|
||||
<div class="settings-section-title">{{ t('settings.regard') }}</div>
|
||||
<div class="settings-section-title">{{ t('settings.sections.about') }}</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
@@ -644,35 +644,31 @@ import { checkUpdate, UpdateResult } from '@/utils/update';
|
||||
import config from '../../../../package.json';
|
||||
|
||||
// 所有平台默认值
|
||||
const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili'];
|
||||
const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
|
||||
|
||||
const platform = window.electron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 创建一个本地缓存的setData,避免频繁更新
|
||||
const localSetData = ref({ ...settingsStore.setData });
|
||||
|
||||
// 在组件卸载时保存设置
|
||||
onUnmounted(() => {
|
||||
settingsStore.setSetData(localSetData.value);
|
||||
});
|
||||
|
||||
const checking = ref(false);
|
||||
const updateInfo = ref<UpdateResult>({
|
||||
hasUpdate: false,
|
||||
latestVersion: '',
|
||||
currentVersion: config.version,
|
||||
releaseInfo: null
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* 防抖保存设置
|
||||
* 避免频繁写入导致性能问题
|
||||
*/
|
||||
const saveSettings = useDebounceFn((data) => {
|
||||
settingsStore.setSetData(data);
|
||||
}, 500);
|
||||
|
||||
/**
|
||||
* 本地缓存的设置数据
|
||||
* 使用本地副本避免直接操作 store,提升性能
|
||||
*/
|
||||
const localSetData = ref({ ...settingsStore.setData });
|
||||
|
||||
/**
|
||||
* 设置数据的计算属性
|
||||
* 提供响应式的读写接口
|
||||
*/
|
||||
const setData = computed({
|
||||
get: () => localSetData.value,
|
||||
set: (newData) => {
|
||||
@@ -680,7 +676,10 @@ const setData = computed({
|
||||
}
|
||||
});
|
||||
|
||||
// 监听localSetData变化,保存设置
|
||||
/**
|
||||
* 监听本地设置变化,自动保存
|
||||
* 使用防抖避免频繁保存
|
||||
*/
|
||||
watch(
|
||||
() => localSetData.value,
|
||||
(newValue) => {
|
||||
@@ -689,11 +688,14 @@ watch(
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 监听store中setData的变化,同步到本地
|
||||
/**
|
||||
* 监听 store 中设置的变化,同步到本地
|
||||
* 避免外部修改导致的数据不一致
|
||||
*/
|
||||
watch(
|
||||
() => settingsStore.setData,
|
||||
(newValue) => {
|
||||
// 只在初始加载时更新本地数据,避免循环更新
|
||||
// 只在数据不同时更新,避免循环触发
|
||||
if (JSON.stringify(localSetData.value) !== JSON.stringify(newValue)) {
|
||||
localSetData.value = { ...newValue };
|
||||
}
|
||||
@@ -701,6 +703,26 @@ watch(
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 组件卸载时确保设置已保存
|
||||
*/
|
||||
onUnmounted(() => {
|
||||
settingsStore.setSetData(localSetData.value);
|
||||
});
|
||||
|
||||
// ==================== 更新检查相关 ====================
|
||||
const checking = ref(false);
|
||||
const updateInfo = ref<UpdateResult>({
|
||||
hasUpdate: false,
|
||||
latestVersion: '',
|
||||
currentVersion: config.version,
|
||||
releaseInfo: null
|
||||
});
|
||||
|
||||
// ==================== i18n ====================
|
||||
const { t } = useI18n();
|
||||
|
||||
// ==================== 主题和界面设置 ====================
|
||||
const isDarkTheme = computed({
|
||||
get: () => settingsStore.theme === 'dark',
|
||||
set: () => settingsStore.toggleTheme()
|
||||
@@ -1008,16 +1030,30 @@ const handleShortcutsChange = (shortcuts: any) => {
|
||||
console.log('快捷键已更新:', shortcuts);
|
||||
};
|
||||
|
||||
// 定义设置分类
|
||||
const settingSections = [
|
||||
{ id: 'basic', title: t('settings.sections.basic') },
|
||||
{ id: 'playback', title: t('settings.sections.playback') },
|
||||
{ id: 'application', title: t('settings.sections.application'), electron: true },
|
||||
{ id: 'network', title: t('settings.sections.network'), electron: true },
|
||||
{ id: 'system', title: t('settings.sections.system'), electron: true },
|
||||
{ id: 'regard', title: t('settings.sections.regard') },
|
||||
{ id: 'donation', title: t('settings.sections.donation') }
|
||||
];
|
||||
/**
|
||||
* 设置分类配置
|
||||
* 定义左侧导航的所有分类项
|
||||
*/
|
||||
interface SettingSection {
|
||||
id: string;
|
||||
title?: string; // 可选,在模板中动态获取 i18n 标题
|
||||
electron?: boolean;
|
||||
}
|
||||
|
||||
const settingSections = computed<SettingSection[]>(() => {
|
||||
const sections: SettingSection[] = [
|
||||
{ id: 'basic' },
|
||||
{ id: 'playback' },
|
||||
{ id: 'application', electron: true },
|
||||
{ id: 'network', electron: true },
|
||||
{ id: 'system', electron: true },
|
||||
{ id: 'about' },
|
||||
{ id: 'donation' }
|
||||
];
|
||||
|
||||
// 过滤非 Electron 环境下的专属分类
|
||||
return sections.filter((section) => !section.electron || isElectron);
|
||||
});
|
||||
|
||||
// 当前激活的分类
|
||||
const currentSection = ref('basic');
|
||||
@@ -1032,18 +1068,27 @@ const systemRef = ref();
|
||||
const aboutRef = ref();
|
||||
const donationRef = ref();
|
||||
|
||||
// 滚动到指定分类
|
||||
/**
|
||||
* Section refs 映射表
|
||||
* 用于滚动定位和状态追踪
|
||||
*/
|
||||
const SECTION_REFS_MAP = computed(() => ({
|
||||
basic: basicRef,
|
||||
playback: playbackRef,
|
||||
application: applicationRef,
|
||||
network: networkRef,
|
||||
system: systemRef,
|
||||
about: aboutRef,
|
||||
donation: donationRef
|
||||
}));
|
||||
|
||||
/**
|
||||
* 滚动到指定分类
|
||||
* @param sectionId - 分类 ID
|
||||
*/
|
||||
const scrollToSection = async (sectionId: string) => {
|
||||
currentSection.value = sectionId;
|
||||
const sectionRef = {
|
||||
basic: basicRef,
|
||||
playback: playbackRef,
|
||||
application: applicationRef,
|
||||
network: networkRef,
|
||||
system: systemRef,
|
||||
about: aboutRef,
|
||||
donation: donationRef
|
||||
}[sectionId];
|
||||
const sectionRef = SECTION_REFS_MAP.value[sectionId];
|
||||
|
||||
if (sectionRef?.value) {
|
||||
await nextTick();
|
||||
@@ -1054,27 +1099,30 @@ const scrollToSection = async (sectionId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动,更新当前激活的分类
|
||||
/**
|
||||
* 滚动偏移阈值(px)
|
||||
* 用于判断当前激活的分类
|
||||
*/
|
||||
const SCROLL_OFFSET_THRESHOLD = 100;
|
||||
|
||||
/**
|
||||
* 处理滚动事件,更新当前激活的分类
|
||||
* 根据滚动位置自动高亮左侧导航
|
||||
*/
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop } = e.target;
|
||||
|
||||
const sections = [
|
||||
{ id: 'basic', ref: basicRef },
|
||||
{ id: 'playback', ref: playbackRef },
|
||||
{ id: 'application', ref: applicationRef },
|
||||
{ id: 'network', ref: networkRef },
|
||||
{ id: 'system', ref: systemRef },
|
||||
{ id: 'about', ref: aboutRef },
|
||||
{ id: 'donation', ref: donationRef }
|
||||
];
|
||||
const sections = Object.entries(SECTION_REFS_MAP.value).map(([id, ref]) => ({
|
||||
id,
|
||||
ref
|
||||
}));
|
||||
|
||||
const activeSection = sections[0].id;
|
||||
let lastValidSection = activeSection;
|
||||
let lastValidSection = sections[0]?.id || 'basic';
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.ref?.value) {
|
||||
const { offsetTop } = section.ref.value;
|
||||
if (scrollTop >= offsetTop - 100) {
|
||||
if (scrollTop >= offsetTop - SCROLL_OFFSET_THRESHOLD) {
|
||||
lastValidSection = section.id;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user