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
+14 -6
View File
@@ -1,19 +1,26 @@
// 试听代理:解析出 320K 音源后由服务端转发(支持 Range),绕过防盗链与混合内容
// 在线播放代理:按设置的播放音质解析音源后由服务端转发(支持 Range),绕过防盗链与混合内容
import { Readable } from 'node:stream';
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 { TtlCache } from './core/util.ts';
import { getSettings } from './settings.ts';
const urlCache = new TtlCache<{ url: string; ext: string }>(3 * 60 * 1000, 200);
// 音源直链通常有几分钟到几小时的有效期;拖动进度条时每个 Range 请求都会命中缓存
const urlCache = new TtlCache<{ url: string; ext: string; quality: QualityKey }>(3 * 60 * 1000, 200);
const MIME: Record<string, string> = { mp3: 'audio/mpeg', flac: 'audio/flac', m4a: 'audio/mp4', ogg: 'audio/ogg' };
export async function proxyStream(ctx: Context, source: string, id: string) {
const provider = getProvider(source);
const audio = await urlCache.getOrSet(`${source}:${id}`, async () => {
const r = await provider.resolveAudio(id, 'high');
return { url: r.url, ext: r.ext };
});
const quality = getSettings().playQuality;
const audio = await urlCache
.getOrSet(`${source}:${id}:${quality}`, async () => {
const r = await provider.resolveAudio(id, quality);
return { url: r.url, ext: r.ext, quality: r.quality };
})
// 版权下架等情况很常见,播放器会自动跳过,不必按服务端错误记日志
.catch((e: Error) => ctx.throw(404, e.message));
const controller = new AbortController();
ctx.req.on('close', () => controller.abort());
@@ -26,6 +33,7 @@ export async function proxyStream(ctx: Context, source: string, id: string) {
ctx.set('Content-Type', MIME[audio.ext] || upstream.headers.get('content-type') || 'application/octet-stream');
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Cache-Control', 'no-store');
ctx.set('X-Audio-Quality', audio.quality);
for (const h of ['content-length', 'content-range']) {
const v = upstream.headers.get(h);
if (v) ctx.set(h, v);