/* ============================================================ Xiao の 电台 — Enhanced JS ============================================================ */ // ==================== 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); } // ==================== Navigation ==================== const tabs = document.querySelectorAll(".nav-tab"); const pages = document.querySelectorAll(".page"); 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"); }); // Eager-load data on tab switch if (pageId === "pageResources") loadResources(); if (pageId === "pagePlaylist") loadPlaylist(); } tabs.forEach((tab) => tab.addEventListener("click", () => switchPage(tab.dataset.page), ), ); // ==================== Helpers ==================== function esc(s) { return String(s ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[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"; } async function api(url, options = {}) { const res = await fetch(url, options); if (!res.ok) throw new Error(await res.text()); return res.json(); } // ==================== 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; } // ==================== Parse Hint ==================== 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), ); // ==================== 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)}
`; return tpl.content.firstElementChild; } 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 resources = await api("/api/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.name)}
`; 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" ? ' 生成中' : ' 未缓存'; } el.querySelector("button").setAttribute( "onclick", "removeFromPlaylist(" + idx + ")", ); } async function loadPlaylist() { try { const playlistData = await api("/api/playlist"); 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.path, 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 newOrder = items.map( (el) => el.dataset.id, ); try { await api("/api/playlist/reorder", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ newOrder }), }); items.forEach((el, i) => { el.querySelector( ".pl-num", ).textContent = i + 1; el.querySelector("button").setAttribute( "onclick", "removeFromPlaylist(" + i + ")", ); }); } catch (err) { console.error("排序更新失败:", err); loadPlaylist(); } }, }); } } catch (err) { console.error("加载播放列表失败:", err); } } // ==================== Actions ==================== async function addToPlaylist(fileName) { try { await api("/api/playlist/add", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ file: fileName }), }); showToast("已添加到播放列表", "success"); await loadPlaylist(); await loadResources(); } catch (err) { showToast("添加失败: " + err.message, "error"); } } async function removeFromPlaylist(index) { try { await api("/api/playlist/remove", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index }), }); await loadPlaylist(); } catch (err) { showToast("移除失败: " + err.message, "error"); } } async function clearPlaylist() { if (!confirm("确定清空播放列表?清空后将自动随机添加一首。")) return; try { await api("/api/playlist/clear", { method: "POST" }); await loadPlaylist(); } catch (err) { showToast("清空失败: " + err.message, "error"); } } // ==================== Cache Info ==================== async function loadCacheInfo() { try { const info = await api("/api/cache/size"); 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", () => { if (musicPlayer.paused) musicPlayer.play(); else musicPlayer.pause(); }); musicPlayer.addEventListener("play", () => { mpPlay.innerHTML = ''; }); musicPlayer.addEventListener("pause", () => { mpPlay.innerHTML = ''; }); musicPlayer.addEventListener("timeupdate", () => { if (!musicPlayer.duration) return; const pct = (musicPlayer.currentTime / musicPlayer.duration) * 100; mpProgress.style.width = pct + "%"; 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", }); }); // ---- Drag-to-seek on the entire mini player bar ---- 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; 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 (info.quality) tags += ` ${esc(info.quality)}`; if (info.fileSize) tags += ` ${formatBytes(info.fileSize)}`; if (info.publishTime) { const d = new Date(info.publishTime); tags += ` ${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`; } 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 api("/api/music/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ 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 = ' 下载到资源库'; } } // ==================== 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(); }); // ==================== Keyboard Shortcuts ==================== document.addEventListener("keydown", (e) => { if ( e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" ) return; if (e.code === "Space") { e.preventDefault(); if (musicPlayer.paused) musicPlayer.play(); else 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; const pageOrder = [ "pageSearch", "pageResources", "pagePlaylist", "pageAbout", ]; 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 ==================== loadResources(); loadPlaylist(); loadCacheInfo(); setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); }, 5000);