音乐模块调整

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
+319
View File
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Music2, Pause, Play, Plus, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Trash2, X } from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import ScrubBar from '@/components/common/ScrubBar.vue'
import SegmentedNav from '@/components/common/SegmentedNav.vue'
import { Dialog, DialogContent, DialogClose, DialogTitle } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const store = useFeiniuStore()
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=400`
}
return ''
})
const coverFailed = ref(false)
watch(coverUrl, () => (coverFailed.value = false))
const isPreview = computed(() => store.nowPlaying?.source === 'preview')
const hasTrack = computed(() => !!store.nowPlaying)
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 tabs = computed(() => [
{ value: 'lyric', label: '歌词' },
{ value: 'queue', label: `队列` }
])
const activeTab = computed({
get: () => store.nowPlayingTab,
set: (v: string) => (store.nowPlayingTab = v === 'queue' ? 'queue' : 'lyric')
})
const currentLineIdx = computed(() => store.currentLine())
// ===== 歌词滚动跟随(用函数 ref 收集节点,避免每次全量 querySelectorAll =====
const lineEls: (HTMLElement | null)[] = []
function setLineEl(el: Element | { $el?: Element } | null, idx: number) {
lineEls[idx] = (el as HTMLElement) ?? null
}
const reduceMotion = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
watch(currentLineIdx, (idx) => {
if (idx < 0) return
lineEls[idx]?.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' })
})
watch(
() => store.lyricLines,
() => {
lineEls.length = 0
}
)
// ===== 队列:大列表增量渲染(避免上千行一次性挂载) =====
const QUEUE_STEP = 200
const queueLimit = ref(QUEUE_STEP)
watch(
() => store.queue.length,
() => (queueLimit.value = QUEUE_STEP)
)
const visibleQueue = computed(() =>
store.queue.slice(0, queueLimit.value).map((item, index) => ({ item, index }))
)
const hasMoreQueue = computed(() => store.queue.length > queueLimit.value)
function onSeek(ratio: number) {
store.seek(ratio * store.duration)
}
function playAt(index: number) {
const item = store.queue[index]
if (item) store.playItem(item)
}
/** 暂停/继续当前曲目(不切换上下文) */
function toggleAt(index: number) {
if (index === store.queueIndex) store.toggle()
else playAt(index)
}
</script>
<template>
<Dialog v-model:open="store.nowPlayingOpen">
<DialogContent
class="max-w-5xl overflow-hidden border p-0 sm:max-w-5xl"
:show-close-button="false"
>
<DialogTitle class="sr-only">正在播放</DialogTitle>
<div class="relative flex h-[min(78vh,600px)] flex-col">
<!-- 背景主题色轻渐变不使用整图高斯模糊避免持续 GPU 开销 -->
<div
class="pointer-events-none absolute inset-0"
style="
background:
radial-gradient(120% 90% at 12% 0%, color-mix(in oklab, var(--muted) 92%, transparent), transparent 62%),
linear-gradient(180deg, color-mix(in oklab, var(--muted) 55%, transparent), var(--background) 72%);
"
/>
<div class="relative flex min-h-0 flex-1">
<!-- ===== 封面 + 控制 ===== -->
<div class="flex w-[46%] min-w-0 flex-col justify-center gap-5 px-9 py-8">
<div class="flex justify-center">
<img
v-if="coverUrl && !coverFailed"
:src="coverUrl"
class="aspect-square w-[min(240px,60%)] rounded-xl object-cover ring-1 ring-border/60"
alt=""
referrerpolicy="no-referrer"
@error="coverFailed = true"
/>
<div
v-else
class="flex aspect-square w-[min(240px,60%)] items-center justify-center rounded-xl bg-muted/60 text-muted-foreground ring-1 ring-border/60"
>
<Music2 class="size-16" />
</div>
</div>
<div class="min-w-0 text-center">
<div class="flex items-center justify-center gap-2">
<h3 class="truncate text-[19px] font-semibold">{{ store.nowPlaying?.title || '未播放' }}</h3>
<span
v-if="isPreview"
class="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
试听
</span>
</div>
<p class="mt-1 truncate text-[13px] text-muted-foreground">
{{ store.nowPlaying?.artistNames || '—'
}}<template v-if="store.nowPlaying?.album"> · {{ store.nowPlaying.album }}</template>
</p>
</div>
<div class="flex flex-col gap-2">
<ScrubBar
:value="store.progress / 100"
:buffered="store.bufferedPercent / 100"
:duration-sec="store.duration"
:disabled="!hasTrack"
label="播放进度"
@seek="onSeek"
/>
<div class="mt-1 flex items-center justify-center gap-6">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="modeLabel"
@click="store.togglePlayMode()"
>
<component :is="modeIcon" class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ modeLabel }}</TooltipContent>
</Tooltip>
<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-6" />
</button>
<button
type="button"
class="flex size-14 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-5 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
<Pause v-else-if="store.playing" class="size-6" />
<Play v-else class="size-6 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-6" />
</button>
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:class="{ 'text-foreground': store.nowPlayingTab === 'queue' }"
aria-label="播放队列"
@click="store.nowPlayingTab = 'queue'"
>
<Plus class="size-4" />
</button>
</div>
</div>
</div>
<!-- ===== 歌词 / 队列 ===== -->
<div class="flex min-w-0 flex-1 flex-col border-l border-border px-6 py-7">
<div class="mb-4 flex items-center">
<SegmentedNav v-model="activeTab" :items="tabs" />
</div>
<ScrollArea class="min-h-0 flex-1">
<div v-if="activeTab === 'lyric'" class="flex flex-col gap-0.5 px-1 pb-6" data-slot="lyric-list">
<p v-if="!store.lyricLines.length" class="py-8 text-center text-[13px] text-muted-foreground">
{{ isPreview ? '试听不加载歌词' : store.nowPlaying?.source === 'local' ? '本地文件无歌词' : '暂无歌词' }}
</p>
<button
v-for="(line, idx) in store.lyricLines"
:key="idx"
:ref="(el) => setLineEl(el as Element | null, idx)"
type="button"
class="rounded-md px-3 py-1.5 text-left text-[14px] leading-7 transition-all duration-200"
:class="
idx === currentLineIdx
? 'bg-accent/60 text-[16px] font-medium text-foreground'
: idx < currentLineIdx
? 'text-muted-foreground/45 hover:text-muted-foreground'
: 'text-muted-foreground/75 hover:text-foreground'
"
@click="store.seek(line.t)"
>
{{ line.text }}
</button>
</div>
<div v-else class="flex flex-col gap-0.5 px-1 pb-6">
<div v-if="!store.queue.length" class="py-8 text-center text-[13px] text-muted-foreground">
队列为空
</div>
<div
v-for="row in visibleQueue"
:key="`${row.item.source}:${row.item.guid ?? row.index}`"
class="group flex items-center gap-3 rounded-md px-2 py-1.5 transition-colors"
:class="row.index === store.queueIndex && !isPreview ? 'bg-accent/60' : 'hover:bg-accent/40'"
>
<button
type="button"
class="flex size-6 shrink-0 items-center justify-center text-[11px] tabular-nums text-muted-foreground"
:aria-label="row.index === store.queueIndex ? '暂停/继续' : '播放'"
@click="toggleAt(row.index)"
>
<span v-if="row.index === store.queueIndex && !isPreview" class="music-eq" :class="{ 'is-paused': !store.playing }">
<i /><i /><i />
</span>
<template v-else>
<Play class="size-3.5 opacity-0 transition-opacity group-hover:opacity-100" />
<span class="group-hover:hidden">{{ row.index + 1 }}</span>
</template>
</button>
<div class="min-w-0 flex-1">
<div
class="truncate text-[13px]"
:class="row.index === store.queueIndex && !isPreview ? 'font-medium' : ''"
>
{{ row.item.title }}
</div>
<div class="truncate text-[11.5px] text-muted-foreground">{{ row.item.artistNames || '—' }}</div>
</div>
<button
type="button"
class="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
aria-label="从队列移除"
@click="store.removeFromQueue(row.index)"
>
<Trash2 class="size-3.5" />
</button>
</div>
<button
v-if="hasMoreQueue"
type="button"
class="mt-1 rounded-md py-2 text-[12px] text-muted-foreground hover:bg-accent/40"
@click="queueLimit += QUEUE_STEP"
>
显示更多{{ store.queue.length - queueLimit }}
</button>
</div>
</ScrollArea>
</div>
</div>
<DialogClose as-child>
<button
type="button"
class="absolute right-4 top-4 z-10 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="关闭"
>
<X class="size-5" />
</button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
</template>