音乐模块调整
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user