diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index ca9fd88..762cd4d 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -2224,6 +2224,7 @@ export default function Settings({ me }) {
+ {radio.laeuft.pausiert && '⏸️ '}
{radio.laeuft.art === 'youtube'
? radio.laeuft.titel
: radio.laeuft.sender}
@@ -2321,6 +2322,14 @@ export default function Settings({ me }) {
{x.emoji || '📻'} {x.name}
))}
+ {/* Pause gibt es nur bei YouTube: ein Dauerstrom laeuft
+ beim Sender weiter, waehrend hier die Leitung volllaeuft --
+ beim Fortsetzen kaeme eine Konserve, die hinterherhinkt. */}
+ radioSteuern('pause')}>
+ {radio.laeuft?.pausiert ? '▶️ Weiter' : '⏸️ Pause'}
+
radioSteuern('skip')}>
⏭️ Überspringen
diff --git a/src/bot/radio-musik.js b/src/bot/radio-musik.js
index d54614c..33a6a2e 100644
--- a/src/bot/radio-musik.js
+++ b/src/bot/radio-musik.js
@@ -28,6 +28,7 @@ import {
} from '../db.js';
import {
laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle,
+ pausieren, verstrichen,
} from './radio.js';
import {
aufloesen, ytdlpVorhanden, letzterFehler, TREFFER,
@@ -95,9 +96,10 @@ export function musikEmbed(client, guildId) {
if (jetzt?.quelle?.art === 'youtube') {
const t = jetzt.quelle.titel;
- const verstrichen = Math.floor((Date.now() - jetzt.seit) / 1000);
- const balken = fortschritt(verstrichen, t.dauer);
- embed.setDescription(`**${kurz(t.titel, 200)}**\n`
+ // 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}>`);
@@ -149,7 +151,7 @@ export function musikKomponenten(guildId) {
const reihen = [];
if (liste.length > 0) {
- const optionen = (praefix) => liste.map((e, i) => {
+ const optionen = () => liste.map((e, i) => {
const o = new StringSelectMenuOptionBuilder()
.setLabel(kurz(`${i + 1}. ${e.titel}`, 100))
.setValue(String(e.id));
@@ -171,13 +173,30 @@ export function musikKomponenten(guildId) {
// 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_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
- nichtsDa && jetzt?.quelle?.art !== 'youtube'),
knopf('musik_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa),
- knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
- !jetzt || jetzt.quelle?.art !== 'youtube' || !jetzt.heimStationId),
knopf('musik_refresh', 'Aktualisieren', '🔄'),
));
return reihen;
@@ -336,7 +355,7 @@ async function zielKanal(interaction) {
}
const IDS = ['musik_add', 'musik_skip', 'musik_leeren', 'musik_heim', 'musik_refresh',
- 'musik_vor', 'musik_weg'];
+ 'musik_pause', 'musik_vor', 'musik_weg'];
/** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */
export async function handleMusikInteraction(interaction) {
@@ -438,6 +457,10 @@ export async function handleMusikInteraction(interaction) {
// 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();
diff --git a/src/bot/radio.js b/src/bot/radio.js
index 9614939..252df21 100644
--- a/src/bot/radio.js
+++ b/src/bot/radio.js
@@ -503,17 +503,80 @@ export function lautstaerkeSetzen(guildId, wunsch) {
return wert;
}
+/**
+ * Wie lange läuft der aktuelle Titel schon — Pausen abgezogen.
+ *
+ * An einer Stelle, weil drei Stellen es brauchen: die Musik-Tafel, das Panel
+ * und der Fortschrittsbalken. Wer das je einzeln rechnet, vergisst an einer
+ * davon die Pause, und dann behauptet ein Balken, ein angehaltener Titel
+ * liefe weiter.
+ *
+ * @returns {number} Sekunden
+ */
+export function verstrichen(eintrag) {
+ if (!eintrag) return 0;
+ const offen = eintrag.pausiertSeit ? Date.now() - eintrag.pausiertSeit : 0;
+ const netto = Date.now() - eintrag.seit - (eintrag.pauseGesamt ?? 0) - offen;
+ return Math.max(0, Math.floor(netto / 1000));
+}
+
+/** Läuft gerade etwas, das angehalten ist? */
+export const istPausiert = (guildId) => Boolean(laufend.get(guildId)?.pausiertSeit);
+
+/**
+ * Anhalten und weiterlaufen lassen.
+ *
+ * **Nur für YouTube, und das ist kein Vergessen.** Ein Sender ist ein
+ * Dauerstrom: hält man ihn an, läuft er beim Sender weiter, während sich
+ * hier die Leitung füllt. Beim Fortsetzen käme dann nicht „dort, wo es
+ * aufhörte", sondern eine Konserve, die immer weiter hinterherhinkt. Für
+ * einen Sender heisst Pause deshalb Stopp — und dafür gibt es den
+ * Stopp-Knopf.
+ *
+ * @param {string} guildId
+ * @param {boolean|null} wunsch true/false, oder null zum Umschalten
+ * @returns {boolean|null} der neue Zustand, oder null wenn nicht möglich
+ */
+export function pausieren(guildId, wunsch = null) {
+ const s = laufend.get(guildId);
+ if (!s || s.quelle?.art !== 'youtube') return null;
+ const soll = wunsch === null ? !s.pausiertSeit : Boolean(wunsch);
+ if (soll === Boolean(s.pausiertSeit)) return soll;
+ if (soll) {
+ // `true` schiebt Stille nach, statt die Verbindung stumm hängen zu
+ // lassen — sonst hält Discord den Sprecher für abgestürzt.
+ s.player.pause(true);
+ s.pausiertSeit = Date.now();
+ } else {
+ s.pauseGesamt = (s.pauseGesamt ?? 0) + (Date.now() - s.pausiertSeit);
+ s.pausiertSeit = null;
+ s.player.unpause();
+ }
+ return soll;
+}
+
/** Alles abräumen — Verbindung, Spieler, ffmpeg */
export function stoppen(guildId) {
const s = laufend.get(guildId);
let abgeraeumt;
if (s) {
- s.player.stop(true);
- ketteAbraeumen(s);
+ // Reihenfolge ist hier alles: **erst aus der Karte, dann anhalten.**
+ //
+ // `player.stop()` meldet den Leerlauf SYNCHRON — gemessen am
+ // 28.08.2026 mit @discordjs/voice 0.19: der Idle-Melder läuft
+ // mitten in `stop()`, noch bevor die nächste Zeile dran ist. Stand
+ // `laufend.delete` danach, fand der Melder den Eintrag noch, hielt
+ // das Ende für „Titel vorbei" und schaltete weiter — auf den
+ // nächsten Titel oder zurück auf den Heimsender.
+ //
+ // Von aussen sah das so aus: der letzte verlässt den Kanal, der Bot
+ // geht raus und steht sofort wieder drin. Genau so gemeldet.
laufend.delete(guildId);
// Sonst zählt der nächste Start ab dem alten Stand weiter und bricht
// womöglich sofort wieder ab.
fehlschlaege.delete(guildId);
+ s.player.stop(true);
+ ketteAbraeumen(s);
// Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es
// liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab.
//
@@ -675,6 +738,11 @@ export async function spielen(kanal, quelle) {
// die Vorgabe ist leise, damit niemand erschrickt.
resource.volume?.setVolume(radioLautstaerke() / 100);
player.play(resource);
+ // Der Spieler wird über Quellenwechsel hinweg wiederverwendet. Stand er
+ // beim Umschalten auf Pause, bliebe der neue Titel stumm stehen — und der
+ // neue Eintrag sagt „läuft". Zwei Aussagen, eine Wirklichkeit: hier wird
+ // sie geradegezogen.
+ player.unpause();
verbindung.subscribe(player);
// Faengt der Ton wirklich an? Wenn ffmpeg fehlt oder der Sender nichts
@@ -733,6 +801,10 @@ export async function spielen(kanal, quelle) {
heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null),
titel: quelle.art === 'youtube' ? quelle.titel.titel : null,
seit: Date.now(), tonLaeuft: null, client: kanal.client,
+ // Angehalten seit wann, und wie viel Pause insgesamt schon drin war.
+ // Ohne das zweite liefe der Fortschrittsbalken nach dem Fortsetzen
+ // um die Pausenlänge voraus.
+ pausiertSeit: null, pauseGesamt: 0,
};
laufend.set(guildId, eintrag);
// Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen
diff --git a/src/web/api.js b/src/web/api.js
index f01799b..a008896 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -40,7 +40,7 @@ import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../
import { istLsServer, modBasis } from '../bot/ls-farm.js';
import {
titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle,
- lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken,
+ lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, pausieren, verstrichen,
} from '../bot/radio.js';
import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js';
import {
@@ -469,8 +469,10 @@ export function registerApiRoutes(app, client) {
kanal: t?.kanal ?? null,
// Sekunden statt eines Zeitstempels: der Browser muesste sonst
// gegen die Uhr des Servers rechnen, und die beiden gehen selten
- // gleich.
- verstrichen: Math.floor((Date.now() - jetzt.seit) / 1000),
+ // gleich. `verstrichen()` zieht Pausen ab -- dieselbe Rechnung
+ // wie in der Discord-Tafel, nicht eine zweite daneben.
+ verstrichen: verstrichen(jetzt),
+ pausiert: Boolean(jetzt.pausiertSeit),
dauer: t ? t.dauer : null,
kanalId: jetzt.channelId,
kanalName: kanal?.name ?? null,
@@ -540,6 +542,17 @@ export function registerApiRoutes(app, client) {
// das am Ergebnis sehen.
await tafelnMelden(client, guildId);
return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) };
+ } else if (aktion === 'pause') {
+ // Gibt null zurueck, wenn nichts oder ein Sender laeuft --
+ // dann ist Pause nicht vorgesehen, und das wird gesagt statt
+ // still nichts zu tun.
+ const neu = pausieren(guildId, b.wert === undefined ? null : Boolean(b.wert));
+ if (neu === null) {
+ return reply.code(400).send({
+ error: 'Pause gibt es nur bei YouTube -- ein Sender laesst sich nicht anhalten, '
+ + 'nur stoppen.',
+ });
+ }
} else if (aktion === 'skip') {
const kanalId = zielId();
if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' });