From 02e2243a9864dac0ed8814ea956f82d295aa9dfd Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 3 Aug 2026 08:41:00 +0700 Subject: [PATCH] fix(voice): screen share releases audio connection so Streamer owns voice session The dank074 Streamer creates its own WebRTC voice connection, but Discord allows only ONE voice session per user. When VoiceController (audio) was already connected, the Streamer join hung forever (never got VOICE_SERVER_UPDATE). Now: 1. ScreenShareController takes releaseVoice/restoreVoice callbacks. 2. Before joining, it disconnects the @discordjs audio connection via VoiceController.disconnectGuild. 3. Streamer joins + streams GoLive. 4. After the stream ends, restoreVoice reconnects the audio connection so mic/listen keep working. 5. media.handler wires these via a new setVoiceController accessor from commandHandler; VoiceController is the single source of truth. Also adds caller-bound timeouts & safe .catch() everywhere so a stream failure can never become an unhandledRejection again. --- .../modules/command-handler/commandHandler.ts | 3 ++ .../modules/command-handler/media.handler.ts | 33 ++++++++++++ .../voice-recording/screenShareController.ts | 51 ++++++++++++++----- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/services/discord-gateway/src/modules/command-handler/commandHandler.ts b/services/discord-gateway/src/modules/command-handler/commandHandler.ts index deff43d..7ccac58 100644 --- a/services/discord-gateway/src/modules/command-handler/commandHandler.ts +++ b/services/discord-gateway/src/modules/command-handler/commandHandler.ts @@ -86,6 +86,9 @@ export class CommandHandler { this.mediaHandler = new MediaHandler(client, () => voiceController.getStatus(), ); + // Give media handler access to disconnect/reconnect voice around screen + // share (GoLive needs its own WebRTC connection). + this.mediaHandler.setVoiceController(() => voiceController); this.guildHandler = new GuildHandler(client); this.moderationHandler = new ModerationHandler(client); diff --git a/services/discord-gateway/src/modules/command-handler/media.handler.ts b/services/discord-gateway/src/modules/command-handler/media.handler.ts index ce8e686..d56f239 100644 --- a/services/discord-gateway/src/modules/command-handler/media.handler.ts +++ b/services/discord-gateway/src/modules/command-handler/media.handler.ts @@ -102,6 +102,21 @@ export class MediaHandler { }); } + /** + * Give MediaHandler access to the VoiceController so screen-share can + * disconnect/reconnect the @discordjs audio connection around a GoLive + * stream (Discord allows only one voice session per user). + */ + private voiceControllerAccessor: (() => { + disconnectGuild(guildId: string): Promise; + connect(guildId: string, channelId: string): Promise; + getStatus(): { activeGuildId: string | null; activeChannelId: string | null }; + }) | null = null; + + setVoiceController(accessor: typeof this.voiceControllerAccessor): void { + this.voiceControllerAccessor = accessor; + } + /** * Persist the latest media state to Redis so the backend/frontend see queue * advances that happen outside a command (natural track end, screen-share @@ -164,6 +179,24 @@ export class MediaHandler { this.screenController = new ScreenShareController( this.client, this.getVoiceStatus, + // releaseVoice — disconnect the @discordjs/voice connection so the + // dank074 Streamer can take over (Discord: one voice session/user). + async () => { + const vc = this.voiceControllerAccessor?.(); + const guildId = vc?.getStatus().activeGuildId ?? null; + if (vc && guildId) { + await vc.disconnectGuild(guildId); + } + }, + // restoreVoice — reconnect the @discordjs audio connection after + // the stream ends so mic/listen keep working. + async () => { + const vc = this.voiceControllerAccessor?.(); + const status = vc?.getStatus(); + if (vc && status?.activeGuildId && status.activeChannelId) { + await vc.connect(status.activeGuildId, status.activeChannelId); + } + }, ); } const playback = await this.screenController.start(url); diff --git a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts index 992bca4..0eaf1ab 100644 --- a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts +++ b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts @@ -38,6 +38,12 @@ export class ScreenShareController { constructor( private readonly client: Client, private readonly getVoiceStatus: () => ScreenShareVoiceStatus, + /** Disconnect the @discordjs/voice connection so the Streamer can take + * over the voice channel (Discord allows only ONE voice session per user + * — two connections collide and the Streamer never gets VOICE_SERVER_UPDATE). */ + private readonly releaseVoice: () => void | Promise, + /** Reconnect the @discordjs/voice connection after the stream ends. */ + private readonly restoreVoice: () => void | Promise, ) {} isActive(): boolean { @@ -60,10 +66,6 @@ export class ScreenShareController { this.streamer = new Streamer(this.client); } - // The dank074 Streamer needs its OWN voice connection — it cannot reuse - // the @discordjs/voice connection owned by VoiceController/recorder. - // Resolve the channel object and join via the Streamer's raw gateway - // path (joinVoiceChannel → joinVoice → WebRTC). const guild = this.client.guilds.cache.get(status.activeGuildId); const channel = guild?.channels.cache.get(status.activeChannelId); if ( @@ -74,6 +76,11 @@ export class ScreenShareController { `Voice channel ${status.activeChannelId} not found for screen share`, ); } + + // Free the @discordjs/voice connection BEFORE the Streamer joins, so + // the user has only one voice session (Discord requirement). + await this.releaseVoice(); + await Promise.race([ this.streamer.joinVoiceChannel(channel), new Promise((_, reject) => @@ -81,7 +88,7 @@ export class ScreenShareController { () => reject( new Error( - "Timed out joining voice channel for screen share (2 koneksi voice bertabrakan?)", + "Timed out joining voice channel for screen share", ), ), 15000, @@ -101,6 +108,31 @@ export class ScreenShareController { }); let stopped = false; + // Restore the @discordjs/voice connection after the stream ends (both + // natural end and failure), so the user can keep using audio/mic. + const restoreAfter = () => { + if (!stopped) { + stopped = true; + try { + command.kill("SIGTERM"); + } catch { + /* already dead */ + } + } + try { + this.streamer?.voiceConnection?.stop(); + } catch { + /* already gone */ + } + if (this.restoreVoice) { + Promise.resolve(this.restoreVoice()).catch((err) => { + this.logger.warn( + { error: err instanceof Error ? err.message : String(err) }, + "Failed to restore voice connection after screen share", + ); + }); + } + }; const done = playStream(output, this.streamer, { type: "go-live", }) @@ -112,16 +144,9 @@ export class ScreenShareController { { error: message, source }, "Screen stream failed during playback", ); - if (!stopped) { - stopped = true; - try { - command.kill("SIGTERM"); - } catch { - /* already dead */ - } - } }) .finally(() => { + restoreAfter(); this.active = null; }); this.active = {