676541457b
- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage - 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件 - 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
527 lines
14 KiB
Vue
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>
|