Files
StarlightRadio/public/js/app.js
T

585 lines
26 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";
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) => `<option value="${esc(s.id)}"${s.id === currentStreamId ? " selected" : ""}>${esc(s.name || s.id)}</option>`)
.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
? `<img class="res-cover" src="${esc(item.albumImage)}" 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 = "";
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 = `<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.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 ====================
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);
})();