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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user