/* Resi Cues — the tile. * * One number matters: how long until the next cue. It is drawn as large as the * tile allows, with the cue's name above it, because on a wall the operator reads * it from across the booth. * * The countdown ticks locally. The server sends the playhead with the instant it * was true (`estMs` at `estAt`) and whether playback is running; this adds the * elapsed time since. That is what keeps the digits moving every second while * Resi itself only reports every few seconds, and it means a slow poll never * shows as a stalled clock. */ const TICK_MS = 250; // redraw faster than a second so the digit turns on time export default function create({ root, moduleApi }) { let stream = null; let ticker = null; let state = null; const settings = () => moduleApi.instanceSettings; function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function hms(ms) { const total = Math.max(0, Math.round(ms / 1000)); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const pad = (n) => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } /** Where the playhead is right now, extrapolated from the last server word. */ function playheadMs() { if (!state || state.estMs === null || state.estMs === undefined) return null; return state.estMs + (state.playing ? Date.now() - state.estAt : 0); } function render() { root.textContent = ''; const set = settings(); if (!state || state.status === 'error') { const box = el('div', 'resi-empty'); box.append(el('div', 'resi-empty-line', (state && state.message) || 'Not connected')); root.append(box); return; } const here = playheadMs(); const wrap = el('div', 'resi-wrap'); if (state.next) { if (set.showCueName !== false) wrap.append(el('div', 'resi-label', 'NEXT')); if (set.showCueName !== false) wrap.append(el('div', 'resi-cue', state.next.name || 'Next cue')); const remaining = here === null ? null : state.next.ms - here; const count = el('div', 'resi-count', remaining === null ? '—' : hms(remaining)); if (remaining !== null && remaining <= 30000) count.classList.add('is-soon'); if (!state.playing) count.classList.add('is-held'); wrap.append(count); } else { wrap.append(el('div', 'resi-label', state.event ? 'NO CUES LEFT' : 'NOTHING PLAYING')); wrap.append(el('div', 'resi-cue', state.event || state.decoder || '')); } const foot = el('div', 'resi-foot'); if (set.showPrevious !== false && state.prev) { foot.append(el('span', 'resi-foot-item', `after ${state.prev.name}${here === null ? '' : ` · ${hms(here - state.prev.ms)} ago`}`)); } if (set.showPosition !== false && here !== null) { foot.append(el('span', 'resi-foot-item', `playhead ${hms(here)}`)); } if (!state.playing && state.estMs !== null) foot.append(el('span', 'resi-foot-item is-held', 'paused')); if (foot.childElementCount) wrap.append(foot); if (set.showCueList && state.cues && state.cues.length) { const list = el('ul', 'resi-list'); for (const cue of state.cues) { const row = el('li', 'resi-list-row'); if (here !== null && cue.ms <= here) row.classList.add('is-past'); if (state.next && cue.ms === state.next.ms) row.classList.add('is-next'); row.append(el('span', 'resi-list-name', cue.name)); row.append(el('span', 'resi-list-at', hms(cue.ms))); list.append(row); } wrap.append(list); } root.append(wrap); } function applyStatus() { if (!state) return moduleApi.setStatus('connecting', 'Waiting for the server'); if (state.status !== 'ok') return moduleApi.setStatus('error', state.message || 'Not connected'); if (!state.playing) return moduleApi.setStatus('connecting', `Playback stopped — ${state.decoder}`); const cadence = state.cadenceMs ? ` · Resi updates every ${Math.round(state.cadenceMs / 1000)}s` : ''; return moduleApi.setStatus('ok', `${state.decoder}${cadence}`); } return { start() { moduleApi.setStatus('connecting', 'Connecting'); stream = moduleApi.sse('/stream', { state: (data) => { try { state = JSON.parse(data); } catch { return; } applyStatus(); render(); }, error: () => moduleApi.setStatus('error', 'Lost the connection to ProdDash'), }); ticker = setInterval(render, TICK_MS); render(); }, stop() { if (stream) stream.close(); if (ticker) clearInterval(ticker); stream = null; ticker = null; root.textContent = ''; }, onConfigChange() { render(); }, }; }