/* Companion Buttons — the tile. 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). 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 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); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } /** The column count that makes `n` square cells biggest inside w × h. */ function bestGrid(n, w, h, captionPx) { let best = { cols: 1, size: 0 }; for (let cols = 1; cols <= n; cols += 1) { const rows = Math.ceil(n / cols); const byWidth = (w - GAP_PX * (cols - 1)) / cols; const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx; const size = Math.floor(Math.min(byWidth, byHeight)); if (size > best.size) best = { cols, size }; } return best; } export default function create({ root, moduleApi }) { const prefs = () => moduleApi.instanceSettings || {}; const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []); const save = (spots) => moduleApi.saveInstanceSettings({ buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })), }); const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`; // 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 cells = []; // one { cell, face, img, note, caption } per chosen button, in order let view = null; let banner = null; 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; /* ── live buttons ─────────────────────────────────────────────────── */ 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); } 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 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 || stopped) return; for (const msg of snap.buttons || []) if (msg.seen || !data.has(msg.key)) onButton(msg); } catch { /* the stream itself catches up on the next redraw */ } }, events: { button(e) { try { onButton(JSON.parse(e.data)); } catch { /* not JSON */ } }, }, error() { lost = true; applyStatus(); }, }); } /** Save what the server resolved a button to — a new id after a re-import, a new place after a move. */ function adopt(index, msg) { const where = msg.location; if (!msg.controlId || !where) return; const spots = chosen(); const s = spots[index]; if (s.id === msg.controlId && s.page === where.page && s.row === where.row && s.column === where.column) return; if (spots.some((o, i) => i !== index && o.id === msg.controlId)) return; // already shown here spots[index] = { id: msg.controlId, page: where.page, row: where.row, column: where.column }; data.set(spotKey(spots[index]), msg); // keep the picture up while the stream is re-made save(spots); syncStream(); } function applyStatus() { const spots = chosen(); if (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() { 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(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; chooser = null; view = null; cells = []; root.textContent = ''; }, }; }