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
+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;