Addresses review of the STREAM_BROWSER / broadcast-defaults work: - SelfbotStreamer now spawns broadcast-helper.mjs on stream start and kills it on stop/self-end (alongside capture + keepalive). The ad-skip, subtitle rule and fullscreen-toolbar-hide are therefore guaranteed broadcast-wide defaults tied to the broadcast - not a manual process. Fail-open: if node/Chrome deps are absent the stream runs without the helper. Verified the helper is a child of the broadcast holder and armed. - Enforce STREAM_BROWSER at the streamer (start() returns early when screenBrowser===false), so EVERY caller including stream-hold.ts is voice-only when it's off, not just the slash command. stream-hold.ts reads STREAM_BROWSER. - Fix broadcast-helper fullscreen: resolve the window of the tab actually in HTML5 fullscreen (via its CDP targetId) instead of the first HTTP tab, so the right Chrome window is toggled when multiple windows exist. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
137 lines
6.1 KiB
JavaScript
137 lines
6.1 KiB
JavaScript
// Persistent broadcast browser helper. Connects over CDP and injects one
|
|
// watcher into every tab (current and future) that:
|
|
// 1. Auto-skips YouTube ads - clicks "Skip ad" the instant it appears, closes
|
|
// overlay ads, and fast-forwards unskippable ads (seek-to-end + 16x + mute)
|
|
// so they clear in ~1s. The pre-ad muted/playbackRate are SAVED and
|
|
// RESTORED when the ad ends, so the main video is never left muted/fast.
|
|
// 2. Applies the subtitle rule per video: captions OFF by default, but a
|
|
// Korean track is turned ON when the video offers one. Runs once per video.
|
|
// Self-contained: no extension, no network/hosts changes. Reconnects across
|
|
// Chrome restarts.
|
|
//
|
|
// node bot/scripts/stream-test/broadcast-helper.mjs (CDP_PORT, default 9222)
|
|
import { chromium } from 'playwright';
|
|
|
|
const CDP = process.env.CDP_PORT || '9222';
|
|
|
|
const WATCH = `(() => {
|
|
if (window.__ytBroadcast) return; window.__ytBroadcast = true;
|
|
let adSaved = null; // {muted, rate} captured when an ad starts
|
|
const capWant = {}; // videoId -> 'ko' | 'off' (desired, decided once)
|
|
const capTries = {}; // videoId -> attempts to read the tracklist
|
|
|
|
const adTick = () => {
|
|
const p = document.getElementById('movie_player');
|
|
const adShowing = !!(p && p.classList && p.classList.contains('ad-showing'));
|
|
const v = document.querySelector('video');
|
|
const skip = document.querySelector(
|
|
'.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button, .ytp-ad-skip-button-container button');
|
|
if (skip) skip.click();
|
|
document.querySelectorAll('.ytp-ad-overlay-close-button, .ytp-ad-overlay-close-container button').forEach((b) => b.click());
|
|
if (adShowing) {
|
|
if (adSaved === null && v) adSaved = { muted: v.muted, rate: v.playbackRate };
|
|
if (v) {
|
|
v.muted = true;
|
|
if (isFinite(v.duration) && v.duration > 0) { try { v.currentTime = v.duration; } catch {} }
|
|
v.playbackRate = 16;
|
|
}
|
|
} else if (adSaved !== null && v) {
|
|
// ad finished: restore exactly what the user had before the ad
|
|
v.muted = adSaved.muted;
|
|
v.playbackRate = adSaved.rate;
|
|
adSaved = null;
|
|
}
|
|
return adShowing;
|
|
};
|
|
|
|
const capTick = (adShowing) => {
|
|
if (adShowing) return; // don't touch captions while an ad plays
|
|
const p = document.getElementById('movie_player');
|
|
if (!p || !p.getOption || !p.getVideoData) return;
|
|
const vid = p.getVideoData().video_id;
|
|
if (!vid) return;
|
|
// Decide the desired state once per video (off, or Korean if offered).
|
|
if (capWant[vid] === undefined) {
|
|
capTries[vid] = (capTries[vid] || 0) + 1;
|
|
let tracks = [];
|
|
try { p.loadModule('captions'); tracks = p.getOption('captions', 'tracklist') || []; } catch {}
|
|
if (tracks.length) capWant[vid] = tracks.find((t) => /^ko/i.test(t.languageCode || '')) ? 'ko' : 'off';
|
|
else if (capTries[vid] > 16) capWant[vid] = 'off'; // no tracks: keep it off
|
|
else return; // tracklist not ready yet
|
|
}
|
|
// Enforce it every tick so YouTube cannot silently re-enable captions.
|
|
let curLc = '';
|
|
try { const c = p.getOption('captions', 'track'); curLc = (c && c.languageCode) || ''; } catch {}
|
|
if (capWant[vid] === 'ko') {
|
|
if (!/^ko/i.test(curLc)) {
|
|
let tracks = []; try { tracks = p.getOption('captions', 'tracklist') || []; } catch {}
|
|
const ko = tracks.find((t) => /^ko/i.test(t.languageCode || ''));
|
|
if (ko) { try { p.setOption('captions', 'track', { languageCode: ko.languageCode }); } catch {} }
|
|
}
|
|
} else if (curLc) { // want off but a track is on -> turn it off
|
|
try { p.setOption('captions', 'track', {}); } catch {}
|
|
try { p.unloadModule('captions'); } catch {}
|
|
}
|
|
};
|
|
|
|
setInterval(() => {
|
|
let adShowing = false;
|
|
try { adShowing = adTick(); } catch {}
|
|
try { capTick(adShowing); } catch {}
|
|
}, 250);
|
|
})();`;
|
|
|
|
async function arm(page) {
|
|
try { await page.addInitScript(WATCH); } catch {} // survives navigations
|
|
try { await page.evaluate(WATCH); } catch {} // arm the already-loaded doc
|
|
}
|
|
|
|
async function session() {
|
|
const b = await chromium.connectOverCDP(`http://localhost:${CDP}`);
|
|
const ctx = b.contexts()[0];
|
|
for (const p of ctx.pages()) await arm(p);
|
|
ctx.on('page', arm); // new tabs
|
|
|
|
// 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();
|
|
let fsWindowId = null;
|
|
const windowIdFor = async (page) => {
|
|
const s = await page.context().newCDPSession(page);
|
|
try {
|
|
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 fsPage = null;
|
|
for (const p of ctx.pages()) {
|
|
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;
|
|
}
|
|
} catch { /* best-effort */ }
|
|
}, 600);
|
|
|
|
console.log('broadcast-helper armed on', ctx.pages().length, 'tab(s)');
|
|
await new Promise((resolve) => b.on('disconnected', resolve));
|
|
clearInterval(fsTimer);
|
|
}
|
|
|
|
// Reconnect across Chrome restarts so the broadcast stays ad-free.
|
|
while (true) {
|
|
try { await session(); } catch { /* CDP down */ }
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
}
|