321 lines
13 KiB
Vue
321 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { ListMusic, Music2, Pause, Play, 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="`播放队列(${store.queue.length})`"
|
||
:title="`播放队列(${store.queue.length})`"
|
||
@click="store.nowPlayingTab = 'queue'"
|
||
>
|
||
<ListMusic 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>
|