From 0a6cec88aa6bcf98e49e010a36424227a802b420 Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 15 Oct 2026 11:48:24 -0400 Subject: [PATCH] =?UTF-8?q?Companion=20Buttons=201.1.0:=20pages=20in=20the?= =?UTF-8?q?=20=EF=BC=8B=20picker,=20buttons=20chosen=20on=20the=20tile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 69 ++--- client.js | 594 +++++++++++++++++------------------------ companion.js | 21 +- module.json | 18 +- server.js | 362 ++++++++++++++----------- style.css | 173 +----------- test/companion-mock.js | 33 ++- test/companion.test.js | 176 ++++++------ 8 files changed, 642 insertions(+), 804 deletions(-) diff --git a/README.md b/README.md index 6170d8d..ccc2b85 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,11 @@ # Companion Buttons A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module -that shows any button from Bitfocus Companion — any page, any button — on a -dashboard tile, 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 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. -- **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 tally lighting up), the tile shows it. Identical redraws — a status page that 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 Companion's settings). Use `127.0.0.1` when Companion runs on the same machine as ProdDash. -2. **+ Add tile → Companion Buttons.** A new tile opens the picker: choose a - page from the list and tap the buttons to show. The numbers on the picked - buttons are the order they appear in. **Done** shows them. -3. To change the buttons later, use the grid icon in the tile's title bar. - The gear has one switch, **Captions**, which writes the page name under - each button. +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. Hover a button for its page and Companion location (`page/row/column`). ### What a tile follows -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. +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. ### 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 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 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. +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. | Route | What it serves | | --- | --- | -| `GET /pages` | Every page and the buttons on it — the picker's list | -| `GET /image/` | One button's current picture as PNG — picker thumbnails | -| `GET /stream//…` | SSE `button` events for a tile's buttons: all of them on connect, then each redraw or move | +| `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) | -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. +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. ## Requirements @@ -81,9 +82,9 @@ A tile holds one stream for all its buttons, because ProdDash carries at most | File | | | --- | --- | | `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 | -| `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-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 68e01a4..3184763 100644 --- a/client.js +++ b/client.js @@ -1,37 +1,24 @@ /* Companion Buttons — the tile. - Shows the Companion buttons this tile was given, as Companion draws them, - as large as the tile allows. One button fills the tile; several share it in - whatever grid makes each one biggest. Every picture is live: the server - forwards each redraw (a status button turning red, a tally lighting up). + Shows one Companion page, or the buttons of it chosen for this tile, every + button as Companion draws it and as large as the tile allows. Every + picture is live: the server forwards each redraw (a status button turning + red, a tally lighting up). - Buttons are chosen in the tile itself — the grid icon in the title bar - opens a picker that shows any Companion page as it is laid out, and a tap - adds or removes a button. The choice is the tile's own (saved with the - layout), so two tiles can show two different sets. Each choice is kept as - the button's id and its page/row/column; the server follows whichever - still holds, and the tile saves back what it resolved to. */ + 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. */ 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 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; -} +const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/; +const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/; +const MAX_SPOTS = 64; function el(tag, className, text) { 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. */ -function bestGrid(n, w, h, captionPx) { +function packedGrid(n, w, h) { let best = { cols: 1, size: 0 }; for (let cols = 1; cols <= n; cols += 1) { const rows = Math.ceil(n / cols); - const byWidth = (w - GAP_PX * (cols - 1)) / cols; - const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx; - const size = Math.floor(Math.min(byWidth, byHeight)); + const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows)); if (size > best.size) best = { cols, size }; } return best; @@ -55,56 +40,232 @@ function bestGrid(n, w, h, captionPx) { export default function create({ root, moduleApi }) { const prefs = () => moduleApi.instanceSettings || {}; - const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []); - const save = (spots) => moduleApi.saveInstanceSettings({ - buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })), - }); - const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`; + const 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 data = new Map(); // spot key → the server's latest word on it - let stream = null; // one stream for every button on the tile + const pictures = new Map(); // spot key → { image, seen } + let page = null; // the server's latest `page` event + let stream = null; let streamPath = ''; let lost = false; - let cells = []; // one { cell, face, img, note, caption } per chosen button, in order - let view = null; - let banner = null; - let pickerEl = null; - let pickerPage = 0; - let pickerPages = []; - let pickerTimer = null; - let pickerToken = 0; + let choosing = false; 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 stopped = false; - /* ── live buttons ─────────────────────────────────────────────────── */ + /* ── status ───────────────────────────────────────────────────────── */ - function syncStream() { - const keys = chosen().map(spotKey); - const path = keys.length ? `/stream/${keys.join('/')}` : ''; + function applyStatus() { + if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen'); + if (lost) return offline('Lost the connection to ProdDash'); + if (!page) return moduleApi.setStatus('connecting', 'Connecting'); + if (page.status === 'error') return offline(page.message || 'Companion is not reachable'); + setOffline(false); + if (page.status !== 'ok') return moduleApi.setStatus('connecting', page.message || 'Connecting to Companion'); + if (!page.found) return moduleApi.setStatus('connecting', 'This page is no longer in Companion'); + if (page.spots.some((s) => !pictures.get(s.key)?.seen)) return moduleApi.setStatus('connecting', 'Waiting for Companion'); + const where = `${page.message} · page ${page.number}`; + return moduleApi.setStatus('ok', page.spots.length === 1 ? where : `${where} · ${page.spots.length} buttons`); + } + + function offline(message) { + setOffline(true); + moduleApi.setStatus('error', message); + } + + function setOffline(on) { + if (!banner || banner.hidden === !on) return; + banner.hidden = !on; + wrap.classList.toggle('is-offline', on); + layout(); // the banner takes room from the buttons + } + + /* ── drawing ──────────────────────────────────────────────────────── */ + + function build() { + root.textContent = ''; + wrap = el('div', 'cb-root'); + banner = el('div', 'cb-banner', 'Companion offline'); + banner.hidden = true; + grid = el('div', 'cb-grid'); + note = el('div', 'cb-empty'); + note.hidden = true; + wrap.append(banner, grid, note); + root.append(wrap); + } + + function showNote(text) { + note.textContent = text; + note.hidden = !text; + grid.hidden = Boolean(text); + } + + /** Lay out the cells for the spots in the latest `page` event. */ + function drawPage() { + const spots = page ? page.spots : []; + wrap.classList.toggle('is-choosing', choosing); + if (!pageId()) return showNote('No page chosen'); + if (!choosing && Array.isArray(chosen()) && !chosen().length) return showNote('No buttons chosen'); + if (page && !page.found) return showNote(''); // the page is gone: draw nothing, the shell folds the tile away + if (page && page.status === 'ok' && !spots.length) return showNote('No buttons on this page'); + showNote(''); + + const keys = new Set(spots.map((s) => s.key)); + for (const [key, c] of cells) { + if (!keys.has(key)) { + c.face.remove(); + cells.delete(key); + } + } + const picked = chosen(); + for (const s of spots) { + let c = cells.get(s.key); + if (!c) { + const face = el('div', 'cb-face is-waiting'); + const img = el('img', 'cb-img'); + img.alt = ''; + img.draggable = false; + img.hidden = true; + face.append(img); + face.addEventListener('click', () => toggle(s.key)); + c = { face, img }; + cells.set(s.key, c); + } + c.face.title = `${page.name} · ${page.number}/${s.row}/${s.column}`; + c.face.classList.toggle('is-off', choosing && picked !== null && !picked.includes(s.key)); + grid.append(c.face); // re-appending keeps them in the server's order + paint(s.key); + } + layout(); + } + + function paint(key) { + const c = cells.get(key); + if (!c) return; + const p = pictures.get(key); + if (p && p.image) { + if (c.img.getAttribute('src') !== p.image) c.img.src = p.image; + c.img.hidden = false; + } else { + c.img.hidden = true; + } + c.face.classList.toggle('is-waiting', !p || !p.seen); + c.face.classList.toggle('is-empty', Boolean(p && p.seen && !p.image)); + } + + function layout() { + if (!grid || !page || !cells.size) return; + const spots = page.spots.filter((s) => cells.has(s.key)); + const w = grid.clientWidth; + const h = grid.clientHeight; + if (keepLayout()) { + // Companion's own rows and columns, cropped to the buttons shown. + const minRow = Math.min(...spots.map((s) => s.row)); + const minCol = Math.min(...spots.map((s) => s.column)); + const rows = Math.max(...spots.map((s) => s.row)) - minRow + 1; + const cols = Math.max(...spots.map((s) => s.column)) - minCol + 1; + const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows)); + grid.style.setProperty('--cb-cols', String(cols)); + grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`); + for (const s of spots) { + const face = cells.get(s.key).face; + face.style.gridRow = String(s.row - minRow + 1); + face.style.gridColumn = String(s.column - minCol + 1); + } + } else { + const { cols, size } = packedGrid(spots.length, w, h); + grid.style.setProperty('--cb-cols', String(cols)); + grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`); + for (const s of spots) { + const face = cells.get(s.key).face; + face.style.gridRow = ''; + face.style.gridColumn = ''; + } + } + } + + /* ── choosing ─────────────────────────────────────────────────────── */ + + function setChoosing(on) { + choosing = on; + pickButton?.classList.toggle('active', on); + connect(); + drawPage(); + } + + /** Put a button in or take it out. Every button in again means the whole page, new buttons included. */ + function toggle(key) { + if (!choosing || !page) return; + const all = page.spots.map((s) => s.key); + const now = chosen() || all; + const next = now.includes(key) ? now.filter((k) => k !== key) : [...now, key]; + const whole = all.every((k) => next.includes(k)); + moduleApi.saveInstanceSettings({ buttons: whole ? null : next.slice(0, MAX_SPOTS) }); + drawPage(); + } + + /* ── the stream ───────────────────────────────────────────────────── */ + + function apply(kind, body) { + if (kind === 'page') { + page = body; + lost = false; + drawPage(); + } else { + pictures.set(body.key, { image: body.image || null, seen: Boolean(body.seen) }); + paint(body.key); + } + applyStatus(); + } + + /** Open the stream for what the tile shows now: the whole page while choosing, else the chosen buttons. */ + function connect() { + const id = pageId(); + const keys = choosing ? null : chosen(); + const path = !id || (keys && !keys.length) ? '' : `${id}${keys ? `/${keys.join('/')}` : ''}`; if (path === streamPath) return; if (stream) stream.close(); stream = null; streamPath = path; - lost = false; - for (const key of [...data.keys()]) if (!keys.includes(key)) data.delete(key); if (!path) return; - stream = moduleApi.sse(path, { + stream = moduleApi.sse(`/stream/${path}`, { + // The page's connection may already carry this stream (another tile, or + // this one before a remount): ask for what it sent on connect. + async open() { + try { + const res = await moduleApi.fetch(`/state/${path}`); + if (!res.ok || path !== streamPath) return; + const snap = await res.json(); + if (path !== streamPath) return; + apply('page', snap.page); + for (const b of snap.buttons) apply('button', b); + } catch { + /* the stream itself will catch up */ + } + }, events: { - button(e) { - let msg; + page(e) { try { - msg = JSON.parse(e.data); + apply('page', JSON.parse(e.data)); } catch { - return; + /* 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() { @@ -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 ────────────────────────────────────────────────────── */ return { start() { - stopped = false; - moduleApi.setStatus('connecting', 'Connecting'); - pickButton = moduleApi.header.addButton({ - icon: GRID_SVG, - title: 'Choose buttons', - onClick: () => (pickerEl ? closePicker() : openPicker()), - }); - observer = new ResizeObserver(() => layout()); - observer.observe(root); - syncStream(); - if (chosen().length) { - buildView(); - applyStatus(); - } else { - openPicker(); + build(); + if (pageId()) { + pickButton = moduleApi.header.addButton({ + icon: GRID_SVG, + title: 'Choose buttons', + onClick: () => setChoosing(!choosing), + }); } + observer = new ResizeObserver(() => layout()); + observer.observe(grid); + drawPage(); + applyStatus(); + connect(); }, stop() { - stopped = true; - clearTimeout(pickerTimer); - pickerToken += 1; if (stream) stream.close(); stream = null; streamPath = ''; - data.clear(); if (observer) observer.disconnect(); observer = null; - pickerEl = null; - view = null; - cells = []; + pictures.clear(); + cells = new Map(); + page = null; root.textContent = ''; }, }; diff --git a/companion.js b/companion.js index d4afb47..1f42652 100644 --- a/companion.js +++ b/companion.js @@ -8,8 +8,9 @@ * * pages.watch → every page, its name, and which control * sits at each row/column - * preview.graphics.controlId → a button's rendered image, following the - * button if it is moved to another page + * 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) * * 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); - 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); } @@ -266,17 +267,16 @@ function createPageBook() { return 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; + /** 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; } return { apply, list, locate, - at, + page, get loaded() { return order.length > 0; }, @@ -303,4 +303,7 @@ function pageName(raw, number) { 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 }; diff --git a/module.json b/module.json index 9818ee4..6f09e19 100644 --- a/module.json +++ b/module.json @@ -1,9 +1,9 @@ { "id": "companion-buttons", "name": "Companion Buttons", - "version": "1.0.0", + "version": "1.1.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": [ { "kind": "local-network", @@ -14,7 +14,7 @@ "server": "server.js", "style": "style.css", "minSize": { "w": 1, "h": 1 }, - "defaultSize": { "w": 3, "h": 3 }, + "defaultSize": { "w": 6, "h": 4 }, "configSchema": { "companion": { "type": "endpoint", @@ -24,10 +24,16 @@ } }, "instanceSchema": { - "captions": { + "layout": { "type": "switch", - "label": "Captions", - "default": false + "label": "Page layout", + "default": true + }, + "page": { + "type": "select", + "label": "Page", + "default": "", + "optionsRoute": "/page-options" } } } diff --git a/server.js b/server.js index d1c7ba9..fd8e131 100644 --- a/server.js +++ b/server.js @@ -3,40 +3,41 @@ /** * Companion Buttons — server part. * - * 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. + * 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. * - * GET /pages → every page and the buttons on it (the tile's picker) - * GET /image/ → one button's current picture, as PNG (picker thumbnails) - * GET /stream///… → SSE: `button` for each of those buttons on connect, - * then again whenever one redraws or moves + * 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). * - * 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. + * 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 * - * 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. + * 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. * * 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 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})$/; +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})$/; let current = null; @@ -50,37 +51,89 @@ function sendJson(res, code, body) { res.end(JSON.stringify(body)); } -/** "/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; +/** "/" → { 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)] }; } module.exports = { init({ config, log }) { const { host, port } = endpointOf(config.companion); const pages = createPageBook(); - const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off } - const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] } + 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 } 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" }; - /* — 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) { let b = buttons.get(controlId); 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); if (link) { b.off = link.subscribe( @@ -91,8 +144,6 @@ 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); }, @@ -105,54 +156,63 @@ 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 || b.waiters.size) return; + if (!b || b.refs > 0) return; clearTimeout(b.linger); b.linger = setTimeout(() => { - if (b.refs > 0 || b.waiters.size) return; + if (b.refs > 0) return; if (b.off) b.off(); buttons.delete(controlId); }, LINGER_MS); } - /** 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); + /* — 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 }; + }); } - 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; - } - - /* — 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, + function pageBody(spots, page) { + return { status: status.state, message: status.message, - image: b ? b.image : null, - // A spot with nothing there is as answered as it will get. - seen: b ? b.seen : pages.loaded, - location, + found: Boolean(page) || !pages.loaded, + name: page ? page.name : '', + number: page ? page.number : 0, + 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) { @@ -163,59 +223,73 @@ module.exports = { } } - function sendButton(controlId) { - for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot)); + /** 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 sendAll() { - for (const v of viewers) { - for (const spot of v.spots) { - point(spot); - write(v, frame(spot)); - } - } + 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)); } function drop(viewer) { if (!viewers.delete(viewer)) return; - for (const spot of viewer.spots) { - if (!spot.target) continue; - const b = buttons.get(spot.target); + for (const s of viewer.spots) { + if (!s.target) continue; + const b = buttons.get(s.target); if (b) b.refs -= 1; - release(spot.target); + release(s.target); } } - function addViewer(res, spots) { - const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) }; + function addViewer(res, view) { + const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' }; viewers.add(viewer); - for (const spot of viewer.spots) { - point(spot); - write(viewer, frame(spot)); - } + refresh(viewer, true); 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 (change && change.type === 'init') { + if (fresh) { 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 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(); + learnAll(); }); } @@ -223,16 +297,31 @@ 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, - linked: Boolean(link), + snapshot, + tileList, }; const self = current; @@ -247,72 +336,41 @@ module.exports = { } } viewers.clear(); - for (const b of buttons.values()) { - clearTimeout(b.linger); - for (const done of b.waiters) done(); - } + for (const b of buttons.values()) clearTimeout(b.linger); buttons.clear(); if (link) link.close(); if (current === self) current = null; }, health() { 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'}` : ''; return { status: status.state, message: status.message + suffix }; }, }; }, + tiles() { + return current ? current.tileList() : []; + }, + routes() { return { - 'GET /pages': (req, res) => { + 'GET /page-options': (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() }); + return sendJson(res, 200, { options: current.pages.list().map((p) => ({ value: p.id, label: p.name })) }); }, - '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 /state/*': (req, res) => { + const view = viewFrom(req); + if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); + if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' }); + return sendJson(res, 200, current.snapshot(view)); }, 'GET /stream/*': (req, res) => { - const spots = spotsFrom(req); - if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' }); + const view = viewFrom(req); + if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', @@ -321,14 +379,12 @@ module.exports = { }); res.write('retry: 3000\n\n'); if (!current) { - 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`); - } + 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`); return void res.end(); } const app = current; - const viewer = app.addViewer(res, spots); + const viewer = app.addViewer(res, view); try { req.socket.setKeepAlive(true, HEARTBEAT_MS); } catch { diff --git a/style.css b/style.css index ff1ca4e..2f9d663 100644 --- a/style.css +++ b/style.css @@ -11,25 +11,18 @@ 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: max-content; + grid-auto-rows: var(--cb-size, 64px); gap: 6px; align-content: center; justify-content: center; } -[data-module="companion-buttons"] .cb-cell { - display: flex; - flex-direction: column; - align-items: center; - min-width: 0; -} +[data-module="companion-buttons"] .cb-grid[hidden] { display: none; } [data-module="companion-buttons"] .cb-face { position: relative; @@ -57,45 +50,15 @@ 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; 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-grid.is-offline .cb-face { +[data-module="companion-buttons"] .cb-root.is-offline .cb-face { opacity: 0.3; filter: grayscale(1); } @@ -116,139 +79,29 @@ [data-module="companion-buttons"] .cb-banner[hidden] { display: none; } -/* ── nothing chosen ─────────────────────────────────────────────────── */ - [data-module="companion-buttons"] .cb-empty { flex: 1; display: flex; - flex-direction: column; align-items: center; justify-content: center; - gap: 10px; - text-align: center; -} - -[data-module="companion-buttons"] .cb-empty-line { color: var(--muted); 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; } -[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-empty[hidden] { display: none; } -[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); +/* 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 { cursor: pointer; } -[data-module="companion-buttons"] .cb-pick-img { - display: block; - 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); +[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-key.is-picked { - outline: 3px solid var(--accent); - outline-offset: -3px; +[data-module="companion-buttons"] .cb-root.is-choosing .cb-face.is-off { + opacity: 0.25; + 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; } diff --git a/test/companion-mock.js b/test/companion-mock.js index 5e49b12..a69f01e 100644 --- a/test/companion-mock.js +++ b/test/companion-mock.js @@ -8,9 +8,10 @@ * * 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 - * 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 + * 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 * 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, @@ -123,9 +124,22 @@ 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' } } }, + 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' } } }, - 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 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' } }); 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}"` } }); } @@ -276,6 +298,7 @@ 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 2573c1d..7f6c123 100644 --- a/test/companion.test.js +++ b/test/companion.test.js @@ -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) { - const events = []; + const pages = []; + const buttons = []; let req; const ready = new Promise((resolve) => { req = http.get({ host: '127.0.0.1', port, path }, (res) => { @@ -78,13 +79,23 @@ function listen(port, path) { buf = buf.slice(at + 2); const name = /^event: (.+)$/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(); }); }); - 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 ────────────────────────────────────────────────────────── */ @@ -135,6 +146,8 @@ 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 ─────────────────────────────────────────────────── */ @@ -152,92 +165,91 @@ async function testLive() { await until('Companion connected', () => handle.health().status === 'ok'); assert.match(handle.health().message, /3 pages/); - // 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' }, + // 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 }, ]); + 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 - 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); + // 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); - // 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; + // 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; await sleep(1600); - 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' }); + assert.strictEqual(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, 'identical redraws must not be re-sent'); // a status button that changes is forwarded - 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); + const firstLook = whole.pic('0.0').image; + await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000); - // 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); + // 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']); - // 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 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(); - // 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(); + // 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(); - // 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({ type: 'update', updatedOrder: null, added: [], - changes: [ - { id: 'p1', name: null, controls: [{ row: 0, column: 1, controlId: null }] }, - { id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:steady' }] }, - ], + changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }], }); - await until('moved location', () => steady.last().location && steady.last().location.page === 2); - assert.strictEqual(steady.last().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); - // 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(); + // 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 }) }; // Companion drops: streams say so, then recover by themselves mock.dropAll(); @@ -254,9 +266,7 @@ 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 - steady.close(); - flip.close(); - gone.close(); + whole.close(); await sleep(100); assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers'); } finally { @@ -275,14 +285,12 @@ async function testUnconfigured() { try { assert.strictEqual(handle.health().status, 'error'); assert.match(handle.health().message, /admin/); - 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'); + assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []); + assert.deepStrictEqual(serverEntry.tiles(), []); + const s = listen(at, '/stream/p1'); await s.ready; - await until('stream answers', () => s.last()); - assert.strictEqual(s.last().status, 'error'); + await until('stream answers', () => s.page()); + assert.strictEqual(s.page().status, 'error'); s.close(); } finally { handle.stop();