mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 10:48:05 +08:00
feat: 跨平台持久化与版本管理优化
- Cookie 存储从 temp_dir 迁移至 Tauri app_data_dir,兼容 Linux - 简单统一风格,UI优化 - recentLocal 播放历史持久化到 localStorage - 添加设置界面可以修改简单的设置
This commit is contained in:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user