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