feat: 全屏「正在播放」页与跨平台兜底播放

- NowPlaying:参考 Apple Music tvOS —— 模糊封面铺底、大号逐行歌词(按距离渐隐 / 模糊、逐行跟随)、接下来列表、可拖动进度条,静置后隐藏控件;Esc 收起、←/→ 快退 / 快进
- 播放兜底链:曲库文件 → 原平台 → 另一个平台的同名歌曲,出错时依次切换并保留进度,合并为一条提示
- 新增 /api/:source/stream-alt:按歌名、歌手、时长在另一个平台匹配同一首歌并代理播放

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-11 12:11:36 +08:00
parent 53d523d7a0
commit c1ad24aace
11 changed files with 1123 additions and 12 deletions
+2 -2
View File
@@ -23,11 +23,11 @@
- QQ:`qq/client.ts` 的 `musicu()` 在 comm 中带 `uin` + `authst` 并附 Cookie —— 缺这一步拿不到 VIP 音源
- `queue.ts` — 任务存 SQLite `tasks` 表,进度只在内存,经 `events.ts` 的 bus 推送;`routes/api.ts` 的 `/api/events` 是 SSE
- `playlists.ts` — 自建歌单(`playlists` / `playlist_items` 表);条目要么是在线曲目(source + track_id)要么是曲库文件(path),`item_key` 去重。读取时给在线曲目补 `localFile`、给文件补 `missing`。`importPlaylist` 经 `resolveLink` 把网易云 / QQ 歌单或专辑链接导入为新歌单(`POST /api/playlists/import`)
- `accounts.ts`(Cookie 用 `secret.ts` AES-GCM 加密存储)、`settings.ts`(运行时设置)、`auth.ts`(可选访问密码)、`library.ts`(曲库工具)、`stream.ts`(在线播放代理,音质取设置 `playQuality`)
- `accounts.ts`(Cookie 用 `secret.ts` AES-GCM 加密存储)、`settings.ts`(运行时设置)、`auth.ts`(可选访问密码)、`library.ts`(曲库工具)、`stream.ts`(在线播放代理,音质取设置 `playQuality`;`/:source/stream-alt` 按歌名 + 歌手 + 时长到另一个平台找同一首歌)
- `db.ts` — `node:sqlite`;history / tasks / settings / playlists / playlist_items 表
- `web/` — Vite 8 + Vue 3.5 + Vue Router 5,无 UI 库
- `lib/store.ts` 全局状态 + SSE;`lib/api.ts` 类型与请求
- `lib/player.ts` 播放器:单个 `<audio>` + 队列(`shallowRef`,整体替换)。曲目统一转成 `PlayItem`(`fromTrack` / `fromLocal` / `fromPlaylistItem`),key 为 `source:id` 或 `file:path`;队列与进度存 localStorage。`lib/playlists.ts` 是歌单缓存与全局「加入歌单」弹窗(`pickPlaylist`)
- `lib/player.ts` 播放器:单个 `<audio>` + 队列(`shallowRef`,整体替换)。曲目统一转成 `PlayItem`(`fromTrack` / `fromLocal` / `fromPlaylistItem`),key 为 `source:id` 或 `file:path`;队列与进度存 localStorage。在线曲目带兜底链 `alt`:曲库文件 → 原平台 → 另一平台同名歌曲,`<audio>` 出错时依次切换并保留进度,`player.via` 是实际出声的音源。`components/NowPlaying.vue` 是全屏正在播放(`player.full`)。`lib/playlists.ts` 是歌单缓存与全局「加入歌单」弹窗(`pickPlaylist`)
- `components/TrackList.vue` 是所有曲目列表(搜索 / 专辑 / 歌单 / 歌手)的核心
- 样式只用 `styles/tokens.css` 的 CSS 变量;`base.css` 有 `.page` `.btn` `.seg` `.badge` 等通用类
+1 -1
View File
@@ -11,7 +11,7 @@
- **下载队列**:并发、全局暂停、单曲暂停 / 继续 / 取消 / 重试,SSE 实时进度;重启后自动恢复
- **音质**:标准 / 极高 / 无损 / Hi-Res / 超清母带 / 空间音频 / 高清臻品;不可用时自动降级
- QQ 音乐支持到 Hi-Res(RS01);母带 / 全景声为加密格式,暂不支持
- **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续
- **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件;本地文件和原平台都放不了时自动换到另一个平台的同名歌曲。全屏「正在播放」页:封面取色背景、大号逐行歌词、接下来列表。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续
- **歌单**:自建歌单可混排在线曲目与本地文件;粘贴网易云 / QQ 音乐歌单分享链接一键导入;从搜索、专辑、歌单、歌手、曲库文件夹或播放队列一键加入,拖动排序,一键下载曲库里还没有的在线曲目
- **曲库**:浏览音乐目录(封面墙)、生成 M3U、打包 ZIP、移入回收站、批量补全标签与歌词
- **历史**:统计(累计、占用、近 30 天、来源与音质分布)、搜索筛选、导出 CSV / JSON
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+7 -1
View File
@@ -32,7 +32,7 @@ import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../library
import { addItems, createPlaylist, deletePlaylist, getPlaylist, importPlaylist, listPlaylists, removeItems, reorderItems, updatePlaylist } from '../playlists.ts';
import { queue, type EnqueueItem } from '../queue.ts';
import { getSettings, updateSettings } from '../settings.ts';
import { proxyStream } from '../stream.ts';
import { proxyAltStream, proxyStream } from '../stream.ts';
export const api = new Router({ prefix: '/api' });
@@ -188,6 +188,12 @@ api.get('/:source/stream/:id', async (ctx) => {
await proxyStream(ctx, ctx.params.source!, ctx.params.id!);
});
/** 跨平台兜底:到另一个平台按歌名 + 歌手 + 时长找同一首歌并播放 */
api.get('/:source/stream-alt', async (ctx) => {
const { name, artists, duration } = ctx.query;
await proxyAltStream(ctx, ctx.params.source!, { name: String(name ?? ''), artists: String(artists ?? ''), duration: Number(duration) || undefined });
});
// ---------- 下载队列 ----------
api.get('/tasks', (ctx) => {
+49
View File
@@ -4,7 +4,9 @@ import type { Context } from 'koa';
import { UA } from './core/net/http.ts';
import { getProvider } from './core/providers/index.ts';
import type { QualityKey } from './core/quality.ts';
import type { Source } from './core/types.ts';
import { TtlCache } from './core/util.ts';
import { norm } from './libraryIndex.ts';
import { getSettings } from './settings.ts';
// 音源直链通常有几分钟到几小时的有效期;拖动进度条时每个 Range 请求都会命中缓存
@@ -40,3 +42,50 @@ export async function proxyStream(ctx: Context, source: string, id: string) {
}
ctx.body = Readable.fromWeb(upstream.body as any);
}
// ---------- 跨平台兜底:本平台没有音源时,到另一个平台找同一首歌 ----------
export interface AltQuery {
name: string;
artists: string;
/** 毫秒 */
duration?: number;
}
const OTHER: Record<Source, Source> = { netease: 'qq', qq: 'netease' };
const matchCache = new TtlCache<string | null>(30 * 60 * 1000, 500);
const splitArtists = (s: string) => s.split(/\s*[/&,,、]\s*/).map(norm).filter(Boolean);
/** 去掉括号里的版本说明,用于次一级匹配:「晴天 (Live)」→「晴天」 */
const bare = (s: string) => norm(s.replace(/[(([【].*?[))\]】]/g, '')) || norm(s);
/** 在 target 平台搜同名同歌手的歌;时长差超过 15 秒视为不同版本 */
export async function findOnSource(target: Source, q: AltQuery): Promise<string | null> {
const key = `${target}:${norm(q.name)}:${norm(q.artists)}:${q.duration ?? ''}`;
return matchCache.getOrSet(key, async () => {
const artists = splitArtists(q.artists);
const { items } = await getProvider(target).search(`${q.name} ${q.artists.split(/\s*\/\s*/)[0] ?? ''}`.trim(), { limit: 20 });
let best: { id: string; score: number } | null = null;
for (const t of items) {
let score = norm(t.name) === norm(q.name) ? 3 : bare(t.name) === bare(q.name) ? 1 : -1;
if (score < 0) continue;
if (artists.length && !splitArtists(t.artists).some((a) => artists.includes(a))) continue;
if (q.duration && t.duration) {
const diff = Math.abs(q.duration - t.duration);
if (diff > 15000) continue;
if (diff <= 4000) score += 2;
}
if (!best || score > best.score) best = { id: t.id, score };
}
return best?.id ?? null;
});
}
export async function proxyAltStream(ctx: Context, source: string, q: AltQuery) {
const target = OTHER[getProvider(source).source];
if (!q.name) ctx.throw(400, '缺少歌名');
const id = await findOnSource(target, q).catch((e: Error) => ctx.throw(404, e.message));
if (!id) ctx.throw(404, `${target === 'qq' ? 'QQ 音乐' : '网易云'}没有找到同一首歌`);
ctx.set('X-Audio-Source', `${target}:${id}`);
await proxyStream(ctx, target, id);
}
+16
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import NowPlaying from './components/NowPlaying.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 { player } from './lib/player.ts';
import { activeCount, auth, bootstrap, connected, progress, qqStatus, summary } from './lib/store.ts';
import { cycleTheme, themeMode } from './lib/theme.ts';
import { navItems } from './router.ts';
@@ -85,11 +87,25 @@ const qqLabel = computed(() => {
<PlayerBar />
</div>
</div>
<Transition name="np">
<NowPlaying v-if="player.full && player.current" />
</Transition>
<PlaylistPicker />
<ToastHost />
</template>
<style scoped>
.np-enter-active,
.np-leave-active {
transition: opacity 360ms var(--ease), transform 420ms cubic-bezier(0.2, 0.9, 0.25, 1);
}
.np-enter-from,
.np-leave-to {
opacity: 0;
transform: translateY(6%);
}
.shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
+1
View File
@@ -42,6 +42,7 @@ const paths = {
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"/>',
lyric: '<path d="M5 5.5h14v10h-8.5L6.5 19v-3.5H5z"/><path d="M9 9.5h6M9 12.5h3.5"/>',
} as const;
</script>
+975
View File
@@ -0,0 +1,975 @@
<script setup lang="ts">
// 全屏「正在播放」:参考 Apple Music tvOS —— 封面取色模糊铺底、大号逐行歌词,静置后隐藏控件
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { coverThumb, formatSeconds } from '../lib/format.ts';
import {
cycleMode,
lyric,
lyricIndex,
MODE_LABEL,
next,
player,
playAt,
prev,
queue,
seek,
seekTo,
setVolume,
toggle,
toggleMute,
VIA_LABEL,
} from '../lib/player.ts';
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
import Icon from './Icon.vue';
const cur = computed(() => player.current!);
const art = computed(() => coverThumb(cur.value.cover, 800));
const backdrop = computed(() => coverThumb(cur.value.cover, 300));
const artFailed = ref(false);
watch(art, () => (artFailed.value = false));
const modeIcon = computed(() => ({ list: 'order', loop: 'repeat', one: 'repeatOne', shuffle: 'shuffle' })[player.mode]);
const viaNote = computed(() => {
const c = cur.value;
if (player.via === 'local') return c.source ? '曲库' : '本地';
if (c.source && player.via !== c.source) return `${VIA_LABEL[player.via]} · 同名兜底`;
return VIA_LABEL[player.via];
});
/** 右侧面板:歌词 / 接下来;没有歌词时默认只显示封面 */
const pane = ref<'lyric' | 'queue' | null>('lyric');
const showLyric = computed(() => pane.value === 'lyric' && lyric.value.length > 0);
const solo = computed(() => !showLyric.value && pane.value !== 'queue');
function close() {
player.full = false;
}
function setPane(p: 'lyric' | 'queue') {
pane.value = pane.value === p ? null : p;
}
// ---------- 静置隐藏控件 ----------
const idle = ref(false);
let idleTimer: ReturnType<typeof setTimeout> | undefined;
function wake() {
idle.value = false;
clearTimeout(idleTimer);
idleTimer = setTimeout(() => (idle.value = player.playing && !dragging.value), 3500);
}
watch(
() => player.playing,
(on) => (on ? wake() : ((idle.value = false), clearTimeout(idleTimer))),
);
// ---------- 进度条(可拖动) ----------
const dragging = ref(false);
const dragRatio = ref(0);
const shownRatio = computed(() => (dragging.value ? dragRatio.value : player.duration ? Math.min(1, player.time / player.duration) : 0));
const shownTime = computed(() => (dragging.value ? dragRatio.value * player.duration : player.time));
function ratioAt(e: PointerEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
}
function onDown(e: PointerEvent) {
if (!player.duration) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragging.value = true;
dragRatio.value = ratioAt(e);
}
function onMove(e: PointerEvent) {
if (dragging.value) dragRatio.value = ratioAt(e);
}
function onUp() {
if (!dragging.value) return;
dragging.value = false;
seek(dragRatio.value);
wake();
}
// ---------- 歌词滚动:当前行停在视口 32% 处,各行按距离依次跟上 ----------
const viewport = ref<HTMLElement | null>(null);
/** 下标与歌词行一一对应(模板 ref 数组不保证顺序) */
const lineEls: Array<HTMLElement | null> = [];
const offset = ref(0);
/** 用户滚轮浏览时的额外偏移,静止 2.5 秒后回到当前行 */
const manual = ref(0);
let manualTimer: ReturnType<typeof setTimeout> | undefined;
function align() {
const vp = viewport.value;
if (!vp) return;
const el = lineEls[Math.max(0, lyricIndex.value)];
offset.value = el ? el.offsetTop + el.offsetHeight / 2 - vp.clientHeight * 0.32 : 0;
}
function onWheel(e: WheelEvent) {
manual.value += e.deltaY;
clearTimeout(manualTimer);
manualTimer = setTimeout(() => (manual.value = 0), 2500);
wake();
}
watch(lyricIndex, () => manual.value === 0 && nextTick(align));
watch([lyric, showLyric], () => nextTick(align));
/** 距当前行越远越淡、越模糊;手动浏览时全部清晰 */
function lineStyle(i: number) {
const d = Math.abs(i - lyricIndex.value);
const ahead = Math.max(0, i - lyricIndex.value);
return {
transitionDelay: `${Math.min(ahead, 8) * 40}ms`,
filter: manual.value || i === lyricIndex.value ? 'none' : `blur(${Math.min(d, 4) * 0.7}px)`,
};
}
const intro = computed(() => lyric.value.length > 0 && lyricIndex.value < 0 && (lyric.value[0]?.t ?? 0) > 4);
// ---------- 接下来 ----------
const queueEl = ref<HTMLElement | null>(null);
watch(
() => [pane.value, player.index] as const,
() =>
pane.value === 'queue' &&
nextTick(() => {
const el = queueEl.value?.querySelector<HTMLElement>('.cur');
el && queueEl.value?.scrollTo({ top: el.offsetTop - 12, behavior: 'smooth' });
}),
);
// ---------- 键盘 ----------
function onKey(e: KeyboardEvent) {
if (e.defaultPrevented || (e.target as HTMLElement).closest?.('input, textarea, select')) return;
wake();
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') seekTo(player.time - 5);
else if (e.key === 'ArrowRight') seekTo(player.time + 5);
else return;
e.preventDefault();
}
onMounted(() => {
window.addEventListener('keydown', onKey);
window.addEventListener('resize', align);
wake();
nextTick(align);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('resize', align);
clearTimeout(idleTimer);
clearTimeout(manualTimer);
});
</script>
<template>
<div class="np" :class="{ idle, solo, paused: !player.playing, 'has-pane': !solo }" role="dialog" aria-label="正在播放" @pointermove="wake" @pointerdown="wake">
<div class="bg" aria-hidden="true">
<Transition name="bg">
<div :key="backdrop ?? 'none'" class="bg-art">
<template v-if="backdrop">
<img class="b1" :src="backdrop" alt="" referrerpolicy="no-referrer" />
<img class="b2" :src="backdrop" alt="" referrerpolicy="no-referrer" />
</template>
</div>
</Transition>
<i class="veil" />
</div>
<header class="top chrome">
<button class="ib" type="button" aria-label="收起" title="收起 (Esc)" @click="close"><Icon name="chevron" :size="22" class="down" /></button>
<span class="eyebrow">Now Playing</span>
<div class="top-r">
<button class="ib" :class="{ on: pane === 'lyric' }" type="button" title="歌词" :disabled="!lyric.length" @click="setPane('lyric')"><Icon name="lyric" :size="19" /></button>
<button class="ib" :class="{ on: pane === 'queue' }" type="button" title="接下来" @click="setPane('queue')"><Icon name="queue" :size="19" /></button>
</div>
</header>
<div class="stage">
<section class="hero">
<div class="art" :class="{ blank: !art || artFailed }">
<Transition name="art" mode="out-in">
<img v-if="art && !artFailed" :key="art" :src="art" alt="" referrerpolicy="no-referrer" @error="artFailed = true" />
</Transition>
</div>
<div class="info">
<h1 class="title">{{ cur.title }}</h1>
<p class="artists">{{ cur.artists || '未知歌手' }}</p>
<p class="sub">
<RouterLink v-if="cur.source && cur.albumId && cur.album" :to="`/album/${cur.source}/${cur.albumId}`" class="album" @click="close">{{ cur.album }}</RouterLink>
<span v-else-if="cur.album" class="album">{{ cur.album }}</span>
<span class="via" :class="player.via"><i class="dot" />{{ viaNote }}</span>
</p>
</div>
</section>
<Transition name="pane" mode="out-in">
<div v-if="showLyric" key="lyric" ref="viewport" class="lyrics" @wheel.passive="onWheel">
<div class="lines" :style="{ transform: `translateY(${-(offset + manual)}px)` }">
<p class="dots" :class="{ on: intro }" aria-hidden="true"><i /><i /><i /></p>
<p
v-for="(l, i) in lyric"
:key="i"
:ref="(el) => (lineEls[i] = el as HTMLElement | null)"
class="line"
:class="{ on: i === lyricIndex, past: i < lyricIndex }"
:style="lineStyle(i)"
@click="seekTo(l.t)"
>
<span class="txt">{{ l.text }}</span>
<span v-if="l.trans" class="trans">{{ l.trans }}</span>
</p>
</div>
</div>
<div v-else-if="pane === 'queue'" key="queue" class="upnext">
<h2 class="eyebrow">接下来 · {{ queue.length }} 首</h2>
<ol ref="queueEl" class="qlist">
<li v-for="(item, i) in queue" :key="`${item.key}-${i}`" class="q" :class="{ cur: i === player.index }" @click="playAt(i)">
<img v-if="item.cover" class="q-art" :src="coverThumb(item.cover, 96)" alt="" loading="lazy" referrerpolicy="no-referrer" />
<span v-else class="q-art blank" />
<span class="q-text">
<span class="q-name">{{ item.title }}</span>
<span class="q-sub">{{ item.artists || '未知歌手' }}</span>
</span>
<span v-if="i === player.index && player.playing" class="eq" aria-label="正在播放"><i /><i /><i /></span>
</li>
</ol>
</div>
</Transition>
</div>
<footer class="transport chrome">
<div class="bar" @pointerdown="onDown" @pointermove="onMove" @pointerup="onUp" @pointercancel="onUp">
<div class="rail"><div class="fill" :style="{ width: `${shownRatio * 100}%` }" /></div>
</div>
<div class="times mono">
<span>{{ formatSeconds(shownTime) }}</span>
<span>-{{ formatSeconds(Math.max(0, player.duration - shownTime)) }}</span>
</div>
<div class="ctrls">
<div class="side l">
<button class="ib" type="button" :title="MODE_LABEL[player.mode]" @click="cycleMode"><Icon :name="modeIcon" :size="19" /></button>
<button class="ib" type="button" title="加入歌单" @click="pickPlaylist([inputFromPlayItem(cur)])"><Icon name="listPlus" :size="19" /></button>
</div>
<div class="main-ctl">
<button class="ib lg" type="button" aria-label="上一首" @click="prev"><Icon name="prev" :size="26" /></button>
<button class="pp" :class="{ busy: player.loading }" type="button" :aria-label="player.playing ? '暂停' : '播放'" @click="toggle">
<Icon :name="player.playing ? 'pause' : 'play'" :size="30" />
</button>
<button class="ib lg" type="button" aria-label="下一首" :disabled="queue.length < 2" @click="next"><Icon name="next" :size="26" /></button>
</div>
<div class="side r">
<button class="ib" type="button" :title="player.muted ? '取消静音' : '静音'" @click="toggleMute">
<Icon :name="player.muted || !player.volume ? 'mute' : 'volume'" :size="18" />
</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))" />
</div>
</div>
</footer>
</div>
</template>
<style scoped>
.np {
--fg: var(--ink);
--fg-dim: color-mix(in oklab, var(--ink) 38%, transparent);
--fg-faint: color-mix(in oklab, var(--ink) 20%, transparent);
--glass: color-mix(in oklab, var(--ink) 9%, transparent);
position: fixed;
inset: 0;
z-index: 30;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
background: var(--bg);
color: var(--fg);
}
.np.idle {
cursor: none;
}
/* ---------- 背景:两层模糊封面反向缓慢旋转,再盖一层纸 / 墨 ---------- */
.bg,
.bg-art,
.veil {
position: absolute;
inset: 0;
}
.bg {
z-index: 0;
overflow: hidden;
background: radial-gradient(120% 90% at 20% 30%, var(--accent-soft), transparent 70%);
}
.bg img {
position: absolute;
width: 90vmax;
height: 90vmax;
object-fit: cover;
filter: blur(70px) saturate(1.5);
opacity: 0.9;
will-change: transform;
}
.b1 {
left: -20vmax;
top: -25vmax;
animation: drift 48s linear infinite;
}
.b2 {
right: -25vmax;
bottom: -30vmax;
animation: drift 62s linear infinite reverse;
}
@keyframes drift {
from {
transform: rotate(0deg) scale(1.1);
}
50% {
transform: rotate(180deg) scale(1.3);
}
to {
transform: rotate(360deg) scale(1.1);
}
}
.veil {
background:
linear-gradient(to bottom, transparent 55%, color-mix(in oklab, var(--bg) 55%, transparent)),
color-mix(in oklab, var(--bg) 52%, transparent);
}
.np > :not(.bg) {
position: relative;
z-index: 1;
}
/* ---------- 顶栏 / 控件 ---------- */
.chrome {
transition: opacity 600ms var(--ease);
}
.idle .chrome {
opacity: 0;
pointer-events: none;
}
.top {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 18px clamp(16px, 4vw, 56px) 0;
}
.top .eyebrow {
color: var(--fg-dim);
}
.top-r {
display: flex;
justify-self: end;
gap: 4px;
}
.ib {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: none;
color: var(--fg-dim);
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.ib:hover:not(:disabled) {
color: var(--fg);
background: var(--glass);
}
.ib:disabled {
opacity: 0.35;
cursor: default;
}
.ib.on {
color: var(--fg);
background: var(--glass);
}
.down {
transform: rotate(90deg);
}
/* ---------- 主体 ---------- */
.stage {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: center;
gap: clamp(24px, 6vw, 96px);
min-height: 0;
padding: 12px clamp(16px, 6vw, 112px);
}
.solo .stage {
grid-template-columns: minmax(0, 1fr);
justify-items: center;
}
.hero {
display: flex;
flex-direction: column;
gap: 28px;
width: 100%;
max-width: 520px;
justify-self: end;
}
.solo .hero {
justify-self: center;
max-width: min(520px, 58vh);
}
.art {
position: relative;
aspect-ratio: 1;
width: 100%;
border-radius: 14px;
overflow: hidden;
box-shadow:
0 30px 60px -24px color-mix(in oklab, #000 55%, transparent),
0 0 0 1px var(--glass);
transition: transform 700ms cubic-bezier(0.3, 1.4, 0.4, 1);
}
.paused .art {
transform: scale(0.88);
}
.art img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.art.blank {
background:
radial-gradient(circle at 50% 50%, var(--accent) 0 9%, var(--bg) 9.5% 10.5%, transparent 11%),
repeating-radial-gradient(circle, var(--ink) 0 1.6px, color-mix(in oklab, var(--ink) 84%, var(--bg)) 1.6px 3px);
}
.info {
min-width: 0;
}
.title {
margin: 0;
font: 600 clamp(22px, 2.3vw, 34px) / 1.2 var(--font-serif);
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artists {
margin: 6px 0 0;
font-size: clamp(15px, 1.4vw, 19px);
color: color-mix(in oklab, var(--ink) 70%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
display: flex;
align-items: center;
gap: 14px;
margin: 10px 0 0;
font-size: 13px;
color: var(--fg-dim);
min-width: 0;
}
.album {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.album:hover {
color: var(--fg);
}
.via {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
padding: 2px 9px;
border-radius: 999px;
background: var(--glass);
font: 500 11px/18px var(--font-mono);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
.via.netease .dot {
background: var(--netease);
}
.via.qq .dot {
background: var(--qq);
}
/* ---------- 歌词 ---------- */
.lyrics {
position: relative;
align-self: stretch;
overflow: hidden;
mask-image: linear-gradient(transparent, #000 12%, #000 78%, transparent);
}
.lines {
position: absolute;
inset: 0 0 auto;
will-change: transform;
}
.line,
.dots {
margin: 0;
padding: 14px 0;
transition:
transform 760ms cubic-bezier(0.25, 0.9, 0.3, 1),
color 400ms var(--ease),
filter 400ms var(--ease);
}
.line {
cursor: pointer;
color: var(--fg-dim);
transform-origin: left center;
transform: scale(0.94);
}
.txt {
display: block;
font: 700 clamp(24px, 2.9vw, 44px) / 1.28 var(--font-serif);
letter-spacing: 0.01em;
text-wrap: balance;
}
.trans {
display: block;
margin-top: 6px;
font: 500 clamp(14px, 1.3vw, 19px) / 1.45 var(--font-sans);
}
.line.on {
color: var(--fg);
transform: none;
}
.line.on .trans {
color: color-mix(in oklab, var(--ink) 66%, transparent);
}
.line:hover {
color: color-mix(in oklab, var(--ink) 70%, transparent);
filter: none !important;
}
.dots {
display: flex;
gap: 10px;
height: 0;
padding: 0;
overflow: hidden;
opacity: 0;
}
.dots.on {
height: auto;
padding: 22px 0;
opacity: 1;
}
.dots i {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--fg);
animation: breathe-dot 1.8s var(--ease) infinite;
}
.dots i:nth-child(2) {
animation-delay: 0.3s;
}
.dots i:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes breathe-dot {
0%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
/* ---------- 接下来 ---------- */
.upnext {
display: flex;
flex-direction: column;
align-self: stretch;
min-height: 0;
padding-top: 4vh;
}
.upnext .eyebrow {
margin: 0 0 12px 10px;
color: var(--fg-dim);
}
.qlist {
flex: 1;
margin: 0;
padding: 0 0 6vh;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
mask-image: linear-gradient(#000 85%, transparent);
}
.q {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
content-visibility: auto;
contain-intrinsic-size: auto 64px;
transition: background var(--dur) var(--ease);
}
.q:hover,
.q.cur {
background: var(--glass);
}
.q-art {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
}
.q-art.blank {
background: var(--glass);
}
.q-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.q-name,
.q-sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.q-name {
font-size: 15px;
}
.q.cur .q-name {
color: var(--accent);
}
.q-sub {
font-size: 12.5px;
color: var(--fg-dim);
}
.eq {
display: flex;
align-items: flex-end;
gap: 2px;
height: 14px;
margin-right: 4px;
}
.eq i {
width: 3px;
height: 100%;
border-radius: 1px;
background: var(--accent);
transform-origin: bottom;
animation: eq 900ms var(--ease) infinite alternate;
}
.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);
}
}
/* ---------- 底部播放控制 ---------- */
.transport {
padding: 0 clamp(16px, 6vw, 112px) clamp(18px, 3.5vh, 36px);
}
.bar {
padding: 10px 0;
cursor: pointer;
touch-action: none;
}
.rail {
position: relative;
height: 5px;
border-radius: 5px;
background: var(--fg-faint);
overflow: hidden;
transition: height var(--dur) var(--ease);
}
.bar:hover .rail {
height: 8px;
}
.fill {
height: 100%;
border-radius: inherit;
background: var(--fg);
}
.bar:hover .fill {
background: var(--accent);
}
.times {
display: flex;
justify-content: space-between;
margin-top: -2px;
font-size: 11.5px;
color: var(--fg-dim);
}
.ctrls {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-top: 4px;
}
.side {
display: flex;
align-items: center;
gap: 4px;
}
.side.r {
justify-self: end;
}
.side input {
width: 110px;
accent-color: var(--ink);
}
.main-ctl {
display: flex;
align-items: center;
gap: 18px;
}
.ib.lg {
width: 52px;
height: 52px;
color: var(--fg);
}
.pp {
display: grid;
place-items: center;
width: 68px;
height: 68px;
border: 0;
border-radius: 50%;
background: var(--ink);
color: var(--bg);
box-shadow: 0 10px 30px -12px color-mix(in oklab, #000 60%, transparent);
transition: transform var(--dur) var(--ease);
}
.pp:hover {
transform: scale(1.05);
}
.pp.busy {
animation: breathe 1.2s var(--ease) infinite;
}
@keyframes breathe {
50% {
box-shadow: 0 0 0 8px var(--accent-soft);
}
}
/* ---------- 过渡 ---------- */
.bg-enter-active,
.bg-leave-active {
transition: opacity 1.2s var(--ease);
}
.bg-enter-from,
.bg-leave-to {
opacity: 0;
}
.art-enter-active,
.art-leave-active {
transition: opacity 320ms var(--ease);
}
.art-enter-from,
.art-leave-to {
opacity: 0;
}
.pane-enter-active,
.pane-leave-active {
transition: opacity 260ms var(--ease), transform 260ms var(--ease);
}
.pane-enter-from,
.pane-leave-to {
opacity: 0;
transform: translateX(16px);
}
@media (prefers-reduced-motion: reduce) {
.bg img,
.dots i,
.eq i {
animation: none;
}
.line,
.art {
transition: none;
}
}
/* ---------- 窄屏:封面与面板二选一 ---------- */
@media (max-width: 760px) {
.stage {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
align-items: stretch;
gap: 18px;
padding: 8px 20px;
}
.solo .stage {
grid-template-rows: auto;
align-content: center;
}
.hero {
justify-self: stretch;
max-width: none;
gap: 22px;
}
.has-pane .hero {
flex-direction: row;
align-items: center;
gap: 14px;
}
.has-pane .art {
width: 64px;
flex-shrink: 0;
border-radius: 8px;
}
.has-pane .title {
font-size: 18px;
}
.has-pane .artists {
margin-top: 2px;
font-size: 14px;
}
.has-pane .sub {
display: none;
}
.txt {
font-size: 26px;
}
.side input {
display: none;
}
.main-ctl {
gap: 10px;
}
.pp {
width: 60px;
height: 60px;
}
}
</style>
+17 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatSeconds } from '../lib/format.ts';
import { currentLyric, cycleMode, MODE_LABEL, next, player, prev, queue, seek, setVolume, stop, toggle, toggleMute } from '../lib/player.ts';
import { currentLyric, cycleMode, MODE_LABEL, next, player, prev, queue, seek, setVolume, stop, toggle, toggleMute, VIA_LABEL } from '../lib/player.ts';
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
import CoverArt from './CoverArt.vue';
import Icon from './Icon.vue';
@@ -16,6 +16,20 @@ function onSeek(e: MouseEvent) {
seek((e.clientX - rect.left) / rect.width);
}
/** 正在播放的不是曲目本来的平台:曲库文件,或另一个平台的同名歌曲 */
const viaTag = computed(() => {
const c = player.current;
if (!c?.source || player.via === c.source) return null;
return player.via === 'local'
? { text: '曲库', title: '曲库里有这首歌,正在播放本地文件' }
: { text: VIA_LABEL[player.via], title: `原平台没有音源,正在播放${VIA_LABEL[player.via]}的同名歌曲` };
});
function openFull() {
player.panel = null;
player.full = true;
}
function openPanel(tab: 'queue' | 'lyric') {
player.panel = player.panel === tab ? null : tab;
}
@@ -33,7 +47,7 @@ function addCurrent() {
</Transition>
<div class="player">
<i class="mini-progress" :style="{ width: `${pct}%` }" />
<button class="now" type="button" title="歌词" @click="openPanel('lyric')">
<button class="now" type="button" title="正在播放" @click="openFull">
<span class="disc" :class="{ spin: player.playing }">
<CoverArt :src="player.current.cover" :size="46" round />
<i class="hole" />
@@ -41,7 +55,7 @@ function addCurrent() {
<span class="meta">
<span class="t">
{{ player.current.title }}
<span v-if="player.current.local && player.current.source" class="tag" title="曲库里有这首歌,正在播放本地文件">曲库</span>
<span v-if="viaTag" class="tag" :title="viaTag.title">{{ viaTag.text }}</span>
</span>
<span class="a muted">
<Transition name="fade" mode="out-in">
+3
View File
@@ -368,6 +368,9 @@ export const api = {
artist: (source: Source, id: string) => request<ArtistDetail>(`/${source}/artist/${encodeURIComponent(id)}`),
lyric: (source: Source, id: string) => request<{ lrc?: string; tlyric?: string }>(`/${source}/lyric/${encodeURIComponent(id)}`),
streamUrl: (source: Source, id: string) => `/api/${source}/stream/${encodeURIComponent(id)}`,
/** 到另一个平台找同一首歌播放;source 是原曲目的平台 */
altStreamUrl: (source: Source, t: { name: string; artists: string; duration?: number }) =>
`/api/${source}/stream-alt?${qs({ name: t.name, artists: t.artists, duration: t.duration || undefined })}`,
tasks: (scope: 'active' | 'finished' | 'all' = 'all') =>
request<{ tasks: Task[]; progress: TaskProgress[]; summary: QueueSummary }>(`/tasks?scope=${scope}`),
+52 -5
View File
@@ -23,8 +23,15 @@ export interface PlayItem {
local?: boolean;
/** 毫秒,加载前先显示 */
duration?: number;
/** src 播放失败时依次尝试:曲库文件 / 原平台 / 另一个平台的同名歌曲 */
alt?: Array<{ src: string; via: Via }>;
}
/** 实际出声的音源 */
export type Via = 'local' | Source;
export const VIA_LABEL: Record<Via, string> = { local: '曲库文件', netease: '网易云', qq: 'QQ 音乐' };
const OTHER: Record<Source, Source> = { netease: 'qq', qq: 'netease' };
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'];
@@ -48,6 +55,10 @@ export const player = reactive({
panel: null as null | 'queue' | 'lyric',
/** 当前队列的来源,如 playlist:3,用于在来源页面上显示播放状态 */
context: '',
/** 当前实际播放的音源(兜底后可能与曲目来源不同) */
via: 'local' as Via,
/** 全屏「正在播放」 */
full: false,
});
/** 播放队列可能有上千首:用 shallowRef,整体替换触发更新 */
@@ -60,6 +71,10 @@ let attached = false;
/** 恢复上次进度:元数据加载后跳转 */
let resumeAt = 0;
let errorStreak = 0;
/** 当前曲目已用掉的兜底音源数 */
let attempt = 0;
/** 本首已失败的音源,换源后真正出声时合并成一条提示 */
let failedVias: Via[] = [];
let prefetched = '';
/** 播放顺序 → 队列下标;随机模式下是洗牌后的顺序 */
let order: number[] = [];
@@ -128,8 +143,13 @@ function loadLyric(item: PlayItem) {
// ---------- 曲目转换 ----------
/** 曲库优先(默认):曲库文件 → 原平台 → 另一个平台;关闭时原平台排在曲库文件前 */
export function fromTrack(t: TrackSummary): PlayItem {
const local = settings.value?.preferLocal !== false ? t.localFile : undefined;
const online = { src: api.streamUrl(t.source, t.id), via: t.source as Via };
const file = t.localFile ? { src: api.libraryFileUrl(t.localFile), via: 'local' as Via } : null;
const chain = (settings.value?.preferLocal !== false ? [file, online] : [online, file]).filter((x) => !!x);
chain.push({ src: api.altStreamUrl(t.source, { name: t.name, artists: t.artists, duration: t.duration }), via: OTHER[t.source] });
const [first, ...alt] = chain;
return {
key: `${t.source}:${t.id}`,
title: t.name,
@@ -140,8 +160,9 @@ export function fromTrack(t: TrackSummary): PlayItem {
id: t.id,
albumId: t.albumId,
duration: t.duration,
src: local ? api.libraryFileUrl(local) : api.streamUrl(t.source, t.id),
local: !!local,
src: first!.src,
local: first!.via === 'local',
alt,
};
}
@@ -206,6 +227,7 @@ function restore() {
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;
if (player.current) player.via = primaryVia(player.current);
resumeAt = Number(s.time) || 0;
player.time = resumeAt;
player.duration = (player.current?.duration ?? 0) / 1000;
@@ -288,6 +310,12 @@ function ensureAudio(): HTMLAudioElement {
a.addEventListener('playing', () => {
player.loading = false;
errorStreak = 0;
const item = player.current;
if (failedVias.length && item) {
const other = player.via !== 'local' && player.via !== item.source;
toast(`${failedVias.map((v) => VIA_LABEL[v]).join('、')}无法播放,改用${VIA_LABEL[player.via]}${other ? '的同名歌曲' : ''}`);
failedVias = [];
}
});
a.addEventListener('canplay', () => (player.loading = false));
a.addEventListener('seeked', updatePosition);
@@ -297,11 +325,26 @@ function ensureAudio(): HTMLAudioElement {
return a;
}
const primaryVia = (item: PlayItem): Via => (item.local || !item.source ? 'local' : item.source);
function onError() {
const item = player.current;
if (!item || !attached || !audio) return;
// 还有兜底音源:从出错的位置接着放
const fallback = item.alt?.[attempt];
if (fallback) {
attempt++;
failedVias.push(player.via);
const at = audio.currentTime || player.time;
resumeAt = at > 1 ? at : 0;
player.via = fallback.via;
player.loading = true;
audio.src = fallback.src;
audio.play().catch(() => {});
return;
}
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)) {
@@ -330,6 +373,9 @@ function load(i: number, autoplay = true, keepResume = false) {
const a = ensureAudio();
if (!keepResume) resumeAt = 0;
prefetched = '';
attempt = 0;
failedVias = [];
player.via = primaryVia(item);
player.index = i;
player.current = item;
player.time = resumeAt;
@@ -527,6 +573,7 @@ export function stop() {
player.playing = false;
player.loading = false;
player.panel = null;
player.full = false;
player.context = '';
lyric.value = [];
if (media) media.metadata = null;