Audiofy – Musikwünsche für Partys

Server (Website, Admin-Bereich, Player-API) und Player (raspotify +
Controller) mit Install-Skripten für Debian-Systeme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:16:39 +01:00
co-authored by Claude Opus 5.5
commit 3610de7bae
45 changed files with 5666 additions and 0 deletions
+450
View File
@@ -0,0 +1,450 @@
// Audiofy – 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-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();