2026-10-14 20:12:23 -04:00
|
|
|
|
/* Companion Buttons — the tile.
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
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).
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
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. */
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
|
|
|
const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/;
|
|
|
|
|
|
const MAX_SPOTS = 64;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
|
|
|
|
|
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. */
|
2026-10-15 11:48:24 -04:00
|
|
|
|
function packedGrid(n, w, h) {
|
2026-10-14 20:12:23 -04:00
|
|
|
|
let best = { cols: 1, size: 0 };
|
|
|
|
|
|
for (let cols = 1; cols <= n; cols += 1) {
|
|
|
|
|
|
const rows = Math.ceil(n / cols);
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
|
2026-10-14 20:12:23 -04:00
|
|
|
|
if (size > best.size) best = { cols, size };
|
|
|
|
|
|
}
|
|
|
|
|
|
return best;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function create({ root, moduleApi }) {
|
|
|
|
|
|
const prefs = () => moduleApi.instanceSettings || {};
|
2026-10-15 11:48:24 -04:00
|
|
|
|
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;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const pictures = new Map(); // spot key → { image, seen }
|
|
|
|
|
|
let page = null; // the server's latest `page` event
|
|
|
|
|
|
let stream = null;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
let streamPath = '';
|
|
|
|
|
|
let lost = false;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
let choosing = false;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
let pickButton = null;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
let wrap = null;
|
|
|
|
|
|
let banner = null;
|
|
|
|
|
|
let grid = null;
|
|
|
|
|
|
let note = null;
|
|
|
|
|
|
let cells = new Map(); // spot key → { face, img }
|
2026-10-14 20:12:23 -04:00
|
|
|
|
let observer = null;
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/* ── status ───────────────────────────────────────────────────────── */
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
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('/')}` : ''}`;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
if (path === streamPath) return;
|
|
|
|
|
|
if (stream) stream.close();
|
|
|
|
|
|
stream = null;
|
|
|
|
|
|
streamPath = path;
|
|
|
|
|
|
if (!path) return;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-10-14 20:12:23 -04:00
|
|
|
|
events: {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
page(e) {
|
2026-10-14 20:12:23 -04:00
|
|
|
|
try {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
apply('page', JSON.parse(e.data));
|
2026-10-14 20:12:23 -04:00
|
|
|
|
} catch {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/* not JSON */
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
button(e) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
apply('button', JSON.parse(e.data));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* not JSON */
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
error() {
|
|
|
|
|
|
lost = true;
|
|
|
|
|
|
applyStatus();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── lifecycle ────────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
start() {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
build();
|
|
|
|
|
|
if (pageId()) {
|
|
|
|
|
|
pickButton = moduleApi.header.addButton({
|
|
|
|
|
|
icon: GRID_SVG,
|
|
|
|
|
|
title: 'Choose buttons',
|
|
|
|
|
|
onClick: () => setChoosing(!choosing),
|
|
|
|
|
|
});
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
2026-10-15 11:48:24 -04:00
|
|
|
|
observer = new ResizeObserver(() => layout());
|
|
|
|
|
|
observer.observe(grid);
|
|
|
|
|
|
drawPage();
|
|
|
|
|
|
applyStatus();
|
|
|
|
|
|
connect();
|
2026-10-14 20:12:23 -04:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
stop() {
|
|
|
|
|
|
if (stream) stream.close();
|
|
|
|
|
|
stream = null;
|
|
|
|
|
|
streamPath = '';
|
|
|
|
|
|
if (observer) observer.disconnect();
|
|
|
|
|
|
observer = null;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
pictures.clear();
|
|
|
|
|
|
cells = new Map();
|
|
|
|
|
|
page = null;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
root.textContent = '';
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|