v2.2 前端增强: - 站内 HLS 收听页 (#pageLive),hls.js 本地化 vendor/hls.min.js - 设置页 (缓存/配置/日志) - 上传进度百分比 (XHR upload.onprogress) - 资源列表排序 (时间/热度/名称 ×升降) - 面板正在播放实时化 (接 playlist-update/song-finished,删死监听) - 死代码清理 + forgetSong 内存泄漏修复 + CORS 非法头移除 - start.cmd 托管启动脚本 (set "VAR=val" 避免尾部空格) v2.3 UI 去 AI 味 (依据 ibelick/ui-skills baseline-ui): - 频道管理+切换合并为单一入口 #btnStreamMgr.channel-btn (删除 header 的 #streamSelect 下拉,弹窗内统一切换/删除/新建) - 清除全部 CSS 渐变与 glow: 橙红 #ff5f6d→#ffc371、紫 #8b5cf6、 径向光晕、缓存条渐变 → 全部改为 accent/token 纯色 - live 区、modal 区硬编码色统一归 CSS 变量 token 测试: api 16/16, e2e 17/17, write 5/5 全绿
878 lines
37 KiB
JavaScript
878 lines
37 KiB
JavaScript
/* ============================================================
|
||
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 = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings", "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 === "pageLive") { updateLiveUI(); loadLiveCover(); }
|
||
if (pageId === "pageSettings") loadSettings();
|
||
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 || [];
|
||
if (!state.streams.some((s) => s.id === currentStreamId)) {
|
||
currentStreamId = state.streams[0] ? state.streams[0].id : "main";
|
||
}
|
||
const cur = state.streams.find((s) => s.id === currentStreamId);
|
||
const nameEl = document.getElementById("currentChannelName");
|
||
if (nameEl) nameEl.textContent = cur ? (cur.name || cur.id) : "主电台";
|
||
document.getElementById("liveLink").href = cur ? cur.m3u8 : "/hls/main/index.m3u8";
|
||
updateLiveUI();
|
||
} catch (e) { console.error("加载流失败", e); }
|
||
}
|
||
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";
|
||
});
|
||
|
||
// ==================== 频道管理 ====================
|
||
const streamModal = document.getElementById("streamModal");
|
||
function openStreamManager() {
|
||
renderStreamMgr();
|
||
streamModal.classList.add("show");
|
||
}
|
||
function closeStreamManager() {
|
||
streamModal.classList.remove("show");
|
||
}
|
||
function renderStreamMgr() {
|
||
const box = document.getElementById("streamMgrList");
|
||
box.innerHTML = state.streams
|
||
.map((s) => {
|
||
const isMain = s.id === "main";
|
||
const cur = s.id === currentStreamId ? ' <span class="now-badge">当前</span>' : "";
|
||
const run = s.running ? "运行中" : "已停止";
|
||
const delBtn = isMain
|
||
? ""
|
||
: '<button class="btn-icon danger js-delstream" title="删除频道"><i class="bi bi-trash3-fill"></i></button>';
|
||
return `<div class="stream-row" data-id="${esc(s.id)}">
|
||
<div class="stream-row-info">
|
||
<div class="stream-row-name">${esc(s.name || s.id)}${cur}</div>
|
||
<div class="stream-row-sub">ID: ${esc(s.id)} · ${run}</div>
|
||
</div>
|
||
<div class="stream-row-actions">
|
||
<button class="btn-icon js-switch" title="切换到该频道"><i class="bi bi-box-arrow-in-right"></i></button>
|
||
${delBtn}
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
box.querySelectorAll(".js-switch").forEach((btn) => {
|
||
btn.addEventListener("click", (e) => switchStream(e.target.closest(".stream-row").dataset.id));
|
||
});
|
||
box.querySelectorAll(".js-delstream").forEach((btn) => {
|
||
btn.addEventListener("click", (e) => deleteStreamById(e.target.closest(".stream-row").dataset.id));
|
||
});
|
||
}
|
||
async function switchStream(id) {
|
||
currentStreamId = id;
|
||
if (livePlaying) stopLive();
|
||
await loadStreams();
|
||
await loadPlaylist();
|
||
await loadResources();
|
||
renderStreamMgr();
|
||
showToast("已切换到频道: " + id, "success");
|
||
}
|
||
async function createStreamFromForm() {
|
||
const idEl = document.getElementById("newStreamId");
|
||
const nameEl = document.getElementById("newStreamName");
|
||
const id = idEl.value.trim();
|
||
const name = nameEl.value.trim();
|
||
if (!id) return showToast("请输入频道 ID", "error");
|
||
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) return showToast("ID 仅允许字母数字_-,1~32位", "error");
|
||
try {
|
||
await post("/api/streams", { id, name: name || id });
|
||
idEl.value = "";
|
||
nameEl.value = "";
|
||
await loadStreams();
|
||
renderStreamMgr();
|
||
showToast("已创建频道: " + id, "success");
|
||
} catch (err) {
|
||
showToast("创建失败: " + err.message, "error");
|
||
}
|
||
}
|
||
async function deleteStreamById(id) {
|
||
if (!confirm("确定删除频道「" + id + "」?\n该频道的播放列表将一并删除。")) return;
|
||
try {
|
||
await api("/api/streams/" + encodeURIComponent(id), { method: "DELETE" });
|
||
if (currentStreamId === id) currentStreamId = "main";
|
||
await loadStreams();
|
||
await loadPlaylist();
|
||
renderStreamMgr();
|
||
showToast("已删除频道: " + id, "success");
|
||
} catch (err) {
|
||
showToast("删除失败: " + err.message, "error");
|
||
}
|
||
}
|
||
document.getElementById("btnStreamMgr").addEventListener("click", openStreamManager);
|
||
document.getElementById("streamModalClose").addEventListener("click", closeStreamManager);
|
||
document.getElementById("btnCreateStream").addEventListener("click", createStreamFromForm);
|
||
streamModal.addEventListener("click", (e) => {
|
||
if (e.target === streamModal) closeStreamManager();
|
||
});
|
||
|
||
// ==================== 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
|
||
? `<img class="res-cover" src="${esc(item.albumImage)}" alt="" loading="lazy">`
|
||
: `<div class="res-cover-ph"><i class="bi bi-music-note"></i></div>`;
|
||
const bg = item.albumImage
|
||
? `<div class="res-bg" style="background-image:url('${esc(item.albumImage)}')"></div>`
|
||
: "";
|
||
const tpl = document.createElement("template");
|
||
tpl.innerHTML = `<div class="res-item">${bg}
|
||
<div class="res-row">${cover}
|
||
<div class="res-meta">
|
||
<div class="res-title" title="${esc(item.title)}">${esc(item.title)}</div>
|
||
<div class="res-artist">${esc(item.artist) || "未知作者"}</div>
|
||
</div>
|
||
<div class="res-actions">
|
||
<span class="heat-chip"><i class="bi bi-fire"></i><span class="js-heat">${item.heat}</span></span>
|
||
<button class="btn-icon js-add" title="添加到当前频道"><i class="bi bi-plus-circle-fill" style="color:var(--accent)"></i></button>
|
||
<button class="btn-icon js-enrich" title="重新匹配作者/封面"><i class="bi bi-arrow-repeat"></i></button>
|
||
<button class="btn-icon danger js-del" title="删除"><i class="bi bi-trash3-fill"></i></button>
|
||
</div>
|
||
</div>
|
||
<div class="res-detail">
|
||
<span><i class="bi bi-clock"></i> ${formatDuration(item.duration)}</span>
|
||
<span class="detail-sep">|</span>
|
||
<span><i class="bi bi-file-earmark"></i> ${formatBytes(item.fileSize)}</span>
|
||
<span class="detail-sep">|</span>
|
||
<span><i class="bi bi-database"></i> 缓存 <span class="js-cache">${formatBytes(item.cacheSize)}</span></span>
|
||
</div>
|
||
</div>`;
|
||
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 = "";
|
||
let resourceSort = "created_desc";
|
||
|
||
function sortResources(list) {
|
||
const arr = list.slice();
|
||
const byName = (a, b) => (a.title || a.file || "").localeCompare(b.title || b.file || "", "zh", { numeric: true });
|
||
switch (resourceSort) {
|
||
case "created_asc": arr.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); break;
|
||
case "created_desc": arr.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); break;
|
||
case "heat_asc": arr.sort((a, b) => (a.heat || 0) - (b.heat || 0)); break;
|
||
case "heat_desc": arr.sort((a, b) => (b.heat || 0) - (a.heat || 0)); break;
|
||
case "name_asc": arr.sort(byName); break;
|
||
case "name_desc": arr.sort((a, b) => byName(b, a)); break;
|
||
}
|
||
return arr;
|
||
}
|
||
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 = sortResources(r.list || []);
|
||
state.resources = resources;
|
||
const container = document.getElementById("resourceList");
|
||
document.getElementById("resourceCount").textContent = resources.length;
|
||
if (resources.length === 0) {
|
||
container.innerHTML = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-folder-x"></i></div><div class="empty-title">暂无资源</div><div class="empty-desc">前往搜索页导入音乐</div></div>`;
|
||
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 = `<div class="pl-item" data-id="${item.entryId}">
|
||
<i class="bi bi-grip-vertical pl-grip"></i>
|
||
<span class="pl-num"></span>
|
||
<div class="pl-info">
|
||
<div class="pl-name" title="${esc(item.title)}">${esc(item.title)}${item.isCurrent ? ' <span class="now-badge">播放中</span>' : ""}</div>
|
||
<div class="pl-sub">
|
||
<span class="heat-chip pl-heat"><i class="bi bi-fire"></i><span class="js-heat"></span></span>
|
||
<span class="pl-cache"></span>
|
||
</div>
|
||
</div>
|
||
<button class="btn-icon danger" title="移除"><i class="bi bi-x-circle-fill"></i></button>
|
||
</div>`;
|
||
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" ? '<i class="bi bi-check-circle-fill"></i> 已缓存'
|
||
: st === "pending" ? '<i class="bi bi-hourglass-split"></i> 生成中'
|
||
: '<i class="bi bi-cloud"></i> 未缓存';
|
||
}
|
||
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 = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-music-note"></i></div><div class="empty-title">播放列表为空</div><div class="empty-desc">从资源列表中添加音乐</div></div>`;
|
||
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 =
|
||
`<i class="bi bi-database"></i> ${(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 = '<i class="bi bi-pause-fill"></i>'; });
|
||
musicPlayer.addEventListener("pause", () => { mpPlay.innerHTML = '<i class="bi bi-play-fill"></i>'; });
|
||
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 = '<i class="bi bi-play-fill"></i>'; });
|
||
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 += `<span class="detail-tag album-tag"><i class="bi bi-disc"></i> ${esc(info.album)}</span>`;
|
||
if (info.duration) tags += `<span class="detail-tag duration-tag"><i class="bi bi-clock"></i> ${formatDuration(info.duration)}</span>`;
|
||
if (id) tags += `<span class="detail-tag"><i class="bi bi-hash"></i> ${id}</span>`;
|
||
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 = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-search"></i></div><div class="empty-title">未找到相关歌曲</div><div class="empty-desc">换个关键词试试</div></div>`;
|
||
container.style.display = "block";
|
||
document.getElementById("searchTips").style.display = "none";
|
||
return;
|
||
}
|
||
container.innerHTML = `
|
||
<div class="sr-head">"${esc(keyword)}" 共 <b>${data.total}</b> 首相关歌曲,点击选择:</div>
|
||
<div class="sr-list">${data.list.map((item, i) => `
|
||
<div class="sr-item" data-id="${item.id}" style="animation-delay:${i * 0.04}s">
|
||
<div class="sr-main">
|
||
<div class="sr-name">${esc(item.name)}</div>
|
||
<div class="sr-artist">${esc((item.artists || []).join(" / ") || "未知作者")}</div>
|
||
</div>
|
||
${item.alias && item.alias[0] ? `<div class="sr-alias" title="${esc(item.alias.join(" / "))}">${esc(item.alias[0])}</div>` : ""}
|
||
<div class="sr-duration">${formatDuration(item.duration / 1000)}</div>
|
||
</div>`).join("")}</div>`;
|
||
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 = '<span class="spinner-sm"></span> 下载中';
|
||
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 = '<i class="bi bi-download"></i> 下载到资源库'; }
|
||
}
|
||
|
||
// ==================== Local Import ====================
|
||
// XHR 上传(带进度回调),fetch 不支持上传进度
|
||
function uploadWithProgress(url, formData, onProgress) {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open("POST", url);
|
||
xhr.withCredentials = true;
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
|
||
};
|
||
xhr.onload = () => {
|
||
if (xhr.status === 401) { window.location.href = "/login"; return; }
|
||
let data = null;
|
||
try { data = JSON.parse(xhr.responseText); } catch (_) {}
|
||
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) resolve(data);
|
||
else reject(new Error((data && data.error) || ("HTTP " + xhr.status)));
|
||
};
|
||
xhr.onerror = () => reject(new Error("网络错误"));
|
||
xhr.send(formData);
|
||
});
|
||
}
|
||
|
||
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 data = await uploadWithProgress("/api/music/import", fd, (pct) => {
|
||
prog.textContent = "上传中... " + pct + "%";
|
||
});
|
||
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 ====================
|
||
// 节流刷新收听页(切歌/列表变化时)
|
||
let liveRefreshTimer = null;
|
||
function refreshLive() {
|
||
if (liveRefreshTimer) return;
|
||
liveRefreshTimer = setTimeout(() => {
|
||
liveRefreshTimer = null;
|
||
const pg = document.getElementById("pageLive");
|
||
if (pg && pg.classList.contains("active")) loadLiveCover();
|
||
}, 300);
|
||
}
|
||
|
||
function connectSSE() {
|
||
const es = new EventSource("/api/events");
|
||
es.addEventListener("cache-status", () => { loadResources(); loadPlaylist(); });
|
||
es.addEventListener("playlist-update", () => { loadPlaylist(); refreshLive(); });
|
||
es.addEventListener("song-finished", () => { loadPlaylist(); refreshLive(); });
|
||
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.getElementById("resourceSort").addEventListener("change", (e) => {
|
||
resourceSort = e.target.value;
|
||
loadResources();
|
||
});
|
||
|
||
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 });
|
||
|
||
// ==================== 站内收听(HLS.js) ====================
|
||
const liveCover = document.getElementById("liveCover");
|
||
const liveCoverPh = document.getElementById("liveCoverPh");
|
||
const liveStation = document.getElementById("liveStation");
|
||
const liveNow = document.getElementById("liveNow");
|
||
const liveSub = document.getElementById("liveSub");
|
||
const liveStatus = document.getElementById("liveStatus");
|
||
const liveProgressFill = document.getElementById("liveProgressFill");
|
||
const livePlayBtn = document.getElementById("livePlayBtn");
|
||
const liveHintStation = document.getElementById("liveHintStation");
|
||
|
||
let hls = null;
|
||
let livePlaying = false;
|
||
|
||
function currentStream() {
|
||
return state.streams.find((s) => s.id === currentStreamId) || null;
|
||
}
|
||
|
||
function setLivePlaying(on) {
|
||
livePlaying = on;
|
||
livePlayBtn.innerHTML = on
|
||
? '<i class="bi bi-pause-fill"></i>'
|
||
: '<i class="bi bi-play-fill"></i>';
|
||
}
|
||
|
||
function updateLiveUI() {
|
||
const st = currentStream();
|
||
const name = st ? (st.name || st.id) : "主电台";
|
||
liveStation.textContent = name;
|
||
liveHintStation.textContent = name;
|
||
}
|
||
|
||
async function loadLiveCover() {
|
||
// 用当前频道播放列表第一首的封面作为"正在播放"占位
|
||
try {
|
||
const r = await api(`/api/streams/${currentStreamId}/playlist`);
|
||
const cur = (r.list || []).find((x) => x.isCurrent) || (r.list || [])[0];
|
||
if (cur && cur.albumImage) {
|
||
liveCover.src = cur.albumImage;
|
||
liveCover.style.display = "";
|
||
liveCoverPh.style.display = "none";
|
||
liveNow.textContent = cur.title || "直播中";
|
||
liveSub.textContent = cur.artist || "--";
|
||
} else {
|
||
liveCover.style.display = "none";
|
||
liveCoverPh.style.display = "";
|
||
liveNow.textContent = "直播进行中";
|
||
liveSub.textContent = "暂无曲目信息";
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function stopLive() {
|
||
if (hls) { try { hls.destroy(); } catch (_) {} hls = null; }
|
||
setLivePlaying(false);
|
||
liveStatus.textContent = "已暂停";
|
||
liveProgressFill.style.width = "0%";
|
||
}
|
||
|
||
function startLive() {
|
||
const st = currentStream();
|
||
const url = st ? st.m3u8 : "/hls/main/index.m3u8";
|
||
const audio = musicPlayer; // 复用全局 audio 元素
|
||
audio.muted = false;
|
||
stopLive();
|
||
|
||
const onPlaying = () => {
|
||
setLivePlaying(true);
|
||
liveStatus.textContent = "直播中";
|
||
liveProgressFill.style.width = "100%";
|
||
};
|
||
const onErr = () => { liveStatus.textContent = "连接失败"; setLivePlaying(false); };
|
||
|
||
if (window.Hls && window.Hls.isSupported()) {
|
||
hls = new Hls({ liveSyncDurationCount: 3, lowLatencyMode: false });
|
||
hls.loadSource(url);
|
||
hls.attachMedia(audio);
|
||
hls.on(Hls.Events.MANIFEST_PARSED, () => { audio.play().catch(onErr); });
|
||
hls.on(Hls.Events.ERROR, (e, data) => {
|
||
if (data && data.fatal) { liveStatus.textContent = "直播流错误"; setLivePlaying(false); }
|
||
});
|
||
audio.addEventListener("playing", onPlaying, { once: true });
|
||
} else if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
||
audio.src = url;
|
||
audio.addEventListener("playing", onPlaying, { once: true });
|
||
audio.play().catch(onErr);
|
||
} else {
|
||
liveStatus.textContent = "浏览器不支持 HLS";
|
||
}
|
||
}
|
||
|
||
function toggleLive() {
|
||
if (livePlaying) {
|
||
musicPlayer.pause();
|
||
setLivePlaying(false);
|
||
liveStatus.textContent = "已暂停";
|
||
} else {
|
||
startLive();
|
||
}
|
||
}
|
||
|
||
livePlayBtn.addEventListener("click", toggleLive);
|
||
|
||
// ==================== 设置页 ====================
|
||
function fmtMB(b) { return (b / 1048576).toFixed(1) + " MB"; }
|
||
|
||
async function loadSettings() {
|
||
// 缓存
|
||
try {
|
||
const c = await api("/api/system/cache");
|
||
document.getElementById("setCacheText").textContent =
|
||
fmtMB(c.total) + " / " + (c.limit / 1048576).toFixed(0) + " MB";
|
||
const pct = Math.min(100, parseFloat(c.usedPercent) || 0);
|
||
const bar = document.getElementById("setCacheBar");
|
||
bar.style.width = pct + "%";
|
||
bar.className = "cache-bar-fill" + (pct > 90 ? " danger" : pct > 70 ? " warn" : "");
|
||
document.getElementById("setCacheNote").textContent =
|
||
"已用 " + c.usedPercent + "%,超限时自动按热度 LRU 淘汰";
|
||
} catch (e) { console.error("缓存信息失败", e); }
|
||
|
||
// 配置
|
||
try {
|
||
const r = await api("/api/system/config");
|
||
const cf = r.config || {};
|
||
const items = [
|
||
["HTTP 端口", cf.HTTP_PORT],
|
||
["切片时长", cf.HLS_TIME + " 秒"],
|
||
["滑动窗口", cf.SLIDE_WINDOW_SIZE + " 段"],
|
||
["缓存上限", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0) + " MB"],
|
||
["FFmpeg 并发", cf.MAX_FFMPEG_CONCURRENT + " 路"],
|
||
];
|
||
document.getElementById("setConfigGrid").innerHTML = items
|
||
.map(([k, v]) => `<div class="config-item"><span>${esc(k)}</span><b>${esc(String(v))}</b></div>`)
|
||
.join("");
|
||
} catch (e) { console.error("配置失败", e); }
|
||
|
||
await loadLogs();
|
||
}
|
||
|
||
async function loadLogs() {
|
||
const tail = document.getElementById("logTail").value || 100;
|
||
try {
|
||
const r = await api("/api/system/logs?tail=" + tail);
|
||
const lines = r.lines || [];
|
||
const el = document.getElementById("logView");
|
||
el.textContent = lines.length ? lines.join("\n") : "(暂无日志)";
|
||
el.scrollTop = el.scrollHeight;
|
||
} catch (e) {
|
||
document.getElementById("logView").textContent = "日志加载失败: " + e.message;
|
||
}
|
||
}
|
||
|
||
document.getElementById("btnCleanup").addEventListener("click", async () => {
|
||
if (!confirm("确定立即清理缓存?将按热度淘汰非播放中的切片。")) return;
|
||
try {
|
||
await post("/api/system/cache/cleanup");
|
||
showToast("缓存已清理", "success");
|
||
await loadSettings();
|
||
await loadResources();
|
||
} catch (e) { showToast("清理失败: " + e.message, "error"); }
|
||
});
|
||
document.getElementById("logTail").addEventListener("change", loadLogs);
|
||
|
||
// ==================== Init ====================
|
||
(async function init() {
|
||
await loadStreams();
|
||
loadLiveCover();
|
||
await loadResources();
|
||
await loadPlaylist();
|
||
loadCacheInfo();
|
||
connectSSE();
|
||
setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); refreshLive(); }, 8000);
|
||
})(); |