Companion Buttons 1.1.0: pages in the + picker, buttons chosen on the tile

The picker row (page list, Clear, Done) is gone. Each Companion page is now
its own + entry, titled with the page's name, showing the whole page minus
page up/down. The grid icon switches the tile to choosing: click buttons to
take them out or put them back, click the icon again when done. The gear
holds one switch, Page layout (Companion's rows and columns, or packed).

- Tiles follow their page by Companion's page id and buttons by row/column,
  so inserted pages and re-imported pages keep showing.
- A tile remounted after a settings change asks GET /state for the snapshot
  the already-open stream sent, instead of waiting blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:48:24 -04:00
co-authored by Claude Opus 5.5
parent 415453ef5f
commit 0a6cec88aa
8 changed files with 642 additions and 804 deletions
+28 -5
View File
@@ -8,9 +8,10 @@
*
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
*
* It serves the two subscriptions the module uses — pages.watch and
* preview.graphics.controlId — over a hand-rolled websocket (no deps), with
* three pages. Page 1's first button flips between green and red every two
* It serves the three subscriptions the module uses — pages.watch,
* preview.graphics.controlId and controls.watchControl — over a hand-rolled
* websocket (no deps), with three pages: a status page with a page-up
* button, a camera page, and an unnamed page holding only navigation. Page 1's first button flips between green and red every two
* seconds, like a status button; page 1's second button redraws the same
* picture every 300 ms, like a button fed a variable that has not changed.
* Every 30 s it sends PING and drops a client that does not answer PONG,
@@ -123,9 +124,22 @@ function readFrames(state, data, onText, onClose) {
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
const pages = {
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' } } },
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' }, 1: { 0: 'bank:up1' } } },
p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
p3: { id: 'p3', name: 'PAGE', controls: {} },
p3: { id: 'p3', name: 'PAGE', controls: { 0: { 0: 'bank:up3' } } },
};
const layered = (value, isExpression = false) => ({
type: 'button-layered',
style: { layers: [{ id: 'canvas', type: 'canvas' }, { id: 'text0', type: 'text', text: { value, isExpression } }] },
});
const configs = {
'bank:flip': layered('FLIP'),
'bank:steady': layered('Steady'),
'bank:blue': layered('Blue'),
'bank:cam1': { type: 'button', style: { text: 'CAM 1' } },
'bank:cam2': layered('CAM 2'),
'bank:up1': { type: 'pageup', style: {} },
'bank:up3': { type: 'pageup', style: {} },
};
const order = ['p1', 'p2', 'p3'];
const looks = {
@@ -184,6 +198,14 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
}
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
const { path, input } = msg.params || {};
if (path === 'controls.watchControl') {
const cfg = configs[input && input.controlId];
if (!cfg) return send(client, { id: msg.id, error: { message: `Control ${input && input.controlId} not found` } });
stats.subscriptions += 1;
client.subs.set(msg.id, { path, input });
send(client, { id: msg.id, result: { type: 'started' } });
return send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', config: cfg, runtime: {} } } });
}
if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
}
@@ -276,6 +298,7 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
redraw,
looks,
pages,
configs,
/** Drop every client without a close frame, as a crash or a cable pull would. */
dropAll() {
for (const c of clients) c.socket.destroy();