Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes
A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword Tap is on (and since when), its
destination and the groups moved, a chip saying whether the slide on screen
carries a keyword, and the last four switches. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.
Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.
The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.
Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c9e03398a6
commit
6b51044be6
@@ -0,0 +1,231 @@
|
||||
/* Overflow Tap — the tile.
|
||||
*
|
||||
* One fact matters across a booth: which keyword Tap is on right now, and
|
||||
* whether the switch actually landed. That is the big line. Under it, the
|
||||
* destination it maps to and the groups it moved; a small line says whether
|
||||
* the slide on screen carries a keyword at all; and the last four switches
|
||||
* sit at the bottom with their times, so a tech can see the service's trail.
|
||||
*
|
||||
* Everything comes from the server's `state` stream. The tile never talks to
|
||||
* ProPresenter or Overflow itself.
|
||||
*/
|
||||
|
||||
const HISTORY_SHOWN = 4;
|
||||
|
||||
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5v3h-3"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let stream = null;
|
||||
let refreshBtn = null;
|
||||
let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state
|
||||
|
||||
const settings = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => settings()[key] !== false;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function clock(ms) {
|
||||
if (!ms) return '';
|
||||
try {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-line reason the tile cannot do its job, or '' when it can. */
|
||||
function blocker() {
|
||||
if (!state) return 'Waiting for ProdDash';
|
||||
const pp = state.propresenter || {};
|
||||
const of = state.overflow || {};
|
||||
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
|
||||
if (!of.configured) return 'No Overflow API key — add it in /admin';
|
||||
if (!(state.keywords || []).length) return 'No keywords — add them in /admin';
|
||||
return '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
root.textContent = '';
|
||||
const box = el('div', 'ot-root');
|
||||
root.append(box);
|
||||
|
||||
const stop = blocker();
|
||||
if (stop) {
|
||||
box.append(el('div', 'ot-empty', stop));
|
||||
return;
|
||||
}
|
||||
|
||||
const pp = state.propresenter || {};
|
||||
const of = state.overflow || {};
|
||||
const cur = state.current;
|
||||
const offline = streamDown || !pp.reachable || !of.reachable;
|
||||
|
||||
if (offline) {
|
||||
box.classList.add('is-offline');
|
||||
const why = streamDown ? 'ProdDash connection lost'
|
||||
: !of.reachable ? (of.error || 'Overflow unreachable')
|
||||
: (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable');
|
||||
box.append(el('div', 'ot-offline', why));
|
||||
}
|
||||
|
||||
const main = el('div', 'ot-main');
|
||||
if (cur) {
|
||||
// The label carries the state and, when there is one, the moment it
|
||||
// became true; the small lines under the destination are the groups
|
||||
// moved — or, when the switch failed or was undone in Overflow, what
|
||||
// went wrong, in their place (a tile has room for one or the other).
|
||||
let label = `TAP IS ON · since ${clock(cur.at)}`;
|
||||
let tone = 'ok';
|
||||
let note = '';
|
||||
let noteClass = 'ot-error';
|
||||
if (cur.pending) {
|
||||
label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING';
|
||||
tone = 'muted';
|
||||
} else if (!cur.ok) {
|
||||
label = `NOT SWITCHED · ${clock(cur.at)}`;
|
||||
tone = 'alert';
|
||||
note = cur.error || '';
|
||||
} else if (cur.drift) {
|
||||
label = `TAP WAS ON · since ${clock(cur.at)}`;
|
||||
tone = 'warn';
|
||||
note = cur.drift;
|
||||
noteClass = 'ot-error is-warn';
|
||||
}
|
||||
main.dataset.tone = tone;
|
||||
main.append(el('div', 'ot-label', label));
|
||||
main.append(el('div', 'ot-keyword', cur.keyword));
|
||||
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : ''));
|
||||
if (note) main.append(el('div', noteClass, note));
|
||||
else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
|
||||
} else {
|
||||
main.dataset.tone = 'muted';
|
||||
main.append(el('div', 'ot-label', 'NO KEYWORD YET'));
|
||||
main.append(el('div', 'ot-keyword is-none', '—'));
|
||||
main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it'));
|
||||
}
|
||||
box.append(main);
|
||||
|
||||
if (on('showSlide')) {
|
||||
const slide = state.slide || {};
|
||||
let text;
|
||||
let cls = 'ot-slide';
|
||||
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
|
||||
else if (!slide.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
|
||||
else if (slide.unmapped) { text = `This slide: ${slide.keyword} — ${slide.unmapped}`; cls += ' is-warn'; }
|
||||
else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; }
|
||||
else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
|
||||
box.append(el('div', cls, text));
|
||||
}
|
||||
|
||||
if (on('showHistory')) {
|
||||
const rows = (state.history || []).slice(0, HISTORY_SHOWN);
|
||||
const list = el('ol', 'ot-history');
|
||||
if (!rows.length) {
|
||||
list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet'));
|
||||
}
|
||||
for (const h of rows) {
|
||||
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
|
||||
row.title = h.ok
|
||||
? `${h.keyword} → ${h.destination ? h.destination.name : ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
|
||||
: `${h.keyword}: ${h.error || 'failed'}`;
|
||||
row.append(el('span', 'ot-h-time', clock(h.at)));
|
||||
row.append(el('span', 'ot-h-kw', h.keyword));
|
||||
row.append(el('span', 'ot-h-dest', h.destination ? h.destination.name : ''));
|
||||
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
|
||||
list.append(row);
|
||||
}
|
||||
box.append(list);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash');
|
||||
const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting';
|
||||
moduleApi.setStatus(s, state.message || '');
|
||||
}
|
||||
|
||||
function handleState(e) {
|
||||
try {
|
||||
state = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
streamDown = false;
|
||||
applyStatus();
|
||||
render();
|
||||
}
|
||||
|
||||
async function backfill() {
|
||||
try {
|
||||
const res = await moduleApi.fetch('/state');
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
if (!body || !body.state || !stream) return;
|
||||
state = body.state;
|
||||
streamDown = false;
|
||||
applyStatus();
|
||||
render();
|
||||
} catch {
|
||||
/* the stream will bring the next state */
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!refreshBtn) return;
|
||||
refreshBtn.classList.add('active');
|
||||
try {
|
||||
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
let why = `Overflow refresh failed (${res.status})`;
|
||||
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
|
||||
moduleApi.setStatus('error', why);
|
||||
}
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Overflow refresh failed: ${err && err.message ? err.message : err}`);
|
||||
} finally {
|
||||
refreshBtn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting');
|
||||
refreshBtn = moduleApi.header.addButton({
|
||||
icon: ICON_REFRESH,
|
||||
title: 'Reload Overflow',
|
||||
onClick: refresh,
|
||||
});
|
||||
stream = moduleApi.sse('/stream', {
|
||||
// A fresh connection replays the state; a tile joining a stream the
|
||||
// page already carries gets only `open`, and the server sends state
|
||||
// on change alone — so backfill, or a second tile waits for the next
|
||||
// switch to draw anything.
|
||||
open() { backfill(); },
|
||||
error() {
|
||||
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
|
||||
streamDown = true;
|
||||
render();
|
||||
},
|
||||
events: { state: handleState },
|
||||
});
|
||||
render();
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (stream) stream.close();
|
||||
stream = null;
|
||||
refreshBtn = null;
|
||||
root.textContent = '';
|
||||
},
|
||||
|
||||
onConfigChange() {
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user