Files
Nekosonic-Music/src/components/PlayerBar.vue
T
atdunbg 263e9b31d9 feat: v0.8.0 详情页架构重构、皮肤系统、侧边栏交互升级、底层模块拆分
- 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画)
- 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索
- 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸)
- 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择)
- 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar)
- 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表
- audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store
- 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等
2026-06-26 21:05:51 +08:00

517 lines
22 KiB
Vue

<template>
<div
class="fixed bottom-0 left-0 right-0 z-50 select-none backdrop-blur-xl"
:style="playerBarBgStyle"
>
<div v-if="ui.dominantColor"
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
:style="{ backgroundColor: ui.dominantColor }"
>
<div class="absolute inset-0 bg-black/60"></div>
</div>
<div ref="progressBar" class="w-full h-1.5 relative group cursor-pointer overflow-visible"
:class="drawerActive ? 'bg-white/10' : 'bg-muted'"
@mousedown.prevent="startSeek">
<div class="absolute left-0 top-0 h-full rounded-full" :class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" :style="{ width: cacheProgress + '%' }"></div>
<div class="absolute left-0 top-0 h-full rounded-full bg-accent"
:style="{ width: displayProgress + '%' }"></div>
<div
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg border border-line 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 relative z-10">
<div class="flex items-center gap-3 w-56 min-w-0">
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="ui.toggleRoamDrawer()" title="全屏展示">
<img :src="getCoverUrl(player.currentSong)" class="w-full h-full object-cover" />
</div>
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="ui.toggleRoamDrawer()" title="全屏展示"
:class="drawerActive ? 'bg-white/10' : 'bg-muted'">
<IconMusic class="w-[18px] h-[18px]" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate" :class="drawerActive ? 'text-white' : ''">{{ player.currentSong?.name }}</p>
<p class="text-xs truncate" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
<template v-for="(a, i) in player.currentSong?.ar || []" :key="a.id || i">
<span v-if="i > 0" :class="drawerActive ? 'text-white/40' : 'text-content-3'">/</span>
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
</template>
<template v-if="player.currentSong?.al?.name">
<span :class="drawerActive ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="player.currentSong!.al.id && router.push({ name: 'album', params: { id: player.currentSong!.al.id } })">{{ player.currentSong.al.name }}</span>
</template>
</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' : (drawerActive ? 'text-white/50 hover:text-danger' : 'text-content-3 hover:text-danger')">
<IconHeart v-if="player.currentSong && player.isLiked(player.currentSong.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
<IconHeart v-else class="w-4 h-4" />
</button>
<button v-if="player.currentSong" @click="shareSong(player.currentSong.id)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="分享">
<IconShare2 class="w-4 h-4" />
</button>
<button v-if="player.currentSong && !download.isDownloaded(player.currentSong!.id) && !download.isDownloading(player.currentSong!.id)" @click="download.downloadSong(player.currentSong)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="下载">
<IconDownload class="w-4 h-4" />
</button>
<IconLoader2 v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" class="w-4 h-4 flex-shrink-0 animate-spin" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
</div>
<div class="flex-1 flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-5 relative">
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
'transition',
player.isFmMode ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
]">
<IconSkipBack class="w-5 h-5" />
</button>
<button @click="player.toggle()"
class="w-9 h-9 flex items-center justify-center rounded-full transition"
:class="drawerActive ? 'bg-white/15 hover:bg-white/25 border border-white/20' : 'bg-muted hover:bg-emphasis border border-emphasis'">
<IconPause v-if="player.playing" class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
<IconPlay v-else class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
</button>
<button @click="player.next()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
<IconSkipForward class="w-5 h-5" />
</button>
<button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" class="absolute left-full ml-5" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐">
<IconHeartOff class="w-[18px] h-[18px]" />
</button>
</div>
<div class="flex items-center gap-2 text-xs" :class="drawerActive ? 'text-white/70' : '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">
<button @click="toggleMute" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
<IconVolumeX v-if="player.volume === 0" class="w-[18px] h-[18px]" />
<IconVolume2 v-else class="w-[18px] h-[18px]" />
</button>
<div class="relative w-20 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="player.volume"
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer outline-none"
:class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" />
</div>
</div>
<button @click="togglePlayMode" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" :title="modeTitle">
<IconRepeat v-if="player.playMode === 'loop'" class="w-[18px] h-[18px]" />
<IconShuffle v-else-if="player.playMode === 'shuffle'" class="w-[18px] h-[18px]" />
<IconRepeat1 v-else class="w-[18px] h-[18px]" />
</button>
<button @click="showQueuePanel = !showQueuePanel"
:class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" title="播放列表">
<IconListMusic class="w-[18px] h-[18px]" />
</button>
</div>
</div>
<Teleport to="body">
<Transition name="queue-fade">
<div v-if="showDislikeModal && player.currentSong" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDislikeModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-5 select-auto">
<h2 class="text-base font-semibold text-content mb-1">减少推荐</h2>
<p class="text-xs text-content-3 mb-4">选择要减少的推荐类型</p>
<div class="flex flex-col gap-2 mb-4">
<button @click="dislikeSong"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
<IconMusic class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
<div>
<p class="text-sm font-medium">不推荐这首歌曲</p>
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ player.currentSong.name }}</p>
</div>
</button>
<button v-if="dislikeArtistName" @click="dislikeArtist"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
<IconUserRound class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
<div>
<p class="text-sm font-medium">减少含此歌手的推荐</p>
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ dislikeArtistName }}</p>
</div>
</button>
</div>
<button @click="showDislikeModal = false"
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
</div>
</div>
</Transition>
<Transition name="queue-fade">
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
</Transition>
<Transition name="queue-slide">
<div v-if="showQueuePanel"
class="fixed right-0 top-0 bottom-0 z-[56] w-[340px] bg-base/95 backdrop-blur border-l border-line flex flex-col shadow-2xl shadow-black/40">
<div class="px-5 pt-5 pb-3">
<div class="flex items-center justify-between">
<div>
<h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
<p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
</div>
<div class="flex items-center gap-1">
<button @click="player.clearQueue()"
class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
清空
</button>
<button @click="showQueuePanel = false"
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
<IconX class="w-4 h-4" />
</button>
</div>
</div>
</div>
<div class="h-px mx-5 bg-line"></div>
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
<IconMusic class="w-10 h-10 opacity-40" />
<p class="text-sm">播放列表为空</p>
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
</div>
<SongListItem
v-for="(song, idx) in player.queue" :key="song.id + '-' + idx"
:id="'queue-item-' + idx"
:song="song"
:index="idx"
:is-current="idx === player.currentIndex"
show-playing-overlay
cover-size="w-9 h-9"
cover-size-param="?param=80y80"
:container-class="idx === player.currentIndex ? 'bg-muted hover:bg-muted gap-3 px-3 py-2 rounded-lg' : 'hover:bg-subtle gap-3 px-3 py-2 rounded-lg'"
@click="playFromQueue(idx)"
>
<template #actions>
<button @click.stop="player.removeFromQueue(idx)"
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
<IconX class="w-3 h-3" />
</button>
</template>
</SongListItem>
<div class="h-2"></div>
<button v-if="player.currentIndex >= 0 && player.queue.length > 0" v-show="!currentSongVisible"
@click="scrollToCurrent"
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
title="定位到正在播放">
<IconCrosshair class="w-[18px] h-[18px]" />
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
import { usePlayerStore } from '../stores/player';
import type { PlayMode } from '../types/song';
import { useUiStore } from '../stores/ui';
import { useSettingsStore } from '../stores/settings';
import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format';
import { getCoverUrl } from '../utils/song';
import { AudioApi } from '../api';
import { showToast } from '../composables/useToast';
import { listen } from '@tauri-apps/api/event';
import { useRouter } from 'vue-router';
import SongListItem from './SongListItem.vue';
import IconSkipBack from '~icons/lucide/skip-back';
import IconPlay from '~icons/lucide/play';
import IconPause from '~icons/lucide/pause';
import IconSkipForward from '~icons/lucide/skip-forward';
import IconHeartOff from '~icons/lucide/heart-off';
import IconVolumeX from '~icons/lucide/volume-x';
import IconVolume2 from '~icons/lucide/volume-2';
import IconRepeat from '~icons/lucide/repeat';
import IconShuffle from '~icons/lucide/shuffle';
import IconRepeat1 from '~icons/lucide/repeat-1';
import IconListMusic from '~icons/lucide/list-music';
import IconDownload from '~icons/lucide/download';
import IconLoader2 from '~icons/lucide/loader-2';
import IconHeart from '~icons/lucide/heart';
import IconX from '~icons/lucide/x';
import IconMusic from '~icons/lucide/music';
import IconCrosshair from '~icons/lucide/crosshair';
import IconUserRound from '~icons/lucide/user-round';
import IconShare2 from '~icons/lucide/share-2';
import { hexToRgba } from '../utils/color';
const router = useRouter();
const player = usePlayerStore();
const ui = useUiStore();
const settings = useSettingsStore();
const download = useDownload();
const drawerActive = computed(() => ui.showRoamDrawer && !!ui.dominantColor);
// PlayerBar 背景:有壁纸时用 --c-bg 高不透明度(与遮罩层同色系,视觉融合),
// 无壁纸时用 surface 色
const playerBarBgStyle = computed(() => {
if (settings.currentWallpaper.path) {
const bgColor = settings.currentColors.bg;
const rgba = hexToRgba(bgColor, 0.82);
return { backgroundColor: rgba };
}
return { backgroundColor: settings.currentColors.surface };
});
const showQueuePanel = ref(false);
const showDislikeModal = ref(false);
const queueListEl = ref<HTMLElement | null>(null);
const currentSongVisible = ref(true);
const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false);
const previewTime = ref(0);
const cacheProgress = ref(0);
const prevVolume = ref(100);
let unlistenCache: (() => void) | null = null;
onMounted(async () => {
const fn = await listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
});
unlistenCache = fn;
});
onBeforeUnmount(() => {
if (unlistenCache) unlistenCache();
});
watch(() => player.currentSong, (song) => {
if (!song) {
cacheProgress.value = 0;
} else if (song.localPath) {
cacheProgress.value = 100;
} else {
cacheProgress.value = 0;
}
});
const modeTexts: Record<PlayMode, string> = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
function togglePlayMode() {
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
const next = modes[(modes.indexOf(player.playMode) + 1) % modes.length];
player.setPlayMode(next);
}
function toggleMute() {
if (player.volume > 0) {
prevVolume.value = player.volume;
player.volume = 0;
} else {
player.volume = prevVolume.value || 100;
}
AudioApi.setVolume(player.volume / 100);
}
let onDocMove: ((e: MouseEvent) => void) | null = null;
let onDocUp: (() => void) | null = null;
function startSeek(e: MouseEvent) {
isSeeking.value = true;
updatePreview(e);
onDocMove = (ev: MouseEvent) => updatePreview(ev);
onDocUp = () => finishSeek();
document.addEventListener('mousemove', onDocMove);
document.addEventListener('mouseup', onDocUp);
}
function updatePreview(e: MouseEvent) {
const bar = progressBar.value;
if (!bar || player.duration === 0) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
previewTime.value = ratio * player.duration;
}
function finishSeek() {
if (!isSeeking.value) return;
isSeeking.value = false;
if (player.duration > 0) {
player.seek(previewTime.value);
}
if (onDocMove) document.removeEventListener('mousemove', onDocMove);
if (onDocUp) document.removeEventListener('mouseup', onDocUp);
onDocMove = null;
onDocUp = null;
}
onBeforeUnmount(() => {
if (onDocMove) document.removeEventListener('mousemove', onDocMove);
if (onDocUp) document.removeEventListener('mouseup', onDocUp);
});
const previewPercent = computed(() => {
if (!player.duration || player.duration === 0) return 0;
return (previewTime.value / player.duration) * 100;
});
const progressPercent = computed(() => {
if (!player.duration || player.duration === 0) return 0;
return (player.currentTime / player.duration) * 100;
});
const displayProgress = computed(() => {
return isSeeking.value ? previewPercent.value : progressPercent.value;
});
function playFromQueue(index: number) {
player.currentIndex = index;
player.playCurrent();
}
const dislikeArtistName = computed(() => {
const song = player.currentSong;
if (!song?.ar?.length) return '';
return song.ar.map(a => a.name).join(' / ');
});
async function dislikeSong() {
if (!player.currentSong) return;
showDislikeModal.value = false;
await player.fmTrash(player.currentSong.id);
showToast('已减少该歌曲推荐', 'success');
}
async function dislikeArtist() {
if (!player.currentSong) return;
showDislikeModal.value = false;
await player.fmTrash(player.currentSong.id);
showToast('已减少含该歌手的推荐', 'success');
}
async function shareSong(id: number) {
const url = `https://music.163.com/song?id=${id}`;
try {
await navigator.clipboard.writeText(url);
showToast('链接已复制', 'success');
} catch {
showToast('复制失败', 'error');
}
}
function scrollToCurrent() {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
let currentSongObserver: IntersectionObserver | null = null;
function setupCurrentSongObserver() {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
nextTick(() => {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (!el || !queueListEl.value) return;
currentSongObserver = new IntersectionObserver(
([entry]) => { currentSongVisible.value = entry.isIntersecting; },
{ root: queueListEl.value, threshold: 0.5 }
);
currentSongObserver.observe(el);
});
}
watch(() => [showQueuePanel.value, player.currentIndex, player.queue.length], () => {
if (showQueuePanel.value) {
setupCurrentSongObserver();
} else {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
currentSongVisible.value = true;
}
});
onBeforeUnmount(() => {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
});
async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10);
player.volume = val;
await AudioApi.setVolume(val / 100);
}
const volumeBarBg = computed(() => {
const pct = player.volume;
const track = drawerActive.value ? 'rgba(255,255,255,0.2)' : 'var(--c-muted)';
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, ${track} ${pct}%)`;
});
</script>
<style scoped>
.vol-slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
width: 100%;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.vol-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right,
var(--c-accent) 0%,
var(--c-accent) var(--vol-fill),
var(--c-muted) var(--vol-fill));
}
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
margin-top: -3px;
cursor: pointer;
transition: transform 0.15s;
}
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.queue-fade-enter-active,
.queue-fade-leave-active {
transition: opacity 0.2s ease;
}
.queue-fade-enter-from,
.queue-fade-leave-to {
opacity: 0;
}
.queue-slide-enter-active,
.queue-slide-leave-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.queue-slide-enter-from,
.queue-slide-leave-to {
transform: translateX(100%);
}
</style>