Radio youtube #2
@@ -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 .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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
+14
-3
@@ -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' },
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user