openvibers/openvibe.live Commits

A channel's robot panel drives from the stream page's keyboard and gamepad: while the panel drives, the keys it names and the gamepad go to it (never typing in chat, never with a modifier), and the player keeps every other key (#389)

OP OpenVibers committed 17 days ago · 555fa0bb6b · 2 files, +134 −1

Changes

ddiff --git a/public/js/bot-embed.js b/public/js/bot-embed.js
iindex 92ac57a3..bd2578e6 100644
---- a/public/js/bot-embed.js
++++ b/public/js/bot-embed.js
@@@ -15,6 +15,10 @@
ov-bot-ticket { ticket } or ov-bot-ticket-none { message }: Drive without a
popup or a second sign-in on openvibe.bot
ov-bot-embed-refused → onRefused(): the owner has not turned the panel on
++ ov-bot-controls → while the panel drives ({ active, keys, pad }), the keys
++ it named and this page's gamepad go to it (ov-bot-key, ov-bot-pad) when the
++ person is on the page and not in the frame: WASD and a controller drive from
++ the stream page. Never a key typed into chat or a field, never with Ctrl/Alt/⌘.
Plain script for the browser (window.BotEmbed) and require() for the tests.
*/
@@@ -85,11 +89,80 @@
const reply = (msg) => {
try { if (frame.contentWindow) frame.contentWindow.postMessage(Object.assign({ robot_id: robotId }, msg), origin); } catch { /* the frame went away */ }
};
++
++ // The page's keyboard and gamepad while the panel drives (Maticus, 2026-10-27: "WASD keyboard AND game controller"
++ // on the stream page). The panel says which keys its controls take; only those are sent and kept from the
++ // page's own shortcuts (the player's arrows and Space), and only until it says it stopped.
++ let controls = null; // { keys, lower, pad } while the panel drives
++ const held = new Set(); // codes sent down and not yet up
++ let padLoop = 0, padLast = 0, padAt = 0, padSent = '';
++ const typing = (t) => !!t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName || '') || t.isContentEditable === true);
++ const wanted = (e) => controls.keys.has(e.code) || (typeof e.key === 'string' && controls.lower.has(e.key.toLowerCase()));
++ const onKeyDown = (e) => {
++ if (!controls || typing(e.target) || e.ctrlKey || e.metaKey || e.altKey || !wanted(e)) return;
++ e.preventDefault(); e.stopPropagation();
++ held.add(e.code);
++ reply({ type: 'ov-bot-key', code: e.code, key: e.key, down: true, repeat: e.repeat === true });
++ };
++ const onKeyUp = (e) => {
++ if (!held.has(e.code)) return;
++ held.delete(e.code);
++ e.preventDefault(); e.stopPropagation();
++ reply({ type: 'ov-bot-key', code: e.code, key: e.key, down: false });
++ };
++ const releaseKeys = () => { for (const code of held) reply({ type: 'ov-bot-key', code, key: '', down: false }); held.clear(); };
++ const onHidden = () => { if (win.document && win.document.hidden) releaseKeys(); };
++ const connected = () => [...((win.navigator && win.navigator.getGamepads && win.navigator.getGamepads()) || [])].filter((g) => g && g.connected);
++ // About 30 states a second while they change, one every 200 ms while they do not (the panel lets go after 500 ms
++ // without one), and none while this window is not the one in front.
++ const padTick = () => {
++ padLoop = 0;
++ if (!controls || !controls.pad) return;
++ const g = connected()[0];
++ if (!g) return; // gamepadconnected starts it again
++ const now = Date.now();
++ if (now - padLast >= 33 && (!win.document || win.document.hasFocus())) {
++ padLast = now;
++ const state = { axes: [...g.axes].slice(0, 8).map((v) => Math.round(Number(v) * 1000) / 1000 || 0), buttons: [...g.buttons].slice(0, 20).map((b) => !!(b && b.pressed)) };
++ const text = JSON.stringify(state);
++ if (text !== padSent || now - padAt >= 200) { reply(Object.assign({ type: 'ov-bot-pad' }, state)); padSent = text; padAt = now; }
++ }
++ padLoop = win.requestAnimationFrame(padTick);
++ };
++ const padStart = () => { if (!padLoop && controls && controls.pad && typeof win.requestAnimationFrame === 'function') padLoop = win.requestAnimationFrame(padTick); };
++ const deactivate = () => {
++ if (!controls) return;
++ releaseKeys();
++ controls = null;
++ win.removeEventListener('keydown', onKeyDown, true);
++ win.removeEventListener('keyup', onKeyUp, true);
++ win.removeEventListener('blur', releaseKeys);
++ win.removeEventListener('gamepadconnected', padStart);
++ if (win.document && win.document.removeEventListener) win.document.removeEventListener('visibilitychange', onHidden);
++ if (padLoop && typeof win.cancelAnimationFrame === 'function') win.cancelAnimationFrame(padLoop);
++ padLoop = 0; padSent = '';
++ };
++ const setControls = (d) => {
++ const keys = (Array.isArray(d.keys) ? d.keys : []).filter((k) => typeof k === 'string' && k && k.length <= 40).slice(0, 64);
++ if (d.active !== true || !keys.length) return deactivate();
++ const fresh = !controls;
++ controls = { keys: new Set(keys), lower: new Set(keys.map((k) => k.toLowerCase())), pad: d.pad === true };
++ for (const code of [...held]) if (!controls.keys.has(code)) { held.delete(code); reply({ type: 'ov-bot-key', code, key: '', down: false }); }
++ if (fresh) {
++ win.addEventListener('keydown', onKeyDown, true);
++ win.addEventListener('keyup', onKeyUp, true);
++ win.addEventListener('blur', releaseKeys);
++ win.addEventListener('gamepadconnected', padStart);
++ if (win.document && win.document.addEventListener) win.document.addEventListener('visibilitychange', onHidden);
++ }
++ padStart();
++ };
const onMessage = async (e) => {
if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;
const d = e.data || {};
if (!d || d.robot_id !== robotId) 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;
asking = true;
try {
@@@ -101,7 +174,7 @@
} finally { asking = false; }
};
win.addEventListener('message', onMessage);
-- return () => win.removeEventListener('message', onMessage);
++ return () => { deactivate(); win.removeEventListener('message', onMessage); };
}
return { iframeAttrs, mount, listen };
ddiff --git a/test/bot-embed-page.test.js b/test/bot-embed-page.test.js
iindex b398120d..d162fea1 100644
---- a/test/bot-embed-page.test.js
++++ b/test/bot-embed-page.test.js
@@@ -320,6 +320,66 @@ async function check(name, fn) {
assert.strictEqual(listeners.size, 0, 'nothing listens for a url it would not frame');
});
++ await check('listen(): while the panel drives, the keys it names and the gamepad go to it; never typing, never after', async () => {
++ const on = {};
++ const add = (t, f) => { (on[t] = on[t] || new Set()).add(f); };
++ const del = (t, f) => { if (on[t]) on[t].delete(f); };
++ let rafs = [];
++ let pads = [];
++ const doc = { hidden: false, hasFocus: () => true, addEventListener: add, removeEventListener: del };
++ const win = { addEventListener: add, removeEventListener: del, document: doc, navigator: { getGamepads: () => pads },
++ requestAnimationFrame: (f) => { rafs.push(f); return rafs.length; }, cancelAnimationFrame: () => { rafs = []; } };
++ const posted = [];
++ const panel = { postMessage: (msg) => posted.push(msg) };
++ const stop = BotEmbed.listen({ frame: { contentWindow: panel }, botEmbed: SIM, win, requestTicket: async () => ({}) });
++ const msg = async (data, origin = 'https://openvibe.bot') => { for (const f of [...(on.message || [])]) await f({ data, origin, source: panel }); };
++ const key = (type, code, extra = {}) => {
++ const e = { code, key: extra.key || code, target: extra.target || { tagName: 'BODY' }, prevented: false, stopped: false,
++ preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...extra };
++ for (const f of [...(on[type] || [])]) f(e);
++ return e;
++ };
++ assert.ok(!(on.keydown && on.keydown.size), 'nothing listens to keys before the panel drives');
++ await msg({ type: 'ov-bot-controls', robot_id: 'rob_sim123', active: true, keys: ['KeyW', 'KeyA', 'Space', 'f'], pad: true }, 'https://evil.example');
++ assert.ok(!(on.keydown && on.keydown.size), 'only Bot\'s origin turns it on');
++ await msg({ type: 'ov-bot-controls', robot_id: 'rob_sim123', active: true, keys: ['KeyW', 'KeyA', 'Space', 'f'], pad: true });
++ const w = key('keydown', 'KeyW', { key: 'w' });
++ assert.ok(w.prevented && w.stopped, 'a key it takes is kept from the player');
++ assert.deepStrictEqual(posted.at(-1), { robot_id: 'rob_sim123', type: 'ov-bot-key', code: 'KeyW', key: 'w', down: true, repeat: false });
++ const n = posted.length;
++ const chat = key('keydown', 'KeyA', { key: 'a', target: { tagName: 'TEXTAREA' } });
++ key('keydown', 'KeyA', { key: 'a', target: { tagName: 'DIV', isContentEditable: true } });
++ key('keydown', 'KeyA', { key: 'a', ctrlKey: true });
++ const other = key('keydown', 'KeyM', { key: 'm' });
++ assert.strictEqual(posted.length, n, 'never typing, never with a modifier, never a key it did not name');
++ assert.ok(!chat.prevented && !other.prevented, 'and those keys stay the page\'s');
++ key('keydown', 'KeyF', { key: 'F' });
++ assert.deepStrictEqual([posted.at(-1).code, posted.at(-1).down], ['KeyF', true], 'a button key matches by its letter');
++ key('keyup', 'KeyW', { key: 'w' });
++ assert.deepStrictEqual([posted.at(-1).code, posted.at(-1).down], ['KeyW', false]);
++ for (const f of [...on.blur]) f();
++ assert.deepStrictEqual([posted.at(-1).code, posted.at(-1).down], ['KeyF', false], 'leaving the window lets go of what is held');
++ // The gamepad: its state goes to the panel while this window is in front.
++ pads = [{ connected: true, axes: [0, -1, 0.1234, 0], buttons: [{ pressed: true }, { pressed: false }] }];
++ for (const f of [...on.gamepadconnected]) f();
++ assert.strictEqual(rafs.length, 1, 'polling starts');
++ rafs.shift()();
++ assert.deepStrictEqual(posted.at(-1), { robot_id: 'rob_sim123', type: 'ov-bot-pad', axes: [0, -1, 0.123, 0], buttons: [true, false] });
++ // Another state for a key it did not name changes nothing; a narrower list lets go of a key no longer named.
++ key('keydown', 'KeyA', { key: 'a' });
++ await msg({ type: 'ov-bot-controls', robot_id: 'rob_sim123', active: true, keys: ['KeyW'], pad: false });
++ assert.deepStrictEqual([posted.at(-1).code, posted.at(-1).down], ['KeyA', false]);
++ // The drive ends: what is held goes up, and nothing listens any more.
++ key('keydown', 'KeyW', { key: 'w' });
++ await msg({ type: 'ov-bot-controls', robot_id: 'rob_sim123', active: false, keys: [] });
++ assert.deepStrictEqual([posted.at(-1).code, posted.at(-1).down], ['KeyW', false]);
++ assert.ok(!on.keydown.size && !on.keyup.size && !on.blur.size && !on.gamepadconnected.size && !on.visibilitychange.size);
++ assert.ok(!key('keydown', 'KeyW').prevented, 'the player has its keys back');
++ await msg({ type: 'ov-bot-controls', robot_id: 'rob_sim123', active: true, keys: ['KeyW'] });
++ stop();
++ assert.ok(!on.keydown.size && !on.message.size, 'stop() removes every listener');
++ });
++
await check('POST …/bot/ticket: a Drive ticket from Bot as the viewer, for the channel\'s own robot only', async () => {
const seen = [];
let reply = { status: 200, body: { ticket: 'K'.repeat(43), robot_id: 'rob_sim123', role: 'queue', expires_in: 120 } };