Files
companion-buttons/client.js
T
christianandClaude Opus 5.5 a3097481e7 Companion Buttons 1.2.0: back to picking any buttons from any pages, without the header row
1.1.0 made each tile one page, so buttons picked on a second page never
showed, choosing ended on every settings change, and a tile could go blank
after a remount. This goes back to 1.0's model and keeps only the changes
asked for:

- Pick buttons from any pages; each gets a ring and its order number.
- No Page / Clear / Done row: Page is in the gear, the grid icon starts and
  ends choosing, a second click on a button takes it out.
- Choosing survives the remount a gear change causes, so switching Page
  carries straight on.
- A tile joining a stream its dashboard already has open (another tile with
  the same buttons, or itself after a settings change) asks GET /state for
  the snapshot instead of waiting blank.
- Gear: Captions and Page. The per-page + entries are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:09:31 -04:00

428 lines
16 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, from any pages, as
Companion draws them and 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).
Choosing: the grid icon in the title bar turns the tile into the Companion
page named by the gear's Page, laid out as it is in Companion. A click on
a button adds it to the tile (it gets a numbered ring, the order it will
show in) or takes it out. Change the gear's Page to pick from another page
— what is already chosen stays. The icon again shows the chosen buttons.
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, so a moved button or a re-imported page keeps showing. */
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 PAGE_RE = /^[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) && typeof s === 'object' && 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)}`;
// Choosing survives the remount a gear change causes (picking from another
// Page), so it is kept per tile for this browser tab, not in the closure.
const choosingKey = `companion-buttons:choosing:${moduleApi.instanceId}`;
const readChoosing = () => {
try {
return sessionStorage.getItem(choosingKey) === '1';
} catch {
return false;
}
};
const writeChoosing = (on) => {
try {
if (on) sessionStorage.setItem(choosingKey, '1');
else sessionStorage.removeItem(choosingKey);
} catch {
/* private window: choosing just ends on the next remount */
}
};
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 choosing = false;
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
let view = null;
let banner = null;
let chooser = null; // the page being chosen from, while choosing
let pages = [];
let pagesTimer = null;
let pagesToken = 0;
let pickButton = null;
let observer = null;
let stopped = false;
/* ── live buttons ─────────────────────────────────────────────────── */
function onButton(msg) {
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);
}
function syncStream() {
const keys = chosen().map(spotKey);
const path = keys.length ? 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(`/stream/${path}`, {
// The dashboard may already carry this stream (another tile showing the
// same buttons, or this tile before a remount); then nothing is re-sent
// on its own, so 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 || stopped) return;
for (const msg of snap.buttons || []) if (msg.seen || !data.has(msg.key)) onButton(msg);
} catch {
/* the stream itself catches up on the next redraw */
}
},
events: {
button(e) {
try {
onButton(JSON.parse(e.data));
} catch {
/* not JSON */
}
},
},
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 (choosing && !spots.length) return; // choosing reports Companion's state itself
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
}
/* ── the chosen buttons ───────────────────────────────────────────── */
function buildView() {
root.textContent = '';
cells = [];
chooser = null;
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;
wrap.append(el('div', 'cb-empty', 'No buttons chosen'));
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`);
}
/* ── choosing ─────────────────────────────────────────────────────── */
function setChoosing(on) {
choosing = on;
writeChoosing(on);
pickButton?.classList.toggle('active', on);
if (on) {
openChooser();
} else {
clearTimeout(pagesTimer);
pagesToken += 1;
syncStream();
buildView();
applyStatus();
}
}
function openChooser() {
root.textContent = '';
cells = [];
view = null;
banner = null;
chooser = el('div', 'cb-chooser');
root.append(chooser);
loadPages();
}
async function loadPages() {
const token = ++pagesToken;
clearTimeout(pagesTimer);
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 || !chooser || token !== pagesToken) return;
pages = Array.isArray(reply.pages) ? reply.pages : [];
moduleApi.setStatus(reply.status === 'ok' ? 'ok' : reply.status === 'connecting' ? 'connecting' : 'error', reply.message || '');
if (!pages.length) {
chooser.textContent = '';
chooser.append(el('div', 'cb-empty', reply.status === 'ok' ? 'Companion has no pages' : reply.message || 'Companion is not reachable'));
pagesTimer = setTimeout(loadPages, PAGES_RETRY_MS);
return;
}
drawChooser();
}
/** The page the gear names; else the page of the first chosen button, or the first page with buttons — saved, so the gear shows it. */
function chooserPage() {
const wanted = String(prefs().page || '');
const named = PAGE_RE.test(wanted) && pages.find((p) => p.id === wanted);
if (named) return named;
const first = chosen()[0];
const fallback = (first && pages.find((p) => p.number === first.page))
|| pages.find((p) => p.buttons.length)
|| pages[0];
moduleApi.saveInstanceSettings({ page: fallback.id });
return fallback;
}
function drawChooser() {
const page = chooserPage();
chooser.textContent = '';
if (!page.buttons.length) {
chooser.append(el('div', 'cb-empty', `${page.name} has no buttons`));
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.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);
}
chooser.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 (!chooser) return;
const ids = chosen().map((s) => s.id);
for (const key of chooser.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: () => setChoosing(!choosing),
});
observer = new ResizeObserver(() => layout());
observer.observe(root);
syncStream();
// A new tile, or one that was choosing before a gear change, goes (back) to choosing.
setChoosing(readChoosing() || !chosen().length);
},
stop() {
stopped = true;
clearTimeout(pagesTimer);
pagesToken += 1;
if (stream) stream.close();
stream = null;
streamPath = '';
data.clear();
if (observer) observer.disconnect();
observer = null;
chooser = null;
view = null;
cells = [];
root.textContent = '';
},
};
}