/* 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 login — add it in /admin'; if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet'; 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, 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 = ''; 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)); if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || '')); if (note) main.append(el('div', 'ot-error', 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 || {}; const ann = state.announcement || {}; const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : []; const annKeys = Array.isArray(ann.keywords) ? ann.keywords : []; let text; let cls = 'ot-slide'; if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; } 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'; } 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.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 || '')); 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 = `Keyword refresh failed (${res.status})`; try { why = (await res.json()).error || why; } catch { /* keep the status */ } moduleApi.setStatus('error', why); } } catch (err) { moduleApi.setStatus('error', `Keyword 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: 'Refresh keywords', 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(); }, }; }