2026-10-07 14:00:37 -04:00
|
|
|
/* 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';
|
2026-10-11 10:25:02 -04:00
|
|
|
if (!of.configured) return 'No Overflow login — add it in /admin';
|
|
|
|
|
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
|
2026-10-07 14:00:37 -04:00
|
|
|
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
|
2026-10-11 10:25:02 -04:00
|
|
|
// moved — or, when the switch failed, what went wrong, in their place
|
|
|
|
|
// (a tile has room for one or the other).
|
2026-10-07 14:00:37 -04:00
|
|
|
let label = `TAP IS ON · since ${clock(cur.at)}`;
|
|
|
|
|
let tone = 'ok';
|
|
|
|
|
let note = '';
|
|
|
|
|
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 || '';
|
|
|
|
|
}
|
|
|
|
|
main.dataset.tone = tone;
|
|
|
|
|
main.append(el('div', 'ot-label', label));
|
|
|
|
|
main.append(el('div', 'ot-keyword', cur.keyword));
|
2026-10-11 10:25:02 -04:00
|
|
|
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
|
|
|
|
|
if (note) main.append(el('div', 'ot-error', note));
|
2026-10-07 14:00:37 -04:00
|
|
|
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 || {};
|
2026-10-11 10:25:02 -04:00
|
|
|
const ann = state.announcement || {};
|
|
|
|
|
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
|
|
|
|
|
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
|
2026-10-07 14:00:37 -04:00
|
|
|
let text;
|
|
|
|
|
let cls = 'ot-slide';
|
|
|
|
|
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
|
2026-10-11 10:25:02 -04:00
|
|
|
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
|
|
|
|
|
else if (slideKeys.length || annKeys.length) {
|
|
|
|
|
const parts = [];
|
|
|
|
|
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
|
|
|
|
|
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
|
|
|
|
|
text = parts.join(' · ');
|
|
|
|
|
cls += ' is-hit';
|
|
|
|
|
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
|
|
|
|
|
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
|
2026-10-07 14:00:37 -04:00
|
|
|
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
|
2026-10-11 10:25:02 -04:00
|
|
|
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
|
2026-10-07 14:00:37 -04:00
|
|
|
: `${h.keyword}: ${h.error || 'failed'}`;
|
|
|
|
|
row.append(el('span', 'ot-h-time', clock(h.at)));
|
|
|
|
|
row.append(el('span', 'ot-h-kw', h.keyword));
|
2026-10-11 10:25:02 -04:00
|
|
|
row.append(el('span', 'ot-h-dest', h.destination || ''));
|
2026-10-07 14:00:37 -04:00
|
|
|
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) {
|
2026-10-11 10:25:02 -04:00
|
|
|
let why = `Keyword refresh failed (${res.status})`;
|
2026-10-07 14:00:37 -04:00
|
|
|
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
|
|
|
|
|
moduleApi.setStatus('error', why);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-10-11 10:25:02 -04:00
|
|
|
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
|
2026-10-07 14:00:37 -04:00
|
|
|
} finally {
|
|
|
|
|
refreshBtn.classList.remove('active');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
start() {
|
|
|
|
|
moduleApi.setStatus('connecting', 'Connecting');
|
|
|
|
|
refreshBtn = moduleApi.header.addButton({
|
|
|
|
|
icon: ICON_REFRESH,
|
2026-10-11 10:25:02 -04:00
|
|
|
title: 'Refresh keywords',
|
2026-10-07 14:00:37 -04:00
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|