音乐模块调整

This commit is contained in:
2026-09-12 15:47:16 +08:00
parent d702ed0d31
commit ffa410b399
33 changed files with 5452 additions and 2237 deletions
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import {
Disc3,
ListMusic,
Maximize2,
MicVocal,
Pause,
Play,
Repeat,
Repeat1,
Shuffle,
SkipBack,
SkipForward,
Volume2,
VolumeX
} from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import ScrubBar from '@/components/common/ScrubBar.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const store = useFeiniuStore()
const open = ref(false)
const coverUrl = computed(() => {
const t = store.nowPlaying
if (!t) return ''
if (t.coverUrl) return t.coverUrl
if (t.source === 'feiniu' && t.coverId && store.mediaPrefix) {
return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(t.coverId)}&size=96`
}
return ''
})
const bigCoverUrl = computed(() => {
const t = store.nowPlaying
if (!t) return ''
if (t.coverUrl) return t.coverUrl
if (t.source === 'feiniu' && t.coverId && store.mediaPrefix) {
return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(t.coverId)}&size=320`
}
return ''
})
const coverFailed = ref(false)
watch(coverUrl, () => (coverFailed.value = false))
/** 是否已播放过(未播放过时不展示歌名,只留唱片图标) */
const hasTrack = computed(() => !!store.nowPlaying)
const title = computed(() => store.nowPlaying?.title ?? '')
const subtitle = computed(() => {
const t = store.nowPlaying
if (!t) return ''
return t.artistNames || (t.source === 'local' ? '本地文件' : '')
})
const isPreview = computed(() => store.nowPlaying?.source === 'preview')
const modeIcon = computed(() => {
if (store.playMode === 'loopOne') return Repeat1
if (store.playMode === 'shuffle') return Shuffle
return Repeat
})
const modeLabel = computed(() =>
store.playMode === 'loopAll' ? '列表循环' : store.playMode === 'loopOne' ? '单曲循环' : '随机播放'
)
const volumePercent = computed(() => (store.muted ? 0 : store.volume))
function onSeek(ratio: number) {
store.seek(ratio * store.duration)
}
function openLyrics(tab: 'lyric' | 'queue') {
store.nowPlayingTab = tab
store.nowPlayingOpen = true
open.value = false
}
// 播放失败统一在这里提示(store 只记录原因)
watch(
() => store.playError,
(msg) => {
if (!msg) return
toast.error(msg)
store.clearPlayError()
}
)
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<!-- 音乐栏唱片图标 + 歌名未播放过时只显示图标 -->
<button
type="button"
class="mr-2 flex h-7 max-w-[210px] items-center gap-2 rounded-md px-1.5 text-left transition-colors hover:bg-secondary/60"
:title="hasTrack ? `${title}${subtitle ? ' · ' + subtitle : ''}` : '未在播放'"
:aria-label="hasTrack ? `音乐控制${title}` : '音乐控制'"
@mousedown.stop
>
<span
class="disc flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full"
:class="{ 'is-playing': store.playing }"
>
<img
v-if="coverUrl && !coverFailed"
:src="coverUrl"
class="size-full object-cover"
alt=""
referrerpolicy="no-referrer"
@error="coverFailed = true"
/>
<Disc3 v-else class="size-4 text-muted-foreground" />
</span>
<span v-if="hasTrack" class="min-w-0 flex-1 truncate text-xs text-foreground/90">
{{ title }}
</span>
</button>
</PopoverTrigger>
<!-- 方形播放控制窗 -->
<PopoverContent
align="end"
:side-offset="8"
class="w-[300px] p-3"
@mousedown.stop
>
<div class="flex flex-col items-center gap-3">
<!-- 封面 -->
<button
type="button"
class="group relative aspect-square w-[132px] overflow-hidden rounded-lg bg-muted ring-1 ring-border/60"
:title="'打开歌词与大屏'"
@click="openLyrics('lyric')"
>
<img
v-if="bigCoverUrl"
:src="bigCoverUrl"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
alt=""
referrerpolicy="no-referrer"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<span v-else class="flex size-full items-center justify-center text-muted-foreground">
<Disc3 class="size-10" />
</span>
<span
class="absolute inset-0 hidden items-center justify-center bg-foreground/35 text-primary-foreground group-hover:flex"
>
<Maximize2 class="size-5" />
</span>
</button>
<!-- 曲目信息 -->
<div class="w-full min-w-0 text-center">
<div class="flex items-center justify-center gap-1.5">
<span class="truncate text-[13px] font-medium">{{ title || '未播放' }}</span>
<span
v-if="isPreview"
class="shrink-0 rounded bg-muted px-1 py-px text-[10px] text-muted-foreground"
>
试听
</span>
</div>
<p class="mt-0.5 truncate text-[11.5px] text-muted-foreground">
{{ subtitle || '—' }}<template v-if="store.nowPlaying?.album"> · {{ store.nowPlaying.album }}</template>
</p>
</div>
<!-- 进度 -->
<ScrubBar
class="w-full"
:value="store.progress / 100"
:buffered="store.bufferedPercent / 100"
:duration-sec="store.duration"
:disabled="!hasTrack"
label="播放进度"
@seek="onSeek"
/>
<!-- 控制 -->
<div class="flex w-full items-center justify-center gap-5">
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:title="modeLabel"
:aria-label="modeLabel"
@click="store.togglePlayMode()"
>
<component :is="modeIcon" class="size-3.5" />
</button>
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
:disabled="!store.queue.length"
aria-label="上一首"
@click="store.prev()"
>
<SkipBack class="size-5" />
</button>
<button
type="button"
class="flex size-10 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
:disabled="!hasTrack"
:aria-label="store.playing ? '暂停' : '播放'"
@click="store.toggle()"
>
<span
v-if="store.loadingPlay"
class="size-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
<Pause v-else-if="store.playing" class="size-4" />
<Play v-else class="size-4 translate-x-px" />
</button>
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
:disabled="!store.queue.length"
aria-label="下一首"
@click="store.next()"
>
<SkipForward class="size-5" />
</button>
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
:disabled="isPreview"
:title="`播放队列(${store.queue.length}`"
aria-label="播放队列"
@click="openLyrics('queue')"
>
<ListMusic class="size-3.5" />
</button>
</div>
<!-- 音量 + 入口 -->
<div class="flex w-full items-center gap-2 border-t pt-2.5">
<button
type="button"
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
:aria-label="store.muted ? '取消静音' : '静音'"
@click="store.toggleMute()"
>
<VolumeX v-if="volumePercent === 0" class="size-3.5" />
<Volume2 v-else class="size-3.5" />
</button>
<div class="w-16 shrink-0">
<ScrubBar compact :value="volumePercent" label="音量" @seek="store.setVolume($event)" />
</div>
<div class="ml-auto flex items-center gap-1">
<button
type="button"
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-[11.5px] text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground"
@click="openLyrics('lyric')"
>
<MicVocal class="size-3.5" />
歌词
</button>
<button
type="button"
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-[11.5px] text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground"
:disabled="isPreview"
@click="openLyrics('queue')"
>
<ListMusic class="size-3.5" />
队列
</button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>
<style scoped>
/* 唱片图标:播放时旋转(暂停保持当前角度继续待命) */
.disc {
animation: discSpin 8s linear infinite;
animation-play-state: paused;
background: var(--muted);
}
.disc.is-playing {
animation-play-state: running;
}
@keyframes discSpin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.disc {
animation: none;
}
}
</style>