/* ============================================================ 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 ? ' 当前' : ""; const run = s.running ? "运行中" : "已停止"; const delBtn = isMain ? "" : ''; return `
${esc(s.name || s.id)}${cur}
ID: ${esc(s.id)} · ${run}
${delBtn}
`; }) .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 ? `` : `
`; const bg = item.albumImage ? `
` : ""; const tpl = document.createElement("template"); tpl.innerHTML = `
${bg}
${cover}
${esc(item.title)}
${esc(item.artist) || "未知作者"}
${item.heat}
${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 = ""; 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 = `
暂无资源
前往搜索页导入音乐
`; 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.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 = ''; }); 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 ==================== // 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 ? '' : ''; } 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]) => `
${esc(k)}${esc(String(v))}
`) .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); })();