"use strict"; // Web UI of the Vocaluxe song server: search sources, queue downloads, browse the library. // Plain JavaScript, no build step. API: /api/v1 (see README.md). const $ = (sel) => document.querySelector(sel); const state = { tab: "search", library: null, // LibraryDto libraryVersion: "", jobs: [], // DownloadJobDto[] results: [], searchSeq: 0, }; function storageGet(key) { try { return localStorage.getItem(key) || ""; } catch { return ""; } } function storageSet(key, value) { try { localStorage.setItem(key, value); } catch { /* private mode */ } } // ---- API ------------------------------------------------------------------ async function api(path, options = {}) { const headers = { ...(options.headers || {}) }; const key = storageGet("apiKey"); if (key) headers["X-Api-Key"] = key; if (options.body) headers["Content-Type"] = "application/json"; const response = await fetch("api/v1/" + path, { ...options, headers }); if (response.status === 401) { await askForKey(); return api(path, options); } if (!response.ok) { let title = response.statusText; try { title = (await response.json()).title || title; } catch { /* not JSON */ } throw new Error(title); } return response.status === 204 ? null : response.json(); } function askForKey() { return new Promise((resolve) => { const dialog = $("#keyDialog"); dialog.addEventListener("close", () => { storageSet("apiKey", $("#keyInput").value.trim()); resolve(); }, { once: true }); dialog.showModal(); }); } // ---- Rendering helpers ---------------------------------------------------- function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === "class") node.className = v; else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); else if (v !== false && v != null) node.setAttribute(k, v === true ? "" : v); } for (const child of children.flat()) { if (child != null && child !== false) node.append(child); } return node; } function cover(url, title) { const fallback = el("div", { class: "cover", "aria-hidden": "true" }, (title || "?").trim().charAt(0).toUpperCase()); if (!url) return fallback; const img = el("img", { class: "cover", src: url, alt: "", loading: "lazy", referrerpolicy: "no-referrer" }); img.addEventListener("error", () => img.replaceWith(fallback), { once: true }); return img; } function songKey(artist, title) { const norm = (s) => (s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim(); return norm(artist) + "|" + norm(title); } // ---- Tabs ----------------------------------------------------------------- function showTab(tab) { state.tab = tab; document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.id === "tab-" + tab)); document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab)); storageSet("tab", tab); if (tab === "library") loadLibrary(); if (tab === "downloads") renderJobs(); if (tab === "search") $("#query").focus({ preventScroll: true }); } document.querySelectorAll(".tabs button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab))); // ---- Sources -------------------------------------------------------------- async function loadSources() { try { const sources = await api("sources"); $("#sources").replaceChildren(...sources.map((s) => el("span", { class: "chip" + (s.enabled && !s.hint ? "" : " off"), title: s.hint || s.description }, el("span", { class: "dot" }), s.name, s.hint && s.enabled ? " (limited)" : "", !s.enabled ? " (off)" : ""))); } catch (e) { $("#sources").replaceChildren(); } } // ---- Search --------------------------------------------------------------- let searchTimer = 0; $("#query").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(search, 450); }); $("#searchForm").addEventListener("submit", (e) => { e.preventDefault(); clearTimeout(searchTimer); $("#query").blur(); // hide the keyboard on phones search(); }); async function search() { const q = $("#query").value.trim(); const seq = ++state.searchSeq; const status = $("#searchStatus"); status.className = "status"; if (q.length < 2) { state.results = []; status.textContent = "Type at least two letters. All song sources are searched at once."; renderResults(); return; } status.textContent = "Searching…"; try { const response = await api("search?q=" + encodeURIComponent(q)); if (seq !== state.searchSeq) return; // a newer search is running state.results = response.results; const errors = Object.entries(response.errors || {}).map(([source, msg]) => `${source}: ${msg}`); status.textContent = (response.results.length ? `${response.results.length} songs found` : "Nothing found") + (errors.length ? ` · not searched: ${errors.join("; ")}` : ""); status.classList.toggle("error", errors.length > 0 && response.results.length === 0); renderResults(); } catch (e) { if (seq !== state.searchSeq) return; status.textContent = "Search failed: " + e.message; status.classList.add("error"); } } function jobFor(result) { return state.jobs.find((j) => j.source === result.source && j.sourceId === result.sourceId); } function resultButton(result) { const job = jobFor(result); if (result.inLibrary || (job && job.state === "done")) { return el("button", { class: "action done", disabled: true, "aria-label": "In the library" }, "✓"); } if (job && (job.state === "queued" || job.state === "running")) { return el("button", { class: "action pending", disabled: true }, Math.round(job.progress * 100) + "%"); } return el("button", { class: "action", "aria-label": `Download ${result.artist} – ${result.title}`, onclick: (e) => download(result, e.currentTarget), }, job && job.state === "failed" ? "Retry" : "Get"); } function renderResults() { $("#results").replaceChildren(...state.results.map((r) => el("li", { class: "item", "data-key": r.source + "/" + r.sourceId }, cover(r.coverUrl, r.title), el("div", { class: "text" }, el("div", { class: "title" }, r.title), el("div", { class: "artist" }, r.artist), el("div", { class: "details" }, el("span", { class: "source" }, r.source), r.details)), resultButton(r)))); } async function download(result, button) { button.disabled = true; button.textContent = "…"; try { const job = await api("downloads", { method: "POST", body: JSON.stringify({ source: result.source, sourceId: result.sourceId, artist: result.artist, title: result.title }), }); state.jobs = [job, ...state.jobs.filter((j) => j.id !== job.id)]; renderResults(); renderBadge(); pollJobs(true); } catch (e) { button.disabled = false; button.textContent = "Get"; alert("Download failed: " + e.message); } } // ---- Downloads ------------------------------------------------------------ let pollTimer = 0; async function pollJobs(soon) { clearTimeout(pollTimer); if (soon) { pollTimer = setTimeout(() => pollJobs(false), 800); return; } try { const before = state.jobs.filter((j) => j.state === "done").length; state.jobs = await api("downloads"); renderJobs(); renderBadge(); if (state.results.length) renderResults(); if (state.jobs.filter((j) => j.state === "done").length !== before) { state.libraryVersion = ""; // a song was added: reload the library when it's shown setTimeout(loadLibraryCount, 3000); } } catch { /* try again later */ } const active = state.jobs.some((j) => j.state === "queued" || j.state === "running"); pollTimer = setTimeout(() => pollJobs(false), active ? 1500 : 10000); } function renderBadge() { const active = state.jobs.filter((j) => j.state === "queued" || j.state === "running").length; const badge = $("#activeBadge"); badge.hidden = active === 0; badge.textContent = active; } const stateText = { queued: "Waiting", running: "Downloading", done: "Done", failed: "Failed" }; function renderJobs() { $("#downloadsEmpty").hidden = state.jobs.length > 0; $("#downloads").replaceChildren(...state.jobs.map((j) => el("li", { class: "item" }, cover("", j.title), el("div", { class: "text" }, el("div", { class: "title" }, j.title), el("div", { class: "artist" }, j.artist), el("div", { class: "details" }, el("span", { class: "source" }, j.source), j.message && j.state === "done" ? j.message : "")), el("span", { class: "state " + j.state }, stateText[j.state] || j.state), j.state === "running" || j.state === "queued" ? el("div", { class: "progress", role: "progressbar", "aria-valuenow": Math.round(j.progress * 100), "aria-valuemin": 0, "aria-valuemax": 100 }, el("span", { style: `width:${Math.round(j.progress * 100)}%` })) : null, j.state === "failed" ? el("div", { class: "message" }, j.message) : null))); } // ---- Library -------------------------------------------------------------- async function loadLibraryCount() { try { const health = await fetch("health").then((r) => r.json()); if (health.songs != null) $("#libraryCount").textContent = health.songs + " songs"; } catch { /* offline */ } } async function loadLibrary() { const status = $("#libraryStatus"); if (state.library && state.libraryVersion) { renderLibrary(); return; } status.textContent = "Loading…"; try { state.library = await api("library"); state.libraryVersion = state.library.version; $("#libraryCount").textContent = state.library.songs.length + " songs"; renderLibrary(); } catch (e) { status.textContent = "Library not available: " + e.message; } } $("#libraryFilter").addEventListener("input", () => renderLibrary()); function renderLibrary() { if (!state.library) return; const words = songKey($("#libraryFilter").value, "").replace("|", "").split(" ").filter(Boolean); const songs = state.library.songs .filter((s) => { const k = songKey(s.artist, s.title).replace("|", " "); return words.every((w) => k.includes(w)); }) .sort((a, b) => a.artist.localeCompare(b.artist) || a.title.localeCompare(b.title)); const shown = songs.slice(0, 300); $("#libraryStatus").textContent = songs.length === 0 ? (state.library.songs.length ? "No matching songs." : "The library is empty. Download songs under Search.") : songs.length > shown.length ? `${songs.length} songs, showing the first ${shown.length}. Filter to narrow down.` : `${songs.length} songs`; $("#library").replaceChildren(...shown.map((s) => el("li", { class: "item" }, el("div", { class: "text" }, el("div", { class: "title" }, s.title), el("div", { class: "artist" }, s.artist))))); } // ---- Start ---------------------------------------------------------------- // A link like "?q=queen" opens a search, "?tab=library" a tab. const params = new URLSearchParams(location.search); if (params.get("q")) $("#query").value = params.get("q"); const startTab = params.get("q") ? "search" : params.get("tab") || storageGet("tab"); loadSources(); loadLibraryCount(); pollJobs(false); search(); showTab(["search", "downloads", "library"].includes(startTab) ? startTab : "search");