258 lines
9.0 KiB
Vue
258 lines
9.0 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { toast } from 'vue-sonner'
|
||
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'
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuSub,
|
||
DropdownMenuSubContent,
|
||
DropdownMenuSubTrigger,
|
||
DropdownMenuTrigger
|
||
} from '@/components/ui/dropdown-menu'
|
||
|
||
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()
|
||
|
||
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 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 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])
|
||
toast.success('已加入歌单')
|
||
}
|
||
|
||
async function uploadToFeiniu() {
|
||
try {
|
||
await store.uploadLocalTrack(props.item)
|
||
toast.success('已上传到飞牛曲库')
|
||
} catch (e) {
|
||
toast.error(String(e))
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="group grid h-11 items-center gap-3 rounded-md px-2 transition-colors"
|
||
: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'
|
||
]"
|
||
@dblclick="play"
|
||
>
|
||
<!-- 序号 / 播放态指示 / 播放按钮。
|
||
行本身不再是 role=button(内部还嵌着下拉菜单的按钮,嵌套交互元素对读屏是噪音);
|
||
播放改由下面这个**常驻 DOM** 的按钮承担——原来它是 hover 才 display:none→block,
|
||
而 display:none 的元素无法获得焦点,键盘用户根本按不到 -->
|
||
<div class="relative 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="transition-opacity group-hover:opacity-0">{{ index + 1 }}</span>
|
||
<button
|
||
type="button"
|
||
class="absolute inset-0 flex items-center justify-center rounded text-foreground opacity-0 outline-none transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50"
|
||
:aria-label="'播放 ' + item.title"
|
||
@click.stop="play"
|
||
>
|
||
<Play class="size-3.5" />
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 封面 -->
|
||
<img
|
||
v-if="coverUrl && !coverFailed"
|
||
:src="coverUrl"
|
||
class="size-9 rounded object-cover"
|
||
alt=""
|
||
loading="lazy"
|
||
referrerpolicy="no-referrer"
|
||
@error="coverFailed = true"
|
||
/>
|
||
<div v-else class="flex size-9 items-center justify-center rounded bg-muted text-muted-foreground">
|
||
<Music2 class="size-4" />
|
||
</div>
|
||
|
||
<!-- 标题 + 元信息 -->
|
||
<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>
|
||
|
||
<!-- 专辑 -->
|
||
<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-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 class="truncate">{{ item.title }}</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem @click="play">
|
||
<Play class="size-4" /> 播放
|
||
</DropdownMenuItem>
|
||
<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="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>
|
||
<!-- 所在目录只是信息,不是动作:用 disabled 的菜单项展示会让人以为点了没反应 -->
|
||
<div class="flex items-center gap-2 px-2 py-1.5 text-[11.5px] text-muted-foreground">
|
||
<FolderOpen class="size-3.5 shrink-0" />
|
||
<span class="truncate" :title="item.dir || ''">{{ item.dir || '未知目录' }}</span>
|
||
</div>
|
||
</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>
|