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>
310 lines
10 KiB
JavaScript
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 };
|