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:
@@ -95,6 +95,21 @@ const WUNSCH_STATUS = [
|
|||||||
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
|
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
|
||||||
const SENDER_LEER = { id: null, name: '', url: '', emoji: '', sort: 0 };
|
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']);
|
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
|
||||||
|
|
||||||
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
// 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 });
|
const [senderDraft, setSenderDraft] = useState({ id: null, name: '', url: '', emoji: '', sort: 0 });
|
||||||
// Ergebnis der Sender-Probe: { erreichbar, sender, titel } oder 'laeuft'
|
// Ergebnis der Sender-Probe: { erreichbar, sender, titel } oder 'laeuft'
|
||||||
const [senderProbe, setSenderProbe] = useState(null);
|
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 [owner, setOwner] = useState(null);
|
||||||
const [auditAlle, setAuditAlle] = useState(false);
|
const [auditAlle, setAuditAlle] = useState(false);
|
||||||
// Module (An/Aus für alle Funktionen)
|
// 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(() => {});
|
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)
|
// Portal-Dienste (Kacheln auf der Startseite)
|
||||||
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
|
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
|
||||||
const [services, setServices] = useState([]);
|
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>
|
<span className="field-hint">ohne Haken antwortet /radio nur mit einem Hinweis</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 .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">
|
<p className="section-intro">
|
||||||
Adressen von Webradio-Strömen. <code>/radio</code> öffnet im Discord eine
|
Adressen von Webradio-Strömen. <code>/radio</code> öffnet im Discord eine
|
||||||
Tafel mit genau dieser Liste zum Durchschalten. Jeder Icecast- oder
|
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
|
// 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.
|
// 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() {
|
async function senderPruefen() {
|
||||||
const url = senderDraft.url.trim();
|
const url = senderDraft.url.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|||||||
@@ -3250,3 +3250,58 @@ button.with-icon, a.with-icon { justify-content: center; }
|
|||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.radio-form-row { grid-template-columns: 1fr; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
+14
-3
@@ -109,10 +109,21 @@ export const MODULES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'radio', group: 'community', name: 'Radio',
|
id: 'radio', group: 'community', name: 'Radio',
|
||||||
desc: 'Webradio im Sprachkanal — /radio öffnet eine Tafel mit Sender-Auswahl zum '
|
desc: 'Webradio und YouTube im Sprachkanal — /radio öffnet eine Tafel mit '
|
||||||
+ 'Durchschalten. Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, '
|
+ 'Sender-Auswahl, /musik eine zweite fürs Einreihen von YouTube-Titeln. '
|
||||||
+ 'und kommt nach einem Neustart in den Kanal zurück.',
|
+ '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,
|
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' },
|
tool: { tab: 'community', label: 'Sender verwalten' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user