音乐模块调整
This commit is contained in:
+8
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 视觉轨道(高 4px,hover / 拖动时增到 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
+1098
-782
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+33
-13
@@ -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 ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user