diff --git a/bot/scripts/stream-test/broadcast-helper.mjs b/bot/scripts/stream-test/broadcast-helper.mjs index 1312176..86c52ab 100644 --- a/bot/scripts/stream-test/broadcast-helper.mjs +++ b/bot/scripts/stream-test/broadcast-helper.mjs @@ -92,30 +92,35 @@ async function session() { for (const p of ctx.pages()) await arm(p); ctx.on('page', arm); // new tabs - // Broadcast-wide: when any tab enters HTML5 fullscreen (a video 'f'), hide - // Chrome's toolbar by putting the WINDOW into Chrome-initiated fullscreen - - // xfwm4 won't hide it on HTML5 fullscreen alone, so the address bar would - // otherwise show on the broadcast. Restore when fullscreen exits. - let winFs = false; + // Broadcast-wide: when a tab enters HTML5 fullscreen (a video 'f'), hide + // Chrome's toolbar by putting THAT tab's window into Chrome-initiated + // fullscreen - xfwm4 won't hide it on HTML5 fullscreen alone, so the address + // bar would otherwise show on the broadcast. We resolve the exact window of + // the fullscreen tab (not just the first tab) and restore it on exit. const cdp = await b.newBrowserCDPSession(); - const setWindowFs = async (fs) => { + let fsWindowId = null; + const windowIdFor = async (page) => { + const s = await page.context().newCDPSession(page); try { - const { targetInfos } = await cdp.send('Target.getTargets'); - const t = targetInfos.find((x) => x.type === 'page' && x.url.startsWith('http')); - if (!t) return; - const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId: t.targetId }); - await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: fs ? 'fullscreen' : 'normal' } }); - winFs = fs; - } catch { /* best-effort */ } + const { targetInfo } = await s.send('Target.getTargetInfo'); + const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId: targetInfo.targetId }); + return windowId; + } finally { await s.detach().catch(() => {}); } }; const fsTimer = setInterval(async () => { try { - let anyFs = false; + let fsPage = null; for (const p of ctx.pages()) { - if (await p.evaluate(() => !!document.fullscreenElement).catch(() => false)) { anyFs = true; break; } + if (await p.evaluate(() => !!document.fullscreenElement).catch(() => false)) { fsPage = p; break; } + } + if (fsPage && fsWindowId === null) { + const windowId = await windowIdFor(fsPage); + await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'fullscreen' } }); + fsWindowId = windowId; + } else if (!fsPage && fsWindowId !== null) { + await cdp.send('Browser.setWindowBounds', { windowId: fsWindowId, bounds: { windowState: 'normal' } }); + fsWindowId = null; } - if (anyFs && !winFs) await setWindowFs(true); - else if (!anyFs && winFs) await setWindowFs(false); } catch { /* best-effort */ } }, 600); diff --git a/bot/scripts/stream-test/stream-hold.ts b/bot/scripts/stream-test/stream-hold.ts index 27298eb..6263e74 100644 --- a/bot/scripts/stream-test/stream-hold.ts +++ b/bot/scripts/stream-test/stream-hold.ts @@ -20,6 +20,7 @@ const config = { streamHw: (process.env.STREAM_HW ?? "1") !== "0", streamAudio: (process.env.STREAM_AUDIO ?? "1") !== "0", streamAudioSource: process.env.STREAM_AUDIO_SOURCE ?? "@DEFAULT_MONITOR@", + screenBrowser: (process.env.STREAM_BROWSER ?? "true") !== "false", } as any; const guildId = process.env.DISCORD_GUILD_ID; diff --git a/bot/src/stream/selfbot.ts b/bot/src/stream/selfbot.ts index 51822bb..24a0ec3 100644 --- a/bot/src/stream/selfbot.ts +++ b/bot/src/stream/selfbot.ts @@ -26,6 +26,7 @@ export class SelfbotStreamer implements ScreenStreamer { private streamer: any = null; private capture: ChildProcess | null = null; private keepalive: VncKeepalive | null = null; + private helper: ChildProcess | null = null; private controller: AbortController | null = null; private active = false; @@ -79,6 +80,12 @@ export class SelfbotStreamer implements ScreenStreamer { async start(ctx: StreamContext): Promise { if (this.active) return "이미 송출 중입니다."; + // Screen-share gate: STREAM_BROWSER=false means voice + API/MCP only, so we + // never go Live. Enforced HERE (not just in the slash command) so every + // caller - including stream-hold.ts - respects it. + if (this.config.screenBrowser === false) { + return "화면 공유(브라우저) 모드가 꺼져 있습니다 (STREAM_BROWSER=false). 음성 + API/MCP 모드로만 동작합니다."; + } if (!this.config.selfbotToken) { return "DISCORD_SELFBOT_TOKEN이 설정되지 않았습니다 (.env). 버너 계정 토큰을 넣어주세요."; } @@ -97,6 +104,7 @@ export class SelfbotStreamer implements ScreenStreamer { let streamer: any = null; let capture: ChildProcess | null = null; let keepalive: VncKeepalive | null = null; + let helper: ChildProcess | null = null; try { const { selfbot, vs } = await this.loadLib(); @@ -191,6 +199,23 @@ export class SelfbotStreamer implements ScreenStreamer { keepalive.start(); } + // Browser-side broadcast defaults (ad-skip, subtitle rule, fullscreen + // toolbar hiding) run in a small CDP helper tied to the stream lifecycle. + // It attaches to the on-screen Chrome (CDP_PORT) and fail-opens if Chrome + // or its deps are absent, so it can never break the stream. + try { + const helperPath = new URL("../../scripts/stream-test/broadcast-helper.mjs", import.meta.url).pathname; + helper = this.helper = spawn("node", [helperPath], { + env: { ...process.env, CDP_PORT: process.env.CDP_PORT ?? "9222" }, + stdio: "ignore", + }); + helper.on("error", () => { + /* node/playwright missing: the stream runs without the helper */ + }); + } catch { + /* ignore */ + } + const { command, output } = prepareStream( capture.stdout, { @@ -234,6 +259,11 @@ export class SelfbotStreamer implements ScreenStreamer { } catch { /* ignore */ } + try { + helper?.kill(); + } catch { + /* ignore */ + } try { streamer?.leaveVoice?.(); streamer?.client?.destroy?.(); @@ -242,6 +272,7 @@ export class SelfbotStreamer implements ScreenStreamer { } if (this.capture === capture) this.capture = null; if (this.keepalive === keepalive) this.keepalive = null; + if (this.helper === helper) this.helper = null; if (this.streamer === streamer) this.streamer = null; this.controller = null; this.active = false; @@ -262,6 +293,11 @@ export class SelfbotStreamer implements ScreenStreamer { } catch { /* ignore */ } + try { + helper?.kill(); + } catch { + /* ignore */ + } try { streamer?.leaveVoice?.(); streamer?.client?.destroy?.(); @@ -275,6 +311,7 @@ export class SelfbotStreamer implements ScreenStreamer { if (this.controller === controller) { if (this.capture === capture) this.capture = null; if (this.keepalive === keepalive) this.keepalive = null; + if (this.helper === helper) this.helper = null; if (this.streamer === streamer) this.streamer = null; this.controller = null; this.active = false; @@ -299,6 +336,12 @@ export class SelfbotStreamer implements ScreenStreamer { /* ignore */ } this.keepalive = null; + try { + this.helper?.kill(); + } catch { + /* ignore */ + } + this.helper = null; try { this.streamer?.leaveVoice?.(); this.streamer?.client?.destroy?.();