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)
Changes
@@@ -15,6 +15,10 @@ov-bot-ticket { ticket } or ov-bot-ticket-none { message }: Drive without apopup or a second sign-in on openvibe.botov-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 };@@@ -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 } };
@@ -15,6 +15,10 @@
@@ -15,6 +15,10 @@
ov-bot-ticket { ticket } or ov-bot-ticket-none { message }: Drive without a
ov-bot-ticket { ticket } or ov-bot-ticket-none { message }: Drive without a
popup or a second sign-in on openvibe.bot
popup or a second sign-in on openvibe.bot
ov-bot-embed-refused → onRefused(): the owner has not turned the panel on
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.
Plain script for the browser (window.BotEmbed) and require() for the tests.
*/
*/
@@ -85,11 +89,80 @@
@@ -85,11 +89,80 @@
const reply = (msg) => {
const reply = (msg) => {
try { if (frame.contentWindow) frame.contentWindow.postMessage(Object.assign({ robot_id: robotId }, msg), origin); } catch { /* the frame went away */ }
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) => {
const onMessage = async (e) => {
if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;
if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;
const d = e.data || {};
const d = e.data || {};
if (!d || d.robot_id !== robotId) return;
if (!d || d.robot_id !== robotId) 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-ticket-request' || asking) return;
if (d.type !== 'ov-bot-ticket-request' || asking) return;
asking = true;
asking = true;
try {
try {
@@ -101,7 +174,7 @@
@@ -101,7 +174,7 @@
} finally { asking = false; }
} finally { asking = false; }
};
};
win.addEventListener('message', onMessage);
win.addEventListener('message', onMessage);
- return () => win.removeEventListener('message', onMessage);
+ return () => { deactivate(); win.removeEventListener('message', onMessage); };
}
}
return { iframeAttrs, mount, listen };
return { iframeAttrs, mount, listen };
@@ -320,6 +320,66 @@ async function check(name, fn) {
@@ -320,6 +320,66 @@ async function check(name, fn) {
assert.strictEqual(listeners.size, 0, 'nothing listens for a url it would not frame');
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 () => {
await check('POST …/bot/ticket: a Drive ticket from Bot as the viewer, for the channel\'s own robot only', async () => {
const seen = [];
const seen = [];
let reply = { status: 200, body: { ticket: 'K'.repeat(43), robot_id: 'rob_sim123', role: 'queue', expires_in: 120 } };
let reply = { status: 200, body: { ticket: 'K'.repeat(43), robot_id: 'rob_sim123', role: 'queue', expires_in: 120 } };