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
+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();
});
}