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