const $ = (selector) => document.querySelector(selector); const $$ = (selector) => document.querySelectorAll(selector); const uiState = { page: 1, query: "", searchMode: "song", busy: false, hotPage: 1, hotPageSize: 10, hotBusy: false, visible: true, connected: false, eqEnabled: false, equalizer: null, searchAbort: null, searchKey: "" }; function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[char])); } function setConnected(connected) { uiState.connected = connected; $("#offline").classList.toggle("hidden", connected); $("#connection").innerHTML = connected ? "已连接麦动 KTV" : "正在连接麦动 KTV…"; } async function api(path, options = {}) { const response = await fetch(path, { cache: "no-store", headers: { "Content-Type": "application/json" }, ...options }); const data = await response.json(); if (!response.ok || data.ok === false) throw new Error(data.error || "操作失败"); setConnected(true); return data; } function toast(text) { const element = $("#toast"); element.textContent = text; element.classList.add("show"); window.setTimeout(() => element.classList.remove("show"), 1600); } function songRow(song, queue = false, current = false, index = 0, ranking = false) { const number = current ? "♪" : String(index + 1).padStart(2, "0"); const actions = queue && !current ? '' : !queue ? '' : ""; return `
${number}
${current ? '正在播放' : ""}${escapeHtml(song.title)}

${escapeHtml(song.singer)}${song.language ? " · " + escapeHtml(song.language) : ""}

${actions}
`; } function openPage(name) { $$("nav button,.page").forEach((element) => element.classList.remove("active")); $(`nav button[data-page="${name}"]`)?.classList.add("active"); $("#" + name)?.classList.add("active"); if (name === "queue") loadQueue(); if (name === "search" && !$("#hotList").children.length) loadHot(1); } async function refresh() { try { const data = await api("/api/v1/state"); const current = data.current || {}; const count = data.queueSize || 0; $("#nowTitle").textContent = current.title || "暂无歌曲"; $("#nowSinger").textContent = current.singer || "请先点一首喜欢的歌"; $("#queueBadge b").textContent = count; $("#navCount").textContent = count; $("#navCount").classList.toggle("hidden", count === 0); $("#playButton span:last-child").textContent = data.playing ? "暂停" : "继续"; $("#playButton").dataset.action = data.playing ? "pause" : "resume"; $("#playButton .control-icon").textContent = data.playing ? "Ⅱ" : "▶"; $("#musicVolume").value = data.musicVolume ?? 85; $("#micVolume").value = data.micVolume ?? 70; $("#musicValue").textContent = data.musicVolume ?? 85; // 麦克风音量由功放/混音器硬件调节(滑杆已禁用), 仅保留显示值。 const micHw = $("#micValue"); if (micHw) micHw.textContent = data.micVolume ?? 70; $("#original").classList.toggle("active", data.vocalMode === "original"); $("#accompany").classList.toggle("active", data.vocalMode !== "original"); $("#nextSong").textContent = data.next?.title || "队列为空"; // 均衡器卡片默认收起: 收起时跳过 DOM 更新(避免每秒轮询重绘), 展开时再渲染一次。 uiState.equalizer = data.equalizer; if (!$("#eqBody")?.classList.contains("hidden")) renderEqualizer(data.equalizer); } catch (_) { setConnected(false); } } // ─── 均衡器(与设备端 KtvEqualizer 同步,频段由设备真实能力决定)─── function renderEqualizer(eq) { if (!eq) return; uiState.eqEnabled = !!eq.enabled; const state = $("#eqState"); if (state) state.textContent = eq.enabled ? (eq.preset || "自定义") : "已关闭"; const toggle = $("#eqToggle"); if (toggle) { toggle.textContent = eq.enabled ? "关闭均衡器" : "开启均衡器"; toggle.classList.toggle("active", !!eq.enabled); } const hint = $("#eqHint"); if (hint) { hint.textContent = eq.globalMix ? "当前作用于全局混音(会影响本机所有声音)" : "均衡器作用于本机播放输出,不改动系统音量"; } const bands = Array.isArray(eq.bands) ? eq.bands : []; const container = $("#eqBands"); if (container && container.childElementCount !== bands.length) { container.innerHTML = bands.map((band, index) => `
${escapeHtml(band.label)}Hz${Number(band.level).toFixed(1)}dB
`).join(""); bands.forEach((band, index) => { const input = $("#eqBand" + index); if (!input) return; attachThumbOnlyDrag(input); input.addEventListener("input", () => { const value = $("#eqValue" + index); if (value) value.textContent = Number(input.value).toFixed(1); }); input.addEventListener("change", () => updateEqualizer({ levels: currentEqLevels() })); }); } else if (container) { bands.forEach((band, index) => { const input = $("#eqBand" + index); // 正在拖动的那根滑杆不要被轮询覆盖。 if (input && document.activeElement !== input) input.value = band.level; const value = $("#eqValue" + index); if (value) value.textContent = Number(band.level).toFixed(1); }); } const presets = Array.isArray(eq.presets) ? eq.presets : []; const presetBox = $("#eqPresets"); if (presetBox && presetBox.childElementCount !== presets.length) { presetBox.innerHTML = presets.map((name) => ``).join(""); presetBox.querySelectorAll(".eq-preset").forEach((button) => { button.addEventListener("click", () => updateEqualizer({ preset: button.dataset.preset })); }); } presetBox?.querySelectorAll(".eq-preset").forEach((button) => { button.classList.toggle("active", button.dataset.preset === eq.preset); }); } function currentEqLevels() { return Array.from($$("#eqBands input[type=range]")).map((input) => Number(input.value)); } /** * 触控优化:滑块必须按住「控件点(滑块)」才能拖动,点轨道空白处不跳变数值。 * * `` 默认点轨道任意位置都会跳值并开始拖动;这里在指针按下时先做 * 命中判断 —— 命中滑块才放行浏览器原生拖动,否则 preventDefault 拦掉这次手势。 * 只有粘性标记滑块处于按下状态时才放行,手指移出轨道仍可继续拖动(由原生处理)。 */ function attachThumbOnlyDrag(input) { if (!input || input.dataset.thumbOnly === "1") return; input.dataset.thumbOnly = "1"; const blocked = (event) => { const rect = input.getBoundingClientRect(); const point = event.touches && event.touches.length ? event.touches[0] : event; const x = point.clientX; const y = point.clientY; // 粗略还原轨道几何:控件左右各留半个滑块宽度。 const thumb = 32; const trackLeft = rect.left + thumb / 2; const trackRight = rect.right - thumb / 2; const span = trackRight - trackLeft; if (span <= 0 || y < rect.top - thumb || y > rect.bottom + thumb) return true; const min = Number(input.min || 0); const max = Number(input.max || 100); const ratio = (Number(input.value) - min) / (max - min || 1); const thumbX = trackLeft + ratio * span; // 命中滑块(含手指触控容差)→ 放行拖动;否则拦掉,避免点轨道跳值。 return Math.abs(x - thumbX) > Math.max(thumb, 26); }; const guard = (event) => { if (blocked(event)) event.preventDefault(); }; input.addEventListener("pointerdown", guard, { passive: false }); input.addEventListener("touchstart", guard, { passive: false }); input.addEventListener("mousedown", guard); } async function updateEqualizer(values) { try { await api("/api/v1/player/equalizer", { method: "PATCH", body: JSON.stringify(values) }); await refresh(); } catch (error) { toast(error.message); } } async function playerAction(action) { try { await api("/api/v1/player/actions", { method: "POST", body: JSON.stringify({ action }) }); await refresh(); } catch (error) { toast(error.message); } } async function updateAudio(values) { try { await api("/api/v1/player/audio", { method: "PATCH", body: JSON.stringify(values) }); await refresh(); } catch (error) { toast(error.message); } } async function search(reset = true) { if (reset) { const nextQuery = $("#query").value.trim(); if (!nextQuery) { toast(`请输入${uiState.searchMode === "singer" ? "歌手" : "歌曲"}名称`); $("#query").focus(); return; } // 同一关键词+同一搜索方式且结果已渲染 → 不重复请求设备(设备端也要查 1GB 曲库)。 const key = `${uiState.searchMode}|${nextQuery}`; if (key === uiState.searchKey && $("#results").childElementCount) { $("#searchResultBlock").scrollIntoView({ behavior: "smooth", block: "start" }); return; } uiState.page = 1; uiState.query = nextQuery; uiState.searchKey = key; $("#results").innerHTML = ""; $("#searchResultBlock").classList.remove("hidden"); $("#resultTitle").textContent = uiState.searchMode === "singer" ? "歌手搜索结果" : "歌曲搜索结果"; $("#resultSummary").textContent = `正在搜索“${uiState.query}”`; $("#more").classList.add("hidden"); if (uiState.searchAbort) uiState.searchAbort.abort(); uiState.searchAbort = new AbortController(); } const signal = uiState.searchAbort ? uiState.searchAbort.signal : undefined; const requestedPage = uiState.page; uiState.busy = true; try { const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${requestedPage}&pageSize=20`, { signal }); const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (requestedPage - 1) * 20 + index)).join(""); if (rows) $("#results").insertAdjacentHTML("beforeend", rows); else if (reset) $("#results").innerHTML = '
没有找到相关歌曲
请更换关键词后重试
'; $("#resultSummary").textContent = `“${uiState.query}”${data.hasMore ? ",还有更多" : ""}`; $("#more").classList.toggle("hidden", !data.hasMore); uiState.page = requestedPage + 1; // 预热下一页: 设备端会把它缓存下来, 用户点「更多」时基本瞬时。 if (requestedPage === 1 && data.hasMore) { window.setTimeout(() => { api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=2&pageSize=20`) .catch(() => {}); }, 500); } } catch (error) { // 被下一次搜索取消属于正常情况, 不弹提示。 if (error && error.name !== "AbortError") toast(error.message); } finally { uiState.busy = false; } } async function loadHot(page) { if (uiState.hotBusy || page < 1) return; uiState.hotBusy = true; $("#hotPrev").disabled = true; $("#hotNext").disabled = true; $("#hotSummary").textContent = "正在加载…"; try { const data = await api(`/api/v1/hot?page=${page}&pageSize=${uiState.hotPageSize}`); uiState.hotPage = data.page || page; const songs = data.songs || []; $("#hotList").innerHTML = songs.map((song, index) => songRow(song, false, false, (uiState.hotPage - 1) * uiState.hotPageSize + index, true) ).join("") || '
暂无热歌排行
'; $("#hotPage").textContent = `第 ${uiState.hotPage} 页`; $("#hotSummary").textContent = songs.length ? `第 ${(uiState.hotPage - 1) * uiState.hotPageSize + 1}-${(uiState.hotPage - 1) * uiState.hotPageSize + songs.length} 名` : "暂无数据"; $("#hotPrev").disabled = !data.hasPrevious; $("#hotNext").disabled = !data.hasMore; if (page > 1) $("#hot").scrollIntoView({ behavior: "smooth", block: "start" }); } catch (error) { $("#hotList").innerHTML = '
热歌排行加载失败
'; $("#hotSummary").textContent = "请点击翻页重试"; $("#hotPrev").disabled = uiState.hotPage <= 1; $("#hotNext").disabled = false; toast(error.message); } finally { uiState.hotBusy = false; } } async function loadQueue() { try { const data = await api("/api/v1/queue"); const queue = data.queue || []; $("#queueCount").textContent = `${queue.length} 首`; $("#queueList").innerHTML = queue.map((song, index) => songRow(song, true, index === 0, index)).join("") || '
还没有点歌
去曲库选一首喜欢的歌吧
'; } catch (error) { toast(error.message); } } document.addEventListener("click", async (event) => { const button = event.target.closest("button"); if (!button) return; if (button.dataset.page) openPage(button.dataset.page); if (button.dataset.action) playerAction(button.dataset.action); if (button.dataset.vocal) updateAudio({ vocalMode: button.dataset.vocal }); if (button.dataset.searchMode) { uiState.searchMode = button.dataset.searchMode; $$('[data-search-mode]').forEach((item) => item.classList.toggle("active", item === button)); $("#query").placeholder = uiState.searchMode === "singer" ? "输入歌手名或拼音" : "输入歌曲名或拼音"; $("#query").value = ""; $("#results").innerHTML = ""; $("#searchResultBlock").classList.add("hidden"); $("#more").classList.add("hidden"); $("#query").focus(); } const row = button.closest(".song"); if (row && button.classList.contains("order")) { try { await api("/api/v1/queue", { method: "POST", body: JSON.stringify({ songId: row.dataset.id }) }); button.textContent = "已点"; button.disabled = true; toast("已加入歌单"); refresh(); } catch (error) { toast(error.message); } } if (row && button.classList.contains("top")) { try { await api("/api/v1/queue/" + encodeURIComponent(row.dataset.id), { method: "PATCH", body: "{}" }); toast("已置顶为下一首"); loadQueue(); } catch (error) { toast(error.message); } } if (row && button.classList.contains("delete")) { try { await api("/api/v1/queue/" + encodeURIComponent(row.dataset.id), { method: "DELETE" }); toast("已从歌单移除"); loadQueue(); refresh(); } catch (error) { toast(error.message); } } }); $("#searchForm").addEventListener("submit", (event) => { event.preventDefault(); search(); }); $("#more").addEventListener("click", () => search(false)); $("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1)); $("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1)); // 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。 attachThumbOnlyDrag($("#musicVolume")); $("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; }); $("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) })); // 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准 $("#eqToggle")?.addEventListener("click", () => updateEqualizer({ enabled: !uiState.eqEnabled })); $("#eqReset")?.addEventListener("click", () => updateEqualizer({ preset: "标准" })); // 均衡器文字栏作为折叠按钮: 默认收起; 展开后在同一次页面会话内保持, 重新加载页面回到收起态。 $("#eqCollapse")?.addEventListener("click", () => { const body = $("#eqBody"); if (!body) return; const collapsed = body.classList.toggle("hidden"); $("#eqCollapse").setAttribute("aria-expanded", collapsed ? "false" : "true"); // 展开时补一次渲染(收起期间轮询跳过了 EQ 的 DOM 更新)。 if (!collapsed) renderEqualizer(uiState.equalizer); }); document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); }); // 搜索进行中暂停状态轮询: 避免与设备端 1GB 曲库查询抢 I/O。 window.setInterval(() => { if (uiState.visible && !uiState.busy) refresh(); }, 1000); refresh(); loadHot(1);