437 lines
14 KiB
Vue
437 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, 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 { createLogger } from '@/lib/logger'
|
||
import ScrubBar from '@/components/common/ScrubBar.vue'
|
||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||
|
||
const store = useFeiniuStore()
|
||
const logger = createLogger('music-widget')
|
||
|
||
const open = ref(false)
|
||
|
||
/** 悬停多久后打开播放控制窗 */
|
||
const HOVER_OPEN_DELAY = 300
|
||
/** 弹层是否由悬停打开:据此决定鼠标移出时是否自动关闭(点击打开则不自动关) */
|
||
let hoverOpened = false
|
||
let hoverTimer: ReturnType<typeof setTimeout> | null = null
|
||
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
||
/** 整库起播进行中(拉列表可能耗时,兼作重复点击保护) */
|
||
const starting = ref(false)
|
||
|
||
function cancelHoverTimer() {
|
||
if (hoverTimer) {
|
||
clearTimeout(hoverTimer)
|
||
hoverTimer = null
|
||
}
|
||
}
|
||
function cancelCloseTimer() {
|
||
if (closeTimer) {
|
||
clearTimeout(closeTimer)
|
||
closeTimer = null
|
||
}
|
||
}
|
||
|
||
/** 悬停 0.3s 打开控制窗(不依赖 Popover 默认的点击展开) */
|
||
function onTriggerEnter() {
|
||
cancelCloseTimer()
|
||
cancelHoverTimer()
|
||
hoverTimer = setTimeout(() => {
|
||
hoverTimer = null
|
||
if (open.value) return
|
||
hoverOpened = true
|
||
open.value = true
|
||
}, HOVER_OPEN_DELAY)
|
||
}
|
||
|
||
function onTriggerLeave() {
|
||
cancelHoverTimer()
|
||
// 悬停打开的:给鼠标从按钮移到弹层留一点余量
|
||
if (!open.value || !hoverOpened) return
|
||
cancelCloseTimer()
|
||
closeTimer = setTimeout(() => (open.value = false), 250)
|
||
}
|
||
|
||
function onContentEnter() {
|
||
cancelCloseTimer()
|
||
}
|
||
|
||
function onContentLeave() {
|
||
if (!hoverOpened) return
|
||
cancelCloseTimer()
|
||
closeTimer = setTimeout(() => (open.value = false), 180)
|
||
}
|
||
|
||
watch(open, (v) => {
|
||
if (v) return
|
||
cancelCloseTimer()
|
||
cancelHoverTimer()
|
||
hoverOpened = false
|
||
})
|
||
|
||
/** 点击标题:打开播放控制窗(点击打开的弹层不随鼠标移出自动关闭) */
|
||
function onTitleClick() {
|
||
cancelHoverTimer()
|
||
cancelCloseTimer()
|
||
hoverOpened = false
|
||
open.value = true
|
||
}
|
||
|
||
/**
|
||
* 点击**音乐图标**:播放 / 暂停。
|
||
* - 有播放上下文(队列非空)→ 播放 / 暂停;
|
||
* - 完全空载 → 依次尝试飞牛曲库**全部列表** → 本地曲库;两者皆空则不做任何反应。
|
||
*/
|
||
async function onIconClick() {
|
||
if (store.nowPlaying || store.queue.length) {
|
||
store.toggle()
|
||
return
|
||
}
|
||
if (starting.value) return
|
||
starting.value = true
|
||
try {
|
||
try {
|
||
await store.loadAllTracks()
|
||
} catch (e) {
|
||
// 未登录 / 网络不通:按「飞牛曲库为空」处理,继续尝试本地
|
||
logger.error(`加载飞牛曲库失败: ${e}`)
|
||
}
|
||
if (store.tracks.length) {
|
||
store.playQueue(store.tracks, 0)
|
||
return
|
||
}
|
||
try {
|
||
await store.scanLocal()
|
||
} catch (e) {
|
||
logger.error(`扫描本地曲库失败: ${e}`)
|
||
}
|
||
if (store.localTracks.length) store.playQueue(store.localTracks, 0)
|
||
} finally {
|
||
starting.value = 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()
|
||
}
|
||
)
|
||
|
||
// 应用启动即初始化音乐 store(幂等):恢复播放列表/播放进度并预载上次在播的曲目。
|
||
// 这样标题栏控件不必等用户先进「音乐库」页,也能显示并续播上次的歌。
|
||
onMounted(() => {
|
||
store.init().catch((e) => logger.error(`音乐初始化失败: ${e}`))
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<Popover v-model:open="open">
|
||
<PopoverAnchor as-child>
|
||
<!-- 音乐栏:图标 = 播放/暂停(悬停有遮罩),标题 = 打开控制窗;
|
||
悬停 0.3s 也会打开控制窗。容器本身不响应点击,避免与图标语义冲突 -->
|
||
<div
|
||
class="mr-2 flex h-7 max-w-[210px] items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-secondary/60"
|
||
@mouseenter="onTriggerEnter"
|
||
@mouseleave="onTriggerLeave"
|
||
@mousedown.stop
|
||
>
|
||
<!-- 唱片图标:点击开始/暂停 -->
|
||
<button
|
||
type="button"
|
||
class="group/icon relative flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full"
|
||
:aria-label="store.playing ? '暂停' : '播放'"
|
||
@click.stop="onIconClick"
|
||
>
|
||
<!-- 旋转动画只作用于这一层,否则遮罩图标会被带着一起转 -->
|
||
<span
|
||
class="disc flex size-5 items-center justify-center rounded-full ring-1 ring-border/50"
|
||
:class="{ 'is-playing': store.playing && !starting }"
|
||
>
|
||
<span
|
||
v-if="starting"
|
||
class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent text-muted-foreground"
|
||
/>
|
||
<img
|
||
v-else-if="coverUrl && !coverFailed"
|
||
:src="coverUrl"
|
||
class="size-full rounded-full object-cover"
|
||
alt=""
|
||
referrerpolicy="no-referrer"
|
||
@error="coverFailed = true"
|
||
/>
|
||
<Disc3 v-else class="size-4 text-muted-foreground" />
|
||
</span>
|
||
<!-- 播放态遮罩:悬停图标时浮现当前可执行的操作 -->
|
||
<span
|
||
v-if="!starting"
|
||
class="absolute inset-0 hidden items-center justify-center bg-foreground/50 text-primary-foreground group-hover/icon:flex"
|
||
>
|
||
<Pause v-if="store.playing" class="size-3" />
|
||
<Play v-else class="size-3 translate-x-px" />
|
||
</span>
|
||
</button>
|
||
|
||
<!-- 标题:点击打开播放控制窗 -->
|
||
<button
|
||
v-if="hasTrack"
|
||
type="button"
|
||
class="min-w-0 flex-1 cursor-pointer truncate text-left text-xs text-foreground/90"
|
||
:aria-label="`打开播放控制:${title}`"
|
||
@click.stop="onTitleClick"
|
||
>
|
||
{{ title }}
|
||
</button>
|
||
</div>
|
||
</PopoverAnchor>
|
||
|
||
<!-- 方形播放控制窗 -->
|
||
<PopoverContent
|
||
align="end"
|
||
:side-offset="8"
|
||
class="w-[300px] p-3"
|
||
@mouseenter="onContentEnter"
|
||
@mouseleave="onContentLeave"
|
||
@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>
|