音乐模块调整

This commit is contained in:
2026-09-12 15:47:16 +08:00
parent d702ed0d31
commit ffa410b399
33 changed files with 5452 additions and 2237 deletions
+8 -1
View File
@@ -5,8 +5,10 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import TitleBar from '@/components/layout/TitleBar.vue'
import Sidebar from '@/components/layout/Sidebar.vue'
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import NowPlayingDialog from '@/components/layout/NowPlayingDialog.vue'
import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { useScreenshotStore } from '@/stores/screenshotStore'
import { useQuickPanelStore } from '@/stores/quickpanelStore'
import { useMonitorStore } from '@/stores/monitorStore'
@@ -19,6 +21,7 @@ import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
import { commands } from '@/lib/bindings'
const appStore = useAppStore()
const feiniuStore = useFeiniuStore()
const screenshotStore = useScreenshotStore()
const quickpanelStore = useQuickPanelStore()
const monitorStore = useMonitorStore()
@@ -197,6 +200,9 @@ const resolveDefaultModule = (): string => {
onMounted(async () => {
await appStore.init().catch(e => console.error('App init error:', e))
// 全局播放器:恢复队列/音量/播放模式等本地状态(幂等,音乐模块内会再次调用但不会重复执行)
feiniuStore.init().catch(e => console.error('播放器初始化失败:', e))
// 初始化监控 store:订阅后端 monitor-data / monitor-network 等事件,
// 使 OSD 窗口在应用启动后即可接收数据流,不依赖用户手动打开监控模块。
// init() 幂等:MonitorModule 挂载时再次调用不会重复订阅。
@@ -283,7 +289,7 @@ onUnmounted(() => {
<TooltipProvider>
<div class="flex flex-col h-screen w-screen overflow-hidden">
<TitleBar :modules="availableModules" @search="handleSearch" />
<div class="flex-1 flex overflow-hidden">
<div class="flex-1 flex overflow-hidden min-h-0">
<Sidebar
:modules="availableModules"
:active-module="activeModule"
@@ -292,6 +298,7 @@ onUnmounted(() => {
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" :loading="moduleLoading" />
</div>
</div>
<NowPlayingDialog />
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
</TooltipProvider>
</template>
+293
View File
@@ -0,0 +1,293 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
/**
* 通用拖拽进度条。
*
* 关键行为:拖动过程中**不**向外提交,只更新本地预览值;
* 指针抬起(或键盘操作)时才 emit `seek`。
* 这样流式音频不会因为拖动而反复中断并重建 Range 请求。
*/
const props = withDefaults(
defineProps<{
/** 当前进度 0~1 */
value: number
/** 已缓冲比例 0~1 */
buffered?: number
/** 总秒数:>0 时显示两侧时间与拖动气泡 */
durationSec?: number
/** 紧凑模式(音量条):细轨道、无时间、无气泡 */
compact?: boolean
disabled?: boolean
/** 无障碍标签 */
label?: string
}>(),
{ buffered: 0, durationSec: 0, compact: false, disabled: false, label: '进度' }
)
const emit = defineEmits<{ seek: [ratio: number] }>()
const rootRef = ref<HTMLElement | null>(null)
const dragging = ref(false)
const dragRatio = ref(0)
const hoverRatio = ref<number | null>(null)
function clamp(v: number) {
return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))
}
function pct(v: number) {
return `${clamp(v) * 100}%`
}
const activeRatio = computed(() => (dragging.value ? dragRatio.value : clamp(props.value)))
const bubbleRatio = computed(() => (dragging.value ? dragRatio.value : hoverRatio.value))
const showTime = computed(() => !props.compact && props.durationSec > 0)
function ratioFromClientX(clientX: number) {
const el = rootRef.value
if (!el) return 0
const r = el.getBoundingClientRect()
if (r.width <= 0) return 0
return clamp((clientX - r.left) / r.width)
}
function fmt(sec: number) {
if (!Number.isFinite(sec) || sec < 0) return '--:--'
const s = Math.floor(sec)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
}
function onDown(e: PointerEvent) {
if (props.disabled) return
const el = e.currentTarget as HTMLElement
dragging.value = true
dragRatio.value = ratioFromClientX(e.clientX)
try {
el.focus({ preventScroll: true })
el.setPointerCapture(e.pointerId)
} catch {
/* 部分环境不支持指针捕获/聚焦,降级为普通拖动 */
}
e.preventDefault()
}
function onMove(e: PointerEvent) {
const r = ratioFromClientX(e.clientX)
hoverRatio.value = r
if (dragging.value) dragRatio.value = r
}
function onUp(e: PointerEvent) {
if (!dragging.value) return
dragging.value = false
dragRatio.value = ratioFromClientX(e.clientX)
emit('seek', dragRatio.value)
}
function onLeave() {
if (!dragging.value) hoverRatio.value = null
}
function onKey(e: KeyboardEvent) {
if (props.disabled) return
const step = e.shiftKey ? 0.1 : 0.02
let next: number | null = null
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clamp(props.value + step)
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clamp(props.value - step)
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = 1
if (next !== null) {
emit('seek', next)
e.preventDefault()
}
}
</script>
<template>
<div
class="scrub"
:class="{ 'is-compact': compact, 'is-dragging': dragging, 'is-disabled': disabled }"
>
<span v-if="showTime" class="scrub-time">{{ fmt(activeRatio * durationSec) }}</span>
<div
ref="rootRef"
class="scrub-track"
role="slider"
:tabindex="disabled ? -1 : 0"
:aria-label="label"
:aria-valuenow="Math.round(activeRatio * 100)"
aria-valuemin="0"
aria-valuemax="100"
:aria-disabled="disabled"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
@pointerleave="onLeave"
@keydown="onKey"
>
<span class="scrub-buffered" :style="{ width: pct(buffered) }" />
<span class="scrub-played" :style="{ width: pct(activeRatio) }" />
<span class="scrub-thumb" :style="{ left: pct(activeRatio) }" />
<span
v-if="showTime && bubbleRatio !== null"
class="scrub-bubble"
:style="{ left: pct(bubbleRatio) }"
>
{{ fmt(bubbleRatio * durationSec) }}
</span>
</div>
<span v-if="showTime" class="scrub-time">{{ fmt(durationSec) }}</span>
</div>
</template>
<style scoped>
.scrub {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 0;
}
.scrub-time {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
flex: 0 0 auto;
min-width: 38px;
text-align: center;
user-select: none;
}
.scrub-time:first-child {
text-align: right;
}
.scrub-track {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 16px;
display: flex;
align-items: center;
cursor: pointer;
touch-action: none;
outline: none;
}
/* 视觉轨道(高 4pxhover / 拖动时增到 7px */
.scrub-track::before {
content: '';
position: absolute;
left: 0;
right: 0;
height: 4px;
border-radius: 999px;
background: var(--muted);
transition: height 0.15s ease;
}
.scrub:hover .scrub-track::before,
.scrub.is-dragging .scrub-track::before {
height: 7px;
}
.scrub-track:focus-visible::before {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 45%, transparent);
}
.scrub-buffered,
.scrub-played {
position: absolute;
left: 0;
height: 4px;
border-radius: 999px;
transition: height 0.15s ease;
}
.scrub:hover .scrub-buffered,
.scrub.is-dragging .scrub-buffered,
.scrub:hover .scrub-played,
.scrub.is-dragging .scrub-played {
height: 7px;
}
.scrub-buffered {
background: color-mix(in oklab, var(--muted-foreground) 28%, transparent);
}
.scrub-played {
background: var(--primary);
}
.scrub-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 0 2px var(--background);
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
.scrub:hover .scrub-thumb,
.scrub.is-dragging .scrub-thumb,
.scrub-track:focus-visible .scrub-thumb {
opacity: 1;
}
.scrub.is-dragging .scrub-thumb {
transform: scale(1.15);
}
.scrub-bubble {
position: absolute;
bottom: 18px;
transform: translateX(-50%);
padding: 2px 6px;
border-radius: 5px;
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
white-space: nowrap;
}
.scrub.is-compact .scrub-time {
display: none;
}
.scrub.is-compact .scrub-track {
height: 14px;
}
.scrub.is-compact .scrub-track::before,
.scrub.is-compact .scrub-buffered,
.scrub.is-compact .scrub-played {
height: 4px;
}
.scrub.is-compact:hover .scrub-track::before,
.scrub.is-compact:hover .scrub-buffered,
.scrub.is-compact:hover .scrub-played,
.scrub.is-compact.is-dragging .scrub-track::before,
.scrub.is-compact.is-dragging .scrub-buffered,
.scrub.is-compact.is-dragging .scrub-played {
height: 6px;
}
.scrub.is-disabled {
opacity: 0.5;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.scrub-track::before,
.scrub-buffered,
.scrub-played,
.scrub-thumb {
transition: none;
}
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { Component } from 'vue'
export interface SegmentedItem {
value: string
label: string
/** lucide 图标组件 */
icon?: Component
/** 右侧计数徽标 */
count?: number
}
/** 模块内统一的分段导航(替代此前并存的三套切换控件) */
defineProps<{
modelValue: string
items: SegmentedItem[]
/** 铺满容器宽度(等分) */
block?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5"
:class="block ? 'flex w-full' : 'w-fit'"
role="tablist"
>
<button
v-for="item in items"
:key="item.value"
type="button"
role="tab"
:aria-selected="modelValue === item.value"
class="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[7px] px-3 py-1.5 text-[13px] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
:class="
modelValue === item.value
? 'bg-background font-medium text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
"
@click="emit('update:modelValue', item.value)"
>
<component :is="item.icon" v-if="item.icon" class="size-3.5 shrink-0" />
<span class="truncate">{{ item.label }}</span>
<span
v-if="item.count"
class="shrink-0 rounded-full bg-muted px-1.5 text-[10px] tabular-nums text-muted-foreground"
:class="modelValue === item.value ? 'bg-muted' : 'bg-background/70'"
>
{{ item.count }}
</span>
</button>
</div>
</template>
+319
View File
@@ -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>
+6
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Search, Settings, ChevronRight, ArrowUp, Check, Loader2 } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import TitleBarMusic from '@/components/layout/TitleBarMusic.vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useSearchStore, type SearchItem } from '@/stores/searchStore'
@@ -363,6 +364,11 @@ const handleBlur = () => {
保存设置
</button>
</Transition>
<!-- 音乐栏唱片图标 + 歌名点击弹出方形播放控制窗位于搜索框左侧 -->
<div class="pointer-events-auto">
<TitleBarMusic />
</div>
<div class="relative max-w-xs mr-3 pointer-events-auto">
<Search
class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
+296
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+36 -15
View File
@@ -26,8 +26,11 @@ const toggle = (code: string) => {
emit('update:model-value', [...next])
}
const selectAll = () => emit('update:model-value', [...props.options])
const clearAll = () => emit('update:model-value', [])
const label = computed(() => {
if (props.modelValue.length === 0) return '选择'
if (props.modelValue.length === 0) return '选择搜索源'
if (props.modelValue.length === 1) return sourceName(props.modelValue[0])
return `已选 ${props.modelValue.length} 个源`
})
@@ -36,34 +39,52 @@ const label = computed(() => {
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" class="justify-between font-normal" size="sm">
<Button variant="outline" size="sm" class="h-9 max-w-56 justify-between font-normal">
<span class="truncate">{{ label }}</span>
<ChevronDown class="size-3.5 opacity-50 shrink-0" />
<ChevronDown class="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2" align="start">
<!-- ScrollArea viewport h-full 需要 root 有确定高度才滚动max-h 不生效用固定 h-72 -->
<div class="mb-1 flex items-center justify-between px-1">
<span class="text-[11px] text-muted-foreground">搜索源</span>
<div class="flex items-center gap-2">
<button
type="button"
class="text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-40"
:disabled="modelValue.length === options.length"
@click="selectAll"
>
全选
</button>
<button
type="button"
class="text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-40"
:disabled="modelValue.length === 0"
@click="clearAll"
>
清空
</button>
</div>
</div>
<!-- ScrollArea viewport h-full需要根节点有确定高度才能滚动 -->
<ScrollArea class="h-72">
<div class="space-y-0.5 pr-2">
<label
v-for="code in options"
:key="code"
class="flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-muted/50"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent/60"
>
<Checkbox
:model-value="checked.has(code)"
@update:model-value="toggle(code)"
/>
<Label class="text-sm cursor-pointer truncate">{{ sourceName(code) }}</Label>
<Checkbox :model-value="checked.has(code)" @update:model-value="toggle(code)" />
<Label class="cursor-pointer truncate text-[13px]">{{ sourceName(code) }}</Label>
</label>
<p v-if="options.length === 0" class="px-2 py-3 text-xs text-muted-foreground">
未获取到可用源请先安装环境
<p v-if="options.length === 0" class="px-2 py-3 text-[11.5px] text-muted-foreground">
未获取到可用源请先设置 环境与诊断安装运行时
</p>
</div>
</ScrollArea>
<div class="mt-1 border-t pt-1.5 px-1 flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ options.length }} 个可选源</span>
<span class="text-xs text-muted-foreground">已选 {{ props.modelValue.length }}</span>
<div class="mt-1 flex items-center justify-between border-t px-1 pt-1.5">
<span class="text-[11px] text-muted-foreground">{{ options.length }} 个可选源</span>
<span class="text-[11px] text-muted-foreground">已选 {{ modelValue.length }}</span>
</div>
</PopoverContent>
</Popover>
+439 -205
View File
@@ -1,269 +1,503 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { FolderOpen, Loader2, Music2, Plus, RefreshCw, Trash2 } from '@lucide/vue'
import { useFeiniuStore, type Playlist } from '@/stores/feiniuStore'
import {
Cloud,
FolderOpen,
HardDrive,
ListMusic,
ListPlus,
Loader2,
Music2,
Pencil,
Play,
Plus,
RefreshCw,
Search,
Trash2
} from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import TrackItem from './TrackItem.vue'
import PlaylistEditorDialog from './PlaylistEditorDialog.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Empty } from '@/components/ui/empty'
import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
const store = useFeiniuStore()
type SubTab = 'feiniu' | 'local' | 'playlists'
const subTab = ref<SubTab>('feiniu')
/** 曲库视图:飞牛 / 本地 / 歌单 */
type LibraryView = 'feiniu' | 'local' | 'playlists'
const view = ref<LibraryView>('feiniu')
// 未登录(无激活连接且未登录)
const needsLogin = computed(() => !store.config.loggedIn && !store.activeConn)
const keywordInput = ref('')
let searchTimer: ReturnType<typeof setTimeout> | undefined
const sourceNav = computed(() => [
{ value: 'feiniu' as const, label: '飞牛曲库', icon: Cloud, count: store.total || store.tracks.length },
{ value: 'local' as const, label: '本地曲库', icon: HardDrive, count: store.localTracks.length },
{ value: 'playlists' as const, label: '我的歌单', icon: ListMusic, count: store.playlists.length }
])
// ===== 飞牛曲库:搜索(直接绑定 store.searchKeyword,此前绑定的是孤立局部变量,导致搜索完全无效)=====
let searchTimer: ReturnType<typeof setTimeout> | undefined
let searchSeq = 0
function onSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
store.loadTracks(1).catch((e) => toast.error(String(e)))
searchTimer = setTimeout(async () => {
const seq = ++searchSeq
try {
await store.loadTracks(1)
} catch (e) {
if (seq === searchSeq) toast.error(String(e))
}
}, 400)
}
function refreshFeiniu() {
store.loadTracks(store.page).catch((e) => toast.error(String(e)))
store.loadTracks(1).catch((e) => toast.error(String(e)))
}
function refreshLocal() {
store.scanLocal().catch((e) => toast.error(String(e)))
}
function playAll() {
const list =
subTab.value === 'feiniu' ? store.tracks : subTab.value === 'local' ? store.localTracks : []
if (list.length) store.playQueue(list, 0)
function playFeiniu() {
if (store.tracks.length) store.playQueue(store.tracks, 0)
}
function playLocal() {
if (store.localTracks.length) store.playQueue(store.localTracks, 0)
}
function playActivePlaylist() {
if (activePlaylist.value?.items.length) store.playQueue(activePlaylist.value.items, 0)
}
// ===== 滚动加载更多(飞牛曲库分页)=====
const loadMoreRef = ref<HTMLElement | null>(null)
let loadMoreObserver: IntersectionObserver | null = null
onMounted(() => {
loadMoreObserver = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return
store.loadMoreTracks().catch(() => {})
},
{ rootMargin: '400px' }
)
watch(
loadMoreRef,
(el) => {
if (!loadMoreObserver) return
loadMoreObserver.disconnect()
if (el) loadMoreObserver.observe(el)
},
{ immediate: true }
)
})
onBeforeUnmount(() => {
clearTimeout(searchTimer)
loadMoreObserver?.disconnect()
})
// ===== 歌单 =====
const playlistName = ref('')
const createOpen = ref(false)
const activePlaylistId = ref('')
const activePlaylist = computed<Playlist | null>(
() => store.playlists.find((p) => p.id === activePlaylistId.value) || null
const activePlaylist = computed(() => store.playlists.find((p) => p.id === activePlaylistId.value) || null)
const createOpen = ref(false)
const newName = ref('')
const renameOpen = ref(false)
const renameValue = ref('')
/** 编辑歌单音乐(勾选加入 / 批量移出) */
const editorOpen = ref(false)
// 切换歌单时关闭编辑器,避免对着已切换的目标继续编辑
watch(activePlaylistId, () => (editorOpen.value = false))
// 切到歌单视图时自动选中第一个
watch(
[view, () => store.playlists.length],
() => {
if (view.value !== 'playlists') return
if (activePlaylistId.value && store.playlists.some((p) => p.id === activePlaylistId.value)) return
activePlaylistId.value = store.playlists[0]?.id ?? ''
},
{ immediate: true }
)
function createPlaylist() {
if (!playlistName.value.trim()) {
const name = newName.value.trim()
if (!name) {
toast.error('请输入歌单名称')
return
}
const id = store.createPlaylist(playlistName.value.trim())
activePlaylistId.value = id
playlistName.value = ''
activePlaylistId.value = store.createPlaylist(name)
newName.value = ''
createOpen.value = false
view.value = 'playlists'
}
function openRename() {
if (!activePlaylist.value) return
renameValue.value = activePlaylist.value.name
renameOpen.value = true
}
function confirmRename() {
const name = renameValue.value.trim()
if (!name || !activePlaylist.value) return
store.renamePlaylist(activePlaylist.value.id, name)
renameOpen.value = false
}
function removePlaylist() {
if (!activePlaylist.value) return
store.deletePlaylist(activePlaylist.value.id)
activePlaylistId.value = ''
}
onMounted(async () => {
await store.init()
if (store.config.loggedIn) {
store.loadTracks(1).catch(() => {})
if (store.config.loggedIn) store.loadTracks(1).catch(() => {})
})
/** 表格表头与 TrackItem 行保持同一条网格 */
const GRID = { gridTemplateColumns: '28px 36px minmax(0,1fr) 74px 28px' }
const listEmptyHint = computed(() => {
if (view.value === 'feiniu') {
if (store.searchKeyword.trim()) return '没有匹配「' + store.searchKeyword.trim() + '」的曲目'
return store.config.loggedIn ? '曲库为空,试试刷新或检查 NAS 曲库目录' : '请先在「设置 → 飞牛音乐连接」登录'
}
return '暂无本地音乐,点击「扫描本地曲库」或先到「发现音乐」下载'
})
</script>
<template>
<ScrollArea class="h-full pr-3">
<div class="flex flex-col gap-4 px-1 pt-1 pb-4 min-w-0">
<!-- 未登录态引导配置连接 -->
<div v-if="needsLogin" class="flex flex-col items-center gap-3 py-16 text-center">
<Music2 class="size-10 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
还没有可用的飞牛音乐连接请到设置 飞牛音乐连接添加并登录
<br />
或先到发现音乐下载歌曲到本地曲库
</p>
</div>
<div class="flex h-full min-h-0">
<!-- ===== 来源导航 ===== -->
<aside class="flex w-[168px] shrink-0 flex-col border-r">
<ScrollArea class="min-h-0 flex-1">
<div class="flex flex-col p-2">
<p class="px-2 pb-1.5 pt-0.5 text-[11px] font-medium tracking-wide text-muted-foreground">曲库</p>
<template v-else>
<!-- 页头 + 子导航 -->
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1 rounded-lg border bg-card p-1">
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition-colors"
:class="subTab === 'feiniu' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="subTab = 'feiniu'"
<!-- 来源选中态与顶部 Tabs 一致无容器底色选中项为浮起胶囊 -->
<div class="flex flex-col gap-0.5">
<button
v-for="nav in sourceNav"
:key="nav.value"
type="button"
class="flex h-8 items-center gap-1.5 rounded-md px-2 text-[13px] transition-colors duration-150"
:class="
view === nav.value
? 'bg-background font-medium text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
"
@click="view = nav.value"
>
<component :is="nav.icon" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ nav.label }}</span>
<!-- 数量始终渲染无歌曲时显示 0并固定宽度右对齐保证数字列竖向对齐 -->
<span
class="min-w-[3ch] shrink-0 text-right text-[10.5px] tabular-nums"
:class="view === nav.value ? 'text-muted-foreground' : 'text-muted-foreground/70'"
>
飞牛曲库
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition-colors"
:class="subTab === 'local' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="subTab = 'local'"
>
本地曲库
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition-colors"
:class="subTab === 'playlists' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="subTab = 'playlists'"
>
我的歌单
</button>
</div>
<Button v-if="store.config.loggedIn" variant="outline" size="sm" @click="playAll">
播放全部
</Button>
</div>
</div>
<!-- ===== 飞牛曲库 ===== -->
<template v-if="subTab === 'feiniu'">
<div class="flex items-center gap-2">
<div class="flex-1">
<Input v-model="keywordInput" placeholder="搜索飞牛曲库(歌名 / 歌手)" @input="onSearchInput" @keydown.enter="onSearchInput" />
</div>
<Button variant="outline" size="icon" :disabled="store.loading" @click="refreshFeiniu">
<RefreshCw :class="store.loading ? 'size-4 animate-spin' : 'size-4'" />
</Button>
</div>
<div class="flex flex-col gap-1.5">
<div v-if="store.loading" class="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 class="size-4 animate-spin" /> 加载曲库
</div>
<Empty v-else-if="!store.tracks.length" class="min-h-40">
<Music2 class="size-10 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
{{ store.config.loggedIn ? '曲库为空或未匹配到结果' : '请先在设置中登录飞牛音乐连接' }}
</p>
</Empty>
<template v-else>
<TrackItem
v-for="(t, i) in store.tracks"
:key="t.guid || i"
:item="t"
:active="store.current?.guid === t.guid"
@dblclick="store.playQueue(store.tracks, i)"
/>
</template>
</div>
</template>
<!-- ===== 本地曲库 ===== -->
<template v-else-if="subTab === 'local'">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" :disabled="store.localScanBusy" @click="refreshLocal">
<Loader2 v-if="store.localScanBusy" class="size-4 animate-spin" />
<FolderOpen v-else class="size-4" />
扫描本地
</Button>
<span class="text-xs text-muted-foreground">{{ store.localTracks.length }} 目录音乐下载目录 + 自定义</span>
</div>
<div class="flex flex-col gap-1.5">
<Empty v-if="!store.localTracks.length && !store.localScanBusy" class="min-h-40">
<FolderOpen class="size-10 text-muted-foreground" />
<p class="text-sm text-muted-foreground">暂无本地音乐点击扫描本地或先到发现音乐下载</p>
</Empty>
<template v-else>
<TrackItem
v-for="(t, i) in store.localTracks"
:key="t.guid || i"
:item="t"
:active="store.current?.guid === t.guid"
@dblclick="store.playQueue(store.localTracks, i)"
/>
</template>
</div>
</template>
<!-- ===== 我的歌单 ===== -->
<template v-else>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" @click="createOpen = true">
<Plus class="size-4" /> 新建歌单
</Button>
{{ nav.count > 999 ? '999+' : nav.count }}
</span>
</button>
</div>
<!-- 歌单列表 + 详情 -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-[220px_1fr]">
<div class="flex flex-col gap-1 rounded-lg border bg-card p-2">
<!-- 歌单列表选中我的歌单时展开 -->
<Transition name="pl-list">
<div v-if="view === 'playlists'" class="mt-1.5 ml-1.5 flex flex-col gap-0.5 border-l pl-1.5">
<button
v-for="p in store.playlists"
:key="p.id"
type="button"
class="flex items-center justify-between rounded-md px-3 py-2 text-sm transition-colors"
:class="activePlaylistId === p.id ? 'bg-primary/10 text-foreground' : 'text-muted-foreground hover:bg-muted/40'"
class="flex h-7 items-center gap-1.5 rounded-md px-2 text-left text-[12.5px] transition-colors duration-150"
:class="
activePlaylistId === p.id
? 'bg-background font-medium text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
"
@click="activePlaylistId = p.id"
>
<span class="truncate">{{ p.name }}</span>
<span class="text-xs">{{ p.items.length }}</span>
<span class="min-w-0 flex-1 truncate">{{ p.name }}</span>
<span class="min-w-[3ch] shrink-0 text-right text-[10.5px] tabular-nums">
{{ p.items.length > 999 ? '999+' : p.items.length }}
</span>
</button>
<Empty v-if="!store.playlists.length" class="min-h-32">
<p class="text-sm text-muted-foreground">还没有歌单</p>
</Empty>
<button
type="button"
class="flex h-7 items-center gap-1.5 rounded-md px-2 text-[12.5px] text-muted-foreground transition-colors duration-150 hover:bg-accent/60 hover:text-foreground"
@click="createOpen = true"
>
<Plus class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">新建歌单</span>
</button>
<p v-if="!store.playlists.length" class="px-2 py-1 text-[11.5px] text-muted-foreground">
还没有歌单
</p>
</div>
</Transition>
</div>
</ScrollArea>
</aside>
<!-- ===== 内容key 随来源变化复用全局 tab-animate 切换动画 ===== -->
<section :key="view" class="tab-animate flex min-w-0 flex-1 flex-col">
<!-- 未登录态 -->
<div v-if="needsLogin" class="flex min-h-0 flex-1 items-center justify-center px-6">
<Empty>
<EmptyMedia>
<Music2 class="size-8 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>还没有可用的飞牛音乐连接</EmptyTitle>
<EmptyDescription>
设置 飞牛音乐连接添加并登录或先到发现音乐下载歌曲到本地曲库
</EmptyDescription>
</Empty>
</div>
<template v-else>
<!-- 工具栏 -->
<header class="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
<template v-if="view === 'feiniu'">
<div class="relative w-full max-w-sm">
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="store.searchKeyword"
class="h-8 pl-8"
placeholder="搜索歌名 / 歌手 / 专辑"
@input="onSearchInput"
@keydown.enter="onSearchInput"
/>
</div>
<Button variant="outline" size="sm" class="h-8" :disabled="store.loading" @click="refreshFeiniu">
<RefreshCw :class="store.loading ? 'size-3.5 animate-spin' : 'size-3.5'" />
刷新
</Button>
<span class="ml-1 text-[11.5px] text-muted-foreground">
{{ store.tracks.length }}<template v-if="store.total > store.tracks.length"> / {{ store.total }}</template>
</span>
<Button size="sm" class="ml-auto h-8" :disabled="!store.tracks.length" @click="playFeiniu">
<Play class="size-3.5" /> 播放全部
</Button>
</template>
<template v-else-if="view === 'local'">
<Button variant="outline" size="sm" class="h-8" :disabled="store.localScanBusy" @click="refreshLocal">
<Loader2 v-if="store.localScanBusy" class="size-3.5 animate-spin" />
<FolderOpen v-else class="size-3.5" />
扫描本地曲库
</Button>
<span class="text-[11.5px] text-muted-foreground">{{ store.localTracks.length }} </span>
<Button size="sm" class="ml-auto h-8" :disabled="!store.localTracks.length" @click="playLocal">
<Play class="size-3.5" /> 播放全部
</Button>
</template>
<template v-else>
<template v-if="activePlaylist">
<div class="min-w-0">
<div class="truncate text-[13px] font-medium">{{ activePlaylist.name }}</div>
<div class="text-[11.5px] text-muted-foreground">{{ activePlaylist.items.length }} </div>
</div>
<div class="ml-auto flex items-center gap-1.5">
<Button variant="outline" size="sm" class="h-8" @click="editorOpen = true">
<ListPlus class="size-3.5" />
编辑音乐
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
aria-label="重命名歌单"
@click="openRename"
>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 text-destructive hover:text-destructive"
aria-label="删除歌单"
@click="removePlaylist"
>
<Trash2 class="size-3.5" />
</Button>
<Button size="sm" class="h-8" :disabled="!activePlaylist.items.length" @click="playActivePlaylist">
<Play class="size-3.5" /> 播放全部
</Button>
</div>
</template>
<span v-else class="text-[12.5px] text-muted-foreground">选择左侧歌单查看内容</span>
</template>
</header>
<!-- 列表 -->
<ScrollArea class="min-h-0 flex-1">
<div class="px-2 pb-4">
<!-- 表头 -->
<div
v-if="
(view === 'feiniu' && store.tracks.length) ||
(view === 'local' && store.localTracks.length) ||
(view === 'playlists' && activePlaylist?.items.length)
"
class="grid h-8 items-center gap-3 px-2 text-[11px] text-muted-foreground"
:style="GRID"
>
<span class="text-center">#</span>
<span />
<span>标题</span>
<span class="text-right">时长</span>
<span />
</div>
<div class="flex flex-col gap-2">
<div v-if="activePlaylist" class="flex items-center justify-between">
<div>
<div class="text-base font-semibold">{{ activePlaylist.name }}</div>
<div class="text-xs text-muted-foreground">{{ activePlaylist.items.length }} </div>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" :disabled="!activePlaylist.items.length" @click="store.playQueue(activePlaylist.items, 0)">
播放全部
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 text-destructive"
@click="store.deletePlaylist(activePlaylist.id); activePlaylistId = ''"
>
<Trash2 class="size-4" />
</Button>
</div>
<!-- 飞牛曲库 -->
<template v-if="view === 'feiniu'">
<div v-if="store.loading" class="flex items-center justify-center gap-2 py-14 text-[12.5px] text-muted-foreground">
<Loader2 class="size-4 animate-spin" /> 加载曲库…
</div>
<div v-if="activePlaylist" class="flex flex-col gap-1.5">
<Empty v-else-if="!store.tracks.length">
<EmptyMedia><Music2 class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>{{ store.searchKeyword.trim() ? '没有匹配的曲目' : '曲库为空' }}</EmptyTitle>
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
</Empty>
<template v-else>
<TrackItem
v-for="(t, i) in activePlaylist.items"
:key="`${t.source}-${t.guid}-${i}`"
v-for="(t, i) in store.tracks"
:key="t.guid || i"
:item="t"
:index="i"
:context="store.tracks"
:active="store.current?.guid === t.guid"
/>
<div class="mt-1 flex justify-end">
<Button
variant="ghost"
size="sm"
class="text-xs text-muted-foreground"
@click="store.removeFromPlaylist(activePlaylist.id, activePlaylist.items.length - 1)"
>
移除最后一首
</Button>
<div
v-if="store.hasMoreTracks"
ref="loadMoreRef"
class="flex items-center justify-center gap-2 py-4 text-[12px] text-muted-foreground"
>
<Loader2 v-if="store.loadingMore" class="size-3.5 animate-spin" />
正在加载更多…
</div>
</div>
<Empty v-else class="min-h-40">
<p class="text-sm text-muted-foreground">选择左侧歌单查看内容</p>
</Empty>
</div>
</div>
</template>
</template>
</div>
</ScrollArea>
</template>
</template>
<!-- 新建歌单弹窗 -->
<Dialog v-model:open="createOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>新建歌单</DialogTitle>
<DialogDescription>歌单保存在本机可混合飞牛 NAS 与本地曲目</DialogDescription>
</DialogHeader>
<Input v-model="playlistName" placeholder="歌单名称" @keydown.enter="createPlaylist" />
<DialogFooter>
<Button variant="outline" @click="createOpen = false">取消</Button>
<Button @click="createPlaylist">创建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<!-- 本地曲库 -->
<template v-else-if="view === 'local'">
<Empty v-if="!store.localTracks.length && !store.localScanBusy">
<EmptyMedia><HardDrive class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>暂无本地音乐</EmptyTitle>
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
</Empty>
<template v-else>
<TrackItem
v-for="(t, i) in store.localTracks"
:key="t.guid || i"
:item="t"
:index="i"
:context="store.localTracks"
:active="store.current?.guid === t.guid"
/>
</template>
</template>
<!-- 歌单详情 -->
<template v-else>
<template v-if="activePlaylist">
<Empty v-if="!activePlaylist.items.length">
<EmptyMedia><ListMusic class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>歌单还是空的</EmptyTitle>
<EmptyDescription>
点击「编辑音乐」从全部音乐里勾选加入,或在曲库列表中通过「⋯」菜单单首添加。
</EmptyDescription>
<Button size="sm" class="mt-1" @click="editorOpen = true">
<ListPlus class="size-3.5" />
编辑音乐
</Button>
</Empty>
<TrackItem
v-for="(t, i) in activePlaylist.items"
:key="`${t.source}:${t.guid}:${i}`"
:item="t"
:index="i"
:context="activePlaylist.items"
:playlist-id="activePlaylist.id"
:playlist-index="i"
:active="store.current?.guid === t.guid"
show-source
/>
</template>
<Empty v-else>
<EmptyMedia><ListMusic class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>还没有歌单</EmptyTitle>
<EmptyDescription>点击左侧「新建歌单」开始整理你的收藏。</EmptyDescription>
</Empty>
</template>
</div>
</ScrollArea>
</template>
</section>
<!-- 新建歌单 -->
<Dialog v-model:open="createOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>新建歌单</DialogTitle>
<DialogDescription>歌单保存在本机,可混合飞牛 NAS 与本地曲目。</DialogDescription>
</DialogHeader>
<Input v-model="newName" placeholder="歌单名称" @keydown.enter="createPlaylist" />
<DialogFooter>
<Button variant="outline" @click="createOpen = false">取消</Button>
<Button @click="createPlaylist">创建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 重命名歌单 -->
<Dialog v-model:open="renameOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>重命名歌单</DialogTitle>
</DialogHeader>
<Input v-model="renameValue" placeholder="歌单名称" @keydown.enter="confirmRename" />
<DialogFooter>
<Button variant="outline" @click="renameOpen = false">取消</Button>
<Button @click="confirmRename">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 编辑歌单音乐:从全部音乐勾选加入 / 批量移出 -->
<PlaylistEditorDialog
v-if="activePlaylist"
v-model:open="editorOpen"
:playlist-id="activePlaylist.id"
/>
</div>
</template>
<style scoped>
/* 歌单子树展开 / 收起 */
.pl-list-enter-active,
.pl-list-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.pl-list-enter-from,
.pl-list-leave-to {
opacity: 0;
transform: translateY(-6px);
}
@media (prefers-reduced-motion: reduce) {
.pl-list-enter-active,
.pl-list-leave-active {
transition: none;
}
}
</style>
-142
View File
@@ -1,142 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ListMusic, Maximize2, Music2, Pause, Play, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Volume2 } from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const store = useFeiniuStore()
const coverUrl = computed(() => {
const t = store.current
if (t?.source === 'feiniu' && t.coverId && store.mediaPrefix) {
return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(t.coverId)}&size=96`
}
return ''
})
function onProgress(v: number[] | undefined) {
store.seek(((v?.[0] ?? 0) / 100) * store.duration)
}
function onVolume(v: number[] | undefined) {
store.setVolume((v?.[0] ?? 0) / 100)
}
const modeIcon = computed(() => {
if (store.playMode === 'loopOne') return Repeat1
if (store.playMode === 'shuffle') return Shuffle
return Repeat
})
</script>
<template>
<div
class="flex items-center gap-3 border-t bg-background/80 px-4 py-2.5 backdrop-blur"
data-slot="player-bar"
>
<!-- 封面 + 信息点击开 Now-Playing -->
<button
type="button"
class="flex min-w-0 items-center gap-3 text-left"
@click="store.nowPlayingOpen = true"
>
<img
v-if="coverUrl"
:src="coverUrl"
class="size-11 shrink-0 rounded-md object-cover shadow"
alt=""
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="flex size-11 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Music2 class="size-5" />
</div>
<div class="min-w-0 max-w-44">
<div class="truncate text-sm font-medium">{{ store.current?.title || '未播放' }}</div>
<div class="truncate text-xs text-muted-foreground">{{ store.current?.artistNames || '选择一首歌曲开始播放' }}</div>
</div>
</button>
<!-- 控制区 -->
<div class="flex flex-1 flex-col items-center gap-1">
<div class="flex items-center gap-3">
<Tooltip>
<TooltipTrigger as-child>
<button type="button" class="text-muted-foreground transition-colors hover:text-foreground" @click="store.togglePlayMode()">
<component :is="modeIcon" class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{{ store.playMode === 'loopAll' ? '列表循环' : store.playMode === 'loopOne' ? '单曲循环' : '随机播放' }}
</TooltipContent>
</Tooltip>
<button type="button" class="text-muted-foreground transition-colors hover:text-foreground" @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="!store.current"
@click="store.toggle()"
>
<Pause v-if="store.playing" class="size-5" />
<Play v-else class="size-5 translate-x-[1px]" />
</button>
<button type="button" class="text-muted-foreground transition-colors hover:text-foreground" @click="store.next()">
<SkipForward class="size-5" />
</button>
<button
type="button"
class="relative text-muted-foreground transition-colors hover:text-foreground"
@click="store.queueVisible = !store.queueVisible"
>
<ListMusic class="size-4" />
<span
v-if="store.queue.length"
class="absolute -right-1.5 -top-1 flex size-3.5 items-center justify-center rounded-full bg-primary text-[8px] font-medium text-primary-foreground"
>
{{ store.queue.length }}
</span>
</button>
</div>
<div class="flex w-full max-w-lg items-center gap-2">
<span class="w-10 text-right text-[10px] tabular-nums text-muted-foreground">{{ store.fmtDuration(store.position) }}</span>
<Slider
:model-value="[store.progress]"
class="flex-1"
:max="100"
:step="0.5"
@update:model-value="onProgress"
/>
<span class="w-10 text-[10px] tabular-nums text-muted-foreground">{{ store.fmtDuration(store.duration) }}</span>
</div>
</div>
<!-- 音量 + 歌词 + 最大化 -->
<div class="flex items-center gap-2">
<Volume2 class="size-4 text-muted-foreground" />
<Slider
:model-value="[store.volume * 100]"
class="w-20"
:max="100"
:step="1"
@update:model-value="onVolume"
/>
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:class="{ 'text-primary': store.lyricVisible }"
@click="store.lyricVisible = !store.lyricVisible"
>
</button>
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
@click="store.nowPlayingOpen = true"
>
<Maximize2 class="size-4" />
</button>
</div>
</div>
</template>
-176
View File
@@ -1,176 +0,0 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { ListMusic, Music2, Pause, Play, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, X } from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { Slider } from '@/components/ui/slider'
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
const store = useFeiniuStore()
const coverUrl = computed(() => {
const t = store.current
if (t?.source === 'feiniu' && t.coverId && store.mediaPrefix) {
return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(t.coverId)}&size=400`
}
return ''
})
const currentLineIdx = computed(() => store.currentLine())
function onProgress(v: number[] | undefined) {
store.seek(((v?.[0] ?? 0) / 100) * store.duration)
}
const modeIcon = computed(() => {
if (store.playMode === 'loopOne') return Repeat1
if (store.playMode === 'shuffle') return Shuffle
return Repeat
})
// 歌词滚动跟随
const lyricScrollEl = ref<HTMLElement | null>(null)
watchEffect(() => {
const idx = currentLineIdx.value
if (idx < 0 || !lyricScrollEl.value) return
const nodes = lyricScrollEl.value.querySelectorAll<HTMLElement>('[data-line]')
const node = nodes[idx]
if (node) node.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
</script>
<template>
<Dialog v-model:open="store.nowPlayingOpen">
<DialogContent
class="max-w-4xl overflow-hidden border-0 p-0 sm:max-w-5xl"
:show-close-button="false"
>
<div class="relative flex min-h-[70vh] flex-col">
<!-- 背景渐变 -->
<div class="pointer-events-none absolute inset-0">
<img v-if="coverUrl" :src="coverUrl" class="h-full w-full scale-110 object-cover blur-2xl opacity-30" alt="" />
<div class="absolute inset-0 bg-gradient-to-b from-background/60 via-background/85 to-background" />
</div>
<div class="relative flex min-h-0 flex-1">
<!-- 封面 + 控制 -->
<div class="flex w-1/2 flex-col items-center justify-center gap-5 p-8">
<img
v-if="coverUrl"
:src="coverUrl"
class="size-64 rounded-xl object-cover shadow-2xl ring-1 ring-border"
alt=""
/>
<div v-else class="flex size-64 items-center justify-center rounded-xl bg-muted/40 text-muted-foreground shadow-2xl">
<Music2 class="size-20" />
</div>
<div class="text-center">
<div class="truncate text-2xl font-semibold">{{ store.current?.title || '未播放' }}</div>
<div class="mt-1 truncate text-sm text-muted-foreground">
{{ store.current?.artistNames || '—' }}<template v-if="store.current?.album"> · {{ store.current.album }}</template>
</div>
</div>
<div class="flex w-full max-w-sm flex-col gap-2">
<Slider
:model-value="[store.progress]"
:max="100"
:step="0.5"
@update:model-value="onProgress"
/>
<div class="flex justify-between text-[11px] tabular-nums text-muted-foreground">
<span>{{ store.fmtDuration(store.position) }}</span>
<span>{{ store.fmtDuration(store.duration) }}</span>
</div>
<div class="mt-1 flex items-center justify-center gap-6">
<button type="button" class="text-muted-foreground hover:text-foreground" @click="store.togglePlayMode()">
<component :is="modeIcon" class="size-5" />
</button>
<button type="button" class="text-muted-foreground hover:text-foreground" @click="store.prev()">
<SkipBack class="size-7" />
</button>
<button
type="button"
class="flex size-16 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90"
:disabled="!store.current"
@click="store.toggle()"
>
<Pause v-if="store.playing" class="size-8" />
<Play v-else class="size-8 translate-x-[1px]" />
</button>
<button type="button" class="text-muted-foreground hover:text-foreground" @click="store.next()">
<SkipForward class="size-7" />
</button>
<button type="button" class="text-muted-foreground hover:text-foreground" @click="store.queueVisible = true">
<ListMusic class="size-5" />
</button>
</div>
</div>
</div>
<!-- 歌词 / 队列 -->
<div class="flex w-1/2 flex-col border-l border-white/10 p-6">
<div class="mb-3 flex items-center justify-between">
<button
type="button"
class="text-sm"
:class="!store.queueVisible ? 'font-semibold text-foreground' : 'text-muted-foreground'"
@click="store.queueVisible = false"
>
歌词
</button>
<button
type="button"
class="text-sm"
:class="store.queueVisible ? 'font-semibold text-foreground' : 'text-muted-foreground'"
@click="store.queueVisible = true"
>
队列{{ store.queue.length }}
</button>
</div>
<ScrollArea class="min-h-0 flex-1 pr-3">
<div v-if="!store.queueVisible" ref="lyricScrollEl" class="flex flex-col gap-1 py-2">
<p v-if="!store.lyricLines.length" class="text-sm text-muted-foreground/60">
{{ store.current?.source === 'local' ? '本地文件无歌词' : '暂无歌词' }}
</p>
<p
v-for="(line, idx) in store.lyricLines"
:key="idx"
data-line
class="cursor-pointer py-1 text-[15px] leading-7 transition-colors"
:class="idx === currentLineIdx ? 'font-medium text-foreground' : 'text-muted-foreground/60'"
@click="store.seek(line.t)"
>
{{ line.text }}
</p>
</div>
<div v-else class="flex flex-col gap-1.5 py-1">
<p v-if="!store.queue.length" class="text-sm text-muted-foreground/60">队列为空</p>
<button
v-for="(q, i) in store.queue"
:key="i"
type="button"
class="flex items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors"
:class="i === store.queueIndex ? 'bg-primary/10' : 'hover:bg-muted/40'"
@click="store.queueIndex = i; store.playItem(q)"
>
<span class="w-5 text-right text-xs tabular-nums text-muted-foreground">{{ i + 1 }}</span>
<div class="min-w-0 flex-1">
<div class="truncate text-sm" :class="i === store.queueIndex ? 'font-medium' : ''">{{ q.title }}</div>
<div class="truncate text-xs text-muted-foreground">{{ q.artistNames }}</div>
</div>
</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 hover:bg-muted/40 hover:text-foreground">
<X class="size-5" />
</button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,418 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { Check, HardDrive, Loader2, Music2, Plus, FolderOpen, Minus } from '@lucide/vue'
import { useFeiniuStore, type PlayableItem } from '@/stores/feiniuStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
const props = defineProps<{
open: boolean
playlistId: string
}>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
const store = useFeiniuStore()
const playlist = computed(() => store.playlists.find((p) => p.id === props.playlistId) || null)
const itemKey = (t: PlayableItem) => `${t.source}:${t.guid ?? ''}`
/** 歌单已有曲目 key 集合 */
const inPlaylistKeys = computed(() => new Set((playlist.value?.items ?? []).map(itemKey)))
// ===== 状态 =====
const tab = ref<'all' | 'inPlaylist'>('all')
const sourceFilter = ref<'all' | 'feiniu' | 'local'>('all')
const keyword = ref('')
const checked = ref<Set<string>>(new Set())
function resetState() {
tab.value = 'all'
sourceFilter.value = 'all'
keyword.value = ''
checked.value = new Set()
limit.value = LIMIT
}
// 打开时重置并确保曲库有数据可挑
watch(
() => props.open,
async (open) => {
if (!open) return
resetState()
if (store.config.loggedIn && store.tracks.length === 0) {
store.loadTracks(1).catch((e) => toast.error(String(e)))
}
}
)
// 切 tab / 切来源时清空选择,避免"看不到的项仍被选中"造成数量对不上
watch([tab, sourceFilter], () => {
checked.value = new Set()
limit.value = LIMIT
})
// ===== 数据源 =====
const allTracks = computed<PlayableItem[]>(() => {
if (sourceFilter.value === 'feiniu') return store.tracks
if (sourceFilter.value === 'local') return store.localTracks
return [...store.tracks, ...store.localTracks]
})
const baseList = computed<PlayableItem[]>(() => {
const list = tab.value === 'inPlaylist' ? playlist.value?.items ?? [] : allTracks.value
const kw = keyword.value.trim().toLowerCase()
if (!kw) return list
return list.filter(
(t) =>
t.title.toLowerCase().includes(kw) ||
t.artistNames.toLowerCase().includes(kw) ||
(t.album ?? '').toLowerCase().includes(kw)
)
})
// 大列表增量渲染
const LIMIT = 120
const limit = ref(LIMIT)
const visibleList = computed(() => baseList.value.slice(0, limit.value))
const hasMore = computed(() => baseList.value.length > limit.value)
watch(baseList, () => (limit.value = LIMIT))
/** 已在歌单的曲目在「全部音乐」里不可重复勾选 */
function isAlreadyIn(item: PlayableItem): boolean {
return tab.value === 'all' && inPlaylistKeys.value.has(itemKey(item))
}
function isSelectable(item: PlayableItem): boolean {
return !isAlreadyIn(item)
}
const selectableKeys = computed(() =>
baseList.value.filter((t) => isSelectable(t)).map(itemKey)
)
const allChecked = computed(
() => selectableKeys.value.length > 0 && selectableKeys.value.every((k) => checked.value.has(k))
)
function toggle(key: string) {
const next = new Set(checked.value)
if (next.has(key)) next.delete(key)
else next.add(key)
checked.value = next
}
function toggleAll() {
checked.value = allChecked.value ? new Set() : new Set(selectableKeys.value)
}
const selectedItems = computed(() => {
const map = new Map<string, PlayableItem>()
for (const t of baseList.value) map.set(itemKey(t), t)
for (const t of playlist.value?.items ?? []) map.set(itemKey(t), t)
const out: PlayableItem[] = []
for (const k of checked.value) {
const t = map.get(k)
if (t) out.push(t)
}
return out
})
const selectedCount = computed(() => checked.value.size)
// ===== 执行 =====
function apply() {
if (!playlist.value) return
const n = selectedCount.value
if (n === 0) return
if (tab.value === 'all') {
store.addToPlaylist(playlist.value.id, selectedItems.value)
toast.success(`已添加 ${n} 首到「${playlist.value.name}`)
} else {
store.removeItemsFromPlaylist(playlist.value.id, [...checked.value])
toast.success(`已从「${playlist.value.name}」移除 ${n}`)
}
emit('update:open', false)
}
const localEmpty = computed(() => store.localTracks.length === 0)
async function scanLocal() {
try {
await store.scanLocal()
toast.success(`已扫描到 ${store.localTracks.length} 首本地音乐`)
} catch (e) {
toast.error(String(e))
}
}
const durationText = (ms?: number) => (ms ? store.fmtDuration(ms / 1000) : '')
</script>
<template>
<Dialog :open="open" @update:open="(v: boolean) => emit('update:open', v)">
<DialogContent class="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>编辑歌单 · {{ playlist?.name || '' }}</DialogTitle>
<DialogDescription>
从全部音乐里勾选加入歌单或切换到已在歌单批量移出
</DialogDescription>
</DialogHeader>
<Tabs v-model="tab" class="flex flex-col">
<div class="flex items-center gap-2">
<TabsList class="grid w-[220px] shrink-0 grid-cols-2">
<TabsTrigger value="all" class="gap-1.5">
<Plus class="size-3.5" />全部音乐
</TabsTrigger>
<TabsTrigger value="inPlaylist" class="gap-1.5">
<Minus class="size-3.5" />已在歌单
<span class="text-xs text-muted-foreground">({{ playlist?.items.length ?? 0 }})</span>
</TabsTrigger>
</TabsList>
<Input
v-model="keyword"
class="h-8 max-w-56"
placeholder="搜索歌名 / 歌手 / 专辑"
/>
</div>
<!-- ===== 全部音乐来源筛选 ===== -->
<div v-if="tab === 'all'" class="mt-2 flex items-center gap-1.5">
<button
v-for="opt in (['all', 'feiniu', 'local'] as const)"
:key="opt"
type="button"
class="rounded-md border px-2 py-1 text-[11.5px] transition-colors"
:class="
sourceFilter === opt
? 'border-primary bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-accent/50'
"
@click="sourceFilter = opt"
>
{{ opt === 'all' ? '全部来源' : opt === 'feiniu' ? '飞牛曲库' : '本地曲库' }}
</button>
<span class="ml-1 text-[11.5px] text-muted-foreground">{{ baseList.length }} </span>
<label class="ml-auto flex cursor-pointer items-center gap-1.5 text-[11.5px] text-muted-foreground">
<Checkbox
:model-value="allChecked"
:disabled="selectableKeys.length === 0"
aria-label="全选当前列表"
@update:model-value="toggleAll"
/>
全选当前列表
</label>
</div>
<div v-else class="mt-2 flex items-center gap-2">
<span class="text-[11.5px] text-muted-foreground">{{ baseList.length }} </span>
<label class="ml-auto flex cursor-pointer items-center gap-1.5 text-[11.5px] text-muted-foreground">
<Checkbox
:model-value="allChecked"
:disabled="selectableKeys.length === 0"
aria-label="全选当前列表"
@update:model-value="toggleAll"
/>
全选当前列表
</label>
</div>
<TabsContent value="all" class="mt-2">
<ScrollArea class="h-[min(320px,45vh)] rounded-md border">
<div class="flex flex-col gap-0.5 p-1">
<!-- 本地曲库未扫描 -->
<div
v-if="sourceFilter !== 'feiniu' && localEmpty"
class="flex items-center gap-2 rounded-md border border-dashed px-3 py-2.5"
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span class="text-[12px] text-muted-foreground">本地曲库尚未扫描扫描后即可一起勾选</span>
<Button
size="sm"
variant="outline"
class="ml-auto h-7 shrink-0"
:disabled="store.localScanBusy"
@click="scanLocal"
>
<Loader2 v-if="store.localScanBusy" class="size-3.5 animate-spin" />
<span :class="store.localScanBusy ? 'ml-1.5' : ''">
{{ store.localScanBusy ? '扫描中' : '扫描本地' }}
</span>
</Button>
</div>
<div
v-if="store.loading && !store.tracks.length"
class="flex items-center justify-center gap-2 py-10 text-[12px] text-muted-foreground"
>
<Loader2 class="size-4 animate-spin" /> 加载曲库
</div>
<p
v-else-if="baseList.length === 0"
class="py-10 text-center text-[12px] text-muted-foreground"
>
{{ keyword.trim() ? '没有匹配的曲目' : '没有可添加的曲目' }}
</p>
<button
v-for="item in visibleList"
:key="itemKey(item)"
type="button"
class="flex h-11 items-center gap-3 rounded-md px-2 text-left transition-colors"
:class="
isSelectable(item)
? 'hover:bg-accent/50'
: 'cursor-not-allowed opacity-55'
"
:disabled="!isSelectable(item)"
@click="toggle(itemKey(item))"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors"
:class="
checked.has(itemKey(item))
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
"
>
<Check v-if="checked.has(itemKey(item))" class="size-3" />
</span>
<span class="flex size-8 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
<HardDrive v-if="item.source === 'local'" class="size-3.5" />
<Music2 v-else class="size-3.5" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px]">{{ item.title }}</span>
<span class="block truncate text-[11.5px] text-muted-foreground">
{{ item.artistNames || '—' }}
</span>
</span>
<span
v-if="isAlreadyIn(item)"
class="shrink-0 rounded border border-border px-1.5 py-0.5 text-[10.5px] text-muted-foreground"
>
已加入
</span>
<span class="shrink-0 text-[11.5px] tabular-nums text-muted-foreground">
{{ durationText(item.durationMs) }}
</span>
</button>
<div class="flex items-center justify-center gap-2 py-2">
<Button
v-if="hasMore"
size="sm"
variant="ghost"
class="text-[11.5px] text-muted-foreground"
@click="limit += LIMIT"
>
显示更多还剩 {{ baseList.length - limit }}
</Button>
<Button
v-if="sourceFilter !== 'local' && store.hasMoreTracks"
size="sm"
variant="ghost"
class="text-[11.5px] text-muted-foreground"
:disabled="store.loadingMore"
@click="store.loadMoreTracks()"
>
<Loader2 v-if="store.loadingMore" class="size-3.5 animate-spin" />
<span :class="store.loadingMore ? 'ml-1.5' : ''">
{{ store.loadingMore ? '加载中' : '继续加载飞牛曲库' }}
</span>
</Button>
</div>
</div>
</ScrollArea>
</TabsContent>
<!-- ===== 已在歌单批量移出 ===== -->
<TabsContent value="inPlaylist" class="mt-2">
<ScrollArea class="h-[min(320px,45vh)] rounded-md border">
<div class="flex flex-col gap-0.5 p-1">
<p
v-if="baseList.length === 0"
class="py-10 text-center text-[12px] text-muted-foreground"
>
{{ keyword.trim() ? '没有匹配的曲目' : '这个歌单还没有歌曲' }}
</p>
<button
v-for="item in visibleList"
:key="itemKey(item)"
type="button"
class="flex h-11 items-center gap-3 rounded-md px-2 text-left transition-colors hover:bg-accent/50"
@click="toggle(itemKey(item))"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors"
:class="
checked.has(itemKey(item))
? 'border-destructive bg-destructive text-white'
: 'border-input'
"
>
<Minus v-if="checked.has(itemKey(item))" class="size-3" />
</span>
<span class="flex size-8 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
<HardDrive v-if="item.source === 'local'" class="size-3.5" />
<Music2 v-else class="size-3.5" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px]">{{ item.title }}</span>
<span class="block truncate text-[11.5px] text-muted-foreground">
{{ item.artistNames || '—' }}
</span>
</span>
<span class="shrink-0 text-[11.5px] tabular-nums text-muted-foreground">
{{ durationText(item.durationMs) }}
</span>
</button>
<div v-if="hasMore" class="flex justify-center py-2">
<Button
size="sm"
variant="ghost"
class="text-[11.5px] text-muted-foreground"
@click="limit += LIMIT"
>
显示更多还剩 {{ baseList.length - limit }}
</Button>
</div>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter class="items-center gap-2 sm:justify-between">
<span class="text-[12px] text-muted-foreground">
已选 <span class="font-medium text-foreground">{{ selectedCount }}</span>
</span>
<div class="flex items-center gap-2">
<Button variant="outline" @click="emit('update:open', false)">取消</Button>
<Button
:variant="tab === 'inPlaylist' ? 'destructive' : 'default'"
:disabled="selectedCount === 0"
@click="apply"
>
{{ tab === 'inPlaylist' ? `移除 ${selectedCount}` : `添加 ${selectedCount}` }}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+175 -40
View File
@@ -1,7 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { Cloud, Music2, MoreHorizontal, Play, Plus } from '@lucide/vue'
import {
ArrowDown,
ArrowUp,
Cloud,
FolderOpen,
HardDrive,
ListPlus,
MoreHorizontal,
Music2,
Play,
Plus,
Trash2
} from '@lucide/vue'
import type { PlayableItem } from '@/stores/feiniuStore'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { Button } from '@/components/ui/button'
@@ -17,10 +29,26 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
const props = defineProps<{
item: PlayableItem
active?: boolean
}>()
const props = withDefaults(
defineProps<{
item: PlayableItem
/** 是否为当前播放项 */
active?: boolean
/** 列表内序号(从 0 开始) */
index?: number
/** 播放上下文:点击播放时以该列表建立队列 */
context?: PlayableItem[]
/** 显示专辑列 */
showAlbum?: boolean
/** 显示来源标识(歌单等混排列表) */
showSource?: boolean
/** 所属歌单 id(提供时可从菜单移除 / 移动) */
playlistId?: string
/** 歌单内的序号(用于移除 / 移动) */
playlistIndex?: number
}>(),
{ active: false, index: 0, showAlbum: false, showSource: false }
)
const store = useFeiniuStore()
@@ -28,10 +56,38 @@ const coverUrl = computed(() => {
if (props.item.source === 'feiniu' && props.item.coverId && store.mediaPrefix) {
return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(props.item.coverId)}&size=64`
}
return ''
return props.item.coverUrl ?? ''
})
const coverFailed = ref(false)
const durationText = computed(() =>
store.fmtDuration(props.item.durationMs ? props.item.durationMs / 1000 : undefined)
)
/** 本地文件没有时长信息时,退化为展示文件大小 */
const fallbackText = computed(() => {
if (props.item.durationMs) return ''
const n = props.item.size
if (!n || n <= 0) return ''
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`
return `${n} B`
})
const durationText = computed(() => store.fmtDuration(props.item.durationMs ? props.item.durationMs / 1000 : undefined))
const isCurrent = computed(
() =>
props.active ||
(store.queueIndex >= 0 &&
store.nowPlaying?.guid === props.item.guid &&
store.nowPlaying?.source === props.item.source)
)
const playlistLength = computed(
() => (props.playlistId ? store.playlists.find((p) => p.id === props.playlistId)?.items.length ?? 1 : 1)
)
function play() {
store.playItem(props.item, props.context)
}
function addToPlaylist(playlistId: string) {
store.addToPlaylist(playlistId, [props.item])
@@ -50,71 +106,150 @@ async function uploadToFeiniu() {
<template>
<div
class="group flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors"
:class="active ? 'border-primary bg-primary/10' : 'border-border hover:bg-muted/40'"
class="group grid h-11 items-center gap-3 rounded-md px-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50"
:class="[
showAlbum
? 'grid-cols-[28px_36px_minmax(0,1fr)_minmax(0,0.75fr)_74px_28px]'
: 'grid-cols-[28px_36px_minmax(0,1fr)_74px_28px]',
isCurrent ? 'bg-accent/60' : 'hover:bg-accent/40'
]"
role="button"
tabindex="0"
@dblclick="play"
@keydown.enter.prevent="play"
>
<!-- 序号 / 播放态指示悬停时原位切换不占用固定的空白列 -->
<div class="flex size-7 items-center justify-center text-[11.5px] tabular-nums text-muted-foreground">
<span v-if="isCurrent" class="music-eq" :class="{ 'is-paused': !store.playing }"><i /><i /><i /></span>
<template v-else>
<span class="group-hover:hidden">{{ index + 1 }}</span>
<button
type="button"
class="hidden text-foreground group-hover:block"
:aria-label="'播放 ' + item.title"
@click.stop="play"
>
<Play class="size-3.5" />
</button>
</template>
</div>
<!-- 封面 -->
<img
v-if="coverUrl"
v-if="coverUrl && !coverFailed"
:src="coverUrl"
class="size-10 shrink-0 rounded-md object-cover"
class="size-9 rounded object-cover"
alt=""
loading="lazy"
@error="($event.target as HTMLImageElement).style.display = 'none'"
referrerpolicy="no-referrer"
@error="coverFailed = true"
/>
<div v-else class="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<div v-else class="flex size-9 items-center justify-center rounded bg-muted text-muted-foreground">
<Music2 class="size-4" />
</div>
<button
type="button"
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 transition-opacity group-hover:opacity-100"
:disabled="store.playing && store.current?.guid === item.guid"
@click="store.playItem(item)"
>
<Play class="size-4 translate-x-[1px]" />
</button>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ item.title }}</div>
<div class="truncate text-xs text-muted-foreground">
{{ item.artistNames }}<template v-if="item.artistNames && item.album"> · </template>{{ item.album }}
<span v-if="item.source === 'local'" class="ml-1 rounded bg-muted px-1 text-[10px]">本地</span>
<!-- 标题 + 元信息 -->
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate text-[13px]" :class="isCurrent ? 'font-medium text-foreground' : ''">
{{ item.title }}
</span>
<span
v-if="showSource"
class="shrink-0 text-muted-foreground"
:title="item.source === 'local' ? '本地文件' : '飞牛 NAS'"
>
<HardDrive v-if="item.source === 'local'" class="size-3" />
<Cloud v-else class="size-3" />
</span>
</div>
<div class="truncate text-[11.5px] text-muted-foreground">{{ item.artistNames || '—' }}</div>
</div>
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ durationText }}</span>
<!-- 专辑 -->
<div v-if="showAlbum" class="truncate text-[11.5px] text-muted-foreground">
{{ item.album || '' }}
</div>
<!-- 时长本地无时长时退化为文件大小 -->
<div class="text-right text-[11.5px] tabular-nums text-muted-foreground">
<span v-if="item.durationMs">{{ durationText }}</span>
<span v-else-if="fallbackText" class="text-[11px]">{{ fallbackText }}</span>
<span v-else>--:--</span>
</div>
<!-- 操作菜单 -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="icon" variant="ghost" class="size-8">
<Button
size="icon"
variant="ghost"
class="size-7 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100"
:aria-label="'更多操作' + item.title"
>
<MoreHorizontal class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuLabel>{{ item.title }}</DropdownMenuLabel>
<DropdownMenuLabel class="truncate">{{ item.title }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="store.playItem(item)">
<DropdownMenuItem @click="play">
<Play class="size-4" /> 播放
</DropdownMenuItem>
<DropdownMenuItem
v-if="item.source === 'local' && store.fnosLoggedIn && store.libraryNasPath"
:disabled="store.uploading"
@click="uploadToFeiniu"
>
<Cloud class="size-4" /> 上传到飞牛曲库
<DropdownMenuItem @click="store.playNext(item); toast.success('已加入下一首')">
<ListPlus class="size-4" /> 下一首播放
</DropdownMenuItem>
<DropdownMenuItem @click="store.addToQueue(item); toast.success('已加入队列')">
<Plus class="size-4" /> 加入队列
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Plus class="size-4" /> 加入歌单
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="w-48 max-h-64 overflow-y-auto">
<DropdownMenuSubContent class="max-h-64 w-48 overflow-y-auto">
<DropdownMenuItem v-if="store.playlists.length === 0" disabled>还没有歌单</DropdownMenuItem>
<DropdownMenuItem v-for="p in store.playlists" :key="p.id" @click="addToPlaylist(p.id)">
{{ p.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<template v-if="item.source === 'local'">
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="store.webdavReady"
:disabled="store.uploading"
@click="uploadToFeiniu"
>
<Cloud class="size-4" /> 上传到飞牛曲库
</DropdownMenuItem>
<DropdownMenuItem disabled>
<FolderOpen class="size-4" /> {{ item.dir || '未知目录' }}
</DropdownMenuItem>
</template>
<template v-if="playlistId">
<DropdownMenuSeparator />
<DropdownMenuItem
:disabled="(playlistIndex ?? 0) <= 0"
@click="store.moveInPlaylist(playlistId, playlistIndex ?? 0, (playlistIndex ?? 0) - 1)"
>
<ArrowUp class="size-4" /> 上移
</DropdownMenuItem>
<DropdownMenuItem
:disabled="(playlistIndex ?? 0) >= playlistLength - 1"
@click="store.moveInPlaylist(playlistId, playlistIndex ?? 0, (playlistIndex ?? 0) + 1)"
>
<ArrowDown class="size-4" /> 下移
</DropdownMenuItem>
<DropdownMenuItem
class="text-destructive focus:text-destructive"
@click="store.removeFromPlaylist(playlistId, playlistIndex ?? 0)"
>
<Trash2 class="size-4" /> 从歌单移除
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
</template>
@@ -1,37 +1,29 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { HardDriveDownload, Trash2 } from '@lucide/vue'
import { HardDriveDownload, Loader2, RefreshCw, Trash2 } from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
const store = useFeiniuStore()
const cacheMax = ref(5)
const cacheEnabled = ref(false)
onMounted(async () => {
await store.init()
cacheMode.value = store.cacheMode
cacheMax.value = 5
/** 开关状态直接由 store.cacheMode 派生,避免与此前一样出现「开关显示关闭但实际在缓存」 */
const cacheEnabled = computed({
get: () => store.cacheMode === 'cache',
set: (v: boolean) => store.setCacheMode(v ? 'cache' : 'stream')
})
// 本地持久化缓存设置(后端 cache_fetch 用 settings.feiniu_cache_max_gb;这里给个默认)
const cacheMode = ref<'stream' | 'cache'>(store.cacheMode)
const usedMb = computed(() => Math.round((store.cacheStatus.usedMb ?? 0) * 10) / 10)
function setMode(v: unknown) {
const mode = v === 'cache' ? 'cache' : 'stream'
cacheMode.value = mode
store.setCacheMode(mode)
toast.success(mode === 'cache' ? '已开启缓存后播放' : '已切换为直连流式播放')
}
onMounted(() => {
void store.ensureCacheStatus()
})
function setEnabled(v: boolean) {
cacheEnabled.value = v
if (!v) store.setCacheMode('stream')
async function refresh() {
await store.ensureCacheStatus()
toast.success('缓存状态已刷新')
}
async function clearAll() {
@@ -41,43 +33,42 @@ async function clearAll() {
</script>
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<HardDriveDownload class="size-4 text-muted-foreground" />
<Label class="font-medium">播放缓存</Label>
<Label class="font-medium">缓存后播放</Label>
</div>
<p class="mt-1 text-xs text-muted-foreground">
缓存后播放 NAS 音频缓存到本机{{ store.cacheStatus.usedMb }} MB / {{ store.cacheStatus.count }}
超出上限自动按 LRU 淘汰直连流式则每次在线拉取
<p class="mt-1 text-[11.5px] text-muted-foreground">
开启后先 NAS 音频缓存到本机再播放首播需要等待整首下载完成关闭则直连流式播放
边下边播拖动即时生效但每次都要联网拉取
</p>
</div>
<Switch v-model:model-value="cacheEnabled" @update:model-value="setEnabled" />
<Switch v-model="cacheEnabled" class="mt-0.5 shrink-0" />
</div>
<template v-if="cacheEnabled">
<div class="flex items-center justify-between">
<Label class="text-muted-foreground">播放模式</Label>
<Select :model-value="cacheMode" @update:model-value="setMode">
<SelectTrigger class="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stream">直连流式</SelectItem>
<SelectItem value="cache">缓存后播放</SelectItem>
</SelectContent>
</Select>
<div class="flex items-center justify-between gap-4 rounded-lg border bg-card px-3 py-2">
<div class="flex items-center gap-2 text-[12.5px]">
<span class="text-muted-foreground">当前占用</span>
<span class="tabular-nums">{{ usedMb }} MB</span>
<span class="text-muted-foreground">· {{ store.cacheStatus.count }} </span>
<span class="text-[11.5px] text-muted-foreground/80">超出上限按 LRU 自动淘汰</span>
</div>
<div class="flex items-center justify-between">
<Label class="text-muted-foreground">当前占用</Label>
<span class="text-sm tabular-nums">{{ store.cacheStatus.usedMb }} MB{{ store.cacheStatus.count }} </span>
<div class="flex shrink-0 items-center gap-1.5">
<Button variant="ghost" size="icon" class="size-8" title="刷新" @click="refresh">
<RefreshCw class="size-3.5" />
</Button>
<Button variant="outline" size="sm" class="text-destructive" :disabled="!store.cacheStatus.count" @click="clearAll">
<Trash2 class="size-3.5" />
<span class="ml-1.5">清空缓存</span>
</Button>
</div>
</div>
<Button variant="outline" size="sm" class="text-destructive" @click="clearAll">
<Trash2 class="size-4" /> 清空缓存
</Button>
</template>
<p class="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
<Loader2 class="size-3" />
切换缓存模式不会中断当前播放下一首起按新模式取流
</p>
</div>
</template>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { Check, Cloud, Loader2, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
import { Check, CircleCheck, Loader2, LogOut, Pencil, Plus, Trash2 } from '@lucide/vue'
import { useFeiniuStore, type FeiniuConnection } from '@/stores/feiniuStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -22,10 +22,6 @@ const editing = ref<Partial<FeiniuConnection> | null>(null)
const editOpen = ref(false)
const password = ref('')
const testing = ref(false)
const fnosPassword = ref('')
const fnosFormOpen = ref(false)
/** 目标 fnOS 登录连接 */
const fnosTarget = ref<FeiniuConnection | null>(null)
function newForm() {
editing.value = { name: '', kind: 'lan', baseUrl: '', username: '', accessCode: '', insecure: false, fnId: '' }
@@ -39,12 +35,6 @@ function editForm(c: FeiniuConnection) {
editOpen.value = true
}
function openFnosLogin(c: FeiniuConnection) {
fnosTarget.value = c
fnosPassword.value = ''
fnosFormOpen.value = true
}
async function save() {
if (!editing.value) return
if (!editing.value.name?.trim() || !editing.value.baseUrl?.trim()) {
@@ -103,21 +93,6 @@ async function remove(c: FeiniuConnection) {
toast.success('已删除')
}
async function fnosLogin() {
if (!fnosTarget.value) return
if (!fnosPassword.value) {
toast.error('请输入 NAS 密码')
return
}
try {
await store.fnosLogin(fnosTarget.value.username, fnosPassword.value)
fnosFormOpen.value = false
toast.success('NAS 文件服务已连接')
} catch (e) {
toast.error(String(e))
}
}
function kindLabel(k: string) {
return k === 'lan' ? '局域网' : k === 'frp' ? 'frp 域名' : 'FnConnect'
}
@@ -154,19 +129,39 @@ onMounted(() => store.refreshConnections())
<span v-if="c.loggedIn" class="mr-1 flex items-center gap-1 text-xs text-emerald-600">
<Check class="size-3.5" /> 已登录
</span>
<Button v-if="store.activeId !== c.id" variant="ghost" size="icon" class="size-8" @click="activate(c)">
<Power class="size-4" />
<Button
v-if="store.activeId !== c.id"
variant="ghost"
size="icon"
class="size-8"
:title="`设为激活连接(${c.name}`"
:aria-label="`设为激活连接 ${c.name}`"
@click="activate(c)"
>
<CircleCheck class="size-4" />
</Button>
<Button v-if="store.activeId === c.id && !store.fnosLoggedIn" variant="ghost" size="icon" class="size-8" :title="`连接 NAS 文件服务(${c.username}`" @click="openFnosLogin(c)">
<Cloud class="size-4" />
</Button>
<Button variant="ghost" size="icon" class="size-8" @click="editForm(c)">
<Button variant="ghost" size="icon" class="size-8" title="编辑连接" aria-label="编辑连接" @click="editForm(c)">
<Pencil class="size-4" />
</Button>
<Button v-if="c.loggedIn" variant="ghost" size="icon" class="size-8" @click="logout(c)">
<Power class="size-4" />
<Button
v-if="c.loggedIn"
variant="ghost"
size="icon"
class="size-8"
title="登出"
aria-label="登出"
@click="logout(c)"
>
<LogOut class="size-4" />
</Button>
<Button variant="ghost" size="icon" class="size-8 text-destructive" @click="remove(c)">
<Button
variant="ghost"
size="icon"
class="size-8 text-destructive"
title="删除连接"
aria-label="删除连接"
@click="remove(c)"
>
<Trash2 class="size-4" />
</Button>
</div>
@@ -234,24 +229,9 @@ onMounted(() => store.refreshConnections())
</DialogContent>
</Dialog>
<!-- fnOS 文件服务登录上传到飞牛用 -->
<Dialog v-model:open="fnosFormOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>连接 NAS 文件服务</DialogTitle>
</DialogHeader>
<p class="text-xs text-muted-foreground">
账号{{ fnosTarget?.username }}连接后即可把本地音乐上传到飞牛曲库目录从曲库删除音乐
</p>
<div class="space-y-1.5">
<Label>NAS 密码</Label>
<Input v-model="fnosPassword" type="password" placeholder="NAS 登录密码" @keydown.enter="fnosLogin" />
</div>
<DialogFooter>
<Button variant="outline" @click="fnosFormOpen = false">取消</Button>
<Button @click="fnosLogin">连接</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<p class="text-xs text-muted-foreground">
提示上传音乐到 NAS下载到飞牛/ 上传 / 删除已改用 <strong class="font-medium">WebDAV</strong>
存储与上传分组里配置无需在此登录文件服务
</p>
</div>
</template>
+5 -4
View File
@@ -3,9 +3,9 @@ import type { SearchIndexItem } from '@/stores/searchIndex'
const searchItems: SearchIndexItem[] = [
{
title: '我的音乐',
description: '飞牛 NAS 曲库 / 本地曲库 / 自定义歌单与内嵌播放器',
keywords: ['音乐', 'music', '飞牛', 'NAS', '曲库', '歌单', '播放', '本地'],
title: '曲库',
description: '飞牛 NAS 曲库 / 本地曲库 / 自定义歌单,标题栏播放控制',
keywords: ['音乐', 'music', '曲库', '我的音乐', '飞牛', 'NAS', '歌单', '播放', '本地', '歌词', '队列'],
tab: 'mymusic'
},
{
@@ -43,5 +43,6 @@ export const moduleConfig: ModuleConfig = {
.catch(() => {})
}
},
order: 55
// 默认排在「代理」之后、整体第二位(proxy=10、clipboard=20
order: 15
}
+17 -1
View File
@@ -59,6 +59,8 @@ export const useAppStore = defineStore('app', () => {
const isAutoStart = ref(false)
const silentAutoStart = ref(false)
const isInitialized = ref(false)
/** 模块排序一次性迁移标记(音乐 → 代理之后),随设置持久化 */
let musicOrderMigrated = false
const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
const moduleOrder = ref<string[]>(initModuleOrder())
@@ -111,6 +113,19 @@ export const useAppStore = defineStore('app', () => {
const known = savedOrder.filter(id => allUserIds.includes(id))
const newlyAdded = allUserIds.filter(id => !savedOrder.includes(id))
moduleOrder.value = [...known, ...newlyAdded]
// 一次性迁移:音乐模块默认排序调整为「代理之后、整体第二位」。
// 迁移后写入标记,尊重用户后续手动拖动的新排序。
if (!settings.musicOrderMigrated) {
const musicIdx = moduleOrder.value.indexOf('music')
if (musicIdx !== -1) {
moduleOrder.value.splice(musicIdx, 1)
const proxyIdx = moduleOrder.value.indexOf('proxy')
moduleOrder.value.splice(proxyIdx === -1 ? 0 : proxyIdx + 1, 0, 'music')
}
saveSettings()
}
musicOrderMigrated = true
}
}
} catch {
@@ -132,7 +147,8 @@ export const useAppStore = defineStore('app', () => {
isAutoStart: isAutoStart.value,
silentAutoStart: silentAutoStart.value,
modules: modulesData,
moduleOrder: moduleOrder.value
moduleOrder: moduleOrder.value,
musicOrderMigrated
}))
} catch {
console.error('Failed to save settings to localStorage')
+596 -167
View File
File diff suppressed because it is too large Load Diff
+33 -13
View File
@@ -224,10 +224,27 @@ export const useMusicStore = defineStore('music', () => {
// 任务历史持久化(localStorage,应用重启后保留记录;恢复时不尝试续传)
const TASKS_KEY = 'thing.music.tasks'
const MAX_TASKS = 30
/** 单次持久化的体积上限:超过则剥离 rawSearch(懒解析字段)后重试,避免撑爆 localStorage 配额 */
const MAX_PERSIST_CHARS = 1_500_000
let persistTimer: ReturnType<typeof setTimeout> | null = null
/** 剥离仅解析期使用的重字段(只影响「重新下载」对懒解析歌曲的能力) */
function stripHeavy(s: MusicSong): MusicSong {
const { rawSearch, defaultDownloadHeaders, defaultDownloadCookies, ...rest } = s
void rawSearch
void defaultDownloadHeaders
void defaultDownloadCookies
return rest
}
function persistTasks() {
const base = tasks.value.slice(0, MAX_TASKS)
try {
localStorage.setItem(TASKS_KEY, JSON.stringify(tasks.value.slice(0, MAX_TASKS)))
let payload = JSON.stringify(base)
if (payload.length > MAX_PERSIST_CHARS) {
payload = JSON.stringify(base.map((t) => ({ ...t, songsData: t.songsData.map(stripHeavy) })))
}
localStorage.setItem(TASKS_KEY, payload)
} catch {
// localStorage 不可用/超限时静默降级(仅影响历史记录)
}
@@ -314,10 +331,11 @@ export const useMusicStore = defineStore('music', () => {
} else if (type === 'done') {
const st = task.songs.find((s) => s.key === ev.key)
if (st) {
// 幂等:重复的 done 事件(断点续传/重发)不再累加 doneCount
if (st.status !== 'done') task.doneCount++
st.status = 'done'
st.downloaded = st.total || st.downloaded
}
task.doneCount++
} else if (type === 'error') {
const st = task.songs.find((s) => s.key === ev.key)
if (st) {
@@ -350,14 +368,15 @@ export const useMusicStore = defineStore('music', () => {
)
}
/** 懒解析:解析单首歌曲的真实下载链接(试听前调用),成功后原地更新
* 搜索结果并返回带链接的最新歌曲对象;失败返回 null */
async function resolveSong(source: string, index: number): Promise<MusicSong | null> {
/** 懒解析:解析单首歌曲的真实下载链接(试听/上传前调用),成功后原地更新
* 搜索结果并返回带链接的最新歌曲对象;失败返回 null
* `quality` 为音质档位 label"" = 最高),解析时按「≤所选最优档」封顶。 */
async function resolveSong(source: string, index: number, quality = ''): Promise<MusicSong | null> {
const song = results.value[source]?.[index]
if (!song) return null
if (song.downloadUrl) return song
if (!song.rawSearch) return null
const v = (await invoke('music_resolve', { song })) as {
const v = (await invoke('music_resolve', { song, quality })) as {
songs: (MusicSong | null)[]
}
const resolved = v.songs?.[0]
@@ -369,8 +388,8 @@ export const useMusicStore = defineStore('music', () => {
return resolved
}
/** 启动下载。engine='rust' 时交给下载器模块(任务在其列表中管理),返回跳过的无链接歌曲数
* 否则走 musicdl 桥接返回 0 */
/** 启动下载。engine='rust' 时交给下载器模块(任务在其列表中管理);
* 否则走 musicdl 桥接返回实际使用的引擎与跳过的无链接歌曲数 */
async function startDownload(
songs: MusicSong[],
opts: {
@@ -383,8 +402,8 @@ export const useMusicStore = defineStore('music', () => {
/** 目标音质 label"" 表示最高);解析下载时按「≤所选最优档」封顶 */
quality?: string
}
): Promise<number> {
if (songs.length === 0) return 0
): Promise<{ engine: 'musicdl' | 'rust'; skipped: number; taskId?: string }> {
if (songs.length === 0) return { engine: 'musicdl', skipped: 0 }
await ensureDownloadListener()
const quality = opts.quality ?? ''
@@ -426,7 +445,7 @@ export const useMusicStore = defineStore('music', () => {
throw e
}
}
return skipped
return { engine: 'rust', skipped }
}
const taskId = crypto.randomUUID()
@@ -468,7 +487,7 @@ export const useMusicStore = defineStore('music', () => {
task.errorMessage = String(e)
throw e
}
return 0
return { engine: 'musicdl', skipped: 0, taskId }
}
async function cancelDownload(taskId: string) {
@@ -520,7 +539,8 @@ export const useMusicStore = defineStore('music', () => {
proxyUrl: proxy,
engine: s.downloadEngine,
maxConcurrent: s.maxConcurrent,
quality: s.defaultDownloadQuality ?? ''
// 沿用原任务的目标音质("最高" 存为空串),而不是当前默认音质
quality: task.quality ?? s.defaultDownloadQuality ?? ''
})
}
+37
View File
@@ -244,4 +244,41 @@ input, textarea, [contenteditable='true'] {
@keyframes recordPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* 音乐:正在播放的跳动均衡器指示(列表行与播放队列共用) */
.music-eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 12px;
}
.music-eq > i {
display: block;
width: 2.5px;
border-radius: 1px;
background: var(--primary);
animation: musicEqBounce 0.9s ease-in-out infinite;
}
.music-eq > i:nth-child(1) {
height: 6px;
animation-delay: -0.2s;
}
.music-eq > i:nth-child(2) {
height: 12px;
animation-delay: -0.5s;
}
.music-eq > i:nth-child(3) {
height: 8px;
animation-delay: -0.35s;
}
.music-eq.is-paused > i {
animation-play-state: paused;
}
@keyframes musicEqBounce {
0%, 100% { transform: scaleY(0.45); }
50% { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.music-eq > i { animation: none; }
}