feat: 声匣 —— 网易云 / QQ 音乐自托管下载服务

由 163-music-dl CLI 重构为 Web 服务:
- 下载流水线、歌词与标签逻辑沿用旧版(node-id3 / ffmpeg)
- 网易云(chksz 接口 + music.163.com 元数据)与 QQ 音乐(VIP Cookie)provider
- QQ 扫码登录与 musickey 自动续期
- SQLite 持久化的下载队列,SSE 实时进度
- 曲库工具(M3U / ZIP / 回收站 / 补全标签)、历史统计与导出、访问密码
- Vite 8 + Vue 3 前端,浅色 / 深色
- Docker 部署配置(飞牛 NAS)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-10 22:05:30 +08:00
commit 21945cc54f
73 changed files with 12507 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
// 试听播放器:单个 <audio>,支持在线音源(服务端代理)与本地曲库文件,并同步滚动歌词
import { computed, reactive, ref } from 'vue';
import { api, type Source } from './api.ts';
import { toastError } from './store.ts';
export interface PlayItem {
key: string;
title: string;
artists: string;
cover?: string;
src: string;
/** 在线曲目用于加载歌词 */
source?: Source;
id?: string;
}
interface LrcLine {
t: number;
text: string;
}
export const player = reactive({
current: null as PlayItem | null,
playing: false,
loading: false,
time: 0,
duration: 0,
volume: 0.85,
});
const lyric = ref<LrcLine[]>([]);
let audio: HTMLAudioElement | null = null;
function parseLrc(text: string | undefined): LrcLine[] {
if (!text) return [];
const out: LrcLine[] = [];
for (const line of text.split('\n')) {
const stamps = [...line.matchAll(/\[(\d+):(\d+(?:\.\d+)?)\]/g)];
const content = line.replace(/\[[^\]]*\]/g, '').trim();
if (!stamps.length || !content) continue;
for (const m of stamps) out.push({ t: Number(m[1]) * 60 + Number(m[2]), text: content });
}
return out.sort((a, b) => a.t - b.t);
}
export const currentLyric = computed(() => {
const lines = lyric.value;
let found = '';
for (const l of lines) {
if (l.t > player.time + 0.2) break;
found = l.text;
}
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 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;
lyric.value = [];
a.src = item.src;
a.play().catch(() => {});
if (item.source && item.id) {
api
.lyric(item.source, item.id)
.then((l) => {
if (player.current?.key === item.key) lyric.value = parseLrc(l.lrc);
})
.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 toggle() {
if (!audio || !player.current) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
export function seek(ratio: number) {
if (audio && player.duration) audio.currentTime = Math.max(0, Math.min(1, ratio)) * player.duration;
}
export function setVolume(v: number) {
player.volume = v;
if (audio) audio.volume = v;
}
export function stop() {
audio?.pause();
if (audio) audio.removeAttribute('src');
player.current = null;
player.playing = false;
lyric.value = [];
}
export function isPlaying(key: string): boolean {
return player.current?.key === key && player.playing;
}