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