音乐模块调整
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
Disc3,
|
||||
@@ -17,13 +17,116 @@ import {
|
||||
VolumeX
|
||||
} from '@lucide/vue'
|
||||
import { useFeiniuStore } from '@/stores/feiniuStore'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import ScrubBar from '@/components/common/ScrubBar.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
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 ''
|
||||
@@ -84,44 +187,81 @@ watch(
|
||||
store.clearPlayError()
|
||||
}
|
||||
)
|
||||
|
||||
// 应用启动即初始化音乐 store(幂等):恢复播放列表/播放进度并预载上次在播的曲目。
|
||||
// 这样标题栏控件不必等用户先进「音乐库」页,也能显示并续播上次的歌。
|
||||
onMounted(() => {
|
||||
store.init().catch((e) => logger.error(`音乐初始化失败: ${e}`))
|
||||
})
|
||||
</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}` : '音乐控制'"
|
||||
<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
|
||||
>
|
||||
<span
|
||||
class="disc flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full"
|
||||
:class="{ 'is-playing': store.playing }"
|
||||
<!-- 唱片图标:点击开始/暂停 -->
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user