From 1e4ccfb1f1fab1a5d98d02d7b7b16ae7edc88719 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 10:21:11 +0100 Subject: [PATCH] Fix: Voice claim system - radio stops when soundboard plays Plugins now claim voice per guild via claimVoice(). When soundboard plays a sound, radio's cleanup runs automatically (kills ffmpeg, broadcasts SSE stop event). Fixes stale "now playing" UI on tab switch. Co-Authored-By: Claude Opus 4.6 --- server/src/core/plugin.ts | 22 ++++++++++++++++++++++ server/src/plugins/radio/index.ts | 5 +++++ server/src/plugins/soundboard/index.ts | 3 +++ 3 files changed, 30 insertions(+) diff --git a/server/src/core/plugin.ts b/server/src/core/plugin.ts index fb41d8c..a98936e 100644 --- a/server/src/core/plugin.ts +++ b/server/src/core/plugin.ts @@ -39,3 +39,25 @@ export function registerPlugin(plugin: Plugin): void { export function getPlugins(): Plugin[] { return [...loadedPlugins]; } + +// ── Voice claim system ── +// Only one plugin can use voice per guild. When a new plugin claims voice, +// the previous claimant's cleanup callback is invoked automatically. +type VoiceClaimCleanup = () => void; +const voiceClaims = new Map(); + +export function claimVoice(guildId: string, pluginName: string, cleanup: VoiceClaimCleanup): void { + const existing = voiceClaims.get(guildId); + if (existing && existing.plugin !== pluginName) { + console.log(`[Voice] ${pluginName} claims guild ${guildId}, releasing ${existing.plugin}`); + existing.cleanup(); + } + voiceClaims.set(guildId, { plugin: pluginName, cleanup }); +} + +export function releaseVoice(guildId: string, pluginName: string): void { + const claim = voiceClaims.get(guildId); + if (claim?.plugin === pluginName) { + voiceClaims.delete(guildId); + } +} diff --git a/server/src/plugins/radio/index.ts b/server/src/plugins/radio/index.ts index 5a64edd..bad1fa0 100644 --- a/server/src/plugins/radio/index.ts +++ b/server/src/plugins/radio/index.ts @@ -7,6 +7,7 @@ import { import type { VoiceBasedChannel } from 'discord.js'; import { ChannelType } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; +import { claimVoice, releaseVoice } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; import { getState, setState } from '../../core/persistence.js'; import { @@ -68,6 +69,7 @@ function stopStream(guildId: string): void { try { state.player.stop(true); } catch {} try { getVoiceConnection(guildId)?.destroy(); } catch {} guildRadioState.delete(guildId); + releaseVoice(guildId, 'radio'); broadcastState(guildId); console.log(`[Radio] Stopped stream in guild ${guildId}`); } @@ -148,6 +150,9 @@ async function startStream( stopStream(guildId); }); + // Claim voice for this guild (stops other plugins like soundboard) + claimVoice(guildId, 'radio', () => stopStream(guildId)); + // State tracken const channelName = 'name' in channel ? (channel as any).name : voiceChannelId; guildRadioState.set(guildId, { diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index b11657f..75bb3c3 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -16,6 +16,7 @@ import sodium from 'libsodium-wrappers'; import nacl from 'tweetnacl'; import { ChannelType, Events, type VoiceState, type Message } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; +import { claimVoice } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; // ── Config (env) ── @@ -332,6 +333,8 @@ let _pluginCtx: PluginContext | null = null; async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`); + // Claim voice for this guild (stops radio if playing) + claimVoice(guildId, 'soundboard', () => { /* soundboard cleanup handled by lifecycle */ }); const ctx = _pluginCtx!; const guild = ctx.client.guilds.cache.get(guildId); if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); }