feat: P7 在线播放(播放队列、媒体控制)与自建歌单

- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage
- 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件
- 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-11 11:31:29 +08:00
parent 9f908fa73f
commit 676541457b
30 changed files with 2995 additions and 174 deletions
+58 -10
View File
@@ -2,7 +2,8 @@
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, playOnline } from '../lib/player.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';
@@ -70,6 +71,17 @@ 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;
@@ -115,6 +127,12 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
</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" /> 下载所选
@@ -130,18 +148,18 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
: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="playOnline(t)"
@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="playOnline(t)" :aria-label="`试听 ${t.name}`">
<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>
<CoverArt v-if="showCover" :src="t.cover" :size="40" />
<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 }}
@@ -175,9 +193,14 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
</template>
</span>
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
<button type="button" class="dl" title="下载" @click="download([t], quality, group)">
<Icon name="download" :size="17" />
</button>
<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>
@@ -254,11 +277,14 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
.row {
position: relative;
display: grid;
grid-template-columns: 18px 30px auto minmax(0, 1fr) auto 96px 44px 32px;
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);
}
@@ -295,7 +321,13 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
}
.no-cover .row {
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 32px;
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 64px;
}
.cover-btn {
padding: 0;
border: 0;
background: none;
}
.num {
@@ -451,6 +483,21 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
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;
@@ -467,7 +514,8 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
.num,
.badges,
.dur {
.dur,
.add {
display: none;
}