/* ============================================================
Xiao の 电台 — app.js (v2)
多路流 + 鉴权 + 删除/导入 + 封面本地化
============================================================ */
// ==================== 全局状态 ====================
let currentStreamId = "main";
const state = { streams: [], resources: [], playlist: [] };
// ==================== Toast ====================
function showToast(msg, type = "") {
const c = document.getElementById("toastContainer");
const el = document.createElement("div");
el.className = "toast-msg" + (type ? " " + type : "");
el.textContent = msg;
c.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
// ==================== Helpers ====================
function esc(s) {
const AMP = String.fromCharCode(38);
const map = { "&": AMP + "amp;", "<": AMP + "lt;", ">": AMP + "gt;", '"': AMP + "quot;", "'": AMP + "#39;" };
return String(s ?? "").replace(/[&<>"']/g, (c) => map[c]);
}
function formatDuration(sec) {
if (!sec && sec !== 0) return "--:--";
const s = Math.round(sec), m = Math.floor(s / 60), ss = s % 60;
return m + ":" + String(ss).padStart(2, "0");
}
function formatBytes(b) {
if (!b) return "0 B";
if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB";
if (b >= 1024) return (b / 1024).toFixed(1) + " KB";
return b + " B";
}
// ==================== API(统一带 cookie,401 跳登录) ====================
async function api(url, options = {}) {
const res = await fetch(url, { credentials: "same-origin", ...options });
if (res.status === 401) {
window.location.href = "/login";
throw new Error("未登录");
}
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; }
if (!res.ok) throw new Error((data && data.error) || text || `HTTP ${res.status}`);
return data;
}
function post(url, body) {
return api(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
});
}
// ==================== Navigation ====================
const tabs = document.querySelectorAll(".nav-tab");
const pages = document.querySelectorAll(".page");
const pageOrder = ["pageSearch", "pageResources", "pagePlaylist", "pageAbout"];
function switchPage(pageId) {
pages.forEach((p) => p.classList.remove("active"));
tabs.forEach((t) => t.classList.remove("active"));
const page = document.getElementById(pageId);
if (page) page.classList.add("active");
tabs.forEach((t) => { if (t.dataset.page === pageId) t.classList.add("active"); });
if (pageId === "pageResources") loadResources();
if (pageId === "pagePlaylist") loadPlaylist();
}
tabs.forEach((tab) => tab.addEventListener("click", () => switchPage(tab.dataset.page)));
// ==================== ID Extraction ====================
function extractMusicId(input) {
input = input.trim();
if (!input) return "";
if (/^\d+$/.test(input)) return input;
const patterns = [
/music\.163\.com\/#\/song\?.*id=(\d+)/,
/music\.163\.com\/song\?.*id=(\d+)/,
/music\.163\.com\/song\/(\d+)/,
/y\.music\.163\.com\/m\/song\?.*id=(\d+)/,
/[?&]id=(\d+)/,
/(\d{5,})/,
];
for (const p of patterns) { const m = input.match(p); if (m) return m[1]; }
return input;
}
// ==================== Streams(多路流) ====================
async function loadStreams() {
try {
const r = await api("/api/streams");
state.streams = r.list || [];
const sel = document.getElementById("streamSelect");
if (!state.streams.some((s) => s.id === currentStreamId)) {
currentStreamId = state.streams[0] ? state.streams[0].id : "main";
}
sel.innerHTML = state.streams
.map((s) => ``)
.join("");
const cur = state.streams.find((s) => s.id === currentStreamId);
document.getElementById("liveLink").href = cur ? cur.m3u8 : "/hls/main/index.m3u8";
} catch (e) { console.error("加载流失败", e); }
}
document.getElementById("streamSelect").addEventListener("change", (e) => {
currentStreamId = e.target.value;
loadStreams();
loadPlaylist();
loadResources();
});
document.getElementById("liveLink").addEventListener("click", (e) => {
e.preventDefault();
window.open(e.currentTarget.href, "_blank");
});
document.getElementById("btnLogout").addEventListener("click", async () => {
await post("/api/auth/logout").catch(() => {});
window.location.href = "/login";
});
// ==================== List Diff ====================
function reconcileList(container, items, keyOf, create, update) {
const nodes = Array.from(container.querySelectorAll("[data-key]"));
const byKey = new Map(nodes.map((n) => [n.dataset.key, n]));
const seen = new Set();
items.forEach((item, idx) => {
const key = String(keyOf(item));
seen.add(key);
let el = byKey.get(key);
if (!el) { el = create(item); el.dataset.key = key; el.classList.add("enter"); }
update(el, item, idx);
if (container.children[idx] !== el)
container.insertBefore(el, container.children[idx] || null);
});
nodes.forEach((el) => {
if (!seen.has(el.dataset.key)) {
el.classList.add("leave");
el.addEventListener("animationend", () => el.remove(), { once: true });
}
});
}
// ==================== Resources ====================
function renderResItem(item) {
const cover = item.albumImage
? `
`
: `
`;
const bg = item.albumImage
? ``
: "";
const tpl = document.createElement("template");
tpl.innerHTML = `${bg}
${formatDuration(item.duration)}
|
${formatBytes(item.fileSize)}
|
缓存 ${formatBytes(item.cacheSize)}
`;
const el = tpl.content.firstElementChild;
el.querySelector(".js-add").addEventListener("click", () => addToPlaylist(item.file));
el.querySelector(".js-del").addEventListener("click", () => deleteResource(item.file, item.title));
el.querySelector(".js-enrich").addEventListener("click", () => enrichResource(item.file));
return el;
}
function updateResItem(el, item) {
const h = el.querySelector(".js-heat");
if (h.textContent !== String(item.heat)) h.textContent = item.heat;
const c = el.querySelector(".js-cache"), cs = formatBytes(item.cacheSize);
if (c.textContent !== cs) c.textContent = cs;
}
let resourceKeyword = "";
function applyResourceFilter() {
document.getElementById("resourceList").querySelectorAll(".res-item").forEach((el) => {
const t = (el.querySelector(".res-title")?.textContent || "").toLowerCase();
const a = (el.querySelector(".res-artist")?.textContent || "").toLowerCase();
el.style.display = !resourceKeyword || t.includes(resourceKeyword) || a.includes(resourceKeyword) ? "" : "none";
});
}
async function loadResources() {
try {
const r = await api("/api/resources");
const resources = r.list || [];
state.resources = resources;
const container = document.getElementById("resourceList");
document.getElementById("resourceCount").textContent = resources.length;
if (resources.length === 0) {
container.innerHTML = ``;
return;
}
if (!container.querySelector("[data-key]")) container.innerHTML = "";
reconcileList(container, resources, (r) => r.file, renderResItem, updateResItem);
applyResourceFilter();
} catch (err) { console.error("加载资源失败:", err); }
}
// ==================== Playlist ====================
let sortableInstance = null;
function renderPlItem(item) {
const tpl = document.createElement("template");
tpl.innerHTML = `
${esc(item.title)}${item.isCurrent ? ' 播放中' : ""}
`;
return tpl.content.firstElementChild;
}
function updatePlItem(el, item, idx) {
const numEl = el.querySelector(".pl-num");
if (numEl.textContent !== String(idx + 1)) numEl.textContent = idx + 1;
const heatEl = el.querySelector(".js-heat");
if (heatEl.textContent !== String(item.heat)) heatEl.textContent = item.heat;
const cacheEl = el.querySelector(".pl-cache");
const st = item.cacheStatus === "ready" ? "ready" : item.cacheStatus === "pending" ? "pending" : "none";
if (cacheEl.dataset.status !== st) {
cacheEl.dataset.status = st;
cacheEl.className = "pl-cache " + st;
cacheEl.innerHTML =
st === "ready" ? ' 已缓存'
: st === "pending" ? ' 生成中'
: ' 未缓存';
}
const btn = el.querySelector("button");
btn.onclick = () => removeFromPlaylist(item.entryId);
}
async function loadPlaylist() {
try {
const r = await api(`/api/streams/${currentStreamId}/playlist`);
const playlistData = r.list || [];
state.playlist = playlistData;
const container = document.getElementById("playlistList");
document.getElementById("playlistCount").textContent = playlistData.length;
if (playlistData.length === 0) {
container.innerHTML = ``;
if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; }
return;
}
if (!container.querySelector("[data-key]")) container.innerHTML = "";
reconcileList(container, playlistData, (p) => p.entryId, renderPlItem, updatePlItem);
if (!sortableInstance) {
sortableInstance = new Sortable(container, {
animation: 200,
ghostClass: "sortable-ghost",
handle: ".pl-grip",
delay: 100,
delayOnTouchOnly: true,
onEnd: async () => {
const items = [...container.querySelectorAll(".pl-item")];
const order = items.map((el) => Number(el.dataset.id));
try {
await post(`/api/streams/${currentStreamId}/playlist/reorder`, { order });
items.forEach((el, i) => {
el.querySelector(".pl-num").textContent = i + 1;
});
} catch (err) {
console.error("排序更新失败:", err);
loadPlaylist();
}
},
});
}
} catch (err) { console.error("加载播放列表失败:", err); }
}
// ==================== Actions ====================
async function addToPlaylist(file) {
try {
await post(`/api/streams/${currentStreamId}/playlist/add`, { file });
showToast("已添加到播放列表", "success");
await loadPlaylist();
await loadResources();
} catch (err) { showToast("添加失败: " + err.message, "error"); }
}
async function removeFromPlaylist(entryId) {
try {
await post(`/api/streams/${currentStreamId}/playlist/remove`, { entryId });
await loadPlaylist();
} catch (err) { showToast("移除失败: " + err.message, "error"); }
}
async function clearPlaylist() {
if (!confirm("确定清空当前频道的播放列表?清空后将自动随机添加一首。")) return;
try {
await post(`/api/streams/${currentStreamId}/playlist/clear`);
await loadPlaylist();
} catch (err) { showToast("清空失败: " + err.message, "error"); }
}
async function deleteResource(file, title) {
if (!confirm(`确定删除「${title}」?\n将同时删除音频文件、切片缓存与播放列表引用。`)) return;
try {
await post("/api/music/delete", { file });
showToast("已删除: " + title, "success");
await loadResources();
await loadPlaylist();
} catch (err) { showToast("删除失败: " + err.message, "error"); }
}
async function enrichResource(file) {
try {
showToast("正在重新匹配...");
await post("/api/music/enrich", { file });
await loadResources();
showToast("匹配完成", "success");
} catch (err) { showToast("匹配失败: " + err.message, "error"); }
}
// ==================== Cache Info ====================
async function loadCacheInfo() {
try {
const info = await api("/api/system/cache");
document.getElementById("cacheInfo").innerHTML =
` ${(info.total / 1048576).toFixed(1)}/${(info.limit / 1048576).toFixed(0)}MB (${info.usedPercent}%)`;
} catch (err) { console.error("加载缓存信息失败:", err); }
}
// ==================== Mini Player ====================
const musicPlayer = document.getElementById("musicPlayer");
const miniPlayer = document.getElementById("miniPlayer");
const mpBody = document.getElementById("mpBody");
const mpProgress = document.getElementById("mpProgress");
const mpCover = document.getElementById("mpCover");
const mpName = document.getElementById("mpName");
const mpArtist = document.getElementById("mpArtist");
const mpPlay = document.getElementById("mpPlay");
const mpTime = document.getElementById("mpTime");
const mpExpand = document.getElementById("mpExpand");
function showMiniPlayer(info) {
mpName.textContent = info.name || "未知";
mpArtist.textContent = info.artist || "--";
if (info.albumImage) { mpCover.src = info.albumImage; mpCover.style.display = ""; }
else mpCover.style.display = "none";
miniPlayer.classList.add("show");
document.body.classList.add("mini-player-visible");
}
function hideMiniPlayer() {
miniPlayer.classList.remove("show");
document.body.classList.remove("mini-player-visible");
}
mpPlay.addEventListener("click", () => { musicPlayer.paused ? musicPlayer.play() : musicPlayer.pause(); });
musicPlayer.addEventListener("play", () => { mpPlay.innerHTML = ''; });
musicPlayer.addEventListener("pause", () => { mpPlay.innerHTML = ''; });
musicPlayer.addEventListener("timeupdate", () => {
if (!musicPlayer.duration) return;
mpProgress.style.width = (musicPlayer.currentTime / musicPlayer.duration) * 100 + "%";
mpTime.textContent = formatDuration(musicPlayer.currentTime);
});
musicPlayer.addEventListener("ended", () => { mpPlay.innerHTML = ''; });
mpExpand.addEventListener("click", () => {
switchPage("pageSearch");
const preview = document.getElementById("musicPreview");
if (preview.style.display !== "none") preview.scrollIntoView({ behavior: "smooth", block: "center" });
});
let isSeeking = false;
function seekFromPointer(e) {
const rect = mpBody.getBoundingClientRect();
const x = e.touches ? e.touches[0].clientX : e.clientX;
const ratio = Math.max(0, Math.min(1, (x - rect.left) / rect.width));
if (musicPlayer.duration) {
musicPlayer.currentTime = ratio * musicPlayer.duration;
mpProgress.style.width = ratio * 100 + "%";
}
}
mpBody.addEventListener("mousedown", (e) => { if (e.target.closest("button")) return; isSeeking = true; seekFromPointer(e); });
mpBody.addEventListener("touchstart", (e) => { if (e.target.closest("button")) return; isSeeking = true; seekFromPointer(e); }, { passive: true });
document.addEventListener("mousemove", (e) => { if (isSeeking) seekFromPointer(e); });
document.addEventListener("touchmove", (e) => { if (isSeeking) seekFromPointer(e); }, { passive: true });
document.addEventListener("mouseup", () => { isSeeking = false; });
document.addEventListener("touchend", () => { isSeeking = false; });
// ==================== Music Search ====================
let musicInfo = null, selectedId = null;
const musicIdInput = document.getElementById("musicId");
const parseHint = document.getElementById("parseHint");
const parsedIdEl = document.getElementById("parsedId");
function updateParseHint() {
const val = musicIdInput.value.trim(), extracted = extractMusicId(val);
if (val && extracted !== val && /^\d+$/.test(extracted)) {
parsedIdEl.textContent = extracted;
parseHint.classList.add("visible");
} else parseHint.classList.remove("visible");
}
musicIdInput.addEventListener("input", updateParseHint);
musicIdInput.addEventListener("paste", () => requestAnimationFrame(updateParseHint));
async function showDetail(id) {
const info = await api("/api/music/detail", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
musicInfo = info; selectedId = id;
document.getElementById("musicName").textContent = info.name;
document.getElementById("musicArtist").textContent = info.artist || "未知作者";
document.getElementById("musicCover").src = info.albumImage || "";
let tags = "";
if (info.album) tags += ` ${esc(info.album)}`;
if (info.duration) tags += ` ${formatDuration(info.duration)}`;
if (id) tags += ` ${id}`;
document.getElementById("musicDetails").innerHTML = tags;
document.getElementById("musicPreview").style.display = "block";
document.getElementById("searchTips").style.display = "none";
musicPlayer.pause();
musicPlayer.src = "http://music.163.com/song/media/outer/url?id=" + id + ".mp3";
showMiniPlayer({ name: info.name, artist: info.artist, albumImage: info.albumImage });
mpProgress.style.width = "0%";
mpTime.textContent = "0:00";
}
function renderSearchResults(data, keyword) {
const container = document.getElementById("searchResults");
musicInfo = null; selectedId = null;
document.getElementById("musicPreview").style.display = "none";
if (!data.list || data.list.length === 0) {
container.innerHTML = ``;
container.style.display = "block";
document.getElementById("searchTips").style.display = "none";
return;
}
container.innerHTML = `
"${esc(keyword)}" 共 ${data.total} 首相关歌曲,点击选择:
${data.list.map((item, i) => `
${esc(item.name)}
${esc((item.artists || []).join(" / ") || "未知作者")}
${item.alias && item.alias[0] ? `
${esc(item.alias[0])}
` : ""}
${formatDuration(item.duration / 1000)}
`).join("")}
`;
container.style.display = "block";
document.getElementById("searchTips").style.display = "none";
container.querySelectorAll(".sr-item").forEach((el) => {
el.addEventListener("click", () => {
container.querySelectorAll(".sr-item").forEach((x) => x.classList.remove("active"));
el.classList.add("active");
showDetail(el.dataset.id).catch((err) => showToast("查询失败: " + err.message, "error"));
});
});
}
async function searchMusic() {
const rawInput = musicIdInput.value.trim();
if (!rawInput) return showToast("请输入歌曲 ID、链接或歌名", "error");
const id = extractMusicId(rawInput);
const btn = document.getElementById("btnSearch");
btn.disabled = true; btn.textContent = "查询中...";
try {
if (id && /^\d+$/.test(id)) {
document.getElementById("searchResults").style.display = "none";
await showDetail(id);
} else {
renderSearchResults(await api("/api/music/search?keyword=" + encodeURIComponent(rawInput)), rawInput);
}
} catch (err) { showToast("查询失败: " + err.message, "error"); }
finally { btn.disabled = false; btn.textContent = "查询"; }
}
async function downloadMusic() {
if (!musicInfo || !selectedId) return;
const btn = document.getElementById("btnDownload");
btn.disabled = true; btn.innerHTML = ' 下载中';
try {
const r = await post("/api/music/download", {
id: selectedId, name: musicInfo.name,
artist: musicInfo.artist, albumImage: musicInfo.albumImage,
});
musicIdInput.value = ""; musicInfo = null; selectedId = null;
document.getElementById("musicPreview").style.display = "none";
document.getElementById("searchResults").style.display = "none";
document.getElementById("searchTips").style.display = "block";
parseHint.classList.remove("visible");
loadResources();
showToast("已下载: " + r.file, "success");
} catch (err) { showToast("下载失败: " + err.message, "error"); }
finally { btn.disabled = false; btn.innerHTML = ' 下载到资源库'; }
}
// ==================== Local Import ====================
const importFileInput = document.getElementById("importFile");
document.getElementById("btnImport").addEventListener("click", () => importFileInput.click());
importFileInput.addEventListener("change", async () => {
const f = importFileInput.files[0];
if (!f) return;
const title = document.getElementById("importTitle").value.trim();
const artist = document.getElementById("importArtist").value.trim();
const prog = document.getElementById("importProgress");
const btn = document.getElementById("btnImport");
btn.disabled = true; prog.textContent = "上传中...";
const fd = new FormData();
fd.append("audio", f);
fd.append("title", title);
fd.append("artist", artist);
try {
const res = await fetch("/api/music/import", { method: "POST", body: fd, credentials: "same-origin" });
if (res.status === 401) { window.location.href = "/login"; return; }
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || "导入失败");
prog.textContent = "导入成功,正在匹配元数据...";
showToast("已导入: " + data.file, "success");
document.getElementById("importTitle").value = "";
document.getElementById("importArtist").value = "";
importFileInput.value = "";
await loadResources();
prog.textContent = "";
} catch (err) {
prog.textContent = "";
showToast("导入失败: " + err.message, "error");
} finally { btn.disabled = false; }
});
// ==================== SSE ====================
function connectSSE() {
const es = new EventSource("/api/events");
es.addEventListener("cache-status", () => { loadResources(); loadPlaylist(); });
es.addEventListener("playlist-update", () => loadPlaylist());
es.addEventListener("song-finished", () => loadPlaylist());
es.addEventListener("now-playing", () => loadPlaylist());
es.onerror = () => { es.close(); setTimeout(connectSSE, 5000); };
}
// ==================== Event Listeners ====================
document.getElementById("btnSearch").addEventListener("click", searchMusic);
document.getElementById("btnDownload").addEventListener("click", downloadMusic);
document.getElementById("btnClear").addEventListener("click", clearPlaylist);
musicIdInput.addEventListener("keydown", (e) => { if (e.key === "Enter") searchMusic(); });
musicPlayer.addEventListener("error", () => showToast("试听失败:该歌曲可能无版权", "error"));
document.getElementById("resourceSearch").addEventListener("input", (e) => {
resourceKeyword = e.target.value.trim().toLowerCase();
applyResourceFilter();
});
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if (e.code === "Space") { e.preventDefault(); musicPlayer.paused ? musicPlayer.play() : musicPlayer.pause(); }
if (e.code === "ArrowRight") musicPlayer.currentTime = Math.min(musicPlayer.duration || 0, musicPlayer.currentTime + 5);
if (e.code === "ArrowLeft") musicPlayer.currentTime = Math.max(0, musicPlayer.currentTime - 5);
});
// ==================== Swipe Navigation ====================
let touchStartX = 0, touchStartY = 0, touchStartTime = 0;
document.addEventListener("touchstart", (e) => {
touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; touchStartTime = Date.now();
}, { passive: true });
document.addEventListener("touchend", (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
const dt = Date.now() - touchStartTime;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || dt > 500) return;
const idx = pageOrder.findIndex((id) => document.getElementById(id)?.classList.contains("active"));
if (idx < 0) return;
if (dx < 0 && idx < pageOrder.length - 1) switchPage(pageOrder[idx + 1]);
else if (dx > 0 && idx > 0) switchPage(pageOrder[idx - 1]);
}, { passive: true });
// ==================== Init ====================
(async function init() {
await loadStreams();
await loadResources();
await loadPlaylist();
loadCacheInfo();
connectSSE();
setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); }, 8000);
})();