From a3097481e7ca3ee4029758113513eacf87ad1892 Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 15 Oct 2026 12:09:31 -0400 Subject: [PATCH] Companion Buttons 1.2.0: back to picking any buttons from any pages, without the header row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.1.0 made each tile one page, so buttons picked on a second page never showed, choosing ended on every settings change, and a tile could go blank after a remount. This goes back to 1.0's model and keeps only the changes asked for: - Pick buttons from any pages; each gets a ring and its order number. - No Page / Clear / Done row: Page is in the gear, the grid icon starts and ends choosing, a second click on a button takes it out. - Choosing survives the remount a gear change causes, so switching Page carries straight on. - A tile joining a stream its dashboard already has open (another tile with the same buttons, or itself after a settings change) asks GET /state for the snapshot instead of waiting blank. - Gear: Captions and Page. The per-page + entries are gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 76 +++--- client.js | 564 +++++++++++++++++++++++++---------------- companion.js | 19 +- module.json | 12 +- server.js | 360 ++++++++++++-------------- style.css | 114 ++++++++- test/companion-mock.js | 33 +-- test/companion.test.js | 199 ++++++++------- 8 files changed, 772 insertions(+), 605 deletions(-) diff --git a/README.md b/README.md index ccc2b85..78f12ab 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # Companion Buttons A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module -that shows Bitfocus Companion pages on dashboard tiles — a whole page, or just -the buttons you pick from it — every button exactly as Companion draws it: -colours, text, icons and live feedback. It is meant for status buttons and -anything else that says what is happening right now. +that shows any Companion buttons — from any pages — on a dashboard tile, +exactly as Companion draws them: colours, text, icons and live feedback. It is +meant for status buttons and anything else that says what is happening right +now. +- **One tile, one or many buttons, from any pages.** 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 tally lighting up), the tile shows it. Identical redraws — a status page that repaints every second with the same picture — are not re-sent. @@ -18,27 +21,29 @@ anything else that says what is happening right now. 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 machine as ProdDash. -2. **+ Add tile:** every Companion page is listed under **Companion - Buttons**. Pick one — "19 Status", say — and the tile shows that page, - titled with its name. Page up/down buttons are left out. -3. **To show only some buttons,** click the grid icon in the tile's title bar. - The whole page appears; click a button to take it out (it fades) or put it - 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. +2. **+ Add tile → Companion Buttons.** The new tile opens on a Companion page, + laid out as it is in Companion. Click the buttons to show: each gets a ring + and a number, the order it will appear in. Click again to take it out. +3. **Another page:** open the gear and change **Page**. The tile stays in + choosing and shows that page; what you already picked is kept. +4. **Done:** click the grid icon in the title bar. The tile shows your + buttons. Click the icon again any time to add or remove some. -Hover a button for its page and Companion location (`page/row/column`). +The gear has two settings: **Captions** (the page name under each button) and +**Page** (which page choosing shows). Hover a button for its page and Companion +location (`page/row/column`). ### What a tile follows -A tile follows its page by Companion's own page id, so inserting or moving -pages changes nothing. Buttons are followed by their row and column on that -page, so a page re-imported from a `.companionconfig` (every button gets a new -internal id) keeps showing. A chosen place that is emptied in Companion shows -a dashed outline. A page deleted in Companion leaves the tile empty; the -dashboard folds it away. +Each picked button is saved with its Companion id **and** its page, row and +column. While Companion still has that button anywhere, the tile follows it, +so dragging it to another spot or inserting a page in front changes nothing. +If the id is gone — a page re-imported from a `.companionconfig` gives every +button a new id — the tile shows whatever is at the saved page, row and column +instead. Either way the tile saves back what it found, so it stays current. + +A button that is gone with nothing at its place shows **Not in Companion**, +and the status dot turns amber. ### When Companion is down @@ -51,23 +56,22 @@ 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 own web buttons and emulator get them from the tRPC websocket on the web interface port (`ws://:/trpc`). The module's server holds one such -connection for all dashboards and uses three read-only subscriptions: -`pages.watch` (pages, names, which button is where), -`preview.graphics.controlId` (a button's PNG, on every redraw) and -`controls.watchControl` (read once per button, to leave out page up/down). -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. +connection for all dashboards and uses two read-only subscriptions: +`pages.watch` (pages, names, which button is where) and +`preview.graphics.controlId` (a button's PNG, on every redraw). 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 | | --- | --- | -| `GET /stream/[//…]` | SSE for one tile: `page` (what to lay out) and `button` (one picture) on connect, then on every change | -| `GET /state/[//…]` | The same as one snapshot, for a tile that joins a stream the dashboard already has open | -| `GET /page-options` | The pages, for a tile added without one (the gear's Page) | +| `GET /stream//…` | SSE `button` events for a tile's buttons: all of them on connect, then each redraw or move | +| `GET /state//…` | The same as one snapshot — for a tile that joins a stream its dashboard already has open, as after a settings change | +| `GET /pages` | Every page and the buttons on it — choosing | +| `GET /image/` | One button's current picture as PNG — choosing | +| `GET /page-options` | The pages — the gear's Page | -The + entries come from the server's `tiles()`, one per page. A tile holds -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. +A tile holds 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 @@ -82,9 +86,9 @@ per page and a status board can show more buttons than that. | File | | | --- | --- | | `module.json` | Manifest | -| `server.js` | + entries, routes, button subscriptions, the per-tile streams | +| `server.js` | Routes, button subscriptions, the per-tile streams | | `companion.js` | The connection to Companion's tRPC websocket and the page list | -| `client.js`, `style.css` | The tile and its choosing mode | +| `client.js`, `style.css` | The tile and its choosing | | `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` | diff --git a/client.js b/client.js index 3184763..af2d345 100644 --- a/client.js +++ b/client.js @@ -1,24 +1,42 @@ /* Companion Buttons — the tile. - Shows one Companion page, or the buttons of it chosen for this tile, every - button as Companion draws it and as large as the tile allows. Every + Shows the Companion buttons this tile was given, from any pages, as + Companion draws them and as large as the tile allows. One button fills the + tile; several share it in whatever grid makes each one biggest. Every picture is live: the server forwards each redraw (a status button turning red, a tally lighting up). - The page comes from the + picker entry the tile was added as (or, for a - tile added without one, the gear's Page). The grid icon in the title bar - switches the tile to choosing: the whole page is shown, the buttons this - tile leaves out faded, and a click puts a button in or takes it out. The - icon again goes back. "Page layout" in the gear keeps Companion's own rows - and columns; off, the buttons are packed into whatever grid makes each - one biggest. */ + Choosing: the grid icon in the title bar turns the tile into the Companion + page named by the gear's Page, laid out as it is in Companion. A click on + a button adds it to the tile (it gets a numbered ring, the order it will + show in) or takes it out. Change the gear's Page to pick from another page + — what is already chosen stays. The icon again shows the chosen buttons. + + Each choice is kept as the button's id and its page/row/column; the + server follows whichever still holds, and the tile saves back what it + resolved to, so a moved button or a re-imported page keeps showing. */ const GRID_SVG = ''; const GAP_PX = 6; +const CAPTION_PX = 18; +const PAGES_RETRY_MS = 5000; +const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/; const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/; -const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/; -const MAX_SPOTS = 64; +const MAX_BUTTONS = 64; + +/** "bank:QOPCkIuUp5U9" → "QOPCkIuUp5U9": what the server's URLs carry. */ +const urlId = (id) => id.slice(5); + +/** A chosen button as the server's stream path names it: page.row.column.id */ +const spotKey = (s) => `${s.page}.${s.row}.${s.column}.${urlId(s.id)}`; + +function validSpot(s) { + return Boolean(s) && typeof s === 'object' && ID_RE.test(String(s.id)) + && Number.isInteger(s.page) && s.page >= 1 && s.page <= 9999 + && Number.isInteger(s.row) && Math.abs(s.row) <= 999 + && Number.isInteger(s.column) && Math.abs(s.column) <= 999; +} function el(tag, className, text) { const node = document.createElement(tag); @@ -28,11 +46,13 @@ function el(tag, className, text) { } /** The column count that makes `n` square cells biggest inside w × h. */ -function packedGrid(n, w, h) { +function bestGrid(n, w, h, captionPx) { let best = { cols: 1, size: 0 }; for (let cols = 1; cols <= n; cols += 1) { const rows = Math.ceil(n / cols); - const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows)); + const byWidth = (w - GAP_PX * (cols - 1)) / cols; + const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx; + const size = Math.floor(Math.min(byWidth, byHeight)); if (size > best.size) best = { cols, size }; } return best; @@ -40,229 +60,88 @@ function packedGrid(n, w, h) { export default function create({ root, moduleApi }) { const prefs = () => moduleApi.instanceSettings || {}; - const pageId = () => { - const p = String(prefs().page || ''); - return PAGE_RE.test(p) ? p : ''; - }; - /** The chosen buttons as "row.column", or null for the whole page (never chosen). */ - const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.map(String).filter((k) => SPOT_RE.test(k)).slice(0, MAX_SPOTS) : null); - const keepLayout = () => choosing || prefs().layout !== false; + const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []); + const save = (spots) => moduleApi.saveInstanceSettings({ + buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })), + }); + const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`; - const pictures = new Map(); // spot key → { image, seen } - let page = null; // the server's latest `page` event - let stream = null; + // Choosing survives the remount a gear change causes (picking from another + // Page), so it is kept per tile for this browser tab, not in the closure. + const choosingKey = `companion-buttons:choosing:${moduleApi.instanceId}`; + const readChoosing = () => { + try { + return sessionStorage.getItem(choosingKey) === '1'; + } catch { + return false; + } + }; + const writeChoosing = (on) => { + try { + if (on) sessionStorage.setItem(choosingKey, '1'); + else sessionStorage.removeItem(choosingKey); + } catch { + /* private window: choosing just ends on the next remount */ + } + }; + + const data = new Map(); // spot key → the server's latest word on it + let stream = null; // one stream for every button on the tile let streamPath = ''; let lost = false; let choosing = false; - let pickButton = null; - let wrap = null; + let cells = []; // one { cell, face, img, note, caption } per chosen button, in order + let view = null; let banner = null; - let grid = null; - let note = null; - let cells = new Map(); // spot key → { face, img } + let chooser = null; // the page being chosen from, while choosing + let pages = []; + let pagesTimer = null; + let pagesToken = 0; + let pickButton = null; let observer = null; + let stopped = false; - /* ── status ───────────────────────────────────────────────────────── */ + /* ── live buttons ─────────────────────────────────────────────────── */ - function applyStatus() { - if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen'); - if (lost) return offline('Lost the connection to ProdDash'); - if (!page) return moduleApi.setStatus('connecting', 'Connecting'); - if (page.status === 'error') return offline(page.message || 'Companion is not reachable'); - setOffline(false); - if (page.status !== 'ok') return moduleApi.setStatus('connecting', page.message || 'Connecting to Companion'); - if (!page.found) return moduleApi.setStatus('connecting', 'This page is no longer in Companion'); - if (page.spots.some((s) => !pictures.get(s.key)?.seen)) return moduleApi.setStatus('connecting', 'Waiting for Companion'); - const where = `${page.message} · page ${page.number}`; - return moduleApi.setStatus('ok', page.spots.length === 1 ? where : `${where} · ${page.spots.length} buttons`); - } - - function offline(message) { - setOffline(true); - moduleApi.setStatus('error', message); - } - - function setOffline(on) { - if (!banner || banner.hidden === !on) return; - banner.hidden = !on; - wrap.classList.toggle('is-offline', on); - layout(); // the banner takes room from the buttons - } - - /* ── drawing ──────────────────────────────────────────────────────── */ - - function build() { - root.textContent = ''; - wrap = el('div', 'cb-root'); - banner = el('div', 'cb-banner', 'Companion offline'); - banner.hidden = true; - grid = el('div', 'cb-grid'); - note = el('div', 'cb-empty'); - note.hidden = true; - wrap.append(banner, grid, note); - root.append(wrap); - } - - function showNote(text) { - note.textContent = text; - note.hidden = !text; - grid.hidden = Boolean(text); - } - - /** Lay out the cells for the spots in the latest `page` event. */ - function drawPage() { - const spots = page ? page.spots : []; - wrap.classList.toggle('is-choosing', choosing); - if (!pageId()) return showNote('No page chosen'); - if (!choosing && Array.isArray(chosen()) && !chosen().length) return showNote('No buttons chosen'); - if (page && !page.found) return showNote(''); // the page is gone: draw nothing, the shell folds the tile away - if (page && page.status === 'ok' && !spots.length) return showNote('No buttons on this page'); - showNote(''); - - const keys = new Set(spots.map((s) => s.key)); - for (const [key, c] of cells) { - if (!keys.has(key)) { - c.face.remove(); - cells.delete(key); - } - } - const picked = chosen(); - for (const s of spots) { - let c = cells.get(s.key); - if (!c) { - const face = el('div', 'cb-face is-waiting'); - const img = el('img', 'cb-img'); - img.alt = ''; - img.draggable = false; - img.hidden = true; - face.append(img); - face.addEventListener('click', () => toggle(s.key)); - c = { face, img }; - cells.set(s.key, c); - } - c.face.title = `${page.name} · ${page.number}/${s.row}/${s.column}`; - c.face.classList.toggle('is-off', choosing && picked !== null && !picked.includes(s.key)); - grid.append(c.face); // re-appending keeps them in the server's order - paint(s.key); - } - layout(); - } - - function paint(key) { - const c = cells.get(key); - if (!c) return; - const p = pictures.get(key); - if (p && p.image) { - if (c.img.getAttribute('src') !== p.image) c.img.src = p.image; - c.img.hidden = false; - } else { - c.img.hidden = true; - } - c.face.classList.toggle('is-waiting', !p || !p.seen); - c.face.classList.toggle('is-empty', Boolean(p && p.seen && !p.image)); - } - - function layout() { - if (!grid || !page || !cells.size) return; - const spots = page.spots.filter((s) => cells.has(s.key)); - const w = grid.clientWidth; - const h = grid.clientHeight; - if (keepLayout()) { - // Companion's own rows and columns, cropped to the buttons shown. - const minRow = Math.min(...spots.map((s) => s.row)); - const minCol = Math.min(...spots.map((s) => s.column)); - const rows = Math.max(...spots.map((s) => s.row)) - minRow + 1; - const cols = Math.max(...spots.map((s) => s.column)) - minCol + 1; - const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows)); - grid.style.setProperty('--cb-cols', String(cols)); - grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`); - for (const s of spots) { - const face = cells.get(s.key).face; - face.style.gridRow = String(s.row - minRow + 1); - face.style.gridColumn = String(s.column - minCol + 1); - } - } else { - const { cols, size } = packedGrid(spots.length, w, h); - grid.style.setProperty('--cb-cols', String(cols)); - grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`); - for (const s of spots) { - const face = cells.get(s.key).face; - face.style.gridRow = ''; - face.style.gridColumn = ''; - } - } - } - - /* ── choosing ─────────────────────────────────────────────────────── */ - - function setChoosing(on) { - choosing = on; - pickButton?.classList.toggle('active', on); - connect(); - drawPage(); - } - - /** Put a button in or take it out. Every button in again means the whole page, new buttons included. */ - function toggle(key) { - if (!choosing || !page) return; - const all = page.spots.map((s) => s.key); - const now = chosen() || all; - const next = now.includes(key) ? now.filter((k) => k !== key) : [...now, key]; - const whole = all.every((k) => next.includes(k)); - moduleApi.saveInstanceSettings({ buttons: whole ? null : next.slice(0, MAX_SPOTS) }); - drawPage(); - } - - /* ── the stream ───────────────────────────────────────────────────── */ - - function apply(kind, body) { - if (kind === 'page') { - page = body; - lost = false; - drawPage(); - } else { - pictures.set(body.key, { image: body.image || null, seen: Boolean(body.seen) }); - paint(body.key); - } + function onButton(msg) { + const index = chosen().findIndex((s) => spotKey(s) === msg.key); + if (index < 0) return; + data.set(msg.key, msg); + lost = false; + paintCell(index); applyStatus(); + adopt(index, msg); } - /** Open the stream for what the tile shows now: the whole page while choosing, else the chosen buttons. */ - function connect() { - const id = pageId(); - const keys = choosing ? null : chosen(); - const path = !id || (keys && !keys.length) ? '' : `${id}${keys ? `/${keys.join('/')}` : ''}`; + function syncStream() { + const keys = chosen().map(spotKey); + const path = keys.length ? keys.join('/') : ''; if (path === streamPath) return; if (stream) stream.close(); stream = null; streamPath = path; + lost = false; + for (const key of [...data.keys()]) if (!keys.includes(key)) data.delete(key); if (!path) return; stream = moduleApi.sse(`/stream/${path}`, { - // The page's connection may already carry this stream (another tile, or - // this one before a remount): ask for what it sent on connect. + // The dashboard may already carry this stream (another tile showing the + // same buttons, or this tile before a remount); then nothing is re-sent + // on its own, so ask for what it sent on connect. async open() { try { const res = await moduleApi.fetch(`/state/${path}`); if (!res.ok || path !== streamPath) return; const snap = await res.json(); - if (path !== streamPath) return; - apply('page', snap.page); - for (const b of snap.buttons) apply('button', b); + if (path !== streamPath || stopped) return; + for (const msg of snap.buttons || []) if (msg.seen || !data.has(msg.key)) onButton(msg); } catch { - /* the stream itself will catch up */ + /* the stream itself catches up on the next redraw */ } }, events: { - page(e) { - try { - apply('page', JSON.parse(e.data)); - } catch { - /* not JSON */ - } - }, button(e) { try { - apply('button', JSON.parse(e.data)); + onButton(JSON.parse(e.data)); } catch { /* not JSON */ } @@ -275,34 +154,273 @@ export default function create({ root, moduleApi }) { }); } + /** Save what the server resolved a button to — a new id after a re-import, a new place after a move. */ + function adopt(index, msg) { + const where = msg.location; + if (!msg.controlId || !where) return; + const spots = chosen(); + const s = spots[index]; + if (s.id === msg.controlId && s.page === where.page && s.row === where.row && s.column === where.column) return; + if (spots.some((o, i) => i !== index && o.id === msg.controlId)) return; // already shown here + spots[index] = { id: msg.controlId, page: where.page, row: where.row, column: where.column }; + data.set(spotKey(spots[index]), msg); // keep the picture up while the stream is re-made + save(spots); + syncStream(); + } + + function applyStatus() { + const spots = chosen(); + if (choosing && !spots.length) return; // choosing reports Companion's state itself + if (!spots.length) return moduleApi.setStatus('ok', 'No buttons chosen'); + if (lost) return showOffline('Lost the connection to ProdDash'); + const list = spots.map((s) => data.get(spotKey(s))); + const bad = list.find((d) => d && d.status === 'error'); + if (bad) return showOffline(bad.message || 'Companion is not reachable'); + setBanner(''); + if (list.some((d) => !d || d.status !== 'ok' || !d.seen)) { + const waiting = list.find((d) => d && d.status !== 'ok'); + return moduleApi.setStatus('connecting', waiting ? waiting.message : 'Waiting for Companion'); + } + const missing = list.filter((d) => !d.image).length; + if (missing) { + return moduleApi.setStatus('connecting', `${missing} of ${list.length} button${list.length === 1 ? ' is' : 's are'} no longer in Companion`); + } + const msg = list[0].message || 'Companion'; + return moduleApi.setStatus('ok', list.length > 1 ? `${msg} · ${list.length} buttons` : msg); + } + + function showOffline(message) { + setBanner('Companion offline'); + moduleApi.setStatus('error', message); + } + + function setBanner(text) { + if (!banner || banner.textContent === text) return; + banner.hidden = !text; + banner.textContent = text; + if (view) view.classList.toggle('is-offline', Boolean(text)); + layout(); // the banner takes room from the buttons + } + + /* ── the chosen buttons ───────────────────────────────────────────── */ + + function buildView() { + root.textContent = ''; + cells = []; + chooser = null; + const spots = chosen(); + const wrap = el('div', 'cb-root'); + banner = el('div', 'cb-banner'); + banner.hidden = true; + wrap.append(banner); + + if (!spots.length) { + view = null; + wrap.append(el('div', 'cb-empty', 'No buttons chosen')); + root.append(wrap); + return; + } + + view = el('div', 'cb-grid'); + view.classList.toggle('has-captions', prefs().captions === true); + spots.forEach((s, index) => { + const cell = el('div', 'cb-cell'); + const face = el('div', 'cb-face is-waiting'); + const img = el('img', 'cb-img'); + img.alt = ''; + img.draggable = false; + img.hidden = true; + const note = el('div', 'cb-note'); + face.append(img, note); + const caption = el('div', 'cb-caption'); + cell.append(face, caption); + view.append(cell); + cells.push({ cell, face, img, note, caption }); + paintCell(index); + }); + wrap.append(view); + root.append(wrap); + layout(); + } + + function paintCell(index) { + const c = cells[index]; + const s = chosen()[index]; + if (!c || !s) return; + const d = data.get(spotKey(s)); + const where = d && d.location; + const image = d && d.image; + if (image) { + if (c.img.getAttribute('src') !== image) c.img.src = image; + c.img.hidden = false; + c.note.textContent = ''; + } else { + c.img.hidden = true; + c.note.textContent = d && d.seen ? 'Not in Companion' : ''; + } + c.face.classList.toggle('is-waiting', !d || !d.seen); + c.face.classList.toggle('is-missing', Boolean(d && d.seen && !image)); + c.caption.textContent = where ? where.pageName : ''; + const title = where ? `${where.pageName} · ${where.page}/${where.row}/${where.column}` : ''; + c.cell.title = title; + c.img.alt = title; + } + + function layout() { + if (!view || !cells.length) return; + const captionPx = prefs().captions === true ? CAPTION_PX : 0; + const { cols, size } = bestGrid(cells.length, view.clientWidth, view.clientHeight, captionPx); + view.style.setProperty('--cb-cols', String(cols)); + view.style.setProperty('--cb-size', `${Math.max(16, size)}px`); + } + + /* ── choosing ─────────────────────────────────────────────────────── */ + + function setChoosing(on) { + choosing = on; + writeChoosing(on); + pickButton?.classList.toggle('active', on); + if (on) { + openChooser(); + } else { + clearTimeout(pagesTimer); + pagesToken += 1; + syncStream(); + buildView(); + applyStatus(); + } + } + + function openChooser() { + root.textContent = ''; + cells = []; + view = null; + banner = null; + chooser = el('div', 'cb-chooser'); + root.append(chooser); + loadPages(); + } + + async function loadPages() { + const token = ++pagesToken; + clearTimeout(pagesTimer); + let reply = null; + try { + const res = await moduleApi.fetch('/pages'); + reply = await res.json(); + if (!res.ok) throw new Error(reply.error || `ProdDash answered ${res.status}`); + } catch (err) { + reply = { status: 'error', message: err.message || 'ProdDash is not answering', pages: [] }; + } + if (stopped || !chooser || token !== pagesToken) return; + pages = Array.isArray(reply.pages) ? reply.pages : []; + moduleApi.setStatus(reply.status === 'ok' ? 'ok' : reply.status === 'connecting' ? 'connecting' : 'error', reply.message || ''); + if (!pages.length) { + chooser.textContent = ''; + chooser.append(el('div', 'cb-empty', reply.status === 'ok' ? 'Companion has no pages' : reply.message || 'Companion is not reachable')); + pagesTimer = setTimeout(loadPages, PAGES_RETRY_MS); + return; + } + drawChooser(); + } + + /** The page the gear names; else the page of the first chosen button, or the first page with buttons — saved, so the gear shows it. */ + function chooserPage() { + const wanted = String(prefs().page || ''); + const named = PAGE_RE.test(wanted) && pages.find((p) => p.id === wanted); + if (named) return named; + const first = chosen()[0]; + const fallback = (first && pages.find((p) => p.number === first.page)) + || pages.find((p) => p.buttons.length) + || pages[0]; + moduleApi.saveInstanceSettings({ page: fallback.id }); + return fallback; + } + + function drawChooser() { + const page = chooserPage(); + chooser.textContent = ''; + if (!page.buttons.length) { + chooser.append(el('div', 'cb-empty', `${page.name} has no buttons`)); + return; + } + // Companion's grid may start below zero; lay the page out from its own corner. + const rows = page.buttons.map((b) => b.row); + const cols = page.buttons.map((b) => b.column); + const minRow = Math.min(0, ...rows); + const minCol = Math.min(0, ...cols); + const grid = el('div', 'cb-pick-grid'); + grid.style.setProperty('--cb-pick-cols', String(Math.max(...cols) - minCol + 1)); + for (const b of page.buttons) { + const key = el('button', 'cb-pick-key'); + key.type = 'button'; + key.dataset.id = b.controlId; + key.style.gridRow = String(b.row - minRow + 1); + key.style.gridColumn = String(b.column - minCol + 1); + key.title = `${page.name} · ${page.number}/${b.row}/${b.column}`; + const img = el('img', 'cb-pick-img'); + img.alt = key.title; + img.draggable = false; + img.src = imageUrl(b.controlId); + img.addEventListener('error', () => img.classList.add('is-broken')); + key.append(img, el('span', 'cb-pick-badge')); + key.addEventListener('click', () => toggle({ id: b.controlId, page: page.number, row: b.row, column: b.column })); + grid.append(key); + } + chooser.append(grid); + markPicked(); + } + + function toggle(spot) { + const spots = chosen(); + const at = spots.findIndex((s) => s.id === spot.id); + if (at >= 0) spots.splice(at, 1); + else if (spots.length < MAX_BUTTONS) spots.push(spot); + save(spots); + markPicked(); + } + + function markPicked() { + if (!chooser) return; + const ids = chosen().map((s) => s.id); + for (const key of chooser.querySelectorAll('.cb-pick-key')) { + const at = ids.indexOf(key.dataset.id); + key.classList.toggle('is-picked', at >= 0); + key.querySelector('.cb-pick-badge').textContent = at >= 0 ? String(at + 1) : ''; + } + } + /* ── lifecycle ────────────────────────────────────────────────────── */ return { start() { - build(); - if (pageId()) { - pickButton = moduleApi.header.addButton({ - icon: GRID_SVG, - title: 'Choose buttons', - onClick: () => setChoosing(!choosing), - }); - } + stopped = false; + moduleApi.setStatus('connecting', 'Connecting'); + pickButton = moduleApi.header.addButton({ + icon: GRID_SVG, + title: 'Choose buttons', + onClick: () => setChoosing(!choosing), + }); observer = new ResizeObserver(() => layout()); - observer.observe(grid); - drawPage(); - applyStatus(); - connect(); + observer.observe(root); + syncStream(); + // A new tile, or one that was choosing before a gear change, goes (back) to choosing. + setChoosing(readChoosing() || !chosen().length); }, stop() { + stopped = true; + clearTimeout(pagesTimer); + pagesToken += 1; if (stream) stream.close(); stream = null; streamPath = ''; + data.clear(); if (observer) observer.disconnect(); observer = null; - pictures.clear(); - cells = new Map(); - page = null; + chooser = null; + view = null; + cells = []; root.textContent = ''; }, }; diff --git a/companion.js b/companion.js index 1f42652..37cf8f9 100644 --- a/companion.js +++ b/companion.js @@ -8,9 +8,8 @@ * * pages.watch → every page, its name, and which control * sits at each row/column - * preview.graphics.controlId → a button's rendered image, on every redraw - * controls.watchControl → a button's setup, read once for its kind - * (page up/down buttons are left out) + * preview.graphics.controlId → a button's rendered image, following the + * button if it is moved to another page * * Nothing here can press, edit or change anything in Companion. * @@ -267,16 +266,17 @@ function createPageBook() { return null; } - /** One page by Companion's own page id (stable when pages are renumbered), or null. */ - function page(id) { - return list().find((p) => p.id === id) || null; + /** The control at a page number, row and column right now, or null. */ + function at(number, row, column) { + const page = pages.get(order[number - 1]); + return (page && page.controls[row] && page.controls[row][column]) || null; } return { apply, list, locate, - page, + at, get loaded() { return order.length > 0; }, @@ -303,7 +303,4 @@ function pageName(raw, number) { return `${number} ${name}`; } -/** 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 }; +module.exports = { createLink, createPageBook, pageName }; diff --git a/module.json b/module.json index 6f09e19..1f7ce6d 100644 --- a/module.json +++ b/module.json @@ -1,9 +1,9 @@ { "id": "companion-buttons", "name": "Companion Buttons", - "version": "1.1.0", + "version": "1.2.0", "proddash": ">=1.10.0", - "description": "Companion pages and buttons, live as Companion draws them — for status and what's happening", + "description": "Any Companion buttons from any pages, live as Companion draws them — for status and what's happening", "permissions": [ { "kind": "local-network", @@ -14,7 +14,7 @@ "server": "server.js", "style": "style.css", "minSize": { "w": 1, "h": 1 }, - "defaultSize": { "w": 6, "h": 4 }, + "defaultSize": { "w": 4, "h": 4 }, "configSchema": { "companion": { "type": "endpoint", @@ -24,10 +24,10 @@ } }, "instanceSchema": { - "layout": { + "captions": { "type": "switch", - "label": "Page layout", - "default": true + "label": "Captions", + "default": false }, "page": { "type": "select", diff --git a/server.js b/server.js index fd8e131..6a7d2ab 100644 --- a/server.js +++ b/server.js @@ -3,41 +3,43 @@ /** * Companion Buttons — server part. * - * Shows Bitfocus Companion pages on dashboard tiles, every button exactly as - * Companion draws it: colours, text, icons and live feedback included. - * ProdDash holds one connection to Companion (companion.js) however many - * dashboards are open; browsers only ever talk to these routes. + * Shows any button from Bitfocus Companion, on any page, exactly as Companion + * draws it: colours, text, icons and live feedback included. ProdDash holds + * one connection to Companion (companion.js) however many dashboards are open; + * browsers only ever talk to these routes. * - * Each Companion page is an entry in the + Add-tile picker (tiles()), titled - * with the page's name. A tile shows its whole page, or the buttons chosen on - * it (the tile's own choosing mode). + * GET /pages → every page and the buttons on it (choosing) + * GET /page-options → the pages, for the gear's Page + * GET /image/ → one button's current picture, as PNG (choosing) + * GET /stream///… → SSE: `button` for each of those buttons on connect, + * then again whenever one redraws or moves + * GET /state///… → the same as one snapshot, for a tile that joins a + * stream its dashboard already has open * - * GET /page-options → the pages, for a tile added without one - * GET /stream/[//…] - * → SSE for one tile: `page` (what to lay out) on - * connect and whenever it changes, `button` (one - * picture) on connect and on every redraw - * GET /state/[//…] - * → the same as one snapshot, for a tile that joins - * a stream already open on its dashboard + * is Companion's control id without its "bank:" prefix, and each is + * page.row.column.id — "19.1.4.t4bp4ZhrQblkr3bPSEBbc" — in the characters + * ProdDash's page stream carries. A tile holds one stream for all its buttons: + * a page carries at most 32 streams, and a status board can show more buttons + * than that. * - * is Companion's own page id, which stays with the page when pages are - * inserted or moved; is a button's row and column on it. Buttons are - * followed by place, so a page re-imported from a .companionconfig — every - * button gets a new id — keeps showing. A tile holds one stream for all its - * buttons: ProdDash carries at most 32 streams per page. + * Which button a tile means: the control id while Companion still has it + * anywhere, so a button that is dragged elsewhere or a page inserted in front + * keeps showing the same button; otherwise whatever is at the page, row and + * column now, so a page that is re-imported (every button gets a new id) keeps + * showing the same place. Every frame says which control and location it + * 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. */ -const { createLink, createPageBook, NAV_TYPES } = require('./companion'); +const { createLink, createPageBook } = require('./companion'); const HEARTBEAT_MS = 15000; -const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves -const DESCRIBE_MS = 3000; // how long to wait for one button's setup -const MAX_SPOTS = 64; -const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/; -const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/; +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 MAX_PER_STREAM = 64; +const ID_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})$/; let current = null; @@ -51,89 +53,37 @@ function sendJson(res, code, body) { res.end(JSON.stringify(body)); } -/** "/" → { pageId, keys: null } (the whole page); "//0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */ -function viewFrom(req) { - const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/'); - if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null; - if (!rest.length) return { pageId, keys: null }; - if (!rest.every((k) => SPOT_RE.test(k))) return null; - return { pageId, keys: [...new Set(rest)] }; +/** "/19.1.4.t4bp4Z…/2.0.0.QOPCk…" → [{ key, page, row, column, id }], or null if malformed. */ +function spotsFrom(req) { + const parts = String(req.wildcard || '').replace(/^\//, '').split('/'); + if (!parts.length || parts.length > MAX_PER_STREAM) return null; + const spots = []; + for (const key of new Set(parts)) { + const m = SPOT_RE.exec(key); + 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 = { init({ config, log }) { const { host, port } = endpointOf(config.companion); const pages = createPageBook(); - const buttons = new Map(); // controlId → { image, seen, refs, linger, off } - 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 } + const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off } + const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] } let link = null; let heartbeat = null; let status = host ? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` } : { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" }; - /* — 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 — */ + /* — following a button in Companion — */ function watch(controlId) { let b = buttons.get(controlId); if (!b) { - b = { image: null, seen: false, refs: 0, linger: null, off: null }; + b = { image: null, seen: false, refs: 0, waiters: new Set(), linger: null, off: null }; buttons.set(controlId, b); if (link) { b.off = link.subscribe( @@ -144,6 +94,8 @@ module.exports = { const changed = image !== b.image || !b.seen; b.image = image; 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. if (changed) sendButton(controlId); }, @@ -156,63 +108,64 @@ module.exports = { return b; } + /** Let go of a button once nothing has looked at it for a while. */ function release(controlId) { const b = buttons.get(controlId); - if (!b || b.refs > 0) return; + if (!b || b.refs > 0 || b.waiters.size) return; clearTimeout(b.linger); b.linger = setTimeout(() => { - if (b.refs > 0) return; + if (b.refs > 0 || b.waiters.size) return; if (b.off) b.off(); buttons.delete(controlId); }, LINGER_MS); } - /* — one tile's view of one page — */ - - /** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */ - function spotsFor(viewer, page) { - 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 }; - }); + /** The control a tile's spot means right now (see the header), or null. */ + function resolve(spot) { + if (!pages.loaded) return spot.id; + if (pages.locate(spot.id)) return spot.id; + return pages.at(spot.page, spot.row, spot.column); } - function pageBody(spots, page) { - return { - status: status.state, - message: status.message, - found: Boolean(page) || !pages.loaded, - name: page ? page.name : '', - number: page ? page.number : 0, - spots: spots.map(({ key, row, column }) => ({ key, row, column })), - }; + function point(spot) { + const target = resolve(spot); + 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; } - function buttonBody(spot) { + /* — what a tile is sent — */ + + function body(spot) { const b = spot.target ? buttons.get(spot.target) : null; return { key: spot.key, + controlId: spot.target, + status: status.state, + message: status.message, image: b ? b.image : null, - // A place with nothing on it is as answered as it will get. - seen: b ? b.seen : pages.loaded, + // A spot with nothing there is as answered as it will get; a button whose + // picture has not arrived yet is not. + seen: b ? b.seen : !spot.target && pages.loaded, + location: spot.target ? pages.locate(spot.target) : null, }; } - 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`; + const frame = (spot) => `event: button\ndata: ${JSON.stringify(body(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. + * What a stream sends on connect, for a tile that joins a stream the + * dashboard already has open — ProdDash reuses the connection when a tile + * remounts after a settings change, so nothing new arrives on its own. */ - 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 snapshot(spots) { + return spots.map((s) => body({ ...s, target: resolve(s) })); } function write(viewer, chunk) { @@ -223,73 +176,59 @@ module.exports = { } } - /** Bring a viewer up to date with the page book; send what changed. */ - 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 sendButton(controlId) { + for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot)); } function sendAll() { - for (const v of viewers) refresh(v, false); - } - - function sendButton(controlId) { - for (const v of viewers) for (const s of v.spots) if (s.target === controlId) write(v, buttonFrame(s)); + for (const v of viewers) { + for (const spot of v.spots) { + point(spot); + write(v, frame(spot)); + } + } } function drop(viewer) { if (!viewers.delete(viewer)) return; - for (const s of viewer.spots) { - if (!s.target) continue; - const b = buttons.get(s.target); + for (const spot of viewer.spots) { + if (!spot.target) continue; + const b = buttons.get(spot.target); if (b) b.refs -= 1; - release(s.target); + release(spot.target); } } - function addViewer(res, view) { - const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' }; + function addViewer(res, spots) { + const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) }; viewers.add(viewer); - refresh(viewer, true); + for (const spot of viewer.spots) { + point(spot); + write(viewer, frame(spot)); + } return viewer; } - /* — the connection — */ - if (host) { link = createLink({ host, port, onStatus(next) { - status = next.state === 'ok' && !pages.loaded ? { state: 'connecting', message: 'Connected — reading pages' } : next; + status = next.state === 'ok' && !pages.loaded + ? { state: 'connecting', message: 'Connected — reading pages' } + : next; if (next.state !== 'ok') log(next.message); sendAll(); }, }); link.subscribe('pages.watch', undefined, (change) => { - const fresh = change && change.type === 'init'; pages.apply(change); - if (fresh) { + if (change && change.type === 'init') { status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` }; log(`connected to Companion at ${host}:${port} (${pages.count} pages)`); } - // A page renamed, a button added or moved, or a page re-imported changes what tiles show. + // A page renamed, a button moved or a page re-imported changes what tiles show. sendAll(); - learnAll(); }); } @@ -297,31 +236,17 @@ module.exports = { for (const v of viewers) write(v, ': ping\n\n'); }, 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 = { get status() { return status; }, pages, + watch, + release, addViewer, drop, snapshot, - tileList, + linked: Boolean(link), }; const self = current; @@ -336,24 +261,23 @@ module.exports = { } } viewers.clear(); - for (const b of buttons.values()) clearTimeout(b.linger); + for (const b of buttons.values()) { + clearTimeout(b.linger); + for (const done of b.waiters) done(); + } buttons.clear(); if (link) link.close(); if (current === self) current = null; }, health() { const shown = new Set(); - for (const v of viewers) for (const s of v.spots) if (s.target) shown.add(s.target); + for (const v of viewers) for (const spot of v.spots) if (spot.target) shown.add(spot.target); const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : ''; return { status: status.state, message: status.message + suffix }; }, }; }, - tiles() { - return current ? current.tileList() : []; - }, - routes() { return { 'GET /page-options': (req, res) => { @@ -362,15 +286,59 @@ module.exports = { }, 'GET /state/*': (req, res) => { - const view = viewFrom(req); - if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); + const spots = spotsFrom(req); + if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' }); if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' }); - return sendJson(res, 200, current.snapshot(view)); + return sendJson(res, 200, { buttons: current.snapshot(spots) }); + }, + + '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) => { + const raw = String(req.wildcard || '').replace(/^\//, ''); + if (!ID_RE.test(raw)) return sendJson(res, 400, { error: 'Not a Companion button id' }); + if (!current || !current.linked) return sendJson(res, 503, { error: current ? current.status.message : 'Not running' }); + const id = `bank:${raw}`; + const app = current; + const b = app.watch(id); + const reply = () => { + if (!b.image) { + res.writeHead(b.seen ? 404 : 504, { 'Cache-Control': 'no-store' }); + res.end(); + } else { + 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) => { - const view = viewFrom(req); - if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); + const spots = spotsFrom(req); + if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' }); res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', @@ -379,12 +347,14 @@ module.exports = { }); res.write('retry: 3000\n\n'); if (!current) { - const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, spots: [] }; - res.write(`event: page\ndata: ${JSON.stringify(body)}\n\n`); + for (const s of 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: button\ndata: ${JSON.stringify(body)}\n\n`); + } return void res.end(); } const app = current; - const viewer = app.addViewer(res, view); + const viewer = app.addViewer(res, spots); try { req.socket.setKeepAlive(true, HEARTBEAT_MS); } catch { diff --git a/style.css b/style.css index 2f9d663..e193294 100644 --- a/style.css +++ b/style.css @@ -11,18 +11,25 @@ overflow: hidden; } +/* ── the chosen buttons ─────────────────────────────────────────────── */ + [data-module="companion-buttons"] .cb-grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(var(--cb-cols, 1), var(--cb-size, 64px)); - grid-auto-rows: var(--cb-size, 64px); + grid-auto-rows: max-content; gap: 6px; align-content: center; justify-content: center; } -[data-module="companion-buttons"] .cb-grid[hidden] { display: none; } +[data-module="companion-buttons"] .cb-cell { + display: flex; + flex-direction: column; + align-items: center; + min-width: 0; +} [data-module="companion-buttons"] .cb-face { position: relative; @@ -50,15 +57,45 @@ to { opacity: 0.8; } } -/* A ticked place with no button on it any more. */ -[data-module="companion-buttons"] .cb-face.is-empty { +[data-module="companion-buttons"] .cb-face.is-missing { background: transparent; outline: 1px dashed var(--border); 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. */ -[data-module="companion-buttons"] .cb-root.is-offline .cb-face { +[data-module="companion-buttons"] .cb-grid.is-offline .cb-face { opacity: 0.3; filter: grayscale(1); } @@ -79,29 +116,78 @@ [data-module="companion-buttons"] .cb-banner[hidden] { display: none; } +/* ── nothing chosen ─────────────────────────────────────────────────── */ + [data-module="companion-buttons"] .cb-empty { flex: 1; display: flex; align-items: center; justify-content: center; + padding: 12px; color: var(--muted); font-size: 0.85rem; text-align: center; } -[data-module="companion-buttons"] .cb-empty[hidden] { display: none; } +/* ── choosing: one Companion page, laid out as Companion has it ─────── */ -/* Choosing: the whole page is shown and clickable; buttons this tile leaves out are faded. */ -[data-module="companion-buttons"] .cb-root.is-choosing .cb-face { +[data-module="companion-buttons"] .cb-chooser { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow: auto; + padding: 6px; +} + +[data-module="companion-buttons"] .cb-pick-grid { + display: grid; + grid-template-columns: repeat(var(--cb-pick-cols, 8), minmax(36px, 1fr)); + gap: 4px; +} + +[data-module="companion-buttons"] .cb-pick-key { + position: relative; + aspect-ratio: 1; + padding: 0; + border: 0; + border-radius: 8%; + overflow: hidden; + background: color-mix(in srgb, var(--text) 6%, transparent); cursor: pointer; + -webkit-tap-highlight-color: transparent; } -[data-module="companion-buttons"] .cb-root.is-choosing .cb-face:hover { - outline: 2px solid var(--accent); - outline-offset: -2px; +[data-module="companion-buttons"] .cb-pick-img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; } -[data-module="companion-buttons"] .cb-root.is-choosing .cb-face.is-off { - opacity: 0.25; - filter: grayscale(1); +[data-module="companion-buttons"] .cb-pick-img.is-broken { visibility: hidden; } + +/* Chosen: a ring and the place it shows in. Nothing else is ever marked. */ +[data-module="companion-buttons"] .cb-pick-key.is-picked { + outline: 3px solid var(--accent); + outline-offset: -3px; } + +[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; } diff --git a/test/companion-mock.js b/test/companion-mock.js index a69f01e..5e49b12 100644 --- a/test/companion-mock.js +++ b/test/companion-mock.js @@ -8,10 +8,9 @@ * * then point Companion Buttons at host 127.0.0.1, port 18000 in /admin. * - * It serves the three subscriptions the module uses — pages.watch, - * preview.graphics.controlId and controls.watchControl — over a hand-rolled - * 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 + * It serves the two subscriptions the module uses — pages.watch and + * preview.graphics.controlId — over a hand-rolled websocket (no deps), with + * three pages. 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 * 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, @@ -124,22 +123,9 @@ function readFrames(state, data, onText, onClose) { function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) { const pages = { - p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' }, 1: { 0: 'bank:up1' } } }, + p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' } } }, p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } }, - 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: {} }, + p3: { id: 'p3', name: 'PAGE', controls: {} }, }; const order = ['p1', 'p2', 'p3']; const looks = { @@ -198,14 +184,6 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } } if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } }); 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') { return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } }); } @@ -298,7 +276,6 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } redraw, looks, pages, - configs, /** Drop every client without a close frame, as a crash or a cable pull would. */ dropAll() { for (const c of clients) c.socket.destroy(); diff --git a/test/companion.test.js b/test/companion.test.js index 7f6c123..1df6580 100644 --- a/test/companion.test.js +++ b/test/companion.test.js @@ -63,10 +63,9 @@ function get(port, path) { }); } -/** An SSE client that collects `page` and `button` events. */ +/** An SSE client that collects `button` events. */ function listen(port, path) { - const pages = []; - const buttons = []; + const events = []; let req; const ready = new Promise((resolve) => { req = http.get({ host: '127.0.0.1', port, path }, (res) => { @@ -79,23 +78,13 @@ function listen(port, path) { buf = buf.slice(at + 2); const name = /^event: (.+)$/m.exec(block); const data = /^data: (.+)$/m.exec(block); - if (!name || !data) continue; - if (name[1] === 'page') pages.push(JSON.parse(data[1])); - if (name[1] === 'button') buttons.push(JSON.parse(data[1])); + if (name && data && name[1] === 'button') events.push(JSON.parse(data[1])); } }); resolve(); }); }); - 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), - }; + return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] }; } /* ── pure parts ────────────────────────────────────────────────────────── */ @@ -146,8 +135,6 @@ function testPageBook() { book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] }); assert.strictEqual(book.locate('bank:x'), null); assert.strictEqual(book.count, 2); - assert.strictEqual(book.page('b').number, 2); - assert.strictEqual(book.page('a'), null); } /* ── against the mock ─────────────────────────────────────────────────── */ @@ -165,91 +152,115 @@ async function testLive() { await until('Companion connected', () => handle.health().status === 'ok'); assert.match(handle.health().message, /3 pages/); - // the + picker: one entry per page with something to show — not the page that is only navigation - await until('nav buttons learned', () => serverEntry.tiles().length === 2); - const tiles = serverEntry.tiles(); - assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]); - assert.deepStrictEqual(tiles[0].settings, { page: 'p1' }); - assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']); - - 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 }, + // pages for the picker + const pages = JSON.parse((await get(at, '/pages')).body); + assert.strictEqual(pages.status, 'ok'); + assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']); + assert.deepStrictEqual(pages.pages[1].buttons, [ + { row: 0, column: 0, controlId: 'bank:cam1' }, + { row: 1, column: 3, controlId: 'bank:cam2' }, ]); - 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)); - // the snapshot a remounted tile asks for matches what the stream sent - const snap = JSON.parse((await get(at, '/state/p1')).body); - assert.deepStrictEqual(snap.page.spots, whole.page().spots); - 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.strictEqual((await get(at, '/state/p1/x')).status, 400); + // thumbnails + const img = await get(at, '/image/blue'); + assert.strictEqual(img.status, 200); + assert.strictEqual(img.type, 'image/png'); + assert.deepStrictEqual([...img.body.subarray(1, 4)], [0x50, 0x4e, 0x47]); + 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 is sent once, not five times - const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length; + // the gear's Page list, by Companion's page id + const pageOptions = JSON.parse((await get(at, '/page-options')).body).options; + assert.deepStrictEqual(pageOptions, [ + { value: 'p1', label: '1 Status' }, + { value: 'p2', label: '2 Cameras' }, + { value: 'p3', label: 'Page 3' }, + ]); + + // a tile that joins a stream its dashboard already has open asks for the snapshot instead + const warm = listen(at, '/stream/1.0.2.blue'); + await warm.ready; + await until('blue streamed', () => warm.last() && warm.last().image); + const snap = JSON.parse((await get(at, '/state/1.0.2.blue/2.0.0.oldid/3.0.0.gone')).body).buttons; + assert.deepStrictEqual(snap.map((b) => [b.key, b.controlId, Boolean(b.image), b.seen]), [ + ['1.0.2.blue', 'bank:blue', true, true], + ['2.0.0.oldid', 'bank:cam1', false, false], + ['3.0.0.gone', null, false, true], + ]); + assert.strictEqual(snap[0].image, warm.last().image); + assert.deepStrictEqual(snap[0].location, { page: 1, row: 0, column: 2, pageName: '1 Status' }); + assert.strictEqual((await get(at, '/state/blue')).status, 400); + warm.close(); + + // a button that redraws the same picture every 300 ms sends one event, not five + const steady = listen(at, '/stream/1.0.1.steady'); + await steady.ready; + await until('steady first frame', () => steady.last() && steady.last().image); + const settled = steady.events.length; await sleep(1600); - assert.strictEqual(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, 'identical redraws must not be re-sent'); + assert.strictEqual(steady.events.length, settled, '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 - const firstLook = whole.pic('0.0').image; - await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000); + const flip = listen(at, '/stream/1.0.0.flip'); + await flip.ready; + await until('flip first frame', () => flip.last() && flip.last().image); + const firstLook = flip.last().image; + await until('flip redraw', () => flip.last().image !== firstLook, 4000); - // chosen buttons only, in the order chosen - const some = listen(at, '/stream/p2/1.3/0.0'); - await some.ready; - 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(some.page().spots.map((s) => s.key), ['1.3', '0.0']); + // one stream carries all of a tile's buttons: each is sent on connect + const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue'); + await pair.ready; + await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2); + assert.deepStrictEqual([...new Set(pair.events.map((e) => e.key))].sort(), ['1.0.2.blue', '2.1.3.cam2']); + 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 chosen place with nothing on it is answered, not left waiting - const hole = listen(at, '/stream/p2/3.3'); - await hole.ready; - await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen); - assert.strictEqual(hole.pic('3.3').image, null); - hole.close(); + // a button that is gone, with nothing at its place either, says so instead of hanging + const gone = listen(at, '/stream/3.0.0.gone'); + await gone.ready; + await until('missing button answered', () => gone.last() && gone.last().seen); + assert.strictEqual(gone.last().image, null); + assert.strictEqual(gone.last().controlId, null); - // a page that does not exist says so - const missing = listen(at, '/stream/nope'); - await missing.ready; - await until('missing page', () => missing.page()); - assert.strictEqual(missing.page().found, false); - missing.close(); + // an unknown id falls back to what is at its page/row/column + const byPlace = listen(at, '/stream/2.0.0.oldid'); + await byPlace.ready; + await until('found by place', () => byPlace.last() && byPlace.last().image); + assert.strictEqual(byPlace.last().controlId, 'bank:cam1'); + byPlace.close(); - // 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']; + // the button moves to another page in Companion: the tile follows it mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], - changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }], + changes: [ + { 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('renamed', () => some.page().name === '2 Cameras Renamed'); - await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image); + await until('moved location', () => steady.last().location && steady.last().location.page === 2); + assert.strictEqual(steady.last().controlId, 'bank:steady'); - // a page inserted in front: the tile follows its page, now page 3 - mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] }); - await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed'); - some.close(); - - // a button added to a whole-page tile appears in it - mock.looks['bank:new'] = mock.looks['bank:blue']; - mock.configs['bank:new'] = mock.configs['bank:blue']; - mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank: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 }) }; + // the page is re-imported: every button gets a new id, the tile keeps its place + mock.looks['bank:blue-new'] = mock.looks['bank:blue']; + const blue = listen(at, '/stream/1.0.2.blue'); + await blue.ready; + await until('blue', () => blue.last() && blue.last().image); + mock.pageUpdate({ + type: 'update', + updatedOrder: null, + added: [], + changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-new' }] }], + }); + 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 mock.dropAll(); @@ -266,7 +277,9 @@ async function testLive() { 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 - whole.close(); + steady.close(); + flip.close(); + gone.close(); await sleep(100); assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers'); } finally { @@ -285,12 +298,14 @@ async function testUnconfigured() { try { assert.strictEqual(handle.health().status, 'error'); assert.match(handle.health().message, /admin/); - assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []); - assert.deepStrictEqual(serverEntry.tiles(), []); - const s = listen(at, '/stream/p1'); + const pages = JSON.parse((await get(at, '/pages')).body); + assert.strictEqual(pages.status, 'error'); + assert.deepStrictEqual(pages.pages, []); + assert.strictEqual((await get(at, '/image/x')).status, 503); + const s = listen(at, '/stream/1.0.0.x'); await s.ready; - await until('stream answers', () => s.page()); - assert.strictEqual(s.page().status, 'error'); + await until('stream answers', () => s.last()); + assert.strictEqual(s.last().status, 'error'); s.close(); } finally { handle.stop();