Files
companion-buttons/companion.js
T

307 lines
10 KiB
JavaScript
Raw Normal View History

/* 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, following the
* button if it is moved to another page
*
* 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 { 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;
}
/** The control at a page number, row and column right now, or null. */
function at(number, row, column) {
const page = pages.get(order[number - 1]);
return (page && page.controls[row] && page.controls[row][column]) || null;
}
return {
apply,
list,
locate,
at,
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}`;
}
module.exports = { createLink, createPageBook, pageName };