Files
StarlightRadio/public/js/app.js
T
Xiao-no-love f07fbbc21f v2.5 安全加固 + 权限体系 + 封面网络化
- 双密码(管理员/用户) + requireAdmin 角色隔离
- 登录失败按IP锁定 + 搜索缓存限流
- 封面远程URL优先+本地回退
- sessions.role / songs.cover_remote 运行时迁移
2026-09-25 22:25:25 +08:00

912 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
Xiao の 电台 — app.js (v2)
多路流 + 鉴权 + 删除/导入 + 封面本地化
============================================================ */
// ==================== 全局状态 ====================
let currentStreamId = "main";
let liveStreamUrl = "/hls/main/index.m3u8";
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";
}
// 封面:远程 URL 优先,失败自动回退本地
function coverSrcAttr(item) {
const remote = item.albumImage || "";
const local = item.coverLocal || "";
if (remote && local && remote !== local) {
return `src="${esc(remote)}" data-local="${esc(local)}" onerror="this.onerror=null;this.src=this.dataset.local;"`;
}
return `src="${esc(remote || local)}"`;
}
// ==================== 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;
}
// 角色 UI:非管理员隐藏 设置页 + 本地导入
function applyRoleUI(role) {
const isAdmin = role === "admin";
const tab = document.querySelector('.nav-tab[data-page="pageSettings"]');
if (tab) tab.style.display = isAdmin ? "" : "none";
const imp = document.getElementById("importCard");
if (imp) imp.style.display = isAdmin ? "" : "none";
}
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) : "主电台";
liveStreamUrl = cur ? cur.m3u8 : "/hls/main/index.m3u8";
updateLiveUI();
} catch (e) { console.error("加载流失败", e); }
}
document.getElementById("liveLink").addEventListener("click", async () => {
const url = new URL(liveStreamUrl, location.origin).href;
try {
await navigator.clipboard.writeText(url);
showToast("已复制直播流链接", "success");
} catch (err) {
const ta = document.createElement("textarea");
ta.value = url;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); showToast("已复制直播流链接", "success"); }
catch (e2) { showToast("复制失败,请手动复制: " + url, "error"); }
ta.remove();
}
});
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" ${coverSrcAttr(item)} 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.onerror = info.coverLocal ? () => { mpCover.onerror = null; mpCover.src = info.coverLocal; } : null; 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.onerror = cur.coverLocal ? () => { liveCover.onerror = null; liveCover.src = cur.coverLocal; } : null;
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() {
if (state.role !== "admin") return;
// 缓存
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() {
try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role); } catch (_) {}
await loadStreams();
loadLiveCover();
await loadResources();
await loadPlaylist();
loadCacheInfo();
connectSSE();
setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); refreshLive(); }, 8000);
})();