Files
music-hub/web/src/components/TrackList.vue
T
Jocay 676541457b feat: P7 在线播放(播放队列、媒体控制)与自建歌单
- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage
- 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件
- 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:31:29 +08:00

527 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { QualityKey, TrackSummary } from '../lib/api.ts';
import { bestQuality, formatDuration, isGold, QUALITY_BADGE, STAGE_LABEL } from '../lib/format.ts';
import { isPlaying, player, playTracks } from '../lib/player.ts';
import { inputFromTrack, pickPlaylist } from '../lib/playlists.ts';
import { activeByTrack, defaultQualityFor, download, isDownloaded, progress, trackKey } from '../lib/store.ts';
import CoverArt from './CoverArt.vue';
import Icon from './Icon.vue';
import QualityPicker from './QualityPicker.vue';
const props = withDefaults(
defineProps<{
tracks: TrackSummary[];
group?: string;
showCover?: boolean;
showAlbum?: boolean;
numbering?: 'index' | 'track';
hideToolbar?: boolean;
}>(),
{ showCover: true, showAlbum: true, numbering: 'index' },
);
const source = computed(() => props.tracks[0]?.source ?? 'netease');
const quality = ref<QualityKey>(defaultQualityFor(source.value));
watch(source, (s) => (quality.value = defaultQualityFor(s)));
const selected = ref(new Set<string>());
let anchor = -1;
watch(
() => props.tracks,
() => {
selected.value = new Set();
anchor = -1;
},
);
const allSelected = computed(() => props.tracks.length > 0 && selected.value.size === props.tracks.length);
const owned = (t: TrackSummary) => isDownloaded(t) || !!t.inLibrary;
const pendingCount = computed(() => props.tracks.filter((t) => !owned(t)).length);
function toggleRow(index: number, e: MouseEvent) {
const next = new Set(selected.value);
const key = props.tracks[index]!.id;
if (e.shiftKey && anchor >= 0) {
const [a, b] = [Math.min(anchor, index), Math.max(anchor, index)];
const value = !next.has(key);
for (let i = a; i <= b; i++) value ? next.add(props.tracks[i]!.id) : next.delete(props.tracks[i]!.id);
} else {
next.has(key) ? next.delete(key) : next.add(key);
}
anchor = index;
selected.value = next;
}
function toggleAll() {
selected.value = allSelected.value ? new Set() : new Set(props.tracks.map((t) => t.id));
}
function selectPending() {
selected.value = new Set(props.tracks.filter((t) => !owned(t)).map((t) => t.id));
}
async function downloadSelected() {
const items = props.tracks.filter((t) => selected.value.has(t.id));
await download(items, quality.value, props.group);
selected.value = new Set();
}
function downloadAll() {
download(props.tracks, quality.value, props.group);
}
const picked = () => (selected.value.size ? props.tracks.filter((t) => selected.value.has(t.id)) : props.tracks);
function playSelected() {
playTracks(picked());
}
/** group 形如「专辑 · 名称」,取名称作为新歌单的默认名 */
function addSelected() {
pickPlaylist(picked().map(inputFromTrack), props.group?.split(' · ').slice(1).join(' · ') ?? '');
}
interface RowState {
kind: string;
text: string;
pct: number;
title?: string;
}
function rowState(t: TrackSummary): RowState | null {
const task = activeByTrack.value.get(trackKey(t.source, t.id));
if (task) {
if (task.status === 'running') {
const p = progress.get(task.id);
if (p && p.stage === 'download' && p.total) return { kind: 'running', text: `${Math.round((p.received / p.total) * 100)}%`, pct: p.received / p.total };
return { kind: 'running', text: STAGE_LABEL[task.stage ?? 'resolve'] ?? '处理中', pct: 0 };
}
return { kind: task.status, text: task.status === 'paused' ? '已暂停' : '排队中', pct: 0 };
}
if (isDownloaded(t)) return { kind: 'done', text: '已下载', pct: 1 };
if (t.inLibrary) return { kind: 'library', text: '曲库中有', pct: 1, title: `曲库中有同名歌曲:${t.inLibrary}` };
return null;
}
function number(t: TrackSummary, i: number) {
return String(props.numbering === 'track' && t.trackNo ? t.trackNo : i + 1).padStart(2, '0');
}
const best = (t: TrackSummary) => bestQuality(t.qualities);
</script>
<template>
<div class="tracklist" :class="{ 'no-cover': !showCover }">
<div v-if="!hideToolbar" class="toolbar">
<button type="button" class="check" :class="{ on: allSelected, part: selected.size && !allSelected }" @click="toggleAll" title="全选">
<Icon v-if="allSelected" name="check" :size="13" />
</button>
<span class="sel muted">
<template v-if="selected.size">已选 <b class="mono">{{ selected.size }}</b> 首</template>
<template v-else>
共 <b class="mono">{{ tracks.length }}</b> 首
<button v-if="pendingCount && pendingCount < tracks.length" class="link" type="button" @click="selectPending">
· 选择未下载的 {{ pendingCount }} 首
</button>
</template>
</span>
<div class="spacer" />
<button class="btn" type="button" :disabled="!tracks.length" :title="selected.size ? '播放所选' : '播放全部'" @click="playSelected">
<Icon name="play" :size="14" /> {{ selected.size ? '播放所选' : '播放全部' }}
</button>
<button class="btn" type="button" :disabled="!tracks.length" :title="selected.size ? '所选加入歌单' : '全部加入歌单'" @click="addSelected">
<Icon name="listPlus" :size="16" /> 加入歌单
</button>
<QualityPicker v-model="quality" :source="source" />
<button v-if="selected.size" class="btn primary" type="button" @click="downloadSelected">
<Icon name="download" :size="16" /> 下载所选
</button>
<button v-else class="btn" type="button" :disabled="!tracks.length" @click="downloadAll">
<Icon name="download" :size="16" /> 全部下载
</button>
</div>
<ol class="rows">
<li
v-for="(t, i) in tracks"
:key="`${t.source}-${t.id}-${i}`"
class="row"
:class="[t.source, { selected: selected.has(t.id), current: player.current?.key === `${t.source}:${t.id}` }]"
@dblclick="playTracks(tracks, i)"
>
<button type="button" class="check" :class="{ on: selected.has(t.id) }" @click="toggleRow(i, $event)" :aria-label="`选择 ${t.name}`">
<Icon v-if="selected.has(t.id)" name="check" :size="13" />
</button>
<span class="num">
<span class="mono faint idx">{{ number(t, i) }}</span>
<button type="button" class="play" @click="playTracks(tracks, i)" :aria-label="`播放 ${t.name}`">
<Icon :name="isPlaying(`${t.source}:${t.id}`) ? 'pause' : 'play'" :size="16" />
</button>
</span>
<button v-if="showCover" type="button" class="cover-btn" tabindex="-1" @click="playTracks(tracks, i)"><CoverArt :src="t.cover" :size="40" /></button>
<div class="title">
<div class="name">
{{ t.name }}
<span v-if="t.vip" class="vip" title="VIP 歌曲">VIP</span>
</div>
<div class="sub muted">
<template v-if="t.artistRefs?.length">
<template v-for="(a, ai) in t.artistRefs" :key="a.id">
<span v-if="ai" class="faint"> / </span>
<RouterLink :to="`/artist/${t.source}/${a.id}`" class="ln">{{ a.name }}</RouterLink>
</template>
</template>
<template v-else>{{ t.artists }}</template>
<template v-if="showAlbum && t.album">
<span class="faint"> · </span>
<RouterLink v-if="t.albumId" :to="`/album/${t.source}/${t.albumId}`" class="ln faint">{{ t.album }}</RouterLink>
<span v-else class="faint">{{ t.album }}</span>
</template>
</div>
</div>
<span class="badges">
<span v-if="best(t)" class="badge" :class="{ gold: isGold(best(t)) }">{{ QUALITY_BADGE[best(t)!] }}</span>
</span>
<span class="state">
<template v-if="rowState(t)">
<span class="st" :class="rowState(t)!.kind" :title="rowState(t)!.title ?? rowState(t)!.text">
<Icon v-if="rowState(t)!.kind === 'done' || rowState(t)!.kind === 'library'" name="check" :size="14" />
{{ rowState(t)!.kind === 'done' ? '' : rowState(t)!.kind === 'library' ? '曲库' : rowState(t)!.text }}
</span>
<span v-if="rowState(t)!.kind === 'running'" class="bar"><i :style="{ width: `${rowState(t)!.pct * 100}%` }" /></span>
</template>
</span>
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
<span class="acts">
<button type="button" class="dl add" title="加入歌单" @click="pickPlaylist([inputFromTrack(t)])">
<Icon name="listPlus" :size="17" />
</button>
<button type="button" class="dl" title="下载" @click="download([t], quality, group)">
<Icon name="download" :size="17" />
</button>
</span>
</li>
</ol>
</div>
</template>
<style scoped>
.toolbar {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 10px 12px 12px;
background: color-mix(in oklab, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1.5px solid var(--ink);
}
.sel {
font-size: 13px;
}
.sel b {
color: var(--ink);
font-weight: 600;
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--accent);
font-size: 13px;
}
.spacer {
flex: 1;
}
.check {
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 1.5px solid var(--line-strong);
border-radius: 5px;
background: transparent;
color: var(--accent-ink);
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.check:hover {
border-color: var(--ink-3);
}
.check.on {
background: var(--accent);
border-color: var(--accent);
}
.check.part {
background: linear-gradient(var(--accent), var(--accent)) center / 8px 2px no-repeat;
border-color: var(--accent);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
}
.row {
position: relative;
display: grid;
grid-template-columns: 18px 30px auto minmax(0, 1fr) auto 96px 44px 64px;
align-items: center;
gap: 14px;
padding: 9px 10px 9px 12px;
border-bottom: 1px solid var(--line);
/* 长列表只渲染视口附近的行 */
content-visibility: auto;
contain-intrinsic-size: auto 59px;
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.row::before {
content: '';
position: absolute;
left: 0;
top: 13px;
bottom: 13px;
width: 2px;
border-radius: 2px;
background: var(--netease);
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.row.qq::before {
background: var(--qq);
}
.row:hover::before,
.row.selected::before,
.row.current::before {
opacity: 0.8;
}
.row:hover {
border-bottom-color: var(--line-strong);
background: color-mix(in oklab, var(--surface) 60%, transparent);
}
.row.selected {
background: var(--accent-soft);
}
.no-cover .row {
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 64px;
}
.cover-btn {
padding: 0;
border: 0;
background: none;
}
.num {
position: relative;
display: grid;
place-items: center;
height: 30px;
}
.idx {
font-size: 11px;
}
.play {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: var(--ink);
color: var(--bg);
opacity: 0;
transform: scale(0.85);
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.row:hover .play,
.row.current .play,
.play:focus-visible {
opacity: 1;
transform: none;
}
.row.current .play {
background: var(--accent);
color: var(--accent-ink);
}
.title {
min-width: 0;
}
.name {
font-size: 14.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.current .name {
color: var(--accent);
}
.vip {
margin-left: 6px;
padding: 0 4px;
border-radius: 3px;
font: 600 9px/14px var(--font-mono);
letter-spacing: 0.05em;
color: var(--qq);
border: 1px solid color-mix(in oklab, var(--qq) 50%, transparent);
vertical-align: 2px;
}
.row.netease .vip {
color: var(--netease);
border-color: color-mix(in oklab, var(--netease) 50%, transparent);
}
.sub {
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ln:hover {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--line-strong);
}
.badges {
display: flex;
gap: 4px;
}
.state {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 11.5px;
}
.st {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--ink-3);
font-family: var(--font-mono);
}
.st.library {
color: var(--ink-3);
cursor: help;
}
.st.done {
color: var(--ok);
}
.st.running {
color: var(--accent);
}
.bar {
width: 72px;
height: 2px;
border-radius: 2px;
background: var(--line);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: var(--accent);
transition: width 300ms linear;
}
.dur {
font-size: 12px;
text-align: right;
}
.dl {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--ink-3);
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.dl:hover {
color: var(--accent);
background: var(--accent-soft);
}
.acts {
display: flex;
justify-content: flex-end;
}
.add {
opacity: 0;
transition: color var(--dur) var(--ease), background var(--dur) var(--ease), opacity var(--dur) var(--ease);
}
.row:hover .add,
.add:focus-visible {
opacity: 1;
}
@media (max-width: 760px) {
.toolbar {
flex-wrap: wrap;
}
.row {
grid-template-columns: 18px auto minmax(0, 1fr) auto 32px;
gap: 10px;
}
.no-cover .row {
grid-template-columns: 18px minmax(0, 1fr) auto 32px;
}
.num,
.badges,
.dur,
.add {
display: none;
}
.state .bar {
width: 44px;
}
}
</style>