Files
Thing/src/modules/music/components/TrackItem.vue
T
2026-09-15 17:17:16 +08:00

258 lines
9.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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:noneblock
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>