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:
@@ -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` 等通用类
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **下载队列**:并发、全局暂停、单曲暂停 / 继续 / 取消 / 重试,SSE 实时进度;重启后自动恢复
|
||||
- **音质**:标准 / 极高 / 无损 / Hi-Res / 超清母带 / 空间音频 / 高清臻品;不可用时自动降级
|
||||
- QQ 音乐支持到 Hi-Res(RS01);母带 / 全景声为加密格式,暂不支持
|
||||
- **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续
|
||||
- **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件;本地文件和原平台都放不了时自动换到另一个平台的同名歌曲。全屏「正在播放」页:封面取色背景、大号逐行歌词、接下来列表。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续
|
||||
- **歌单**:自建歌单可混排在线曲目与本地文件;粘贴网易云 / QQ 音乐歌单分享链接一键导入;从搜索、专辑、歌单、歌手、曲库文件夹或播放队列一键加入,拖动排序,一键下载曲库里还没有的在线曲目
|
||||
- **曲库**:浏览音乐目录(封面墙)、生成 M3U、打包 ZIP、移入回收站、批量补全标签与歌词
|
||||
- **历史**:统计(累计、占用、近 30 天、来源与音质分布)、搜索筛选、导出 CSV / JSON
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user