Zwei Befunde aus dem Betrieb. 1. Der Bot ging aus dem leeren Kanal und kam sofort wieder rein. `stoppen()` hielt erst den Spieler an und loeschte dann aus `laufend`. `player.stop()` meldet den Leerlauf aber SYNCHRON -- gemessen mit @discordjs/voice 0.19: der Idle-Melder laeuft mitten in stop(), noch vor der naechsten Zeile. Er fand den Eintrag also noch, hielt das Ende fuer "Titel vorbei" und schaltete weiter: naechster Titel oder zurueck auf den Heimsender. Von aussen genau das gemeldete Bild. Jetzt wird erst geloescht, dann angehalten. Nachgestellt mit einem echten AudioPlayer und beiden Reihenfolgen -- die Kontrolle ist dabei die alte Reihenfolge: sie schaltet nachweislich weiter (1x), die neue nicht (0x). Ohne diese Gegenprobe koennte der Test etwas ganz anderes messen. 2. Im YouTube-Modus fehlte die Pause. `pausieren()` gibt es jetzt, ausdruecklich nur fuer YouTube. Ein Sender laesst sich nicht sinnvoll anhalten: er laeuft beim Betreiber weiter, waehrend hier die Leitung volllaeuft, und beim Fortsetzen kaeme eine Konserve, die immer weiter hinterherhinkt. Fuer einen Sender heisst Pause Stopp -- dafuer gibt es den Stopp-Knopf, und der Pause-Knopf graut aus. Die verstrichene Zeit rechnet Pausen heraus, sonst liefe der Fortschrittsbalken nach dem Fortsetzen um die Pausenlaenge voraus. `verstrichen()` steht an EINER Stelle, die sich Discord-Tafel und Panel teilen -- zwei Rechnungen waeren zwei Gelegenheiten, die Pause zu vergessen. Die Musik-Tafel bekommt eine zweite Knopfreihe: mit der Pause waeren es sechs Knoepfe, Discord laesst fuenf je Reihe zu. Platz ist reichlich (vier von fuenf Reihen), und die Trennung ist ohnehin sinnvoller -- oben, was gerade klingt, unten, was mit der Liste passiert. Nachgezaehlt: 2 Reihen ohne Liste, 4 mit. Beim Quellenwechsel wird ausdruecklich fortgesetzt: der Spieler ueberlebt den Wechsel, und stand er auf Pause, waere der neue Titel stumm gestartet waehrend der Eintrag "laeuft" sagt. Nebenbei: mein Feld-Abgleich hielt eine zweite Kopie der Feldliste von `radioStand()` und driftete beim ersten neuen Feld prompt ab -- er meldete `pausiert` als fehlend, obwohl die API es liefert. Er liest die Form jetzt aus der Quelle, statt sie doppelt zu pflegen.
523 lines
23 KiB
JavaScript
523 lines
23 KiB
JavaScript
// Die zweite Tafel: YouTube und die Warteschlange.
|
|
//
|
|
// Warum eine eigene Nachricht und nicht ein paar Knöpfe mehr an der
|
|
// Radio-Tafel: Discord lässt fünf Reihen je Nachricht zu, und vier davon
|
|
// gehören dort den Sender-Menüs. Für „Überspringen", „Einreihen",
|
|
// „Warteschlange" und „Leeren" ist da schlicht kein Platz — und ein
|
|
// Sender-Menü zu opfern hiesse, Sender aus der Auswahl zu werfen.
|
|
//
|
|
// Zwei Tafeln haben ausserdem einen Vorzug, der nicht bloss Notlösung ist:
|
|
// `/radio` bleibt genau das, was es war. Wer nie ein Video einreiht, merkt
|
|
// von diesem ganzen Umbau nichts.
|
|
//
|
|
// Die Abhängigkeit zeigt bewusst nur in eine Richtung: diese Datei kennt das
|
|
// Radio, das Radio kennt sie nicht. Es meldet über `beiAenderung()` nur, dass
|
|
// sich etwas geändert hat. Ein gegenseitiger Import zwischen zwei Dateien ist
|
|
// etwas, das beim Lesen niemand erwartet und beim Umbauen jeden beisst.
|
|
import {
|
|
ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags,
|
|
ModalBuilder, TextInputBuilder, TextInputStyle,
|
|
StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
|
|
} from 'discord.js';
|
|
import { brandEmbed } from '../embeds.js';
|
|
import { brandFooter, youtubeAn } from '../runtime-settings.js';
|
|
import { moduleEnabled } from '../modules.js';
|
|
import {
|
|
queueList, queueAdd, queueRemove, queueClear, queueVor, queueStand,
|
|
ytPanel, setYtPanel, clearYtPanel, getStation,
|
|
} from '../db.js';
|
|
import {
|
|
laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle,
|
|
pausieren, verstrichen,
|
|
} from './radio.js';
|
|
import {
|
|
aufloesen, ytdlpVorhanden, letzterFehler, TREFFER,
|
|
} from './youtube.js';
|
|
|
|
// Discord lässt 25 Einträge je Auswahlmenü zu. Die Warteschlange kann länger
|
|
// sein — dann zeigen die Menüs die ersten 25, und der Rest wird über das
|
|
// Web-Panel verwaltet. Das steht auch so in der Tafel, statt stillschweigend
|
|
// zu unterschlagen, dass da noch etwas ist.
|
|
const JE_MENU = 25;
|
|
|
|
// ── Darstellung ───────────────────────────────────────────────────────────
|
|
|
|
/** 3:07 statt 187 */
|
|
export function dauerText(sekunden) {
|
|
if (sekunden === null || sekunden === undefined) return 'live';
|
|
const s = Math.max(0, Math.round(sekunden));
|
|
const h = Math.floor(s / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
const rest = s % 60;
|
|
return h > 0
|
|
? `${h}:${String(m).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
|
: `${m}:${String(rest).padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Ein Balken für den Fortschritt.
|
|
*
|
|
* Nur wo er etwas aussagen kann: ohne bekannte Dauer (Livestream) gäbe er
|
|
* einen Stand vor, den niemand kennt. Dann steht dort lieber nichts.
|
|
*/
|
|
function fortschritt(verstrichen, dauer) {
|
|
if (!dauer || dauer <= 0) return null;
|
|
const anteil = Math.min(1, Math.max(0, verstrichen / dauer));
|
|
const voll = Math.round(anteil * 12);
|
|
return `\`${'━'.repeat(voll)}${'─'.repeat(12 - voll)}\` ${dauerText(verstrichen)} / ${dauerText(dauer)}`;
|
|
}
|
|
|
|
/** Titel kürzen, damit Discord die Nachricht nicht ablehnt */
|
|
const kurz = (text, n) => (String(text ?? '').length > n
|
|
? `${String(text).slice(0, n - 1)}…`
|
|
: String(text ?? ''));
|
|
|
|
export function musikEmbed(client, guildId) {
|
|
const embed = brandEmbed(client, 'RADIO')
|
|
.setTitle('▶️ Musik')
|
|
.setFooter({ text: brandFooter('RADIO') })
|
|
.setTimestamp();
|
|
|
|
// Ein fehlendes yt-dlp ist die häufigste Ursache und die am schlechtesten
|
|
// zu erratende: alles sieht normal aus, es passiert nur nichts. Deshalb
|
|
// steht sie ganz oben und nicht in einer Fussnote.
|
|
if (ytdlpVorhanden() === false) {
|
|
return embed.setDescription('⚠️ Auf dem Server fehlt **yt-dlp** — ohne das kann der Bot '
|
|
+ 'kein YouTube abspielen. Die Radiosender laufen davon unberührt weiter.');
|
|
}
|
|
if (!youtubeAn()) {
|
|
return embed.setDescription('YouTube ist ausgeschaltet — im Panel unter '
|
|
+ '**Community → Radio** einschalten.');
|
|
}
|
|
|
|
const jetzt = laeuft(guildId);
|
|
const liste = queueList(guildId);
|
|
const stand = queueStand(guildId);
|
|
|
|
if (jetzt?.quelle?.art === 'youtube') {
|
|
const t = jetzt.quelle.titel;
|
|
// Pausen abgezogen — sonst behauptet der Balken, ein angehaltener
|
|
// Titel liefe weiter, und läuft beim Fortsetzen voraus.
|
|
const balken = fortschritt(verstrichen(jetzt), t.dauer);
|
|
embed.setDescription(`${jetzt.pausiertSeit ? '⏸️ ' : ''}**${kurz(t.titel, 200)}**\n`
|
|
+ `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}`
|
|
+ (balken ? `${balken}\n` : '🔴 _Livestream_\n')
|
|
+ `in <#${jetzt.channelId}>`);
|
|
if (t.thumb) embed.setThumbnail(t.thumb);
|
|
} else if (jetzt) {
|
|
// Es läuft etwas, nur eben ein Sender. Das ist kein Fehler und wird
|
|
// auch nicht als einer dargestellt.
|
|
const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null;
|
|
embed.setDescription(`📻 Gerade läuft das Radio${heim ? ` (**${heim.name}**)` : ''}.\n`
|
|
+ 'Was hier eingereiht wird, übernimmt sofort — danach geht es zurück auf den Sender.');
|
|
} else {
|
|
embed.setDescription(liste.length > 0
|
|
? 'Nichts läuft. **Überspringen** startet die Warteschlange — '
|
|
+ 'geh vorher in einen Sprachkanal.'
|
|
: 'Nichts läuft und nichts eingereiht. **Einreihen…** nimmt einen '
|
|
+ 'YouTube-Link oder einen Suchbegriff.');
|
|
}
|
|
|
|
if (liste.length > 0) {
|
|
const zeilen = liste.slice(0, 10).map((e, i) =>
|
|
`\`${String(i + 1).padStart(2)}.\` ${kurz(e.titel, 60)} \`${dauerText(e.dauer)}\``
|
|
+ (e.wunsch_von ? ` — _${kurz(e.wunsch_von, 24)}_` : ''));
|
|
if (liste.length > 10) zeilen.push(`_… und ${liste.length - 10} weitere_`);
|
|
embed.addFields({
|
|
name: `Warteschlange — ${stand.n} Titel, ${dauerText(stand.dauer)} gesamt`,
|
|
value: zeilen.join('\n').slice(0, 1024),
|
|
});
|
|
}
|
|
|
|
// Der letzte Fehlschlag, im Klartext. Genau das ist der Unterschied
|
|
// zwischen „YouTube geht nicht" und „YouTube verlangt eine Anmeldung".
|
|
const fehler = letzterFehler();
|
|
if (fehler && !jetzt) {
|
|
embed.addFields({
|
|
name: '⚠️ Zuletzt schiefgegangen',
|
|
value: `\`\`\`${kurz(fehler.text, 300)}\`\`\``,
|
|
});
|
|
}
|
|
return embed;
|
|
}
|
|
|
|
const knopf = (id, label, emoji, stil = ButtonStyle.Secondary, aus = false) =>
|
|
new ButtonBuilder().setCustomId(id).setLabel(label).setEmoji(emoji)
|
|
.setStyle(stil).setDisabled(aus);
|
|
|
|
export function musikKomponenten(guildId) {
|
|
const liste = queueList(guildId).slice(0, JE_MENU);
|
|
const jetzt = laeuft(guildId);
|
|
const reihen = [];
|
|
|
|
if (liste.length > 0) {
|
|
const optionen = () => liste.map((e, i) => {
|
|
const o = new StringSelectMenuOptionBuilder()
|
|
.setLabel(kurz(`${i + 1}. ${e.titel}`, 100))
|
|
.setValue(String(e.id));
|
|
const unten = [dauerText(e.dauer), e.kanal].filter(Boolean).join(' · ');
|
|
if (unten) o.setDescription(kurz(unten, 100));
|
|
return o;
|
|
});
|
|
reihen.push(new ActionRowBuilder().addComponents(
|
|
new StringSelectMenuBuilder().setCustomId('musik_vor')
|
|
.setPlaceholder('⏭️ Als Nächstes spielen …').addOptions(optionen())
|
|
));
|
|
reihen.push(new ActionRowBuilder().addComponents(
|
|
new StringSelectMenuBuilder().setCustomId('musik_weg')
|
|
.setPlaceholder('🗑️ Aus der Liste nehmen …').addOptions(optionen())
|
|
));
|
|
}
|
|
|
|
// Am Anschlag ausgrauen statt stumm nichts zu tun — dieselbe Regel wie
|
|
// an der Radio-Tafel: ein Knopf, der sich drücken lässt und nichts
|
|
// bewirkt, sieht aus wie ein kaputter Knopf.
|
|
const nichtsDa = liste.length === 0;
|
|
const ytLaeuft = jetzt?.quelle?.art === 'youtube';
|
|
const pausiert = Boolean(jetzt?.pausiertSeit);
|
|
|
|
// Zwei Knopfreihen statt einer: mit der Pause wären es sechs Knöpfe, und
|
|
// Discord lässt nur fünf je Reihe zu. Platz ist da — zwei Auswahlmenüs
|
|
// plus zwei Knopfreihen sind vier von fünf möglichen Reihen.
|
|
//
|
|
// Die Trennung ist ausserdem die sinnvollere: oben, was gerade klingt,
|
|
// unten, was mit der Liste passiert.
|
|
reihen.push(new ActionRowBuilder().addComponents(
|
|
// Pause gibt es nur bei YouTube. Warum, steht bei `pausieren()` in
|
|
// `radio.js` — kurz: einen Dauerstrom anzuhalten heisst, ihm
|
|
// hinterherzuhinken.
|
|
pausiert
|
|
? knopf('musik_pause', 'Weiter', '▶️', ButtonStyle.Success)
|
|
: knopf('musik_pause', 'Pause', '⏸️', ButtonStyle.Primary, !ytLaeuft),
|
|
knopf('musik_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
|
|
nichtsDa && !ytLaeuft),
|
|
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
|
|
!ytLaeuft || !jetzt.heimStationId),
|
|
));
|
|
reihen.push(new ActionRowBuilder().addComponents(
|
|
knopf('musik_add', 'Einreihen…', '▶️', ButtonStyle.Success),
|
|
knopf('musik_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa),
|
|
knopf('musik_refresh', 'Aktualisieren', '🔄'),
|
|
));
|
|
return reihen;
|
|
}
|
|
|
|
/** Die gespeicherte Tafel neu zeichnen — für den Melder aus `radio.js` */
|
|
export async function musikTafelAktualisieren(client, guildId) {
|
|
const p = ytPanel(guildId);
|
|
if (!p) return;
|
|
const kanal = await client.channels.fetch(p.channel_id).catch(() => null);
|
|
const nachricht = await kanal?.messages?.fetch(p.message_id).catch(() => null);
|
|
if (!nachricht) {
|
|
// Die Nachricht ist weg (gelöscht, Kanal aufgeräumt). Den Verweis
|
|
// stehenzulassen hiesse, es bei jeder Änderung erneut zu versuchen.
|
|
clearYtPanel(guildId);
|
|
return;
|
|
}
|
|
await nachricht.edit({
|
|
embeds: [musikEmbed(client, guildId)],
|
|
components: musikKomponenten(guildId),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// ── Einreihen ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Eingabe auflösen und einreihen — die eine Stelle für Discord *und* Panel.
|
|
*
|
|
* Bei einem Suchbegriff wird bewusst nicht der erste Treffer genommen,
|
|
* sondern die Auswahl zurückgegeben: „Sleeping Sun" hat drei Fassungen, und
|
|
* welche gemeint war, weiss nur der, der es getippt hat. Bei einer Adresse
|
|
* gibt es nichts zu wählen — die geht direkt rein, eine Playlist mit allen
|
|
* Einträgen auf einmal.
|
|
*
|
|
* @returns {Promise<{eingereiht?: object[], treffer?: object[], fehler?: string}>}
|
|
*/
|
|
export async function einreihen(guildId, eingabe, { wunschVon = null } = {}) {
|
|
if (!youtubeAn()) return { fehler: 'YouTube ist ausgeschaltet.' };
|
|
const r = await aufloesen(eingabe, { max: TREFFER });
|
|
if (r.fehler) return { fehler: r.fehler };
|
|
if (r.suche) return { treffer: r.treffer };
|
|
return { eingereiht: eintraegeAufnehmen(guildId, r.treffer, wunschVon) };
|
|
}
|
|
|
|
/** Fertige Einträge in die Liste schreiben */
|
|
export function eintraegeAufnehmen(guildId, eintraege, wunschVon = null) {
|
|
for (const e of eintraege) queueAdd(guildId, { ...e, wunsch_von: wunschVon });
|
|
return eintraege;
|
|
}
|
|
|
|
/**
|
|
* Nach dem Einreihen: soll das jetzt losgehen?
|
|
*
|
|
* Drei Fälle, und der mittlere ist der, den ich am 28.08.2026 falsch hatte:
|
|
*
|
|
* nichts läuft → die Liste fängt an. Sonst legt jemand drei Titel hinein,
|
|
* es passiert nichts, und der naheliegende Schluss wäre
|
|
* „kaputt".
|
|
* Sender läuft → **der Titel übernimmt sofort.** „Das Radio ist die
|
|
* Grundstellung, YouTube die Unterbrechung" — und eine
|
|
* Unterbrechung, die wartet, ist keine. Hier stand vorher
|
|
* „die Liste kommt dran, wenn sie dran ist", und das war
|
|
* beim Sender schlicht falsch: ein Sender endet nie, also
|
|
* gibt es kein `Idle`, das weiterschaltet. Der einzige
|
|
* `Idle`, den es gibt, ist ein abgerissener Strom — und der
|
|
* startet denselben Sender neu. Der Titel wartete also auf
|
|
* einen Moment, der nie kommt. Im Betrieb gemeldet: Link
|
|
* eingereiht, Bot blieb beim Radio.
|
|
* Der Sender ist nicht verloren: er steht als `heimStation`
|
|
* im laufenden Eintrag und kommt zurück, wenn die Liste
|
|
* leer ist.
|
|
* Titel läuft → nicht dazwischenfunken. Der Neue reiht sich ein, und
|
|
* *hier* stimmt „kommt dran, wenn er dran ist" auch.
|
|
*/
|
|
export async function ggfAnwerfen(client, guildId, kanalId) {
|
|
const jetzt = laeuft(guildId);
|
|
if (!uebernimmtSofort(jetzt)) return false;
|
|
// Ohne eigene Angabe der Kanal, in dem der Bot schon steht.
|
|
const ziel = jetzt?.channelId ?? kanalId;
|
|
if (!ziel) return false;
|
|
const q = await weiter(client, guildId, { erzwungen: true, kanalId: ziel });
|
|
return Boolean(q);
|
|
}
|
|
|
|
/**
|
|
* Die Regel als eigener Satz — weil genau sie falsch war.
|
|
*
|
|
* Sie steckte als Bedingung mitten in `ggfAnwerfen` (`if (laeuft(guildId))
|
|
* return false`) und war von aussen weder zu lesen noch zu prüfen. Hier hat
|
|
* sie einen Namen und lässt sich mit allen drei Eingaben durchspielen.
|
|
*
|
|
* @param {object|null} jetzt Der laufende Eintrag, oder null
|
|
* @returns {boolean} true = der neue Titel fängt sofort an
|
|
*/
|
|
export function uebernimmtSofort(jetzt) {
|
|
// Nur ein laufender YouTube-Titel wird nicht unterbrochen. Ein Sender
|
|
// schon — er ist die Grundstellung, kein Vorrang.
|
|
return jetzt?.quelle?.art !== 'youtube';
|
|
}
|
|
|
|
// ── Suchtreffer zwischenlagern ────────────────────────────────────────────
|
|
//
|
|
// Ein Discord-Auswahlmenü gibt nur die `value` zurück, und da passt keine
|
|
// vollständige Auskunft hinein. Also bleiben die Treffer kurz hier liegen und
|
|
// die Kennung wandert in die customId.
|
|
//
|
|
// Mit Verfallszeit, weil sonst jede Suche für immer Speicher belegt — bei
|
|
// einem Bot, der monatelang läuft, ist das kein theoretischer Einwand.
|
|
const treffermerker = new Map(); // token → { treffer, zeit }
|
|
const MERK_DAUER = 5 * 60_000;
|
|
|
|
function trefferMerken(token, treffer) {
|
|
const grenze = Date.now() - MERK_DAUER;
|
|
for (const [k, v] of treffermerker) if (v.zeit < grenze) treffermerker.delete(k);
|
|
treffermerker.set(token, { treffer, zeit: Date.now() });
|
|
}
|
|
|
|
function trefferHolen(token) {
|
|
const e = treffermerker.get(token);
|
|
if (!e || e.zeit < Date.now() - MERK_DAUER) return null;
|
|
return e.treffer;
|
|
}
|
|
|
|
/** Die flüchtige Auswahl nach einer Suche */
|
|
function trefferAuswahl(token, treffer) {
|
|
return new ActionRowBuilder().addComponents(
|
|
new StringSelectMenuBuilder()
|
|
.setCustomId(`musik_treffer:${token}`)
|
|
.setPlaceholder('Welchen davon?')
|
|
.addOptions(treffer.map((t, i) => {
|
|
const o = new StringSelectMenuOptionBuilder()
|
|
.setLabel(kurz(t.titel, 100))
|
|
.setValue(String(i));
|
|
const unten = [dauerText(t.dauer), t.kanal].filter(Boolean).join(' · ');
|
|
if (unten) o.setDescription(kurz(unten, 100));
|
|
return o;
|
|
}))
|
|
);
|
|
}
|
|
|
|
// ── Anbindung ─────────────────────────────────────────────────────────────
|
|
|
|
export function startMusik() {
|
|
// Damit ein Titelwechsel, ein Stopp oder ein Überspringen auch hier
|
|
// ankommt — ohne dass `radio.js` diese Datei kennen muss.
|
|
beiAenderung(musikTafelAktualisieren);
|
|
}
|
|
|
|
/** Wo darf gespielt werden: der eigene Kanal, sonst der laufende */
|
|
async function zielKanal(interaction) {
|
|
const eigener = interaction.member?.voice?.channel;
|
|
if (eigener) return eigener;
|
|
const jetzt = laeuft(interaction.guild?.id);
|
|
if (!jetzt) return null;
|
|
return interaction.client.channels.fetch(jetzt.channelId).catch(() => null);
|
|
}
|
|
|
|
const IDS = ['musik_add', 'musik_skip', 'musik_leeren', 'musik_heim', 'musik_refresh',
|
|
'musik_pause', 'musik_vor', 'musik_weg'];
|
|
|
|
/** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */
|
|
export async function handleMusikInteraction(interaction) {
|
|
const { customId } = interaction;
|
|
const istTreffer = customId.startsWith('musik_treffer:');
|
|
if (customId !== 'musik_modal' && !istTreffer && !IDS.includes(customId)) return false;
|
|
|
|
const guildId = interaction.guild?.id;
|
|
|
|
// Das Eingabefenster muss *vor* jedem Aufschub kommen: Discord lässt ein
|
|
// Modal nur als erste Antwort auf eine Interaktion zu.
|
|
if (customId === 'musik_add') {
|
|
await interaction.showModal(new ModalBuilder()
|
|
.setCustomId('musik_modal')
|
|
.setTitle('Einreihen')
|
|
.addComponents(new ActionRowBuilder().addComponents(
|
|
new TextInputBuilder()
|
|
.setCustomId('eingabe')
|
|
.setLabel('YouTube-Link oder Suchbegriff')
|
|
.setPlaceholder('https://youtu.be/… oder: nightwish sleeping sun')
|
|
.setStyle(TextInputStyle.Short)
|
|
.setMaxLength(300)
|
|
.setRequired(true)
|
|
)));
|
|
return true;
|
|
}
|
|
|
|
// Auflösen dauert länger als die drei Sekunden, die Discord gibt.
|
|
if (customId === 'musik_modal' || istTreffer) {
|
|
// Beim Eingabefenster gibt es noch keine Nachricht, also eine neue
|
|
// (flüchtige). Bei der Trefferauswahl gibt es sie — dann wird sie
|
|
// ersetzt, statt eine zweite daneben zu stellen und die Auswahl
|
|
// stehen zu lassen, als könnte man noch einmal wählen.
|
|
await (istTreffer
|
|
? interaction.deferUpdate()
|
|
: interaction.deferReply({ flags: MessageFlags.Ephemeral }));
|
|
const kanal = await zielKanal(interaction);
|
|
|
|
let eintraege = null;
|
|
if (istTreffer) {
|
|
const treffer = trefferHolen(customId.split(':')[1]);
|
|
if (!treffer) {
|
|
await interaction.editReply('❌ Die Auswahl ist abgelaufen — bitte neu suchen.');
|
|
return true;
|
|
}
|
|
const gewaehlt = treffer[Number(interaction.values[0])];
|
|
if (!gewaehlt) {
|
|
await interaction.editReply('❌ Diesen Treffer gibt es nicht mehr.');
|
|
return true;
|
|
}
|
|
eintraege = eintraegeAufnehmen(guildId, [gewaehlt],
|
|
interaction.member?.displayName ?? interaction.user?.username);
|
|
} else {
|
|
const eingabe = interaction.fields.getTextInputValue('eingabe');
|
|
const r = await einreihen(guildId, eingabe, {
|
|
wunschVon: interaction.member?.displayName ?? interaction.user?.username,
|
|
});
|
|
if (r.fehler) {
|
|
await interaction.editReply(`❌ ${r.fehler}`);
|
|
return true;
|
|
}
|
|
if (r.treffer) {
|
|
const token = interaction.id;
|
|
trefferMerken(token, r.treffer);
|
|
await interaction.editReply({
|
|
content: `🔎 ${r.treffer.length} Treffer:`,
|
|
components: [trefferAuswahl(token, r.treffer)],
|
|
});
|
|
return true;
|
|
}
|
|
eintraege = r.eingereiht;
|
|
}
|
|
|
|
const angeworfen = await ggfAnwerfen(interaction.client, guildId, kanal?.id);
|
|
await interaction.editReply({
|
|
content: eintraege.length === 1
|
|
? `✅ **${kurz(eintraege[0].titel, 120)}** ${angeworfen ? 'läuft jetzt' : 'eingereiht'}.`
|
|
: `✅ ${eintraege.length} Titel eingereiht${angeworfen ? ' — der erste läuft' : ''}.`
|
|
+ (kanal ? '' : '\n_Geh in einen Sprachkanal, damit es losgeht._'),
|
|
components: [],
|
|
});
|
|
// Hier zeichnet `tafelnMelden` beide Tafeln: die Antwort oben war
|
|
// flüchtig und ist keine von ihnen.
|
|
await tafelnMelden(interaction.client, guildId);
|
|
return true;
|
|
}
|
|
|
|
// Ab hier: alles, was die Tafel an Ort und Stelle neu zeichnet.
|
|
if (customId === 'musik_skip') {
|
|
const kanal = await zielKanal(interaction);
|
|
if (!kanal) {
|
|
await interaction.reply({
|
|
content: '❌ Geh erst in einen Sprachkanal.',
|
|
flags: MessageFlags.Ephemeral,
|
|
});
|
|
return true;
|
|
}
|
|
// Aufgeschoben, weil ein Quellenwechsel eine Verbindung aufbauen kann
|
|
// und das über den drei Sekunden liegt.
|
|
await interaction.deferUpdate();
|
|
await weiter(interaction.client, guildId, { erzwungen: true, kanalId: kanal.id });
|
|
} else if (customId === 'musik_pause') {
|
|
// Kein Aufschub nötig: Anhalten passiert im Speicher, ohne Netz.
|
|
pausieren(guildId);
|
|
await interaction.deferUpdate();
|
|
} else if (customId === 'musik_leeren') {
|
|
queueClear(guildId);
|
|
await interaction.deferUpdate();
|
|
} else if (customId === 'musik_heim') {
|
|
const jetzt = laeuft(guildId);
|
|
const heim = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null;
|
|
await interaction.deferUpdate();
|
|
if (heim) {
|
|
const kanal = await interaction.client.channels.fetch(jetzt.channelId).catch(() => null);
|
|
// Die Warteschlange bleibt stehen: „zum Radio" heisst „jetzt
|
|
// wieder Sender", nicht „alles wegwerfen".
|
|
if (kanal) await spielen(kanal, radioQuelle(heim));
|
|
}
|
|
} else if (customId === 'musik_vor') {
|
|
queueVor(guildId, Number(interaction.values[0]));
|
|
await interaction.deferUpdate();
|
|
} else if (customId === 'musik_weg') {
|
|
queueRemove(Number(interaction.values[0]));
|
|
await interaction.deferUpdate();
|
|
} else {
|
|
await interaction.deferUpdate(); // musik_refresh
|
|
}
|
|
|
|
await interaction.editReply({
|
|
embeds: [musikEmbed(interaction.client, guildId)],
|
|
components: musikKomponenten(guildId),
|
|
}).catch(() => {});
|
|
// Die Radio-Tafel zeigt dieselbe Wirklichkeit und muss mitgezogen werden.
|
|
// Nur die Radio-Tafel: die Musik-Tafel ist die Nachricht, auf der gerade
|
|
// geklickt wurde, und die ist eine Zeile weiter oben schon neu gezeichnet.
|
|
// `tafelnMelden` würde sie ein zweites Mal anfassen — eine Anfrage an
|
|
// Discord für nichts.
|
|
await tafelAktualisieren(interaction.client, guildId).catch(() => {});
|
|
return true;
|
|
}
|
|
|
|
/** Die Tafel im Kanal öffnen — von `/musik` aufgerufen */
|
|
export async function musikTafelOeffnen(interaction) {
|
|
if (!moduleEnabled('radio')) {
|
|
await interaction.reply({
|
|
content: '❌ Radio ist ausgeschaltet — im Panel unter **Community → Radio** einschalten.',
|
|
flags: MessageFlags.Ephemeral,
|
|
});
|
|
return;
|
|
}
|
|
await interaction.deferReply();
|
|
const guildId = interaction.guild.id;
|
|
const nachricht = await interaction.editReply({
|
|
embeds: [musikEmbed(interaction.client, guildId)],
|
|
components: musikKomponenten(guildId),
|
|
});
|
|
// Die alte Tafel verliert ihre Knöpfe nicht, aber ihren Anspruch: von hier
|
|
// an wird die neue nachgeführt. Zwei Tafeln, von denen eine einfriert,
|
|
// wären schlechter als eine, die umzieht.
|
|
setYtPanel(guildId, nachricht.channelId ?? interaction.channelId, nachricht.id);
|
|
}
|
|
|
|
// Nur zum Prüfen von aussen — die Merker-Verfallszeit ohne Wartezeit testbar.
|
|
export const _treffermerker = treffermerker;
|