音乐模块调整

This commit is contained in:
2026-09-12 15:47:16 +08:00
parent d702ed0d31
commit ffa410b399
33 changed files with 5452 additions and 2237 deletions
+175 -40
View File
@@ -1,7 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { Cloud, Music2, MoreHorizontal, Play, Plus } from '@lucide/vue'
import {
ArrowDown,
ArrowUp,
Cloud,
FolderOpen,
HardDrive,
ListPlus,
MoreHorizontal,
Music2,
Play,
Plus,
Trash2
} from '@lucide/vue'
import type { PlayableItem } from '@/stores/feiniuStore'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { Button } from '@/components/ui/button'
@@ -17,10 +29,26 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
const props = defineProps<{
item: PlayableItem
active?: boolean
}>()
const props = withDefaults(
defineProps<{
item: PlayableItem
/** 是否为当前播放项 */
active?: boolean
/** 列表内序号(从 0 开始) */
index?: number
/** 播放上下文:点击播放时以该列表建立队列 */
context?: PlayableItem[]
/** 显示专辑列 */
showAlbum?: boolean
/** 显示来源标识(歌单等混排列表) */
showSource?: boolean
/** 所属歌单 id(提供时可从菜单移除 / 移动) */
playlistId?: string
/** 歌单内的序号(用于移除 / 移动) */
playlistIndex?: number
}>(),
{ active: false, index: 0, showAlbum: false, showSource: false }
)
const store = useFeiniuStore()
@@ -28,10 +56,38 @@ const coverUrl = computed(() => {
if (props.item.source === 'feiniu' && props.item.coverId && store.mediaPrefix) {
return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(props.item.coverId)}&size=64`
}
return ''
return props.item.coverUrl ?? ''
})
const coverFailed = ref(false)
const durationText = computed(() =>
store.fmtDuration(props.item.durationMs ? props.item.durationMs / 1000 : undefined)
)
/** 本地文件没有时长信息时,退化为展示文件大小 */
const fallbackText = computed(() => {
if (props.item.durationMs) return ''
const n = props.item.size
if (!n || n <= 0) return ''
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`
return `${n} B`
})
const durationText = computed(() => store.fmtDuration(props.item.durationMs ? props.item.durationMs / 1000 : undefined))
const isCurrent = computed(
() =>
props.active ||
(store.queueIndex >= 0 &&
store.nowPlaying?.guid === props.item.guid &&
store.nowPlaying?.source === props.item.source)
)
const playlistLength = computed(
() => (props.playlistId ? store.playlists.find((p) => p.id === props.playlistId)?.items.length ?? 1 : 1)
)
function play() {
store.playItem(props.item, props.context)
}
function addToPlaylist(playlistId: string) {
store.addToPlaylist(playlistId, [props.item])
@@ -50,71 +106,150 @@ async function uploadToFeiniu() {
<template>
<div
class="group flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors"
:class="active ? 'border-primary bg-primary/10' : 'border-border hover:bg-muted/40'"
class="group grid h-11 items-center gap-3 rounded-md px-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50"
:class="[
showAlbum
? 'grid-cols-[28px_36px_minmax(0,1fr)_minmax(0,0.75fr)_74px_28px]'
: 'grid-cols-[28px_36px_minmax(0,1fr)_74px_28px]',
isCurrent ? 'bg-accent/60' : 'hover:bg-accent/40'
]"
role="button"
tabindex="0"
@dblclick="play"
@keydown.enter.prevent="play"
>
<!-- 序号 / 播放态指示悬停时原位切换不占用固定的空白列 -->
<div class="flex size-7 items-center justify-center text-[11.5px] tabular-nums text-muted-foreground">
<span v-if="isCurrent" class="music-eq" :class="{ 'is-paused': !store.playing }"><i /><i /><i /></span>
<template v-else>
<span class="group-hover:hidden">{{ index + 1 }}</span>
<button
type="button"
class="hidden text-foreground group-hover:block"
:aria-label="'播放 ' + item.title"
@click.stop="play"
>
<Play class="size-3.5" />
</button>
</template>
</div>
<!-- 封面 -->
<img
v-if="coverUrl"
v-if="coverUrl && !coverFailed"
:src="coverUrl"
class="size-10 shrink-0 rounded-md object-cover"
class="size-9 rounded object-cover"
alt=""
loading="lazy"
@error="($event.target as HTMLImageElement).style.display = 'none'"
referrerpolicy="no-referrer"
@error="coverFailed = true"
/>
<div v-else class="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<div v-else class="flex size-9 items-center justify-center rounded bg-muted text-muted-foreground">
<Music2 class="size-4" />
</div>
<button
type="button"
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 transition-opacity group-hover:opacity-100"
:disabled="store.playing && store.current?.guid === item.guid"
@click="store.playItem(item)"
>
<Play class="size-4 translate-x-[1px]" />
</button>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ item.title }}</div>
<div class="truncate text-xs text-muted-foreground">
{{ item.artistNames }}<template v-if="item.artistNames && item.album"> · </template>{{ item.album }}
<span v-if="item.source === 'local'" class="ml-1 rounded bg-muted px-1 text-[10px]">本地</span>
<!-- 标题 + 元信息 -->
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate text-[13px]" :class="isCurrent ? 'font-medium text-foreground' : ''">
{{ item.title }}
</span>
<span
v-if="showSource"
class="shrink-0 text-muted-foreground"
:title="item.source === 'local' ? '本地文件' : '飞牛 NAS'"
>
<HardDrive v-if="item.source === 'local'" class="size-3" />
<Cloud v-else class="size-3" />
</span>
</div>
<div class="truncate text-[11.5px] text-muted-foreground">{{ item.artistNames || '—' }}</div>
</div>
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ durationText }}</span>
<!-- 专辑 -->
<div v-if="showAlbum" class="truncate text-[11.5px] text-muted-foreground">
{{ item.album || '' }}
</div>
<!-- 时长本地无时长时退化为文件大小 -->
<div class="text-right text-[11.5px] tabular-nums text-muted-foreground">
<span v-if="item.durationMs">{{ durationText }}</span>
<span v-else-if="fallbackText" class="text-[11px]">{{ fallbackText }}</span>
<span v-else>--:--</span>
</div>
<!-- 操作菜单 -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="icon" variant="ghost" class="size-8">
<Button
size="icon"
variant="ghost"
class="size-7 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100"
:aria-label="'更多操作' + item.title"
>
<MoreHorizontal class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuLabel>{{ item.title }}</DropdownMenuLabel>
<DropdownMenuLabel class="truncate">{{ item.title }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="store.playItem(item)">
<DropdownMenuItem @click="play">
<Play class="size-4" /> 播放
</DropdownMenuItem>
<DropdownMenuItem
v-if="item.source === 'local' && store.fnosLoggedIn && store.libraryNasPath"
:disabled="store.uploading"
@click="uploadToFeiniu"
>
<Cloud class="size-4" /> 上传到飞牛曲库
<DropdownMenuItem @click="store.playNext(item); toast.success('已加入下一首')">
<ListPlus class="size-4" /> 下一首播放
</DropdownMenuItem>
<DropdownMenuItem @click="store.addToQueue(item); toast.success('已加入队列')">
<Plus class="size-4" /> 加入队列
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Plus class="size-4" /> 加入歌单
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="w-48 max-h-64 overflow-y-auto">
<DropdownMenuSubContent class="max-h-64 w-48 overflow-y-auto">
<DropdownMenuItem v-if="store.playlists.length === 0" disabled>还没有歌单</DropdownMenuItem>
<DropdownMenuItem v-for="p in store.playlists" :key="p.id" @click="addToPlaylist(p.id)">
{{ p.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<template v-if="item.source === 'local'">
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="store.webdavReady"
:disabled="store.uploading"
@click="uploadToFeiniu"
>
<Cloud class="size-4" /> 上传到飞牛曲库
</DropdownMenuItem>
<DropdownMenuItem disabled>
<FolderOpen class="size-4" /> {{ item.dir || '未知目录' }}
</DropdownMenuItem>
</template>
<template v-if="playlistId">
<DropdownMenuSeparator />
<DropdownMenuItem
:disabled="(playlistIndex ?? 0) <= 0"
@click="store.moveInPlaylist(playlistId, playlistIndex ?? 0, (playlistIndex ?? 0) - 1)"
>
<ArrowUp class="size-4" /> 上移
</DropdownMenuItem>
<DropdownMenuItem
:disabled="(playlistIndex ?? 0) >= playlistLength - 1"
@click="store.moveInPlaylist(playlistId, playlistIndex ?? 0, (playlistIndex ?? 0) + 1)"
>
<ArrowDown class="size-4" /> 下移
</DropdownMenuItem>
<DropdownMenuItem
class="text-destructive focus:text-destructive"
@click="store.removeFromPlaylist(playlistId, playlistIndex ?? 0)"
>
<Trash2 class="size-4" /> 从歌单移除
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
</template>