Files
Thing/src/components/layout/TitleBarMusic.vue
T
2026-09-15 17:17:16 +08:00

437 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>