feat: 跨平台持久化与版本管理优化

- Cookie 存储从 temp_dir 迁移至 Tauri app_data_dir,兼容 Linux
- 简单统一风格,UI优化
- recentLocal 播放历史持久化到 localStorage
- 添加设置界面可以修改简单的设置
This commit is contained in:
2026-05-12 09:58:07 +08:00
parent 463e8e95b6
commit 7847a9f6b2
28 changed files with 1592 additions and 535 deletions

View File

@ -1,52 +1,44 @@
<template>
<div v-if="player.currentSong"
class="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-white/10 z-50 select-none">
<!-- 歌词精简条仅在非漫游全屏时显示 -->
<div v-if="currentLyricText && !player.showRoamDrawer" @click="showFullLyric = !showFullLyric"
class="px-6 py-1 text-center text-xs text-green-200/80 cursor-pointer hover:bg-white/5 transition truncate">
{{ currentLyricText }}
</div>
<div
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line z-50 select-none">
<!-- 进度条 -->
<div ref="progressBar" class="w-full h-1.5 bg-white/10 rounded-full relative group cursor-pointer overflow-visible"
<div ref="progressBar" class="w-full h-1.5 bg-muted rounded-full relative group cursor-pointer overflow-visible"
@mousedown.prevent="startSeek">
<!-- 缓存进度灰白 -->
<div class="absolute left-0 top-0 h-full bg-white/20 rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<!-- 播放进度绿色渐变 -->
<div class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-emerald-500 rounded-full"
<div class="absolute left-0 top-0 h-full bg-emphasis rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<div class="absolute left-0 top-0 h-full bg-accent rounded-full"
:style="{ width: displayProgress + '%' }"></div>
<!-- 拖动圆点基于容器定位left 百分比 -->
<div
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
</div>
<!-- 主控制区 -->
<div class="flex items-center px-6 h-16">
<!-- 左侧歌曲信息 -->
<div class="flex items-center gap-3 w-56 min-w-0">
<img :src="player.currentSong.al?.picUrl"
<img :src="player.currentSong?.al?.picUrl"
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
@click="player.openRoamDrawer()" title="全屏展示" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ player.currentSong.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{player.currentSong.ar?.map((a: any) => a.name).join('/')}}
@click="player.toggleRoamDrawer()" title="全屏展示" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ player.currentSong?.name }}</p>
<p class="text-xs text-content-2 truncate">
{{player.currentSong?.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : 'text-content-3 hover:text-danger'">
<svg v-if="player.currentSong && player.isLiked(player.currentSong.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
</div>
<!-- 中间控制按钮 + 时间 -->
<div class="flex-1 flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-5">
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
'text-xl transition',
player.isFmMode ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 hover:text-white',
'transition',
player.isFmMode ? 'text-content-4 cursor-not-allowed' : 'text-content-2 hover:text-content',
]">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="19" x2="5" y2="5"/></svg>
</button>
<button @click="player.toggle()"
class="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20">
class="w-9 h-9 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis">
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
@ -56,112 +48,93 @@
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click="player.next()" class="text-xl text-gray-400 hover:text-white transition"></button>
<button @click="player.next()" class="text-content-2 hover:text-content transition">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
</button>
</div>
<div class="flex items-center gap-2 text-xs text-gray-400">
<div class="flex items-center gap-2 text-xs text-content-2">
<span>{{ formatTime(player.currentTime) }}</span>
<span>/</span>
<span>{{ formatTime(player.duration) }}</span>
</div>
</div>
<!-- 右侧音量模式播放列表 -->
<div class="w-56 flex justify-end items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-sm text-gray-400">🔊</span>
<div class="relative w-24 h-6 flex items-center">
<button @click="toggleMute" class="text-content-2 hover:text-content transition">
<svg v-if="volume === 0" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
</button>
<div class="relative w-20 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-white/20 outline-none" />
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-emphasis outline-none" />
</div>
</div>
<button @click="togglePlayMode" class="text-gray-400 hover:text-white transition text-lg" :title="modeTitle">
{{ modeIcon }}
<button @click="togglePlayMode" class="text-content-2 hover:text-content transition" :title="modeTitle">
<svg v-if="player.playMode === 'loop'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
<svg v-else-if="player.playMode === 'shuffle'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="11" y="15" font-size="8" fill="currentColor" stroke="none" font-weight="bold">1</text></svg>
</button>
<button @click="showQueuePanel = !showQueuePanel"
class="text-gray-400 hover:text-white transition text-xl relative" title="播放列表">
📋
class="text-content-2 hover:text-content transition relative" title="播放列表">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
<span v-if="player.queue.length > 0"
class="absolute -top-1 -right-1 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
class="absolute -top-1 -right-1 bg-accent text-content text-xs rounded-full w-4 h-4 flex items-center justify-center">
{{ player.queue.length }}
</span>
</button>
</div>
</div>
<!-- 队列面板 -->
<Transition name="slide-up">
<div v-if="showQueuePanel"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
class="border-t border-line bg-surface/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
<button @click="player.clearQueue()" class="text-xs text-red-400 hover:text-red-300 transition">清空</button>
<button @click="player.clearQueue()" class="text-xs text-danger hover:text-danger transition">清空</button>
</div>
<div class="space-y-1">
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
idx === player.currentIndex ? 'bg-green-500/20 text-white' : 'hover:bg-white/5 text-gray-300',
idx === player.currentIndex ? 'bg-accent-dim text-content' : 'hover:bg-subtle text-content-2',
]">
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-500 truncate">
<p class="text-xs text-content-3 truncate">
{{song.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="text-gray-500 hover:text-red-400 transition text-sm">
class="text-content-3 hover:text-danger transition text-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
</div>
</Transition>
<!-- 全屏歌词浮层 -->
<Transition name="slide-up">
<div v-if="showFullLyric && lyrics.length > 0 && !player.showRoamDrawer"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-72 overflow-hidden flex flex-col"
@click.self="showFullLyric = false">
<div class="flex justify-between mb-2">
<h3 class="text-xs font-semibold">歌词</h3>
<button @click="showFullLyric = false" class="text-gray-400 hover:text-white">收起</button>
</div>
<div ref="lyricContainer"
class="flex-1 overflow-y-auto overflow-x-hidden whitespace-normal break-words space-y-1 text-sm text-center">
<p v-for="(line, idx) in lyrics" :key="idx" :class="idx === currentLyricIdx
? 'text-green-400 font-medium scale-105 transition'
: 'text-gray-400'
" class="px-4 py-0.5">
{{ line.text }}
</p>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch, onMounted } from 'vue';
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { formatTime } from '../utils/format';
import { invoke } from '@tauri-apps/api/core';
import { useLyric } from '../composables/UserLyric';
import { listen } from '@tauri-apps/api/event';
const player = usePlayerStore();
const showQueuePanel = ref(false);
const { lyrics, currentLyricIdx, currentLyricText } = useLyric();
const showFullLyric = ref(false);
const lyricContainer = ref<HTMLElement | null>(null);
const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false);
const previewTime = ref(0);
const cacheProgress = ref(0);
const volume = ref(100);
const prevVolume = ref(100);
let unlistenCache: (() => void) | null = null;
// 缓存进度监听
onMounted(async () => {
const fn = await listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
@ -172,10 +145,7 @@ onBeforeUnmount(() => {
if (unlistenCache) unlistenCache();
});
// 播放模式
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeIcons = { loop: '🔁', shuffle: '🔀', 'repeat-one': '🔂' };
const modeIcon = computed(() => modeIcons[player.playMode] || '🔁');
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
function togglePlayMode() {
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
@ -183,7 +153,16 @@ function togglePlayMode() {
player.setPlayMode(next);
}
// 进度条拖拽逻辑
function toggleMute() {
if (volume.value > 0) {
prevVolume.value = volume.value;
volume.value = 0;
} else {
volume.value = prevVolume.value || 100;
}
invoke('set_volume', { vol: volume.value / 100 });
}
let onDocMove: ((e: MouseEvent) => void) | null = null;
let onDocUp: (() => void) | null = null;
@ -234,13 +213,6 @@ const displayProgress = computed(() => {
return isSeeking.value ? previewPercent.value : progressPercent.value;
});
function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function playFromQueue(index: number) {
player.currentIndex = index;
player.playCurrent();
@ -255,25 +227,11 @@ async function handleVolumeChange(e: Event) {
const volumeBarBg = computed(() => {
const pct = volume.value;
return `linear-gradient(to right, #34d399 0%, #10b981 ${pct}%, rgba(255,255,255,0.15) ${pct}%)`;
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${pct}%)`;
});
// 歌词浮层自动滚动
watch(
() => currentLyricIdx.value,
() => {
if (showFullLyric.value && lyricContainer.value) {
nextTick(() => {
const active = lyricContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
);
</script>
<style scoped>
/* 样式保持不变(原有歌词浮层过渡、滑块样式等) */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.2s ease;
@ -300,9 +258,9 @@ watch(
height: 6px;
border-radius: 3px;
background: linear-gradient(to right,
#34d399 0%,
#10b981 var(--vol-fill),
rgba(255, 255, 255, 0.15) var(--vol-fill));
var(--c-accent) 0%,
var(--c-accent) var(--vol-fill),
var(--c-muted) var(--vol-fill));
}
.vol-slider::-webkit-slider-thumb {
@ -321,4 +279,4 @@ watch(
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
</style>
</style>