diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index fefbf26..ca9fd88 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -95,6 +95,21 @@ const WUNSCH_STATUS = [ // Alles mit Editor, Vorschau oder Liste bleibt einspaltig. const SENDER_LEER = { id: null, name: '', url: '', emoji: '', sort: 0 }; +/** + * 3:07 statt 187. Ohne bekannte Dauer steht „live" da und nicht „0:00" — + * ein Livestream hat keine Laenge, und eine Null waere eine Behauptung. + */ +function dauerText(sekunden) { + if (sekunden === null || sekunden === undefined) return 'live'; + const t = Math.max(0, Math.round(sekunden)); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const r = t % 60; + return h > 0 + ? `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}` + : `${m}:${String(r).padStart(2, '0')}`; +} + const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']); // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner) @@ -432,6 +447,14 @@ export default function Settings({ me }) { const [senderDraft, setSenderDraft] = useState({ id: null, name: '', url: '', emoji: '', sort: 0 }); // Ergebnis der Sender-Probe: { erreichbar, sender, titel } oder 'laeuft' const [senderProbe, setSenderProbe] = useState(null); + // Fernbedienung: der Live-Stand vom Bot (null = noch nichts geholt) + const [radio, setRadio] = useState(null); + const [radioKanal, setRadioKanal] = useState(''); + const [radioEingabe, setRadioEingabe] = useState(''); + const [radioTreffer, setRadioTreffer] = useState(null); // Auswahl nach einer Suche + const [radioLaedt, setRadioLaedt] = useState(false); + const [radioFehler, setRadioFehler] = useState(''); + const [ytCookies, setYtCookies] = useState(''); const [owner, setOwner] = useState(null); const [auditAlle, setAuditAlle] = useState(false); // Module (An/Aus für alle Funktionen) @@ -472,6 +495,23 @@ export default function Settings({ me }) { apiGet('/api/radio').then((d) => setSender(d.sender ?? [])).catch(() => {}); }, []); + // Der Live-Stand der Fernbedienung. + // + // Nur solange der Community-Reiter offen ist: sonst liefe im Hintergrund + // alle fuenf Sekunden eine Anfrage gegen eine Ansicht, die niemand sieht. + // Fuenf Sekunden, weil der Fortschrittsbalken sonst ruckelt und ein Titel + // selten kuerzer als zwei Minuten ist. + useEffect(() => { + if (tab !== 'community') return undefined; + let lebt = true; + const holen = () => apiGet('/api/radio/status') + .then((d) => { if (lebt) setRadio(d); }) + .catch(() => {}); + holen(); + const timer = setInterval(holen, 5000); + return () => { lebt = false; clearInterval(timer); }; + }, [tab]); + // Portal-Dienste (Kacheln auf der Startseite) const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 }; const [services, setServices] = useState([]); @@ -2151,6 +2191,323 @@ export default function Settings({ me }) { ohne Haken antwortet /radio nur mit einem Hinweis +
+ Der Bot steht in mehreren Servern und es ist keiner eingestellt — + unter System die Server-ID eintragen, sonst weiss + das Panel nicht, welchen es steuern soll. +
+ )} + + {radio && !radio.mehrdeutig && ( +🔴 Livestream
+ )} + {radio.laeuft.tonLaeuft === false && ( ++ ⚠️ Es kommt kein Ton an. + {radio.werkzeuge.ffmpeg === false + && ' Auf dem Server fehlt ffmpeg.'} +
+ )} + > + ) : ( ++ Nichts läuft gerade. + {radio.stand.n > 0 + && ` ${radio.stand.n} Titel warten — Sprachkanal wählen und abspielen.`} +
+ )} +❌ {radioFehler}
} + + {/* ── yt-dlp ────────────────────────────────────── + Steht sichtbar hier und nicht in einer Fussnote: + wenn YouTube klemmt, ist das fast immer die + Antwort, und Stille erklaert sich nicht selbst. */} +{radio.werkzeuge.letzterFehler.text}
+ >
+ )}
+
+
+ YouTube sperrt Server-Adressen inzwischen häufig
+ („Sign in to confirm you're not a bot"). Zwei Griffe helfen,
+ beide ohne neues Image: eine cookies.txt aus dem
+ Browser eines angemeldeten Kontos, und eigene Argumente
+ (Feld weiter unten in dieser Sektion).
+
yt-dlp {radio.werkzeuge.argumente}
+
+
Adressen von Webradio-Strömen. /radio öffnet im Discord eine
Tafel mit genau dieser Liste zum Durchschalten. Jeder Icecast- oder
@@ -4089,6 +4446,116 @@ export default function Settings({ me }) {
// Vor dem Speichern hören, ob überhaupt etwas kommt — eine tote Adresse
// fällt sonst erst auf, wenn jemand im Sprachkanal sitzt und nichts passiert.
+ /**
+ * Eine Aktion an den Bot schicken und den Stand uebernehmen.
+ *
+ * An einer Stelle, weil sonst jeder Knopf seine eigene Fehlerbehandlung
+ * bekaeme -- und einer davon irgendwann keine.
+ */
+ async function radioSteuern(aktion, mehr = {}) {
+ setRadioFehler('');
+ setRadioLaedt(true);
+ try {
+ const d = await apiPost('/api/radio/steuern', {
+ aktion, guildId: radio?.guildId, kanalId: radioKanal || undefined, ...mehr,
+ });
+ // Nicht auf die naechste Abfrage warten: ein Knopf, der erst in
+ // fuenf Sekunden wirkt, fuehlt sich kaputt an.
+ setRadio((alt) => (alt ? { ...alt, ...d } : alt));
+ } catch (e) {
+ setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
+ } finally {
+ setRadioLaedt(false);
+ }
+ }
+
+ /** Einreihen -- Adresse direkt, Suchbegriff erst zur Auswahl */
+ async function radioEinreihen(treffer = null) {
+ const eingabe = radioEingabe.trim();
+ if (!treffer && !eingabe) return;
+ setRadioFehler('');
+ setRadioLaedt(true);
+ try {
+ const d = await apiPost('/api/radio/warteschlange', {
+ guildId: radio?.guildId,
+ kanalId: radioKanal || undefined,
+ ...(treffer ? { treffer } : { eingabe }),
+ });
+ if (d.suche) {
+ // Nicht raten, welcher gemeint war -- fragen.
+ setRadioTreffer(d.treffer);
+ return;
+ }
+ setRadioTreffer(null);
+ setRadioEingabe('');
+ setRadio((alt) => (alt ? { ...alt, ...d } : alt));
+ setFeedback(d.angeworfen
+ ? '\u25b6\ufe0f L\u00e4uft jetzt'
+ : `\u2705 ${d.eingereiht.length} eingereiht`);
+ } catch (e) {
+ setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
+ } finally {
+ setRadioLaedt(false);
+ }
+ }
+
+ /** Einen Eintrag verschieben oder entfernen */
+ async function radioListe(was, id) {
+ setRadioFehler('');
+ try {
+ const q = radio?.warteschlange ?? [];
+ let d;
+ if (was === 'weg') {
+ d = await apiDelete(`/api/radio/warteschlange/${id}?guildId=${radio?.guildId ?? ''}`);
+ } else if (was === 'leeren') {
+ d = await apiDelete(`/api/radio/warteschlange?guildId=${radio?.guildId ?? ''}`);
+ } else {
+ // Der Browser schickt die neue Reihenfolge als IDs; die Zahlen
+ // vergibt der Server. Was von hier kommt, ist eine Reihenfolge
+ // und sonst nichts.
+ const i = q.findIndex((e) => e.id === id);
+ const j = was === 'hoch' ? i - 1 : i + 1;
+ if (i < 0 || j < 0 || j >= q.length) return;
+ const ids = q.map((e) => e.id);
+ [ids[i], ids[j]] = [ids[j], ids[i]];
+ d = await apiPost('/api/radio/warteschlange/sortieren',
+ { guildId: radio?.guildId, ids });
+ }
+ setRadio((alt) => (alt ? { ...alt, ...d } : alt));
+ } catch (e) {
+ setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
+ }
+ }
+
+ /** Die cookies.txt setzen oder loeschen */
+ async function ytCookiesSpeichern() {
+ setRadioFehler('');
+ try {
+ const d = await apiPut('/api/radio/youtube/cookies', { inhalt: ytCookies });
+ setYtCookies('');
+ setRadio((alt) => (alt
+ ? { ...alt, werkzeuge: { ...alt.werkzeuge, cookies: d.cookies } }
+ : alt));
+ setFeedback(d.cookies ? '\u2705 Cookies gesetzt' : '\u2705 Cookies gel\u00f6scht');
+ } catch (e) {
+ setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
+ }
+ }
+
+ /** yt-dlp aktualisieren -- wirkt bis zum naechsten Neuanlegen des Containers */
+ async function ytAktualisieren() {
+ setRadioFehler('');
+ setRadioLaedt(true);
+ try {
+ const d = await apiPost('/api/radio/youtube/update', {});
+ setFeedback(`\u2705 yt-dlp ${d.fassung ?? ''}`);
+ } catch (e) {
+ setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
+ } finally {
+ setRadioLaedt(false);
+ }
+ }
+
async function senderPruefen() {
const url = senderDraft.url.trim();
if (!url) return;
diff --git a/frontend/src/style.css b/frontend/src/style.css
index cf844ab..a7aa601 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -3250,3 +3250,58 @@ button.with-icon, a.with-icon { justify-content: center; }
@media (max-width: 560px) {
.radio-form-row { grid-template-columns: 1fr; }
}
+
+/* ── Radio-Fernbedienung ──────────────────────────────────────────────── */
+.radio-remote {
+ border: 1px solid var(--border);
+ background: var(--bg2);
+ padding: 1rem;
+ margin-bottom: 1.2rem;
+ display: flex;
+ flex-direction: column;
+ gap: .9rem;
+}
+.radio-now { border-left: 3px solid var(--accent); padding-left: .8rem; }
+.radio-now-head { display: flex; gap: .7rem; align-items: flex-start; }
+.radio-badge { font-size: 1.4rem; line-height: 1.2; }
+.radio-now-text { min-width: 0; display: flex; flex-direction: column; gap: .15rem; }
+.radio-now-text strong { color: var(--text); overflow-wrap: anywhere; }
+.radio-now-sub { color: var(--text-dim); font-size: .72rem; overflow-wrap: anywhere; }
+
+.radio-progress { display: flex; align-items: center; gap: .6rem; margin-top: .5rem; }
+.radio-progress-bar { flex: 1; height: 4px; background: var(--bg3); overflow: hidden; }
+.radio-progress-bar span {
+ display: block; height: 100%; background: var(--accent);
+ /* Der Balken springt sonst bei jeder Abfrage; fuenf Sekunden Abstand
+ zwischen zwei Staenden sieht ohne das aus wie ein Ruckeln. */
+ transition: width .6s linear;
+}
+.radio-progress-text { color: var(--text-dim); font-size: .68rem; font-variant-numeric: tabular-nums; }
+
+.radio-add { display: flex; gap: .5rem; }
+.radio-add input { flex: 1; min-width: 0; }
+.radio-station-pick { max-width: 16rem; }
+
+.radio-fehler {
+ background: var(--bg3);
+ border: 1px solid var(--border);
+ padding: .55rem .7rem;
+ font-size: .68rem;
+ /* Eine Fehlerzeile von yt-dlp ist lang und darf nicht die Seite
+ breiter machen -- sie bricht um statt zu schieben. */
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ margin: 0 0 .6rem;
+ color: var(--text-dim);
+}
+
+.radio-yt { border-top: 1px solid var(--border); padding-top: .8rem; }
+.radio-yt > summary { cursor: pointer; color: var(--text-dim); font-size: .74rem; }
+.radio-yt > summary:hover { color: var(--text); }
+.radio-yt textarea { width: 100%; font-family: var(--mono, monospace); font-size: .68rem; }
+.radio-yt .field { margin-top: .7rem; }
+
+@media (max-width: 700px) {
+ .radio-add { flex-direction: column; }
+ .radio-station-pick { max-width: none; }
+}
diff --git a/src/modules.js b/src/modules.js
index 199fdbb..d6f2520 100644
--- a/src/modules.js
+++ b/src/modules.js
@@ -109,10 +109,21 @@ export const MODULES = [
},
{
id: 'radio', group: 'community', name: 'Radio',
- desc: 'Webradio im Sprachkanal — /radio öffnet eine Tafel mit Sender-Auswahl zum '
- + 'Durchschalten. Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, '
- + 'und kommt nach einem Neustart in den Kanal zurück.',
+ desc: 'Webradio und YouTube im Sprachkanal — /radio öffnet eine Tafel mit '
+ + 'Sender-Auswahl, /musik eine zweite fürs Einreihen von YouTube-Titeln. '
+ + 'Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, und kommt '
+ + 'nach einem Neustart in den Kanal zurück.',
tab: 'community', setting: 'radio_enabled', defaultOn: false,
+ fields: [
+ {
+ setting: 'radio_youtube_enabled', label: 'YouTube erlauben', art: 'schalter',
+ hint: 'braucht yt-dlp auf dem Server; ohne Haken laufen nur die Sender',
+ },
+ {
+ setting: 'ytdlp_args', label: 'Eigene yt-dlp-Argumente', art: 'text',
+ hint: 'nur nötig, wenn YouTube sperrt — z. B. --extractor-args "youtube:player_client=android_vr"',
+ },
+ ],
tool: { tab: 'community', label: 'Sender verwalten' },
},
{