139 lines
4.9 KiB
JavaScript
139 lines
4.9 KiB
JavaScript
/* 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();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|