Files
javis_bot/bot/scripts/stream-test/broadcast-helper.mjs
javis-bot ef6f6ff57d feat(stream): STREAM_BROWSER flag + make toolbar-hide/subtitles broadcast-wide
- Add STREAM_BROWSER (.env) gating screen-share/browser mode. false => the
  /자비스 stream command stays voice + API/MCP only (no Go-Live); true (default)
  => screen share as before. (Browser-driven info retrieval in true mode is a
  follow-up build; the bot has no browser-control tools yet.)
- Make the two test-time fixes broadcast-wide defaults via broadcast-helper.mjs:
  it now also watches every tab for HTML5 fullscreen and toggles Chrome window
  fullscreen so the address bar is hidden for ANY video (xfwm4 won't hide it on
  'f' alone), restoring on exit. Subtitles were already enforced per video.
  scenario.mjs drops its own fullscreen toggle and relies on the helper.
- Revert the test-settings env vars from .env.example (not wanted).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 16:17:29 +09:00

132 lines
5.9 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 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;
const cdp = await b.newBrowserCDPSession();
const setWindowFs = async (fs) => {
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 fsTimer = setInterval(async () => {
try {
let anyFs = false;
for (const p of ctx.pages()) {
if (await p.evaluate(() => !!document.fullscreenElement).catch(() => false)) { anyFs = true; break; }
}
if (anyFs && !winFs) await setWindowFs(true);
else if (!anyFs && winFs) await setWindowFs(false);
} 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));
}