Panel: die volle Fernbedienung ueber der Senderliste

Was laeuft, wo, aus welcher Quelle, mit Fortschrittsbalken. Sprachkanal
waehlen, Sender abspielen, Ueberspringen, Zum Radio, Stopp,
Lautstaerke-Schieber. Einreihen per Link oder Suchbegriff, Warteschlange
mit Hoch/Runter/Weg und Leeren. Darunter ein aufklappbarer yt-dlp-Block:
Zustand, Fassung, cookies.txt, eigene Argumente, Aktualisieren -- und die
letzte Fehlerzeile im Klartext.

Steht ueber der Senderliste, weil man oefter etwas abspielt als die Liste
pflegt.

Die Abfrage laeuft nur, solange der Community-Reiter offen ist. Sonst
ginge alle fuenf Sekunden eine Anfrage gegen eine Ansicht raus, die
niemand ansieht.

Der Balken erscheint nur bei bekannter Dauer. Bei einem Livestream gaebe
er einen Stand vor, den niemand kennt -- dort steht stattdessen "live".

Die Kanalauswahl graut aus, wo der Bot nicht rein darf. Ein Kanal, den das
Panel anbietet und in den der Bot dann nicht kommt, sieht aus wie ein
kaputter Knopf.

Geprueft: der Vite-Build laeuft durch, und ein Abgleich stellt alle 30
Feldpfade, die die Oberflaeche vom Stand liest, gegen die echte
API-Antwort -- 0 fehlen. Der Abgleich hat eine Gegenprobe mit bekanntem
Ausgang (ein Feld, das es nicht gibt, muss als fehlend erkannt werden) und
prueft vorher, ob die sonst leeren Zweige (Warteschlange, Cookies, letzter
Fehler) ueberhaupt besetzt sind -- sonst meldet er "ungueltig" statt "in
Ordnung". Seine erste Fassung hatte Fehlalarm geschlagen (?. und .map als
fehlende Felder gelesen); das ist behoben.

