openvibers/openvibe.live Commits
Robot controls over the video or under it, per viewer (#773)
Changes
nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- A channel bound to an OpenVibe.Bot robot carries the streamer's default place for the robot's controls:++-- 'overlay' puts them over the video, 'panel' under it (the previous behaviour). A viewer may choose either,++-- or hide them, for themselves; this is only the default until they do.++ALTER TABLE channels ADD COLUMN bot_controls_mode text COLLATE "C" DEFAULT 'panel';++ALTER TABLE channels ADD CONSTRAINT channels_bot_controls_mode CHECK (bot_controls_mode IS NULL OR bot_controls_mode IN ('overlay', 'panel'));@@@ -12,6 +12,7 @@"test:analytics-pg": "LIVE_TEST_STORE=pg node test/analytics-store.test.js && LIVE_TEST_STORE=pg node test/analytics-privacy.test.js","test:powerchat": "for f in test/powerchat-*.test.js; do node \"$f\" || exit 1; done","test:browser": "node test/browser/smoke.js",++ "test:bot-overlay": "node test/browser/bot-overlay.js","perf:page": "node scripts/perf/measure-page.js","perf:budget": "node scripts/perf/check-budgets.js","n-1:record": "node scripts/n-1-record.js"@@@ -2039,6 +2039,37 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var/* ── The robot's panel (OpenVibe.Bot) ──────────────────────────────────────────────────────────────────────────────It was a bare frame at the browser's default 300 by 150 with scrollbars, and a black band beside it (the owner,11-10: "all this broken space"). A card under the player, as wide as the column; its height can be pulled taller. */++/* The controls over the picture: a layer above the video and below the player's own bar (z-index 5), unmute++ overlay (20) and floating chat button. Its bottom leaves the bar's height out; bot-embed.js measures it into++ --ch-bot-bar-h. The slot itself takes no press: the iframe is clipped to the panel's control rectangles, so a++ press anywhere else reaches the video. */++.ch-bot-overlay-slot { position: absolute; left: 0; right: 0; top: 0; bottom: var(--ch-bot-bar-h, 64px); z-index: 4; pointer-events: none; }++.ch-bot-overlay-slot[hidden] { display: none; }++.ch-bot-overlay-slot iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; pointer-events: auto; }++/* The player's small switch: one 40 px robot button that opens the three choices, so the bar keeps its room on a++ phone (the old select was wide enough to push the clip button out at 360 px). */++.ch-bot-mode-inline { position: relative; display: inline-flex; align-items: center; }++.ch-bot-mode-inline[hidden] { display: none; }++.ch-bot-mode-inline #ch-bot-mode-btn { width: 40px; height: 40px; min-width: 40px; min-height: 40px; padding: 0; }++.ch-bot-mode-inline #ch-bot-mode-btn[data-mode="hidden"] { opacity: .55; }++.ch-bot-mode-menu {++ position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 30; min-width: 158px; padding: 4px;++ display: grid; gap: 2px; border-radius: 10px; border: 1px solid rgba(255,255,255,.16);++ background: rgba(12,15,24,.96); box-shadow: 0 8px 24px rgba(0,0,0,.45);++}++.ch-bot-mode-menu[hidden] { display: none; }++.ch-bot-mode-menu button { min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: #fff; text-align: left; font-size: .8rem; }++.ch-bot-mode-menu button[aria-checked="true"] { background: color-mix(in srgb, var(--accent) 35%, transparent); }++/* The card's switch and its one-line state when the controls are on the video. */++.ch-bot-card-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid var(--border); }++.ch-bot-card-bar[hidden] { display: none; }++.ch-bot-card-label { color: var(--text-muted, var(--text-secondary)); font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }++.ch-bot-switch { display: inline-flex; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }++.ch-bot-switch button { min-height: 30px; padding: 0 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }++.ch-bot-switch button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent-strong, #fff); }++.ch-bot-card-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; color: var(--text-secondary); font-size: .9rem; }++.ch-bot-card-line[hidden] { display: none; }++.ch-bot-card-line button { min-height: 36px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: transparent; color: var(--text-primary); font-size: .85rem; font-weight: 600; }#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }#ch-bot-section > h3 { display: flex; align-items: center; gap: 9px; margin: 0; padding: 10px 14px; font-size: .9rem; font-weight: 700; color: var(--text-primary); border-bottom: 1px solid var(--border); }#ch-bot-section > h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }@@@ -11,7 +11,7 @@<section class="dash-card" id="ch-bench-stream" aria-label="Bench vote" hidden></section><div class="stream-layout"><div class="stream-main">-- <div class="video-container" id="video-container">++ <div class="video-container" id="video-container" tabindex="-1"><div class="video-placeholder"><i class="fa-solid fa-satellite-dish fa-3x"></i><p>Connecting to stream...</p>@@@ -23,6 +23,9 @@</div><canvas id="video-canvas"></canvas><video id="video-element" autoplay playsinline></video>++ <!-- OpenVibe.Bot controls over the picture (mode overlay; filled by public/js/bot-embed.js).++ It ends above the player's bar and sits under the bar/unmute pill/chat button. -->++ <div class="ch-bot-overlay-slot" id="ch-bot-overlay-slot" hidden></div><div class="video-overlay" id="video-overlay"><!-- DVR Progress Bar (hidden until live VOD available) --><div class="dvr-progress-wrap" id="dvr-progress-wrap" style="display:none">@@@ -53,6 +56,16 @@<button class="vc-btn" id="btn-stats" title="Stream stats"><i class="fa-solid fa-gauge-high"></i></button>++ <div class="ch-bot-mode-inline" id="ch-bot-mode-inline" hidden>++ <button type="button" class="vc-btn" id="ch-bot-mode-btn" aria-haspopup="true" aria-expanded="false" aria-label="Robot controls: on the video" title="Robot controls: on the video">++ <i class="fa-solid fa-robot" aria-hidden="true"></i>++ </button>++ <div class="ch-bot-mode-menu" id="ch-bot-mode-menu" role="menu" hidden>++ <button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button>++ <button type="button" role="menuitemradio" data-bot-mode="panel">Under the video</button>++ <button type="button" role="menuitemradio" data-bot-mode="hidden">Hidden</button>++ </div>++ </div><button class="vc-btn" id="btn-clip" title="Create Clip (30s)"><i class="fa-solid fa-scissors"></i></button>@@@ -105,6 +118,18 @@<!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) --><section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden><h3 id="ch-bot-heading">Robot</h3>++ <div class="ch-bot-card-bar" id="ch-bot-card-bar" hidden>++ <span class="ch-bot-card-label">Controls</span>++ <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Robot controls">++ <button type="button" data-bot-mode="overlay">On the video</button>++ <button type="button" data-bot-mode="panel">Under the video</button>++ <button type="button" data-bot-mode="hidden">Hidden</button>++ </div>++ </div>++ <div class="ch-bot-card-line" id="ch-bot-card-line" hidden>++ <span>Robot · controls are on the video</span>++ <button type="button" data-bot-show-panel>Show panel</button>++ </div><div id="ch-bot-container"></div></section><!-- Live translation captions for non-English streamers (filled by _startCaptions in app.js) -->@@@ -132,6 +132,16 @@<input type="text" id="dash-bot-robot-id" placeholder="rob_…" autocomplete="off" spellcheck="false"></div></div>++ <div class="settings-row">++ <label class="settings-row-label" for="dash-bot-controls-mode">Controls start</label>++ <div class="settings-row-content">++ <select id="dash-bot-controls-mode">++ <option value="panel">Under the video</option>++ <option value="overlay">On the video</option>++ </select>++ <p class="muted" style="font-size:0.8rem;margin:4px 0 0">Each viewer can move them or hide them; this is where they start before anyone chooses.</p>++ </div>++ </div><button class="btn btn-primary btn-small" onclick="saveBotEmbedSetting()"><i class="fa-solid fa-floppy-disk"></i> Save</button><span id="dash-bot-robot-status" class="muted" style="font-size:0.82rem" role="status"></span><p class="muted" style="font-size:0.8rem;margin:10px 0 0">No robot yet? <a href="https://openvibe.bot/try" target="_blank" rel="noopener">Drive a simulated one</a>, or see <a href="https://openvibe.bot/models" target="_blank" rel="noopener">what it can drive</a>.</p>@@@ -2872,16 +2872,43 @@ function _channelFooter(live) {// The panel's Drive asks this page for a ticket: a viewer signed in here drives with no popup or second sign-in// (POST /api/streams/channel/:username/bot/ticket; Bot decides who may). When the robot's owner has not turned the// panel on, viewers do not see an empty box; the owner keeps the frame, which links to the switch on openvibe.bot.++// Where the controls go is the viewer's choice (on the video / under the video / hidden), kept in localStorage++// per channel and robot and beating the streamer's default from bot_embed.controls_mode.let _botEmbedStop = null;function _applyBotEmbed(botEmbed, username) {const box = document.getElementById('ch-bot-container');if (!box) return;-- const show = () => {-- if (typeof BotEmbed === 'undefined') return;-- const frame = BotEmbed.mount(box, botEmbed);++ const section = document.getElementById('ch-bot-section');++ const slot = document.getElementById('ch-bot-overlay-slot');++ const switchWrap = document.getElementById('ch-bot-mode-inline');++ const menu = document.getElementById('ch-bot-mode-menu');++ const menuBtn = document.getElementById('ch-bot-mode-btn');++ const cardBar = document.getElementById('ch-bot-card-bar');++ const cardSwitch = document.getElementById('ch-bot-card-switch');++ const line = document.getElementById('ch-bot-card-line');++ const player = document.getElementById('video-container');++ const robotId = botEmbed && botEmbed.robot_id ? botEmbed.robot_id : null;++++ function stopFrame() {if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }-- if (frame && username && typeof BotEmbed.listen === 'function') {-- const robotId = botEmbed.robot_id;++ }++ function clear(container) {++ if (!container) return;++ try { container.replaceChildren(); } catch { /* a fake in the tests */ }++ }++ // The player's bar keeps its own room: the overlay layer ends above it, never over the controls or the pills.++ function setBar() {++ if (!player || !player.querySelector) return;++ const bar = player.querySelector('.video-overlay');++ if (!bar || !bar.getBoundingClientRect) return;++ const h = Math.round(bar.getBoundingClientRect().height);++ if (h > 0) player.style.setProperty('--ch-bot-bar-h', `${h}px`);++ }++ _applyBotEmbed.setBar = setBar;++ function mountIn(container, mode, toggleSection) {++ stopFrame();++ const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'overlay' ? 'overlay' : null, toggleSection });++ if (frame && typeof BotEmbed.listen === 'function') {_botEmbedStop = BotEmbed.listen({frame, botEmbed,requestTicket: async () => {@@@ -2892,12 +2919,151 @@ function _applyBotEmbed(botEmbed, username) {},onRefused: () => {if (currentUser && currentUser.username === username) return;-- if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }-- BotEmbed.mount(box, null);++ stopFrame();++ clear(slot); clear(box);++ if (slot) slot.hidden = true;++ if (line) line.hidden = true;++ if (section) section.hidden = true;},++ onOpenPanel: () => _applyBotEmbed.choose('panel'),++ onFocusPlayer: () => {++ const v = document.getElementById('video-container');++ if (v && v.focus) { try { v.focus({ preventScroll: true }); } catch { v.focus(); } }++ },++ });++ }++ return frame;++ }++ const MODE_NAME = { overlay: 'on the video', panel: 'under the video', hidden: 'hidden' };++ function closeMenu() {++ if (!menu) return;++ menu.hidden = true;++ if (menuBtn) menuBtn.setAttribute('aria-expanded', 'false');++ }++ function sync(mode) {++ if (menuBtn) {++ const label = `Robot controls: ${MODE_NAME[mode] || mode}`;++ menuBtn.dataset.mode = mode;++ menuBtn.setAttribute('aria-label', label);++ menuBtn.title = label;++ menuBtn.setAttribute('aria-expanded', menu && !menu.hidden ? 'true' : 'false');++ }++ const mark = (root) => {++ if (!root || !root.querySelectorAll) return;++ for (const b of root.querySelectorAll('[data-bot-mode]')) {++ const on = b.dataset.botMode === mode;++ b.setAttribute(b.getAttribute('role') === 'menuitemradio' ? 'aria-checked' : 'aria-pressed', String(on));++ }++ };++ mark(menu); mark(cardSwitch);++ }++ function render() {++ if (typeof BotEmbed === 'undefined') return;++ const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId));++ _applyBotEmbed.mode = mode;++ _applyBotEmbed.frame = null;++ _applyBotEmbed.botEmbed = botEmbed;++ if (switchWrap) switchWrap.hidden = !robotId;++ if (cardBar) cardBar.hidden = !robotId;++ if (mode === 'overlay') {++ if (slot) {++ slot.hidden = false;++ _applyBotEmbed.frame = mountIn(slot, 'overlay', false);++ }++ clear(box);++ box.hidden = true;++ if (line) line.hidden = false;++ if (section) section.hidden = false;++ } else if (mode === 'panel') {++ if (slot) { clear(slot); slot.hidden = true; }++ if (line) line.hidden = true;++ box.hidden = false;++ if (section) section.hidden = false;++ _applyBotEmbed.frame = mountIn(box, 'panel', true);++ } else {++ stopFrame();++ clear(box); clear(slot);++ box.hidden = true;++ if (slot) slot.hidden = true;++ if (line) line.hidden = true;++ if (section) section.hidden = true;++ }++ sync(mode);++ }++ function choose(mode, persist = true) {++ if (typeof BotEmbed === 'undefined' || !BotEmbed.validView(mode)) return;++ if (persist && robotId && BotEmbed.writeChoice) BotEmbed.writeChoice(window, username, robotId, mode);++ closeMenu();++ render();++ }++ _applyBotEmbed.render = render;++ _applyBotEmbed.choose = choose;++++ // The two switches (the player's small one and the card's) and the overlay's way back to the full panel. One++ // listener each for the page, whichever robot was bound last is the one they drive.++ if (menu && menuBtn && menuBtn.dataset.botBound !== '1') {++ menuBtn.dataset.botBound = '1';++ menuBtn.addEventListener('click', () => {++ const open = menu.hidden;++ menu.hidden = !open;++ menuBtn.setAttribute('aria-expanded', String(open));++ });++ menu.addEventListener('click', (e) => {++ const b = e.target && e.target.closest ? e.target.closest('[data-bot-mode]') : null;++ if (b) _applyBotEmbed.choose(b.dataset.botMode);++ });++ if (document.addEventListener) {++ document.addEventListener('click', (e) => {++ if (menu.hidden) return;++ if (menu.contains && menu.contains(e.target)) return;++ if (menuBtn.contains && menuBtn.contains(e.target)) return;++ closeMenu();});}-- if (window.ov) ov.scope().onDispose(() => { if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; } BotEmbed.mount(box, null); });++ }++ if (cardSwitch && cardSwitch.dataset.botBound !== '1') {++ cardSwitch.dataset.botBound = '1';++ cardSwitch.addEventListener('click', (e) => {++ const b = e.target && e.target.closest ? e.target.closest('[data-bot-mode]') : null;++ if (b) _applyBotEmbed.choose(b.dataset.botMode);++ });++ }++ if (line && line.dataset.botBound !== '1') {++ line.dataset.botBound = '1';++ line.addEventListener('click', (e) => {++ if (e.target && e.target.closest && e.target.closest('[data-bot-show-panel]')) _applyBotEmbed.choose('panel');++ });++ }++ // The pointer on the page (outside the clipped controls) wakes the overlay's idle fade.++ if (player && player.dataset.botActivity !== '1') {++ player.dataset.botActivity = '1';++ player.addEventListener('pointermove', () => {++ if (_applyBotEmbed.mode !== 'overlay' || !_applyBotEmbed.frame || typeof BotEmbed === 'undefined') return;++ const now = Date.now();++ if (now - (_applyBotEmbed.activityAt || 0) < 250) return;++ _applyBotEmbed.activityAt = now;++ BotEmbed.notifyActivity(_applyBotEmbed.frame, _applyBotEmbed.botEmbed);++ }, { passive: true });++ }++ setBar();++ if (player && typeof ResizeObserver === 'function' && !_applyBotEmbed.barObserver) {++ const bar = player.querySelector && player.querySelector('.video-overlay');++ if (bar) {++ _applyBotEmbed.barObserver = new ResizeObserver(() => _applyBotEmbed.setBar());++ _applyBotEmbed.barObserver.observe(bar);++ }++ }++++ const show = () => {++ if (typeof BotEmbed === 'undefined') return;++ render();++ if (window.ov) ov.scope().onDispose(() => {++ stopFrame();++ clear(slot);++ if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);++ else clear(box);++ if (_applyBotEmbed.barObserver) { _applyBotEmbed.barObserver.disconnect(); _applyBotEmbed.barObserver = null; }++ });};if (typeof BotEmbed !== 'undefined') return show();if (!(botEmbed && botEmbed.url) || !window.ov) return;@@@ -35,7 +35,11 @@const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';-- function embedSrc(botEmbed) {++ const PANEL_MODES = ['panel', 'overlay', 'compact'];++ const VIEW_MODES = ['overlay', 'panel', 'hidden'];++ const CHOICE_KEY = 'ov:live:bot-controls';++++ function embedSrc(botEmbed, mode = null) {if (!botEmbed || typeof botEmbed.url !== 'string' || !botEmbed.url) return null;let u;try { u = new URL(botEmbed.url); } catch { return null; }@@@ -43,11 +47,15 @@const local = u.hostname === 'localhost' || u.hostname.endsWith('.localhost');if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;if (!/\/embed$/.test(u.pathname)) return null;++ if (mode != null) {++ if (!PANEL_MODES.includes(mode)) return null;++ u.searchParams.set('mode', mode);++ }return u.href;}-- function iframeAttrs(botEmbed) {-- const src = embedSrc(botEmbed);++ function iframeAttrs(botEmbed, { mode = null } = {}) {++ const src = embedSrc(botEmbed, mode);if (!src) return null;return {src,@@@ -63,10 +71,10 @@return (container.closest && container.closest('section')) || container.parentNode || null;}-- function mount(container, botEmbed) {++ function mount(container, botEmbed, { mode = null, toggleSection = true } = {}) {if (!container) return null;-- const section = sectionOf(container);-- const attrs = iframeAttrs(botEmbed);++ const section = toggleSection ? sectionOf(container) : null;++ const attrs = iframeAttrs(botEmbed, { mode });if (!attrs) {container.replaceChildren();if (section) section.hidden = true;@@@ -84,6 +92,71 @@return frame;}++ // ── The viewer's choice, per channel and robot ────────────────────────────────────────────────────++ // No account is needed: localStorage only. A stored valid choice beats the streamer's default; without one++ // the channel's `bot_embed.controls_mode` decides, and 'panel' is the old behaviour.++ function choiceKey(channel, robotId) {++ return `${CHOICE_KEY}:${String(channel || '')}:${String(robotId || '')}`;++ }++ function validView(mode) { return VIEW_MODES.includes(mode) ? mode : null; }++ function defaultMode(botEmbed) { return botEmbed && botEmbed.controls_mode === 'overlay' ? 'overlay' : 'panel'; }++ function effectiveMode(botEmbed, stored) {++ return validView(stored) || defaultMode(botEmbed);++ }++ function readChoice(win, channel, robotId) {++ try {++ const s = win && win.localStorage;++ return s ? validView(s.getItem(choiceKey(channel, robotId))) : null;++ } catch { return null; }++ }++ function writeChoice(win, channel, robotId, mode) {++ if (!validView(mode)) return false;++ try {++ const s = win && win.localStorage;++ if (!s) return false;++ s.setItem(choiceKey(channel, robotId), mode);++ return true;++ } catch { return false; }++ }++++ /** The control rectangles from ov-bot-size.hit → a clip-path path, or null when there is nothing usable. */++ function clipPathFor(hit) {++ if (!Array.isArray(hit)) return null;++ const rects = hit.filter((r) => r && [r.x, r.y, r.w, r.h].every((n) => Number.isFinite(Number(n))) && Number(r.w) > 0 && Number(r.h) > 0)++ .slice(0, 12)++ .map((r) => ({ x: Math.max(0, Number(r.x)), y: Math.max(0, Number(r.y)), w: Number(r.w), h: Number(r.h) }));++ if (!rects.length) return null;++ const path = rects.map((r) => `M${Math.round(r.x)} ${Math.round(r.y)} H${Math.round(r.x + r.w)} V${Math.round(r.y + r.h)} H${Math.round(r.x)} Z`).join(' ');++ return `path("${path}")`;++ }++ function applyClip(frame, message) {++ try {++ if (!frame || !frame.style) return;++ const path = message && message.mode === 'overlay' ? clipPathFor(message.hit) : null;++ frame.style.clipPath = path || '';++ } catch { /* a fake frame in the tests */ }++ }++++ /** Ask the frame to switch view; it reloads itself in that view (its controls come from the robot's profile). */++ function setMode(frame, botEmbed, mode) {++ const src = embedSrc(botEmbed, mode);++ if (!frame || !src || !frame.contentWindow) return false;++ try {++ frame.contentWindow.postMessage({ type: 'ov-bot-mode', robot_id: botEmbed.robot_id, mode }, new URL(src).origin);++ return true;++ } catch { return false; }++ }++++ /** Tell the frame the pointer is moving on the page (the overlay wakes from its idle fade). */++ function notifyActivity(frame, botEmbed) {++ const src = embedSrc(botEmbed);++ if (!frame || !src || !frame.contentWindow) return false;++ try {++ frame.contentWindow.postMessage({ type: 'ov-bot-activity', robot_id: botEmbed.robot_id }, new URL(src).origin);++ return true;++ } catch { return false; }++ }++// The panel takes the page's colours (OpenVibe.Bot public/embed-frame.js): these tokens, read from the page's// computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];@@@ -111,7 +184,7 @@} catch { return null; }}-- function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, win = typeof window !== 'undefined' ? window : null }) {++ function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, onOpenPanel = () => {}, onFocusPlayer = () => {}, onSize = null, win = typeof window !== 'undefined' ? window : null }) {const src = embedSrc(botEmbed);if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};const origin = new URL(src).origin;@@@ -224,8 +297,18 @@const d = e.data || {};// The panel's size is about this frame, not about driving: accept it with or without the id, but never// for another robot.-- if (d.type === 'ov-bot-size') { if (d.robot_id == null || d.robot_id === robotId) setHeight(d.height); return; }++ if (d.type === 'ov-bot-size') {++ if (d.robot_id == null || d.robot_id === robotId) {++ // An overlay fills the player; its clip keeps the presses outside the controls going through.++ if (d.mode === 'overlay') applyClip(frame, d);++ else { applyClip(frame, null); setHeight(d.height); }++ if (typeof onSize === 'function') onSize(d);++ }++ return;++ }if (!d || d.robot_id !== robotId) return;++ if (d.type === 'ov-bot-open-panel') { onOpenPanel(); return; }++ if (d.type === 'ov-bot-blur-controls') { onFocusPlayer(); return; }if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }if (d.type === 'ov-bot-controls') { setControls(d); return; }if (d.type !== 'ov-bot-ticket-request' || asking) return;@@@ -250,5 +333,5 @@};}-- return { iframeAttrs, mount, listen, THEME_TOKENS, MIN_HEIGHT, MAX_HEIGHT };++ return { iframeAttrs, mount, listen, embedSrc, choiceKey, validView, defaultMode, effectiveMode, readChoice, writeChoice, clipPathFor, applyClip, setMode, notifyActivity, VIEW_MODES, PANEL_MODES, THEME_TOKENS, MIN_HEIGHT, MAX_HEIGHT };});@@@ -775,16 +775,21 @@ async function loadBotEmbedSetting() {const be = data && data.bot_embed;if (!be || !be.enabled) { card.style.display = 'none'; return; }document.getElementById('dash-bot-robot-id').value = be.robot_id || '';++ const mode = document.getElementById('dash-bot-controls-mode');++ if (mode) mode.value = be.controls_mode === 'overlay' ? 'overlay' : 'panel';card.style.display = '';} catch { card.style.display = 'none'; }}async function saveBotEmbedSetting() {const input = document.getElementById('dash-bot-robot-id');++ const mode = document.getElementById('dash-bot-controls-mode');const status = document.getElementById('dash-bot-robot-status');if (!input || !currentUser) return;const id = input.value.trim();try {-- const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body: { robot_id: id || null } });++ const body = { robot_id: id || null };++ if (mode && (mode.value === 'overlay' || mode.value === 'panel')) body.controls_mode = mode.value;++ const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body });const bound = data && data.bot_embed && data.bot_embed.robot_id;if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';} catch (e) {@@@ -49,11 +49,15 @@ function embedUrl(robotId) {return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;}--/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url }. */++/** The two places a streamer may put the controls by default; a viewer may still hide them or move them. */++const CONTROL_MODES = ['overlay', 'panel'];++const controlMode = (value) => (CONTROL_MODES.includes(value) ? value : 'panel');++++/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url, controls_mode }. */function channelEmbed(channel) {if (!enabled()) return undefined;const id = channel && validRobotId(channel.bot_robot_id) ? channel.bot_robot_id : null;-- return { enabled: true, robot_id: id, url: id ? embedUrl(id) : null };++ return { enabled: true, robot_id: id, url: id ? embedUrl(id) : null, controls_mode: controlMode(channel && channel.bot_controls_mode) };}--module.exports = { enabled, botOrigin, validRobotId, embedUrl, channelEmbed, DEFAULT_BOT_URL };++module.exports = { enabled, botOrigin, validRobotId, embedUrl, channelEmbed, controlMode, CONTROL_MODES, DEFAULT_BOT_URL };@@@ -29,13 +29,29 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? nextif (Number(req.user.id) !== Number(channel.user_id)) {return res.status(403).json({ error: 'Only the channel owner can bind a robot' });}-- const raw = req.body ? req.body.robot_id : undefined;-- let robotId;-- if (raw === null || raw === '') robotId = null;-- else if (embed.validRobotId(raw)) robotId = raw;-- else return res.status(400).json({ error: 'robot_id must be a Bot robot id (rob_…) or null' });-- await db.updateChannel(channel.user_id, { bot_robot_id: robotId });-- res.json({ ok: true, bot_embed: embed.channelEmbed({ bot_robot_id: robotId }) });++ const body = req.body || {};++ const hasRobot = Object.prototype.hasOwnProperty.call(body, 'robot_id');++ const hasMode = Object.prototype.hasOwnProperty.call(body, 'controls_mode');++ if (!hasRobot && !hasMode) return res.status(400).json({ error: 'send robot_id, controls_mode, or both' });++ let robotId = channel.bot_robot_id || null;++ if (hasRobot) {++ const raw = body.robot_id;++ if (raw === null || raw === '') robotId = null;++ else if (embed.validRobotId(raw)) robotId = raw;++ else return res.status(400).json({ error: 'robot_id must be a Bot robot id (rob_…) or null' });++ }++ let mode = embed.controlMode(channel.bot_controls_mode);++ if (hasMode) {++ if (!embed.CONTROL_MODES.includes(body.controls_mode)) {++ return res.status(400).json({ error: "controls_mode must be 'overlay' or 'panel'" });++ }++ mode = body.controls_mode;++ }++ const patch = {};++ if (hasRobot) patch.bot_robot_id = robotId;++ if (hasMode) patch.bot_controls_mode = mode;++ await db.updateChannel(channel.user_id, patch);++ res.json({ ok: true, bot_embed: embed.channelEmbed({ bot_robot_id: robotId, bot_controls_mode: mode }) });} catch (err) {console.error('[BotEmbed] Bind error:', err.message);res.status(500).json({ error: 'Failed to bind robot' });@@@ -1895,7 +1895,7 @@ async function updateChannel(userId, fields) {const updates = [];const params = [];for (const [key, val] of Object.entries(fields)) {-- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id'].includes(key)) {++ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id', 'bot_controls_mode'].includes(key)) {updates.push(`${key} = ?`);params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);}@@@ -561,6 +561,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {delete publicChannel.force_vod_recording_disabled;// The binding is only shown through `bot_embed` below, and only with LIVE_BOT_EMBED on.delete publicChannel.bot_robot_id;++ delete publicChannel.bot_controls_mode;const botEmbed = require('../bot/embed').channelEmbed(channel);// Counts for tab badges. Owner/mods see hidden ones too. Pastes are counted by@@@ -38,7 +38,7 @@ function publicManagedStream(ms) {/** A channel row as anyone may see it. `weather_enabled` replaces the ZIP it is derived from. */function publicChannel(ch) {if (!ch || typeof ch !== 'object') return ch;-- const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled', 'bot_robot_id']);++ const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled', 'bot_robot_id', 'bot_controls_mode']);if (!('weather_enabled' in out)) out.weather_enabled = !!(ch.weather_zip && ch.weather_detail && ch.weather_detail !== 'off');return out;}@@@ -87,20 +87,21 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };});await check('flag off: the channel GET has no bot_embed key and no bot_robot_id even when one is stored', async () => {-- await raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_stored1', chan.id);++ await raw.prepare('UPDATE channels SET bot_robot_id = ?, bot_controls_mode = ? WHERE id = ?').run('rob_stored1', 'overlay', chan.id);const r = await channelGet();assert.strictEqual(r.status, 200, r.text.slice(0, 200));assert.ok(!('bot_embed' in r.body), 'no bot_embed key');assert.ok(!('bot_robot_id' in r.body.channel), 'no raw column on the public channel');++ assert.ok(!('bot_controls_mode' in r.body.channel), 'the controls default is only shown through bot_embed');assert.ok(!r.text.includes('rob_stored1'));-- await raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(chan.id);++ await raw.prepare('UPDATE channels SET bot_robot_id = NULL, bot_controls_mode = ? WHERE id = ?').run('panel', chan.id);});process.env.LIVE_BOT_EMBED = '1';await check('flag on, unbound: bot_embed is enabled with no robot', async () => {const r = await channelGet();-- assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null });++ assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null, controls_mode: 'panel' });});await check('flag on: anonymous 401, non-owner 403, channel mod 403', async () => {@@@ -127,10 +128,28 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };const r = await bind('rob_sim123');assert.strictEqual(r.status, 200, r.text);const g = await channelGet();-- assert.deepStrictEqual(g.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed' });++ assert.deepStrictEqual(g.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'panel' });assert.ok(!('bot_robot_id' in g.body.channel));});++ await check('controls_mode: the streamer\'s default is saved with the binding, served, and validated', async () => {++ // Only the default: the robot stays bound.++ const mode = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { controls_mode: 'overlay' } });++ assert.strictEqual(mode.status, 200, mode.text);++ assert.deepStrictEqual(mode.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'overlay' });++ assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'overlay', 'the channel serializer carries it to the page');++ // Both together, then an invalid value changes nothing.++ const both = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { robot_id: 'rob_sim123', controls_mode: 'panel' } });++ assert.strictEqual(both.status, 200);++ assert.strictEqual(both.body.bot_embed.controls_mode, 'panel');++ const bad = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { controls_mode: 'hidden' } });++ assert.strictEqual(bad.status, 400, 'hidden is a viewer choice, not a streamer default');++ assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'panel');++ const empty = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: {} });++ assert.strictEqual(empty.status, 400, 'a PUT must say what to change');++ assert.strictEqual((await db.getChannelByUserId(1)).bot_controls_mode, 'panel');++ });++await check('a custom LIVE_BOT_URL changes the origin', async () => {process.env.LIVE_BOT_URL = 'https://bot.example.test/';assert.strictEqual((await channelGet()).body.bot_embed.url, 'https://bot.example.test/panel/rob_sim123/embed');@@@ -157,7 +176,7 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };});await check('null and empty string unbind; url is null', async () => {-- const unbound = { enabled: true, robot_id: null, url: null };++ const unbound = { enabled: true, robot_id: null, url: null, controls_mode: 'panel' };await bind('rob_sim123');const a = await bind(null);assert.strictEqual(a.status, 200, a.text);@@@ -29,7 +29,7 @@ console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };const BotEmbed = require('../public/js/bot-embed.js');--const SIM = { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed' };++const SIM = { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'panel' };const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';// Just enough DOM for mount(): elements with attributes, children, hidden and a section parent.@@@ -116,6 +116,42 @@ async function check(name, fn) {assert.strictEqual(BotEmbed.mount(null, SIM), null, 'no container (another page) is a no-op');});++ await check('modes: the viewer\'s remembered choice beats the streamer\'s default, and the frame asks for that view', () => {++ const overlayDefault = { ...SIM, controls_mode: 'overlay' };++ assert.strictEqual(BotEmbed.effectiveMode(SIM, null), 'panel', 'no default and no choice: under the video');++ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, null), 'overlay', 'the streamer put them on the video');++ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'panel'), 'panel', 'a viewer may move them under the video');++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'overlay'), 'overlay', 'and onto it');++ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'hidden'), 'hidden', 'or hide them');++ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'bogus'), 'overlay', 'a bad stored value is ignored');++ assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'bogus' }, null), 'panel', 'a bad streamer default is panel');++ assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'overlay' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=overlay');++ assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'compact' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=compact');++ assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'bogus' }), null, 'only the three views');++ assert.strictEqual(BotEmbed.iframeAttrs(SIM).src, SIM.url, 'the default stays the plain embed url');++ const saved = new Map();++ const win = { localStorage: { getItem: (k) => (saved.has(k) ? saved.get(k) : null), setItem: (k, v) => saved.set(k, String(v)) } };++ assert.strictEqual(BotEmbed.readChoice(win, 'rover', 'rob_sim123'), null);++ assert.strictEqual(BotEmbed.writeChoice(win, 'rover', 'rob_sim123', 'overlay'), true);++ assert.strictEqual(BotEmbed.readChoice(win, 'rover', 'rob_sim123'), 'overlay');++ assert.strictEqual(BotEmbed.readChoice(win, 'other', 'rob_sim123'), null, 'per channel');++ assert.strictEqual(BotEmbed.readChoice(win, 'rover', 'rob_other'), null, 'per robot');++ assert.strictEqual(BotEmbed.choiceKey('rover', 'rob_sim123'), 'ov:live:bot-controls:rover:rob_sim123');++ assert.strictEqual(BotEmbed.writeChoice(win, 'rover', 'rob_sim123', 'bogus'), false);++ assert.strictEqual(BotEmbed.writeChoice({ localStorage: { setItem() { throw new Error('blocked'); } } }, 'rover', 'rob_sim123', 'panel'), false, 'a browser that refuses storage is harmless');++ });++++ await check('clipPathFor: the controls\' rectangles become one clip path; malformed boxes are ignored', () => {++ assert.strictEqual(BotEmbed.clipPathFor([{ x: 10, y: 20, w: 44, h: 44 }]),++ 'path("M10 20 H54 V64 H10 Z")');++ assert.strictEqual(BotEmbed.clipPathFor([{ x: 0, y: 0, w: 132, h: 132 }, { x: 300.4, y: 240.2, w: 44, h: 132 }]),++ 'path("M0 0 H132 V132 H0 Z M300 240 H344 V372 H300 Z")');++ assert.strictEqual(BotEmbed.clipPathFor([{ x: 0, y: 0, w: 0, h: 44 }, { x: 'no', y: 1, w: 2, h: 3 }, null]), null);++ assert.strictEqual(BotEmbed.clipPathFor('nope'), null);++ const tooMany = Array.from({ length: 20 }, (_, i) => ({ x: i, y: i, w: 4, h: 4 }));++ assert.strictEqual((BotEmbed.clipPathFor(tooMany).match(/M/g) || []).length, 12, 'at most the known twelve boxes');++ });++await check('page wiring: the script is its own lazy feature and the section starts hidden', () => {const features = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'features.json'), 'utf8')).features;assert.deepStrictEqual(features.botEmbed.js, ['/js/bot-embed.js']);@@@ -216,7 +252,7 @@ async function check(name, fn) {await check('flag on, unbound: the page still loads nothing', async () => {assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { robot_id: null })).status, 200);const r = await channelGet();-- assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null });++ assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null, controls_mode: 'panel' });const p = page();await runApply(p, r.body.bot_embed);assert.deepStrictEqual(p.loads, []);@@@ -231,13 +267,13 @@ async function check(name, fn) {const p = page();await runApply(p, r.body.bot_embed);assert.deepStrictEqual(p.loads, ['botEmbed']);-- assert.strictEqual(p.dom.container.children.length, 1);++ assert.strictEqual(p.dom.container.children.length, 1, 'one frame');const frame = p.dom.container.children[0];assert.strictEqual(frame.tagName, 'IFRAME');-- assert.strictEqual(frame.getAttribute('src'), 'https://openvibe.bot/panel/rob_sim123/embed');-- assert.strictEqual(frame.getAttribute('sandbox'), SANDBOX);-- assert.strictEqual(p.dom.section.hidden, false);-- assert.strictEqual(p.disposers.length, 1);++ assert.strictEqual(frame.getAttribute('src'), 'https://openvibe.bot/panel/rob_sim123/embed', 'panel src');++ assert.strictEqual(frame.getAttribute('sandbox'), SANDBOX, 'sandbox');++ assert.strictEqual(p.dom.section.hidden, false, 'section shown');++ assert.strictEqual(p.disposers.length, 1, 'one disposer');p.disposers[0]();assert.deepStrictEqual(p.dom.container.children, [], 'leaving the route removes the frame');assert.strictEqual(p.dom.section.hidden, true);@@@ -357,6 +393,32 @@ async function check(name, fn) {assert.ok(!winListeners.get('ov:theme').size && !winListeners.get('ov:display').size && !frameListeners.get('load').size, 'stop() removes them');});++ await check('listen(): an overlay fills the player, clips to its controls, and reports more/blur to the page', async () => {++ const listeners = new Set();++ const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };++ let opened = 0;++ let focused = 0;++ let sized = null;++ const panel = { postMessage: () => {} };++ const frame = { contentWindow: panel, style: {}, closest: () => null };++ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => ({}),++ onOpenPanel: () => { opened++; }, onFocusPlayer: () => { focused++; }, onSize: (d) => { sized = d; } });++ const send = async (data) => { for (const f of [...listeners]) await f({ data, origin: 'https://openvibe.bot', source: panel }); };++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', mode: 'overlay', height: 390, minWidth: 280, minHeight: 196,++ hit: [{ x: 16, y: 238, w: 132, h: 132 }, { x: 330, y: 238, w: 44, h: 132 }] });++ assert.strictEqual(frame.style.clipPath, 'path("M16 238 H148 V370 H16 Z M330 238 H374 V370 H330 Z")');++ assert.strictEqual(frame.style.height, undefined, 'the overlay fills its slot; it is not a card to resize');++ assert.strictEqual(sized && sized.mode, 'overlay');++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', mode: 'overlay', height: 390, hit: [] });++ assert.strictEqual(frame.style.clipPath, '', 'no boxes: no clip rather than a frame that swallows the video');++ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', mode: 'panel', height: 500 });++ assert.strictEqual(frame.style.clipPath, '', 'the full panel is a normal card again');++ await send({ type: 'ov-bot-open-panel', robot_id: 'rob_sim123' });++ await send({ type: 'ov-bot-blur-controls', robot_id: 'rob_sim123' });++ assert.deepStrictEqual([opened, focused], [1, 1], 'more asks for the full panel; Escape gives the player focus');++ stop();++ });++await check('listen(): the panel\'s height sets the frame between 160 and 720 px, and only from its own frame', async () => {const listeners = new Set();const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };nnew file mode 100644@@@ -0,0 +1,133 @@++#!/usr/bin/env node++/**++ * The robot controls over the player: a real Chrome at 390×780 (phone upright), 844×390 (phone on its side)++ * and 1280×800. Needs a running site with a channel bound to a robot, and Chrome (not part of `npm test`).++ *++ * BASE=https://openvibe.live CHANNEL=/@Maticus npm run test:bot-overlay++ * OUT=/tmp/shots BASE=http://127.0.0.1:4188 CHANNEL=/@rover node test/browser/bot-overlay.js++ *++ * It forces the live area visible when the channel is offline (the same trick the deploy check uses), chooses++ * "Controls: on the video", then checks:++ * - the overlay slot never reaches into the player's controls bar, unmute overlay or the chat fold button++ * (the controls' z-index stays below all three);++ * - the frame is clipped to the controls (a clip-path with at least one rectangle), so a press in the middle++ * of the picture reaches the player instead of the transparent iframe;++ * - the card under the player is the one line with "Show panel";++ * - a screenshot at each size under OUT.++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');++++const BASE = (process.env.BASE || 'https://openvibe.live').replace(/\/$/, '');++const CHANNEL = process.env.CHANNEL || '/@Maticus';++const OUT = process.env.OUT || '/home/workstation/openvibe/research-2026-11-10/controls-overlay';++const SIZES = [[390, 780, 'portrait'], [844, 390, 'landscape'], [1280, 800, 'desktop']];++++async function forceLive(page) {++ await page.evaluate(() => {++ const live = document.getElementById('ch-live-area');++ const off = document.getElementById('ch-offline-area');++ if (live) live.style.display = '';++ if (off) off.style.display = 'none';++ const channel = document.getElementById('page-channel');++ if (channel) channel.classList.add('ch-is-live');++ const v = document.getElementById('video-element');++ if (v) { v.style.display = 'block'; v.style.opacity = '1'; v.style.filter = 'none'; v.style.transform = 'none'; }++ const box = document.getElementById('video-container');++ if (box) box.style.background = '#0b0d12';++ });++}++++async function pickOverlay(page) {++ const inline = page.locator('#ch-bot-mode-inline');++ await inline.waitFor({ state: 'visible', timeout: 20000 });++ const btn = page.locator('#ch-bot-mode-btn');++ if ((await btn.getAttribute('data-mode')) !== 'overlay') {++ await btn.click();++ await page.locator('#ch-bot-mode-menu [data-bot-mode="overlay"]').click();++ }++ await page.waitForFunction(() => {++ const slot = document.getElementById('ch-bot-overlay-slot');++ return slot && !slot.hidden && slot.querySelector('iframe');++ }, null, { timeout: 20000 });++ const frame = page.locator('#ch-bot-overlay-slot iframe');++ await frame.waitFor({ state: 'visible', timeout: 20000 });++ return frame;++}++++(async () => {++ fs.mkdirSync(OUT, { recursive: true });++ const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });++ const context = await browser.newContext();++ let failed = 0;++ for (const [width, height, name] of SIZES) {++ const page = await context.newPage();++ const errors = [];++ page.on('pageerror', (e) => errors.push(e.message));++ await page.setViewportSize({ width, height });++ await page.goto(BASE + CHANNEL, { waitUntil: 'load' });++ await page.waitForTimeout(1500);++ try {++ await forceLive(page);++ await pickOverlay(page);++ await page.waitForTimeout(1200);++ const m = await page.evaluate(() => {++ const rect = (el) => (el ? el.getBoundingClientRect().toJSON() : null);++ const z = (el) => { if (!el) return null; const v = getComputedStyle(el).zIndex; return v === 'auto' ? 0 : Number(v) || 0; };++ const slot = document.getElementById('ch-bot-overlay-slot');++ const player = document.getElementById('video-container');++ const bar = player && player.querySelector('.video-overlay');++ const unmute = document.getElementById('unmute-overlay');++ const fold = document.getElementById('ch-chat-reopen');++ const frame = slot && slot.querySelector('iframe');++ return {++ slot: rect(slot), player: rect(player), bar: rect(bar), unmute: rect(unmute), fold: fold && !fold.hidden ? rect(fold) : null,++ zSlot: z(slot), zBar: z(bar), zUnmute: z(unmute), zFold: z(fold),++ clip: frame ? (frame.style.clipPath || getComputedStyle(frame).clipPath) : null,++ cardLine: (() => { const el = document.getElementById('ch-bot-card-line'); return el ? !el.hidden : false; })(),++ cardFrame: !!document.querySelector('#ch-bot-container iframe'),++ };++ });++ assert.ok(m.slot && m.player, 'the player and its overlay slot are on the page');++ assert.ok(m.slot.bottom <= m.bar.top + 0.5, `the slot ends above the player's bar (slot ${m.slot.bottom} > bar ${m.bar.top})`);++ if (m.fold) assert.ok(m.slot.right <= m.fold.left || m.slot.left >= m.fold.right || m.slot.bottom <= m.fold.top || m.slot.top >= m.fold.bottom, 'the slot never covers the chat fold button');++ assert.ok(m.zSlot < m.zBar, `the slot stays under the player's bar (${m.zSlot} < ${m.zBar})`);++ if (m.unmute) assert.ok(m.zSlot < m.zUnmute, `and under the unmute pill (${m.zSlot} < ${m.zUnmute})`);++ if (m.fold && m.zFold != null) assert.ok(m.zSlot < m.zFold, `and under the chat fold button (${m.zSlot} < ${m.zFold})`);++ assert.ok(m.clip && /M\\d+\\s+\\d+/.test(m.clip), `the frame is clipped to its controls (${m.clip})`);++ assert.ok(m.cardLine && !m.cardFrame, 'the card under the player is the one-line state, not a second frame');++ // A press in the middle of the picture must reach the player, not the transparent frame.++ const cx = m.player.x + m.player.width / 2;++ const cy = m.player.y + m.player.height / 2;++ await page.evaluate(() => {++ window.__ovVideoClicks = 0;++ const player = document.getElementById('video-container');++ player.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('#ch-bot-overlay-slot')) window.__ovVideoClicks++; }, true);++ });++ const hit = await page.evaluate(([x, y]) => {++ const el = document.elementFromPoint(x, y);++ return { id: el && el.id, inSlot: !!(el && el.closest && el.closest('#ch-bot-overlay-slot')) };++ }, [cx, cy]);++ await page.mouse.click(cx, cy);++ await page.waitForTimeout(250);++ const clicks = await page.evaluate(() => window.__ovVideoClicks || 0);++ assert.ok(!hit.inSlot, `the centre of the picture is not the overlay frame (element ${hit.id || '?'})`);++ assert.ok(clicks >= 1, 'a press outside the controls reaches the player');++ assert.deepStrictEqual(errors, [], `no page errors (${errors.join(' · ')})`);++ const shot = path.join(OUT, `bot-overlay-${width}x${height}-${name}.png`);++ await page.screenshot({ path: shot, fullPage: false });++ console.log(` ✓ ${width}×${height}: slot above the bar (${Math.round(m.bar.top - m.slot.bottom)} px clear), clipped, press passes through → ${shot}`);++ } catch (e) {++ failed++;++ console.log(` ✗ ${width}×${height}: ${e.message}`);++ try { await page.screenshot({ path: path.join(OUT, `bot-overlay-${width}x${height}-${name}-fail.png`), fullPage: false }); } catch { /* */ }++ }++ await page.close();++ }++ await browser.close();++ if (failed) { console.log(`bot-overlay: ${failed} failed`); process.exit(1); }++ console.log('bot-overlay: all passed');++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- A channel bound to an OpenVibe.Bot robot carries the streamer's default place for the robot's controls:
+-- 'overlay' puts them over the video, 'panel' under it (the previous behaviour). A viewer may choose either,
+-- or hide them, for themselves; this is only the default until they do.
+ALTER TABLE channels ADD COLUMN bot_controls_mode text COLLATE "C" DEFAULT 'panel';
+ALTER TABLE channels ADD CONSTRAINT channels_bot_controls_mode CHECK (bot_controls_mode IS NULL OR bot_controls_mode IN ('overlay', 'panel'));
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
"test:analytics-pg": "LIVE_TEST_STORE=pg node test/analytics-store.test.js && LIVE_TEST_STORE=pg node test/analytics-privacy.test.js",
"test:analytics-pg": "LIVE_TEST_STORE=pg node test/analytics-store.test.js && LIVE_TEST_STORE=pg node test/analytics-privacy.test.js",
"test:powerchat": "for f in test/powerchat-*.test.js; do node \"$f\" || exit 1; done",
"test:powerchat": "for f in test/powerchat-*.test.js; do node \"$f\" || exit 1; done",
"test:browser": "node test/browser/smoke.js",
"test:browser": "node test/browser/smoke.js",
+ "test:bot-overlay": "node test/browser/bot-overlay.js",
"perf:page": "node scripts/perf/measure-page.js",
"perf:page": "node scripts/perf/measure-page.js",
"perf:budget": "node scripts/perf/check-budgets.js",
"perf:budget": "node scripts/perf/check-budgets.js",
"n-1:record": "node scripts/n-1-record.js"
"n-1:record": "node scripts/n-1-record.js"
@@ -2039,6 +2039,37 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
@@ -2039,6 +2039,37 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
/* ── The robot's panel (OpenVibe.Bot) ──────────────────────────────────────────────────────────────────────────────
/* ── The robot's panel (OpenVibe.Bot) ──────────────────────────────────────────────────────────────────────────────
It was a bare frame at the browser's default 300 by 150 with scrollbars, and a black band beside it (the owner,
It was a bare frame at the browser's default 300 by 150 with scrollbars, and a black band beside it (the owner,
11-10: "all this broken space"). A card under the player, as wide as the column; its height can be pulled taller. */
11-10: "all this broken space"). A card under the player, as wide as the column; its height can be pulled taller. */
+/* The controls over the picture: a layer above the video and below the player's own bar (z-index 5), unmute
+ overlay (20) and floating chat button. Its bottom leaves the bar's height out; bot-embed.js measures it into
+ --ch-bot-bar-h. The slot itself takes no press: the iframe is clipped to the panel's control rectangles, so a
+ press anywhere else reaches the video. */
+.ch-bot-overlay-slot { position: absolute; left: 0; right: 0; top: 0; bottom: var(--ch-bot-bar-h, 64px); z-index: 4; pointer-events: none; }
+.ch-bot-overlay-slot[hidden] { display: none; }
+.ch-bot-overlay-slot iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; pointer-events: auto; }
+/* The player's small switch: one 40 px robot button that opens the three choices, so the bar keeps its room on a
+ phone (the old select was wide enough to push the clip button out at 360 px). */
+.ch-bot-mode-inline { position: relative; display: inline-flex; align-items: center; }
+.ch-bot-mode-inline[hidden] { display: none; }
+.ch-bot-mode-inline #ch-bot-mode-btn { width: 40px; height: 40px; min-width: 40px; min-height: 40px; padding: 0; }
+.ch-bot-mode-inline #ch-bot-mode-btn[data-mode="hidden"] { opacity: .55; }
+.ch-bot-mode-menu {
+ position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 30; min-width: 158px; padding: 4px;
+ display: grid; gap: 2px; border-radius: 10px; border: 1px solid rgba(255,255,255,.16);
+ background: rgba(12,15,24,.96); box-shadow: 0 8px 24px rgba(0,0,0,.45);
+}
+.ch-bot-mode-menu[hidden] { display: none; }
+.ch-bot-mode-menu button { min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: #fff; text-align: left; font-size: .8rem; }
+.ch-bot-mode-menu button[aria-checked="true"] { background: color-mix(in srgb, var(--accent) 35%, transparent); }
+/* The card's switch and its one-line state when the controls are on the video. */
+.ch-bot-card-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid var(--border); }
+.ch-bot-card-bar[hidden] { display: none; }
+.ch-bot-card-label { color: var(--text-muted, var(--text-secondary)); font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
+.ch-bot-switch { display: inline-flex; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
+.ch-bot-switch button { min-height: 30px; padding: 0 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }
+.ch-bot-switch button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent-strong, #fff); }
+.ch-bot-card-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; color: var(--text-secondary); font-size: .9rem; }
+.ch-bot-card-line[hidden] { display: none; }
+.ch-bot-card-line button { min-height: 36px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: transparent; color: var(--text-primary); font-size: .85rem; font-weight: 600; }
#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }
#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }
#ch-bot-section > h3 { display: flex; align-items: center; gap: 9px; margin: 0; padding: 10px 14px; font-size: .9rem; font-weight: 700; color: var(--text-primary); border-bottom: 1px solid var(--border); }
#ch-bot-section > h3 { display: flex; align-items: center; gap: 9px; margin: 0; padding: 10px 14px; font-size: .9rem; font-weight: 700; color: var(--text-primary); border-bottom: 1px solid var(--border); }
#ch-bot-section > h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }
#ch-bot-section > h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
<section class="dash-card" id="ch-bench-stream" aria-label="Bench vote" hidden></section>
<section class="dash-card" id="ch-bench-stream" aria-label="Bench vote" hidden></section>
<div class="stream-layout">
<div class="stream-layout">
<div class="stream-main">
<div class="stream-main">
- <div class="video-container" id="video-container">
+ <div class="video-container" id="video-container" tabindex="-1">
<div class="video-placeholder">
<div class="video-placeholder">
<i class="fa-solid fa-satellite-dish fa-3x"></i>
<i class="fa-solid fa-satellite-dish fa-3x"></i>
<p>Connecting to stream...</p>
<p>Connecting to stream...</p>
@@ -23,6 +23,9 @@
@@ -23,6 +23,9 @@
</div>
</div>
<canvas id="video-canvas"></canvas>
<canvas id="video-canvas"></canvas>
<video id="video-element" autoplay playsinline></video>
<video id="video-element" autoplay playsinline></video>
+ <!-- OpenVibe.Bot controls over the picture (mode overlay; filled by public/js/bot-embed.js).
+ It ends above the player's bar and sits under the bar/unmute pill/chat button. -->
+ <div class="ch-bot-overlay-slot" id="ch-bot-overlay-slot" hidden></div>
<div class="video-overlay" id="video-overlay">
<div class="video-overlay" id="video-overlay">
<!-- DVR Progress Bar (hidden until live VOD available) -->
<!-- DVR Progress Bar (hidden until live VOD available) -->
<div class="dvr-progress-wrap" id="dvr-progress-wrap" style="display:none">
<div class="dvr-progress-wrap" id="dvr-progress-wrap" style="display:none">
@@ -53,6 +56,16 @@
@@ -53,6 +56,16 @@
<button class="vc-btn" id="btn-stats" title="Stream stats">
<button class="vc-btn" id="btn-stats" title="Stream stats">
<i class="fa-solid fa-gauge-high"></i>
<i class="fa-solid fa-gauge-high"></i>
</button>
</button>
+ <div class="ch-bot-mode-inline" id="ch-bot-mode-inline" hidden>
+ <button type="button" class="vc-btn" id="ch-bot-mode-btn" aria-haspopup="true" aria-expanded="false" aria-label="Robot controls: on the video" title="Robot controls: on the video">
+ <i class="fa-solid fa-robot" aria-hidden="true"></i>
+ </button>
+ <div class="ch-bot-mode-menu" id="ch-bot-mode-menu" role="menu" hidden>
+ <button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button>
+ <button type="button" role="menuitemradio" data-bot-mode="panel">Under the video</button>
+ <button type="button" role="menuitemradio" data-bot-mode="hidden">Hidden</button>
+ </div>
+ </div>
<button class="vc-btn" id="btn-clip" title="Create Clip (30s)">
<button class="vc-btn" id="btn-clip" title="Create Clip (30s)">
<i class="fa-solid fa-scissors"></i>
<i class="fa-solid fa-scissors"></i>
</button>
</button>
@@ -105,6 +118,18 @@
@@ -105,6 +118,18 @@
<!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->
<!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->
<section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>
<section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>
<h3 id="ch-bot-heading">Robot</h3>
<h3 id="ch-bot-heading">Robot</h3>
+ <div class="ch-bot-card-bar" id="ch-bot-card-bar" hidden>
+ <span class="ch-bot-card-label">Controls</span>
+ <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Robot controls">
+ <button type="button" data-bot-mode="overlay">On the video</button>
+ <button type="button" data-bot-mode="panel">Under the video</button>
+ <button type="button" data-bot-mode="hidden">Hidden</button>
+ </div>
+ </div>
+ <div class="ch-bot-card-line" id="ch-bot-card-line" hidden>
+ <span>Robot · controls are on the video</span>
+ <button type="button" data-bot-show-panel>Show panel</button>
+ </div>
<div id="ch-bot-container"></div>
<div id="ch-bot-container"></div>
</section>
</section>
<!-- Live translation captions for non-English streamers (filled by _startCaptions in app.js) -->
<!-- Live translation captions for non-English streamers (filled by _startCaptions in app.js) -->
@@ -132,6 +132,16 @@
@@ -132,6 +132,16 @@
<input type="text" id="dash-bot-robot-id" placeholder="rob_…" autocomplete="off" spellcheck="false">
<input type="text" id="dash-bot-robot-id" placeholder="rob_…" autocomplete="off" spellcheck="false">
</div>
</div>
</div>
</div>
+ <div class="settings-row">
+ <label class="settings-row-label" for="dash-bot-controls-mode">Controls start</label>
+ <div class="settings-row-content">
+ <select id="dash-bot-controls-mode">
+ <option value="panel">Under the video</option>
+ <option value="overlay">On the video</option>
+ </select>
+ <p class="muted" style="font-size:0.8rem;margin:4px 0 0">Each viewer can move them or hide them; this is where they start before anyone chooses.</p>
+ </div>
+ </div>
<button class="btn btn-primary btn-small" onclick="saveBotEmbedSetting()"><i class="fa-solid fa-floppy-disk"></i> Save</button>
<button class="btn btn-primary btn-small" onclick="saveBotEmbedSetting()"><i class="fa-solid fa-floppy-disk"></i> Save</button>
<span id="dash-bot-robot-status" class="muted" style="font-size:0.82rem" role="status"></span>
<span id="dash-bot-robot-status" class="muted" style="font-size:0.82rem" role="status"></span>
<p class="muted" style="font-size:0.8rem;margin:10px 0 0">No robot yet? <a href="https://openvibe.bot/try" target="_blank" rel="noopener">Drive a simulated one</a>, or see <a href="https://openvibe.bot/models" target="_blank" rel="noopener">what it can drive</a>.</p>
<p class="muted" style="font-size:0.8rem;margin:10px 0 0">No robot yet? <a href="https://openvibe.bot/try" target="_blank" rel="noopener">Drive a simulated one</a>, or see <a href="https://openvibe.bot/models" target="_blank" rel="noopener">what it can drive</a>.</p>
@@ -2872,16 +2872,43 @@ function _channelFooter(live) {
@@ -2872,16 +2872,43 @@ function _channelFooter(live) {
// The panel's Drive asks this page for a ticket: a viewer signed in here drives with no popup or second sign-in
// The panel's Drive asks this page for a ticket: a viewer signed in here drives with no popup or second sign-in
// (POST /api/streams/channel/:username/bot/ticket; Bot decides who may). When the robot's owner has not turned the
// (POST /api/streams/channel/:username/bot/ticket; Bot decides who may). When the robot's owner has not turned the
// panel on, viewers do not see an empty box; the owner keeps the frame, which links to the switch on openvibe.bot.
// panel on, viewers do not see an empty box; the owner keeps the frame, which links to the switch on openvibe.bot.
+// Where the controls go is the viewer's choice (on the video / under the video / hidden), kept in localStorage
+// per channel and robot and beating the streamer's default from bot_embed.controls_mode.
let _botEmbedStop = null;
let _botEmbedStop = null;
function _applyBotEmbed(botEmbed, username) {
function _applyBotEmbed(botEmbed, username) {
const box = document.getElementById('ch-bot-container');
const box = document.getElementById('ch-bot-container');
if (!box) return;
if (!box) return;
- const show = () => {
- if (typeof BotEmbed === 'undefined') return;
- const frame = BotEmbed.mount(box, botEmbed);
+ const section = document.getElementById('ch-bot-section');
+ const slot = document.getElementById('ch-bot-overlay-slot');
+ const switchWrap = document.getElementById('ch-bot-mode-inline');
+ const menu = document.getElementById('ch-bot-mode-menu');
+ const menuBtn = document.getElementById('ch-bot-mode-btn');
+ const cardBar = document.getElementById('ch-bot-card-bar');
+ const cardSwitch = document.getElementById('ch-bot-card-switch');
+ const line = document.getElementById('ch-bot-card-line');
+ const player = document.getElementById('video-container');
+ const robotId = botEmbed && botEmbed.robot_id ? botEmbed.robot_id : null;
+
+ function stopFrame() {
if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }
if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }
- if (frame && username && typeof BotEmbed.listen === 'function') {
- const robotId = botEmbed.robot_id;
+ }
+ function clear(container) {
+ if (!container) return;
+ try { container.replaceChildren(); } catch { /* a fake in the tests */ }
+ }
+ // The player's bar keeps its own room: the overlay layer ends above it, never over the controls or the pills.
+ function setBar() {
+ if (!player || !player.querySelector) return;
+ const bar = player.querySelector('.video-overlay');
+ if (!bar || !bar.getBoundingClientRect) return;
+ const h = Math.round(bar.getBoundingClientRect().height);
+ if (h > 0) player.style.setProperty('--ch-bot-bar-h', `${h}px`);
+ }
+ _applyBotEmbed.setBar = setBar;
+ function mountIn(container, mode, toggleSection) {
+ stopFrame();
+ const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'overlay' ? 'overlay' : null, toggleSection });
+ if (frame && typeof BotEmbed.listen === 'function') {
_botEmbedStop = BotEmbed.listen({
_botEmbedStop = BotEmbed.listen({
frame, botEmbed,
frame, botEmbed,
requestTicket: async () => {
requestTicket: async () => {
@@ -2892,12 +2919,151 @@ function _applyBotEmbed(botEmbed, username) {
@@ -2892,12 +2919,151 @@ function _applyBotEmbed(botEmbed, username) {
},
},
onRefused: () => {
onRefused: () => {
if (currentUser && currentUser.username === username) return;
if (currentUser && currentUser.username === username) return;
- if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }
- BotEmbed.mount(box, null);
+ stopFrame();
+ clear(slot); clear(box);
+ if (slot) slot.hidden = true;
+ if (line) line.hidden = true;
+ if (section) section.hidden = true;
},
},
+ onOpenPanel: () => _applyBotEmbed.choose('panel'),
+ onFocusPlayer: () => {
+ const v = document.getElementById('video-container');
+ if (v && v.focus) { try { v.focus({ preventScroll: true }); } catch { v.focus(); } }
+ },
+ });
+ }
+ return frame;
+ }
+ const MODE_NAME = { overlay: 'on the video', panel: 'under the video', hidden: 'hidden' };
+ function closeMenu() {
+ if (!menu) return;
+ menu.hidden = true;
+ if (menuBtn) menuBtn.setAttribute('aria-expanded', 'false');
+ }
+ function sync(mode) {
+ if (menuBtn) {
+ const label = `Robot controls: ${MODE_NAME[mode] || mode}`;
+ menuBtn.dataset.mode = mode;
+ menuBtn.setAttribute('aria-label', label);
+ menuBtn.title = label;
+ menuBtn.setAttribute('aria-expanded', menu && !menu.hidden ? 'true' : 'false');
+ }
+ const mark = (root) => {
+ if (!root || !root.querySelectorAll) return;
+ for (const b of root.querySelectorAll('[data-bot-mode]')) {
+ const on = b.dataset.botMode === mode;
+ b.setAttribute(b.getAttribute('role') === 'menuitemradio' ? 'aria-checked' : 'aria-pressed', String(on));
+ }
+ };
+ mark(menu); mark(cardSwitch);
+ }
+ function render() {
+ if (typeof BotEmbed === 'undefined') return;
+ const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId));
+ _applyBotEmbed.mode = mode;
+ _applyBotEmbed.frame = null;
+ _applyBotEmbed.botEmbed = botEmbed;
+ if (switchWrap) switchWrap.hidden = !robotId;
+ if (cardBar) cardBar.hidden = !robotId;
+ if (mode === 'overlay') {
+ if (slot) {
+ slot.hidden = false;
+ _applyBotEmbed.frame = mountIn(slot, 'overlay', false);
+ }
+ clear(box);
+ box.hidden = true;
+ if (line) line.hidden = false;
+ if (section) section.hidden = false;
+ } else if (mode === 'panel') {
+ if (slot) { clear(slot); slot.hidden = true; }
+ if (line) line.hidden = true;
+ box.hidden = false;
+ if (section) section.hidden = false;
+ _applyBotEmbed.frame = mountIn(box, 'panel', true);
+ } else {
+ stopFrame();
+ clear(box); clear(slot);
+ box.hidden = true;
+ if (slot) slot.hidden = true;
+ if (line) line.hidden = true;
+ if (section) section.hidden = true;
+ }
+ sync(mode);
+ }
+ function choose(mode, persist = true) {
+ if (typeof BotEmbed === 'undefined' || !BotEmbed.validView(mode)) return;
+ if (persist && robotId && BotEmbed.writeChoice) BotEmbed.writeChoice(window, username, robotId, mode);
+ closeMenu();
+ render();
+ }
+ _applyBotEmbed.render = render;
+ _applyBotEmbed.choose = choose;
+
+ // The two switches (the player's small one and the card's) and the overlay's way back to the full panel. One
+ // listener each for the page, whichever robot was bound last is the one they drive.
+ if (menu && menuBtn && menuBtn.dataset.botBound !== '1') {
+ menuBtn.dataset.botBound = '1';
+ menuBtn.addEventListener('click', () => {
+ const open = menu.hidden;
+ menu.hidden = !open;
+ menuBtn.setAttribute('aria-expanded', String(open));
+ });
+ menu.addEventListener('click', (e) => {
+ const b = e.target && e.target.closest ? e.target.closest('[data-bot-mode]') : null;
+ if (b) _applyBotEmbed.choose(b.dataset.botMode);
+ });
+ if (document.addEventListener) {
+ document.addEventListener('click', (e) => {
+ if (menu.hidden) return;
+ if (menu.contains && menu.contains(e.target)) return;
+ if (menuBtn.contains && menuBtn.contains(e.target)) return;
+ closeMenu();
});
});
}
}
- if (window.ov) ov.scope().onDispose(() => { if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; } BotEmbed.mount(box, null); });
+ }
+ if (cardSwitch && cardSwitch.dataset.botBound !== '1') {
+ cardSwitch.dataset.botBound = '1';
+ cardSwitch.addEventListener('click', (e) => {
+ const b = e.target && e.target.closest ? e.target.closest('[data-bot-mode]') : null;
+ if (b) _applyBotEmbed.choose(b.dataset.botMode);
+ });
+ }
+ if (line && line.dataset.botBound !== '1') {
+ line.dataset.botBound = '1';
+ line.addEventListener('click', (e) => {
+ if (e.target && e.target.closest && e.target.closest('[data-bot-show-panel]')) _applyBotEmbed.choose('panel');
+ });
+ }
+ // The pointer on the page (outside the clipped controls) wakes the overlay's idle fade.
+ if (player && player.dataset.botActivity !== '1') {
+ player.dataset.botActivity = '1';
+ player.addEventListener('pointermove', () => {
+ if (_applyBotEmbed.mode !== 'overlay' || !_applyBotEmbed.frame || typeof BotEmbed === 'undefined') return;
+ const now = Date.now();
+ if (now - (_applyBotEmbed.activityAt || 0) < 250) return;
+ _applyBotEmbed.activityAt = now;
+ BotEmbed.notifyActivity(_applyBotEmbed.frame, _applyBotEmbed.botEmbed);
+ }, { passive: true });
+ }
+ setBar();
+ if (player && typeof ResizeObserver === 'function' && !_applyBotEmbed.barObserver) {
+ const bar = player.querySelector && player.querySelector('.video-overlay');
+ if (bar) {
+ _applyBotEmbed.barObserver = new ResizeObserver(() => _applyBotEmbed.setBar());
+ _applyBotEmbed.barObserver.observe(bar);
+ }
+ }
+
+ const show = () => {
+ if (typeof BotEmbed === 'undefined') return;
+ render();
+ if (window.ov) ov.scope().onDispose(() => {
+ stopFrame();
+ clear(slot);
+ if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);
+ else clear(box);
+ if (_applyBotEmbed.barObserver) { _applyBotEmbed.barObserver.disconnect(); _applyBotEmbed.barObserver = null; }
+ });
};
};
if (typeof BotEmbed !== 'undefined') return show();
if (typeof BotEmbed !== 'undefined') return show();
if (!(botEmbed && botEmbed.url) || !window.ov) return;
if (!(botEmbed && botEmbed.url) || !window.ov) return;
@@ -35,7 +35,11 @@
@@ -35,7 +35,11 @@
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
- function embedSrc(botEmbed) {
+ const PANEL_MODES = ['panel', 'overlay', 'compact'];
+ const VIEW_MODES = ['overlay', 'panel', 'hidden'];
+ const CHOICE_KEY = 'ov:live:bot-controls';
+
+ function embedSrc(botEmbed, mode = null) {
if (!botEmbed || typeof botEmbed.url !== 'string' || !botEmbed.url) return null;
if (!botEmbed || typeof botEmbed.url !== 'string' || !botEmbed.url) return null;
let u;
let u;
try { u = new URL(botEmbed.url); } catch { return null; }
try { u = new URL(botEmbed.url); } catch { return null; }
@@ -43,11 +47,15 @@
@@ -43,11 +47,15 @@
const local = u.hostname === 'localhost' || u.hostname.endsWith('.localhost');
const local = u.hostname === 'localhost' || u.hostname.endsWith('.localhost');
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;
if (!/\/embed$/.test(u.pathname)) return null;
if (!/\/embed$/.test(u.pathname)) return null;
+ if (mode != null) {
+ if (!PANEL_MODES.includes(mode)) return null;
+ u.searchParams.set('mode', mode);
+ }
return u.href;
return u.href;
}
}
- function iframeAttrs(botEmbed) {
- const src = embedSrc(botEmbed);
+ function iframeAttrs(botEmbed, { mode = null } = {}) {
+ const src = embedSrc(botEmbed, mode);
if (!src) return null;
if (!src) return null;
return {
return {
src,
src,
@@ -63,10 +71,10 @@
@@ -63,10 +71,10 @@
return (container.closest && container.closest('section')) || container.parentNode || null;
return (container.closest && container.closest('section')) || container.parentNode || null;
}
}
- function mount(container, botEmbed) {
+ function mount(container, botEmbed, { mode = null, toggleSection = true } = {}) {
if (!container) return null;
if (!container) return null;
- const section = sectionOf(container);
- const attrs = iframeAttrs(botEmbed);
+ const section = toggleSection ? sectionOf(container) : null;
+ const attrs = iframeAttrs(botEmbed, { mode });
if (!attrs) {
if (!attrs) {
container.replaceChildren();
container.replaceChildren();
if (section) section.hidden = true;
if (section) section.hidden = true;
@@ -84,6 +92,71 @@
@@ -84,6 +92,71 @@
return frame;
return frame;
}
}
+ // ── The viewer's choice, per channel and robot ────────────────────────────────────────────────────
+ // No account is needed: localStorage only. A stored valid choice beats the streamer's default; without one
+ // the channel's `bot_embed.controls_mode` decides, and 'panel' is the old behaviour.
+ function choiceKey(channel, robotId) {
+ return `${CHOICE_KEY}:${String(channel || '')}:${String(robotId || '')}`;
+ }
+ function validView(mode) { return VIEW_MODES.includes(mode) ? mode : null; }
+ function defaultMode(botEmbed) { return botEmbed && botEmbed.controls_mode === 'overlay' ? 'overlay' : 'panel'; }
+ function effectiveMode(botEmbed, stored) {
+ return validView(stored) || defaultMode(botEmbed);
+ }
+ function readChoice(win, channel, robotId) {
+ try {
+ const s = win && win.localStorage;
+ return s ? validView(s.getItem(choiceKey(channel, robotId))) : null;
+ } catch { return null; }
+ }
+ function writeChoice(win, channel, robotId, mode) {
+ if (!validView(mode)) return false;
+ try {
+ const s = win && win.localStorage;
+ if (!s) return false;
+ s.setItem(choiceKey(channel, robotId), mode);
+ return true;
+ } catch { return false; }
+ }
+
+ /** The control rectangles from ov-bot-size.hit → a clip-path path, or null when there is nothing usable. */
+ function clipPathFor(hit) {
+ if (!Array.isArray(hit)) return null;
+ const rects = hit.filter((r) => r && [r.x, r.y, r.w, r.h].every((n) => Number.isFinite(Number(n))) && Number(r.w) > 0 && Number(r.h) > 0)
+ .slice(0, 12)
+ .map((r) => ({ x: Math.max(0, Number(r.x)), y: Math.max(0, Number(r.y)), w: Number(r.w), h: Number(r.h) }));
+ if (!rects.length) return null;
+ const path = rects.map((r) => `M${Math.round(r.x)} ${Math.round(r.y)} H${Math.round(r.x + r.w)} V${Math.round(r.y + r.h)} H${Math.round(r.x)} Z`).join(' ');
+ return `path("${path}")`;
+ }
+ function applyClip(frame, message) {
+ try {
+ if (!frame || !frame.style) return;
+ const path = message && message.mode === 'overlay' ? clipPathFor(message.hit) : null;
+ frame.style.clipPath = path || '';
+ } catch { /* a fake frame in the tests */ }
+ }
+
+ /** Ask the frame to switch view; it reloads itself in that view (its controls come from the robot's profile). */
+ function setMode(frame, botEmbed, mode) {
+ const src = embedSrc(botEmbed, mode);
+ if (!frame || !src || !frame.contentWindow) return false;
+ try {
+ frame.contentWindow.postMessage({ type: 'ov-bot-mode', robot_id: botEmbed.robot_id, mode }, new URL(src).origin);
+ return true;
+ } catch { return false; }
+ }
+
+ /** Tell the frame the pointer is moving on the page (the overlay wakes from its idle fade). */
+ function notifyActivity(frame, botEmbed) {
+ const src = embedSrc(botEmbed);
+ if (!frame || !src || !frame.contentWindow) return false;
+ try {
+ frame.contentWindow.postMessage({ type: 'ov-bot-activity', robot_id: botEmbed.robot_id }, new URL(src).origin);
+ return true;
+ } catch { return false; }
+ }
+
// The panel takes the page's colours (OpenVibe.Bot public/embed-frame.js): these tokens, read from the page's
// The panel takes the page's colours (OpenVibe.Bot public/embed-frame.js): these tokens, read from the page's
// computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.
// computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.
const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];
const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];
@@ -111,7 +184,7 @@
@@ -111,7 +184,7 @@
} catch { return null; }
} catch { return null; }
}
}
- function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, win = typeof window !== 'undefined' ? window : null }) {
+ function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, onOpenPanel = () => {}, onFocusPlayer = () => {}, onSize = null, win = typeof window !== 'undefined' ? window : null }) {
const src = embedSrc(botEmbed);
const src = embedSrc(botEmbed);
if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};
if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};
const origin = new URL(src).origin;
const origin = new URL(src).origin;
@@ -224,8 +297,18 @@
@@ -224,8 +297,18 @@
const d = e.data || {};
const d = e.data || {};
// The panel's size is about this frame, not about driving: accept it with or without the id, but never
// The panel's size is about this frame, not about driving: accept it with or without the id, but never
// for another robot.
// for another robot.
- if (d.type === 'ov-bot-size') { if (d.robot_id == null || d.robot_id === robotId) setHeight(d.height); return; }
+ if (d.type === 'ov-bot-size') {
+ if (d.robot_id == null || d.robot_id === robotId) {
+ // An overlay fills the player; its clip keeps the presses outside the controls going through.
+ if (d.mode === 'overlay') applyClip(frame, d);
+ else { applyClip(frame, null); setHeight(d.height); }
+ if (typeof onSize === 'function') onSize(d);
+ }
+ return;
+ }
if (!d || d.robot_id !== robotId) return;
if (!d || d.robot_id !== robotId) return;
+ if (d.type === 'ov-bot-open-panel') { onOpenPanel(); return; }
+ if (d.type === 'ov-bot-blur-controls') { onFocusPlayer(); return; }
if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }
if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }
if (d.type === 'ov-bot-controls') { setControls(d); return; }
if (d.type === 'ov-bot-controls') { setControls(d); return; }
if (d.type !== 'ov-bot-ticket-request' || asking) return;
if (d.type !== 'ov-bot-ticket-request' || asking) return;
@@ -250,5 +333,5 @@
@@ -250,5 +333,5 @@
};
};
}
}
- return { iframeAttrs, mount, listen, THEME_TOKENS, MIN_HEIGHT, MAX_HEIGHT };
+ return { iframeAttrs, mount, listen, embedSrc, choiceKey, validView, defaultMode, effectiveMode, readChoice, writeChoice, clipPathFor, applyClip, setMode, notifyActivity, VIEW_MODES, PANEL_MODES, THEME_TOKENS, MIN_HEIGHT, MAX_HEIGHT };
});
});
@@ -775,16 +775,21 @@ async function loadBotEmbedSetting() {
@@ -775,16 +775,21 @@ async function loadBotEmbedSetting() {
const be = data && data.bot_embed;
const be = data && data.bot_embed;
if (!be || !be.enabled) { card.style.display = 'none'; return; }
if (!be || !be.enabled) { card.style.display = 'none'; return; }
document.getElementById('dash-bot-robot-id').value = be.robot_id || '';
document.getElementById('dash-bot-robot-id').value = be.robot_id || '';
+ const mode = document.getElementById('dash-bot-controls-mode');
+ if (mode) mode.value = be.controls_mode === 'overlay' ? 'overlay' : 'panel';
card.style.display = '';
card.style.display = '';
} catch { card.style.display = 'none'; }
} catch { card.style.display = 'none'; }
}
}
async function saveBotEmbedSetting() {
async function saveBotEmbedSetting() {
const input = document.getElementById('dash-bot-robot-id');
const input = document.getElementById('dash-bot-robot-id');
+ const mode = document.getElementById('dash-bot-controls-mode');
const status = document.getElementById('dash-bot-robot-status');
const status = document.getElementById('dash-bot-robot-status');
if (!input || !currentUser) return;
if (!input || !currentUser) return;
const id = input.value.trim();
const id = input.value.trim();
try {
try {
- const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body: { robot_id: id || null } });
+ const body = { robot_id: id || null };
+ if (mode && (mode.value === 'overlay' || mode.value === 'panel')) body.controls_mode = mode.value;
+ const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body });
const bound = data && data.bot_embed && data.bot_embed.robot_id;
const bound = data && data.bot_embed && data.bot_embed.robot_id;
if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';
if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';
} catch (e) {
} catch (e) {
@@ -49,11 +49,15 @@ function embedUrl(robotId) {
@@ -49,11 +49,15 @@ function embedUrl(robotId) {
return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;
return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;
}
}
-/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url }. */
+/** The two places a streamer may put the controls by default; a viewer may still hide them or move them. */
+const CONTROL_MODES = ['overlay', 'panel'];
+const controlMode = (value) => (CONTROL_MODES.includes(value) ? value : 'panel');
+
+/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url, controls_mode }. */
function channelEmbed(channel) {
function channelEmbed(channel) {
if (!enabled()) return undefined;
if (!enabled()) return undefined;
const id = channel && validRobotId(channel.bot_robot_id) ? channel.bot_robot_id : null;
const id = channel && validRobotId(channel.bot_robot_id) ? channel.bot_robot_id : null;
- return { enabled: true, robot_id: id, url: id ? embedUrl(id) : null };
+ return { enabled: true, robot_id: id, url: id ? embedUrl(id) : null, controls_mode: controlMode(channel && channel.bot_controls_mode) };
}
}
-module.exports = { enabled, botOrigin, validRobotId, embedUrl, channelEmbed, DEFAULT_BOT_URL };
+module.exports = { enabled, botOrigin, validRobotId, embedUrl, channelEmbed, controlMode, CONTROL_MODES, DEFAULT_BOT_URL };
@@ -29,13 +29,29 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
@@ -29,13 +29,29 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
if (Number(req.user.id) !== Number(channel.user_id)) {
if (Number(req.user.id) !== Number(channel.user_id)) {
return res.status(403).json({ error: 'Only the channel owner can bind a robot' });
return res.status(403).json({ error: 'Only the channel owner can bind a robot' });
}
}
- const raw = req.body ? req.body.robot_id : undefined;
- let robotId;
- if (raw === null || raw === '') robotId = null;
- else if (embed.validRobotId(raw)) robotId = raw;
- else return res.status(400).json({ error: 'robot_id must be a Bot robot id (rob_…) or null' });
- await db.updateChannel(channel.user_id, { bot_robot_id: robotId });
- res.json({ ok: true, bot_embed: embed.channelEmbed({ bot_robot_id: robotId }) });
+ const body = req.body || {};
+ const hasRobot = Object.prototype.hasOwnProperty.call(body, 'robot_id');
+ const hasMode = Object.prototype.hasOwnProperty.call(body, 'controls_mode');
+ if (!hasRobot && !hasMode) return res.status(400).json({ error: 'send robot_id, controls_mode, or both' });
+ let robotId = channel.bot_robot_id || null;
+ if (hasRobot) {
+ const raw = body.robot_id;
+ if (raw === null || raw === '') robotId = null;
+ else if (embed.validRobotId(raw)) robotId = raw;
+ else return res.status(400).json({ error: 'robot_id must be a Bot robot id (rob_…) or null' });
+ }
+ let mode = embed.controlMode(channel.bot_controls_mode);
+ if (hasMode) {
+ if (!embed.CONTROL_MODES.includes(body.controls_mode)) {
+ return res.status(400).json({ error: "controls_mode must be 'overlay' or 'panel'" });
+ }
+ mode = body.controls_mode;
+ }
+ const patch = {};
+ if (hasRobot) patch.bot_robot_id = robotId;
+ if (hasMode) patch.bot_controls_mode = mode;
+ await db.updateChannel(channel.user_id, patch);
+ res.json({ ok: true, bot_embed: embed.channelEmbed({ bot_robot_id: robotId, bot_controls_mode: mode }) });
} catch (err) {
} catch (err) {
console.error('[BotEmbed] Bind error:', err.message);
console.error('[BotEmbed] Bind error:', err.message);
res.status(500).json({ error: 'Failed to bind robot' });
res.status(500).json({ error: 'Failed to bind robot' });
@@ -1895,7 +1895,7 @@ async function updateChannel(userId, fields) {
@@ -1895,7 +1895,7 @@ async function updateChannel(userId, fields) {
const updates = [];
const updates = [];
const params = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
for (const [key, val] of Object.entries(fields)) {
- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id'].includes(key)) {
+ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id', 'bot_controls_mode'].includes(key)) {
updates.push(`${key} = ?`);
updates.push(`${key} = ?`);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
}
}
@@ -561,6 +561,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -561,6 +561,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
delete publicChannel.force_vod_recording_disabled;
delete publicChannel.force_vod_recording_disabled;
// The binding is only shown through `bot_embed` below, and only with LIVE_BOT_EMBED on.
// The binding is only shown through `bot_embed` below, and only with LIVE_BOT_EMBED on.
delete publicChannel.bot_robot_id;
delete publicChannel.bot_robot_id;
+ delete publicChannel.bot_controls_mode;
const botEmbed = require('../bot/embed').channelEmbed(channel);
const botEmbed = require('../bot/embed').channelEmbed(channel);
// Counts for tab badges. Owner/mods see hidden ones too. Pastes are counted by
// Counts for tab badges. Owner/mods see hidden ones too. Pastes are counted by
@@ -38,7 +38,7 @@ function publicManagedStream(ms) {
@@ -38,7 +38,7 @@ function publicManagedStream(ms) {
/** A channel row as anyone may see it. `weather_enabled` replaces the ZIP it is derived from. */
/** A channel row as anyone may see it. `weather_enabled` replaces the ZIP it is derived from. */
function publicChannel(ch) {
function publicChannel(ch) {
if (!ch || typeof ch !== 'object') return ch;
if (!ch || typeof ch !== 'object') return ch;
- const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled', 'bot_robot_id']);
+ const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled', 'bot_robot_id', 'bot_controls_mode']);
if (!('weather_enabled' in out)) out.weather_enabled = !!(ch.weather_zip && ch.weather_detail && ch.weather_detail !== 'off');
if (!('weather_enabled' in out)) out.weather_enabled = !!(ch.weather_zip && ch.weather_detail && ch.weather_detail !== 'off');
return out;
return out;
}
}
@@ -87,20 +87,21 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
@@ -87,20 +87,21 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
});
});
await check('flag off: the channel GET has no bot_embed key and no bot_robot_id even when one is stored', async () => {
await check('flag off: the channel GET has no bot_embed key and no bot_robot_id even when one is stored', async () => {
- await raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_stored1', chan.id);
+ await raw.prepare('UPDATE channels SET bot_robot_id = ?, bot_controls_mode = ? WHERE id = ?').run('rob_stored1', 'overlay', chan.id);
const r = await channelGet();
const r = await channelGet();
assert.strictEqual(r.status, 200, r.text.slice(0, 200));
assert.strictEqual(r.status, 200, r.text.slice(0, 200));
assert.ok(!('bot_embed' in r.body), 'no bot_embed key');
assert.ok(!('bot_embed' in r.body), 'no bot_embed key');
assert.ok(!('bot_robot_id' in r.body.channel), 'no raw column on the public channel');
assert.ok(!('bot_robot_id' in r.body.channel), 'no raw column on the public channel');
+ assert.ok(!('bot_controls_mode' in r.body.channel), 'the controls default is only shown through bot_embed');
assert.ok(!r.text.includes('rob_stored1'));
assert.ok(!r.text.includes('rob_stored1'));
- await raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(chan.id);
+ await raw.prepare('UPDATE channels SET bot_robot_id = NULL, bot_controls_mode = ? WHERE id = ?').run('panel', chan.id);
});
});
process.env.LIVE_BOT_EMBED = '1';
process.env.LIVE_BOT_EMBED = '1';
await check('flag on, unbound: bot_embed is enabled with no robot', async () => {
await check('flag on, unbound: bot_embed is enabled with no robot', async () => {
const r = await channelGet();
const r = await channelGet();
- assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null });
+ assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null, controls_mode: 'panel' });
});
});
await check('flag on: anonymous 401, non-owner 403, channel mod 403', async () => {
await check('flag on: anonymous 401, non-owner 403, channel mod 403', async () => {
@@ -127,10 +128,28 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
@@ -127,10 +128,28 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
const r = await bind('rob_sim123');
const r = await bind('rob_sim123');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const g = await channelGet();
const g = await channelGet();
- assert.deepStrictEqual(g.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed' });
+ assert.deepStrictEqual(g.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'panel' });
assert.ok(!('bot_robot_id' in g.body.channel));
assert.ok(!('bot_robot_id' in g.body.channel));
});
});
+ await check('controls_mode: the streamer\'s default is saved with the binding, served, and validated', async () => {
+ // Only the default: the robot stays bound.
+ const mode = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { controls_mode: 'overlay' } });
+ assert.strictEqual(mode.status, 200, mode.text);
+ assert.deepStrictEqual(mode.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'overlay' });
+ assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'overlay', 'the channel serializer carries it to the page');
+ // Both together, then an invalid value changes nothing.
+ const both = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { robot_id: 'rob_sim123', controls_mode: 'panel' } });
+ assert.strictEqual(both.status, 200);
+ assert.strictEqual(both.body.bot_embed.controls_mode, 'panel');
+ const bad = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { controls_mode: 'hidden' } });
+ assert.strictEqual(bad.status, 400, 'hidden is a viewer choice, not a streamer default');
+ assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'panel');
+ const empty = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: {} });
+ assert.strictEqual(empty.status, 400, 'a PUT must say what to change');
+ assert.strictEqual((await db.getChannelByUserId(1)).bot_controls_mode, 'panel');
+ });
+
await check('a custom LIVE_BOT_URL changes the origin', async () => {
await check('a custom LIVE_BOT_URL changes the origin', async () => {
process.env.LIVE_BOT_URL = 'https://bot.example.test/';
process.env.LIVE_BOT_URL = 'https://bot.example.test/';
assert.strictEqual((await channelGet()).body.bot_embed.url, 'https://bot.example.test/panel/rob_sim123/embed');
assert.strictEqual((await channelGet()).body.bot_embed.url, 'https://bot.example.test/panel/rob_sim123/embed');
@@ -157,7 +176,7 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
@@ -157,7 +176,7 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
});
});
await check('null and empty string unbind; url is null', async () => {
await check('null and empty string unbind; url is null', async () => {
- const unbound = { enabled: true, robot_id: null, url: null };
+ const unbound = { enabled: true, robot_id: null, url: null, controls_mode: 'panel' };
await bind('rob_sim123');
await bind('rob_sim123');
const a = await bind(null);
const a = await bind(null);
assert.strictEqual(a.status, 200, a.text);
assert.strictEqual(a.status, 200, a.text);
@@ -29,7 +29,7 @@ console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
@@ -29,7 +29,7 @@ console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
const BotEmbed = require('../public/js/bot-embed.js');
const BotEmbed = require('../public/js/bot-embed.js');
-const SIM = { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed' };
+const SIM = { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'panel' };
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
// Just enough DOM for mount(): elements with attributes, children, hidden and a section parent.
// Just enough DOM for mount(): elements with attributes, children, hidden and a section parent.
@@ -116,6 +116,42 @@ async function check(name, fn) {
@@ -116,6 +116,42 @@ async function check(name, fn) {
assert.strictEqual(BotEmbed.mount(null, SIM), null, 'no container (another page) is a no-op');
assert.strictEqual(BotEmbed.mount(null, SIM), null, 'no container (another page) is a no-op');
});
});
+ await check('modes: the viewer\'s remembered choice beats the streamer\'s default, and the frame asks for that view', () => {
+ const overlayDefault = { ...SIM, controls_mode: 'overlay' };
+ assert.strictEqual(BotEmbed.effectiveMode(SIM, null), 'panel', 'no default and no choice: under the video');
+ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, null), 'overlay', 'the streamer put them on the video');
+ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'panel'), 'panel', 'a viewer may move them under the video');
+ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'overlay'), 'overlay', 'and onto it');
+ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'hidden'), 'hidden', 'or hide them');
+ assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'bogus'), 'overlay', 'a bad stored value is ignored');
+ assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'bogus' }, null), 'panel', 'a bad streamer default is panel');
+ assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'overlay' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=overlay');
+ assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'compact' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=compact');
+ assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'bogus' }), null, 'only the three views');
+ assert.strictEqual(BotEmbed.iframeAttrs(SIM).src, SIM.url, 'the default stays the plain embed url');
+ const saved = new Map();
+ const win = { localStorage: { getItem: (k) => (saved.has(k) ? saved.get(k) : null), setItem: (k, v) => saved.set(k, String(v)) } };
+ assert.strictEqual(BotEmbed.readChoice(win, 'rover', 'rob_sim123'), null);
+ assert.strictEqual(BotEmbed.writeChoice(win, 'rover', 'rob_sim123', 'overlay'), true);
+ assert.strictEqual(BotEmbed.readChoice(win, 'rover', 'rob_sim123'), 'overlay');
+ assert.strictEqual(BotEmbed.readChoice(win, 'other', 'rob_sim123'), null, 'per channel');
+ assert.strictEqual(BotEmbed.readChoice(win, 'rover', 'rob_other'), null, 'per robot');
+ assert.strictEqual(BotEmbed.choiceKey('rover', 'rob_sim123'), 'ov:live:bot-controls:rover:rob_sim123');
+ assert.strictEqual(BotEmbed.writeChoice(win, 'rover', 'rob_sim123', 'bogus'), false);
+ assert.strictEqual(BotEmbed.writeChoice({ localStorage: { setItem() { throw new Error('blocked'); } } }, 'rover', 'rob_sim123', 'panel'), false, 'a browser that refuses storage is harmless');
+ });
+
+ await check('clipPathFor: the controls\' rectangles become one clip path; malformed boxes are ignored', () => {
+ assert.strictEqual(BotEmbed.clipPathFor([{ x: 10, y: 20, w: 44, h: 44 }]),
+ 'path("M10 20 H54 V64 H10 Z")');
+ assert.strictEqual(BotEmbed.clipPathFor([{ x: 0, y: 0, w: 132, h: 132 }, { x: 300.4, y: 240.2, w: 44, h: 132 }]),
+ 'path("M0 0 H132 V132 H0 Z M300 240 H344 V372 H300 Z")');
+ assert.strictEqual(BotEmbed.clipPathFor([{ x: 0, y: 0, w: 0, h: 44 }, { x: 'no', y: 1, w: 2, h: 3 }, null]), null);
+ assert.strictEqual(BotEmbed.clipPathFor('nope'), null);
+ const tooMany = Array.from({ length: 20 }, (_, i) => ({ x: i, y: i, w: 4, h: 4 }));
+ assert.strictEqual((BotEmbed.clipPathFor(tooMany).match(/M/g) || []).length, 12, 'at most the known twelve boxes');
+ });
+
await check('page wiring: the script is its own lazy feature and the section starts hidden', () => {
await check('page wiring: the script is its own lazy feature and the section starts hidden', () => {
const features = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'features.json'), 'utf8')).features;
const features = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'features.json'), 'utf8')).features;
assert.deepStrictEqual(features.botEmbed.js, ['/js/bot-embed.js']);
assert.deepStrictEqual(features.botEmbed.js, ['/js/bot-embed.js']);
@@ -216,7 +252,7 @@ async function check(name, fn) {
@@ -216,7 +252,7 @@ async function check(name, fn) {
await check('flag on, unbound: the page still loads nothing', async () => {
await check('flag on, unbound: the page still loads nothing', async () => {
assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { robot_id: null })).status, 200);
assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { robot_id: null })).status, 200);
const r = await channelGet();
const r = await channelGet();
- assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null });
+ assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null, controls_mode: 'panel' });
const p = page();
const p = page();
await runApply(p, r.body.bot_embed);
await runApply(p, r.body.bot_embed);
assert.deepStrictEqual(p.loads, []);
assert.deepStrictEqual(p.loads, []);
@@ -231,13 +267,13 @@ async function check(name, fn) {
@@ -231,13 +267,13 @@ async function check(name, fn) {
const p = page();
const p = page();
await runApply(p, r.body.bot_embed);
await runApply(p, r.body.bot_embed);
assert.deepStrictEqual(p.loads, ['botEmbed']);
assert.deepStrictEqual(p.loads, ['botEmbed']);
- assert.strictEqual(p.dom.container.children.length, 1);
+ assert.strictEqual(p.dom.container.children.length, 1, 'one frame');
const frame = p.dom.container.children[0];
const frame = p.dom.container.children[0];
assert.strictEqual(frame.tagName, 'IFRAME');
assert.strictEqual(frame.tagName, 'IFRAME');
- assert.strictEqual(frame.getAttribute('src'), 'https://openvibe.bot/panel/rob_sim123/embed');
- assert.strictEqual(frame.getAttribute('sandbox'), SANDBOX);
- assert.strictEqual(p.dom.section.hidden, false);
- assert.strictEqual(p.disposers.length, 1);
+ assert.strictEqual(frame.getAttribute('src'), 'https://openvibe.bot/panel/rob_sim123/embed', 'panel src');
+ assert.strictEqual(frame.getAttribute('sandbox'), SANDBOX, 'sandbox');
+ assert.strictEqual(p.dom.section.hidden, false, 'section shown');
+ assert.strictEqual(p.disposers.length, 1, 'one disposer');
p.disposers[0]();
p.disposers[0]();
assert.deepStrictEqual(p.dom.container.children, [], 'leaving the route removes the frame');
assert.deepStrictEqual(p.dom.container.children, [], 'leaving the route removes the frame');
assert.strictEqual(p.dom.section.hidden, true);
assert.strictEqual(p.dom.section.hidden, true);
@@ -357,6 +393,32 @@ async function check(name, fn) {
@@ -357,6 +393,32 @@ async function check(name, fn) {
assert.ok(!winListeners.get('ov:theme').size && !winListeners.get('ov:display').size && !frameListeners.get('load').size, 'stop() removes them');
assert.ok(!winListeners.get('ov:theme').size && !winListeners.get('ov:display').size && !frameListeners.get('load').size, 'stop() removes them');
});
});
+ await check('listen(): an overlay fills the player, clips to its controls, and reports more/blur to the page', async () => {
+ const listeners = new Set();
+ const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };
+ let opened = 0;
+ let focused = 0;
+ let sized = null;
+ const panel = { postMessage: () => {} };
+ const frame = { contentWindow: panel, style: {}, closest: () => null };
+ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => ({}),
+ onOpenPanel: () => { opened++; }, onFocusPlayer: () => { focused++; }, onSize: (d) => { sized = d; } });
+ const send = async (data) => { for (const f of [...listeners]) await f({ data, origin: 'https://openvibe.bot', source: panel }); };
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', mode: 'overlay', height: 390, minWidth: 280, minHeight: 196,
+ hit: [{ x: 16, y: 238, w: 132, h: 132 }, { x: 330, y: 238, w: 44, h: 132 }] });
+ assert.strictEqual(frame.style.clipPath, 'path("M16 238 H148 V370 H16 Z M330 238 H374 V370 H330 Z")');
+ assert.strictEqual(frame.style.height, undefined, 'the overlay fills its slot; it is not a card to resize');
+ assert.strictEqual(sized && sized.mode, 'overlay');
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', mode: 'overlay', height: 390, hit: [] });
+ assert.strictEqual(frame.style.clipPath, '', 'no boxes: no clip rather than a frame that swallows the video');
+ await send({ type: 'ov-bot-size', robot_id: 'rob_sim123', mode: 'panel', height: 500 });
+ assert.strictEqual(frame.style.clipPath, '', 'the full panel is a normal card again');
+ await send({ type: 'ov-bot-open-panel', robot_id: 'rob_sim123' });
+ await send({ type: 'ov-bot-blur-controls', robot_id: 'rob_sim123' });
+ assert.deepStrictEqual([opened, focused], [1, 1], 'more asks for the full panel; Escape gives the player focus');
+ stop();
+ });
+
await check('listen(): the panel\'s height sets the frame between 160 and 720 px, and only from its own frame', async () => {
await check('listen(): the panel\'s height sets the frame between 160 and 720 px, and only from its own frame', async () => {
const listeners = new Set();
const listeners = new Set();
const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };
const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+/**
+ * The robot controls over the player: a real Chrome at 390×780 (phone upright), 844×390 (phone on its side)
+ * and 1280×800. Needs a running site with a channel bound to a robot, and Chrome (not part of `npm test`).
+ *
+ * BASE=https://openvibe.live CHANNEL=/@Maticus npm run test:bot-overlay
+ * OUT=/tmp/shots BASE=http://127.0.0.1:4188 CHANNEL=/@rover node test/browser/bot-overlay.js
+ *
+ * It forces the live area visible when the channel is offline (the same trick the deploy check uses), chooses
+ * "Controls: on the video", then checks:
+ * - the overlay slot never reaches into the player's controls bar, unmute overlay or the chat fold button
+ * (the controls' z-index stays below all three);
+ * - the frame is clipped to the controls (a clip-path with at least one rectangle), so a press in the middle
+ * of the picture reaches the player instead of the transparent iframe;
+ * - the card under the player is the one line with "Show panel";
+ * - a screenshot at each size under OUT.
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
+
+const BASE = (process.env.BASE || 'https://openvibe.live').replace(/\/$/, '');
+const CHANNEL = process.env.CHANNEL || '/@Maticus';
+const OUT = process.env.OUT || '/home/workstation/openvibe/research-2026-11-10/controls-overlay';
+const SIZES = [[390, 780, 'portrait'], [844, 390, 'landscape'], [1280, 800, 'desktop']];
+
+async function forceLive(page) {
+ await page.evaluate(() => {
+ const live = document.getElementById('ch-live-area');
+ const off = document.getElementById('ch-offline-area');
+ if (live) live.style.display = '';
+ if (off) off.style.display = 'none';
+ const channel = document.getElementById('page-channel');
+ if (channel) channel.classList.add('ch-is-live');
+ const v = document.getElementById('video-element');
+ if (v) { v.style.display = 'block'; v.style.opacity = '1'; v.style.filter = 'none'; v.style.transform = 'none'; }
+ const box = document.getElementById('video-container');
+ if (box) box.style.background = '#0b0d12';
+ });
+}
+
+async function pickOverlay(page) {
+ const inline = page.locator('#ch-bot-mode-inline');
+ await inline.waitFor({ state: 'visible', timeout: 20000 });
+ const btn = page.locator('#ch-bot-mode-btn');
+ if ((await btn.getAttribute('data-mode')) !== 'overlay') {
+ await btn.click();
+ await page.locator('#ch-bot-mode-menu [data-bot-mode="overlay"]').click();
+ }
+ await page.waitForFunction(() => {
+ const slot = document.getElementById('ch-bot-overlay-slot');
+ return slot && !slot.hidden && slot.querySelector('iframe');
+ }, null, { timeout: 20000 });
+ const frame = page.locator('#ch-bot-overlay-slot iframe');
+ await frame.waitFor({ state: 'visible', timeout: 20000 });
+ return frame;
+}
+
+(async () => {
+ fs.mkdirSync(OUT, { recursive: true });
+ const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
+ const context = await browser.newContext();
+ let failed = 0;
+ for (const [width, height, name] of SIZES) {
+ const page = await context.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewportSize({ width, height });
+ await page.goto(BASE + CHANNEL, { waitUntil: 'load' });
+ await page.waitForTimeout(1500);
+ try {
+ await forceLive(page);
+ await pickOverlay(page);
+ await page.waitForTimeout(1200);
+ const m = await page.evaluate(() => {
+ const rect = (el) => (el ? el.getBoundingClientRect().toJSON() : null);
+ const z = (el) => { if (!el) return null; const v = getComputedStyle(el).zIndex; return v === 'auto' ? 0 : Number(v) || 0; };
+ const slot = document.getElementById('ch-bot-overlay-slot');
+ const player = document.getElementById('video-container');
+ const bar = player && player.querySelector('.video-overlay');
+ const unmute = document.getElementById('unmute-overlay');
+ const fold = document.getElementById('ch-chat-reopen');
+ const frame = slot && slot.querySelector('iframe');
+ return {
+ slot: rect(slot), player: rect(player), bar: rect(bar), unmute: rect(unmute), fold: fold && !fold.hidden ? rect(fold) : null,
+ zSlot: z(slot), zBar: z(bar), zUnmute: z(unmute), zFold: z(fold),
+ clip: frame ? (frame.style.clipPath || getComputedStyle(frame).clipPath) : null,
+ cardLine: (() => { const el = document.getElementById('ch-bot-card-line'); return el ? !el.hidden : false; })(),
+ cardFrame: !!document.querySelector('#ch-bot-container iframe'),
+ };
+ });
+ assert.ok(m.slot && m.player, 'the player and its overlay slot are on the page');
+ assert.ok(m.slot.bottom <= m.bar.top + 0.5, `the slot ends above the player's bar (slot ${m.slot.bottom} > bar ${m.bar.top})`);
+ if (m.fold) assert.ok(m.slot.right <= m.fold.left || m.slot.left >= m.fold.right || m.slot.bottom <= m.fold.top || m.slot.top >= m.fold.bottom, 'the slot never covers the chat fold button');
+ assert.ok(m.zSlot < m.zBar, `the slot stays under the player's bar (${m.zSlot} < ${m.zBar})`);
+ if (m.unmute) assert.ok(m.zSlot < m.zUnmute, `and under the unmute pill (${m.zSlot} < ${m.zUnmute})`);
+ if (m.fold && m.zFold != null) assert.ok(m.zSlot < m.zFold, `and under the chat fold button (${m.zSlot} < ${m.zFold})`);
+ assert.ok(m.clip && /M\\d+\\s+\\d+/.test(m.clip), `the frame is clipped to its controls (${m.clip})`);
+ assert.ok(m.cardLine && !m.cardFrame, 'the card under the player is the one-line state, not a second frame');
+ // A press in the middle of the picture must reach the player, not the transparent frame.
+ const cx = m.player.x + m.player.width / 2;
+ const cy = m.player.y + m.player.height / 2;
+ await page.evaluate(() => {
+ window.__ovVideoClicks = 0;
+ const player = document.getElementById('video-container');
+ player.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('#ch-bot-overlay-slot')) window.__ovVideoClicks++; }, true);
+ });
+ const hit = await page.evaluate(([x, y]) => {
+ const el = document.elementFromPoint(x, y);
+ return { id: el && el.id, inSlot: !!(el && el.closest && el.closest('#ch-bot-overlay-slot')) };
+ }, [cx, cy]);
+ await page.mouse.click(cx, cy);
+ await page.waitForTimeout(250);
+ const clicks = await page.evaluate(() => window.__ovVideoClicks || 0);
+ assert.ok(!hit.inSlot, `the centre of the picture is not the overlay frame (element ${hit.id || '?'})`);
+ assert.ok(clicks >= 1, 'a press outside the controls reaches the player');
+ assert.deepStrictEqual(errors, [], `no page errors (${errors.join(' · ')})`);
+ const shot = path.join(OUT, `bot-overlay-${width}x${height}-${name}.png`);
+ await page.screenshot({ path: shot, fullPage: false });
+ console.log(` ✓ ${width}×${height}: slot above the bar (${Math.round(m.bar.top - m.slot.bottom)} px clear), clipped, press passes through → ${shot}`);
+ } catch (e) {
+ failed++;
+ console.log(` ✗ ${width}×${height}: ${e.message}`);
+ try { await page.screenshot({ path: path.join(OUT, `bot-overlay-${width}x${height}-${name}-fail.png`), fullPage: false }); } catch { /* */ }
+ }
+ await page.close();
+ }
+ await browser.close();
+ if (failed) { console.log(`bot-overlay: ${failed} failed`); process.exit(1); }
+ console.log('bot-overlay: all passed');
+})();