/* 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 = ''; 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(); }, }; }