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