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>
This commit is contained in:
@@ -1,24 +1,42 @@
|
||||
/* 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
|
||||
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).
|
||||
|
||||
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. */
|
||||
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 SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/;
|
||||
const MAX_SPOTS = 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);
|
||||
@@ -28,11 +46,13 @@ function el(tag, className, text) {
|
||||
}
|
||||
|
||||
/** The column count that makes `n` square cells biggest inside w × h. */
|
||||
function packedGrid(n, 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 size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
|
||||
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;
|
||||
@@ -40,229 +60,88 @@ function packedGrid(n, w, h) {
|
||||
|
||||
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 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 pictures = new Map(); // spot key → { image, seen }
|
||||
let page = null; // the server's latest `page` event
|
||||
let stream = null;
|
||||
// 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 pickButton = null;
|
||||
let wrap = null;
|
||||
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
|
||||
let view = null;
|
||||
let banner = null;
|
||||
let grid = null;
|
||||
let note = null;
|
||||
let cells = new Map(); // spot key → { face, img }
|
||||
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;
|
||||
|
||||
/* ── status ───────────────────────────────────────────────────────── */
|
||||
/* ── live buttons ─────────────────────────────────────────────────── */
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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('/')}` : ''}`;
|
||||
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 page's connection may already carry this stream (another tile, or
|
||||
// this one before a remount): ask for what it sent on connect.
|
||||
// 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) return;
|
||||
apply('page', snap.page);
|
||||
for (const b of snap.buttons) apply('button', b);
|
||||
if (path !== streamPath || stopped) return;
|
||||
for (const msg of snap.buttons || []) if (msg.seen || !data.has(msg.key)) onButton(msg);
|
||||
} catch {
|
||||
/* the stream itself will catch up */
|
||||
/* the stream itself catches up on the next redraw */
|
||||
}
|
||||
},
|
||||
events: {
|
||||
page(e) {
|
||||
try {
|
||||
apply('page', JSON.parse(e.data));
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
},
|
||||
button(e) {
|
||||
try {
|
||||
apply('button', JSON.parse(e.data));
|
||||
onButton(JSON.parse(e.data));
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
@@ -275,34 +154,273 @@ export default function create({ root, moduleApi }) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
build();
|
||||
if (pageId()) {
|
||||
pickButton = moduleApi.header.addButton({
|
||||
icon: GRID_SVG,
|
||||
title: 'Choose buttons',
|
||||
onClick: () => setChoosing(!choosing),
|
||||
});
|
||||
}
|
||||
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(grid);
|
||||
drawPage();
|
||||
applyStatus();
|
||||
connect();
|
||||
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;
|
||||
pictures.clear();
|
||||
cells = new Map();
|
||||
page = null;
|
||||
chooser = null;
|
||||
view = null;
|
||||
cells = [];
|
||||
root.textContent = '';
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user