NICHT geprueft: das tatsaechliche Rendern im Browser -- hier ist weder
Browser noch DOM-Werkzeug vorhanden.
This commit is contained in:
2026-08-28 13:50:06 +02:00
parent 1ac349029f
commit dbec4e9775
3 changed files with 536 additions and 3 deletions
+467
View File
@@ -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 }) {
<span className="field-hint">ohne Haken antwortet /radio nur mit einem Hinweis</span>
</label>
</div>
<div className="field" data-setting="radio_youtube_enabled">
<label className="toggle">
<input type="checkbox" checked={Boolean(form.radio_youtube_enabled)}
onChange={(e) => setForm({ ...form, radio_youtube_enabled: e.target.checked })} />
<span>YouTube erlauben</span>
<span className="field-hint">
braucht <code>yt-dlp</code> auf dem Server; ohne Haken laufen nur die Sender
</span>
</label>
</div>
{/* Fernbedienung
Steht ueber der Senderliste, weil man oefter etwas
abspielt als die Liste pflegt. */}
{radio?.mehrdeutig && (
<p className="notice">
Der Bot steht in mehreren Servern und es ist keiner eingestellt
unter <strong>System</strong> die Server-ID eintragen, sonst weiss
das Panel nicht, welchen es steuern soll.
</p>
)}
{radio && !radio.mehrdeutig && (
<div className="radio-remote">
<div className="radio-now">
{radio.laeuft ? (
<>
<div className="radio-now-head">
<span className="radio-badge">
{radio.laeuft.art === 'youtube' ? '▶️' : '📻'}
</span>
<div className="radio-now-text">
<strong>
{radio.laeuft.art === 'youtube'
? radio.laeuft.titel
: radio.laeuft.sender}
</strong>
<span className="radio-now-sub">
{radio.laeuft.art === 'youtube'
? [radio.laeuft.kanal, radio.laeuft.heimStation
&& `danach zurück auf ${radio.laeuft.heimStation.name}`]
.filter(Boolean).join(' · ')
: (radio.laeuft.titel ?? 'Titel wird abgefragt …')}
</span>
<span className="radio-now-sub">
🔊 {radio.laeuft.kanalName ?? radio.laeuft.kanalId}
</span>
</div>
</div>
{/* Der Balken nur, wo er etwas aussagen kann: ohne
bekannte Dauer gaebe er einen Stand vor, den
niemand kennt. */}
{radio.laeuft.dauer ? (
<div className="radio-progress">
<div className="radio-progress-bar">
<span style={{
width: `${Math.min(100, (radio.laeuft.verstrichen
/ radio.laeuft.dauer) * 100)}%`,
}} />
</div>
<span className="radio-progress-text">
{dauerText(radio.laeuft.verstrichen)} / {dauerText(radio.laeuft.dauer)}
</span>
</div>
) : radio.laeuft.art === 'youtube' && (
<p className="radio-now-sub">🔴 Livestream</p>
)}
{radio.laeuft.tonLaeuft === false && (
<p className="notice">
Es kommt kein Ton an.
{radio.werkzeuge.ffmpeg === false
&& ' Auf dem Server fehlt ffmpeg.'}
</p>
)}
</>
) : (
<p className="radio-now-sub">
Nichts läuft gerade.
{radio.stand.n > 0
&& ` ${radio.stand.n} Titel warten — Sprachkanal wählen und abspielen.`}
</p>
)}
</div>
<div className="radio-form-row">
<div className="field">
<label>Sprachkanal <span className="field-hint">wohin der Bot gehen soll</span></label>
<select value={radioKanal} onChange={(e) => setRadioKanal(e.target.value)}>
<option value="">
{radio.laeuft
? `— läuft in ${radio.laeuft.kanalName ?? radio.laeuft.kanalId}`
: '— bitte wählen —'}
</option>
{radio.sprachkanaele.map((k) => (
<option key={k.id} value={k.id} disabled={!k.darfRein}>
{k.name}
{k.besetzt > 0 ? ` (${k.besetzt})` : ''}
{k.darfRein ? '' : ' — darf ich nicht rein'}
</option>
))}
</select>
</div>
<div className="field">
<label>
Lautstärke <span className="field-hint">{radio.lautstaerke} %</span>
</label>
{/* onMouseUp/onChange statt live bei jedem Pixel: sonst
geht je Mausbewegung eine Anfrage raus. */}
<input type="range"
min={radio.grenzen.min} max={radio.grenzen.max}
step={radio.grenzen.schritt}
value={radio.lautstaerke}
onChange={(e) => setRadio({ ...radio, lautstaerke: Number(e.target.value) })}
onMouseUp={(e) => radioSteuern('laut', { wert: Number(e.target.value) })}
onTouchEnd={(e) => radioSteuern('laut', { wert: Number(e.target.value) })} />
</div>
</div>
<div className="settings-actions">
<select className="radio-station-pick" defaultValue=""
onChange={(e) => {
if (!e.target.value) return;
radioSteuern('sender', { stationId: Number(e.target.value) });
e.target.value = '';
}}>
<option value="">Sender abspielen </option>
{sender.map((x) => (
<option key={x.id} value={x.id}>{x.emoji || '📻'} {x.name}</option>
))}
</select>
<button className="btn btn-ghost" disabled={radioLaedt || !radio.laeuft}
onClick={() => radioSteuern('skip')}>
Überspringen
</button>
<button className="btn btn-ghost"
disabled={radioLaedt || radio.laeuft?.art !== 'youtube'
|| !radio.laeuft?.heimStation}
onClick={() => radioSteuern('heim')}>
📻 Zum Radio
</button>
<button className="btn btn-ghost danger" disabled={radioLaedt || !radio.laeuft}
onClick={() => radioSteuern('stopp')}>
Stopp
</button>
</div>
{/* ── Einreihen ── */}
<div className="field">
<label>
YouTube einreihen
<span className="field-hint">Link oder Suchbegriff</span>
</label>
<div className="radio-add">
<input type="text" placeholder="https://youtu.be/… oder: nightwish sleeping sun"
value={radioEingabe}
disabled={!radio.werkzeuge.youtubeAn || radio.werkzeuge.ytdlp === false}
onChange={(e) => { setRadioEingabe(e.target.value); setRadioTreffer(null); }}
onKeyDown={(e) => e.key === 'Enter' && radioEinreihen()} />
<button className="btn btn-save" disabled={radioLaedt || !radioEingabe.trim()}
onClick={() => radioEinreihen()}>
{radioLaedt ? '…' : 'Einreihen'}
</button>
</div>
{!radio.werkzeuge.youtubeAn && (
<span className="field-hint">
YouTube ist ausgeschaltet Haken oben setzen und speichern.
</span>
)}
</div>
{/* Bei einer Suche wird nicht geraten, welcher gemeint war. */}
{radioTreffer && (
<div className="radio-list">
{radioTreffer.map((t) => (
<div className="radio-row" key={t.video_id}>
<span className="radio-emoji"></span>
<div className="radio-info">
<span className="radio-name">{t.titel}</span>
<span className="radio-url">
{[t.kanal, dauerText(t.dauer)].filter(Boolean).join(' · ')}
</span>
</div>
<div className="radio-actions">
<button className="btn btn-ghost" disabled={radioLaedt}
onClick={() => radioEinreihen(t)}>
Nehmen
</button>
</div>
</div>
))}
</div>
)}
{/* ── Warteschlange ── */}
{radio.warteschlange.length > 0 && (
<>
<label>
Warteschlange
<span className="field-hint">
{radio.stand.n} Titel, {dauerText(radio.stand.dauer)} gesamt
</span>
</label>
<div className="radio-list">
{radio.warteschlange.map((e, i) => (
<div className="radio-row" key={e.id}>
<span className="radio-emoji">{i + 1}.</span>
<div className="radio-info">
<span className="radio-name">{e.titel}</span>
<span className="radio-url">
{[e.kanal, dauerText(e.dauer),
e.wunsch_von && `von ${e.wunsch_von}`]
.filter(Boolean).join(' · ')}
</span>
</div>
<div className="radio-actions">
<button className="btn btn-ghost" title="nach oben"
disabled={i === 0}
onClick={() => radioListe('hoch', e.id)}></button>
<button className="btn btn-ghost" title="nach unten"
disabled={i === radio.warteschlange.length - 1}
onClick={() => radioListe('runter', e.id)}></button>
<button className="btn btn-ghost danger" title="entfernen"
onClick={() => radioListe('weg', e.id)}></button>
</div>
</div>
))}
</div>
<div className="settings-actions">
<button className="btn btn-ghost danger"
onClick={() => radioListe('leeren')}>
Warteschlange leeren
</button>
</div>
</>
)}
{radioFehler && <p className="notice"> {radioFehler}</p>}
{/* 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. */}
<details className="radio-yt">
<summary>
yt-dlp {radio.werkzeuge.ytdlp === false
? '❌ fehlt'
: radio.werkzeuge.ytdlp === null
? '… noch nicht geprüft'
: `${radio.werkzeuge.ytdlpFassung ?? 'da'}`}
{radio.werkzeuge.cookies ? ' · Cookies gesetzt' : ''}
</summary>
{radio.werkzeuge.letzterFehler && (
<>
<label>Zuletzt schiefgegangen</label>
<pre className="radio-fehler">{radio.werkzeuge.letzterFehler.text}</pre>
</>
)}
<p className="section-intro">
YouTube sperrt Server-Adressen inzwischen häufig
(Sign in to confirm you're not a bot"). Zwei Griffe helfen,
beide ohne neues Image: eine <code>cookies.txt</code> aus dem
Browser eines angemeldeten Kontos, und eigene Argumente
(Feld weiter unten in dieser Sektion).
</p>
<div className="field">
<label>
cookies.txt
<span className="field-hint">
{radio.werkzeuge.cookies
? `gesetzt, ${radio.werkzeuge.cookies.bytes} Bytes — leer speichern löscht sie`
: 'im Netscape-Format; leer = keine'}
</span>
</label>
<textarea rows={4} placeholder="# Netscape HTTP Cookie File&#10;.youtube.com TRUE / TRUE 0 …"
value={ytCookies} onChange={(e) => setYtCookies(e.target.value)} />
</div>
<div className="field" data-setting="ytdlp_args">
<label>
Eigene yt-dlp-Argumente
<span className="field-hint">
z. B. --extractor-args "youtube:player_client=android_vr"
</span>
</label>
<input type="text" value={form.ytdlp_args ?? ''}
onChange={(e) => setForm({ ...form, ytdlp_args: e.target.value })} />
</div>
<label>
Was tatsächlich rausgeht
<span className="field-hint">
aus Grundeinstellung, Cookies und deinen Argumenten
</span>
</label>
<pre className="radio-fehler">yt-dlp {radio.werkzeuge.argumente}</pre>
<div className="settings-actions">
<button className="btn btn-ghost" onClick={ytCookiesSpeichern}>
Cookies speichern
</button>
<button className="btn btn-ghost" disabled={radioLaedt}
onClick={ytAktualisieren}>
yt-dlp aktualisieren
</button>
</div>
<span className="field-hint">
Aktualisieren wirkt sofort, aber nur bis der Container neu
angelegt wird danach gilt wieder die Fassung aus dem Image.
Ein Deploy holt ohnehin die neueste.
</span>
</details>
</div>
)}
<p className="section-intro">
Adressen von Webradio-Strömen. <code>/radio</code> ö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;