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>
This commit is contained in:
@@ -1,14 +1,11 @@
|
|||||||
# Companion Buttons
|
# Companion Buttons
|
||||||
|
|
||||||
A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module
|
A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module
|
||||||
that shows any button from Bitfocus Companion — any page, any button — on a
|
that shows Bitfocus Companion pages on dashboard tiles — a whole page, or just
|
||||||
dashboard tile, exactly as Companion draws it: colours, text, icons and live
|
the buttons you pick from it — every button exactly as Companion draws it:
|
||||||
feedback. It is meant for status buttons and anything else that says what is
|
colours, text, icons and live feedback. It is meant for status buttons and
|
||||||
happening right now.
|
anything else that says what is happening right now.
|
||||||
|
|
||||||
- **One tile, one or many buttons.** A single button fills the tile; several
|
|
||||||
share it in whatever grid makes each one biggest, and resizing the tile
|
|
||||||
re-flows them.
|
|
||||||
- **Live.** Every time Companion redraws a button (a status turning red, a
|
- **Live.** Every time Companion redraws a button (a status turning red, a
|
||||||
tally lighting up), the tile shows it. Identical redraws — a status page that
|
tally lighting up), the tile shows it. Identical redraws — a status page that
|
||||||
repaints every second with the same picture — are not re-sent.
|
repaints every second with the same picture — are not re-sent.
|
||||||
@@ -21,26 +18,27 @@ happening right now.
|
|||||||
running Companion and its web interface port (8000 unless changed in
|
running Companion and its web interface port (8000 unless changed in
|
||||||
Companion's settings). Use `127.0.0.1` when Companion runs on the same
|
Companion's settings). Use `127.0.0.1` when Companion runs on the same
|
||||||
machine as ProdDash.
|
machine as ProdDash.
|
||||||
2. **+ Add tile → Companion Buttons.** A new tile opens the picker: choose a
|
2. **+ Add tile:** every Companion page is listed under **Companion
|
||||||
page from the list and tap the buttons to show. The numbers on the picked
|
Buttons**. Pick one — "19 Status", say — and the tile shows that page,
|
||||||
buttons are the order they appear in. **Done** shows them.
|
titled with its name. Page up/down buttons are left out.
|
||||||
3. To change the buttons later, use the grid icon in the tile's title bar.
|
3. **To show only some buttons,** click the grid icon in the tile's title bar.
|
||||||
The gear has one switch, **Captions**, which writes the page name under
|
The whole page appears; click a button to take it out (it fades) or put it
|
||||||
each button.
|
back. Click the icon again when done. Putting every button back means the
|
||||||
|
whole page again, including buttons added to it later.
|
||||||
|
4. **The gear** has one switch, **Page layout**: on, the buttons keep
|
||||||
|
Companion's rows and columns; off, they are packed into whatever grid makes
|
||||||
|
each one biggest.
|
||||||
|
|
||||||
Hover a button for its page and Companion location (`page/row/column`).
|
Hover a button for its page and Companion location (`page/row/column`).
|
||||||
|
|
||||||
### What a tile follows
|
### What a tile follows
|
||||||
|
|
||||||
Each picked button is saved with its Companion id **and** its page, row and
|
A tile follows its page by Companion's own page id, so inserting or moving
|
||||||
column. While Companion still has that button anywhere, the tile follows it,
|
pages changes nothing. Buttons are followed by their row and column on that
|
||||||
so dragging it to another spot or inserting a page in front changes nothing.
|
page, so a page re-imported from a `.companionconfig` (every button gets a new
|
||||||
If the id is gone — a page re-imported from a `.companionconfig` gives every
|
internal id) keeps showing. A chosen place that is emptied in Companion shows
|
||||||
button a new id — the tile shows whatever is at the saved page, row and column
|
a dashed outline. A page deleted in Companion leaves the tile empty; the
|
||||||
instead. Either way the tile saves back what it found, so it stays current.
|
dashboard folds it away.
|
||||||
|
|
||||||
A button that is gone with nothing at its place shows **Not in Companion**,
|
|
||||||
and the status dot turns amber.
|
|
||||||
|
|
||||||
### When Companion is down
|
### When Companion is down
|
||||||
|
|
||||||
@@ -53,20 +51,23 @@ last pictures to grey, so nobody reads them as live. ProdDash keeps retrying
|
|||||||
Companion 4 and 5 have no HTTP route that returns a button's picture. Their
|
Companion 4 and 5 have no HTTP route that returns a button's picture. Their
|
||||||
own web buttons and emulator get them from the tRPC websocket on the web
|
own web buttons and emulator get them from the tRPC websocket on the web
|
||||||
interface port (`ws://<host>:<port>/trpc`). The module's server holds one such
|
interface port (`ws://<host>:<port>/trpc`). The module's server holds one such
|
||||||
connection for all dashboards and uses two read-only subscriptions:
|
connection for all dashboards and uses three read-only subscriptions:
|
||||||
`pages.watch` (pages, names, which button is where) and
|
`pages.watch` (pages, names, which button is where),
|
||||||
`preview.graphics.controlId` (a button's PNG, on every redraw). That is
|
`preview.graphics.controlId` (a button's PNG, on every redraw) and
|
||||||
Companion's internal API rather than a documented one, so a future Companion
|
`controls.watchControl` (read once per button, to leave out page up/down).
|
||||||
release could change it; it was built and tested against Companion 5.0.7.
|
That is Companion's internal API rather than a documented one, so a future
|
||||||
|
Companion release could change it; it was built and tested against Companion
|
||||||
|
5.0.7.
|
||||||
|
|
||||||
| Route | What it serves |
|
| Route | What it serves |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /pages` | Every page and the buttons on it — the picker's list |
|
| `GET /stream/<page>[/<row.col>/…]` | SSE for one tile: `page` (what to lay out) and `button` (one picture) on connect, then on every change |
|
||||||
| `GET /image/<id>` | One button's current picture as PNG — picker thumbnails |
|
| `GET /state/<page>[/<row.col>/…]` | The same as one snapshot, for a tile that joins a stream the dashboard already has open |
|
||||||
| `GET /stream/<page.row.column.id>/…` | SSE `button` events for a tile's buttons: all of them on connect, then each redraw or move |
|
| `GET /page-options` | The pages, for a tile added without one (the gear's Page) |
|
||||||
|
|
||||||
A tile holds one stream for all its buttons, because ProdDash carries at most
|
The + entries come from the server's `tiles()`, one per page. A tile holds
|
||||||
32 streams per page and a status board can show more buttons than that.
|
one stream for all its buttons, because ProdDash carries at most 32 streams
|
||||||
|
per page and a status board can show more buttons than that.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -81,9 +82,9 @@ A tile holds one stream for all its buttons, because ProdDash carries at most
|
|||||||
| File | |
|
| File | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `module.json` | Manifest |
|
| `module.json` | Manifest |
|
||||||
| `server.js` | Routes, button subscriptions, the per-tile streams |
|
| `server.js` | + entries, routes, button subscriptions, the per-tile streams |
|
||||||
| `companion.js` | The connection to Companion's tRPC websocket and the page list |
|
| `companion.js` | The connection to Companion's tRPC websocket and the page list |
|
||||||
| `client.js`, `style.css` | The tile and its picker |
|
| `client.js`, `style.css` | The tile and its choosing mode |
|
||||||
| `test/companion.test.js` | Tests: `node test/companion.test.js` |
|
| `test/companion.test.js` | Tests: `node test/companion.test.js` |
|
||||||
| `test/companion-mock.js` | A stand-in Companion: `node test/companion-mock.js 18000`, then point the module at `127.0.0.1` port `18000` |
|
| `test/companion-mock.js` | A stand-in Companion: `node test/companion-mock.js 18000`, then point the module at `127.0.0.1` port `18000` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
/* Companion Buttons — the tile.
|
/* Companion Buttons — the tile.
|
||||||
|
|
||||||
Shows the Companion buttons this tile was given, as Companion draws them,
|
Shows one Companion page, or the buttons of it chosen for this tile, every
|
||||||
as large as the tile allows. One button fills the tile; several share it in
|
button as Companion draws it and as large as the tile allows. Every
|
||||||
whatever grid makes each one biggest. Every picture is live: the server
|
picture is live: the server forwards each redraw (a status button turning
|
||||||
forwards each redraw (a status button turning red, a tally lighting up).
|
red, a tally lighting up).
|
||||||
|
|
||||||
Buttons are chosen in the tile itself — the grid icon in the title bar
|
The page comes from the + picker entry the tile was added as (or, for a
|
||||||
opens a picker that shows any Companion page as it is laid out, and a tap
|
tile added without one, the gear's Page). The grid icon in the title bar
|
||||||
adds or removes a button. The choice is the tile's own (saved with the
|
switches the tile to choosing: the whole page is shown, the buttons this
|
||||||
layout), so two tiles can show two different sets. Each choice is kept as
|
tile leaves out faded, and a click puts a button in or takes it out. The
|
||||||
the button's id and its page/row/column; the server follows whichever
|
icon again goes back. "Page layout" in the gear keeps Companion's own rows
|
||||||
still holds, and the tile saves back what it resolved to. */
|
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 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 GAP_PX = 6;
|
||||||
const CAPTION_PX = 18;
|
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
const PAGES_RETRY_MS = 5000;
|
const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/;
|
||||||
const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/;
|
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) && 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) {
|
function el(tag, className, text) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
@@ -41,13 +28,11 @@ function el(tag, className, text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The column count that makes `n` square cells biggest inside w × h. */
|
/** The column count that makes `n` square cells biggest inside w × h. */
|
||||||
function bestGrid(n, w, h, captionPx) {
|
function packedGrid(n, w, h) {
|
||||||
let best = { cols: 1, size: 0 };
|
let best = { cols: 1, size: 0 };
|
||||||
for (let cols = 1; cols <= n; cols += 1) {
|
for (let cols = 1; cols <= n; cols += 1) {
|
||||||
const rows = Math.ceil(n / cols);
|
const rows = Math.ceil(n / cols);
|
||||||
const byWidth = (w - GAP_PX * (cols - 1)) / cols;
|
const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
|
||||||
const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx;
|
|
||||||
const size = Math.floor(Math.min(byWidth, byHeight));
|
|
||||||
if (size > best.size) best = { cols, size };
|
if (size > best.size) best = { cols, size };
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
@@ -55,56 +40,232 @@ function bestGrid(n, w, h, captionPx) {
|
|||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
const prefs = () => moduleApi.instanceSettings || {};
|
const prefs = () => moduleApi.instanceSettings || {};
|
||||||
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []);
|
const pageId = () => {
|
||||||
const save = (spots) => moduleApi.saveInstanceSettings({
|
const p = String(prefs().page || '');
|
||||||
buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })),
|
return PAGE_RE.test(p) ? p : '';
|
||||||
});
|
};
|
||||||
const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`;
|
/** 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 data = new Map(); // spot key → the server's latest word on it
|
const pictures = new Map(); // spot key → { image, seen }
|
||||||
let stream = null; // one stream for every button on the tile
|
let page = null; // the server's latest `page` event
|
||||||
|
let stream = null;
|
||||||
let streamPath = '';
|
let streamPath = '';
|
||||||
let lost = false;
|
let lost = false;
|
||||||
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
|
let choosing = false;
|
||||||
let view = null;
|
|
||||||
let banner = null;
|
|
||||||
let pickerEl = null;
|
|
||||||
let pickerPage = 0;
|
|
||||||
let pickerPages = [];
|
|
||||||
let pickerTimer = null;
|
|
||||||
let pickerToken = 0;
|
|
||||||
let pickButton = null;
|
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;
|
let observer = null;
|
||||||
let stopped = false;
|
|
||||||
|
|
||||||
/* ── live buttons ─────────────────────────────────────────────────── */
|
/* ── status ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function syncStream() {
|
function applyStatus() {
|
||||||
const keys = chosen().map(spotKey);
|
if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen');
|
||||||
const path = keys.length ? `/stream/${keys.join('/')}` : '';
|
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 (path === streamPath) return;
|
||||||
if (stream) stream.close();
|
if (stream) stream.close();
|
||||||
stream = null;
|
stream = null;
|
||||||
streamPath = path;
|
streamPath = path;
|
||||||
lost = false;
|
|
||||||
for (const key of [...data.keys()]) if (!keys.includes(key)) data.delete(key);
|
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
stream = moduleApi.sse(path, {
|
stream = moduleApi.sse(`/stream/${path}`, {
|
||||||
events: {
|
// The page's connection may already carry this stream (another tile, or
|
||||||
button(e) {
|
// this one before a remount): ask for what it sent on connect.
|
||||||
let msg;
|
async open() {
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(e.data);
|
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 {
|
} catch {
|
||||||
return;
|
/* 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 */
|
||||||
}
|
}
|
||||||
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() {
|
error() {
|
||||||
@@ -114,307 +275,34 @@ 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 (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 ────────────────────────────────────────────────────── */
|
/* ── lifecycle ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start() {
|
start() {
|
||||||
stopped = false;
|
build();
|
||||||
moduleApi.setStatus('connecting', 'Connecting');
|
if (pageId()) {
|
||||||
pickButton = moduleApi.header.addButton({
|
pickButton = moduleApi.header.addButton({
|
||||||
icon: GRID_SVG,
|
icon: GRID_SVG,
|
||||||
title: 'Choose buttons',
|
title: 'Choose buttons',
|
||||||
onClick: () => (pickerEl ? closePicker() : openPicker()),
|
onClick: () => setChoosing(!choosing),
|
||||||
});
|
});
|
||||||
observer = new ResizeObserver(() => layout());
|
|
||||||
observer.observe(root);
|
|
||||||
syncStream();
|
|
||||||
if (chosen().length) {
|
|
||||||
buildView();
|
|
||||||
applyStatus();
|
|
||||||
} else {
|
|
||||||
openPicker();
|
|
||||||
}
|
}
|
||||||
|
observer = new ResizeObserver(() => layout());
|
||||||
|
observer.observe(grid);
|
||||||
|
drawPage();
|
||||||
|
applyStatus();
|
||||||
|
connect();
|
||||||
},
|
},
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
|
||||||
clearTimeout(pickerTimer);
|
|
||||||
pickerToken += 1;
|
|
||||||
if (stream) stream.close();
|
if (stream) stream.close();
|
||||||
stream = null;
|
stream = null;
|
||||||
streamPath = '';
|
streamPath = '';
|
||||||
data.clear();
|
|
||||||
if (observer) observer.disconnect();
|
if (observer) observer.disconnect();
|
||||||
observer = null;
|
observer = null;
|
||||||
pickerEl = null;
|
pictures.clear();
|
||||||
view = null;
|
cells = new Map();
|
||||||
cells = [];
|
page = null;
|
||||||
root.textContent = '';
|
root.textContent = '';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-9
@@ -8,8 +8,9 @@
|
|||||||
*
|
*
|
||||||
* pages.watch → every page, its name, and which control
|
* pages.watch → every page, its name, and which control
|
||||||
* sits at each row/column
|
* sits at each row/column
|
||||||
* preview.graphics.controlId → a button's rendered image, following the
|
* preview.graphics.controlId → a button's rendered image, on every redraw
|
||||||
* button if it is moved to another page
|
* controls.watchControl → a button's setup, read once for its kind
|
||||||
|
* (page up/down buttons are left out)
|
||||||
*
|
*
|
||||||
* Nothing here can press, edit or change anything in Companion.
|
* Nothing here can press, edit or change anything in Companion.
|
||||||
*
|
*
|
||||||
@@ -245,7 +246,7 @@ function createPageBook() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
buttons.sort((a, b) => a.row - b.row || a.column - b.column);
|
buttons.sort((a, b) => a.row - b.row || a.column - b.column);
|
||||||
return { number: i + 1, name: pageName(page.name, i + 1), buttons };
|
return { id, number: i + 1, name: pageName(page.name, i + 1), buttons };
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -266,17 +267,16 @@ function createPageBook() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The control at a page number, row and column right now, or null. */
|
/** One page by Companion's own page id (stable when pages are renumbered), or null. */
|
||||||
function at(number, row, column) {
|
function page(id) {
|
||||||
const page = pages.get(order[number - 1]);
|
return list().find((p) => p.id === id) || null;
|
||||||
return (page && page.controls[row] && page.controls[row][column]) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apply,
|
apply,
|
||||||
list,
|
list,
|
||||||
locate,
|
locate,
|
||||||
at,
|
page,
|
||||||
get loaded() {
|
get loaded() {
|
||||||
return order.length > 0;
|
return order.length > 0;
|
||||||
},
|
},
|
||||||
@@ -303,4 +303,7 @@ function pageName(raw, number) {
|
|||||||
return `${number} ${name}`;
|
return `${number} ${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createLink, createPageBook, pageName };
|
/** Companion's navigation buttons: page up, page down, the page number. Never shown. */
|
||||||
|
const NAV_TYPES = new Set(['pageup', 'pagedown', 'pagenum']);
|
||||||
|
|
||||||
|
module.exports = { createLink, createPageBook, pageName, NAV_TYPES };
|
||||||
|
|||||||
+12
-6
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "companion-buttons",
|
"id": "companion-buttons",
|
||||||
"name": "Companion Buttons",
|
"name": "Companion Buttons",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"proddash": ">=1.10.0",
|
"proddash": ">=1.10.0",
|
||||||
"description": "Any Companion button from any page, live as Companion draws it — for status and what's happening",
|
"description": "Companion pages and buttons, live as Companion draws them — for status and what's happening",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
"kind": "local-network",
|
"kind": "local-network",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 1, "h": 1 },
|
"minSize": { "w": 1, "h": 1 },
|
||||||
"defaultSize": { "w": 3, "h": 3 },
|
"defaultSize": { "w": 6, "h": 4 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"companion": {
|
"companion": {
|
||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
@@ -24,10 +24,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"captions": {
|
"layout": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "Captions",
|
"label": "Page layout",
|
||||||
"default": false
|
"default": true
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Page",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/page-options"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,40 +3,41 @@
|
|||||||
/**
|
/**
|
||||||
* Companion Buttons — server part.
|
* Companion Buttons — server part.
|
||||||
*
|
*
|
||||||
* Shows any button from Bitfocus Companion, on any page, exactly as Companion
|
* Shows Bitfocus Companion pages on dashboard tiles, every button exactly as
|
||||||
* draws it: colours, text, icons and live feedback included. ProdDash holds
|
* Companion draws it: colours, text, icons and live feedback included.
|
||||||
* one connection to Companion (companion.js) however many dashboards are open;
|
* ProdDash holds one connection to Companion (companion.js) however many
|
||||||
* browsers only ever talk to these routes.
|
* dashboards are open; browsers only ever talk to these routes.
|
||||||
*
|
*
|
||||||
* GET /pages → every page and the buttons on it (the tile's picker)
|
* Each Companion page is an entry in the + Add-tile picker (tiles()), titled
|
||||||
* GET /image/<id> → one button's current picture, as PNG (picker thumbnails)
|
* with the page's name. A tile shows its whole page, or the buttons chosen on
|
||||||
* GET /stream/<b>/<b>/… → SSE: `button` for each of those buttons on connect,
|
* it (the tile's own choosing mode).
|
||||||
* then again whenever one redraws or moves
|
|
||||||
*
|
*
|
||||||
* <id> is Companion's control id without its "bank:" prefix, and each <b> is
|
* GET /page-options → the pages, for a tile added without one
|
||||||
* page.row.column.id — "19.1.4.t4bp4ZhrQblkr3bPSEBbc" — in the characters
|
* GET /stream/<page>[/<r.c>/…]
|
||||||
* ProdDash's page stream carries. A tile holds one stream for all its buttons:
|
* → SSE for one tile: `page` (what to lay out) on
|
||||||
* a page carries at most 32 streams, and a status board can show more buttons
|
* connect and whenever it changes, `button` (one
|
||||||
* than that.
|
* picture) on connect and on every redraw
|
||||||
|
* GET /state/<page>[/<r.c>/…]
|
||||||
|
* → the same as one snapshot, for a tile that joins
|
||||||
|
* a stream already open on its dashboard
|
||||||
*
|
*
|
||||||
* Which button a tile means: the control id while Companion still has it
|
* <page> is Companion's own page id, which stays with the page when pages are
|
||||||
* anywhere, so a button that is dragged elsewhere or a page inserted in front
|
* inserted or moved; <r.c> is a button's row and column on it. Buttons are
|
||||||
* keeps showing the same button; otherwise whatever is at the page, row and
|
* followed by place, so a page re-imported from a .companionconfig — every
|
||||||
* column now, so a page that is re-imported (every button gets a new id) keeps
|
* button gets a new id — keeps showing. A tile holds one stream for all its
|
||||||
* showing the same place. Every frame says which control and location it
|
* buttons: ProdDash carries at most 32 streams per page.
|
||||||
* resolved to, and the tile saves that back, so it stays current.
|
|
||||||
*
|
*
|
||||||
* Read-only by design: nothing here can press a button or change Companion.
|
* Read-only by design: nothing here can press a button or change Companion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { createLink, createPageBook } = require('./companion');
|
const { createLink, createPageBook, NAV_TYPES } = require('./companion');
|
||||||
|
|
||||||
const HEARTBEAT_MS = 15000;
|
const HEARTBEAT_MS = 15000;
|
||||||
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves
|
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves
|
||||||
const FIRST_FRAME_MS = 4000; // how long /image waits for Companion's first picture
|
const DESCRIBE_MS = 3000; // how long to wait for one button's setup
|
||||||
const MAX_PER_STREAM = 64;
|
const MAX_SPOTS = 64;
|
||||||
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
const SPOT_RE = /^(\d{1,4})\.(-?\d{1,3})\.(-?\d{1,3})\.([A-Za-z0-9_-]{1,64})$/;
|
const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/;
|
||||||
|
|
||||||
let current = null;
|
let current = null;
|
||||||
|
|
||||||
@@ -50,37 +51,89 @@ function sendJson(res, code, body) {
|
|||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "/19.1.4.t4bp4Z…/2.0.0.QOPCk…" → [{ key, page, row, column, id }], or null if malformed. */
|
/** "/<page>" → { pageId, keys: null } (the whole page); "/<page>/0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */
|
||||||
function spotsFrom(req) {
|
function viewFrom(req) {
|
||||||
const parts = String(req.wildcard || '').replace(/^\//, '').split('/');
|
const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/');
|
||||||
if (!parts.length || parts.length > MAX_PER_STREAM) return null;
|
if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null;
|
||||||
const spots = [];
|
if (!rest.length) return { pageId, keys: null };
|
||||||
for (const key of new Set(parts)) {
|
if (!rest.every((k) => SPOT_RE.test(k))) return null;
|
||||||
const m = SPOT_RE.exec(key);
|
return { pageId, keys: [...new Set(rest)] };
|
||||||
if (!m) return null;
|
|
||||||
spots.push({ key, page: Number(m[1]), row: Number(m[2]), column: Number(m[3]), id: `bank:${m[4]}` });
|
|
||||||
}
|
|
||||||
return spots;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const { host, port } = endpointOf(config.companion);
|
const { host, port } = endpointOf(config.companion);
|
||||||
const pages = createPageBook();
|
const pages = createPageBook();
|
||||||
const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off }
|
const buttons = new Map(); // controlId → { image, seen, refs, linger, off }
|
||||||
const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] }
|
const known = new Map(); // controlId → { type }
|
||||||
|
const describing = new Map(); // controlId → Promise
|
||||||
|
const viewers = new Set(); // { res, pageId, keys, spots: [{ key, row, column, target }], sig }
|
||||||
let link = null;
|
let link = null;
|
||||||
let heartbeat = null;
|
let heartbeat = null;
|
||||||
let status = host
|
let status = host
|
||||||
? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` }
|
? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` }
|
||||||
: { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" };
|
: { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" };
|
||||||
|
|
||||||
/* — following a button in Companion — */
|
/* — what kind of button each is (read once per button; navigation is never shown) — */
|
||||||
|
|
||||||
|
function describe(controlId) {
|
||||||
|
if (!link) return Promise.resolve(null);
|
||||||
|
if (describing.has(controlId)) return describing.get(controlId);
|
||||||
|
const p = new Promise((resolve) => {
|
||||||
|
let off = null;
|
||||||
|
const finish = (info) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (off) off();
|
||||||
|
off = null;
|
||||||
|
describing.delete(controlId);
|
||||||
|
if (info) known.set(controlId, info);
|
||||||
|
resolve(info);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(null), DESCRIBE_MS);
|
||||||
|
off = link.subscribe(
|
||||||
|
'controls.watchControl',
|
||||||
|
{ controlId },
|
||||||
|
(data) => {
|
||||||
|
if (!data || data.type !== 'init') return;
|
||||||
|
const cfg = data.config || {};
|
||||||
|
finish({ type: String(cfg.type || '') });
|
||||||
|
},
|
||||||
|
() => finish(null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
describing.set(controlId, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Learn every button Companion has that is not known yet, a few at a time. */
|
||||||
|
let learning = false;
|
||||||
|
async function learnAll() {
|
||||||
|
if (learning) return;
|
||||||
|
learning = true;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const todo = [];
|
||||||
|
for (const p of pages.list()) for (const b of p.buttons) if (!known.has(b.controlId)) todo.push(b.controlId);
|
||||||
|
if (!todo.length || !link || status.state !== 'ok') break;
|
||||||
|
for (let i = 0; i < todo.length; i += 16) {
|
||||||
|
await Promise.all(todo.slice(i, i + 16).map(describe));
|
||||||
|
}
|
||||||
|
sendAll(); // nav buttons now known: whole-page tiles drop them
|
||||||
|
if (todo.every((id) => !known.has(id))) break; // Companion is not answering; try again on the next change
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
learning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNav = (controlId) => NAV_TYPES.has(known.get(controlId)?.type);
|
||||||
|
|
||||||
|
/* — following a button's picture — */
|
||||||
|
|
||||||
function watch(controlId) {
|
function watch(controlId) {
|
||||||
let b = buttons.get(controlId);
|
let b = buttons.get(controlId);
|
||||||
if (!b) {
|
if (!b) {
|
||||||
b = { image: null, seen: false, refs: 0, waiters: new Set(), linger: null, off: null };
|
b = { image: null, seen: false, refs: 0, linger: null, off: null };
|
||||||
buttons.set(controlId, b);
|
buttons.set(controlId, b);
|
||||||
if (link) {
|
if (link) {
|
||||||
b.off = link.subscribe(
|
b.off = link.subscribe(
|
||||||
@@ -91,8 +144,6 @@ module.exports = {
|
|||||||
const changed = image !== b.image || !b.seen;
|
const changed = image !== b.image || !b.seen;
|
||||||
b.image = image;
|
b.image = image;
|
||||||
b.seen = true;
|
b.seen = true;
|
||||||
for (const done of b.waiters) done();
|
|
||||||
b.waiters.clear();
|
|
||||||
// Status buttons redraw every second with the same picture; only a real change goes out.
|
// Status buttons redraw every second with the same picture; only a real change goes out.
|
||||||
if (changed) sendButton(controlId);
|
if (changed) sendButton(controlId);
|
||||||
},
|
},
|
||||||
@@ -105,54 +156,63 @@ module.exports = {
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Let go of a button once nothing has looked at it for a while. */
|
|
||||||
function release(controlId) {
|
function release(controlId) {
|
||||||
const b = buttons.get(controlId);
|
const b = buttons.get(controlId);
|
||||||
if (!b || b.refs > 0 || b.waiters.size) return;
|
if (!b || b.refs > 0) return;
|
||||||
clearTimeout(b.linger);
|
clearTimeout(b.linger);
|
||||||
b.linger = setTimeout(() => {
|
b.linger = setTimeout(() => {
|
||||||
if (b.refs > 0 || b.waiters.size) return;
|
if (b.refs > 0) return;
|
||||||
if (b.off) b.off();
|
if (b.off) b.off();
|
||||||
buttons.delete(controlId);
|
buttons.delete(controlId);
|
||||||
}, LINGER_MS);
|
}, LINGER_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The control a tile's spot means right now (see the header), or null. */
|
/* — one tile's view of one page — */
|
||||||
function resolve(spot) {
|
|
||||||
if (!pages.loaded) return spot.id;
|
/** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */
|
||||||
if (pages.locate(spot.id)) return spot.id;
|
function spotsFor(viewer, page) {
|
||||||
return pages.at(spot.page, spot.row, spot.column);
|
if (!page) return [];
|
||||||
|
const at = new Map(page.buttons.map((b) => [`${b.row}.${b.column}`, b]));
|
||||||
|
const keys = viewer.keys || page.buttons.filter((b) => !isNav(b.controlId)).map((b) => `${b.row}.${b.column}`);
|
||||||
|
return keys.map((key) => {
|
||||||
|
const [row, column] = key.split('.').map(Number);
|
||||||
|
return { key, row, column, target: at.get(key)?.controlId || null };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function point(spot) {
|
function pageBody(spots, page) {
|
||||||
const target = resolve(spot);
|
return {
|
||||||
if (target === spot.target) return false;
|
|
||||||
if (spot.target) {
|
|
||||||
const old = buttons.get(spot.target);
|
|
||||||
if (old) old.refs -= 1;
|
|
||||||
release(spot.target);
|
|
||||||
}
|
|
||||||
spot.target = target;
|
|
||||||
if (target) watch(target).refs += 1;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — what a tile is sent — */
|
|
||||||
|
|
||||||
function frame(spot) {
|
|
||||||
const b = spot.target ? buttons.get(spot.target) : null;
|
|
||||||
const location = spot.target ? pages.locate(spot.target) : null;
|
|
||||||
const body = {
|
|
||||||
key: spot.key,
|
|
||||||
controlId: spot.target,
|
|
||||||
status: status.state,
|
status: status.state,
|
||||||
message: status.message,
|
message: status.message,
|
||||||
image: b ? b.image : null,
|
found: Boolean(page) || !pages.loaded,
|
||||||
// A spot with nothing there is as answered as it will get.
|
name: page ? page.name : '',
|
||||||
seen: b ? b.seen : pages.loaded,
|
number: page ? page.number : 0,
|
||||||
location,
|
spots: spots.map(({ key, row, column }) => ({ key, row, column })),
|
||||||
};
|
};
|
||||||
return `event: button\ndata: ${JSON.stringify(body)}\n\n`;
|
}
|
||||||
|
|
||||||
|
function buttonBody(spot) {
|
||||||
|
const b = spot.target ? buttons.get(spot.target) : null;
|
||||||
|
return {
|
||||||
|
key: spot.key,
|
||||||
|
image: b ? b.image : null,
|
||||||
|
// A place with nothing on it is as answered as it will get.
|
||||||
|
seen: b ? b.seen : pages.loaded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageFrame = (viewer, page) => `event: page\ndata: ${JSON.stringify(pageBody(viewer.spots, page))}\n\n`;
|
||||||
|
const buttonFrame = (spot) => `event: button\ndata: ${JSON.stringify(buttonBody(spot))}\n\n`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a stream would send on connect, for a tile that joined a stream
|
||||||
|
* already open on the page (a remount after a settings change): ProdDash
|
||||||
|
* reuses the connection, so the tile asks for this on `open` instead.
|
||||||
|
*/
|
||||||
|
function snapshot(view) {
|
||||||
|
const page = pages.page(view.pageId);
|
||||||
|
const spots = spotsFor({ keys: view.keys }, page);
|
||||||
|
return { page: pageBody(spots, page), buttons: spots.map(buttonBody) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(viewer, chunk) {
|
function write(viewer, chunk) {
|
||||||
@@ -163,59 +223,73 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendButton(controlId) {
|
/** Bring a viewer up to date with the page book; send what changed. */
|
||||||
for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot));
|
function refresh(viewer, force) {
|
||||||
|
const page = pages.page(viewer.pageId);
|
||||||
|
const next = spotsFor(viewer, page);
|
||||||
|
const before = new Map(viewer.spots.map((s) => [s.key, s.target]));
|
||||||
|
for (const s of next) if (s.target) watch(s.target).refs += 1;
|
||||||
|
for (const s of viewer.spots) {
|
||||||
|
if (!s.target) continue;
|
||||||
|
const b = buttons.get(s.target);
|
||||||
|
if (b) b.refs -= 1;
|
||||||
|
release(s.target);
|
||||||
|
}
|
||||||
|
viewer.spots = next;
|
||||||
|
const sig = JSON.stringify([status, page && page.name, page && page.number, next.map((s) => [s.key, s.target])]);
|
||||||
|
if (!force && sig === viewer.sig) return;
|
||||||
|
viewer.sig = sig;
|
||||||
|
write(viewer, pageFrame(viewer, page));
|
||||||
|
for (const s of next) if (force || before.get(s.key) !== s.target || !before.has(s.key)) write(viewer, buttonFrame(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendAll() {
|
function sendAll() {
|
||||||
for (const v of viewers) {
|
for (const v of viewers) refresh(v, false);
|
||||||
for (const spot of v.spots) {
|
|
||||||
point(spot);
|
|
||||||
write(v, frame(spot));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendButton(controlId) {
|
||||||
|
for (const v of viewers) for (const s of v.spots) if (s.target === controlId) write(v, buttonFrame(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
function drop(viewer) {
|
function drop(viewer) {
|
||||||
if (!viewers.delete(viewer)) return;
|
if (!viewers.delete(viewer)) return;
|
||||||
for (const spot of viewer.spots) {
|
for (const s of viewer.spots) {
|
||||||
if (!spot.target) continue;
|
if (!s.target) continue;
|
||||||
const b = buttons.get(spot.target);
|
const b = buttons.get(s.target);
|
||||||
if (b) b.refs -= 1;
|
if (b) b.refs -= 1;
|
||||||
release(spot.target);
|
release(s.target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addViewer(res, spots) {
|
function addViewer(res, view) {
|
||||||
const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) };
|
const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' };
|
||||||
viewers.add(viewer);
|
viewers.add(viewer);
|
||||||
for (const spot of viewer.spots) {
|
refresh(viewer, true);
|
||||||
point(spot);
|
|
||||||
write(viewer, frame(spot));
|
|
||||||
}
|
|
||||||
return viewer;
|
return viewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* — the connection — */
|
||||||
|
|
||||||
if (host) {
|
if (host) {
|
||||||
link = createLink({
|
link = createLink({
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
onStatus(next) {
|
onStatus(next) {
|
||||||
status = next.state === 'ok' && !pages.loaded
|
status = next.state === 'ok' && !pages.loaded ? { state: 'connecting', message: 'Connected — reading pages' } : next;
|
||||||
? { state: 'connecting', message: 'Connected — reading pages' }
|
|
||||||
: next;
|
|
||||||
if (next.state !== 'ok') log(next.message);
|
if (next.state !== 'ok') log(next.message);
|
||||||
sendAll();
|
sendAll();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
link.subscribe('pages.watch', undefined, (change) => {
|
link.subscribe('pages.watch', undefined, (change) => {
|
||||||
|
const fresh = change && change.type === 'init';
|
||||||
pages.apply(change);
|
pages.apply(change);
|
||||||
if (change && change.type === 'init') {
|
if (fresh) {
|
||||||
status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` };
|
status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` };
|
||||||
log(`connected to Companion at ${host}:${port} (${pages.count} pages)`);
|
log(`connected to Companion at ${host}:${port} (${pages.count} pages)`);
|
||||||
}
|
}
|
||||||
// A page renamed, a button moved or a page re-imported changes what tiles show.
|
// A page renamed, a button added or moved, or a page re-imported changes what tiles show.
|
||||||
sendAll();
|
sendAll();
|
||||||
|
learnAll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,16 +297,31 @@ module.exports = {
|
|||||||
for (const v of viewers) write(v, ': ping\n\n');
|
for (const v of viewers) write(v, ': ping\n\n');
|
||||||
}, HEARTBEAT_MS);
|
}, HEARTBEAT_MS);
|
||||||
|
|
||||||
|
/** The + picker's entries: one per page that has a button worth showing. */
|
||||||
|
function tileList() {
|
||||||
|
return pages
|
||||||
|
.list()
|
||||||
|
.filter((p) => p.buttons.some((b) => !isNav(b.controlId)))
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: `Companion page ${p.number}`,
|
||||||
|
settings: { page: p.id },
|
||||||
|
instanceSchema: {
|
||||||
|
layout: { type: 'switch', label: 'Page layout', default: true },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
current = {
|
current = {
|
||||||
get status() {
|
get status() {
|
||||||
return status;
|
return status;
|
||||||
},
|
},
|
||||||
pages,
|
pages,
|
||||||
watch,
|
|
||||||
release,
|
|
||||||
addViewer,
|
addViewer,
|
||||||
drop,
|
drop,
|
||||||
linked: Boolean(link),
|
snapshot,
|
||||||
|
tileList,
|
||||||
};
|
};
|
||||||
const self = current;
|
const self = current;
|
||||||
|
|
||||||
@@ -247,72 +336,41 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewers.clear();
|
viewers.clear();
|
||||||
for (const b of buttons.values()) {
|
for (const b of buttons.values()) clearTimeout(b.linger);
|
||||||
clearTimeout(b.linger);
|
|
||||||
for (const done of b.waiters) done();
|
|
||||||
}
|
|
||||||
buttons.clear();
|
buttons.clear();
|
||||||
if (link) link.close();
|
if (link) link.close();
|
||||||
if (current === self) current = null;
|
if (current === self) current = null;
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
const shown = new Set();
|
const shown = new Set();
|
||||||
for (const v of viewers) for (const spot of v.spots) if (spot.target) shown.add(spot.target);
|
for (const v of viewers) for (const s of v.spots) if (s.target) shown.add(s.target);
|
||||||
const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : '';
|
const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : '';
|
||||||
return { status: status.state, message: status.message + suffix };
|
return { status: status.state, message: status.message + suffix };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
tiles() {
|
||||||
return {
|
return current ? current.tileList() : [];
|
||||||
'GET /pages': (req, res) => {
|
|
||||||
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
|
|
||||||
const { state, message } = current.status;
|
|
||||||
return sendJson(res, 200, { status: state, message, pages: current.pages.list() });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /image/*': (req, res) => {
|
routes() {
|
||||||
const raw = String(req.wildcard || '').replace(/^\//, '');
|
return {
|
||||||
if (!ID_RE.test(raw)) return sendJson(res, 400, { error: 'Not a Companion button id' });
|
'GET /page-options': (req, res) => {
|
||||||
if (!current || !current.linked) return sendJson(res, 503, { error: current ? current.status.message : 'Not running' });
|
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
|
||||||
const id = `bank:${raw}`;
|
return sendJson(res, 200, { options: current.pages.list().map((p) => ({ value: p.id, label: p.name })) });
|
||||||
const app = current;
|
},
|
||||||
const b = app.watch(id);
|
|
||||||
const reply = () => {
|
'GET /state/*': (req, res) => {
|
||||||
if (!b.image) {
|
const view = viewFrom(req);
|
||||||
res.writeHead(b.seen ? 404 : 504, { 'Cache-Control': 'no-store' });
|
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
|
||||||
res.end();
|
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
|
||||||
} else {
|
return sendJson(res, 200, current.snapshot(view));
|
||||||
const png = Buffer.from(b.image.slice(b.image.indexOf(',') + 1), 'base64');
|
|
||||||
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store', 'Content-Length': png.length });
|
|
||||||
res.end(png);
|
|
||||||
}
|
|
||||||
app.release(id);
|
|
||||||
};
|
|
||||||
if (b.seen) return reply();
|
|
||||||
let done = false;
|
|
||||||
const finish = () => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
b.waiters.delete(finish);
|
|
||||||
reply();
|
|
||||||
};
|
|
||||||
const timer = setTimeout(finish, FIRST_FRAME_MS);
|
|
||||||
b.waiters.add(finish);
|
|
||||||
req.on('close', () => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
b.waiters.delete(finish);
|
|
||||||
app.release(id);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /stream/*': (req, res) => {
|
'GET /stream/*': (req, res) => {
|
||||||
const spots = spotsFrom(req);
|
const view = viewFrom(req);
|
||||||
if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' });
|
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
@@ -321,14 +379,12 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
res.write('retry: 3000\n\n');
|
res.write('retry: 3000\n\n');
|
||||||
if (!current) {
|
if (!current) {
|
||||||
for (const s of spots) {
|
const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, spots: [] };
|
||||||
const body = { key: s.key, controlId: null, status: 'error', message: 'Companion Buttons is not running', image: null, seen: false, location: null };
|
res.write(`event: page\ndata: ${JSON.stringify(body)}\n\n`);
|
||||||
res.write(`event: button\ndata: ${JSON.stringify(body)}\n\n`);
|
|
||||||
}
|
|
||||||
return void res.end();
|
return void res.end();
|
||||||
}
|
}
|
||||||
const app = current;
|
const app = current;
|
||||||
const viewer = app.addViewer(res, spots);
|
const viewer = app.addViewer(res, view);
|
||||||
try {
|
try {
|
||||||
req.socket.setKeepAlive(true, HEARTBEAT_MS);
|
req.socket.setKeepAlive(true, HEARTBEAT_MS);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -11,25 +11,18 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── the chosen buttons ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-grid {
|
[data-module="companion-buttons"] .cb-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--cb-cols, 1), var(--cb-size, 64px));
|
grid-template-columns: repeat(var(--cb-cols, 1), var(--cb-size, 64px));
|
||||||
grid-auto-rows: max-content;
|
grid-auto-rows: var(--cb-size, 64px);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-cell {
|
[data-module="companion-buttons"] .cb-grid[hidden] { display: none; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-face {
|
[data-module="companion-buttons"] .cb-face {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -57,45 +50,15 @@
|
|||||||
to { opacity: 0.8; }
|
to { opacity: 0.8; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-face.is-missing {
|
/* A ticked place with no button on it any more. */
|
||||||
|
[data-module="companion-buttons"] .cb-face.is-empty {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
outline: 1px dashed var(--border);
|
outline: 1px dashed var(--border);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-note {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8%;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: clamp(0.6rem, calc(var(--cb-size, 64px) * 0.12), 0.9rem);
|
|
||||||
line-height: 1.15;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-note:empty { display: none; }
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-caption {
|
|
||||||
display: none;
|
|
||||||
width: var(--cb-size, 64px);
|
|
||||||
height: 18px;
|
|
||||||
padding-top: 3px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 15px;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-grid.has-captions .cb-caption { display: block; }
|
|
||||||
|
|
||||||
/* Companion gone: the last pictures stay, faded, so nobody reads them as live. */
|
/* Companion gone: the last pictures stay, faded, so nobody reads them as live. */
|
||||||
[data-module="companion-buttons"] .cb-grid.is-offline .cb-face {
|
[data-module="companion-buttons"] .cb-root.is-offline .cb-face {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
@@ -116,139 +79,29 @@
|
|||||||
|
|
||||||
[data-module="companion-buttons"] .cb-banner[hidden] { display: none; }
|
[data-module="companion-buttons"] .cb-banner[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── nothing chosen ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-empty {
|
[data-module="companion-buttons"] .cb-empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-empty-line {
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-action,
|
|
||||||
[data-module="companion-buttons"] .cb-pick-btn {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-action,
|
|
||||||
[data-module="companion-buttons"] .cb-pick-done {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── the picker ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-picker {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-head {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-page {
|
|
||||||
flex: 1 1 8em;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-note {
|
|
||||||
padding: 16px 8px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-grid {
|
[data-module="companion-buttons"] .cb-empty[hidden] { display: none; }
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(var(--cb-pick-cols, 8), minmax(36px, 1fr));
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-key {
|
/* Choosing: the whole page is shown and clickable; buttons this tile leaves out are faded. */
|
||||||
position: relative;
|
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face {
|
||||||
aspect-ratio: 1;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-img {
|
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face:hover {
|
||||||
display: block;
|
outline: 2px solid var(--accent);
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-img.is-broken { visibility: hidden; }
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-key:hover {
|
|
||||||
outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent);
|
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-key.is-picked {
|
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face.is-off {
|
||||||
outline: 3px solid var(--accent);
|
opacity: 0.25;
|
||||||
outline-offset: -3px;
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
right: 3px;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="companion-buttons"] .cb-pick-badge:empty { display: none; }
|
|
||||||
|
|||||||
+28
-5
@@ -8,9 +8,10 @@
|
|||||||
*
|
*
|
||||||
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
|
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
|
||||||
*
|
*
|
||||||
* It serves the two subscriptions the module uses — pages.watch and
|
* It serves the three subscriptions the module uses — pages.watch,
|
||||||
* preview.graphics.controlId — over a hand-rolled websocket (no deps), with
|
* preview.graphics.controlId and controls.watchControl — over a hand-rolled
|
||||||
* three pages. Page 1's first button flips between green and red every two
|
* websocket (no deps), with three pages: a status page with a page-up
|
||||||
|
* button, a camera page, and an unnamed page holding only navigation. Page 1's first button flips between green and red every two
|
||||||
* seconds, like a status button; page 1's second button redraws the same
|
* seconds, like a status button; page 1's second button redraws the same
|
||||||
* picture every 300 ms, like a button fed a variable that has not changed.
|
* picture every 300 ms, like a button fed a variable that has not changed.
|
||||||
* Every 30 s it sends PING and drops a client that does not answer PONG,
|
* Every 30 s it sends PING and drops a client that does not answer PONG,
|
||||||
@@ -123,9 +124,22 @@ function readFrames(state, data, onText, onClose) {
|
|||||||
|
|
||||||
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
|
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
|
||||||
const pages = {
|
const pages = {
|
||||||
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' } } },
|
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' }, 1: { 0: 'bank:up1' } } },
|
||||||
p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
|
p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
|
||||||
p3: { id: 'p3', name: 'PAGE', controls: {} },
|
p3: { id: 'p3', name: 'PAGE', controls: { 0: { 0: 'bank:up3' } } },
|
||||||
|
};
|
||||||
|
const layered = (value, isExpression = false) => ({
|
||||||
|
type: 'button-layered',
|
||||||
|
style: { layers: [{ id: 'canvas', type: 'canvas' }, { id: 'text0', type: 'text', text: { value, isExpression } }] },
|
||||||
|
});
|
||||||
|
const configs = {
|
||||||
|
'bank:flip': layered('FLIP'),
|
||||||
|
'bank:steady': layered('Steady'),
|
||||||
|
'bank:blue': layered('Blue'),
|
||||||
|
'bank:cam1': { type: 'button', style: { text: 'CAM 1' } },
|
||||||
|
'bank:cam2': layered('CAM 2'),
|
||||||
|
'bank:up1': { type: 'pageup', style: {} },
|
||||||
|
'bank:up3': { type: 'pageup', style: {} },
|
||||||
};
|
};
|
||||||
const order = ['p1', 'p2', 'p3'];
|
const order = ['p1', 'p2', 'p3'];
|
||||||
const looks = {
|
const looks = {
|
||||||
@@ -184,6 +198,14 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
|
|||||||
}
|
}
|
||||||
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
|
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
|
||||||
const { path, input } = msg.params || {};
|
const { path, input } = msg.params || {};
|
||||||
|
if (path === 'controls.watchControl') {
|
||||||
|
const cfg = configs[input && input.controlId];
|
||||||
|
if (!cfg) return send(client, { id: msg.id, error: { message: `Control ${input && input.controlId} not found` } });
|
||||||
|
stats.subscriptions += 1;
|
||||||
|
client.subs.set(msg.id, { path, input });
|
||||||
|
send(client, { id: msg.id, result: { type: 'started' } });
|
||||||
|
return send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', config: cfg, runtime: {} } } });
|
||||||
|
}
|
||||||
if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
|
if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
|
||||||
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
|
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
|
||||||
}
|
}
|
||||||
@@ -276,6 +298,7 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
|
|||||||
redraw,
|
redraw,
|
||||||
looks,
|
looks,
|
||||||
pages,
|
pages,
|
||||||
|
configs,
|
||||||
/** Drop every client without a close frame, as a crash or a cable pull would. */
|
/** Drop every client without a close frame, as a crash or a cable pull would. */
|
||||||
dropAll() {
|
dropAll() {
|
||||||
for (const c of clients) c.socket.destroy();
|
for (const c of clients) c.socket.destroy();
|
||||||
|
|||||||
+92
-84
@@ -63,9 +63,10 @@ function get(port, path) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An SSE client that collects `button` events. */
|
/** An SSE client that collects `page` and `button` events. */
|
||||||
function listen(port, path) {
|
function listen(port, path) {
|
||||||
const events = [];
|
const pages = [];
|
||||||
|
const buttons = [];
|
||||||
let req;
|
let req;
|
||||||
const ready = new Promise((resolve) => {
|
const ready = new Promise((resolve) => {
|
||||||
req = http.get({ host: '127.0.0.1', port, path }, (res) => {
|
req = http.get({ host: '127.0.0.1', port, path }, (res) => {
|
||||||
@@ -78,13 +79,23 @@ function listen(port, path) {
|
|||||||
buf = buf.slice(at + 2);
|
buf = buf.slice(at + 2);
|
||||||
const name = /^event: (.+)$/m.exec(block);
|
const name = /^event: (.+)$/m.exec(block);
|
||||||
const data = /^data: (.+)$/m.exec(block);
|
const data = /^data: (.+)$/m.exec(block);
|
||||||
if (name && data && name[1] === 'button') events.push(JSON.parse(data[1]));
|
if (!name || !data) continue;
|
||||||
|
if (name[1] === 'page') pages.push(JSON.parse(data[1]));
|
||||||
|
if (name[1] === 'button') buttons.push(JSON.parse(data[1]));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] };
|
return {
|
||||||
|
pages,
|
||||||
|
buttons,
|
||||||
|
ready,
|
||||||
|
close: () => req.destroy(),
|
||||||
|
page: () => pages[pages.length - 1],
|
||||||
|
/** The latest picture for one row.column, or undefined. */
|
||||||
|
pic: (key) => [...buttons].reverse().find((b) => b.key === key),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── pure parts ────────────────────────────────────────────────────────── */
|
/* ── pure parts ────────────────────────────────────────────────────────── */
|
||||||
@@ -135,6 +146,8 @@ function testPageBook() {
|
|||||||
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
|
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
|
||||||
assert.strictEqual(book.locate('bank:x'), null);
|
assert.strictEqual(book.locate('bank:x'), null);
|
||||||
assert.strictEqual(book.count, 2);
|
assert.strictEqual(book.count, 2);
|
||||||
|
assert.strictEqual(book.page('b').number, 2);
|
||||||
|
assert.strictEqual(book.page('a'), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── against the mock ─────────────────────────────────────────────────── */
|
/* ── against the mock ─────────────────────────────────────────────────── */
|
||||||
@@ -152,92 +165,91 @@ async function testLive() {
|
|||||||
await until('Companion connected', () => handle.health().status === 'ok');
|
await until('Companion connected', () => handle.health().status === 'ok');
|
||||||
assert.match(handle.health().message, /3 pages/);
|
assert.match(handle.health().message, /3 pages/);
|
||||||
|
|
||||||
// pages for the picker
|
// the + picker: one entry per page with something to show — not the page that is only navigation
|
||||||
const pages = JSON.parse((await get(at, '/pages')).body);
|
await until('nav buttons learned', () => serverEntry.tiles().length === 2);
|
||||||
assert.strictEqual(pages.status, 'ok');
|
const tiles = serverEntry.tiles();
|
||||||
assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']);
|
assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]);
|
||||||
assert.deepStrictEqual(pages.pages[1].buttons, [
|
assert.deepStrictEqual(tiles[0].settings, { page: 'p1' });
|
||||||
{ row: 0, column: 0, controlId: 'bank:cam1' },
|
assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']);
|
||||||
{ row: 1, column: 3, controlId: 'bank:cam2' },
|
|
||||||
|
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
|
||||||
|
assert.deepStrictEqual(pageOptions.map((o) => o.label), ['1 Status', '2 Cameras', 'Page 3']);
|
||||||
|
|
||||||
|
// a whole page: every button but the page-up one, laid out where Companion has them
|
||||||
|
const whole = listen(at, '/stream/p1');
|
||||||
|
await whole.ready;
|
||||||
|
await until('whole page', () => whole.page() && whole.page().status === 'ok' && whole.page().spots.length === 3);
|
||||||
|
assert.deepStrictEqual(whole.page().spots, [
|
||||||
|
{ key: '0.0', row: 0, column: 0 },
|
||||||
|
{ key: '0.1', row: 0, column: 1 },
|
||||||
|
{ key: '0.2', row: 0, column: 2 },
|
||||||
]);
|
]);
|
||||||
|
assert.strictEqual(whole.page().name, '1 Status');
|
||||||
|
await until('three pictures', () => ['0.0', '0.1', '0.2'].every((k) => whole.pic(k) && whole.pic(k).image));
|
||||||
|
|
||||||
// thumbnails
|
// the snapshot a remounted tile asks for matches what the stream sent
|
||||||
const img = await get(at, '/image/blue');
|
const snap = JSON.parse((await get(at, '/state/p1')).body);
|
||||||
assert.strictEqual(img.status, 200);
|
assert.deepStrictEqual(snap.page.spots, whole.page().spots);
|
||||||
assert.strictEqual(img.type, 'image/png');
|
assert.deepStrictEqual(snap.buttons.map((b) => [b.key, Boolean(b.image), b.seen]), [['0.0', true, true], ['0.1', true, true], ['0.2', true, true]]);
|
||||||
assert.deepStrictEqual([...img.body.subarray(1, 4)], [0x50, 0x4e, 0x47]);
|
assert.strictEqual((await get(at, '/state/p1/x')).status, 400);
|
||||||
assert.strictEqual((await get(at, '/image/gone')).status, 404);
|
|
||||||
assert.strictEqual((await get(at, '/image/..%2Fetc')).status, 400);
|
|
||||||
assert.strictEqual((await get(at, `/image/${'x'.repeat(200)}`)).status, 400);
|
|
||||||
|
|
||||||
// a button that redraws the same picture every 300 ms sends one event, not five
|
// a button that redraws the same picture every 300 ms is sent once, not five times
|
||||||
const steady = listen(at, '/stream/1.0.1.steady');
|
const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length;
|
||||||
await steady.ready;
|
|
||||||
await until('steady first frame', () => steady.last() && steady.last().image);
|
|
||||||
const settled = steady.events.length;
|
|
||||||
await sleep(1600);
|
await sleep(1600);
|
||||||
assert.strictEqual(steady.events.length, settled, 'identical redraws must not be re-sent');
|
assert.strictEqual(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, 'identical redraws must not be re-sent');
|
||||||
assert.strictEqual(steady.last().key, '1.0.1.steady');
|
|
||||||
assert.strictEqual(steady.last().controlId, 'bank:steady');
|
|
||||||
assert.deepStrictEqual(steady.last().location, { page: 1, row: 0, column: 1, pageName: '1 Status' });
|
|
||||||
|
|
||||||
// a status button that changes is forwarded
|
// a status button that changes is forwarded
|
||||||
const flip = listen(at, '/stream/1.0.0.flip');
|
const firstLook = whole.pic('0.0').image;
|
||||||
await flip.ready;
|
await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000);
|
||||||
await until('flip first frame', () => flip.last() && flip.last().image);
|
|
||||||
const firstLook = flip.last().image;
|
|
||||||
await until('flip redraw', () => flip.last().image !== firstLook, 4000);
|
|
||||||
|
|
||||||
// one stream carries all of a tile's buttons: each is sent on connect
|
// chosen buttons only, in the order chosen
|
||||||
const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue');
|
const some = listen(at, '/stream/p2/1.3/0.0');
|
||||||
await pair.ready;
|
await some.ready;
|
||||||
await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2);
|
await until('two ticked', () => some.page() && some.page().spots.length === 2 && some.pic('1.3') && some.pic('1.3').image && some.pic('0.0'));
|
||||||
assert.deepStrictEqual([...new Set(pair.events.map((e) => e.key))].sort(), ['1.0.2.blue', '2.1.3.cam2']);
|
assert.deepStrictEqual(some.page().spots.map((s) => s.key), ['1.3', '0.0']);
|
||||||
pair.close();
|
|
||||||
assert.strictEqual((await get(at, `/stream/${Array.from({ length: 65 }, (_, i) => `1.0.${i}.b${i}`).join('/')}`)).status, 400);
|
|
||||||
assert.strictEqual((await get(at, '/stream/flip')).status, 400);
|
|
||||||
assert.strictEqual((await get(at, '/stream/1.0.0.bank:flip')).status, 400);
|
|
||||||
|
|
||||||
// a button that is gone, with nothing at its place either, says so instead of hanging
|
// a chosen place with nothing on it is answered, not left waiting
|
||||||
const gone = listen(at, '/stream/3.0.0.gone');
|
const hole = listen(at, '/stream/p2/3.3');
|
||||||
await gone.ready;
|
await hole.ready;
|
||||||
await until('missing button answered', () => gone.last() && gone.last().seen);
|
await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen);
|
||||||
assert.strictEqual(gone.last().image, null);
|
assert.strictEqual(hole.pic('3.3').image, null);
|
||||||
assert.strictEqual(gone.last().controlId, null);
|
hole.close();
|
||||||
|
|
||||||
// an unknown id falls back to what is at its page/row/column
|
// a page that does not exist says so
|
||||||
const byPlace = listen(at, '/stream/2.0.0.oldid');
|
const missing = listen(at, '/stream/nope');
|
||||||
await byPlace.ready;
|
await missing.ready;
|
||||||
await until('found by place', () => byPlace.last() && byPlace.last().image);
|
await until('missing page', () => missing.page());
|
||||||
assert.strictEqual(byPlace.last().controlId, 'bank:cam1');
|
assert.strictEqual(missing.page().found, false);
|
||||||
byPlace.close();
|
missing.close();
|
||||||
|
|
||||||
// the button moves to another page in Companion: the tile follows it
|
// bad paths
|
||||||
|
assert.strictEqual((await get(at, '/stream/p1/abc')).status, 400);
|
||||||
|
assert.strictEqual((await get(at, '/stream/p:1')).status, 400);
|
||||||
|
assert.strictEqual((await get(at, `/stream/p1/${Array.from({ length: 65 }, (_, i) => `0.${i}`).join('/')}`)).status, 400);
|
||||||
|
|
||||||
|
// the page is re-imported: every button gets a new id, the tile keeps showing the same places
|
||||||
|
mock.looks['bank:cam2-new'] = mock.looks['bank:cam2'];
|
||||||
|
mock.configs['bank:cam2-new'] = mock.configs['bank:cam2'];
|
||||||
mock.pageUpdate({
|
mock.pageUpdate({
|
||||||
type: 'update',
|
type: 'update',
|
||||||
updatedOrder: null,
|
updatedOrder: null,
|
||||||
added: [],
|
added: [],
|
||||||
changes: [
|
changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }],
|
||||||
{ id: 'p1', name: null, controls: [{ row: 0, column: 1, controlId: null }] },
|
|
||||||
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:steady' }] },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
await until('moved location', () => steady.last().location && steady.last().location.page === 2);
|
await until('renamed', () => some.page().name === '2 Cameras Renamed');
|
||||||
assert.strictEqual(steady.last().controlId, 'bank:steady');
|
await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image);
|
||||||
|
|
||||||
// the page is re-imported: every button gets a new id, the tile keeps its place
|
// a page inserted in front: the tile follows its page, now page 3
|
||||||
mock.looks['bank:blue-new'] = mock.looks['bank:blue'];
|
mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] });
|
||||||
const blue = listen(at, '/stream/1.0.2.blue');
|
await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed');
|
||||||
await blue.ready;
|
some.close();
|
||||||
await until('blue', () => blue.last() && blue.last().image);
|
|
||||||
mock.pageUpdate({
|
// a button added to a whole-page tile appears in it
|
||||||
type: 'update',
|
mock.looks['bank:new'] = mock.looks['bank:blue'];
|
||||||
updatedOrder: null,
|
mock.configs['bank:new'] = mock.configs['bank:blue'];
|
||||||
added: [],
|
mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank:new' }] }] });
|
||||||
changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-new' }] }],
|
await until('new button shown', () => whole.page().spots.some((s) => s.key === '2.2') && whole.pic('2.2') && whole.pic('2.2').image);
|
||||||
});
|
const flip = { last: () => ({ status: whole.page().status, image: whole.pic('0.0') && whole.pic('0.0').image }) };
|
||||||
await until('re-imported button found by place', () => blue.last().controlId === 'bank:blue-new' && blue.last().image);
|
|
||||||
blue.close();
|
|
||||||
|
|
||||||
// Companion drops: streams say so, then recover by themselves
|
// Companion drops: streams say so, then recover by themselves
|
||||||
mock.dropAll();
|
mock.dropAll();
|
||||||
@@ -254,9 +266,7 @@ async function testLive() {
|
|||||||
await until('stream live after restart', () => flip.last().status === 'ok' && flip.last().image, 4000);
|
await until('stream live after restart', () => flip.last().status === 'ok' && flip.last().image, 4000);
|
||||||
|
|
||||||
// closing a viewer keeps the subscription a little while (remounts are cheap), stop() ends all
|
// closing a viewer keeps the subscription a little while (remounts are cheap), stop() ends all
|
||||||
steady.close();
|
whole.close();
|
||||||
flip.close();
|
|
||||||
gone.close();
|
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
|
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -275,14 +285,12 @@ async function testUnconfigured() {
|
|||||||
try {
|
try {
|
||||||
assert.strictEqual(handle.health().status, 'error');
|
assert.strictEqual(handle.health().status, 'error');
|
||||||
assert.match(handle.health().message, /admin/);
|
assert.match(handle.health().message, /admin/);
|
||||||
const pages = JSON.parse((await get(at, '/pages')).body);
|
assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []);
|
||||||
assert.strictEqual(pages.status, 'error');
|
assert.deepStrictEqual(serverEntry.tiles(), []);
|
||||||
assert.deepStrictEqual(pages.pages, []);
|
const s = listen(at, '/stream/p1');
|
||||||
assert.strictEqual((await get(at, '/image/x')).status, 503);
|
|
||||||
const s = listen(at, '/stream/1.0.0.x');
|
|
||||||
await s.ready;
|
await s.ready;
|
||||||
await until('stream answers', () => s.last());
|
await until('stream answers', () => s.page());
|
||||||
assert.strictEqual(s.last().status, 'error');
|
assert.strictEqual(s.page().status, 'error');
|
||||||
s.close();
|
s.close();
|
||||||
} finally {
|
} finally {
|
||||||
handle.stop();
|
handle.stop();
|
||||||
|
|||||||
Reference in New Issue
Block a user