Files
companion-buttons/companion.js
T
christianandClaude Opus 5.5 0a6cec88aa 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>
2026-10-15 11:48:24 -04:00

310 lines
10 KiB
JavaScript

/* Companion Buttons — the connection to Companion.
*
* Companion 4 and 5 have no HTTP route that returns a button's picture. Its own
* web buttons and emulator get them from the tRPC API on the web interface port
* (`ws://<host>:<port>/trpc`), which streams a PNG every time a button redraws.
* This is a client for exactly the two read-only subscriptions this module
* needs, over Node's built-in WebSocket:
*
* pages.watch → every page, its name, and which control
* sits at each row/column
* preview.graphics.controlId → a button's rendered image, on every redraw
* controls.watchControl → a button's setup, read once for its kind
* (page up/down buttons are left out)
*
* Nothing here can press, edit or change anything in Companion.
*
* The wire format is tRPC 11's websocket protocol without a transformer:
* → { id, method: 'subscription', params: { path, input } }
* ← { id, result: { type: 'started' | 'data' | 'stopped', data } }
* ← { id, error: { message, … } }
* → { id, method: 'subscription.stop' }
* plus a bare 'PING' every 30 s that must be answered 'PONG' within 5 s, and
* `{ method: 'reconnect' }` when Companion is shutting down.
*/
'use strict';
const BACKOFF_MS = [1000, 2000, 5000, 10000];
const SILENCE_MS = 75000; // Companion pings every 30 s; two missed means the link is dead
const CONNECT_TIMEOUT_MS = 8000;
/**
* A long-lived connection that survives Companion restarting. Subscriptions
* are remembered and sent again after every reconnect, so callers subscribe
* once and simply keep receiving data.
*
* const link = createLink({ host, port, onStatus });
* const off = link.subscribe('pages.watch', undefined, (data) => …);
* off(); // stop that subscription
* link.close(); // stop everything
*/
function createLink({ host, port, onStatus = () => {}, WebSocketImpl = globalThis.WebSocket }) {
const url = `ws://${host}:${port}/trpc`;
const subs = new Map(); // key → { path, input, onData, wireId }
let ws = null;
let nextId = 1;
let nextKey = 1;
let attempt = 0;
let retryTimer = null;
let silenceTimer = null;
let connectTimer = null;
let closed = false;
let status = { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` };
function setStatus(state, message) {
if (status.state === state && status.message === message) return;
status = { state, message };
onStatus(status);
}
function send(msg) {
if (!ws || ws.readyState !== 1) return;
try {
ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
} catch {
/* the close handler deals with it */
}
}
function start(sub) {
sub.wireId = nextId++;
send({ id: sub.wireId, method: 'subscription', params: { path: sub.path, input: sub.input } });
}
function heard() {
clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => drop('Companion stopped answering'), SILENCE_MS);
}
function drop(reason) {
const old = ws;
ws = null;
clearTimeout(silenceTimer);
clearTimeout(connectTimer);
if (old) {
old.onopen = old.onmessage = old.onclose = old.onerror = null;
try {
old.close();
} catch {
/* already gone */
}
}
for (const sub of subs.values()) sub.wireId = 0;
if (closed) return;
setStatus('error', `${reason} — retrying`);
const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
attempt += 1;
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, wait);
}
function onMessage(raw) {
heard();
const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8');
if (text === 'PING') return send('PONG');
if (text === 'PONG') return;
let msg;
try {
msg = JSON.parse(text);
} catch {
return;
}
if (msg && msg.method === 'reconnect') return drop('Companion is restarting');
if (!msg || msg.id === undefined || msg.id === null) return;
const sub = [...subs.values()].find((s) => s.wireId === msg.id);
if (!sub) return;
if (msg.error) {
sub.onError(String(msg.error.message || 'Companion refused the request'));
return;
}
if (msg.result && msg.result.type === 'data') sub.onData(msg.result.data);
}
function connect() {
if (closed) return;
clearTimeout(retryTimer);
if (typeof WebSocketImpl !== 'function') {
setStatus('error', 'This ProdDash runs on a Node.js without WebSocket (Node 22 or newer is needed)');
return;
}
setStatus('connecting', `Connecting to Companion at ${host}:${port}`);
let socket;
try {
socket = new WebSocketImpl(url);
} catch (err) {
drop(`Cannot reach Companion at ${host}:${port} (${err.message})`);
return;
}
ws = socket;
connectTimer = setTimeout(() => {
if (ws === socket && socket.readyState !== 1) drop(`No answer from Companion at ${host}:${port}`);
}, CONNECT_TIMEOUT_MS);
socket.onopen = () => {
if (ws !== socket) return;
clearTimeout(connectTimer);
attempt = 0;
heard();
setStatus('ok', `Connected to Companion at ${host}:${port}`);
for (const sub of subs.values()) start(sub);
};
socket.onmessage = (e) => {
if (ws === socket) onMessage(e.data);
};
socket.onclose = () => {
if (ws === socket) drop(`Lost Companion at ${host}:${port}`);
};
socket.onerror = () => {
if (ws === socket && socket.readyState !== 1) drop(`Cannot reach Companion at ${host}:${port}`);
};
}
connect();
return {
get status() {
return status;
},
subscribe(path, input, onData, onError = () => {}) {
const key = nextKey++;
const sub = { path, input, onData, onError, wireId: 0 };
subs.set(key, sub);
if (ws && ws.readyState === 1) start(sub);
return () => {
if (!subs.delete(key)) return;
if (sub.wireId) send({ id: sub.wireId, method: 'subscription.stop' });
};
},
close() {
closed = true;
clearTimeout(retryTimer);
subs.clear();
drop('Closed');
},
};
}
/**
* Companion's page list, kept current from `pages.watch`. Page numbers are
* positions in `order` (1-based), so they follow Companion when pages are
* inserted or moved, exactly as its own UI numbers them.
*/
function createPageBook() {
let order = [];
const pages = new Map(); // page id → { id, name, controls: { row: { column: controlId } } }
function apply(change) {
if (!change || typeof change !== 'object') return;
if (change.type === 'init') {
order = Array.isArray(change.order) ? [...change.order] : [];
pages.clear();
for (const [id, page] of Object.entries(change.pages || {})) {
if (page) pages.set(id, clonePage(page));
}
return;
}
if (change.type !== 'update') return;
for (const page of change.added || []) pages.set(page.id, clonePage(page));
if (Array.isArray(change.updatedOrder)) {
order = [...change.updatedOrder];
for (const id of [...pages.keys()]) if (!order.includes(id)) pages.delete(id);
}
for (const item of change.changes || []) {
const page = pages.get(item.id);
if (!page) continue;
if (item.name !== null && item.name !== undefined) page.name = item.name;
for (const { row, column, controlId } of item.controls || []) {
if (controlId) {
if (!page.controls[row]) page.controls[row] = {};
page.controls[row][column] = controlId;
} else if (page.controls[row]) {
delete page.controls[row][column];
if (!Object.keys(page.controls[row]).length) delete page.controls[row];
}
}
}
}
function clonePage(page) {
const controls = {};
for (const [row, cols] of Object.entries(page.controls || {})) controls[row] = { ...cols };
return { id: page.id, name: String(page.name || ''), controls };
}
/** Every page in Companion's order, with the buttons it holds. */
function list() {
return order
.map((id, i) => {
const page = pages.get(id);
if (!page) return null;
const buttons = [];
for (const [row, cols] of Object.entries(page.controls)) {
for (const [column, controlId] of Object.entries(cols)) {
buttons.push({ row: Number(row), column: Number(column), controlId });
}
}
buttons.sort((a, b) => a.row - b.row || a.column - b.column);
return { id, number: i + 1, name: pageName(page.name, i + 1), buttons };
})
.filter(Boolean);
}
/** Where a control sits right now, or null when it is on no page. */
function locate(controlId) {
for (let i = 0; i < order.length; i += 1) {
const page = pages.get(order[i]);
if (!page) continue;
for (const [row, cols] of Object.entries(page.controls)) {
for (const [column, id] of Object.entries(cols)) {
if (id === controlId) {
return { page: i + 1, row: Number(row), column: Number(column), pageName: pageName(page.name, i + 1) };
}
}
}
}
return null;
}
/** One page by Companion's own page id (stable when pages are renumbered), or null. */
function page(id) {
return list().find((p) => p.id === id) || null;
}
return {
apply,
list,
locate,
page,
get loaded() {
return order.length > 0;
},
get count() {
return order.length;
},
};
}
/**
* A page as people read it, with its number in front. Companion page names
* often carry a literal "\n" so they wrap on a 72 px button ("19\nStatus");
* on one line that is "19 Status". A name that already starts with its page
* number is kept as it is, and "PAGE" — what Companion calls a page nobody
* named — becomes "Page 24".
*/
function pageName(raw, number) {
const name = String(raw || '')
.replace(/\\n|\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!name || name.toUpperCase() === 'PAGE') return `Page ${number}`;
if (name.startsWith(`${number} `) || name === String(number)) return name;
return `${number} ${name}`;
}
/** Companion's navigation buttons: page up, page down, the page number. Never shown. */
const NAV_TYPES = new Set(['pageup', 'pagedown', 'pagenum']);
module.exports = { createLink, createPageBook, pageName, NAV_TYPES };