openvibers/openvibe.live Commits

Robot controls over the video or under it, per viewer (#773)

OP OpenVibers committed 3 days ago · 164680e550 · 16 files, +605 −43

* Robot controls over the video or under it, per viewer

A channel bound to a Bot robot gets an overlay layer inside the player (above the video, below
the bar and unmute pill, its bottom reserved for the bar) that frames Bot's overlay view, and
a three-way switch in the player and on the robot card. The viewer's choice (on the video /
under the video / hidden) is kept in localStorage per channel and robot; without one, the
streamer's default decides. The default is stored on the channel (migration 0038, expand) and
served through bot_embed.controls_mode; the binding PUT accepts it and the dashboard sets it.
The overlay iframe is clipped to the control rectangles from Bot so presses reach the video.
The card becomes one line while the controls are on the video, and the switch works in the
player's full screen. Adds a browser layout test at 390x780, 844x390 and 1280x800.

* Player: a 40 px robot button with the three control choices

The select pushed the clip button out of the bar at 360 px. The player's switch is now one
40 px robot button (where the stats gauge already steps aside on a narrow phone) opening the
same three choices, and it moves before the clip/fullscreen pair so those stay last.

Changes

ddiff --git a/migrations/0038_bot_controls_mode.sql b/migrations/0038_bot_controls_mode.sql
nnew file mode 100644
iindex 00000000..2e1a36d4
---- /dev/null
++++ b/migrations/0038_bot_controls_mode.sql
@@@ -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'));
ddiff --git a/package.json b/package.json
iindex e2224f9e..2c5d7740 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 54ec1146..a8766b31 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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); }
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 02f9a008..6554e847 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -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) -->
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 76b9d842..fbf38c5f 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 14e7ea89..bf746219 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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;
ddiff --git a/public/js/bot-embed.js b/public/js/bot-embed.js
iindex a9ba7645..7fb0b3a4 100644
---- a/public/js/bot-embed.js
++++ b/public/js/bot-embed.js
@@@ -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 };
});
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex 45ec831c..76225626 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -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) {
ddiff --git a/server/bot/embed.js b/server/bot/embed.js
iindex fd799a6b..ae4f83d2 100644
---- a/server/bot/embed.js
++++ b/server/bot/embed.js
@@@ -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 };
ddiff --git a/server/bot/routes.js b/server/bot/routes.js
iindex 11340544..c0cf9912 100644
---- a/server/bot/routes.js
++++ b/server/bot/routes.js
@@@ -29,13 +29,29 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
if (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' });
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 494f8e99..44307106 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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);
}
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 7ed0163c..f05895ef 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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
ddiff --git a/server/web/serializers.js b/server/web/serializers.js
iindex 6706b1d2..451d0a11 100644
---- a/server/web/serializers.js
++++ b/server/web/serializers.js
@@@ -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;
}
ddiff --git a/test/bot-embed-binding.test.js b/test/bot-embed-binding.test.js
iindex 8889d3a7..df1d0a46 100644
---- a/test/bot-embed-binding.test.js
++++ b/test/bot-embed-binding.test.js
@@@ -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);
ddiff --git a/test/bot-embed-page.test.js b/test/bot-embed-page.test.js
iindex af1df0ca..12f74d34 100644
---- a/test/bot-embed-page.test.js
++++ b/test/bot-embed-page.test.js
@@@ -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) };
ddiff --git a/test/browser/bot-overlay.js b/test/browser/bot-overlay.js
nnew file mode 100644
iindex 00000000..c57db3fd
---- /dev/null
++++ b/test/browser/bot-overlay.js
@@@ -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');
++})();