// Playwish – Admin-Bereich. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten). "use strict"; const ABFRAGE_MS = 3000; const QUELLE = { playlist: "Playlist", wish: "Wunsch", owner: "Besitzer", fremd: "Spotify-App" }; const BEFEHL = { play: "Play", pause: "Pause", skip: "Weiter", volume: "Lautstärke", bt_scan: "Lautsprecher suchen", bt_connect: "Lautsprecher verbinden" }; const $ = (id) => document.getElementById(id); let csrf = ""; let zustand = null; let fortschritt = null; let aktiverReiter = "wiedergabe"; let lautstaerkeZiel = null; // {wert, bis}: gesendeter Wert, bis der Player ihn bestätigt // --- Hilfen ------------------------------------------------------------------ function el(tag, klasse, text) { const e = document.createElement(tag); if (klasse) e.className = klasse; if (text !== undefined && text !== null) e.textContent = text; return e; } function cover(url, gross) { const img = el("img", "cover"); img.alt = ""; img.loading = "lazy"; if (url && url.startsWith("https://i.scdn.co/")) img.src = url; if (gross) img.width = img.height = 72; return img; } function textBlock(titel, interpret, ...extra) { const t = el("div", "text"); t.append(el("span", "t", titel), el("span", "a", interpret)); extra.filter(Boolean).forEach((x) => t.append(x)); return t; } function knopf(text, klasse, aktion, titel) { const b = el("button", "klein " + (klasse || ""), text); b.type = "button"; if (titel) b.title = b.ariaLabel = titel; b.addEventListener("click", async () => { b.disabled = true; try { await aktion(); } finally { b.disabled = false; } }); return b; } function uhrzeit(iso) { return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); } let toastTimer = null; function toast(text, fehler) { const t = $("toast"); t.textContent = text; t.className = "toast" + (fehler ? " fehler" : ""); t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, 3500); } async function api(pfad, daten, methode) { const optionen = { method: methode || (daten === undefined ? "GET" : "POST"), credentials: "same-origin", headers: {} }; if (optionen.method !== "GET") { optionen.headers["X-CSRF-Token"] = csrf; optionen.headers["Content-Type"] = "application/json"; optionen.body = JSON.stringify(daten || {}); } const antwort = await fetch("/admin/api" + pfad, optionen); if (antwort.status === 401) { location.href = "/admin/login"; throw new Error("Abgemeldet"); } let inhalt = null; try { inhalt = await antwort.json(); } catch (_) { /* leer */ } if (!antwort.ok) { const meldung = inhalt && typeof inhalt.detail === "string" ? inhalt.detail : "Fehler " + antwort.status; toast(meldung, true); throw new Error(meldung); } return inhalt; } async function aktion(pfad, daten, meldung) { await api(pfad, daten); if (meldung) toast(meldung); await laden(); } function setzeWert(id, wert) { const e = $(id); if (document.activeElement !== e) e.value = wert; // Eingaben des Besitzers nicht überschreiben } function setzeSchalter(id, wert) { const e = $(id); if (document.activeElement !== e) e.checked = !!wert; } // --- Reiter -------------------------------------------------------------------- function zeigeReiter(name) { aktiverReiter = name; document.querySelectorAll("[data-reiter]").forEach((b) => b.setAttribute("aria-selected", String(b.dataset.reiter === name))); document.querySelectorAll("[data-seite]").forEach((s) => { s.hidden = s.dataset.seite !== name; }); if (name === "verlauf") ladeVerlauf(); if (name === "gaeste") { ladeZugang(); ladeSperrliste(); } } // --- Darstellung des Zustands ------------------------------------------------------ function zeigePillen(z) { const p = $("pillen"); p.replaceChildren(); const st = z.player.state || {}; const pille = (text, gut) => p.append(el("span", "pille " + (gut ? "gut" : "schlecht"), text)); if (z.player.online) pille("Player online", true); else pille(z.player.last_seen_s === null ? "Player noch nie gesehen" : `Player offline (${Math.round(z.player.last_seen_s / 60)} min)`, false); if (z.player.online) { pille(st.device_ok === false ? "Gerät fehlt" : "Gerät ok", st.device_ok !== false); pille(st.speaker_ok === false ? "Lautsprecher getrennt" : "Lautsprecher ok", st.speaker_ok !== false); } if (z.settings.wishes_paused) pille("Wünsche pausiert", false); if (!z.settings.access_enabled) pille("Gastzugang zu", false); } function zeigeWiedergabe(z) { const st = z.player.state || {}; const box = $("jetzt-inhalt"); box.replaceChildren(); if (z.player.online && st.track) { const marke = st.source ? el("span", "marke quelle", QUELLE[st.source] || st.source) : null; box.append(cover(st.track.cover_url, true), textBlock(st.track.title, st.track.artist, marke, st.is_playing ? null : el("span", "m", "pausiert"))); fortschritt = st.track.duration_ms ? { basis: (st.progress_ms || 0) + (st.is_playing ? z.player.age_ms || 0 : 0), dauer: st.track.duration_ms, zeit: performance.now(), spielt: st.is_playing, } : null; } else { box.append(el("span", "hinweis", z.player.online ? "Gerade läuft nichts." : "Der Player meldet sich nicht.")); fortschritt = null; } zeichneFortschritt(); $("naechster").textContent = st.next ? `Als Nächstes: ${st.next.title} – ${st.next.artist}` : ""; $("pi-fehler").hidden = !st.error; $("pi-fehler").textContent = st.error || ""; if (typeof st.volume_percent === "number") { // Nach einer Änderung den eigenen Wert halten, bis der Player ihn zurückmeldet (max. 15 s) const ziel = lautstaerkeZiel; const wartet = ziel && performance.now() < ziel.bis && st.volume_percent !== ziel.wert; if (!wartet) { lautstaerkeZiel = null; setzeWert("lautstaerke", st.volume_percent); if (document.activeElement !== $("lautstaerke")) $("lautstaerke-wert").textContent = st.volume_percent; } } const stopp = z.settings.emergency_stop; $("notaus-hinweis").hidden = !stopp; const symbol = el("span", "symbol", stopp ? "✅" : "⛔"); symbol.setAttribute("aria-hidden", "true"); $("notaus").replaceChildren(symbol, el("span", null, stopp ? "Freigeben" : "Not-Aus")); $("notaus").className = "gross mit-symbol voll " + (stopp ? "akzent" : "gefahr"); const liste = $("befehle"); liste.replaceChildren(); z.commands.forEach((b) => { const li = el("li"); const status = { ok: "ausgeführt", fehler: "fehlgeschlagen", wartet: "wartet …", abgelaufen: "nicht abgeholt" }[b.status]; li.append(el("span", "zeit", uhrzeit(b.created_at)), textBlock(BEFEHL[b.type] || b.type, b.message || ""), el("span", "status " + (b.status === "ok" ? "queued" : b.status === "wartet" ? "pending" : "rejected"), status)); liste.append(li); }); if (!z.commands.length) liste.append(el("li", "hinweis", "Noch keine Befehle.")); } function zeichneFortschritt() { const balken = $("fortschritt-balken"); if (!fortschritt) { balken.style.width = "0"; return; } const pos = fortschritt.basis + (fortschritt.spielt ? performance.now() - fortschritt.zeit : 0); balken.style.width = Math.min(100, (pos / fortschritt.dauer) * 100) + "%"; } function wunschZeile(w, istFreigabe) { const li = el("li"); const extra = []; if (w.priority) extra.push(el("span", "marke besitzer", "Besitzer")); if (w.explicit) extra.push(el("span", "marke e", "E")); const wer = w.priority ? null : el("span", "m", (w.guest_name ? w.guest_name + " · " : "") + uhrzeit(w.created_at)); const text = textBlock(w.title, w.artist, wer); extra.forEach((x) => text.querySelector(".t").append(x)); const knoepfe = el("div", "aktionen"); if (istFreigabe) { knoepfe.append(knopf("✓", "akzent", () => aktion(`/wish/${w.id}/approve`, {}, "Freigegeben"), "Freigeben"), knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/reject`, {}, "Abgelehnt"), "Ablehnen")); } else { knoepfe.append(knopf("↑", "", () => aktion(`/wish/${w.id}/up`, {}), "Nach oben"), knopf("↓", "", () => aktion(`/wish/${w.id}/down`, {}), "Nach unten"), knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/delete`, {}, "Entfernt"), "Löschen")); } if (w.has_guest) { knoepfe.append(knopf("🚫", "gefahr", async () => { if (confirm(`Gast „${w.guest_name || "ohne Namen"}“ sperren und seine offenen Wünsche entfernen?`)) { await aktion(`/wish/${w.id}/block-guest`, {}, "Gast gesperrt"); } }, "Gast sperren")); } li.append(cover(w.cover_url), text, knoepfe); return li; } function zeigeWuensche(z) { setzeSchalter("s-annahme", !z.settings.wishes_paused); setzeSchalter("s-moderation", z.settings.moderation); const fl = $("freigabe"); fl.replaceChildren(...z.pending.map((w) => wunschZeile(w, true))); $("freigabe-leer").hidden = z.pending.length > 0; $("freigabe-karte").hidden = !z.settings.moderation && z.pending.length === 0; const zahl = $("zahl-freigabe"); zahl.hidden = z.pending.length === 0; zahl.textContent = z.pending.length; const ws = $("warteschlange"); ws.replaceChildren(...z.queue.map((w) => wunschZeile(w, false))); $("warteschlange-leer").hidden = z.queue.length > 0; } function zeigeLautsprecher(z) { const bt = z.player.bluetooth || {}; const box = $("bt-aktuell"); box.replaceChildren(); if (bt.current) { const status = el("span", "pille " + (bt.current.connected ? "gut" : "schlecht"), bt.current.connected ? "verbunden" : "getrennt"); box.append(textBlock(bt.current.name || "Unbekannter Lautsprecher", bt.current.mac), status); } else { box.append(el("span", "hinweis", z.player.online ? "Kein Bluetooth-Lautsprecher eingestellt." : "Player meldet sich nicht.")); } const suchen = $("bt-suchen"); suchen.disabled = !!bt.scanning || !z.player.online; suchen.textContent = bt.scanning ? "Läuft …" : "Nach Lautsprechern suchen"; $("bt-status").textContent = bt.scanning ? "Suche bzw. Kopplung läuft – das dauert bis zu 45 Sekunden." : bt.scanned_at ? `Letzte Suche: ${uhrzeit(bt.scanned_at)} – ${(bt.devices || []).length} Audiogerät(e) gefunden.` : ""; const liste = $("bt-geraete"); liste.replaceChildren(...(bt.devices || []).map((g) => { const li = el("li"); const istAktuell = bt.current && bt.current.mac === g.mac; const info = [g.mac, g.paired ? "gekoppelt" : "neu"].join(" · "); const knoepfe = el("div", "aktionen"); if (!istAktuell) { const b = knopf("Verbinden", "akzent", async () => { if (!confirm(`Auf „${g.name || g.mac}“ umstellen? Die Musik setzt dabei kurz aus.`)) return; await aktion("/command", { type: "bt_connect", mac: g.mac }, "Verbinde – bitte warten …"); }); b.disabled = !!bt.scanning; knoepfe.append(b); } else { knoepfe.append(el("span", "pille gut", "aktuell")); } li.append(textBlock(g.name || "Unbekannt", info), knoepfe); return li; })); } function zeigeEinstellungen(z) { const e = z.settings; setzeWert("e-n", e.wish_every_n); setzeWert("e-sperrzeit", e.repeat_block_minutes); setzeWert("e-playlist-prozent", e.playlist_repeat_percent); setzeWert("e-limit-anzahl", e.wish_limit_count); setzeWert("e-limit-minuten", e.wish_limit_minutes); setzeSchalter("s-explizit", e.block_explicit); setzeSchalter("s-zugang", e.access_enabled); const link = $("playlist-link"); link.textContent = e.playlist_id || "keine"; if (e.playlist_id) link.href = "https://open.spotify.com/playlist/" + encodeURIComponent(e.playlist_id); } async function laden() { try { zustand = await api("/state"); } catch (_) { return; } csrf = zustand.csrf; zeigePillen(zustand); zeigeWiedergabe(zustand); zeigeWuensche(zustand); zeigeEinstellungen(zustand); zeigeLautsprecher(zustand); } // --- Gäste: Zugang & Sperrliste ------------------------------------------------------ async function ladeZugang() { const z = await api("/access"); setzeSchalter("s-zugang", z.enabled); $("einladungslink").value = z.link; $("qr").src = "/admin/api/access/qr?t=" + Date.now(); } async function ladeSperrliste() { const daten = await api("/blocklist"); const art = { track: "Titel", artist: "Interpret", guest: "Gast", ip: "Gast (IP)" }; const liste = $("sperrliste"); liste.replaceChildren(...daten.items.map((s) => { const li = el("li"); li.append(textBlock(s.label || "–", art[s.kind] || s.kind), knopf("Entsperren", "", async () => { await api(`/blocklist/${s.id}/delete`, {}); toast("Entsperrt"); ladeSperrliste(); })); return li; })); $("sperrliste-leer").hidden = daten.items.length > 0; } // --- Suche (Als Nächstes / Sperren) --------------------------------------------------- function suchfeld(feldId, listeId, zeile) { let timer = null; let nr = 0; $(feldId).addEventListener("input", (ereignis) => { clearTimeout(timer); const q = ereignis.target.value.trim(); if (q.length < 2) { nr++; $(listeId).replaceChildren(); return; } timer = setTimeout(async () => { const meine = ++nr; try { const daten = await api("/search?q=" + encodeURIComponent(q)); if (meine !== nr) return; $(listeId).replaceChildren(...(daten.results.length ? daten.results.map(zeile) : [el("li", "hinweis", "Nichts gefunden.")])); } catch (_) { /* Meldung kam schon */ } }, 400); }); } function naechstesZeile(t) { const li = el("li"); li.append(cover(t.cover_url), textBlock(t.title, t.artist), knopf("Als Nächstes", "akzent", async () => { await aktion("/play-next", { track_uri: t.track_uri }, `„${t.title}“ kommt als Nächstes`); $("naechstes-suche").value = ""; $("naechstes-treffer").replaceChildren(); })); return li; } function sperrZeile(t) { const li = el("li"); const knoepfe = el("div", "aktionen"); knoepfe.append(knopf("Titel", "gefahr", async () => { await api("/blocklist", { kind: "track", value: t.track_uri, label: `${t.title} – ${t.artist}` }); toast("Titel gesperrt"); ladeSperrliste(); }, "Titel sperren")); (t.artists || []).forEach((a) => knoepfe.append(knopf(a.name, "gefahr", async () => { await api("/blocklist", { kind: "artist", value: a.uri, label: a.name }); toast(`${a.name} gesperrt`); ladeSperrliste(); }, `Interpret ${a.name} sperren`))); li.append(cover(t.cover_url), textBlock(t.title, t.artist), knoepfe); return li; } // --- Verlauf ----------------------------------------------------------------------- async function ladeVerlauf() { const daten = await api("/history?limit=200"); $("verlauf").replaceChildren(...daten.items.map((h) => { const li = el("li"); const quelle = el("span", "marke quelle", QUELLE[h.source] || "?"); const wer = h.guest_name ? el("span", "m", "gewünscht von " + h.guest_name) : null; const text = textBlock(h.title, h.artist, wer); text.querySelector(".t").append(quelle); li.append(el("span", "zeit", uhrzeit(h.started_at)), cover(h.cover_url), text); return li; })); $("verlauf-leer").hidden = daten.items.length > 0; } // --- Start --------------------------------------------------------------------------- function start() { document.querySelectorAll("[data-reiter]").forEach((b) => b.addEventListener("click", () => zeigeReiter(b.dataset.reiter))); document.querySelectorAll("[data-befehl]").forEach((b) => b.addEventListener("click", async () => { b.disabled = true; try { await aktion("/command", { type: b.dataset.befehl }, "Befehl gesendet"); } catch (_) { /* gemeldet */ } b.disabled = false; })); const regler = $("lautstaerke"); regler.addEventListener("input", () => { $("lautstaerke-wert").textContent = regler.value; }); regler.addEventListener("change", () => { lautstaerkeZiel = { wert: Number(regler.value), bis: performance.now() + 15000 }; aktion("/command", { type: "volume", percent: Number(regler.value) }, "Lautstärke gesendet") .catch(() => { lautstaerkeZiel = null; }); }); $("notaus").addEventListener("click", async () => { const stopp = !(zustand && zustand.settings.emergency_stop); await aktion("/emergency", { stop: stopp }, stopp ? "Not-Aus ausgelöst" : "Freigegeben").catch(() => {}); }); const schalter = (id, key, umkehren) => $(id).addEventListener("change", (ereignis) => { const wert = umkehren ? !ereignis.target.checked : ereignis.target.checked; aktion("/settings", { [key]: wert }, "Gespeichert").catch(() => { ereignis.target.checked = !ereignis.target.checked; }); }); schalter("s-annahme", "wishes_paused", true); schalter("s-moderation", "moderation"); schalter("s-explizit", "block_explicit"); document.querySelectorAll("[data-speichern]").forEach((b) => b.addEventListener("click", () => { const werte = {}; b.dataset.speichern.split(",").forEach((paar) => { const [feld, key] = paar.split(":"); werte[key] = Number($(feld).value); }); aktion("/settings", werte, "Gespeichert").catch(() => {}); })); $("s-zugang").addEventListener("change", (ereignis) => { aktion("/access", { enabled: ereignis.target.checked }, ereignis.target.checked ? "Gastzugang offen" : "Gastzugang geschlossen") .catch(() => { ereignis.target.checked = !ereignis.target.checked; }); }); $("link-kopieren").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("einladungslink").value); toast("Link kopiert"); } catch (_) { $("einladungslink").select(); toast("Bitte manuell kopieren", true); } }); $("link-erneuern").addEventListener("click", async () => { if (!confirm("Neuen Link erzeugen? Der alte Link und alle bisherigen Gastzugänge werden ungültig.")) return; await api("/access/renew", {}); toast("Neuer Link erzeugt"); ladeZugang(); }); $("playlist-wechseln").addEventListener("click", async () => { await aktion("/playlist", { link: $("playlist-neu").value }, "Playlist gewechselt").catch(() => {}); $("playlist-neu").value = ""; }); $("bt-suchen").addEventListener("click", () => aktion("/command", { type: "bt_scan" }, "Suche gestartet").catch(() => {})); $("neu-mischen").addEventListener("click", () => aktion("/reshuffle", {}, "Wird neu gemischt").catch(() => {})); $("abmelden").addEventListener("click", async () => { await api("/logout", {}).catch(() => {}); location.href = "/admin/login"; }); suchfeld("naechstes-suche", "naechstes-treffer", naechstesZeile); suchfeld("sperr-suche", "sperr-treffer", sperrZeile); laden(); setInterval(() => { if (!document.hidden) laden(); }, ABFRAGE_MS); setInterval(zeichneFortschritt, 1000); document.addEventListener("visibilitychange", () => { if (!document.hidden) laden(); }); } start();