mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-08-06 03:44:18 +08:00
- 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画) - 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索 - 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸) - 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择) - 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar) - 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表 - audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store - 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等
517 lines
22 KiB
Vue
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>
|