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,127 @@
|
||||
/* Audiofy – Admin-Bereich (ergänzt app.css) */
|
||||
|
||||
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 4px 0 12px; }
|
||||
.kopf h1 { font-size: 1.4rem; }
|
||||
.pillen { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pille { font-size: .78rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: #333; color: var(--leise); }
|
||||
.pille.gut { background: #12391f; color: var(--akzent); }
|
||||
.pille.schlecht { background: #3d1717; color: var(--fehler); }
|
||||
|
||||
.reiter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
background: var(--bg);
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.reiter button {
|
||||
flex: 1 0 auto;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: .95rem;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--karte);
|
||||
color: var(--leise);
|
||||
cursor: pointer;
|
||||
}
|
||||
.reiter button[aria-selected="true"] { background: var(--text); color: #111; }
|
||||
.zahl { display: inline-block; min-width: 20px; margin-left: 4px; padding: 0 6px; border-radius: 999px; background: var(--warnung); color: #111; font-size: .78rem; }
|
||||
|
||||
button.gross, a.gross {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
min-height: 52px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: #2f2f2f;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
button.akzent { background: var(--akzent); color: var(--akzent-text); }
|
||||
/* Symbol über dem Text */
|
||||
.gross.mit-symbol { flex-direction: column; gap: 4px; min-height: 72px; padding: 8px 12px; }
|
||||
.gross.mit-symbol .symbol { font-size: 1.6rem; line-height: 1; }
|
||||
button.gefahr { background: #b3261e; color: #fff; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
form#login button { width: 100%; margin-top: 14px; }
|
||||
|
||||
.knopfreihe { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
|
||||
.knopfreihe.zwei { grid-template-columns: repeat(2, 1fr); }
|
||||
.voll { width: 100%; margin-top: 12px; }
|
||||
|
||||
button.klein {
|
||||
font: inherit;
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #2f2f2f;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
button.klein.akzent { background: var(--akzent); color: var(--akzent-text); }
|
||||
button.klein.gefahr { background: #4a1c1c; color: var(--fehler); }
|
||||
.aktionen { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
|
||||
.schalter-zeile { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-top: 1px solid var(--linie); }
|
||||
.schalter-zeile:first-of-type { border-top: 0; }
|
||||
.schalter-zeile .text .m { white-space: normal; }
|
||||
.schalter { position: relative; width: 58px; height: 34px; flex: 0 0 auto; }
|
||||
.schalter input { opacity: 0; width: 100%; height: 100%; margin: 0; position: absolute; cursor: pointer; z-index: 1; }
|
||||
.schalter span { position: absolute; inset: 0; border-radius: 999px; background: #444; transition: background .15s; }
|
||||
.schalter span::after { content: ""; position: absolute; top: 4px; left: 4px; width: 26px; height: 26px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
||||
.schalter input:checked + span { background: var(--akzent); }
|
||||
.schalter input:checked + span::after { transform: translateX(24px); }
|
||||
.schalter input:focus-visible + span { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
|
||||
.feld-zeile { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
||||
.feld-zeile input[type="number"] {
|
||||
width: 96px;
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
background: #2a2a2a;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
input[type="range"] { width: 100%; height: 36px; accent-color: var(--akzent); }
|
||||
.lautstaerke { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||||
.lautstaerke output { min-width: 3ch; text-align: right; font-weight: 700; }
|
||||
|
||||
.notaus-aktiv { background: #3d1717; color: var(--fehler); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 16px; font-weight: 700; }
|
||||
|
||||
.qr { display: block; width: 100%; max-width: 280px; margin: 12px auto; background: #fff; border-radius: 12px; padding: 8px; }
|
||||
.link-feld { width: 100%; font: inherit; font-size: .9rem; color: var(--text); background: #2a2a2a; border: 0; border-radius: 10px; padding: 10px 12px; }
|
||||
|
||||
.fehlertext { color: var(--fehler); }
|
||||
|
||||
/* Legende zu den Symbol-Knöpfen – nur auf Touch-Geräten (dort gibt es keine Hover-Beschriftung) */
|
||||
.legende { display: none; flex-wrap: wrap; justify-content: center; text-align: center; gap: 4px 14px; margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--linie); color: var(--leise); font-size: .82rem; }
|
||||
@media (hover: none) { .legende { display: flex; } }
|
||||
.zeit { color: var(--leise); font-size: .8rem; white-space: nowrap; }
|
||||
.marke.besitzer { background: #6a4cff; color: #fff; }
|
||||
.marke.quelle { background: #333; color: var(--leise); }
|
||||
|
||||
.abstand { margin-top: 18px; }
|
||||
@@ -0,0 +1,176 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Audiofy – Admin</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<script src="/static/admin.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="kopf">
|
||||
<h1>Audiofy Admin</h1>
|
||||
<button id="abmelden" class="klein" type="button">Abmelden</button>
|
||||
</div>
|
||||
<div class="pillen" id="pillen"></div>
|
||||
|
||||
<nav class="reiter" role="tablist">
|
||||
<button type="button" role="tab" data-reiter="wiedergabe" aria-selected="true">Wiedergabe</button>
|
||||
<button type="button" role="tab" data-reiter="wuensche">Wünsche<span id="zahl-freigabe" class="zahl" hidden></span></button>
|
||||
<button type="button" role="tab" data-reiter="gaeste">Gäste</button>
|
||||
<button type="button" role="tab" data-reiter="einstellungen">Einstellungen</button>
|
||||
<button type="button" role="tab" data-reiter="verlauf">Verlauf</button>
|
||||
</nav>
|
||||
|
||||
<div id="notaus-hinweis" class="notaus-aktiv" hidden>Not-Aus aktiv – der Controller ist angehalten.</div>
|
||||
|
||||
<!-- ===== Wiedergabe ===== -->
|
||||
<section data-seite="wiedergabe">
|
||||
<div class="karte">
|
||||
<h2>Läuft gerade</h2>
|
||||
<div id="jetzt-inhalt" class="titel-zeile"></div>
|
||||
<div class="fortschritt"><div id="fortschritt-balken"></div></div>
|
||||
<p id="naechster" class="hinweis klein"></p>
|
||||
<p id="pi-fehler" class="hinweis klein fehlertext" hidden></p>
|
||||
<div class="knopfreihe">
|
||||
<button class="gross mit-symbol" type="button" data-befehl="play"><span class="symbol" aria-hidden="true">▶</span><span>Play</span></button>
|
||||
<button class="gross mit-symbol" type="button" data-befehl="pause"><span class="symbol" aria-hidden="true">⏸</span><span>Pause</span></button>
|
||||
<button class="gross mit-symbol" type="button" data-befehl="skip"><span class="symbol" aria-hidden="true">⏭</span><span>Weiter</span></button>
|
||||
</div>
|
||||
<div class="lautstaerke">
|
||||
<span aria-hidden="true">🔊</span>
|
||||
<input id="lautstaerke" type="range" min="0" max="100" step="5" aria-label="Lautstärke">
|
||||
<output id="lautstaerke-wert">–</output>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="karte">
|
||||
<h2>Als Nächstes spielen</h2>
|
||||
<input id="naechstes-suche" type="search" placeholder="Titel suchen …" autocomplete="off" maxlength="100">
|
||||
<ul id="naechstes-treffer" class="liste"></ul>
|
||||
</div>
|
||||
|
||||
<div class="karte">
|
||||
<h2>Not-Aus</h2>
|
||||
<p class="hinweis klein">Stoppt die Wiedergabe und hält den Controller an, bis du wieder freigibst.</p>
|
||||
<button id="notaus" class="gross mit-symbol gefahr voll" type="button"><span class="symbol" aria-hidden="true">⛔</span><span>Not-Aus</span></button>
|
||||
</div>
|
||||
|
||||
<div class="karte">
|
||||
<h2>Letzte Befehle</h2>
|
||||
<ul id="befehle" class="liste kompakt"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Wünsche ===== -->
|
||||
<section data-seite="wuensche" hidden>
|
||||
<div class="karte">
|
||||
<div class="schalter-zeile">
|
||||
<div class="text"><span class="t">Wünsche annehmen</span><span class="m">Aus = Gäste sehen „pausiert“</span></div>
|
||||
<label class="schalter"><input type="checkbox" id="s-annahme"><span></span></label>
|
||||
</div>
|
||||
<div class="schalter-zeile">
|
||||
<div class="text"><span class="t">Moderation</span><span class="m">Wünsche erst nach deiner Freigabe spielen</span></div>
|
||||
<label class="schalter"><input type="checkbox" id="s-moderation"><span></span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="karte" id="freigabe-karte">
|
||||
<h2>Zur Freigabe</h2>
|
||||
<ul id="freigabe" class="liste"></ul>
|
||||
<p id="freigabe-leer" class="hinweis">Nichts zu prüfen.</p>
|
||||
<p class="legende"><span>✓ freigeben</span><span>✕ ablehnen</span><span>🚫 Gast sperren</span></p>
|
||||
</div>
|
||||
|
||||
<div class="karte">
|
||||
<h2>Warteschlange</h2>
|
||||
<ul id="warteschlange" class="liste"></ul>
|
||||
<p id="warteschlange-leer" class="hinweis">Keine Wünsche in der Warteschlange.</p>
|
||||
<p class="legende"><span>↑ früher</span><span>↓ später</span><span>✕ löschen</span><span>🚫 Gast sperren</span></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Gäste ===== -->
|
||||
<section data-seite="gaeste" hidden>
|
||||
<div class="karte">
|
||||
<h2>Gastzugang</h2>
|
||||
<div class="schalter-zeile">
|
||||
<div class="text"><span class="t">Zugang offen</span><span class="m">Aus = niemand kommt auf die Wunschseite</span></div>
|
||||
<label class="schalter"><input type="checkbox" id="s-zugang"><span></span></label>
|
||||
</div>
|
||||
<img id="qr" class="qr" alt="QR-Code zum Einladungslink">
|
||||
<input id="einladungslink" class="link-feld" type="text" readonly aria-label="Einladungslink">
|
||||
<div class="knopfreihe zwei">
|
||||
<button id="link-kopieren" class="gross" type="button">Link kopieren</button>
|
||||
<a id="qr-png" class="gross" href="/admin/api/access/qr?format=png" download>QR als Bild</a>
|
||||
</div>
|
||||
<button id="link-erneuern" class="gross voll" type="button">Neuen Link erzeugen</button>
|
||||
<p class="hinweis klein">Ein neuer Link macht den alten und alle damit erteilten Zugänge ungültig.</p>
|
||||
</div>
|
||||
|
||||
<div class="karte">
|
||||
<h2>Limit pro Gast</h2>
|
||||
<div class="feld-zeile"><span>Wünsche</span><input id="e-limit-anzahl" type="number" min="1" max="50"></div>
|
||||
<div class="feld-zeile"><span>pro Minuten</span><input id="e-limit-minuten" type="number" min="1" max="1440"></div>
|
||||
<button class="gross akzent voll" type="button" data-speichern="e-limit-anzahl:wish_limit_count,e-limit-minuten:wish_limit_minutes">Speichern</button>
|
||||
</div>
|
||||
|
||||
<div class="karte">
|
||||
<h2>Sperrliste</h2>
|
||||
<div class="schalter-zeile">
|
||||
<div class="text"><span class="t">Explizite Titel sperren</span></div>
|
||||
<label class="schalter"><input type="checkbox" id="s-explizit"><span></span></label>
|
||||
</div>
|
||||
<input id="sperr-suche" type="search" placeholder="Titel oder Interpret zum Sperren suchen …" autocomplete="off" maxlength="100">
|
||||
<ul id="sperr-treffer" class="liste"></ul>
|
||||
<h2 class="abstand">Gesperrt</h2>
|
||||
<ul id="sperrliste" class="liste"></ul>
|
||||
<p id="sperrliste-leer" class="hinweis">Nichts gesperrt.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Einstellungen ===== -->
|
||||
<section data-seite="einstellungen" hidden>
|
||||
<div class="karte">
|
||||
<h2>Ablauf</h2>
|
||||
<div class="feld-zeile"><span>Jeder wievielte Titel ist ein Wunsch (N)</span><input id="e-n" type="number" min="1" max="20"></div>
|
||||
<div class="feld-zeile"><span>Sperrzeit für Wiederholungen (Min.)</span><input id="e-sperrzeit" type="number" min="0" max="1440"></div>
|
||||
<button class="gross akzent voll" type="button" data-speichern="e-n:wish_every_n,e-sperrzeit:repeat_block_minutes">Speichern</button>
|
||||
</div>
|
||||
<div class="karte">
|
||||
<h2>Lautsprecher</h2>
|
||||
<div id="bt-aktuell" class="titel-zeile"></div>
|
||||
<p class="hinweis klein">Neuen Bluetooth-Lautsprecher vorher in den Kopplungsmodus versetzen (meist Bluetooth-Taste gedrückt halten, bis die LED schnell blinkt). Während der Suche kann die Musik kurz stocken – WLAN und Bluetooth teilen sich oft einen Funkchip.</p>
|
||||
<button id="bt-suchen" class="gross voll" type="button">Nach Lautsprechern suchen</button>
|
||||
<p id="bt-status" class="hinweis klein"></p>
|
||||
<ul id="bt-geraete" class="liste"></ul>
|
||||
</div>
|
||||
|
||||
<div class="karte">
|
||||
<h2>Grund-Playlist</h2>
|
||||
<p class="hinweis klein">Aktuell: <a id="playlist-link" target="_blank" rel="noopener noreferrer"></a></p>
|
||||
<input id="playlist-neu" type="text" placeholder="Spotify-Playlist-Link einfügen" autocomplete="off" maxlength="300">
|
||||
<div class="knopfreihe zwei">
|
||||
<button id="playlist-wechseln" class="gross akzent" type="button">Wechseln</button>
|
||||
<button id="neu-mischen" class="gross" type="button">Neu mischen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Verlauf ===== -->
|
||||
<section data-seite="verlauf" hidden>
|
||||
<div class="karte">
|
||||
<h2>Verlauf</h2>
|
||||
<ul id="verlauf" class="liste"></ul>
|
||||
<p id="verlauf-leer" class="hinweis">Noch nichts gespielt.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="assertive" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -0,0 +1,166 @@
|
||||
/* Audiofy – Gastseite (mobil zuerst) */
|
||||
:root {
|
||||
--bg: #121212;
|
||||
--karte: #1e1e1e;
|
||||
--linie: #2c2c2c;
|
||||
--text: #f2f2f2;
|
||||
--leise: #a7a7a7;
|
||||
--akzent: #1ed760;
|
||||
--akzent-text: #0b0b0b;
|
||||
--warnung: #f5a524;
|
||||
--fehler: #ff6b6b;
|
||||
--radius: 14px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
header { text-align: center; margin: 8px 0 16px; }
|
||||
h1 { margin: 0; font-size: 1.9rem; letter-spacing: .02em; }
|
||||
.untertitel { margin: 0; color: var(--leise); }
|
||||
h2 { margin: 0 0 12px; font-size: 1.05rem; color: var(--leise); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
|
||||
|
||||
.karte {
|
||||
background: var(--karte);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: #3a2d10;
|
||||
color: var(--warnung);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.banner.leise { background: var(--karte); color: var(--leise); font-weight: 400; }
|
||||
|
||||
.hinweis { color: var(--leise); margin: 8px 0 0; }
|
||||
.klein { font-size: .875rem; }
|
||||
|
||||
input[type="search"], input[type="text"] {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1.05rem; /* ≥ 16px verhindert Zoom auf iOS */
|
||||
color: var(--text);
|
||||
background: #2a2a2a;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus { border-color: var(--akzent); }
|
||||
|
||||
.name-zeile { display: block; margin-top: 10px; }
|
||||
.turnstile { display: flex; justify-content: center; }
|
||||
.turnstile:not(:empty) { margin-top: 10px; }
|
||||
.name-zeile span { display: block; font-size: .875rem; color: var(--leise); margin: 0 0 4px 6px; }
|
||||
.name-zeile input { font-size: 1rem; padding: 10px 16px; }
|
||||
|
||||
.liste { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.liste li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--linie);
|
||||
}
|
||||
.liste li:first-child { border-top: 0; }
|
||||
.liste.kompakt li { padding: 6px 0; }
|
||||
|
||||
.cover {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 6px;
|
||||
background: #333;
|
||||
flex: 0 0 auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
.titel-zeile { display: flex; align-items: center; gap: 14px; }
|
||||
.titel-zeile .cover { width: 72px; height: 72px; }
|
||||
|
||||
.text { min-width: 0; flex: 1; }
|
||||
.text .t, .text .a {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.text .t { font-weight: 600; }
|
||||
.text .a, .text .m { color: var(--leise); font-size: .9rem; }
|
||||
.text .m { display: block; }
|
||||
.marke {
|
||||
display: inline-block;
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
vertical-align: 2px;
|
||||
background: var(--akzent);
|
||||
color: var(--akzent-text);
|
||||
}
|
||||
.marke.e { background: #555; color: var(--text); }
|
||||
|
||||
button.wuenschen {
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
min-height: 44px;
|
||||
min-width: 96px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--akzent);
|
||||
color: var(--akzent-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.wuenschen:disabled { background: #333; color: var(--leise); cursor: default; }
|
||||
.gesperrt { color: var(--leise); font-size: .8rem; }
|
||||
|
||||
.fortschritt { height: 4px; background: #333; border-radius: 2px; margin-top: 12px; overflow: hidden; }
|
||||
#fortschritt-balken { height: 100%; width: 0; background: var(--akzent); transition: width 1s linear; }
|
||||
|
||||
.status { font-size: .8rem; font-weight: 700; white-space: nowrap; }
|
||||
.status.queued { color: var(--akzent); }
|
||||
.status.pending { color: var(--warnung); }
|
||||
.status.played { color: var(--leise); }
|
||||
.status.rejected, .status.deleted { color: var(--fehler); }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100% - 32px);
|
||||
background: var(--text);
|
||||
color: #111;
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.toast.fehler { background: var(--fehler); color: #111; }
|
||||
|
||||
footer { text-align: center; color: #666; font-size: .8rem; margin-top: 24px; }
|
||||
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0, 0, 0, 0); border: 0;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<title>Audiofy – Musikwünsche</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="preconnect" href="https://challenges.cloudflare.com">
|
||||
<script src="/static/turnstile.js" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Audiofy</h1>
|
||||
<p class="untertitel">Musikwünsche</p>
|
||||
</header>
|
||||
|
||||
<section id="jetzt" class="karte" aria-live="polite">
|
||||
<h2>Läuft gerade</h2>
|
||||
<div id="jetzt-inhalt" class="titel-zeile leer">
|
||||
<span class="hinweis">Gerade läuft nichts.</span>
|
||||
</div>
|
||||
<div id="fortschritt" class="fortschritt" hidden><div id="fortschritt-balken"></div></div>
|
||||
</section>
|
||||
|
||||
<div id="hinweis-pausiert" class="banner" hidden>Die Wunschannahme ist gerade pausiert.</div>
|
||||
<div id="hinweis-moderation" class="banner leise" hidden>Wünsche werden vor dem Abspielen kurz geprüft.</div>
|
||||
|
||||
<section id="suche" class="karte">
|
||||
<h2>Song wünschen</h2>
|
||||
<label class="sr-only" for="suchfeld">Titel oder Interpret</label>
|
||||
<input id="suchfeld" type="search" placeholder="Titel oder Interpret suchen …"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="100" enterkeyhint="search">
|
||||
<label class="name-zeile">
|
||||
<span>Dein Name (optional)</span>
|
||||
<input id="name" type="text" maxlength="30" autocomplete="nickname" placeholder="z. B. Tante Erna">
|
||||
</label>
|
||||
<p id="limit-info" class="hinweis klein"></p>
|
||||
<div id="turnstile" class="turnstile"></div>
|
||||
<ul id="treffer" class="liste"></ul>
|
||||
</section>
|
||||
|
||||
<section id="meine-karte" class="karte" hidden>
|
||||
<h2>Deine Wünsche</h2>
|
||||
<ul id="meine" class="liste kompakt"></ul>
|
||||
</section>
|
||||
|
||||
<section class="karte">
|
||||
<h2>Als Nächstes gewünscht</h2>
|
||||
<p id="freigabe-info" class="hinweis klein" hidden></p>
|
||||
<ul id="warteschlange" class="liste"></ul>
|
||||
<p id="warteschlange-leer" class="hinweis">Noch keine Wünsche – sei die oder der Erste!</p>
|
||||
</section>
|
||||
|
||||
<footer>Suche und Titelinfos: Spotify</footer>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="assertive" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Audiofy – Admin</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="preconnect" href="https://challenges.cloudflare.com">
|
||||
<script src="/static/turnstile.js" defer></script>
|
||||
<script src="/static/login.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Audiofy</h1>
|
||||
<p class="untertitel">Admin-Bereich</p>
|
||||
</header>
|
||||
<form id="login" class="karte">
|
||||
<h2>Anmelden</h2>
|
||||
<label class="name-zeile">
|
||||
<span>Passwort</span>
|
||||
<input id="passwort" type="password" autocomplete="current-password" required maxlength="200">
|
||||
</label>
|
||||
<div id="turnstile" class="turnstile"></div>
|
||||
<button class="gross akzent" type="submit">Anmelden</button>
|
||||
<p id="fehler" class="hinweis fehlertext" role="alert"></p>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Audiofy – Admin-Login
|
||||
"use strict";
|
||||
|
||||
let schutz = { token: async () => null, reset() {}, bereit: () => true }; // Turnstile (falls aktiv)
|
||||
window.AudiofyTurnstile.einrichten(document.getElementById("turnstile"), "login").then((s) => { schutz = s; });
|
||||
|
||||
document.getElementById("login").addEventListener("submit", async (ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
const fehler = document.getElementById("fehler");
|
||||
const knopf = ereignis.target.querySelector("button");
|
||||
fehler.textContent = "";
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
if (!schutz.bereit()) knopf.textContent = "Sicherheitsprüfung …";
|
||||
const turnstile = await schutz.token();
|
||||
knopf.textContent = "Anmelden …";
|
||||
const antwort = await fetch("/admin/login", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: document.getElementById("passwort").value, turnstile }),
|
||||
}).finally(() => schutz.reset());
|
||||
if (antwort.ok) {
|
||||
location.href = "/admin";
|
||||
return;
|
||||
}
|
||||
let meldung = "Anmeldung fehlgeschlagen.";
|
||||
try { meldung = (await antwort.json()).detail || meldung; } catch (_) { /* leer */ }
|
||||
fehler.textContent = meldung;
|
||||
} catch (_) {
|
||||
fehler.textContent = "Keine Verbindung zum Server.";
|
||||
}
|
||||
knopf.textContent = "Anmelden";
|
||||
knopf.disabled = false;
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Audiofy – Cloudflare Turnstile (Bot-Schutz). Lädt das Widget nur, wenn der Server einen Site-Key liefert.
|
||||
"use strict";
|
||||
|
||||
window.AudiofyTurnstile = (() => {
|
||||
let ladeVersprechen = null;
|
||||
|
||||
function skriptLaden() {
|
||||
if (!ladeVersprechen) {
|
||||
ladeVersprechen = new Promise((fertig, fehler) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
s.async = true;
|
||||
s.onload = () => fertig(window.turnstile);
|
||||
s.onerror = () => fehler(new Error("Turnstile konnte nicht geladen werden"));
|
||||
document.head.append(s);
|
||||
});
|
||||
}
|
||||
return ladeVersprechen;
|
||||
}
|
||||
|
||||
const LEER = { token: async () => null, reset() {}, bereit: () => true };
|
||||
|
||||
// Liefert {token(): Promise<string|null>, reset(), bereit()} – ohne Site-Key ein "leerer" Schutz.
|
||||
async function einrichten(container, aktion) {
|
||||
// Site-Key und Cloudflare-Skript gleichzeitig holen (spart einen Umlauf)
|
||||
const skript = skriptLaden().catch(() => null);
|
||||
let siteKey = null;
|
||||
try {
|
||||
siteKey = (await (await fetch("/api/turnstile", { credentials: "same-origin" })).json()).site_key;
|
||||
} catch (_) { /* ohne Site-Key weiter */ }
|
||||
if (!siteKey) return LEER;
|
||||
|
||||
let aktuell = null;
|
||||
let warten = [];
|
||||
const melden = (wert) => { aktuell = wert; warten.forEach((f) => f(wert)); warten = []; };
|
||||
let ts = null;
|
||||
let id = null;
|
||||
try {
|
||||
ts = await skript;
|
||||
if (!ts) return LEER; // Skript nicht ladbar → Server entscheidet
|
||||
id = ts.render(container, {
|
||||
sitekey: siteKey,
|
||||
action: aktion,
|
||||
appearance: "interaction-only", // nur sichtbar, wenn Cloudflare eine Bestätigung braucht
|
||||
language: "de",
|
||||
callback: (t) => melden(t),
|
||||
"expired-callback": () => { aktuell = null; },
|
||||
"error-callback": () => { melden(null); },
|
||||
});
|
||||
} catch (_) {
|
||||
return LEER; // Server entscheidet dann
|
||||
}
|
||||
|
||||
return {
|
||||
bereit: () => !!aktuell,
|
||||
// wartet bis zu 15 s auf ein Token
|
||||
token() {
|
||||
if (aktuell) return Promise.resolve(aktuell);
|
||||
return new Promise((fertig) => {
|
||||
warten.push(fertig);
|
||||
setTimeout(() => fertig(null), 15000);
|
||||
});
|
||||
},
|
||||
// jedes Token gilt nur einmal → nach jeder Anfrage ein neues holen
|
||||
reset() {
|
||||
aktuell = null;
|
||||
try { ts.reset(id); } catch (_) { /* egal */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { einrichten };
|
||||
})();
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Audiofy – Musikwünsche</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Audiofy</h1>
|
||||
<p class="untertitel">Musikwünsche</p>
|
||||
</header>
|
||||
<section class="karte">
|
||||
<h2>Nur für Gäste</h2>
|
||||
<p>Musikwünsche sind nur mit Einladung möglich.</p>
|
||||
<p class="hinweis">Bitte scanne den QR-Code vor Ort oder öffne den Einladungslink, den du bekommen hast.
|
||||
Falls das nicht klappt, ist die Wunschannahme vielleicht gerade geschlossen.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user