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
+237
View File
@@ -0,0 +1,237 @@
// Audiofy – Gastseite. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const STATUS_ALLE_MS = 5000;
const SUCHE_VERZOEGERUNG_MS = 400;
const STATUS_TEXT = {
queued: "auf der Liste",
pending: "wartet auf Freigabe",
played: "gespielt",
rejected: "abgelehnt",
deleted: "entfernt",
};
const $ = (id) => document.getElementById(id);
let letzterStatus = null;
let schutz = { token: async () => null, reset() {}, bereit: () => true }; // Turnstile (falls aktiv)
let fortschrittBasis = null; // {progress, dauer, zeit, spielt}
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";
img.decoding = "async";
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));
if (extra) t.append(extra);
return t;
}
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; }, 4000);
}
async function api(pfad, optionen) {
const antwort = await fetch(pfad, Object.assign({ credentials: "same-origin" }, optionen));
let daten = null;
try { daten = await antwort.json(); } catch (_) { /* leer */ }
if (antwort.status === 401) { // Zugang abgelaufen/deaktiviert → Hinweisseite zeigen
location.reload();
throw new Error("Kein Zugang.");
}
if (!antwort.ok) {
const meldung = daten && typeof daten.detail === "string" ? daten.detail : "Da ist etwas schiefgelaufen.";
throw new Error(meldung);
}
return daten;
}
// --- Läuft gerade ---------------------------------------------------------
function zeigeJetzt(jetzt) {
const box = $("jetzt-inhalt");
box.replaceChildren();
if (!jetzt) {
box.className = "titel-zeile leer";
box.append(el("span", "hinweis", "Gerade läuft nichts."));
$("fortschritt").hidden = true;
fortschrittBasis = null;
return;
}
box.className = "titel-zeile";
const extra = jetzt.is_wish ? el("span", "marke", "Wunsch") : null;
const text = textBlock(jetzt.title, jetzt.artist);
if (extra) text.querySelector(".t").append(extra);
if (!jetzt.is_playing) text.append(el("span", "m", "pausiert"));
box.append(cover(jetzt.cover_url, true), text);
if (jetzt.duration_ms) {
fortschrittBasis = {
progress: jetzt.progress_ms + (jetzt.is_playing ? jetzt.age_ms : 0),
dauer: jetzt.duration_ms,
zeit: performance.now(),
spielt: jetzt.is_playing,
};
$("fortschritt").hidden = false;
zeichneFortschritt();
}
}
function zeichneFortschritt() {
if (!fortschrittBasis) return;
const b = fortschrittBasis;
const pos = b.progress + (b.spielt ? performance.now() - b.zeit : 0);
$("fortschritt-balken").style.width = Math.min(100, (pos / b.dauer) * 100) + "%";
}
// --- Status (Warteschlange, eigene Wünsche, Hinweise) -----------------------
function zeigeStatus(s) {
letzterStatus = s;
zeigeJetzt(s.now_playing);
$("hinweis-pausiert").hidden = !s.wishes_paused;
$("hinweis-moderation").hidden = !s.moderation || s.wishes_paused;
const l = s.limit;
$("limit-info").textContent = s.wishes_paused ? "" :
l.remaining > 0
? `Du kannst noch ${l.remaining} von ${l.count} Wünschen in ${l.minutes} Minuten abgeben.`
: `Limit erreicht – nächster Wunsch in ca. ${l.next_in_minutes} Min.`;
const ws = $("warteschlange");
ws.replaceChildren();
s.queue.forEach((w, i) => {
const li = el("li");
const wer = w.mine ? "dein Wunsch" : (w.name ? `gewünscht von ${w.name}` : null);
li.append(el("span", "status", String(i + 1) + "."), cover(w.cover_url),
textBlock(w.title, w.artist, wer ? el("span", "m", wer) : null));
ws.append(li);
});
$("warteschlange-leer").hidden = s.queue.length > 0;
const fi = $("freigabe-info");
fi.hidden = !s.pending_count;
fi.textContent = s.pending_count === 1 ? "1 Wunsch wartet auf Freigabe." : `${s.pending_count} Wünsche warten auf Freigabe.`;
const meine = $("meine");
meine.replaceChildren();
s.mine.forEach((w) => {
const li = el("li");
li.append(textBlock(w.title, w.artist), el("span", "status " + w.status, STATUS_TEXT[w.status] || w.status));
meine.append(li);
});
$("meine-karte").hidden = s.mine.length === 0;
aktualisiereKnoepfe();
}
async function ladeStatus() {
try {
zeigeStatus(await api("/api/status"));
} catch (_) { /* nächster Versuch beim nächsten Intervall */ }
}
// --- Suche & Wünschen --------------------------------------------------------
let sucheTimer = null;
let sucheNr = 0;
function aktualisiereKnoepfe() {
const aus = letzterStatus && (letzterStatus.wishes_paused || letzterStatus.limit.remaining === 0);
document.querySelectorAll("button.wuenschen").forEach((b) => {
if (!b.dataset.gesperrt && !b.dataset.erledigt) b.disabled = !!aus;
});
}
function zeigeTreffer(treffer) {
const liste = $("treffer");
liste.replaceChildren();
if (!treffer.length) {
liste.append(el("li", "hinweis", "Nichts gefunden."));
return;
}
treffer.forEach((t) => {
const li = el("li");
const text = textBlock(t.title, t.artist, t.blocked ? el("span", "gesperrt", t.blocked) : null);
if (t.explicit) text.querySelector(".t").append(el("span", "marke e", "E"));
const knopf = el("button", "wuenschen", "Wünschen");
knopf.type = "button";
if (t.blocked) { knopf.disabled = true; knopf.dataset.gesperrt = "1"; }
knopf.addEventListener("click", () => wuenschen(t, knopf));
li.append(cover(t.cover_url), text, knopf);
liste.append(li);
});
aktualisiereKnoepfe();
}
async function suchen(q) {
const nr = ++sucheNr;
try {
const daten = await api("/api/search?q=" + encodeURIComponent(q));
if (nr === sucheNr) zeigeTreffer(daten.results);
} catch (fehler) {
if (nr === sucheNr) toast(fehler.message, true);
}
}
async function wuenschen(titel, knopf) {
knopf.disabled = true;
const name = $("name").value.trim();
try { localStorage.setItem("audiofy-name", name); } catch (_) { /* privat */ }
try {
if (!schutz.bereit()) knopf.textContent = "Prüfung …";
const turnstile = await schutz.token();
const daten = await api("/api/wish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ track_uri: titel.track_uri, name: name || null, turnstile }),
}).finally(() => schutz.reset());
knopf.textContent = "✓ Gewünscht";
knopf.dataset.erledigt = "1";
toast(daten.message);
ladeStatus();
} catch (fehler) {
toast(fehler.message, true);
knopf.textContent = "Wünschen";
knopf.disabled = false;
}
}
function start() {
try { $("name").value = localStorage.getItem("audiofy-name") || ""; } catch (_) { /* privat */ }
$("suchfeld").addEventListener("input", (ereignis) => {
clearTimeout(sucheTimer);
const q = ereignis.target.value.trim();
if (q.length < 2) { sucheNr++; $("treffer").replaceChildren(); return; }
sucheTimer = setTimeout(() => suchen(q), SUCHE_VERZOEGERUNG_MS);
});
$("suchfeld").addEventListener("keydown", (ereignis) => {
if (ereignis.key === "Enter") { ereignis.target.blur(); } // Tastatur auf dem Handy schließen
});
window.AudiofyTurnstile.einrichten($("turnstile"), "wish").then((s) => { schutz = s; });
ladeStatus();
setInterval(() => { if (!document.hidden) ladeStatus(); }, STATUS_ALLE_MS);
setInterval(zeichneFortschritt, 1000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) ladeStatus(); });
}
start();