Files
companion-buttons/client.js
T
christianandClaude Opus 5.5 415453ef5f Companion Buttons 1.0.0: any Companion button from any page, live on a ProdDash tile
A ProdDash module for status buttons and what's happening: pick buttons from
any Companion page in the tile's own picker; each is shown exactly as
Companion draws it and redraws live. One tile can hold one button or many.

- One read-only connection to Companion's tRPC websocket (pages.watch and
  preview.graphics.controlId), shared by every dashboard; identical redraws
  are not re-sent.
- A tile follows its button by id, and by page/row/column when the id is
  gone (a re-imported page), saving back what it resolved to.
- One SSE stream per tile, within ProdDash's 32-streams-per-page limit.
- Red dot and a faded "Companion offline" view when Companion is down;
  recovers by itself.
- Tests against a mock Companion: node test/companion.test.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:12:23 -04:00

422 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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).
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. */
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;
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/** The column count that makes `n` square cells biggest inside w × h. */
function bestGrid(n, w, h, captionPx) {
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));
if (size > best.size) best = { cols, size };
}
return best;
}
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 data = new Map(); // spot key → the server's latest word on it
let stream = null; // one stream for every button on the tile
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 pickButton = null;
let observer = null;
let stopped = false;
/* ── live buttons ─────────────────────────────────────────────────── */
function syncStream() {
const keys = chosen().map(spotKey);
const path = keys.length ? `/stream/${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, {
events: {
button(e) {
let msg;
try {
msg = JSON.parse(e.data);
} catch {
return;
}
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() {
lost = true;
applyStatus();
},
});
}
/** 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();
}
},
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 = [];
root.textContent = '';
},
};
}