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:
@@ -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();
|
||||
Reference in New Issue
Block a user