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 = {