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
+35 -34
View File
@@ -1,14 +1,11 @@
# Companion Buttons
A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module
that shows any button from Bitfocus Companion — any page, any button — on a
dashboard tile, exactly as Companion draws it: colours, text, icons and live
feedback. It is meant for status buttons and anything else that says what is
happening right now.
that shows Bitfocus Companion pages on dashboard tiles — a whole page, or just
the buttons you pick from it — every button exactly as Companion draws it:
colours, text, icons and live feedback. It is meant for status buttons and
anything else that says what is happening right now.
- **One tile, one or many buttons.** A single button fills the tile; several
share it in whatever grid makes each one biggest, and resizing the tile
re-flows them.
- **Live.** Every time Companion redraws a button (a status turning red, a
tally lighting up), the tile shows it. Identical redraws — a status page that
repaints every second with the same picture — are not re-sent.
@@ -21,26 +18,27 @@ happening right now.
running Companion and its web interface port (8000 unless changed in
Companion's settings). Use `127.0.0.1` when Companion runs on the same
machine as ProdDash.
2. **+ Add tile → Companion Buttons.** A new tile opens the picker: choose a
page from the list and tap the buttons to show. The numbers on the picked
buttons are the order they appear in. **Done** shows them.
3. To change the buttons later, use the grid icon in the tile's title bar.
The gear has one switch, **Captions**, which writes the page name under
each button.
2. **+ Add tile:** every Companion page is listed under **Companion
Buttons**. Pick one — "19 Status", say — and the tile shows that page,
titled with its name. Page up/down buttons are left out.
3. **To show only some buttons,** click the grid icon in the tile's title bar.
The whole page appears; click a button to take it out (it fades) or put it
back. Click the icon again when done. Putting every button back means the
whole page again, including buttons added to it later.
4. **The gear** has one switch, **Page layout**: on, the buttons keep
Companion's rows and columns; off, they are packed into whatever grid makes
each one biggest.
Hover a button for its page and Companion location (`page/row/column`).
### What a tile follows
Each picked button is saved with its Companion id **and** its page, row and
column. While Companion still has that button anywhere, the tile follows it,
so dragging it to another spot or inserting a page in front changes nothing.
If the id is gone — a page re-imported from a `.companionconfig` gives every
button a new id — the tile shows whatever is at the saved page, row and column
instead. Either way the tile saves back what it found, so it stays current.
A button that is gone with nothing at its place shows **Not in Companion**,
and the status dot turns amber.
A tile follows its page by Companion's own page id, so inserting or moving
pages changes nothing. Buttons are followed by their row and column on that
page, so a page re-imported from a `.companionconfig` (every button gets a new
internal id) keeps showing. A chosen place that is emptied in Companion shows
a dashed outline. A page deleted in Companion leaves the tile empty; the
dashboard folds it away.
### When Companion is down
@@ -53,20 +51,23 @@ last pictures to grey, so nobody reads them as live. ProdDash keeps retrying
Companion 4 and 5 have no HTTP route that returns a button's picture. Their
own web buttons and emulator get them from the tRPC websocket on the web
interface port (`ws://<host>:<port>/trpc`). The module's server holds one such
connection for all dashboards and uses two read-only subscriptions:
`pages.watch` (pages, names, which button is where) and
`preview.graphics.controlId` (a button's PNG, on every redraw). That is
Companion's internal API rather than a documented one, so a future Companion
release could change it; it was built and tested against Companion 5.0.7.
connection for all dashboards and uses three read-only subscriptions:
`pages.watch` (pages, names, which button is where),
`preview.graphics.controlId` (a button's PNG, on every redraw) and
`controls.watchControl` (read once per button, to leave out page up/down).
That is Companion's internal API rather than a documented one, so a future
Companion release could change it; it was built and tested against Companion
5.0.7.
| Route | What it serves |
| --- | --- |
| `GET /pages` | Every page and the buttons on it — the picker's list |
| `GET /image/<id>` | One button's current picture as PNG — picker thumbnails |
| `GET /stream/<page.row.column.id>/…` | SSE `button` events for a tile's buttons: all of them on connect, then each redraw or move |
| `GET /stream/<page>[/<row.col>/…]` | SSE for one tile: `page` (what to lay out) and `button` (one picture) on connect, then on every change |
| `GET /state/<page>[/<row.col>/…]` | The same as one snapshot, for a tile that joins a stream the dashboard already has open |
| `GET /page-options` | The pages, for a tile added without one (the gear's Page) |
A tile holds one stream for all its buttons, because ProdDash carries at most
32 streams per page and a status board can show more buttons than that.
The + entries come from the server's `tiles()`, one per page. A tile holds
one stream for all its buttons, because ProdDash carries at most 32 streams
per page and a status board can show more buttons than that.
## Requirements
@@ -81,9 +82,9 @@ A tile holds one stream for all its buttons, because ProdDash carries at most
| File | |
| --- | --- |
| `module.json` | Manifest |
| `server.js` | Routes, button subscriptions, the per-tile streams |
| `server.js` | + entries, routes, button subscriptions, the per-tile streams |
| `companion.js` | The connection to Companion's tRPC websocket and the page list |
| `client.js`, `style.css` | The tile and its picker |
| `client.js`, `style.css` | The tile and its choosing mode |
| `test/companion.test.js` | Tests: `node test/companion.test.js` |
| `test/companion-mock.js` | A stand-in Companion: `node test/companion-mock.js 18000`, then point the module at `127.0.0.1` port `18000` |
+241 -353
View File
@@ -1,37 +1,24 @@
/* Companion Buttons — the tile.
Shows the Companion buttons this tile was given, as Companion draws them,
as large as the tile allows. One button fills the tile; several share it in
whatever grid makes each one biggest. Every picture is live: the server
forwards each redraw (a status button turning red, a tally lighting up).
Shows one Companion page, or the buttons of it chosen for this tile, every
button as Companion draws it and as large as the tile allows. Every
picture is live: the server forwards each redraw (a status button turning
red, a tally lighting up).
Buttons are chosen in the tile itself — the grid icon in the title bar
opens a picker that shows any Companion page as it is laid out, and a tap
adds or removes a button. The choice is the tile's own (saved with the
layout), so two tiles can show two different sets. Each choice is kept as
the button's id and its page/row/column; the server follows whichever
still holds, and the tile saves back what it resolved to. */
The page comes from the + picker entry the tile was added as (or, for a
tile added without one, the gear's Page). The grid icon in the title bar
switches the tile to choosing: the whole page is shown, the buttons this
tile leaves out faded, and a click puts a button in or takes it out. The
icon again goes back. "Page layout" in the gear keeps Companion's own rows
and columns; off, the buttons are packed into whatever grid makes each
one biggest. */
const GRID_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></svg>';
const GAP_PX = 6;
const CAPTION_PX = 18;
const PAGES_RETRY_MS = 5000;
const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/;
const MAX_BUTTONS = 64;
/** "bank:QOPCkIuUp5U9" → "QOPCkIuUp5U9": what the server's URLs carry. */
const urlId = (id) => id.slice(5);
/** A chosen button as the server's stream path names it: page.row.column.id */
const spotKey = (s) => `${s.page}.${s.row}.${s.column}.${urlId(s.id)}`;
function validSpot(s) {
return Boolean(s) && ID_RE.test(String(s.id))
&& Number.isInteger(s.page) && s.page >= 1 && s.page <= 9999
&& Number.isInteger(s.row) && Math.abs(s.row) <= 999
&& Number.isInteger(s.column) && Math.abs(s.column) <= 999;
}
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/;
const MAX_SPOTS = 64;
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -41,13 +28,11 @@ function el(tag, className, text) {
}
/** The column count that makes `n` square cells biggest inside w × h. */
function bestGrid(n, w, h, captionPx) {
function packedGrid(n, w, h) {
let best = { cols: 1, size: 0 };
for (let cols = 1; cols <= n; cols += 1) {
const rows = Math.ceil(n / cols);
const byWidth = (w - GAP_PX * (cols - 1)) / cols;
const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx;
const size = Math.floor(Math.min(byWidth, byHeight));
const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
if (size > best.size) best = { cols, size };
}
return best;
@@ -55,56 +40,232 @@ function bestGrid(n, w, h, captionPx) {
export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {};
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []);
const save = (spots) => moduleApi.saveInstanceSettings({
buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })),
});
const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`;
const pageId = () => {
const p = String(prefs().page || '');
return PAGE_RE.test(p) ? p : '';
};
/** The chosen buttons as "row.column", or null for the whole page (never chosen). */
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.map(String).filter((k) => SPOT_RE.test(k)).slice(0, MAX_SPOTS) : null);
const keepLayout = () => choosing || prefs().layout !== false;
const data = new Map(); // spot key → the server's latest word on it
let stream = null; // one stream for every button on the tile
const pictures = new Map(); // spot key → { image, seen }
let page = null; // the server's latest `page` event
let stream = null;
let streamPath = '';
let lost = false;
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
let view = null;
let banner = null;
let pickerEl = null;
let pickerPage = 0;
let pickerPages = [];
let pickerTimer = null;
let pickerToken = 0;
let choosing = false;
let pickButton = null;
let wrap = null;
let banner = null;
let grid = null;
let note = null;
let cells = new Map(); // spot key → { face, img }
let observer = null;
let stopped = false;
/* ── live buttons ─────────────────────────────────────────────────── */
/* ── status ───────────────────────────────────────────────────────── */
function syncStream() {
const keys = chosen().map(spotKey);
const path = keys.length ? `/stream/${keys.join('/')}` : '';
function applyStatus() {
if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen');
if (lost) return offline('Lost the connection to ProdDash');
if (!page) return moduleApi.setStatus('connecting', 'Connecting');
if (page.status === 'error') return offline(page.message || 'Companion is not reachable');
setOffline(false);
if (page.status !== 'ok') return moduleApi.setStatus('connecting', page.message || 'Connecting to Companion');
if (!page.found) return moduleApi.setStatus('connecting', 'This page is no longer in Companion');
if (page.spots.some((s) => !pictures.get(s.key)?.seen)) return moduleApi.setStatus('connecting', 'Waiting for Companion');
const where = `${page.message} · page ${page.number}`;
return moduleApi.setStatus('ok', page.spots.length === 1 ? where : `${where} · ${page.spots.length} buttons`);
}
function offline(message) {
setOffline(true);
moduleApi.setStatus('error', message);
}
function setOffline(on) {
if (!banner || banner.hidden === !on) return;
banner.hidden = !on;
wrap.classList.toggle('is-offline', on);
layout(); // the banner takes room from the buttons
}
/* ── drawing ──────────────────────────────────────────────────────── */
function build() {
root.textContent = '';
wrap = el('div', 'cb-root');
banner = el('div', 'cb-banner', 'Companion offline');
banner.hidden = true;
grid = el('div', 'cb-grid');
note = el('div', 'cb-empty');
note.hidden = true;
wrap.append(banner, grid, note);
root.append(wrap);
}
function showNote(text) {
note.textContent = text;
note.hidden = !text;
grid.hidden = Boolean(text);
}
/** Lay out the cells for the spots in the latest `page` event. */
function drawPage() {
const spots = page ? page.spots : [];
wrap.classList.toggle('is-choosing', choosing);
if (!pageId()) return showNote('No page chosen');
if (!choosing && Array.isArray(chosen()) && !chosen().length) return showNote('No buttons chosen');
if (page && !page.found) return showNote(''); // the page is gone: draw nothing, the shell folds the tile away
if (page && page.status === 'ok' && !spots.length) return showNote('No buttons on this page');
showNote('');
const keys = new Set(spots.map((s) => s.key));
for (const [key, c] of cells) {
if (!keys.has(key)) {
c.face.remove();
cells.delete(key);
}
}
const picked = chosen();
for (const s of spots) {
let c = cells.get(s.key);
if (!c) {
const face = el('div', 'cb-face is-waiting');
const img = el('img', 'cb-img');
img.alt = '';
img.draggable = false;
img.hidden = true;
face.append(img);
face.addEventListener('click', () => toggle(s.key));
c = { face, img };
cells.set(s.key, c);
}
c.face.title = `${page.name} · ${page.number}/${s.row}/${s.column}`;
c.face.classList.toggle('is-off', choosing && picked !== null && !picked.includes(s.key));
grid.append(c.face); // re-appending keeps them in the server's order
paint(s.key);
}
layout();
}
function paint(key) {
const c = cells.get(key);
if (!c) return;
const p = pictures.get(key);
if (p && p.image) {
if (c.img.getAttribute('src') !== p.image) c.img.src = p.image;
c.img.hidden = false;
} else {
c.img.hidden = true;
}
c.face.classList.toggle('is-waiting', !p || !p.seen);
c.face.classList.toggle('is-empty', Boolean(p && p.seen && !p.image));
}
function layout() {
if (!grid || !page || !cells.size) return;
const spots = page.spots.filter((s) => cells.has(s.key));
const w = grid.clientWidth;
const h = grid.clientHeight;
if (keepLayout()) {
// Companion's own rows and columns, cropped to the buttons shown.
const minRow = Math.min(...spots.map((s) => s.row));
const minCol = Math.min(...spots.map((s) => s.column));
const rows = Math.max(...spots.map((s) => s.row)) - minRow + 1;
const cols = Math.max(...spots.map((s) => s.column)) - minCol + 1;
const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
grid.style.setProperty('--cb-cols', String(cols));
grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`);
for (const s of spots) {
const face = cells.get(s.key).face;
face.style.gridRow = String(s.row - minRow + 1);
face.style.gridColumn = String(s.column - minCol + 1);
}
} else {
const { cols, size } = packedGrid(spots.length, w, h);
grid.style.setProperty('--cb-cols', String(cols));
grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`);
for (const s of spots) {
const face = cells.get(s.key).face;
face.style.gridRow = '';
face.style.gridColumn = '';
}
}
}
/* ── choosing ─────────────────────────────────────────────────────── */
function setChoosing(on) {
choosing = on;
pickButton?.classList.toggle('active', on);
connect();
drawPage();
}
/** Put a button in or take it out. Every button in again means the whole page, new buttons included. */
function toggle(key) {
if (!choosing || !page) return;
const all = page.spots.map((s) => s.key);
const now = chosen() || all;
const next = now.includes(key) ? now.filter((k) => k !== key) : [...now, key];
const whole = all.every((k) => next.includes(k));
moduleApi.saveInstanceSettings({ buttons: whole ? null : next.slice(0, MAX_SPOTS) });
drawPage();
}
/* ── the stream ───────────────────────────────────────────────────── */
function apply(kind, body) {
if (kind === 'page') {
page = body;
lost = false;
drawPage();
} else {
pictures.set(body.key, { image: body.image || null, seen: Boolean(body.seen) });
paint(body.key);
}
applyStatus();
}
/** Open the stream for what the tile shows now: the whole page while choosing, else the chosen buttons. */
function connect() {
const id = pageId();
const keys = choosing ? null : chosen();
const path = !id || (keys && !keys.length) ? '' : `${id}${keys ? `/${keys.join('/')}` : ''}`;
if (path === streamPath) return;
if (stream) stream.close();
stream = null;
streamPath = path;
lost = false;
for (const key of [...data.keys()]) if (!keys.includes(key)) data.delete(key);
if (!path) return;
stream = moduleApi.sse(path, {
stream = moduleApi.sse(`/stream/${path}`, {
// The page's connection may already carry this stream (another tile, or
// this one before a remount): ask for what it sent on connect.
async open() {
try {
const res = await moduleApi.fetch(`/state/${path}`);
if (!res.ok || path !== streamPath) return;
const snap = await res.json();
if (path !== streamPath) return;
apply('page', snap.page);
for (const b of snap.buttons) apply('button', b);
} catch {
/* the stream itself will catch up */
}
},
events: {
button(e) {
let msg;
page(e) {
try {
msg = JSON.parse(e.data);
apply('page', JSON.parse(e.data));
} catch {
return;
/* not JSON */
}
},
button(e) {
try {
apply('button', JSON.parse(e.data));
} catch {
/* not JSON */
}
const index = chosen().findIndex((s) => spotKey(s) === msg.key);
if (index < 0) return;
data.set(msg.key, msg);
lost = false;
paintCell(index);
applyStatus();
adopt(index, msg);
},
},
error() {
@@ -114,307 +275,34 @@ export default function create({ root, moduleApi }) {
});
}
/** Save what the server resolved a button to — a new id after a re-import, a new place after a move. */
function adopt(index, msg) {
const where = msg.location;
if (!msg.controlId || !where) return;
const spots = chosen();
const s = spots[index];
if (s.id === msg.controlId && s.page === where.page && s.row === where.row && s.column === where.column) return;
if (spots.some((o, i) => i !== index && o.id === msg.controlId)) return; // already shown here
spots[index] = { id: msg.controlId, page: where.page, row: where.row, column: where.column };
data.set(spotKey(spots[index]), msg); // keep the picture up while the stream is re-made
save(spots);
syncStream();
}
function applyStatus() {
const spots = chosen();
if (pickerEl && !spots.length) return; // the picker reports while it is the whole tile
if (!spots.length) return moduleApi.setStatus('ok', 'No buttons chosen');
if (lost) return showOffline('Lost the connection to ProdDash');
const list = spots.map((s) => data.get(spotKey(s)));
const bad = list.find((d) => d && d.status === 'error');
if (bad) return showOffline(bad.message || 'Companion is not reachable');
setBanner('');
if (list.some((d) => !d || d.status !== 'ok' || !d.seen)) {
const waiting = list.find((d) => d && d.status !== 'ok');
return moduleApi.setStatus('connecting', waiting ? waiting.message : 'Waiting for Companion');
}
const missing = list.filter((d) => !d.image).length;
if (missing) {
return moduleApi.setStatus('connecting', `${missing} of ${list.length} button${list.length === 1 ? ' is' : 's are'} no longer in Companion`);
}
const msg = list[0].message || 'Companion';
return moduleApi.setStatus('ok', list.length > 1 ? `${msg} · ${list.length} buttons` : msg);
}
function showOffline(message) {
setBanner('Companion offline');
moduleApi.setStatus('error', message);
}
function setBanner(text) {
if (!banner || banner.textContent === text) return;
banner.hidden = !text;
banner.textContent = text;
if (view) view.classList.toggle('is-offline', Boolean(text));
layout(); // the banner takes room from the buttons
}
/* ── drawing the chosen buttons ───────────────────────────────────── */
function buildView() {
root.textContent = '';
cells = [];
const spots = chosen();
const wrap = el('div', 'cb-root');
banner = el('div', 'cb-banner');
banner.hidden = true;
wrap.append(banner);
if (!spots.length) {
view = null;
const empty = el('div', 'cb-empty');
empty.append(el('div', 'cb-empty-line', 'No buttons chosen'));
const choose = el('button', 'cb-action', 'Choose buttons');
choose.type = 'button';
choose.addEventListener('click', openPicker);
empty.append(choose);
wrap.append(empty);
root.append(wrap);
return;
}
view = el('div', 'cb-grid');
view.classList.toggle('has-captions', prefs().captions === true);
spots.forEach((s, index) => {
const cell = el('div', 'cb-cell');
const face = el('div', 'cb-face is-waiting');
const img = el('img', 'cb-img');
img.alt = '';
img.draggable = false;
img.hidden = true;
const note = el('div', 'cb-note');
face.append(img, note);
const caption = el('div', 'cb-caption');
cell.append(face, caption);
view.append(cell);
cells.push({ cell, face, img, note, caption });
paintCell(index);
});
wrap.append(view);
root.append(wrap);
layout();
}
function paintCell(index) {
const c = cells[index];
const s = chosen()[index];
if (!c || !s) return;
const d = data.get(spotKey(s));
const where = d && d.location;
const image = d && d.image;
if (image) {
if (c.img.getAttribute('src') !== image) c.img.src = image;
c.img.hidden = false;
c.note.textContent = '';
} else {
c.img.hidden = true;
c.note.textContent = d && d.seen ? 'Not in Companion' : '';
}
c.face.classList.toggle('is-waiting', !d || !d.seen);
c.face.classList.toggle('is-missing', Boolean(d && d.seen && !image));
c.caption.textContent = where ? where.pageName : '';
const title = where ? `${where.pageName} · ${where.page}/${where.row}/${where.column}` : '';
c.cell.title = title;
c.img.alt = title;
}
function layout() {
if (!view || !cells.length) return;
const captionPx = prefs().captions === true ? CAPTION_PX : 0;
const { cols, size } = bestGrid(cells.length, view.clientWidth, view.clientHeight, captionPx);
view.style.setProperty('--cb-cols', String(cols));
view.style.setProperty('--cb-size', `${Math.max(16, size)}px`);
}
/* ── the picker ───────────────────────────────────────────────────── */
function openPicker() {
if (pickerEl) return;
pickButton?.classList.add('active');
pickerEl = el('div', 'cb-picker');
const head = el('div', 'cb-pick-head');
const select = el('select', 'cb-pick-page');
select.setAttribute('aria-label', 'Page');
const clear = el('button', 'cb-pick-btn', 'Clear');
clear.type = 'button';
const done = el('button', 'cb-pick-btn cb-pick-done', 'Done');
done.type = 'button';
head.append(select, clear, done);
const body = el('div', 'cb-pick-body');
pickerEl.append(head, body);
root.textContent = '';
root.append(pickerEl);
select.addEventListener('change', () => {
pickerPage = Number(select.value) || 0;
drawPage();
});
clear.addEventListener('click', () => {
save([]);
markPicked();
});
done.addEventListener('click', closePicker);
loadPages();
}
function closePicker() {
if (!pickerEl) return;
clearTimeout(pickerTimer);
pickerToken += 1;
pickerEl = null;
pickButton?.classList.remove('active');
syncStream();
buildView();
applyStatus();
}
async function loadPages() {
const token = ++pickerToken;
clearTimeout(pickerTimer);
let reply = null;
try {
const res = await moduleApi.fetch('/pages');
reply = await res.json();
if (!res.ok) throw new Error(reply.error || `ProdDash answered ${res.status}`);
} catch (err) {
reply = { status: 'error', message: err.message || 'ProdDash is not answering', pages: [] };
}
if (stopped || !pickerEl || token !== pickerToken) return;
pickerPages = Array.isArray(reply.pages) ? reply.pages : [];
if (!chosen().length) {
moduleApi.setStatus(reply.status === 'ok' ? 'ok' : reply.status === 'connecting' ? 'connecting' : 'error', reply.message || '');
}
if (!pickerPages.length) {
const body = pickerEl.querySelector('.cb-pick-body');
body.textContent = '';
body.append(el('div', 'cb-pick-note', reply.status === 'ok' ? 'Companion has no pages' : reply.message || 'Companion is not reachable'));
pickerTimer = setTimeout(loadPages, PAGES_RETRY_MS);
return;
}
if (!pickerPages.some((p) => p.number === pickerPage)) pickerPage = startPage();
const select = pickerEl.querySelector('.cb-pick-page');
select.textContent = '';
for (const p of pickerPages) {
const opt = el('option', '', p.name);
opt.value = String(p.number);
select.append(opt);
}
select.value = String(pickerPage);
drawPage();
}
/** The page of the first chosen button, else the first page that has buttons. */
function startPage() {
const first = chosen()[0];
if (first && pickerPages.some((p) => p.number === first.page)) return first.page;
return (pickerPages.find((p) => p.buttons.length) || pickerPages[0]).number;
}
function drawPage() {
if (!pickerEl) return;
const body = pickerEl.querySelector('.cb-pick-body');
body.textContent = '';
const page = pickerPages.find((p) => p.number === pickerPage);
if (!page || !page.buttons.length) {
body.append(el('div', 'cb-pick-note', 'No buttons on this page'));
return;
}
// Companion's grid may start below zero; lay the page out from its own corner.
const rows = page.buttons.map((b) => b.row);
const cols = page.buttons.map((b) => b.column);
const minRow = Math.min(0, ...rows);
const minCol = Math.min(0, ...cols);
const grid = el('div', 'cb-pick-grid');
grid.style.setProperty('--cb-pick-cols', String(Math.max(...cols) - minCol + 1));
for (const b of page.buttons) {
const key = el('button', 'cb-pick-key');
key.type = 'button';
key.dataset.id = b.controlId;
key.style.gridRow = String(b.row - minRow + 1);
key.style.gridColumn = String(b.column - minCol + 1);
key.title = `${page.name} · ${page.number}/${b.row}/${b.column}`;
const img = el('img', 'cb-pick-img');
img.alt = key.title;
img.loading = 'lazy';
img.draggable = false;
img.src = imageUrl(b.controlId);
img.addEventListener('error', () => img.classList.add('is-broken'));
key.append(img, el('span', 'cb-pick-badge'));
key.addEventListener('click', () => toggle({ id: b.controlId, page: page.number, row: b.row, column: b.column }));
grid.append(key);
}
body.append(grid);
markPicked();
}
function toggle(spot) {
const spots = chosen();
const at = spots.findIndex((s) => s.id === spot.id);
if (at >= 0) spots.splice(at, 1);
else if (spots.length < MAX_BUTTONS) spots.push(spot);
save(spots);
markPicked();
}
function markPicked() {
if (!pickerEl) return;
const ids = chosen().map((s) => s.id);
for (const key of pickerEl.querySelectorAll('.cb-pick-key')) {
const at = ids.indexOf(key.dataset.id);
key.classList.toggle('is-picked', at >= 0);
key.querySelector('.cb-pick-badge').textContent = at >= 0 ? String(at + 1) : '';
}
}
/* ── lifecycle ────────────────────────────────────────────────────── */
return {
start() {
stopped = false;
moduleApi.setStatus('connecting', 'Connecting');
pickButton = moduleApi.header.addButton({
icon: GRID_SVG,
title: 'Choose buttons',
onClick: () => (pickerEl ? closePicker() : openPicker()),
});
observer = new ResizeObserver(() => layout());
observer.observe(root);
syncStream();
if (chosen().length) {
buildView();
applyStatus();
} else {
openPicker();
build();
if (pageId()) {
pickButton = moduleApi.header.addButton({
icon: GRID_SVG,
title: 'Choose buttons',
onClick: () => setChoosing(!choosing),
});
}
observer = new ResizeObserver(() => layout());
observer.observe(grid);
drawPage();
applyStatus();
connect();
},
stop() {
stopped = true;
clearTimeout(pickerTimer);
pickerToken += 1;
if (stream) stream.close();
stream = null;
streamPath = '';
data.clear();
if (observer) observer.disconnect();
observer = null;
pickerEl = null;
view = null;
cells = [];
pictures.clear();
cells = new Map();
page = null;
root.textContent = '';
},
};
+12 -9
View File
@@ -8,8 +8,9 @@
*
* 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
* 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.
*
@@ -245,7 +246,7 @@ function createPageBook() {
}
}
buttons.sort((a, b) => a.row - b.row || a.column - b.column);
return { number: i + 1, name: pageName(page.name, i + 1), buttons };
return { id, number: i + 1, name: pageName(page.name, i + 1), buttons };
})
.filter(Boolean);
}
@@ -266,17 +267,16 @@ function createPageBook() {
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;
/** 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,
at,
page,
get loaded() {
return order.length > 0;
},
@@ -303,4 +303,7 @@ function pageName(raw, number) {
return `${number} ${name}`;
}
module.exports = { createLink, createPageBook, pageName };
/** 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 };
+12 -6
View File
@@ -1,9 +1,9 @@
{
"id": "companion-buttons",
"name": "Companion Buttons",
"version": "1.0.0",
"version": "1.1.0",
"proddash": ">=1.10.0",
"description": "Any Companion button from any page, live as Companion draws it — for status and what's happening",
"description": "Companion pages and buttons, live as Companion draws them — for status and what's happening",
"permissions": [
{
"kind": "local-network",
@@ -14,7 +14,7 @@
"server": "server.js",
"style": "style.css",
"minSize": { "w": 1, "h": 1 },
"defaultSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 6, "h": 4 },
"configSchema": {
"companion": {
"type": "endpoint",
@@ -24,10 +24,16 @@
}
},
"instanceSchema": {
"captions": {
"layout": {
"type": "switch",
"label": "Captions",
"default": false
"label": "Page layout",
"default": true
},
"page": {
"type": "select",
"label": "Page",
"default": "",
"optionsRoute": "/page-options"
}
}
}
+209 -153
View File
@@ -3,40 +3,41 @@
/**
* Companion Buttons — server part.
*
* Shows any button from Bitfocus Companion, on any page, exactly as Companion
* draws it: colours, text, icons and live feedback included. ProdDash holds
* one connection to Companion (companion.js) however many dashboards are open;
* browsers only ever talk to these routes.
* Shows Bitfocus Companion pages on dashboard tiles, every button exactly as
* Companion draws it: colours, text, icons and live feedback included.
* ProdDash holds one connection to Companion (companion.js) however many
* dashboards are open; browsers only ever talk to these routes.
*
* GET /pages → every page and the buttons on it (the tile's picker)
* GET /image/<id> → one button's current picture, as PNG (picker thumbnails)
* GET /stream/<b>/<b>/… → SSE: `button` for each of those buttons on connect,
* then again whenever one redraws or moves
* Each Companion page is an entry in the + Add-tile picker (tiles()), titled
* with the page's name. A tile shows its whole page, or the buttons chosen on
* it (the tile's own choosing mode).
*
* <id> is Companion's control id without its "bank:" prefix, and each <b> is
* page.row.column.id — "19.1.4.t4bp4ZhrQblkr3bPSEBbc" — in the characters
* ProdDash's page stream carries. A tile holds one stream for all its buttons:
* a page carries at most 32 streams, and a status board can show more buttons
* than that.
* GET /page-options → the pages, for a tile added without one
* GET /stream/<page>[/<r.c>/…]
* → SSE for one tile: `page` (what to lay out) on
* connect and whenever it changes, `button` (one
* picture) on connect and on every redraw
* GET /state/<page>[/<r.c>/…]
* → the same as one snapshot, for a tile that joins
* a stream already open on its dashboard
*
* Which button a tile means: the control id while Companion still has it
* anywhere, so a button that is dragged elsewhere or a page inserted in front
* keeps showing the same button; otherwise whatever is at the page, row and
* column now, so a page that is re-imported (every button gets a new id) keeps
* showing the same place. Every frame says which control and location it
* resolved to, and the tile saves that back, so it stays current.
* <page> is Companion's own page id, which stays with the page when pages are
* inserted or moved; <r.c> is a button's row and column on it. Buttons are
* followed by place, so a page re-imported from a .companionconfig — every
* button gets a new id — keeps showing. A tile holds one stream for all its
* buttons: ProdDash carries at most 32 streams per page.
*
* Read-only by design: nothing here can press a button or change Companion.
*/
const { createLink, createPageBook } = require('./companion');
const { createLink, createPageBook, NAV_TYPES } = require('./companion');
const HEARTBEAT_MS = 15000;
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves
const FIRST_FRAME_MS = 4000; // how long /image waits for Companion's first picture
const MAX_PER_STREAM = 64;
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^(\d{1,4})\.(-?\d{1,3})\.(-?\d{1,3})\.([A-Za-z0-9_-]{1,64})$/;
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves
const DESCRIBE_MS = 3000; // how long to wait for one button's setup
const MAX_SPOTS = 64;
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/;
let current = null;
@@ -50,37 +51,89 @@ function sendJson(res, code, body) {
res.end(JSON.stringify(body));
}
/** "/19.1.4.t4bp4Z…/2.0.0.QOPCk…" → [{ key, page, row, column, id }], or null if malformed. */
function spotsFrom(req) {
const parts = String(req.wildcard || '').replace(/^\//, '').split('/');
if (!parts.length || parts.length > MAX_PER_STREAM) return null;
const spots = [];
for (const key of new Set(parts)) {
const m = SPOT_RE.exec(key);
if (!m) return null;
spots.push({ key, page: Number(m[1]), row: Number(m[2]), column: Number(m[3]), id: `bank:${m[4]}` });
}
return spots;
/** "/<page>" → { pageId, keys: null } (the whole page); "/<page>/0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */
function viewFrom(req) {
const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/');
if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null;
if (!rest.length) return { pageId, keys: null };
if (!rest.every((k) => SPOT_RE.test(k))) return null;
return { pageId, keys: [...new Set(rest)] };
}
module.exports = {
init({ config, log }) {
const { host, port } = endpointOf(config.companion);
const pages = createPageBook();
const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off }
const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] }
const buttons = new Map(); // controlId → { image, seen, refs, linger, off }
const known = new Map(); // controlId → { type }
const describing = new Map(); // controlId → Promise
const viewers = new Set(); // { res, pageId, keys, spots: [{ key, row, column, target }], sig }
let link = null;
let heartbeat = null;
let status = host
? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` }
: { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" };
/* — following a button in Companion — */
/* — what kind of button each is (read once per button; navigation is never shown) — */
function describe(controlId) {
if (!link) return Promise.resolve(null);
if (describing.has(controlId)) return describing.get(controlId);
const p = new Promise((resolve) => {
let off = null;
const finish = (info) => {
clearTimeout(timer);
if (off) off();
off = null;
describing.delete(controlId);
if (info) known.set(controlId, info);
resolve(info);
};
const timer = setTimeout(() => finish(null), DESCRIBE_MS);
off = link.subscribe(
'controls.watchControl',
{ controlId },
(data) => {
if (!data || data.type !== 'init') return;
const cfg = data.config || {};
finish({ type: String(cfg.type || '') });
},
() => finish(null)
);
});
describing.set(controlId, p);
return p;
}
/** Learn every button Companion has that is not known yet, a few at a time. */
let learning = false;
async function learnAll() {
if (learning) return;
learning = true;
try {
for (;;) {
const todo = [];
for (const p of pages.list()) for (const b of p.buttons) if (!known.has(b.controlId)) todo.push(b.controlId);
if (!todo.length || !link || status.state !== 'ok') break;
for (let i = 0; i < todo.length; i += 16) {
await Promise.all(todo.slice(i, i + 16).map(describe));
}
sendAll(); // nav buttons now known: whole-page tiles drop them
if (todo.every((id) => !known.has(id))) break; // Companion is not answering; try again on the next change
}
} finally {
learning = false;
}
}
const isNav = (controlId) => NAV_TYPES.has(known.get(controlId)?.type);
/* — following a button's picture — */
function watch(controlId) {
let b = buttons.get(controlId);
if (!b) {
b = { image: null, seen: false, refs: 0, waiters: new Set(), linger: null, off: null };
b = { image: null, seen: false, refs: 0, linger: null, off: null };
buttons.set(controlId, b);
if (link) {
b.off = link.subscribe(
@@ -91,8 +144,6 @@ module.exports = {
const changed = image !== b.image || !b.seen;
b.image = image;
b.seen = true;
for (const done of b.waiters) done();
b.waiters.clear();
// Status buttons redraw every second with the same picture; only a real change goes out.
if (changed) sendButton(controlId);
},
@@ -105,54 +156,63 @@ module.exports = {
return b;
}
/** Let go of a button once nothing has looked at it for a while. */
function release(controlId) {
const b = buttons.get(controlId);
if (!b || b.refs > 0 || b.waiters.size) return;
if (!b || b.refs > 0) return;
clearTimeout(b.linger);
b.linger = setTimeout(() => {
if (b.refs > 0 || b.waiters.size) return;
if (b.refs > 0) return;
if (b.off) b.off();
buttons.delete(controlId);
}, LINGER_MS);
}
/** The control a tile's spot means right now (see the header), or null. */
function resolve(spot) {
if (!pages.loaded) return spot.id;
if (pages.locate(spot.id)) return spot.id;
return pages.at(spot.page, spot.row, spot.column);
/* — one tile's view of one page — */
/** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */
function spotsFor(viewer, page) {
if (!page) return [];
const at = new Map(page.buttons.map((b) => [`${b.row}.${b.column}`, b]));
const keys = viewer.keys || page.buttons.filter((b) => !isNav(b.controlId)).map((b) => `${b.row}.${b.column}`);
return keys.map((key) => {
const [row, column] = key.split('.').map(Number);
return { key, row, column, target: at.get(key)?.controlId || null };
});
}
function point(spot) {
const target = resolve(spot);
if (target === spot.target) return false;
if (spot.target) {
const old = buttons.get(spot.target);
if (old) old.refs -= 1;
release(spot.target);
}
spot.target = target;
if (target) watch(target).refs += 1;
return true;
}
/* — what a tile is sent — */
function frame(spot) {
const b = spot.target ? buttons.get(spot.target) : null;
const location = spot.target ? pages.locate(spot.target) : null;
const body = {
key: spot.key,
controlId: spot.target,
function pageBody(spots, page) {
return {
status: status.state,
message: status.message,
image: b ? b.image : null,
// A spot with nothing there is as answered as it will get.
seen: b ? b.seen : pages.loaded,
location,
found: Boolean(page) || !pages.loaded,
name: page ? page.name : '',
number: page ? page.number : 0,
spots: spots.map(({ key, row, column }) => ({ key, row, column })),
};
return `event: button\ndata: ${JSON.stringify(body)}\n\n`;
}
function buttonBody(spot) {
const b = spot.target ? buttons.get(spot.target) : null;
return {
key: spot.key,
image: b ? b.image : null,
// A place with nothing on it is as answered as it will get.
seen: b ? b.seen : pages.loaded,
};
}
const pageFrame = (viewer, page) => `event: page\ndata: ${JSON.stringify(pageBody(viewer.spots, page))}\n\n`;
const buttonFrame = (spot) => `event: button\ndata: ${JSON.stringify(buttonBody(spot))}\n\n`;
/**
* What a stream would send on connect, for a tile that joined a stream
* already open on the page (a remount after a settings change): ProdDash
* reuses the connection, so the tile asks for this on `open` instead.
*/
function snapshot(view) {
const page = pages.page(view.pageId);
const spots = spotsFor({ keys: view.keys }, page);
return { page: pageBody(spots, page), buttons: spots.map(buttonBody) };
}
function write(viewer, chunk) {
@@ -163,59 +223,73 @@ module.exports = {
}
}
function sendButton(controlId) {
for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot));
/** Bring a viewer up to date with the page book; send what changed. */
function refresh(viewer, force) {
const page = pages.page(viewer.pageId);
const next = spotsFor(viewer, page);
const before = new Map(viewer.spots.map((s) => [s.key, s.target]));
for (const s of next) if (s.target) watch(s.target).refs += 1;
for (const s of viewer.spots) {
if (!s.target) continue;
const b = buttons.get(s.target);
if (b) b.refs -= 1;
release(s.target);
}
viewer.spots = next;
const sig = JSON.stringify([status, page && page.name, page && page.number, next.map((s) => [s.key, s.target])]);
if (!force && sig === viewer.sig) return;
viewer.sig = sig;
write(viewer, pageFrame(viewer, page));
for (const s of next) if (force || before.get(s.key) !== s.target || !before.has(s.key)) write(viewer, buttonFrame(s));
}
function sendAll() {
for (const v of viewers) {
for (const spot of v.spots) {
point(spot);
write(v, frame(spot));
}
}
for (const v of viewers) refresh(v, false);
}
function sendButton(controlId) {
for (const v of viewers) for (const s of v.spots) if (s.target === controlId) write(v, buttonFrame(s));
}
function drop(viewer) {
if (!viewers.delete(viewer)) return;
for (const spot of viewer.spots) {
if (!spot.target) continue;
const b = buttons.get(spot.target);
for (const s of viewer.spots) {
if (!s.target) continue;
const b = buttons.get(s.target);
if (b) b.refs -= 1;
release(spot.target);
release(s.target);
}
}
function addViewer(res, spots) {
const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) };
function addViewer(res, view) {
const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' };
viewers.add(viewer);
for (const spot of viewer.spots) {
point(spot);
write(viewer, frame(spot));
}
refresh(viewer, true);
return viewer;
}
/* — the connection — */
if (host) {
link = createLink({
host,
port,
onStatus(next) {
status = next.state === 'ok' && !pages.loaded
? { state: 'connecting', message: 'Connected — reading pages' }
: next;
status = next.state === 'ok' && !pages.loaded ? { state: 'connecting', message: 'Connected — reading pages' } : next;
if (next.state !== 'ok') log(next.message);
sendAll();
},
});
link.subscribe('pages.watch', undefined, (change) => {
const fresh = change && change.type === 'init';
pages.apply(change);
if (change && change.type === 'init') {
if (fresh) {
status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` };
log(`connected to Companion at ${host}:${port} (${pages.count} pages)`);
}
// A page renamed, a button moved or a page re-imported changes what tiles show.
// A page renamed, a button added or moved, or a page re-imported changes what tiles show.
sendAll();
learnAll();
});
}
@@ -223,16 +297,31 @@ module.exports = {
for (const v of viewers) write(v, ': ping\n\n');
}, HEARTBEAT_MS);
/** The + picker's entries: one per page that has a button worth showing. */
function tileList() {
return pages
.list()
.filter((p) => p.buttons.some((b) => !isNav(b.controlId)))
.map((p) => ({
id: p.id,
name: p.name,
description: `Companion page ${p.number}`,
settings: { page: p.id },
instanceSchema: {
layout: { type: 'switch', label: 'Page layout', default: true },
},
}));
}
current = {
get status() {
return status;
},
pages,
watch,
release,
addViewer,
drop,
linked: Boolean(link),
snapshot,
tileList,
};
const self = current;
@@ -247,72 +336,41 @@ module.exports = {
}
}
viewers.clear();
for (const b of buttons.values()) {
clearTimeout(b.linger);
for (const done of b.waiters) done();
}
for (const b of buttons.values()) clearTimeout(b.linger);
buttons.clear();
if (link) link.close();
if (current === self) current = null;
},
health() {
const shown = new Set();
for (const v of viewers) for (const spot of v.spots) if (spot.target) shown.add(spot.target);
for (const v of viewers) for (const s of v.spots) if (s.target) shown.add(s.target);
const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : '';
return { status: status.state, message: status.message + suffix };
},
};
},
tiles() {
return current ? current.tileList() : [];
},
routes() {
return {
'GET /pages': (req, res) => {
'GET /page-options': (req, res) => {
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
const { state, message } = current.status;
return sendJson(res, 200, { status: state, message, pages: current.pages.list() });
return sendJson(res, 200, { options: current.pages.list().map((p) => ({ value: p.id, label: p.name })) });
},
'GET /image/*': (req, res) => {
const raw = String(req.wildcard || '').replace(/^\//, '');
if (!ID_RE.test(raw)) return sendJson(res, 400, { error: 'Not a Companion button id' });
if (!current || !current.linked) return sendJson(res, 503, { error: current ? current.status.message : 'Not running' });
const id = `bank:${raw}`;
const app = current;
const b = app.watch(id);
const reply = () => {
if (!b.image) {
res.writeHead(b.seen ? 404 : 504, { 'Cache-Control': 'no-store' });
res.end();
} else {
const png = Buffer.from(b.image.slice(b.image.indexOf(',') + 1), 'base64');
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store', 'Content-Length': png.length });
res.end(png);
}
app.release(id);
};
if (b.seen) return reply();
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
b.waiters.delete(finish);
reply();
};
const timer = setTimeout(finish, FIRST_FRAME_MS);
b.waiters.add(finish);
req.on('close', () => {
if (done) return;
done = true;
clearTimeout(timer);
b.waiters.delete(finish);
app.release(id);
});
'GET /state/*': (req, res) => {
const view = viewFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
return sendJson(res, 200, current.snapshot(view));
},
'GET /stream/*': (req, res) => {
const spots = spotsFrom(req);
if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' });
const view = viewFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
@@ -321,14 +379,12 @@ module.exports = {
});
res.write('retry: 3000\n\n');
if (!current) {
for (const s of spots) {
const body = { key: s.key, controlId: null, status: 'error', message: 'Companion Buttons is not running', image: null, seen: false, location: null };
res.write(`event: button\ndata: ${JSON.stringify(body)}\n\n`);
}
const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, spots: [] };
res.write(`event: page\ndata: ${JSON.stringify(body)}\n\n`);
return void res.end();
}
const app = current;
const viewer = app.addViewer(res, spots);
const viewer = app.addViewer(res, view);
try {
req.socket.setKeepAlive(true, HEARTBEAT_MS);
} catch {
+13 -160
View File
@@ -11,25 +11,18 @@
overflow: hidden;
}
/* ── the chosen buttons ─────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(var(--cb-cols, 1), var(--cb-size, 64px));
grid-auto-rows: max-content;
grid-auto-rows: var(--cb-size, 64px);
gap: 6px;
align-content: center;
justify-content: center;
}
[data-module="companion-buttons"] .cb-cell {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
[data-module="companion-buttons"] .cb-grid[hidden] { display: none; }
[data-module="companion-buttons"] .cb-face {
position: relative;
@@ -57,45 +50,15 @@
to { opacity: 0.8; }
}
[data-module="companion-buttons"] .cb-face.is-missing {
/* A ticked place with no button on it any more. */
[data-module="companion-buttons"] .cb-face.is-empty {
background: transparent;
outline: 1px dashed var(--border);
outline-offset: -1px;
}
[data-module="companion-buttons"] .cb-note {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8%;
color: var(--muted);
font-size: clamp(0.6rem, calc(var(--cb-size, 64px) * 0.12), 0.9rem);
line-height: 1.15;
text-align: center;
}
[data-module="companion-buttons"] .cb-note:empty { display: none; }
[data-module="companion-buttons"] .cb-caption {
display: none;
width: var(--cb-size, 64px);
height: 18px;
padding-top: 3px;
color: var(--muted);
font-size: 0.72rem;
line-height: 15px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="companion-buttons"] .cb-grid.has-captions .cb-caption { display: block; }
/* Companion gone: the last pictures stay, faded, so nobody reads them as live. */
[data-module="companion-buttons"] .cb-grid.is-offline .cb-face {
[data-module="companion-buttons"] .cb-root.is-offline .cb-face {
opacity: 0.3;
filter: grayscale(1);
}
@@ -116,139 +79,29 @@
[data-module="companion-buttons"] .cb-banner[hidden] { display: none; }
/* ── nothing chosen ─────────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
}
[data-module="companion-buttons"] .cb-empty-line {
color: var(--muted);
font-size: 0.85rem;
}
[data-module="companion-buttons"] .cb-action,
[data-module="companion-buttons"] .cb-pick-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
[data-module="companion-buttons"] .cb-action,
[data-module="companion-buttons"] .cb-pick-done {
border-color: var(--accent);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
/* ── the picker ─────────────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-picker {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: var(--panel);
}
[data-module="companion-buttons"] .cb-pick-head {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px;
border-bottom: 1px solid var(--border);
}
[data-module="companion-buttons"] .cb-pick-page {
flex: 1 1 8em;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 0.8rem;
}
[data-module="companion-buttons"] .cb-pick-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px;
}
[data-module="companion-buttons"] .cb-pick-note {
padding: 16px 8px;
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
[data-module="companion-buttons"] .cb-pick-grid {
display: grid;
grid-template-columns: repeat(var(--cb-pick-cols, 8), minmax(36px, 1fr));
gap: 4px;
}
[data-module="companion-buttons"] .cb-empty[hidden] { display: none; }
[data-module="companion-buttons"] .cb-pick-key {
position: relative;
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 8%;
overflow: hidden;
background: color-mix(in srgb, var(--text) 6%, transparent);
/* Choosing: the whole page is shown and clickable; buttons this tile leaves out are faded. */
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face {
cursor: pointer;
}
[data-module="companion-buttons"] .cb-pick-img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
[data-module="companion-buttons"] .cb-pick-img.is-broken { visibility: hidden; }
[data-module="companion-buttons"] .cb-pick-key:hover {
outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent);
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face:hover {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
[data-module="companion-buttons"] .cb-pick-key.is-picked {
outline: 3px solid var(--accent);
outline-offset: -3px;
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face.is-off {
opacity: 0.25;
filter: grayscale(1);
}
[data-module="companion-buttons"] .cb-pick-badge {
position: absolute;
top: 3px;
right: 3px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent);
color: var(--accent-text);
font-size: 0.65rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
[data-module="companion-buttons"] .cb-pick-badge:empty { display: none; }
+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();
+92 -84
View File
@@ -63,9 +63,10 @@ function get(port, path) {
});
}
/** An SSE client that collects `button` events. */
/** An SSE client that collects `page` and `button` events. */
function listen(port, path) {
const events = [];
const pages = [];
const buttons = [];
let req;
const ready = new Promise((resolve) => {
req = http.get({ host: '127.0.0.1', port, path }, (res) => {
@@ -78,13 +79,23 @@ function listen(port, path) {
buf = buf.slice(at + 2);
const name = /^event: (.+)$/m.exec(block);
const data = /^data: (.+)$/m.exec(block);
if (name && data && name[1] === 'button') events.push(JSON.parse(data[1]));
if (!name || !data) continue;
if (name[1] === 'page') pages.push(JSON.parse(data[1]));
if (name[1] === 'button') buttons.push(JSON.parse(data[1]));
}
});
resolve();
});
});
return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] };
return {
pages,
buttons,
ready,
close: () => req.destroy(),
page: () => pages[pages.length - 1],
/** The latest picture for one row.column, or undefined. */
pic: (key) => [...buttons].reverse().find((b) => b.key === key),
};
}
/* ── pure parts ────────────────────────────────────────────────────────── */
@@ -135,6 +146,8 @@ function testPageBook() {
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
assert.strictEqual(book.locate('bank:x'), null);
assert.strictEqual(book.count, 2);
assert.strictEqual(book.page('b').number, 2);
assert.strictEqual(book.page('a'), null);
}
/* ── against the mock ─────────────────────────────────────────────────── */
@@ -152,92 +165,91 @@ async function testLive() {
await until('Companion connected', () => handle.health().status === 'ok');
assert.match(handle.health().message, /3 pages/);
// pages for the picker
const pages = JSON.parse((await get(at, '/pages')).body);
assert.strictEqual(pages.status, 'ok');
assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']);
assert.deepStrictEqual(pages.pages[1].buttons, [
{ row: 0, column: 0, controlId: 'bank:cam1' },
{ row: 1, column: 3, controlId: 'bank:cam2' },
// the + picker: one entry per page with something to show — not the page that is only navigation
await until('nav buttons learned', () => serverEntry.tiles().length === 2);
const tiles = serverEntry.tiles();
assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]);
assert.deepStrictEqual(tiles[0].settings, { page: 'p1' });
assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']);
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
assert.deepStrictEqual(pageOptions.map((o) => o.label), ['1 Status', '2 Cameras', 'Page 3']);
// a whole page: every button but the page-up one, laid out where Companion has them
const whole = listen(at, '/stream/p1');
await whole.ready;
await until('whole page', () => whole.page() && whole.page().status === 'ok' && whole.page().spots.length === 3);
assert.deepStrictEqual(whole.page().spots, [
{ key: '0.0', row: 0, column: 0 },
{ key: '0.1', row: 0, column: 1 },
{ key: '0.2', row: 0, column: 2 },
]);
assert.strictEqual(whole.page().name, '1 Status');
await until('three pictures', () => ['0.0', '0.1', '0.2'].every((k) => whole.pic(k) && whole.pic(k).image));
// thumbnails
const img = await get(at, '/image/blue');
assert.strictEqual(img.status, 200);
assert.strictEqual(img.type, 'image/png');
assert.deepStrictEqual([...img.body.subarray(1, 4)], [0x50, 0x4e, 0x47]);
assert.strictEqual((await get(at, '/image/gone')).status, 404);
assert.strictEqual((await get(at, '/image/..%2Fetc')).status, 400);
assert.strictEqual((await get(at, `/image/${'x'.repeat(200)}`)).status, 400);
// the snapshot a remounted tile asks for matches what the stream sent
const snap = JSON.parse((await get(at, '/state/p1')).body);
assert.deepStrictEqual(snap.page.spots, whole.page().spots);
assert.deepStrictEqual(snap.buttons.map((b) => [b.key, Boolean(b.image), b.seen]), [['0.0', true, true], ['0.1', true, true], ['0.2', true, true]]);
assert.strictEqual((await get(at, '/state/p1/x')).status, 400);
// a button that redraws the same picture every 300 ms sends one event, not five
const steady = listen(at, '/stream/1.0.1.steady');
await steady.ready;
await until('steady first frame', () => steady.last() && steady.last().image);
const settled = steady.events.length;
// a button that redraws the same picture every 300 ms is sent once, not five times
const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length;
await sleep(1600);
assert.strictEqual(steady.events.length, settled, 'identical redraws must not be re-sent');
assert.strictEqual(steady.last().key, '1.0.1.steady');
assert.strictEqual(steady.last().controlId, 'bank:steady');
assert.deepStrictEqual(steady.last().location, { page: 1, row: 0, column: 1, pageName: '1 Status' });
assert.strictEqual(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, 'identical redraws must not be re-sent');
// a status button that changes is forwarded
const flip = listen(at, '/stream/1.0.0.flip');
await flip.ready;
await until('flip first frame', () => flip.last() && flip.last().image);
const firstLook = flip.last().image;
await until('flip redraw', () => flip.last().image !== firstLook, 4000);
const firstLook = whole.pic('0.0').image;
await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000);
// one stream carries all of a tile's buttons: each is sent on connect
const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue');
await pair.ready;
await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2);
assert.deepStrictEqual([...new Set(pair.events.map((e) => e.key))].sort(), ['1.0.2.blue', '2.1.3.cam2']);
pair.close();
assert.strictEqual((await get(at, `/stream/${Array.from({ length: 65 }, (_, i) => `1.0.${i}.b${i}`).join('/')}`)).status, 400);
assert.strictEqual((await get(at, '/stream/flip')).status, 400);
assert.strictEqual((await get(at, '/stream/1.0.0.bank:flip')).status, 400);
// chosen buttons only, in the order chosen
const some = listen(at, '/stream/p2/1.3/0.0');
await some.ready;
await until('two ticked', () => some.page() && some.page().spots.length === 2 && some.pic('1.3') && some.pic('1.3').image && some.pic('0.0'));
assert.deepStrictEqual(some.page().spots.map((s) => s.key), ['1.3', '0.0']);
// a button that is gone, with nothing at its place either, says so instead of hanging
const gone = listen(at, '/stream/3.0.0.gone');
await gone.ready;
await until('missing button answered', () => gone.last() && gone.last().seen);
assert.strictEqual(gone.last().image, null);
assert.strictEqual(gone.last().controlId, null);
// a chosen place with nothing on it is answered, not left waiting
const hole = listen(at, '/stream/p2/3.3');
await hole.ready;
await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen);
assert.strictEqual(hole.pic('3.3').image, null);
hole.close();
// an unknown id falls back to what is at its page/row/column
const byPlace = listen(at, '/stream/2.0.0.oldid');
await byPlace.ready;
await until('found by place', () => byPlace.last() && byPlace.last().image);
assert.strictEqual(byPlace.last().controlId, 'bank:cam1');
byPlace.close();
// a page that does not exist says so
const missing = listen(at, '/stream/nope');
await missing.ready;
await until('missing page', () => missing.page());
assert.strictEqual(missing.page().found, false);
missing.close();
// the button moves to another page in Companion: the tile follows it
// bad paths
assert.strictEqual((await get(at, '/stream/p1/abc')).status, 400);
assert.strictEqual((await get(at, '/stream/p:1')).status, 400);
assert.strictEqual((await get(at, `/stream/p1/${Array.from({ length: 65 }, (_, i) => `0.${i}`).join('/')}`)).status, 400);
// the page is re-imported: every button gets a new id, the tile keeps showing the same places
mock.looks['bank:cam2-new'] = mock.looks['bank:cam2'];
mock.configs['bank:cam2-new'] = mock.configs['bank:cam2'];
mock.pageUpdate({
type: 'update',
updatedOrder: null,
added: [],
changes: [
{ id: 'p1', name: null, controls: [{ row: 0, column: 1, controlId: null }] },
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:steady' }] },
],
changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }],
});
await until('moved location', () => steady.last().location && steady.last().location.page === 2);
assert.strictEqual(steady.last().controlId, 'bank:steady');
await until('renamed', () => some.page().name === '2 Cameras Renamed');
await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image);
// the page is re-imported: every button gets a new id, the tile keeps its place
mock.looks['bank:blue-new'] = mock.looks['bank:blue'];
const blue = listen(at, '/stream/1.0.2.blue');
await blue.ready;
await until('blue', () => blue.last() && blue.last().image);
mock.pageUpdate({
type: 'update',
updatedOrder: null,
added: [],
changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-new' }] }],
});
await until('re-imported button found by place', () => blue.last().controlId === 'bank:blue-new' && blue.last().image);
blue.close();
// a page inserted in front: the tile follows its page, now page 3
mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] });
await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed');
some.close();
// a button added to a whole-page tile appears in it
mock.looks['bank:new'] = mock.looks['bank:blue'];
mock.configs['bank:new'] = mock.configs['bank:blue'];
mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank:new' }] }] });
await until('new button shown', () => whole.page().spots.some((s) => s.key === '2.2') && whole.pic('2.2') && whole.pic('2.2').image);
const flip = { last: () => ({ status: whole.page().status, image: whole.pic('0.0') && whole.pic('0.0').image }) };
// Companion drops: streams say so, then recover by themselves
mock.dropAll();
@@ -254,9 +266,7 @@ async function testLive() {
await until('stream live after restart', () => flip.last().status === 'ok' && flip.last().image, 4000);
// closing a viewer keeps the subscription a little while (remounts are cheap), stop() ends all
steady.close();
flip.close();
gone.close();
whole.close();
await sleep(100);
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
} finally {
@@ -275,14 +285,12 @@ async function testUnconfigured() {
try {
assert.strictEqual(handle.health().status, 'error');
assert.match(handle.health().message, /admin/);
const pages = JSON.parse((await get(at, '/pages')).body);
assert.strictEqual(pages.status, 'error');
assert.deepStrictEqual(pages.pages, []);
assert.strictEqual((await get(at, '/image/x')).status, 503);
const s = listen(at, '/stream/1.0.0.x');
assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []);
assert.deepStrictEqual(serverEntry.tiles(), []);
const s = listen(at, '/stream/p1');
await s.ready;
await until('stream answers', () => s.last());
assert.strictEqual(s.last().status, 'error');
await until('stream answers', () => s.page());
assert.strictEqual(s.page().status, 'error');
s.close();
} finally {
handle.stop();