Name, Seitentitel, Dienste (playwish-web, playwish-controller), Dienst-Benutzer (playwish), Pfade (/opt/playwish, /usr/local/src/playwish), Vorlagen und Repository-Adresse. „Audiofy“ war bereits von mehreren Produkten belegt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
452 lines
19 KiB
JavaScript
452 lines
19 KiB
JavaScript
// 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();
|