Files
companion-buttons/client.js
T
christianandClaude Opus 5.5 0a6cec88aa 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>
2026-10-15 11:48:24 -04:00

310 lines
11 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 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).
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 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);
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 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 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;
}
export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {};
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 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 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;
/* ── status ───────────────────────────────────────────────────────── */
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;
if (!path) return;
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: {
page(e) {
try {
apply('page', JSON.parse(e.data));
} catch {
/* not JSON */
}
},
button(e) {
try {
apply('button', JSON.parse(e.data));
} catch {
/* not JSON */
}
},
},
error() {
lost = true;
applyStatus();
},
});
}
/* ── lifecycle ────────────────────────────────────────────────────── */
return {
start() {
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() {
if (stream) stream.close();
stream = null;
streamPath = '';
if (observer) observer.disconnect();
observer = null;
pictures.clear();
cells = new Map();
page = null;
root.textContent = '';
},
};
}