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
+2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import PlayerBar from './components/PlayerBar.vue';
import PlaylistPicker from './components/PlaylistPicker.vue';
import ToastHost from './components/ToastHost.vue';
import { formatSpeed } from './lib/format.ts';
import { activeCount, auth, bootstrap, connected, progress, qqStatus, summary } from './lib/store.ts';
@@ -84,6 +85,7 @@ const qqLabel = computed(() => {
<PlayerBar />
</div>
</div>
<PlaylistPicker />
<ToastHost />
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
// 歌单封面:4 张以上不同封面拼成 2×2,否则取第一张;填满父元素
import { computed, ref } from 'vue';
import { coverThumb } from '../lib/format.ts';
const props = defineProps<{ covers: Array<string | null | undefined>; px?: number }>();
const failed = ref(new Set<string>());
const list = computed(() => props.covers.filter((c): c is string => !!c && !failed.value.has(c)));
const shown = computed(() => (list.value.length >= 4 ? list.value.slice(0, 4) : list.value.slice(0, 1)));
function onError(c: string) {
failed.value = new Set(failed.value).add(c);
}
</script>
<template>
<span class="mosaic" :class="{ quad: shown.length === 4 }">
<img
v-for="c in shown"
:key="c"
:src="coverThumb(c, shown.length === 4 ? (px ?? 300) / 2 : (px ?? 300))"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onError(c)"
/>
</span>
</template>
<style scoped>
.mosaic {
display: grid;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: inherit;
background:
radial-gradient(circle at 50% 50%, var(--line-strong) 0 7%, transparent 7.5%),
repeating-radial-gradient(circle, var(--surface-2) 0 2px, color-mix(in oklab, var(--surface-2) 70%, var(--line)) 2px 3px);
box-shadow: inset 0 0 0 1px var(--line);
}
.mosaic.quad {
grid-template: 1fr 1fr / 1fr 1fr;
}
img {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
object-fit: cover;
display: block;
}
</style>
+14 -4
View File
@@ -5,7 +5,8 @@ import { SOURCE_LABEL } from '../lib/format.ts';
import CoverArt from './CoverArt.vue';
defineProps<{
source: Source;
/** 不传表示自建歌单等不属于某个平台的内容 */
source?: Source;
kind: string;
title: string;
cover?: string;
@@ -20,11 +21,11 @@ const expanded = ref(false);
<template>
<header class="dh" :class="source">
<div class="art" :class="{ round }">
<CoverArt :src="cover" :size="196" :px="500" :round="round" />
<slot name="art"><CoverArt :src="cover" :size="196" :px="500" :round="round" /></slot>
<span v-if="!round" class="vinyl" aria-hidden="true" />
</div>
<div class="info">
<span class="eyebrow kicker"><i class="dot" />{{ kind }} · {{ SOURCE_LABEL[source] }}</span>
<span class="eyebrow kicker"><i class="dot" />{{ kind }}<template v-if="source"> · {{ SOURCE_LABEL[source] }}</template></span>
<h1>{{ title }}</h1>
<div class="by"><slot name="by" /></div>
<div v-if="meta?.some(Boolean)" class="meta mono faint">
@@ -54,10 +55,15 @@ const expanded = ref(false);
isolation: isolate;
}
.art :deep(.cover) {
.art :deep(.cover),
.art :deep(.mosaic) {
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
}
.art :deep(.mosaic) {
border-radius: 4px;
}
.vinyl {
position: absolute;
top: 6px;
@@ -95,6 +101,10 @@ const expanded = ref(false);
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
.netease .dot {
background: var(--netease);
}
+12
View File
@@ -30,6 +30,18 @@ const paths = {
export: '<path d="M12 15V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v5.5h14V14"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
logout: '<path d="M14 4.5H6.5v15H14M10.5 12H20m0 0-3.5-3.5M20 12l-3.5 3.5"/>',
prev: '<path d="M6.5 5.5v13"/><path d="M18.5 6.2v11.6L9.6 12z" fill="currentColor" stroke="none"/>',
next: '<path d="M17.5 5.5v13"/><path d="M5.5 6.2v11.6l8.9-5.8z" fill="currentColor" stroke="none"/>',
repeat: '<path d="M4.5 11.5v-1a3.5 3.5 0 0 1 3.5-3.5h11.5m0 0-3-3m3 3-3 3"/><path d="M19.5 12.5v1a3.5 3.5 0 0 1-3.5 3.5H4.5m0 0 3 3m-3-3 3-3"/>',
repeatOne: '<path d="M4.5 11.5v-1a3.5 3.5 0 0 1 3.5-3.5h11.5m0 0-3-3m3 3-3 3"/><path d="M19.5 12.5v1a3.5 3.5 0 0 1-3.5 3.5H4.5m0 0 3 3m-3-3 3-3"/><path d="m11 10.6 1.4-.9v4.6" stroke-width="1.4"/>',
shuffle: '<path d="M4 7h2.8c2 0 3.2 1 4.2 2.7l2.2 4.6c1 1.7 2.2 2.7 4.2 2.7H20m0 0-2.5-2.5M20 17l-2.5 2.5"/><path d="M4 17h2.8c1.3 0 2.2-.4 3-1.2m3.4-7.6c.8-.8 1.7-1.2 3-1.2H20m0 0-2.5-2.5M20 7l-2.5 2.5"/>',
order: '<path d="M4.5 7h10M4.5 12h10M4.5 17h6"/><path d="M18.5 8.5v10m0 0-2.5-2.5m2.5 2.5 2.5-2.5"/>',
queue: '<path d="M4 6.5h12M4 11h12M4 15.5h7"/><path d="M18.5 12.5v6.6"/><circle cx="16.6" cy="19.1" r="1.9"/><path d="M18.5 12.5l2.4 1.2"/>',
listPlus: '<path d="M4 6.5h12M4 11h12M4 15.5h7"/><path d="M17.5 13.5v7M14 17h7"/>',
playNext: '<path d="M4 6.5h12M4 11h9M4 15.5h7"/><path d="M15 13.6v6.8l5.4-3.4z" fill="currentColor" stroke="none"/>',
grip: '<circle cx="9.5" cy="7" r="1.15" fill="currentColor" stroke="none"/><circle cx="14.5" cy="7" r="1.15" fill="currentColor" stroke="none"/><circle cx="9.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="14.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="9.5" cy="17" r="1.15" fill="currentColor" stroke="none"/><circle cx="14.5" cy="17" r="1.15" fill="currentColor" stroke="none"/>',
edit: '<path d="M5 19l.9-3.9L15.6 5.4a1.8 1.8 0 0 1 2.6 0l.4.4a1.8 1.8 0 0 1 0 2.6L8.9 18.1z"/><path d="m13.8 7.2 3 3"/>',
mute: '<path d="M4.5 9.5h3l4.5-4v13l-4.5-4h-3z"/><path d="m15.5 9.5 5 5m0-5-5 5"/>',
} as const;
</script>
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
// 播放器上方的抽屉:播放队列 / 滚动歌词
import { nextTick, onMounted, ref, watch } from 'vue';
import { formatDateTime, SOURCE_LABEL } from '../lib/format.ts';
import { lyric, lyricIndex, player, playAt, queue, removeAt, seekTo, stop, type PlayItem } from '../lib/player.ts';
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
import Icon from './Icon.vue';
const listEl = ref<HTMLElement | null>(null);
const lyricEl = ref<HTMLElement | null>(null);
/** 用户手动滚动歌词后暂停自动跟随 */
let freeScrollUntil = 0;
const label = (i: PlayItem) => (i.source ? SOURCE_LABEL[i.source] : '本地');
function center(container: HTMLElement | null, selector: string, smooth: boolean) {
const el = container?.querySelector<HTMLElement>(selector);
if (!container || !el) return;
container.scrollTo({ top: el.offsetTop - container.clientHeight / 2 + el.offsetHeight / 2, behavior: smooth ? 'smooth' : 'auto' });
}
function syncScroll(smooth: boolean) {
nextTick(() => {
if (player.panel === 'queue') center(listEl.value, '.cur', smooth);
else if (Date.now() > freeScrollUntil) center(lyricEl.value, '.on', smooth);
});
}
onMounted(() => syncScroll(false));
watch(() => player.panel, () => syncScroll(false));
watch(lyricIndex, () => player.panel === 'lyric' && syncScroll(true));
watch(lyric, () => player.panel === 'lyric' && syncScroll(false));
function saveQueue() {
pickPlaylist(queue.value.map(inputFromPlayItem), `播放队列 ${formatDateTime(new Date().toISOString())}`);
}
</script>
<template>
<div class="panel" role="dialog" aria-label="播放队列与歌词">
<header class="head">
<div class="seg" role="tablist">
<button type="button" role="tab" :aria-selected="player.panel === 'queue'" @click="player.panel = 'queue'">
播放队列 <span class="mono">{{ queue.length }}</span>
</button>
<button type="button" role="tab" :aria-selected="player.panel === 'lyric'" @click="player.panel = 'lyric'">歌词</button>
</div>
<div class="ops">
<template v-if="player.panel === 'queue'">
<button class="btn sm ghost" type="button" title="保存为歌单" @click="saveQueue"><Icon name="listPlus" :size="15" /> 存为歌单</button>
<button class="btn sm ghost danger" type="button" title="清空队列并停止" @click="stop"><Icon name="trash" :size="15" /></button>
</template>
<button class="icon-btn" type="button" aria-label="收起" @click="player.panel = null"><Icon name="x" :size="16" /></button>
</div>
</header>
<ol v-if="player.panel === 'queue'" ref="listEl" class="queue">
<li v-for="(item, i) in queue" :key="`${item.key}-${i}`" class="q" :class="[item.source ?? 'local', { cur: i === player.index }]" @click="playAt(i)">
<span class="q-no mono">
<span v-if="i === player.index && player.playing" class="eq" aria-label="正在播放"><i /><i /><i /></span>
<template v-else>{{ String(i + 1).padStart(2, '0') }}</template>
</span>
<span class="q-title">
<span class="q-name">{{ item.title }}</span>
<span class="q-sub faint">{{ item.artists || '未知歌手' }}</span>
</span>
<span class="q-src faint"><i class="dot" />{{ item.local && item.source ? '曲库' : label(item) }}</span>
<button class="icon-btn rm" type="button" :aria-label="`从队列移除 ${item.title}`" @click.stop="removeAt(i)"><Icon name="x" :size="14" /></button>
</li>
</ol>
<div v-else ref="lyricEl" class="lyrics" @wheel.passive="freeScrollUntil = Date.now() + 3000" @touchmove.passive="freeScrollUntil = Date.now() + 3000">
<template v-if="lyric.length">
<p v-for="(l, i) in lyric" :key="i" class="line" :class="{ on: i === lyricIndex }" @click="seekTo(l.t)">
{{ l.text }}
<span v-if="l.trans" class="trans">{{ l.trans }}</span>
</p>
</template>
<p v-else class="none faint">暂无歌词</p>
</div>
</div>
</template>
<style scoped>
.panel {
position: absolute;
right: 16px;
bottom: calc(100% + 10px);
z-index: 20;
display: flex;
flex-direction: column;
width: 400px;
height: min(560px, calc(100vh - 140px));
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
box-shadow: var(--shadow);
overflow: hidden;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 12px 10px 14px;
border-bottom: 1px solid var(--line);
}
.seg .mono {
font-size: 11px;
opacity: 0.7;
}
.ops {
display: flex;
align-items: center;
gap: 2px;
}
.icon-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-3);
flex-shrink: 0;
}
.icon-btn:hover {
color: var(--ink);
background: var(--surface-2);
}
/* ---- 队列 ---- */
.queue {
position: relative;
flex: 1;
margin: 0;
padding: 6px 6px 10px;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
}
.q {
position: relative;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto 28px;
align-items: center;
gap: 10px;
padding: 6px 4px 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
content-visibility: auto;
contain-intrinsic-size: auto 48px;
transition: background var(--dur) var(--ease);
}
.q:hover {
background: var(--surface-2);
}
.q-no {
display: grid;
place-items: center;
font-size: 11px;
color: var(--ink-3);
}
.q-title {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.35;
}
.q-name,
.q-sub {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.q-name {
font-size: 13.5px;
}
.q-sub {
font-size: 11.5px;
}
.q.cur .q-name {
color: var(--accent);
}
.q-src {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
white-space: nowrap;
}
.dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ink-3);
}
.netease .dot {
background: var(--netease);
}
.qq .dot {
background: var(--qq);
}
.rm {
opacity: 0;
}
.q:hover .rm,
.rm:focus-visible {
opacity: 1;
}
.eq {
display: flex;
align-items: flex-end;
gap: 2px;
height: 12px;
}
.eq i {
width: 2.5px;
height: 100%;
border-radius: 1px;
background: var(--accent);
animation: eq 900ms var(--ease) infinite alternate;
transform-origin: bottom;
}
.eq i:nth-child(2) {
animation-delay: -300ms;
}
.eq i:nth-child(3) {
animation-delay: -600ms;
}
@keyframes eq {
from {
transform: scaleY(0.25);
}
to {
transform: scaleY(1);
}
}
/* ---- 歌词 ---- */
.lyrics {
position: relative;
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 40% 22px;
text-align: center;
scrollbar-width: none;
mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
}
.lyrics::-webkit-scrollbar {
display: none;
}
.line {
margin: 0;
padding: 7px 0;
font: 15px/1.55 var(--font-serif);
color: var(--ink-3);
cursor: pointer;
transition: color 240ms var(--ease), transform 240ms var(--ease);
}
.line:hover {
color: var(--ink-2);
}
.line.on {
color: var(--ink);
transform: scale(1.06);
}
.trans {
display: block;
font: 12.5px/1.5 var(--font-sans);
opacity: 0.8;
}
.none {
margin: 0;
font-family: var(--font-serif);
}
@media (max-width: 760px) {
.panel {
left: 8px;
right: 8px;
width: auto;
height: min(70vh, 560px);
}
}
</style>
+231 -62
View File
@@ -1,69 +1,132 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatSeconds } from '../lib/format.ts';
import { currentLyric, player, seek, setVolume, stop, toggle } from '../lib/player.ts';
import { currentLyric, cycleMode, MODE_LABEL, next, player, prev, queue, seek, setVolume, stop, toggle, toggleMute } from '../lib/player.ts';
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
import CoverArt from './CoverArt.vue';
import Icon from './Icon.vue';
import PlayPanel from './PlayPanel.vue';
const pct = computed(() => (player.duration ? (player.time / player.duration) * 100 : 0));
const pct = computed(() => (player.duration ? Math.min(100, (player.time / player.duration) * 100) : 0));
const modeIcon = computed(() => ({ list: 'order', loop: 'repeat', one: 'repeatOne', shuffle: 'shuffle' })[player.mode]);
function onSeek(e: MouseEvent) {
const el = e.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
seek((e.clientX - rect.left) / rect.width);
}
function openPanel(tab: 'queue' | 'lyric') {
player.panel = player.panel === tab ? null : tab;
}
function addCurrent() {
if (player.current) pickPlaylist([inputFromPlayItem(player.current)]);
}
</script>
<template>
<Transition name="slide">
<div v-if="player.current" class="player">
<div class="disc" :class="{ spin: player.playing }">
<CoverArt :src="player.current.cover" :size="46" round />
<i class="hole" />
</div>
<div class="meta">
<div class="t">{{ player.current.title }}</div>
<div class="a muted">
<Transition name="fade" mode="out-in">
<span v-if="currentLyric && player.playing" :key="currentLyric" class="lyric">{{ currentLyric }}</span>
<span v-else>{{ player.current.artists }}</span>
</Transition>
<div v-if="player.current" class="player-dock">
<Transition name="panel">
<PlayPanel v-if="player.panel" />
</Transition>
<div class="player">
<i class="mini-progress" :style="{ width: `${pct}%` }" />
<button class="now" type="button" title="歌词" @click="openPanel('lyric')">
<span class="disc" :class="{ spin: player.playing }">
<CoverArt :src="player.current.cover" :size="46" round />
<i class="hole" />
</span>
<span class="meta">
<span class="t">
{{ player.current.title }}
<span v-if="player.current.local && player.current.source" class="tag" title="曲库里有这首歌,正在播放本地文件">曲库</span>
</span>
<span class="a muted">
<Transition name="fade" mode="out-in">
<span v-if="currentLyric && player.playing" :key="currentLyric" class="lyric">{{ currentLyric }}</span>
<span v-else>{{ player.current.artists }}</span>
</Transition>
</span>
</span>
</button>
<div class="controls">
<button class="ctl mode" type="button" :title="MODE_LABEL[player.mode]" :aria-label="MODE_LABEL[player.mode]" @click="cycleMode">
<Icon :name="modeIcon" :size="18" />
</button>
<button class="ctl prev" type="button" aria-label="上一首" :disabled="queue.length < 2 && !player.time" @click="prev"><Icon name="prev" :size="18" /></button>
<button class="pp" :class="{ busy: player.loading }" type="button" @click="toggle" :aria-label="player.playing ? '暂停' : '播放'">
<Icon :name="player.playing ? 'pause' : 'play'" :size="20" />
</button>
<button class="ctl" type="button" aria-label="下一首" :disabled="queue.length < 2" @click="next"><Icon name="next" :size="18" /></button>
</div>
<div class="timeline">
<span class="mono faint">{{ formatSeconds(player.time) }}</span>
<div class="track" @click="onSeek">
<div class="fill" :style="{ width: `${pct}%` }" />
<div class="knob" :style="{ left: `${pct}%` }" />
</div>
<span class="mono faint">{{ formatSeconds(player.duration) }}</span>
</div>
<div class="extras">
<button class="ctl add" type="button" title="加入歌单" @click="addCurrent"><Icon name="listPlus" :size="18" /></button>
<button class="ctl" :class="{ on: player.panel === 'queue' }" type="button" title="播放队列" @click="openPanel('queue')">
<Icon name="queue" :size="18" />
<span v-if="queue.length > 1" class="n mono">{{ queue.length > 999 ? '999+' : queue.length }}</span>
</button>
<span class="vol">
<button class="ctl" type="button" :title="player.muted ? '取消静音' : '静音'" @click="toggleMute">
<Icon :name="player.muted || !player.volume ? 'mute' : 'volume'" :size="17" />
</button>
<input type="range" min="0" max="1" step="0.01" aria-label="音量" :value="player.muted ? 0 : player.volume" @input="setVolume(Number(($event.target as HTMLInputElement).value))" />
</span>
<button class="ctl close" type="button" title="停止并清空队列" @click="stop"><Icon name="x" :size="16" /></button>
</div>
</div>
<button class="pp" type="button" @click="toggle" :aria-label="player.playing ? '暂停' : '播放'">
<Icon :name="player.playing ? 'pause' : 'play'" :size="20" />
</button>
<div class="timeline">
<span class="mono faint">{{ formatSeconds(player.time) }}</span>
<div class="track" @click="onSeek">
<div class="fill" :style="{ width: `${pct}%` }" />
<div class="knob" :style="{ left: `${pct}%` }" />
</div>
<span class="mono faint">{{ formatSeconds(player.duration) }}</span>
</div>
<label class="vol" title="音量">
<Icon name="volume" :size="17" />
<input type="range" min="0" max="1" step="0.01" :value="player.volume" @input="setVolume(Number(($event.target as HTMLInputElement).value))" />
</label>
<button class="close" type="button" @click="stop" aria-label="关闭"><Icon name="x" :size="16" /></button>
</div>
</Transition>
</template>
<style scoped>
/* 不能叫 .dock:根元素会继承 App.vue 的作用域,撞上侧栏的 .dock 样式 */
.player-dock {
position: relative;
}
.player {
position: relative;
display: grid;
grid-template-columns: auto minmax(120px, 240px) auto 1fr auto auto;
grid-template-columns: minmax(160px, 260px) auto minmax(0, 1fr) auto;
align-items: center;
gap: 18px;
padding: 10px 20px 10px 16px;
gap: 20px;
padding: 10px 16px 10px 16px;
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--surface) 94%, transparent);
backdrop-filter: blur(12px);
}
.mini-progress {
display: none;
}
.now {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
padding: 0;
border: 0;
background: none;
text-align: left;
}
.disc {
position: relative;
flex-shrink: 0;
border-radius: 50%;
box-shadow: 0 0 0 3px var(--ink), var(--shadow);
}
@@ -87,21 +150,38 @@ function onSeek(e: MouseEvent) {
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.t,
.a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.now:hover .t {
color: var(--accent);
}
.tag {
margin-left: 4px;
padding: 0 4px;
border: 1px solid var(--line-strong);
border-radius: 3px;
font: 600 9px/14px var(--font-mono);
color: var(--ink-3);
vertical-align: 2px;
}
.a {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lyric {
@@ -109,11 +189,63 @@ function onSeek(e: MouseEvent) {
color: var(--ink);
}
.controls,
.extras {
display: flex;
align-items: center;
gap: 4px;
}
.ctl {
position: relative;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-2);
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.ctl:hover:not(:disabled) {
color: var(--ink);
background: var(--surface-2);
}
.ctl:disabled {
opacity: 0.35;
cursor: default;
}
.ctl.on {
color: var(--accent);
}
.mode {
color: var(--ink-3);
}
.n {
position: absolute;
top: 1px;
right: -2px;
min-width: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--surface-2);
font-size: 9.5px;
line-height: 15px;
color: var(--ink-2);
}
.pp {
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0 4px;
border: 0;
border-radius: 50%;
background: var(--ink);
@@ -125,6 +257,17 @@ function onSeek(e: MouseEvent) {
transform: scale(1.06);
}
.pp.busy {
box-shadow: 0 0 0 3px var(--accent-soft);
animation: breathe 1.2s var(--ease) infinite;
}
@keyframes breathe {
50% {
box-shadow: 0 0 0 6px var(--accent-soft);
}
}
.timeline {
display: flex;
align-items: center;
@@ -179,31 +322,18 @@ function onSeek(e: MouseEvent) {
.vol {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-3);
gap: 2px;
}
.vol input {
width: 80px;
width: 76px;
accent-color: var(--accent);
}
.close {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-3);
}
.close:hover {
color: var(--ink);
background: var(--surface-2);
}
.slide-enter-active,
.slide-leave-active {
transition: transform 220ms var(--ease), opacity 220ms var(--ease);
@@ -225,16 +355,55 @@ function onSeek(e: MouseEvent) {
opacity: 0;
}
@media (max-width: 760px) {
.player {
grid-template-columns: auto 1fr auto auto;
gap: 12px;
padding: 8px 12px;
}
.panel-enter-active,
.panel-leave-active {
transition: opacity 180ms var(--ease), transform 180ms var(--ease);
}
.timeline,
.vol {
.panel-enter-from,
.panel-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (max-width: 1100px) {
.vol input,
.mode {
display: none;
}
}
@media (max-width: 760px) {
.player {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 6px;
padding: 8px 8px 8px 12px;
}
.mini-progress {
display: block;
position: absolute;
top: -1px;
left: 0;
height: 2px;
background: var(--accent);
}
.timeline,
.vol,
.prev,
.add,
.close {
display: none;
}
.disc :deep(.cover) {
width: 40px !important;
height: 40px !important;
}
.hole {
inset: 16px;
}
}
</style>
+246
View File
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { addToMyPlaylist, createMyPlaylist, myPlaylists, picker } from '../lib/playlists.ts';
import CoverMosaic from './CoverMosaic.vue';
import Icon from './Icon.vue';
const creating = ref(false);
const name = ref('');
const busy = ref(false);
const input = ref<HTMLInputElement | null>(null);
watch(
() => picker.open,
(open) => {
if (!open) return;
creating.value = false;
name.value = picker.suggestedName;
},
);
function close() {
picker.open = false;
picker.items = [];
}
async function choose(id: number) {
busy.value = true;
const ok = await addToMyPlaylist(id, picker.items);
busy.value = false;
if (ok) close();
}
async function create() {
const title = name.value.trim();
if (!title) return;
busy.value = true;
const created = await createMyPlaylist(title, picker.items);
busy.value = false;
if (created) close();
}
function startCreate() {
creating.value = true;
nextTick(() => input.value?.select());
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && picker.open) close();
}
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
</script>
<template>
<Transition name="pop">
<div v-if="picker.open" class="backdrop" @click.self="close">
<div class="dialog" role="dialog" aria-label="加入歌单">
<button class="x" type="button" aria-label="关闭" @click="close"><Icon name="x" :size="16" /></button>
<span class="eyebrow">Add to playlist</span>
<h3>加入歌单 <span class="count">{{ picker.items.length }} 首</span></h3>
<form v-if="creating" class="create" @submit.prevent="create">
<input ref="input" v-model="name" class="input" maxlength="100" placeholder="新歌单名称" />
<button class="btn sm primary" type="submit" :disabled="busy || !name.trim()">创建</button>
<button class="btn sm ghost" type="button" @click="creating = false">取消</button>
</form>
<button v-else class="row new" type="button" @click="startCreate">
<span class="art plus"><Icon name="plus" :size="18" /></span>
<span class="name">新建歌单</span>
</button>
<ul v-if="myPlaylists?.length" class="list">
<li v-for="p in myPlaylists" :key="p.id">
<button class="row" type="button" :disabled="busy" @click="choose(p.id)">
<span class="art"><CoverMosaic :covers="p.covers" :px="88" /></span>
<span class="name">{{ p.name }}</span>
<span class="mono faint n">{{ p.count }} 首</span>
</button>
</li>
</ul>
<p v-else-if="myPlaylists" class="faint empty">还没有歌单,新建一个吧</p>
</div>
</div>
</Transition>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 16px;
background: color-mix(in oklab, var(--ink) 35%, transparent);
backdrop-filter: blur(3px);
}
.dialog {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
width: 380px;
max-width: 100%;
max-height: min(560px, calc(100vh - 32px));
padding: 22px 14px 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
box-shadow: var(--shadow);
}
.eyebrow,
h3 {
padding: 0 10px;
}
h3 {
margin-bottom: 8px;
font-size: 19px;
}
.x {
position: absolute;
top: 10px;
right: 10px;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-3);
}
.x:hover {
background: var(--surface-2);
color: var(--ink);
}
.list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
border-top: 1px solid var(--line);
padding-top: 6px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 7px 10px;
border: 0;
border-radius: var(--radius-sm);
background: none;
text-align: left;
transition: background var(--dur) var(--ease);
}
.row:hover:not(:disabled) {
background: var(--surface-2);
}
.row:disabled {
opacity: 0.6;
cursor: progress;
}
.art {
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
}
.art.plus {
display: grid;
place-items: center;
border: 1.5px dashed var(--line-strong);
color: var(--accent);
}
.row.new:hover .plus {
border-color: var(--accent);
}
.name {
flex: 1;
min-width: 0;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.n {
font-size: 11.5px;
}
.create {
display: flex;
gap: 8px;
padding: 7px 10px;
}
.create .input {
flex: 1;
min-width: 0;
height: 30px;
}
.create .btn {
height: 30px;
}
.empty {
margin: 4px 10px 8px;
font-size: 12.5px;
}
.pop-enter-active,
.pop-leave-active {
transition: opacity 160ms var(--ease);
}
.pop-enter-active .dialog,
.pop-leave-active .dialog {
transition: transform 160ms var(--ease);
}
.pop-enter-from,
.pop-leave-to {
opacity: 0;
}
.pop-enter-from .dialog,
.pop-leave-to .dialog {
transform: translateY(6px) scale(0.98);
}
</style>
+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;
}
+78
View File
@@ -22,6 +22,8 @@ export interface TrackSummary {
downloaded?: boolean;
/** 曲库中同名文件(相对路径),不是通过本服务下载的 */
inLibrary?: string;
/** 曲库中的同名文件(不论是否经本服务下载),播放时可直接使用 */
localFile?: string;
}
export interface Paged<T> {
@@ -87,6 +89,7 @@ export interface TrackMeta {
publisher?: string;
downloaded?: boolean;
inLibrary?: string;
localFile?: string;
}
export interface QualityOption {
@@ -112,8 +115,12 @@ export interface Settings {
albumCover: boolean;
concurrency: number;
gapMs: number;
playQuality: PlayQuality;
preferLocal: boolean;
}
export type PlayQuality = 'standard' | 'high' | 'lossless';
export interface Health {
ok: boolean;
ffmpeg: boolean;
@@ -214,10 +221,69 @@ export interface LibraryEntry {
size: number;
mtime: number;
hasLyric?: boolean;
/** 音频文件由路径推断的歌名 / 歌手 / 专辑 */
title?: string;
artists?: string;
album?: string;
cover?: string;
covers?: string[];
}
export interface LocalTrack {
path: string;
name: string;
artists: string;
album: string;
cover?: string;
lrc: boolean;
size: number;
}
/** 自建歌单中的一首:在线曲目有 source + trackId,本地文件有 path */
export interface PlaylistItemInput {
source?: Source | null;
id?: string | null;
path?: string | null;
name: string;
artists: string;
album: string;
albumId?: string | null;
cover?: string | null;
duration?: number | null;
}
export interface PlaylistItem {
id: number;
key: string;
source: Source | null;
trackId: string | null;
path: string | null;
name: string;
artists: string;
album: string;
albumId: string | null;
cover: string | null;
duration: number | null;
addedAt: string;
localFile?: string;
missing?: boolean;
}
export interface MyPlaylistSummary {
id: number;
name: string;
description: string;
count: number;
duration: number;
covers: string[];
createdAt: string;
updatedAt: string;
}
export interface MyPlaylist extends MyPlaylistSummary {
items: PlaylistItem[];
}
export interface LibraryListing {
path: string;
hasCover: boolean;
@@ -332,8 +398,20 @@ export const api = {
rebuildLibraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index', json('POST')),
artistImages: () => request<{ job: ArtistImageJob | null; missing: string[] }>('/library/artist-images'),
startArtistImages: () => request<ArtistImageJob>('/library/artist-images', json('POST')),
libraryTracks: (path: string) => request<{ items: LocalTrack[]; truncated: boolean }>(`/library/tracks?${qs({ path })}`),
trashInfo: () => request<{ count: number; size: number }>('/library/trash'),
emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
myPlaylists: () => request<{ items: MyPlaylistSummary[] }>('/playlists').then((r) => r.items),
myPlaylist: (id: number) => request<MyPlaylist>(`/playlists/${id}`),
createPlaylist: (name: string, items?: PlaylistItemInput[], description?: string) =>
request<MyPlaylistSummary>('/playlists', json('POST', { name, description, items })),
updatePlaylist: (id: number, patch: { name?: string; description?: string }) => request<MyPlaylistSummary>(`/playlists/${id}`, json('PATCH', patch)),
deletePlaylist: (id: number) => request(`/playlists/${id}`, { method: 'DELETE' }),
addToPlaylist: (id: number, items: PlaylistItemInput[]) =>
request<{ added: number; duplicates: number }>(`/playlists/${id}/items`, json('POST', { items })),
removeFromPlaylist: (id: number, ids: number[]) => request<{ removed: number }>(`/playlists/${id}/items/remove`, json('POST', { ids })),
reorderPlaylist: (id: number, ids: number[]) => request(`/playlists/${id}/order`, json('PUT', { ids })),
retagJob: () => request<{ job: RetagJob | null }>('/library/retag').then((r) => r.job),
retag: (path: string, source: Source, onlyMissingLyric: boolean) =>
request<RetagJob>('/library/retag', json('POST', { path, source, onlyMissingLyric })),
+7
View File
@@ -40,6 +40,13 @@ export function formatDuration(ms?: number | null): string {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
/** 总时长:1 小时 12 分 / 38 分钟 */
export function formatTotal(ms?: number | null): string {
if (!ms) return '';
const min = Math.round(ms / 60000);
return min >= 60 ? `${Math.floor(min / 60)} 小时 ${min % 60} 分` : `${Math.max(1, min)} 分钟`;
}
export function formatSeconds(sec: number): string {
if (!Number.isFinite(sec)) return '0:00';
const s = Math.floor(sec);
+482 -56
View File
@@ -1,37 +1,72 @@
// 试听播放器:单个 <audio>,支持在线音源(服务端代理)与本地曲库文件,并同步滚动歌词
import { computed, reactive, ref } from 'vue';
import { api, type Source } from './api.ts';
import { toastError } from './store.ts';
// 播放器:单个 <audio> + 播放队列。在线曲目经服务端代理,曲库文件直接读取;
// 同步滚动歌词,接入系统媒体控制(锁屏 / 耳机按键),队列与进度保存在本机
import { computed, reactive, shallowRef } from 'vue';
import { api, type PlaylistItem, type Source, type TrackSummary } from './api.ts';
import { coverThumb } from './format.ts';
import { settings, toast, toastError } from './store.ts';
export interface PlayItem {
/** netease:123 / qq:xxx / file:相对路径 */
key: string;
title: string;
artists: string;
album?: string;
cover?: string;
src: string;
/** 在线曲目用于加载歌词 */
/** 在线曲目:加载歌词、跳转专辑 */
source?: Source;
id?: string;
albumId?: string;
/** 本地文件旁的 .lrc */
lrc?: string;
/** 实际播放的是曲库文件 */
local?: boolean;
/** 毫秒,加载前先显示 */
duration?: number;
}
interface LrcLine {
export type PlayMode = 'list' | 'loop' | 'one' | 'shuffle';
export const MODE_LABEL: Record<PlayMode, string> = { list: '顺序播放', loop: '列表循环', one: '单曲循环', shuffle: '随机播放' };
const MODES: PlayMode[] = ['loop', 'one', 'shuffle', 'list'];
export interface LrcLine {
t: number;
text: string;
trans?: string;
}
export const player = reactive({
current: null as PlayItem | null,
index: -1,
mode: 'loop' as PlayMode,
playing: false,
loading: false,
time: 0,
duration: 0,
volume: 0.85,
muted: false,
panel: null as null | 'queue' | 'lyric',
/** 当前队列的来源,如 playlist:3,用于在来源页面上显示播放状态 */
context: '',
});
const lyric = ref<LrcLine[]>([]);
let audio: HTMLAudioElement | null = null;
/** 播放队列可能有上千首:用 shallowRef,整体替换触发更新 */
export const queue = shallowRef<PlayItem[]>([]);
export const lyric = shallowRef<LrcLine[]>([]);
function parseLrc(text: string | undefined): LrcLine[] {
let audio: HTMLAudioElement | null = null;
/** 当前曲目已经交给 <audio>(恢复的队列在点播放前不加载) */
let attached = false;
/** 恢复上次进度:元数据加载后跳转 */
let resumeAt = 0;
let errorStreak = 0;
let prefetched = '';
/** 播放顺序 → 队列下标;随机模式下是洗牌后的顺序 */
let order: number[] = [];
// ---------- 歌词 ----------
function parseLines(text: string | undefined): LrcLine[] {
if (!text) return [];
const out: LrcLine[] = [];
for (const line of text.split('\n')) {
@@ -43,86 +78,477 @@ function parseLrc(text: string | undefined): LrcLine[] {
return out.sort((a, b) => a.t - b.t);
}
export const currentLyric = computed(() => {
const lines = lyric.value;
let found = '';
function parseLrc(lrc: string | undefined, tlyric?: string): LrcLine[] {
const lines = parseLines(lrc);
if (!tlyric) return lines;
const trans = new Map(parseLines(tlyric).map((l) => [l.t.toFixed(2), l.text]));
for (const l of lines) {
if (l.t > player.time + 0.2) break;
found = l.text;
const t = trans.get(l.t.toFixed(2));
if (t && t !== '//' && t !== l.text) l.trans = t;
}
return lines;
}
/** 二分查找当前行,timeupdate 每秒触发数次,长歌词也不必整表扫描 */
export const lyricIndex = computed(() => {
const lines = lyric.value;
const t = player.time + 0.2;
let lo = 0;
let hi = lines.length - 1;
let found = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (lines[mid]!.t <= t) {
found = mid;
lo = mid + 1;
} else hi = mid - 1;
}
return found;
});
function ensureAudio(): HTMLAudioElement {
if (audio) return audio;
audio = new Audio();
audio.preload = 'auto';
audio.volume = player.volume;
audio.addEventListener('timeupdate', () => (player.time = audio!.currentTime));
audio.addEventListener('durationchange', () => (player.duration = audio!.duration || 0));
audio.addEventListener('play', () => (player.playing = true));
audio.addEventListener('pause', () => (player.playing = false));
audio.addEventListener('waiting', () => (player.loading = true));
audio.addEventListener('playing', () => (player.loading = false));
audio.addEventListener('ended', () => (player.playing = false));
audio.addEventListener('error', () => {
player.loading = false;
player.playing = false;
if (player.current) toastError(new Error('试听失败:音源不可用'));
});
return audio;
}
export const currentLyric = computed(() => lyric.value[lyricIndex.value]?.text ?? '');
export function play(item: PlayItem) {
const a = ensureAudio();
if (player.current?.key === item.key) {
toggle();
return;
}
player.current = item;
player.time = 0;
player.duration = 0;
player.loading = true;
function loadLyric(item: PlayItem) {
lyric.value = [];
a.src = item.src;
a.play().catch(() => {});
const apply = (lines: LrcLine[]) => {
if (player.current?.key === item.key) lyric.value = lines;
};
if (item.source && item.id) {
api
.lyric(item.source, item.id)
.then((l) => {
if (player.current?.key === item.key) lyric.value = parseLrc(l.lrc);
})
.then((l) => apply(parseLrc(l.lrc, l.tlyric)))
.catch(() => {});
} else if (item.lrc) {
fetch(item.lrc)
.then((r) => (r.ok ? r.text() : ''))
.then((text) => apply(parseLrc(text)))
.catch(() => {});
}
}
export function playOnline(t: { source: Source; id: string; name: string; artists: string; cover?: string }) {
play({ key: `${t.source}:${t.id}`, title: t.name, artists: t.artists, cover: t.cover, src: api.streamUrl(t.source, t.id), source: t.source, id: t.id });
// ---------- 曲目转换 ----------
export function fromTrack(t: TrackSummary): PlayItem {
const local = settings.value?.preferLocal !== false ? t.localFile : undefined;
return {
key: `${t.source}:${t.id}`,
title: t.name,
artists: t.artists,
album: t.album,
cover: t.cover,
source: t.source,
id: t.id,
albumId: t.albumId,
duration: t.duration,
src: local ? api.libraryFileUrl(local) : api.streamUrl(t.source, t.id),
local: !!local,
};
}
/** lrc:是否有同名 .lrc;未知(歌单里的文件)时也去尝试读取 */
export function fromLocal(t: { path: string; name: string; artists: string; album?: string; cover?: string; lrc?: boolean }): PlayItem {
return {
key: `file:${t.path}`,
title: t.name,
artists: t.artists,
album: t.album,
cover: t.cover,
src: api.libraryFileUrl(t.path),
lrc: t.lrc === false ? undefined : api.libraryFileUrl(t.path.replace(/\.[^./]+$/, '.lrc')),
local: true,
};
}
export function fromPlaylistItem(i: PlaylistItem): PlayItem {
if (i.path) return { ...fromLocal({ path: i.path, name: i.name, artists: i.artists, album: i.album, cover: i.cover ?? undefined }), duration: i.duration ?? undefined };
return fromTrack({
source: i.source!,
id: i.trackId!,
name: i.name,
artists: i.artists,
album: i.album,
albumId: i.albumId ?? undefined,
cover: i.cover ?? undefined,
duration: i.duration ?? undefined,
localFile: i.localFile,
});
}
// ---------- 本机保存 ----------
const STORE_KEY = 'sx-player';
let persistTimer: ReturnType<typeof setTimeout> | undefined;
function persistNow() {
clearTimeout(persistTimer);
try {
if (!queue.value.length) localStorage.removeItem(STORE_KEY);
else
localStorage.setItem(
STORE_KEY,
JSON.stringify({ queue: queue.value, index: player.index, mode: player.mode, volume: player.volume, time: player.time }),
);
} catch {}
}
function persist() {
clearTimeout(persistTimer);
persistTimer = setTimeout(persistNow, 400);
}
function restore() {
try {
const s = JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
if (!s) return;
if (typeof s.volume === 'number') player.volume = Math.min(1, Math.max(0, s.volume));
if (MODES.includes(s.mode)) player.mode = s.mode;
if (Array.isArray(s.queue) && s.queue.length) {
queue.value = s.queue.filter((i: PlayItem) => i && typeof i.key === 'string' && typeof i.src === 'string');
player.index = Math.min(Math.max(0, Number(s.index) || 0), queue.value.length - 1);
player.current = queue.value[player.index] ?? null;
resumeAt = Number(s.time) || 0;
player.time = resumeAt;
player.duration = (player.current?.duration ?? 0) / 1000;
rebuildOrder();
}
} catch {}
}
// ---------- 系统媒体控制 ----------
const media = typeof navigator !== 'undefined' && 'mediaSession' in navigator ? navigator.mediaSession : null;
function updateMetadata(item: PlayItem) {
if (!media) return;
const art = coverThumb(item.cover, 512);
media.metadata = new MediaMetadata({
title: item.title,
artist: item.artists,
album: item.album ?? '',
artwork: art ? [{ src: new URL(art, location.href).href, sizes: '512x512' }] : [],
});
}
function updatePosition() {
if (!media || !audio || !Number.isFinite(audio.duration) || !audio.duration) return;
try {
media.setPositionState({ duration: audio.duration, position: Math.min(audio.currentTime, audio.duration), playbackRate: audio.playbackRate });
} catch {}
}
function setupMediaSession() {
if (!media) return;
const handlers: Array<[MediaSessionAction, MediaSessionActionHandler]> = [
['play', () => resume()],
['pause', () => audio?.pause()],
['previoustrack', () => prev()],
['nexttrack', () => next()],
['seekto', (d) => d.seekTime !== undefined && seekTo(d.seekTime)],
['seekbackward', (d) => seekTo(player.time - (d.seekOffset ?? 10))],
['seekforward', (d) => seekTo(player.time + (d.seekOffset ?? 10))],
];
for (const [action, fn] of handlers) {
try {
media.setActionHandler(action, fn);
} catch {}
}
}
// ---------- <audio> ----------
function ensureAudio(): HTMLAudioElement {
if (audio) return audio;
const a = new Audio();
audio = a;
a.preload = 'auto';
a.volume = player.volume;
a.muted = player.muted;
a.addEventListener('timeupdate', () => {
player.time = a.currentTime;
prefetchNext();
});
a.addEventListener('durationchange', () => {
if (Number.isFinite(a.duration) && a.duration) player.duration = a.duration;
updatePosition();
});
a.addEventListener('loadedmetadata', () => {
if (resumeAt && resumeAt < a.duration - 1) a.currentTime = resumeAt;
resumeAt = 0;
});
a.addEventListener('play', () => {
player.playing = true;
if (media) media.playbackState = 'playing';
});
a.addEventListener('pause', () => {
player.playing = false;
if (media) media.playbackState = 'paused';
persistNow();
});
a.addEventListener('waiting', () => (player.loading = true));
a.addEventListener('playing', () => {
player.loading = false;
errorStreak = 0;
});
a.addEventListener('canplay', () => (player.loading = false));
a.addEventListener('seeked', updatePosition);
a.addEventListener('ended', () => step(1, true));
a.addEventListener('error', onError);
setupMediaSession();
return a;
}
function onError() {
player.loading = false;
player.playing = false;
const item = player.current;
if (!item || !attached) return;
errorStreak++;
// 连续失败时自动跳过,但不无限循环
if (queue.value.length > 1 && errorStreak < Math.min(queue.value.length, 5)) {
toast(`无法播放「${item.title}」,已跳到下一首`, 'error');
step(1, false);
} else {
toastError(new Error(`播放失败:「${item.title}」音源不可用`));
}
}
/** 快播完时预先请求下一首在线曲目的第一个字节,让服务端提前解析好音源直链 */
function prefetchNext() {
if (!audio || !player.duration || player.duration - player.time > 25) return;
const nextItem = peekNext();
if (!nextItem || nextItem.local || prefetched === nextItem.key) return;
prefetched = nextItem.key;
fetch(nextItem.src, { headers: { Range: 'bytes=0-0' } })
.then((r) => r.body?.cancel())
.catch(() => {});
}
/** keepResume:继续上次保存的进度(仅恢复的队列首次播放时) */
function load(i: number, autoplay = true, keepResume = false) {
const item = queue.value[i];
if (!item) return;
const a = ensureAudio();
if (!keepResume) resumeAt = 0;
prefetched = '';
player.index = i;
player.current = item;
player.time = resumeAt;
player.duration = (item.duration ?? 0) / 1000;
player.loading = autoplay;
attached = true;
a.src = item.src;
if (autoplay) {
a.play().catch((e: DOMException) => {
// 浏览器拦截自动播放时保持暂停状态,等用户点击
if (e.name === 'NotAllowedError') player.loading = false;
});
}
loadLyric(item);
updateMetadata(item);
persist();
}
// ---------- 队列与顺序 ----------
function rebuildOrder() {
const n = queue.value.length;
order = Array.from({ length: n }, (_, i) => i);
if (player.mode !== 'shuffle' || n < 2) return;
for (let i = n - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j]!, order[i]!];
}
// 当前曲目排在最前,接下来的才是随机的
const at = order.indexOf(player.index);
if (at > 0) [order[0], order[at]] = [order[at]!, order[0]!];
}
function peekNext(): PlayItem | undefined {
const n = queue.value.length;
if (!n || player.mode === 'one') return undefined;
let pos = order.indexOf(player.index) + 1;
if (pos >= n) {
if (player.mode === 'list') return undefined;
pos = 0;
}
return queue.value[order[pos]!];
}
function step(dir: 1 | -1, auto: boolean) {
const n = queue.value.length;
if (!n) return;
if (auto && player.mode === 'one') {
seekTo(0);
audio?.play().catch(() => {});
return;
}
let pos = Math.max(0, order.indexOf(player.index)) + dir;
if (pos >= n || pos < 0) {
if (auto && player.mode === 'list') return; // 顺序播放到底就停
if (player.mode === 'shuffle' && pos >= n) {
rebuildOrder();
pos = n > 1 ? 1 : 0;
} else pos = (pos + n) % n;
}
load(order[pos]!);
}
/** 用一组曲目替换播放队列并从 start 开始播放;点的是正在播放的那首则暂停 / 继续 */
export function playQueue(items: PlayItem[], start = 0, context = '') {
const target = items[start];
if (!target) return;
const same = player.current?.key === target.key && attached;
player.context = context;
queue.value = items.slice();
player.index = start;
rebuildOrder();
if (same) {
persist();
toggle();
} else load(start);
}
export function play(item: PlayItem) {
playQueue([item]);
}
export function playTracks(tracks: TrackSummary[], start = 0) {
playQueue(tracks.map(fromTrack), start);
}
/** 随机播放整组 */
export function shuffleQueue(items: PlayItem[], context = '') {
if (!items.length) return;
player.mode = 'shuffle';
playQueue(items, Math.floor(Math.random() * items.length), context);
}
/** 加到「下一首播放」;队列为空时直接播放 */
export function playNext(items: PlayItem[]) {
if (!items.length) return;
if (!player.current) return playQueue(items);
const add = items.filter((i) => i.key !== player.current!.key);
const keys = new Set(add.map((i) => i.key));
// 已在队列中的挪到当前曲目之后;at 是当前曲目在新队列中的位置
let at = 0;
const rest = queue.value.filter((it, idx) => {
const keep = idx === player.index || !keys.has(it.key);
if (keep && idx < player.index) at++;
return keep;
});
queue.value = [...rest.slice(0, at + 1), ...add, ...rest.slice(at + 1)];
player.index = at;
rebuildOrder();
persist();
toast(`已添加 ${items.length} 首到下一首播放`, 'ok');
}
export function playAt(i: number) {
if (i === player.index && attached) toggle();
else load(i);
}
export function removeAt(i: number) {
const list = queue.value.slice();
if (!list[i]) return;
list.splice(i, 1);
if (!list.length) return stop();
const wasCurrent = i === player.index;
queue.value = list;
if (i < player.index) player.index--;
rebuildOrder();
if (wasCurrent) load(Math.min(i, list.length - 1), player.playing);
else persist();
}
export function next() {
step(1, false);
}
export function prev() {
if (player.time > 3) seekTo(0);
else step(-1, false);
}
export function cycleMode() {
player.mode = MODES[(MODES.indexOf(player.mode) + 1) % MODES.length]!;
rebuildOrder();
persist();
}
function resume() {
if (!player.current) return;
if (!attached) load(player.index, true, true);
else audio?.play().catch(() => {});
}
export function toggle() {
if (!audio || !player.current) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
if (!player.current) return;
if (!attached || audio?.paused) resume();
else audio?.pause();
}
export function seekTo(sec: number) {
const t = Math.max(0, Math.min(sec, player.duration || sec));
if (audio && attached) audio.currentTime = t;
else resumeAt = t;
player.time = t;
}
export function seek(ratio: number) {
if (audio && player.duration) audio.currentTime = Math.max(0, Math.min(1, ratio)) * player.duration;
if (player.duration) seekTo(Math.max(0, Math.min(1, ratio)) * player.duration);
}
export function setVolume(v: number) {
player.volume = v;
if (audio) audio.volume = v;
player.muted = false;
if (audio) {
audio.volume = v;
audio.muted = false;
}
persist();
}
export function toggleMute() {
player.muted = !player.muted;
if (audio) audio.muted = player.muted;
}
export function stop() {
audio?.pause();
if (audio) audio.removeAttribute('src');
audio?.removeAttribute('src');
audio?.load();
attached = false;
resumeAt = 0;
queue.value = [];
order = [];
player.current = null;
player.index = -1;
player.playing = false;
player.loading = false;
player.panel = null;
player.context = '';
lyric.value = [];
if (media) media.metadata = null;
persistNow();
}
export function isPlaying(key: string): boolean {
return player.current?.key === key && player.playing;
}
// ---------- 初始化 ----------
restore();
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', persistNow);
document.addEventListener('visibilitychange', () => document.visibilityState === 'hidden' && persistNow());
// 空格键播放 / 暂停(输入框、按钮等聚焦时不拦截)
window.addEventListener('keydown', (e) => {
if (e.code !== 'Space' || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || !player.current) return;
if ((e.target as HTMLElement).closest?.('input, textarea, select, button, a, [contenteditable]')) return;
e.preventDefault();
toggle();
});
}
+76
View File
@@ -0,0 +1,76 @@
// 自建歌单:列表缓存、「加入歌单」弹窗状态与曲目转换
import { reactive, ref, watch } from 'vue';
import { api, type LocalTrack, type MyPlaylistSummary, type PlaylistItem, type PlaylistItemInput, type TrackSummary } from './api.ts';
import type { PlayItem } from './player.ts';
import { playlistTick, toast, toastError } from './store.ts';
export const myPlaylists = ref<MyPlaylistSummary[] | null>(null);
let loading: Promise<void> | null = null;
export function loadMyPlaylists(force = false): Promise<void> {
if (myPlaylists.value && !force) return Promise.resolve();
loading ??= api
.myPlaylists()
.then((list) => void (myPlaylists.value = list))
.catch(toastError)
.finally(() => (loading = null));
return loading;
}
// 其他页面 / 设备改动后,已加载过的列表跟着刷新
watch(playlistTick, () => myPlaylists.value && loadMyPlaylists(true));
// ---------- 转换 ----------
export function inputFromTrack(t: TrackSummary): PlaylistItemInput {
return { source: t.source, id: t.id, name: t.name, artists: t.artists, album: t.album, albumId: t.albumId, cover: t.cover, duration: t.duration };
}
export function inputFromLocal(t: Pick<LocalTrack, 'path' | 'name' | 'artists' | 'album'> & { cover?: string }): PlaylistItemInput {
return { path: t.path, name: t.name, artists: t.artists, album: t.album, cover: t.cover };
}
export function inputFromPlayItem(p: PlayItem): PlaylistItemInput {
if (p.source && p.id) return { source: p.source, id: p.id, name: p.title, artists: p.artists, album: p.album ?? '', albumId: p.albumId, cover: p.cover, duration: p.duration };
return { path: p.key.slice('file:'.length), name: p.title, artists: p.artists, album: p.album ?? '', cover: p.cover, duration: p.duration };
}
export function inputFromItem(i: PlaylistItem): PlaylistItemInput {
return { source: i.source, id: i.trackId, path: i.path, name: i.name, artists: i.artists, album: i.album, albumId: i.albumId, cover: i.cover, duration: i.duration };
}
// ---------- 「加入歌单」弹窗 ----------
export const picker = reactive({ open: false, items: [] as PlaylistItemInput[], suggestedName: '' });
export function pickPlaylist(items: PlaylistItemInput[], suggestedName = '') {
if (!items.length) return;
picker.items = items;
picker.suggestedName = suggestedName;
picker.open = true;
loadMyPlaylists();
}
export async function addToMyPlaylist(id: number, items: PlaylistItemInput[]): Promise<boolean> {
try {
const { added, duplicates } = await api.addToPlaylist(id, items);
const name = myPlaylists.value?.find((p) => p.id === id)?.name ?? '歌单';
toast(added ? `已加入「${name}」${added} 首${duplicates ? `,${duplicates} 首已在歌单中` : ''}` : `全部已在「${name}」中`, added ? 'ok' : 'info');
return true;
} catch (e) {
toastError(e);
return false;
}
}
export async function createMyPlaylist(name: string, items: PlaylistItemInput[] = []): Promise<MyPlaylistSummary | null> {
try {
const created = await api.createPlaylist(name, items);
toast(`已创建歌单「${created.name}」${items.length ? `,共 ${created.count} 首` : ''}`, 'ok');
loadMyPlaylists(true);
return created;
} catch (e) {
toastError(e);
return null;
}
}
+3
View File
@@ -30,6 +30,8 @@ export const retagJob = ref<RetagJob | null>(null);
export const artistImageJob = ref<ArtistImageJob | null>(null);
/** 每次有新的下载历史时递增,供页面刷新 */
export const historyTick = ref(0);
/** 自建歌单有变动时递增(含其他标签页 / 设备的修改) */
export const playlistTick = ref(0);
/** 本次会话内完成下载的曲目 */
export const downloadedNow = reactive(new Set<string>());
export const connected = ref(false);
@@ -112,6 +114,7 @@ function connectEvents() {
on('settings', (s: Settings) => (settings.value = s));
on('account', () => refreshAccounts(true));
on('history', () => historyTick.value++);
on('playlists', () => playlistTick.value++);
on('retag', (j: RetagJob) => (retagJob.value = j));
on('artist-images', (j: ArtistImageJob) => (artistImageJob.value = j));
source.onerror = () => (connected.value = false);
+3
View File
@@ -5,6 +5,7 @@ export const navItems = [
{ path: '/', name: 'search', label: '搜索' },
{ path: '/queue', name: 'queue', label: '队列' },
{ path: '/library', name: 'library', label: '曲库' },
{ path: '/playlists', name: 'playlists', label: '歌单' },
{ path: '/history', name: 'history', label: '历史' },
{ path: '/settings', name: 'settings', label: '设置' },
] as const;
@@ -18,6 +19,8 @@ export const router = createRouter({
{ path: '/artist/:source/:id', name: 'artist', component: () => import('./views/ArtistView.vue'), props: true },
{ path: '/queue', name: 'queue', component: () => import('./views/QueueView.vue') },
{ path: '/library', name: 'library', component: () => import('./views/LibraryView.vue') },
{ path: '/playlists', name: 'playlists', component: () => import('./views/PlaylistsView.vue') },
{ path: '/playlists/:id', name: 'my-playlist', component: () => import('./views/MyPlaylistView.vue'), props: true },
{ path: '/history', name: 'history', component: () => import('./views/HistoryView.vue') },
{ path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
+5 -6
View File
@@ -5,7 +5,8 @@ import Icon from '../components/Icon.vue';
import TrackList from '../components/TrackList.vue';
import { api, type AlbumDetail, type Source } from '../lib/api.ts';
import { formatDate } from '../lib/format.ts';
import { playOnline } from '../lib/player.ts';
import { playTracks } from '../lib/player.ts';
import { inputFromTrack, pickPlaylist } from '../lib/playlists.ts';
const props = defineProps<{ source: Source; id: string }>();
const album = ref<AlbumDetail | null>(null);
@@ -21,10 +22,7 @@ onMounted(async () => {
const tracks = computed(() => (album.value?.tracks ?? []).map((t) => ({ ...t, cover: t.cover || album.value?.cover })));
function playFirst() {
const t = album.value?.tracks[0];
if (t) playOnline({ ...t, cover: t.cover || album.value?.cover });
}
</script>
<template>
@@ -50,7 +48,8 @@ function playFirst() {
<span v-else>{{ album.artist }}</span>
</template>
<template #actions>
<button class="btn" type="button" @click="playFirst"><Icon name="play" :size="14" /> 试听</button>
<button class="btn primary" type="button" :disabled="!tracks.length" @click="playTracks(tracks)"><Icon name="play" :size="14" /> 播放全部</button>
<button class="btn" type="button" :disabled="!tracks.length" @click="pickPlaylist(tracks.map(inputFromTrack), album.name)"><Icon name="listPlus" :size="16" /> 加入歌单</button>
</template>
</DetailHeader>
<TrackList :tracks="tracks" :show-cover="false" :show-album="false" numbering="track" :group="`专辑 · ${album.name}`" />
+1 -1
View File
@@ -81,7 +81,7 @@ const qualities = computed(() => {
<template>
<section class="page">
<header class="page-head">
<span class="eyebrow">04 · History</span>
<span class="eyebrow">05 · History</span>
<div class="row-between">
<h2>下载历史</h2>
<div class="exports">
+67 -13
View File
@@ -4,7 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
import Icon from '../components/Icon.vue';
import { api, type LibraryEntry, type LibraryListing, type Source } from '../lib/api.ts';
import { formatSize } from '../lib/format.ts';
import { isPlaying, play } from '../lib/player.ts';
import { fromLocal, isPlaying, player, playQueue } from '../lib/player.ts';
import { inputFromLocal, pickPlaylist } from '../lib/playlists.ts';
import { artistImageJob, historyTick, retagJob, toast, toastError } from '../lib/store.ts';
const route = useRoute();
@@ -71,14 +72,53 @@ function stripExt(name: string) {
return name.replace(/\.[^.]+$/, '');
}
function playFile(e: LibraryEntry) {
play({
key: `file:${e.path}`,
title: stripExt(e.name).replace(/^\d+\.\s*/, ''),
artists: artistName.value || folderName.value,
cover: listing.value?.hasCover ? api.libraryFileUrl(`${path.value}/cover.jpg`) : undefined,
src: api.libraryFileUrl(e.path),
});
/** 当前文件夹的封面图 */
const dirCover = computed(() => {
const img = other.value.find((o) => /^(cover|folder)\.(jpe?g|png)$/i.test(o.name));
return img ? api.libraryFileUrl(img.path) : undefined;
});
function localOf(e: LibraryEntry) {
return {
path: e.path,
name: e.title || stripExt(e.name).replace(/^\d+\.\s*/, ''),
artists: e.artists || artistName.value || folderName.value,
album: e.album ?? '',
cover: dirCover.value,
lrc: !!e.hasLyric,
};
}
/** 播放当前文件夹,从点击的那首开始 */
function playFile(i: number) {
playQueue(audio.value.map((e) => fromLocal(localOf(e))), i, `library:${path.value}`);
}
/** 当前目录(含子文件夹)的全部音频 */
async function folderTracks() {
const { items, truncated } = await api.libraryTracks(path.value);
if (truncated) toast(`文件过多,只取前 ${items.length} 首`);
return items.map((t) => ({ ...t, cover: t.cover ? api.libraryFileUrl(t.cover) : undefined }));
}
async function playFolder() {
try {
const items = await folderTracks();
if (!items.length) return toast('文件夹中没有音频文件');
playQueue(items.map(fromLocal), 0, `library:${path.value}`);
} catch (e) {
toastError(e);
}
}
async function addFolder() {
try {
const items = await folderTracks();
if (!items.length) return toast('文件夹中没有音频文件');
pickPlaylist(items.map(inputFromLocal), folderName.value);
} catch (e) {
toastError(e);
}
}
async function m3u() {
@@ -161,6 +201,8 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
<div class="tools">
<button class="btn sm" type="button" title="补全标签与歌词" @click="retagOpen = !retagOpen"><Icon name="tag" :size="15" /> 补全标签</button>
<template v-if="path">
<button class="btn sm" type="button" title="播放此文件夹(含子文件夹)" @click="playFolder"><Icon name="play" :size="13" /> 播放</button>
<button class="btn sm" type="button" title="把此文件夹(含子文件夹)加入歌单" @click="addFolder"><Icon name="listPlus" :size="15" /> 加入歌单</button>
<button class="btn sm" type="button" title="生成 M3U 播放列表" @click="m3u"><Icon name="list" :size="15" /> M3U</button>
<a class="btn sm" :href="api.libraryZipUrl(path)" title="打包下载"><Icon name="zip" :size="15" /> ZIP</a>
<button class="btn sm danger" type="button" title="移入回收站" @click="trashPath(path, folderName)"><Icon name="trash" :size="15" /></button>
@@ -245,14 +287,15 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
</div>
<ol v-if="audio.length" class="files">
<li v-for="f in audio" :key="f.path" class="file" :class="{ current: isPlaying(`file:${f.path}`) }">
<button type="button" class="play" @click="playFile(f)" :aria-label="`播放 ${f.name}`">
<li v-for="(f, i) in audio" :key="f.path" class="file" :class="{ current: player.current?.key === `file:${f.path}` }" @dblclick="playFile(i)">
<button type="button" class="play" @click="playFile(i)" :aria-label="`播放 ${f.name}`">
<Icon :name="isPlaying(`file:${f.path}`) ? 'pause' : 'play'" :size="14" />
</button>
<span class="fname">{{ stripExt(f.name) }}</span>
<span class="ext mono">{{ f.name.split('.').pop()?.toUpperCase() }}</span>
<span class="lrc mono" :class="{ on: f.hasLyric }" :title="f.hasLyric ? '有歌词' : '缺少歌词'">LRC</span>
<span class="size mono faint">{{ formatSize(f.size) }}</span>
<button type="button" class="del add" title="加入歌单" @click="pickPlaylist([inputFromLocal(localOf(f))])"><Icon name="listPlus" :size="16" /></button>
<button type="button" class="del" title="移入回收站" @click="trashPath(f.path, f.name)"><Icon name="trash" :size="15" /></button>
</li>
</ol>
@@ -517,11 +560,13 @@ details li.bad {
.file {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto auto 72px 30px;
grid-template-columns: 28px minmax(0, 1fr) auto auto 72px 30px 30px;
align-items: center;
gap: 14px;
padding: 9px 6px;
border-bottom: 1px solid var(--line);
content-visibility: auto;
contain-intrinsic-size: auto 47px;
}
.file:hover {
@@ -599,6 +644,15 @@ details li.bad {
color: var(--danger);
}
.del.add {
margin-right: -10px;
}
.del.add:hover {
color: var(--accent);
background: var(--accent-soft);
}
.other {
margin-top: 16px;
font-size: 12.5px;
@@ -639,7 +693,7 @@ details li.bad {
@media (max-width: 760px) {
.file {
grid-template-columns: 28px minmax(0, 1fr) auto 30px;
grid-template-columns: 28px minmax(0, 1fr) auto 30px 30px;
}
.ext,
+648
View File
@@ -0,0 +1,648 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import CoverArt from '../components/CoverArt.vue';
import CoverMosaic from '../components/CoverMosaic.vue';
import DetailHeader from '../components/DetailHeader.vue';
import Icon from '../components/Icon.vue';
import { api, type MyPlaylist, type PlaylistItem, type Source, type TrackSummary } from '../lib/api.ts';
import { formatDate, formatDuration, formatTotal, SOURCE_LABEL } from '../lib/format.ts';
import { fromPlaylistItem, isPlaying, player, playNext, playQueue, shuffleQueue } from '../lib/player.ts';
import { inputFromItem, loadMyPlaylists, pickPlaylist } from '../lib/playlists.ts';
import { defaultQualityFor, download, playlistTick, toast, toastError } from '../lib/store.ts';
const props = defineProps<{ id: string }>();
const router = useRouter();
const pid = computed(() => Number(props.id));
const playlist = ref<MyPlaylist | null>(null);
const error = ref('');
const filter = ref('');
const editing = ref(false);
const form = ref({ name: '', description: '' });
async function load() {
try {
playlist.value = await api.myPlaylist(pid.value);
error.value = '';
} catch (e) {
error.value = (e as Error).message;
}
}
onMounted(load);
// 其他标签页改动后同步;自己发起的改动已经乐观更新,忽略紧随其后的那次通知
let selfChange = 0;
watch(playlistTick, () => {
if (Date.now() - selfChange > 1500) load();
});
const items = computed(() => {
const list = playlist.value?.items ?? [];
const q = filter.value.trim().toLowerCase();
return q ? list.filter((t) => `${t.name} ${t.artists} ${t.album}`.toLowerCase().includes(q)) : list;
});
const playable = computed(() => items.value.filter((i) => !i.missing));
const context = computed(() => `playlist:${pid.value}`);
const covers = computed(() => playlist.value?.covers ?? []);
const localCount = computed(() => playlist.value?.items.filter((i) => i.path).length ?? 0);
const keyOf = (i: PlaylistItem) => (i.path ? `file:${i.path}` : `${i.source}:${i.trackId}`);
const sourceLabel = (i: PlaylistItem) => (i.source ? SOURCE_LABEL[i.source] : '本地');
function playFrom(item: PlaylistItem) {
if (item.missing) return;
const list = playable.value;
playQueue(list.map(fromPlaylistItem), Math.max(0, list.indexOf(item)), context.value);
}
// ---------- 编辑 ----------
function startEdit() {
if (!playlist.value) return;
form.value = { name: playlist.value.name, description: playlist.value.description };
editing.value = true;
}
async function saveEdit() {
try {
selfChange = Date.now();
const s = await api.updatePlaylist(pid.value, form.value);
if (playlist.value) Object.assign(playlist.value, { name: s.name, description: s.description });
editing.value = false;
loadMyPlaylists(true);
} catch (e) {
toastError(e);
}
}
async function remove() {
if (!playlist.value || !confirm(`删除歌单「${playlist.value.name}」?\n(只删除歌单,不会删除任何音乐文件)`)) return;
try {
await api.deletePlaylist(pid.value);
toast('歌单已删除');
loadMyPlaylists(true);
router.replace('/playlists');
} catch (e) {
toastError(e);
}
}
async function removeItem(item: PlaylistItem) {
if (!playlist.value) return;
const before = playlist.value.items;
playlist.value.items = before.filter((i) => i !== item);
playlist.value.count--;
selfChange = Date.now();
try {
await api.removeFromPlaylist(pid.value, [item.id]);
} catch (e) {
playlist.value.items = before;
playlist.value.count++;
toastError(e);
}
}
/** 下载歌单中曲库里还没有的在线曲目,按平台分别使用默认音质 */
const toDownload = computed(() => (playlist.value?.items ?? []).filter((i) => i.source && !i.localFile));
function downloadMissing() {
const bySource = new Map<Source, TrackSummary[]>();
for (const i of toDownload.value) {
const t: TrackSummary = { source: i.source!, id: i.trackId!, name: i.name, artists: i.artists, album: i.album, cover: i.cover ?? undefined };
bySource.set(t.source, [...(bySource.get(t.source) ?? []), t]);
}
for (const [source, tracks] of bySource) download(tracks, defaultQualityFor(source), `歌单 · ${playlist.value?.name}`);
}
// ---------- 拖动排序 ----------
const dragFrom = ref(-1);
const dragOver = ref(-1);
const canDrag = computed(() => !filter.value.trim());
function onDragStart(i: number, e: DragEvent) {
dragFrom.value = i;
e.dataTransfer?.setData('text/plain', String(i));
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDragOver(i: number) {
if (dragFrom.value >= 0) dragOver.value = i;
}
async function onDrop() {
const from = dragFrom.value;
const to = dragOver.value;
dragFrom.value = dragOver.value = -1;
if (!playlist.value || from < 0 || to < 0 || from === to) return;
const list = playlist.value.items.slice();
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved!);
const before = playlist.value.items;
playlist.value.items = list;
selfChange = Date.now();
try {
await api.reorderPlaylist(pid.value, list.map((i) => i.id));
} catch (e) {
playlist.value.items = before;
toastError(e);
}
}
function onDragEnd() {
dragFrom.value = dragOver.value = -1;
}
</script>
<template>
<section class="page">
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
<p v-if="error" class="state-msg">{{ error }}</p>
<template v-else-if="playlist">
<DetailHeader
kind="自建歌单"
:title="playlist.name"
:meta="[`${playlist.count} 首`, formatTotal(playlist.duration), localCount > 0 && `本地 ${localCount} 首`, `创建于 ${formatDate(playlist.createdAt)}`]"
:description="playlist.description"
>
<template #art><CoverMosaic :covers="covers" :px="500" /></template>
<template #actions>
<button class="btn primary" type="button" :disabled="!playable.length" @click="playQueue(playable.map(fromPlaylistItem), 0, context)">
<Icon name="play" :size="14" /> 播放全部
</button>
<button class="btn" type="button" :disabled="!playable.length" @click="shuffleQueue(playable.map(fromPlaylistItem), context)">
<Icon name="shuffle" :size="15" /> 随机
</button>
<button class="btn" type="button" title="编辑名称与简介" @click="startEdit"><Icon name="edit" :size="15" /> 编辑</button>
<button class="btn ghost danger" type="button" title="删除歌单" @click="remove"><Icon name="trash" :size="15" /></button>
</template>
</DetailHeader>
<Transition name="fold">
<form v-if="editing" class="panel" @submit.prevent="saveEdit">
<label class="fld">
<span class="faint">名称</span>
<input v-model="form.name" class="input" maxlength="100" required />
</label>
<label class="fld">
<span class="faint">简介</span>
<textarea v-model="form.description" class="input area" rows="3" maxlength="2000" placeholder="可选" />
</label>
<div class="panel-row">
<div class="spacer" />
<button class="btn sm ghost" type="button" @click="editing = false">取消</button>
<button class="btn sm primary" type="submit" :disabled="!form.name.trim()">保存</button>
</div>
</form>
</Transition>
<div class="toolbar">
<span class="muted small">
<template v-if="filter.trim()">筛选出 <b class="mono">{{ items.length }}</b> 首</template>
<template v-else>共 <b class="mono">{{ playlist.items.length }}</b> 首<span v-if="playlist.items.length > 1" class="faint"> · 拖动可排序</span></template>
</span>
<div class="spacer" />
<input v-model="filter" class="input filter" type="search" placeholder="在歌单中筛选…" />
<button class="btn sm" type="button" :disabled="!items.length" title="添加到其他歌单" @click="pickPlaylist(items.map(inputFromItem))">
<Icon name="listPlus" :size="15" /> 加入歌单
</button>
<button v-if="toDownload.length" class="btn sm" type="button" title="下载曲库里还没有的在线曲目" @click="downloadMissing">
<Icon name="download" :size="15" /> 下载缺少的 {{ toDownload.length }} 首
</button>
</div>
<ol v-if="items.length" class="rows" @dragover.prevent @drop.prevent="onDrop">
<li
v-for="(t, i) in items"
:key="t.id"
class="row"
:class="[
t.source ?? 'local',
{
current: player.current?.key === keyOf(t),
missing: t.missing,
dragging: dragFrom === i,
'drop-before': dragOver === i && dragFrom > i,
'drop-after': dragOver === i && dragFrom < i,
},
]"
:draggable="canDrag"
@dragstart="onDragStart(i, $event)"
@dragenter.prevent="onDragOver(i)"
@dragend="onDragEnd"
@dblclick="playFrom(t)"
>
<span class="grip" :class="{ off: !canDrag }" aria-hidden="true"><Icon name="grip" :size="14" /></span>
<span class="num">
<span class="mono faint idx">{{ String(i + 1).padStart(2, '0') }}</span>
<button type="button" class="play" :disabled="t.missing" :aria-label="`播放 ${t.name}`" @click="playFrom(t)">
<Icon :name="isPlaying(keyOf(t)) ? 'pause' : 'play'" :size="16" />
</button>
</span>
<button type="button" class="cover-btn" tabindex="-1" :disabled="t.missing" @click="playFrom(t)"><CoverArt :src="t.cover" :size="40" /></button>
<div class="title">
<div class="name">{{ t.name }}</div>
<div class="sub muted">
{{ t.artists || '未知歌手' }}
<template v-if="t.album">
<span class="faint"> · </span>
<RouterLink v-if="t.source && 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="tags">
<span v-if="t.missing" class="st bad" :title="t.path ?? ''">文件缺失</span>
<span v-else-if="t.localFile" class="st" :title="`曲库中有:${t.localFile}`"><Icon name="check" :size="13" /> 曲库</span>
<span class="src"><i class="dot" />{{ sourceLabel(t) }}</span>
</span>
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
<span class="acts">
<button type="button" class="act" title="下一首播放" :disabled="t.missing" @click="playNext([fromPlaylistItem(t)])"><Icon name="playNext" :size="16" /></button>
<button type="button" class="act rm" title="从歌单移除" @click="removeItem(t)"><Icon name="x" :size="15" /></button>
</span>
</li>
</ol>
<p v-else-if="filter.trim()" class="state-msg">没有匹配的歌曲</p>
<div v-else class="empty">
<p class="muted">歌单还是空的</p>
<p class="faint small">在搜索、专辑、歌手页勾选歌曲,或在曲库里点文件旁的 <Icon name="listPlus" :size="13" class="inline" /> 加进来。</p>
<RouterLink to="/" class="btn sm"><Icon name="search" :size="14" /> 去搜索</RouterLink>
</div>
</template>
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
</section>
</template>
<style scoped>
.back {
margin: -16px 0 14px -10px;
color: var(--ink-2);
}
.panel {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.fld {
display: grid;
grid-template-columns: 48px 1fr;
align-items: start;
gap: 12px;
font-size: 13px;
}
.fld .faint {
line-height: 34px;
}
.area {
height: auto;
padding: 8px 12px;
resize: vertical;
line-height: 1.6;
}
.panel-row {
display: flex;
gap: 8px;
}
.spacer {
flex: 1;
}
.fold-enter-active,
.fold-leave-active {
transition: opacity 160ms var(--ease), transform 160ms var(--ease);
}
.fold-enter-from,
.fold-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.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);
}
.small {
font-size: 13px;
}
.toolbar b {
color: var(--ink);
font-weight: 600;
}
.filter {
width: 200px;
height: 28px;
font-size: 12.5px;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
}
.row {
position: relative;
display: grid;
grid-template-columns: 14px 30px auto minmax(0, 1fr) auto 44px 64px;
align-items: center;
gap: 14px;
padding: 9px 10px 9px 6px;
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), opacity var(--dur) var(--ease);
}
.row::before {
content: '';
position: absolute;
left: 0;
top: 13px;
bottom: 13px;
width: 2px;
border-radius: 2px;
background: var(--ink-3);
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.row.netease::before {
background: var(--netease);
}
.row.qq::before {
background: var(--qq);
}
.row:hover::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.missing {
opacity: 0.5;
}
.row.dragging {
opacity: 0.35;
}
.row.drop-before {
box-shadow: inset 0 2px 0 var(--accent);
}
.row.drop-after {
box-shadow: inset 0 -2px 0 var(--accent);
}
.grip {
display: grid;
place-items: center;
color: var(--ink-3);
cursor: grab;
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.row:hover .grip:not(.off) {
opacity: 1;
}
.grip.off {
cursor: default;
}
.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:not(:disabled),
.row.current .play,
.play:focus-visible {
opacity: 1;
transform: none;
}
.row.current .play {
background: var(--accent);
color: var(--accent-ink);
}
.cover-btn {
padding: 0;
border: 0;
background: none;
}
.title {
min-width: 0;
}
.name {
font-size: 14.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.current .name {
color: var(--accent);
}
.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);
}
.tags {
display: flex;
align-items: center;
gap: 12px;
font-size: 11.5px;
}
.st {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--ink-3);
font-family: var(--font-mono);
cursor: help;
}
.st.bad {
color: var(--danger);
}
.src {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 52px;
color: var(--ink-2);
white-space: nowrap;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-3);
}
.netease .dot {
background: var(--netease);
}
.qq .dot {
background: var(--qq);
}
.dur {
font-size: 12px;
text-align: right;
}
.acts {
display: flex;
justify-content: flex-end;
}
.act {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--ink-3);
opacity: 0;
transition: color var(--dur) var(--ease), background var(--dur) var(--ease), opacity var(--dur) var(--ease);
}
.row:hover .act,
.act:focus-visible {
opacity: 1;
}
.act:hover:not(:disabled) {
color: var(--accent);
background: var(--accent-soft);
}
.act.rm:hover {
color: var(--danger);
background: color-mix(in oklab, var(--danger) 12%, transparent);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 64px 16px;
text-align: center;
}
.empty p {
margin: 0;
}
.empty .small {
margin-bottom: 12px;
}
.inline {
display: inline-block;
vertical-align: -2px;
}
@media (max-width: 760px) {
.toolbar {
flex-wrap: wrap;
}
.filter {
width: 100%;
order: 9;
}
.row {
grid-template-columns: auto minmax(0, 1fr) auto 32px;
gap: 10px;
padding-left: 10px;
}
.grip,
.num,
.dur,
.st,
.act:not(.rm) {
display: none;
}
.acts,
.act {
opacity: 1;
}
}
</style>
+6
View File
@@ -4,6 +4,8 @@ import DetailHeader from '../components/DetailHeader.vue';
import Icon from '../components/Icon.vue';
import TrackList from '../components/TrackList.vue';
import { api, type PlaylistDetail, type Source } from '../lib/api.ts';
import { playTracks } from '../lib/player.ts';
import { inputFromTrack, pickPlaylist } from '../lib/playlists.ts';
const props = defineProps<{ source: Source; id: string }>();
const playlist = ref<PlaylistDetail | null>(null);
@@ -42,6 +44,10 @@ const tracks = computed(() => {
<span v-if="playlist.creator">by {{ playlist.creator }}</span>
</template>
<template #actions>
<button class="btn primary" type="button" :disabled="!playlist.tracks.length" @click="playTracks(playlist.tracks)"><Icon name="play" :size="14" /> 播放全部</button>
<button class="btn" type="button" :disabled="!playlist.tracks.length" title="另存为自建歌单,或加入已有歌单" @click="pickPlaylist(playlist.tracks.map(inputFromTrack), playlist.name)">
<Icon name="listPlus" :size="16" /> 收藏到歌单
</button>
<input v-model="filter" class="input" type="search" placeholder="在歌单中筛选…" />
</template>
</DetailHeader>
+225
View File
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import CoverMosaic from '../components/CoverMosaic.vue';
import Icon from '../components/Icon.vue';
import { api, type MyPlaylistSummary } from '../lib/api.ts';
import { formatTotal } from '../lib/format.ts';
import { fromPlaylistItem, player, playQueue, toggle } from '../lib/player.ts';
import { createMyPlaylist, loadMyPlaylists, myPlaylists } from '../lib/playlists.ts';
import { toastError } from '../lib/store.ts';
const router = useRouter();
const creating = ref(false);
const name = ref('');
const input = ref<HTMLInputElement | null>(null);
onMounted(() => loadMyPlaylists(true));
function startCreate() {
creating.value = true;
name.value = '';
nextTick(() => input.value?.focus());
}
async function create() {
const title = name.value.trim();
if (!title) return;
const created = await createMyPlaylist(title);
if (created) {
creating.value = false;
router.push(`/playlists/${created.id}`);
}
}
const playingHere = (p: MyPlaylistSummary) => player.context === `playlist:${p.id}` && player.playing;
async function playAll(p: MyPlaylistSummary) {
if (player.context === `playlist:${p.id}` && player.current) return toggle();
try {
const detail = await api.myPlaylist(p.id);
playQueue(detail.items.filter((i) => !i.missing).map(fromPlaylistItem), 0, `playlist:${p.id}`);
} catch (e) {
toastError(e);
}
}
</script>
<template>
<section class="page">
<header class="page-head">
<span class="eyebrow">04 · Playlists</span>
<div class="row-between">
<h2>
歌单
<span v-if="myPlaylists?.length" class="count">{{ myPlaylists.length }} 个</span>
</h2>
<form v-if="creating" class="create" @submit.prevent="create">
<input ref="input" v-model="name" class="input" maxlength="100" placeholder="歌单名称" @keydown.esc="creating = false" />
<button class="btn sm primary" type="submit" :disabled="!name.trim()">创建</button>
<button class="btn sm ghost" type="button" @click="creating = false">取消</button>
</form>
<button v-else class="btn sm" type="button" @click="startCreate"><Icon name="plus" :size="15" /> 新建歌单</button>
</div>
</header>
<div v-if="myPlaylists?.length" class="grid">
<article v-for="p in myPlaylists" :key="p.id" class="tile">
<div class="art-wrap">
<RouterLink :to="`/playlists/${p.id}`" class="art">
<CoverMosaic :covers="p.covers" :px="320" />
</RouterLink>
<button v-if="p.count" class="play" :class="{ on: playingHere(p) }" type="button" :aria-label="`播放 ${p.name}`" @click="playAll(p)">
<Icon :name="playingHere(p) ? 'pause' : 'play'" :size="18" />
</button>
</div>
<RouterLink :to="`/playlists/${p.id}`" class="tile-name">{{ p.name }}</RouterLink>
<span class="tile-meta mono faint">{{ p.count }} 首<template v-if="p.duration"> · {{ formatTotal(p.duration) }}</template></span>
</article>
</div>
<div v-else-if="myPlaylists" class="empty">
<span class="empty-art"><Icon name="queue" :size="30" /></span>
<p class="muted">还没有歌单</p>
<p class="faint small">在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单。</p>
<button class="btn primary" type="button" @click="startCreate"><Icon name="plus" :size="16" /> 新建歌单</button>
</div>
<div v-else class="grid">
<div v-for="i in 5" :key="i" class="tile"><span class="art skeleton" /></div>
</div>
</section>
</template>
<style scoped>
.create {
display: flex;
gap: 8px;
}
.create .input {
width: 220px;
height: 28px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 26px 20px;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.art-wrap {
position: relative;
margin-bottom: 6px;
}
.art {
display: block;
aspect-ratio: 1;
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow);
transition: transform var(--dur) var(--ease);
}
.art.skeleton {
height: auto;
border: 0;
box-shadow: none;
}
.tile:hover .art {
transform: translateY(-2px);
}
.play {
position: absolute;
right: 10px;
bottom: 10px;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: var(--accent);
color: var(--accent-ink);
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(6px);
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.tile:hover .play,
.play.on,
.play:focus-visible {
opacity: 1;
transform: none;
}
.tile-name {
font-size: 14.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-name:hover {
color: var(--accent);
}
.tile-meta {
font-size: 11.5px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 72px 16px;
text-align: center;
}
.empty-art {
display: grid;
place-items: center;
width: 72px;
height: 72px;
margin-bottom: 10px;
border: 1.5px dashed var(--line-strong);
border-radius: var(--radius);
color: var(--ink-3);
}
.empty p {
margin: 0;
}
.small {
max-width: 380px;
margin-bottom: 14px !important;
font-size: 12.5px;
}
@media (max-width: 760px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 20px 14px;
}
.play {
opacity: 1;
transform: none;
width: 34px;
height: 34px;
}
}
</style>
+23 -2
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue';
import Icon from '../components/Icon.vue';
import QRLoginDialog from '../components/QRLoginDialog.vue';
import { api, type QQStatus, type QualityKey, type Settings } from '../lib/api.ts';
import { api, type PlayQuality, type QQStatus, type QualityKey, type Settings } from '../lib/api.ts';
import { formatDate, QUALITY_BADGE, QUALITY_LABEL } from '../lib/format.ts';
import { auth, ffmpeg, meta, qqStatus, refreshAccounts, settings, toast, toastError } from '../lib/store.ts';
import { themeMode, type ThemeMode } from '../lib/theme.ts';
@@ -81,6 +81,7 @@ async function save(patch: Partial<Settings>) {
}
const qualityOptions = computed(() => [...(meta.value?.qualities ?? [])].reverse());
const playQualities: PlayQuality[] = ['standard', 'high', 'lossless'];
// ---- 访问密码 ----
const pwCurrent = ref('');
@@ -125,7 +126,7 @@ onMounted(() => {
<template>
<section class="page narrow">
<header class="page-head">
<span class="eyebrow">05 · Settings</span>
<span class="eyebrow">06 · Settings</span>
<h2>设置</h2>
</header>
@@ -248,6 +249,26 @@ onMounted(() => {
<p v-if="ffmpeg === false" class="warn small">未检测到 ffmpeg:FLAC 等非 MP3 文件无法写入标签。请安装 ffmpeg 并加入 PATH。</p>
</section>
<!-- 播放 -->
<section v-if="settings" class="group">
<h3>播放</h3>
<div class="field">
<div class="label">在线播放音质<span class="faint">无损约 30 MB / 首,网络较慢时建议「极高」</span></div>
<div class="seg" role="tablist">
<button v-for="q in playQualities" :key="q" type="button" role="tab" :aria-selected="settings.playQuality === q" @click="save({ playQuality: q })">
{{ QUALITY_LABEL[q] }} · {{ QUALITY_BADGE[q] }}
</button>
</div>
</div>
<div class="toggles">
<label class="toggle">
<input type="checkbox" :checked="settings.preferLocal" @change="save({ preferLocal: ($event.target as HTMLInputElement).checked })" />
<span class="sw" />
曲库里已有的歌曲直接播放本地文件
</label>
</div>
</section>
<!-- 访问密码 -->
<section class="group">
<h3>访问密码 <span class="faint state">{{ auth.required ? '已开启' : '未开启' }}</span></h3>