feat: 一系列播放优化

This commit is contained in:
alger
2025-11-21 01:18:19 +08:00
parent 07f6152c56
commit 1a0e449e13
19 changed files with 1712 additions and 304 deletions

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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的情况

View File

@@ -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'));
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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;
}
};

View File

@@ -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) {

View 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();

View 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();

View File

@@ -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} 解析失败`);

View File

@@ -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];
}
}
};
},
{

View File

@@ -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;

View File

@@ -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;

View File

@@ -272,9 +272,6 @@ const parseWordByWordLine = (line: string): ParseResult<LyricLine> => {
currentPos = wordEndPos;
}
console.log('fullText', fullText);
console.log('words', words);
return {
success: true,
data: {

View File

@@ -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;
}
}