422 lines
15 KiB
JavaScript
422 lines
15 KiB
JavaScript
/* 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 = '';
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|