Merge pull request #3 from michaelmcgary2008/claude/propresenter-timers-module
Timers module: live ProPresenter timers and LTC timecode
This commit is contained in:
@@ -87,13 +87,20 @@ are written by the app itself.
|
|||||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||||
Admin config: host, port, password.
|
Admin config: host, port, password.
|
||||||
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
|
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||||
|
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||||
|
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
|
unmistakably red. Read-only — no start/stop controls. Admin config: host,
|
||||||
|
port, password.
|
||||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
To develop modules against mocks instead of real gear:
|
To develop modules against mocks instead of real gear:
|
||||||
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`) and
|
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
||||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter on
|
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
||||||
`127.0.0.1:1599`). See the testing section of the
|
`127.0.0.1:1599`) and `node tools/pp-timers-mock.js 1600` (ProPresenter
|
||||||
|
timers + timecode on `127.0.0.1:1600`). See the testing section of the
|
||||||
[module guide](docs/MODULE-GUIDE.md).
|
[module guide](docs/MODULE-GUIDE.md).
|
||||||
|
|
||||||
## Install as a desktop web app
|
## Install as a desktop web app
|
||||||
|
|||||||
@@ -429,6 +429,10 @@ client-side failures (a tile that fails to start says so in the tile).
|
|||||||
- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists
|
- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists
|
||||||
and an advancing slide index (including the arrangement-repeat case).
|
and an advancing slide index (including the arrangement-repeat case).
|
||||||
Point the Now/Next module at host `127.0.0.1`, port `1599`.
|
Point the Now/Next module at host `127.0.0.1`, port `1599`.
|
||||||
|
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
|
||||||
|
overrunning, stopped and elapsed timers plus an LTC timecode that
|
||||||
|
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
||||||
|
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
|
||||||
|
|
||||||
**The resilience checklist** — every module must pass this before it's done:
|
**The resilience checklist** — every module must pass this before it's done:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
/* ProPresenter Timers — client part.
|
||||||
|
|
||||||
|
The server part does all the ProPresenter talking and streams one merged
|
||||||
|
state object here over SSE. Each tile renders whichever timers (and/or the
|
||||||
|
LTC timecode) it has enabled, as auto-sized cards in a responsive grid.
|
||||||
|
Which items a tile shows is per-tile state, picked in a "Timers ▾" header
|
||||||
|
menu and persisted with the layout — the timer list is dynamic, so a
|
||||||
|
static instanceSchema field can't list them (same approach as the ProdCom
|
||||||
|
module's channels menu). Timers this tile has never seen default to
|
||||||
|
enabled: only explicit un-ticks are stored. */
|
||||||
|
|
||||||
|
const LTC_KEY = '__ltc__';
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let state = null;
|
||||||
|
let feedOffline = false;
|
||||||
|
let stream = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
let hiddenItems = new Set(moduleApi.instanceSettings.hiddenItems || []);
|
||||||
|
|
||||||
|
// Which picker entry this tile was added as (the module's tiles() list):
|
||||||
|
// '' or 'all' = every item, selectable via the Timers ▾ menu;
|
||||||
|
// 'timer:<uuid>' or 'ltc' = exactly one card, no menu.
|
||||||
|
const variant = String(moduleApi.variant || '');
|
||||||
|
const soloKey = variant === 'ltc' ? LTC_KEY
|
||||||
|
: variant.startsWith('timer:') ? variant.slice('timer:'.length)
|
||||||
|
: '';
|
||||||
|
/** Last name seen for a solo timer, so its card stays labeled if the
|
||||||
|
timer is later deleted in ProPresenter. */
|
||||||
|
let lastSoloName = '';
|
||||||
|
|
||||||
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="tm-wrap">
|
||||||
|
<div class="tm-banner" hidden></div>
|
||||||
|
<div class="tm-hint" hidden></div>
|
||||||
|
<div class="tm-grid"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const wrap = root.querySelector('.tm-wrap');
|
||||||
|
const bannerEl = root.querySelector('.tm-banner');
|
||||||
|
const hintEl = root.querySelector('.tm-hint');
|
||||||
|
const gridEl = root.querySelector('.tm-grid');
|
||||||
|
|
||||||
|
/** key (uuid or LTC_KEY) → { el, nameEl, timeEl, stateEl } */
|
||||||
|
const cards = new Map();
|
||||||
|
|
||||||
|
/* ── title-bar menu: pick which timers / LTC this tile shows ────── */
|
||||||
|
|
||||||
|
let menuEl = null; // the open menu's element (filled on each open)
|
||||||
|
const itemsMenu = soloKey ? null : moduleApi.header.addMenu({
|
||||||
|
label: 'Timers ▾',
|
||||||
|
title: 'Choose which timers this tile shows',
|
||||||
|
build(menu) {
|
||||||
|
menu.classList.add('tm-menu');
|
||||||
|
menuEl = menu;
|
||||||
|
rebuildMenu();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function menuRows() {
|
||||||
|
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name }));
|
||||||
|
rows.push({ key: LTC_KEY, name: 'LTC timecode' });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistHidden() {
|
||||||
|
moduleApi.saveInstanceSettings({ hiddenItems: [...hiddenItems] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleItem(key) {
|
||||||
|
if (hiddenItems.has(key)) hiddenItems.delete(key);
|
||||||
|
else hiddenItems.add(key);
|
||||||
|
persistHidden();
|
||||||
|
rebuildMenu();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMenuLabel() {
|
||||||
|
if (!itemsMenu) return;
|
||||||
|
const rows = menuRows();
|
||||||
|
const visible = rows.filter((r) => !hiddenItems.has(r.key)).length;
|
||||||
|
itemsMenu.setLabel(visible === rows.length
|
||||||
|
? 'Timers ▾'
|
||||||
|
: `Timers (${visible}/${rows.length}) ▾`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMenu() {
|
||||||
|
updateMenuLabel();
|
||||||
|
if (!menuEl) return; // menu not opened yet — the label is enough
|
||||||
|
menuEl.innerHTML = '';
|
||||||
|
const rows = menuRows();
|
||||||
|
if (rows.length === 1) {
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'tm-menu-note';
|
||||||
|
note.textContent = 'No timers discovered yet';
|
||||||
|
menuEl.appendChild(note);
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
const on = !hiddenItems.has(row.key);
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'tm-menu-item' + (on ? ' on' : '');
|
||||||
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = row.name;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
// keep the menu open: the rebuild detaches this item, which would
|
||||||
|
// make the outside-click handler think we clicked outside
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleItem(row.key);
|
||||||
|
});
|
||||||
|
menuEl.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── state → cards ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Map the API's state string onto our four looks. The spec's enum is
|
||||||
|
stopped|running|complete|overrunning|overran, but its own response
|
||||||
|
example says "overrun" — match the substring. */
|
||||||
|
function classify(apiState) {
|
||||||
|
const s = String(apiState || '').toLowerCase();
|
||||||
|
if (s.includes('overr')) return 'overrun';
|
||||||
|
if (s === 'running') return 'running';
|
||||||
|
if (s === 'complete') return 'complete';
|
||||||
|
return 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_TEXT = { running: 'Running', stopped: 'Stopped', complete: 'Complete', overrun: 'Overrun' };
|
||||||
|
|
||||||
|
/** Show the value the way ProPresenter reports it (sign included);
|
||||||
|
only the ".00" hundredths some builds append are dropped for fit. */
|
||||||
|
function displayTime(raw) {
|
||||||
|
const s = String(raw || '').replace(/\.\d+$/, '');
|
||||||
|
return s || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleItems() {
|
||||||
|
const ltcState = state?.ltc || { supported: null, time: '', receiving: false };
|
||||||
|
if (soloKey === LTC_KEY) return [{ key: LTC_KEY, ltc: ltcState }];
|
||||||
|
if (soloKey) {
|
||||||
|
const timer = (state?.timers || []).find((t) => t.uuid === soloKey);
|
||||||
|
if (timer) {
|
||||||
|
lastSoloName = timer.name;
|
||||||
|
return [{ key: soloKey, timer }];
|
||||||
|
}
|
||||||
|
// The timer this tile was added for isn't in ProPresenter's list (yet,
|
||||||
|
// or any more) — keep a clearly-labeled card up rather than erroring.
|
||||||
|
return [{ key: soloKey, missing: true }];
|
||||||
|
}
|
||||||
|
const items = [];
|
||||||
|
for (const t of state?.timers || []) {
|
||||||
|
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
|
||||||
|
}
|
||||||
|
if (!hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCard(item) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'tm-card' + (item.ltc ? ' tm-card-ltc' : '');
|
||||||
|
const nameEl = document.createElement('div');
|
||||||
|
nameEl.className = 'tm-name';
|
||||||
|
const timeEl = document.createElement('div');
|
||||||
|
timeEl.className = 'tm-time';
|
||||||
|
const stateEl = document.createElement('div');
|
||||||
|
stateEl.className = 'tm-state';
|
||||||
|
if (item.ltc) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'tm-ltc-badge';
|
||||||
|
badge.textContent = 'LTC';
|
||||||
|
nameEl.append(badge, document.createTextNode('Timecode'));
|
||||||
|
}
|
||||||
|
el.append(nameEl, timeEl, stateEl);
|
||||||
|
return { el, nameEl, timeEl, stateEl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A solo tile whose timer vanished from ProPresenter's list. While the
|
||||||
|
server is unreachable the list is merely unknown — the banner already
|
||||||
|
says so, so only claim "not in ProPresenter" when actually connected. */
|
||||||
|
function updateMissingCard(card) {
|
||||||
|
card.nameEl.textContent = lastSoloName || 'Timer';
|
||||||
|
card.timeEl.textContent = '—';
|
||||||
|
if (state?.reachable) {
|
||||||
|
card.stateEl.textContent = 'Not in ProPresenter';
|
||||||
|
card.el.className = 'tm-card is-missing';
|
||||||
|
} else {
|
||||||
|
card.stateEl.textContent = '—';
|
||||||
|
card.el.className = 'tm-card is-stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimerCard(card, timer) {
|
||||||
|
card.nameEl.textContent = timer.name;
|
||||||
|
card.timeEl.textContent = displayTime(timer.time);
|
||||||
|
const kind = classify(timer.state);
|
||||||
|
card.stateEl.textContent = timer.state ? (STATE_TEXT[kind] || timer.state) : '—';
|
||||||
|
card.el.className = `tm-card is-${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLtcCard(card, ltc) {
|
||||||
|
let mode;
|
||||||
|
if (ltc.supported === false) {
|
||||||
|
mode = 'unavailable';
|
||||||
|
card.timeEl.textContent = '—';
|
||||||
|
card.stateEl.textContent = 'Timecode not available on this ProPresenter';
|
||||||
|
} else if (ltc.supported === null) {
|
||||||
|
mode = 'waiting';
|
||||||
|
card.timeEl.textContent = displayTime(ltc.time);
|
||||||
|
card.stateEl.textContent = 'Waiting for ProPresenter…';
|
||||||
|
} else if (ltc.receiving) {
|
||||||
|
mode = 'receiving';
|
||||||
|
card.timeEl.textContent = displayTime(ltc.time);
|
||||||
|
card.stateEl.textContent = 'Receiving';
|
||||||
|
} else {
|
||||||
|
mode = 'nosignal';
|
||||||
|
card.timeEl.textContent = displayTime(ltc.time);
|
||||||
|
card.stateEl.textContent = 'No signal';
|
||||||
|
}
|
||||||
|
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── status dot + degraded banners ──────────────────────────────── */
|
||||||
|
|
||||||
|
function renderStatus() {
|
||||||
|
wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false));
|
||||||
|
if (feedOffline) {
|
||||||
|
bannerEl.hidden = false;
|
||||||
|
bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
|
||||||
|
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
bannerEl.hidden = true;
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.enabled) {
|
||||||
|
bannerEl.hidden = true;
|
||||||
|
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.reachable) {
|
||||||
|
bannerEl.hidden = false;
|
||||||
|
bannerEl.textContent = 'ProPresenter unreachable — reconnecting…';
|
||||||
|
moduleApi.setStatus('error', 'ProPresenter unreachable — reconnecting…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bannerEl.hidden = true;
|
||||||
|
moduleApi.setStatus('ok', 'Following ProPresenter timers');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── main render: diff the card set, update in place ────────────── */
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderStatus();
|
||||||
|
rebuildMenu();
|
||||||
|
|
||||||
|
let hint = '';
|
||||||
|
if (state && !state.enabled) {
|
||||||
|
hint = 'No ProPresenter connection — set the host and port in Admin.';
|
||||||
|
}
|
||||||
|
const items = hint ? [] : visibleItems();
|
||||||
|
if (!hint && state?.reachable && !items.length) {
|
||||||
|
hint = 'Nothing selected — pick timers in the Timers ▾ menu.';
|
||||||
|
}
|
||||||
|
hintEl.hidden = !hint;
|
||||||
|
hintEl.textContent = hint;
|
||||||
|
gridEl.hidden = Boolean(hint);
|
||||||
|
if (hint) return;
|
||||||
|
|
||||||
|
const wanted = new Set(items.map((i) => i.key));
|
||||||
|
let structureChanged = false;
|
||||||
|
for (const [key, card] of cards) {
|
||||||
|
if (!wanted.has(key)) {
|
||||||
|
card.el.remove();
|
||||||
|
cards.delete(key);
|
||||||
|
structureChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
let card = cards.get(item.key);
|
||||||
|
if (!card) {
|
||||||
|
card = makeCard(item);
|
||||||
|
cards.set(item.key, card);
|
||||||
|
structureChanged = true;
|
||||||
|
}
|
||||||
|
if (item.ltc) updateLtcCard(card, item.ltc);
|
||||||
|
else if (item.missing) updateMissingCard(card);
|
||||||
|
else updateTimerCard(card, item.timer);
|
||||||
|
}
|
||||||
|
// Keep DOM order in step with the state's order (config-list order).
|
||||||
|
const orderedEls = items.map((i) => cards.get(i.key).el);
|
||||||
|
if (structureChanged || orderedEls.some((el, i) => gridEl.children[i] !== el)) {
|
||||||
|
gridEl.replaceChildren(...orderedEls);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── sizing: pick the column count that gives the biggest digits ── */
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
const n = Math.max(1, gridEl.childElementCount);
|
||||||
|
const W = root.clientWidth;
|
||||||
|
const H = root.clientHeight;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const PAD = H < 120 ? 4 : 10;
|
||||||
|
const GAP = H < 120 ? 5 : 8;
|
||||||
|
const CARD_PAD = 12; // a card's own vertical padding + borders
|
||||||
|
|
||||||
|
// For each column count, budget a card's full stack (name line, digits,
|
||||||
|
// state line) and score by the digit size it affords ("-00:00:00" ≈ 9
|
||||||
|
// tabular chars wide). Cards too short for three readable lines drop to
|
||||||
|
// a compact look — no state text, the card color carries the state —
|
||||||
|
// but any layout with a readable full stack beats every compact one.
|
||||||
|
let best = null;
|
||||||
|
for (let cols = 1; cols <= n; cols += 1) {
|
||||||
|
const rows = Math.ceil(n / cols);
|
||||||
|
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18;
|
||||||
|
const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows - CARD_PAD;
|
||||||
|
if (cw <= 0 || ch <= 0) continue;
|
||||||
|
const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16)));
|
||||||
|
const byWidth = (cw * 1.55) / 9;
|
||||||
|
const full = Math.min(byWidth, ch - 2.6 * meta);
|
||||||
|
const compact = Math.min(byWidth, ch - 1.3 * meta);
|
||||||
|
const mode = full >= 14 ? 'full' : 'compact';
|
||||||
|
const size = mode === 'full' ? full : compact;
|
||||||
|
const score = (mode === 'full' ? 1000 : 0) + size;
|
||||||
|
if (!best || score > best.score) best = { cols, meta, mode, size, cw, score };
|
||||||
|
}
|
||||||
|
if (!best) return;
|
||||||
|
gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`;
|
||||||
|
wrap.style.setProperty('--tm-pad', PAD + 'px');
|
||||||
|
wrap.style.setProperty('--tm-gap', GAP + 'px');
|
||||||
|
wrap.classList.toggle('tm-compact', best.mode === 'compact');
|
||||||
|
const timeSize = Math.max(11, Math.floor(best.size));
|
||||||
|
// LTC digits ("00:00:00:00") are ~11.5 tabular chars wide in the same cell.
|
||||||
|
const ltcSize = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / 11.5, best.size)));
|
||||||
|
wrap.style.setProperty('--tm-time-size', timeSize + 'px');
|
||||||
|
wrap.style.setProperty('--tm-ltc-size', ltcSize + 'px');
|
||||||
|
wrap.style.setProperty('--tm-meta-size', best.meta + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
open() {
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
// EventSource retries by itself (and the shell recreates closed
|
||||||
|
// streams), but the tile must admit it is frozen meanwhile.
|
||||||
|
feedOffline = true;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
state(ev) {
|
||||||
|
try {
|
||||||
|
state = JSON.parse(ev.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
// Instant paint from the snapshot, then live updates.
|
||||||
|
moduleApi.fetch('/state')
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (body && body.state && !state) {
|
||||||
|
state = body.state;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { /* the stream will cover it */ });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
let lastW = 0;
|
||||||
|
let lastH = 0;
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
const w = Math.round(root.clientWidth);
|
||||||
|
const h = Math.round(root.clientHeight);
|
||||||
|
if (w === lastW && h === lastH) return;
|
||||||
|
lastW = w;
|
||||||
|
lastH = h;
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(root);
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
stream?.close();
|
||||||
|
stream = null;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
|
},
|
||||||
|
onResize() {
|
||||||
|
fit();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "propresenter-timers",
|
||||||
|
"name": "Timers",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Live ProPresenter timers and LTC timecode",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": { "w": 2, "h": 1 },
|
||||||
|
"defaultSize": { "w": 4, "h": 2 },
|
||||||
|
"configSchema": {
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "ProPresenter host / IP",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "ProPresenter network port",
|
||||||
|
"default": 1025
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "API password (only if set in ProPresenter)",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProPresenter Timers — server part.
|
||||||
|
*
|
||||||
|
* Polls the ProPresenter openAPI for the configured timer list, the live
|
||||||
|
* timer values, and (when the connected build exposes it) LTC timecode, and
|
||||||
|
* fans one merged state object out to every tile over SSE. Browsers never
|
||||||
|
* talk to ProPresenter directly.
|
||||||
|
*
|
||||||
|
* GET /state → { state: <state> } (instant paint for new tiles)
|
||||||
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
*
|
||||||
|
* Endpoints used, verified against openapi.propresenter.com (API v1):
|
||||||
|
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
|
||||||
|
* countdown:{duration} | count_down_to_time:{time_of_day,period}
|
||||||
|
* | elapsed:{start_time,end_time} } ]
|
||||||
|
* GET /v1/timers/current → [ { id:{uuid,name,index}, time:"HH:MM:SS", state } ]
|
||||||
|
* `time` may be negative ("-00:00:02") while overrunning, and some builds
|
||||||
|
* append hundredths ("00:00:01.00" appears in the spec's schema example).
|
||||||
|
* `state` enum is stopped|running|complete|overrunning|overran — but the
|
||||||
|
* spec's own response example says "overrun", so treat anything containing
|
||||||
|
* "overr" as overrun. The API exposes NO per-timer color anywhere.
|
||||||
|
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched
|
||||||
|
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
|
||||||
|
* allowlist either) documents no timecode/LTC route at all. We probe this
|
||||||
|
* candidate route once per connection anyway so a future ProPresenter
|
||||||
|
* that adds it lights up without a module update; a 404 marks LTC
|
||||||
|
* "not available on this ProPresenter" (ltc.supported = false).
|
||||||
|
*
|
||||||
|
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
|
||||||
|
* notes in ../propresenter-now-next/propresenter-core). So: requests run
|
||||||
|
* strictly sequentially, the next tick is scheduled only after the previous
|
||||||
|
* one finished, live values are the only every-tick call, and the timer list
|
||||||
|
* rides a slow lane. The endpoints do offer `?chunked` streaming, but polling
|
||||||
|
* matches how propresenter-core survives real-world ProPresenter quirks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hot-path cadence — live timer values. */
|
||||||
|
const POLL_MS = 500;
|
||||||
|
/** Backoff while ProPresenter is unreachable. */
|
||||||
|
const RETRY_MS = 2500;
|
||||||
|
/** The configured-timer list refreshes every Nth tick (~2s at 500ms). */
|
||||||
|
const SLOW_EVERY = 4;
|
||||||
|
/** Abort a hung request well before it can stall the loop for good. */
|
||||||
|
const REQUEST_TIMEOUT_MS = 4000;
|
||||||
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
|
||||||
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
function clearedState(enabled) {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
reachable: false,
|
||||||
|
lastError: '',
|
||||||
|
timers: [],
|
||||||
|
// supported: null = not probed yet (or ProPresenter unreachable),
|
||||||
|
// true/false once a reachable ProPresenter answered the probe.
|
||||||
|
ltc: { supported: null, time: '', receiving: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const streams = new Set();
|
||||||
|
const host = String(config.host || '');
|
||||||
|
const port = Number(config.port) || 1025;
|
||||||
|
const password = String(config.password || '');
|
||||||
|
const enabled = Boolean(host && port);
|
||||||
|
|
||||||
|
let latest = clearedState(enabled);
|
||||||
|
let lastFrame = '';
|
||||||
|
let stopped = false;
|
||||||
|
let pollTimer = null;
|
||||||
|
let tick = 0;
|
||||||
|
|
||||||
|
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
|
||||||
|
let timerConfigs = new Map();
|
||||||
|
/** null = probe /v1/timecode/status on the next reachable tick. */
|
||||||
|
let ltcSupported = null;
|
||||||
|
let ltcLast = { time: '', receiving: false };
|
||||||
|
|
||||||
|
function url(target) {
|
||||||
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
|
if (password) built.searchParams.set('password', password);
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(target) {
|
||||||
|
const response = await fetch(url(target), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
|
||||||
|
err.status = response.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timerType(entry) {
|
||||||
|
if (entry.countdown) return 'countdown';
|
||||||
|
if (entry.count_down_to_time) return 'countdown_to_time';
|
||||||
|
if (entry.elapsed) return 'elapsed';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTimerList(list) {
|
||||||
|
if (!Array.isArray(list)) return;
|
||||||
|
const next = new Map();
|
||||||
|
for (const entry of list) {
|
||||||
|
const uuid = entry?.id?.uuid;
|
||||||
|
if (!uuid) continue;
|
||||||
|
next.set(uuid, {
|
||||||
|
name: String(entry.id.name || 'Timer'),
|
||||||
|
index: Number(entry.id.index) || 0,
|
||||||
|
type: timerType(entry),
|
||||||
|
allowsOverrun: Boolean(entry.allows_overrun),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
timerConfigs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge configured timers (order, type) with live values into one list. */
|
||||||
|
function mergeTimers(currentTimes) {
|
||||||
|
const live = new Map();
|
||||||
|
if (Array.isArray(currentTimes)) {
|
||||||
|
for (const entry of currentTimes) {
|
||||||
|
const uuid = entry?.id?.uuid;
|
||||||
|
if (!uuid) continue;
|
||||||
|
live.set(uuid, {
|
||||||
|
name: String(entry.id.name || ''),
|
||||||
|
time: typeof entry.time === 'string' ? entry.time : '',
|
||||||
|
state: typeof entry.state === 'string' ? entry.state : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const [uuid, cfg] of timerConfigs) {
|
||||||
|
const now = live.get(uuid);
|
||||||
|
live.delete(uuid);
|
||||||
|
out.push({
|
||||||
|
uuid,
|
||||||
|
name: (now && now.name) || cfg.name,
|
||||||
|
type: cfg.type,
|
||||||
|
allowsOverrun: cfg.allowsOverrun,
|
||||||
|
time: now ? now.time : '',
|
||||||
|
state: now ? now.state : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Timers reported live but missing from the (possibly stale) config
|
||||||
|
// list still deserve a card — type unknown until the slow lane catches up.
|
||||||
|
for (const [uuid, now] of live) {
|
||||||
|
out.push({ uuid, name: now.name || 'Timer', type: 'unknown', allowsOverrun: false, time: now.time, state: now.state });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liberal parse of a hypothetical timecode-status body (no spec documents
|
||||||
|
* its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped
|
||||||
|
* string and a receiving/running boolean under likely key names.
|
||||||
|
*/
|
||||||
|
function parseLtc(body) {
|
||||||
|
const out = { time: '', receiving: false };
|
||||||
|
if (!body || typeof body !== 'object') return out;
|
||||||
|
const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
||||||
|
for (const key of ['time', 'timecode', 'current_time', 'value']) {
|
||||||
|
if (typeof body[key] === 'string' && TC.test(body[key])) {
|
||||||
|
out.time = body[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!out.time) {
|
||||||
|
for (const value of Object.values(body)) {
|
||||||
|
if (typeof value === 'string' && TC.test(value)) {
|
||||||
|
out.time = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of ['receiving', 'running', 'is_running', 'active']) {
|
||||||
|
if (typeof body[key] === 'boolean') {
|
||||||
|
out.receiving = body[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLtc() {
|
||||||
|
if (ltcSupported === false) return;
|
||||||
|
try {
|
||||||
|
const body = await requestJson('/v1/timecode/status');
|
||||||
|
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
|
||||||
|
ltcSupported = true;
|
||||||
|
ltcLast = parseLtc(body);
|
||||||
|
} catch (err) {
|
||||||
|
// 404 = this ProPresenter has no timecode API (the expected case, as
|
||||||
|
// of API v1). Anything else is a real failure — let the tick handle it.
|
||||||
|
if (err && err.status === 404) {
|
||||||
|
ltcSupported = false;
|
||||||
|
ltcLast = { time: '', receiving: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast() {
|
||||||
|
latest = {
|
||||||
|
enabled,
|
||||||
|
reachable: latest.reachable,
|
||||||
|
lastError: latest.lastError,
|
||||||
|
timers: latest.timers,
|
||||||
|
ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving },
|
||||||
|
};
|
||||||
|
if (!streams.size) return;
|
||||||
|
let frame;
|
||||||
|
try {
|
||||||
|
frame = JSON.stringify(latest);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame === lastFrame) return;
|
||||||
|
lastFrame = frame;
|
||||||
|
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollTick() {
|
||||||
|
if (stopped) return;
|
||||||
|
try {
|
||||||
|
tick += 1;
|
||||||
|
// Slow lane first on the very first tick so cards get names/types
|
||||||
|
// immediately; afterwards every SLOW_EVERY ticks.
|
||||||
|
if (tick % SLOW_EVERY === 1 || !timerConfigs.size) {
|
||||||
|
applyTimerList(await requestJson('/v1/timers'));
|
||||||
|
}
|
||||||
|
// THE HOT PATH — live values every tick.
|
||||||
|
const currentTimes = await requestJson('/v1/timers/current');
|
||||||
|
// Probe timecode once per connection; poll it only when it exists.
|
||||||
|
await pollLtc();
|
||||||
|
latest.timers = mergeTimers(currentTimes);
|
||||||
|
latest.reachable = true;
|
||||||
|
latest.lastError = '';
|
||||||
|
} catch (err) {
|
||||||
|
latest.reachable = false;
|
||||||
|
latest.lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
// Re-probe timecode after a reconnect — the unreachable spell may
|
||||||
|
// have been a ProPresenter upgrade or a different machine.
|
||||||
|
ltcSupported = null;
|
||||||
|
ltcLast = { time: '', receiving: false };
|
||||||
|
}
|
||||||
|
broadcast();
|
||||||
|
if (stopped) return;
|
||||||
|
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
pollTick();
|
||||||
|
log(`polling timers on ${host}:${port}`);
|
||||||
|
} else {
|
||||||
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
current = { streams, getLatest: () => latest };
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
|
if (latest.reachable) {
|
||||||
|
const ltcNote = latest.ltc.supported ? ' (with timecode)' : '';
|
||||||
|
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: latest.lastError
|
||||||
|
? `ProPresenter unreachable: ${latest.lastError}`
|
||||||
|
: 'Trying to reach ProPresenter…',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Add-tile picker's entries: one per discovered timer, plus "All
|
||||||
|
* timers" and the LTC card (moduleApi.variant carries the entry id —
|
||||||
|
* 'all', 'timer:<uuid>' or 'ltc'). Answered from the poller's latest
|
||||||
|
* state, never from a fresh upstream request — the picker must stay
|
||||||
|
* instant, and a dead ProPresenter must not stall it.
|
||||||
|
*/
|
||||||
|
tiles() {
|
||||||
|
const state = current ? current.getLatest() : null;
|
||||||
|
if (!state || !state.enabled) return []; // unconfigured → classic single entry
|
||||||
|
const TYPE_LABEL = {
|
||||||
|
countdown: 'Countdown timer',
|
||||||
|
countdown_to_time: 'Countdown to a time of day',
|
||||||
|
elapsed: 'Elapsed timer',
|
||||||
|
unknown: 'Timer',
|
||||||
|
};
|
||||||
|
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
||||||
|
return [
|
||||||
|
{ id: 'all', name: 'All timers', description: 'Every timer and the LTC timecode, selectable per tile' },
|
||||||
|
...state.timers.map((t) => ({
|
||||||
|
id: `timer:${t.uuid}`,
|
||||||
|
name: t.name,
|
||||||
|
description: TYPE_LABEL[t.type] || 'Timer',
|
||||||
|
...oneCard,
|
||||||
|
})),
|
||||||
|
{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
routes() {
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) }));
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
const state = current ? current.getLatest() : clearedState(false);
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||||||
|
if (!current) return void res.end();
|
||||||
|
const { streams } = current;
|
||||||
|
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||||
|
// not queue frames forever; keepalive probes make the OS notice.
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/* ProPresenter Timers module — scoped to its tiles, shell variables only.
|
||||||
|
The API exposes no per-timer color, so a card's color IS its state:
|
||||||
|
accent = running, muted = stopped, warn = complete, danger = overrun.
|
||||||
|
Overrun must be unmistakable at a distance — danger fill + pulse. */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-wrap {
|
||||||
|
--tm-time-size: 32px;
|
||||||
|
--tm-ltc-size: 26px;
|
||||||
|
--tm-meta-size: 11px;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-banner {
|
||||||
|
flex: none;
|
||||||
|
margin: 6px 8px 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Values still on screen while the feed is down are stale — say so visually. */
|
||||||
|
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-hint {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* ── card grid (column count set by client.js fit()) ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
gap: var(--tm-gap, 8px);
|
||||||
|
padding: var(--tm-pad, 10px);
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Compact cards (short tiles): the state line goes, its color stays.
|
||||||
|
The one exception is the LTC card with no timecode API behind it —
|
||||||
|
that message must never be hidden. */
|
||||||
|
[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; }
|
||||||
|
[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; }
|
||||||
|
[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; }
|
||||||
|
|
||||||
|
/* ── one timer card ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-name {
|
||||||
|
flex: none;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
font-size: var(--tm-meta-size);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-time {
|
||||||
|
flex: none;
|
||||||
|
font-size: var(--tm-time-size);
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-state {
|
||||||
|
flex: none;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: var(--tm-meta-size);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── state looks ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-running {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-complete {
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--warn) 10%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); }
|
||||||
|
|
||||||
|
/* A solo tile whose timer was deleted in ProPresenter. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); }
|
||||||
|
|
||||||
|
/* Overrun: readable across the room even on a wall display. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 22%, transparent);
|
||||||
|
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tm-overrun-pulse {
|
||||||
|
50% { background: color-mix(in srgb, var(--danger) 38%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the LTC card: visually its own thing ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc .tm-time {
|
||||||
|
font-size: var(--tm-ltc-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-ltc-badge {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.15em 0.55em;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95em;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal {
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state {
|
||||||
|
color: var(--warn);
|
||||||
|
animation: tm-nosignal-blink 1.6s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
|
/* No timecode API on this ProPresenter: say so, quietly. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-waiting .tm-time { color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 9px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item .check {
|
||||||
|
width: 14px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; }
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-note {
|
||||||
|
padding: 6px 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Mock ProPresenter timer + timecode API for developing the
|
||||||
|
* propresenter-timers module without a live ProPresenter (the older
|
||||||
|
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
|
||||||
|
*
|
||||||
|
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
|
||||||
|
*
|
||||||
|
* Serves the endpoint shapes verified against openapi.propresenter.com:
|
||||||
|
* GET /version (reachability probe)
|
||||||
|
* GET /v1/timers configured timers (countdown / countdown-to-time /
|
||||||
|
* elapsed variants, matching the spec's oneOf shapes)
|
||||||
|
* GET /v1/timers/current live values — one countdown running, one that hits
|
||||||
|
* zero after 20s and overruns (negative time, state
|
||||||
|
* "overrunning"), one stopped, one elapsed running
|
||||||
|
* GET /v1/timecode/status hypothetical LTC route (NOT in the published
|
||||||
|
* spec — the module probes it for forward compat).
|
||||||
|
* Advancing HH:MM:SS:FF at 30fps that drops to
|
||||||
|
* no-signal for 15s out of every 60s.
|
||||||
|
* Pass --no-ltc to 404 it and exercise the
|
||||||
|
* "not available on this ProPresenter" path.
|
||||||
|
*
|
||||||
|
* --drop=<uuid,...> omits those timers from both timer endpoints — restart
|
||||||
|
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
|
||||||
|
* ProPresenter (a solo tile for it must say so, not error).
|
||||||
|
*
|
||||||
|
* Every incoming request is logged so we can confirm what the module fetches.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
|
||||||
|
const NO_LTC = args.includes('--no-ltc');
|
||||||
|
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
|
||||||
|
const FPS = 30;
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const elapsedSec = () => Math.floor((Date.now() - startedAt) / 1000);
|
||||||
|
|
||||||
|
/* ── configured timers (the /v1/timers document) ─────────────────── */
|
||||||
|
|
||||||
|
const TIMERS = [
|
||||||
|
{
|
||||||
|
id: { uuid: 'T1-SERMON', name: 'Sermon Countdown', index: 0 },
|
||||||
|
allows_overrun: false,
|
||||||
|
countdown: { duration: 25 * 60 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: { uuid: 'T2-WALKIN', name: 'Walk-in', index: 1 },
|
||||||
|
allows_overrun: true,
|
||||||
|
countdown: { duration: 20 }, // hits zero fast so the overrun path is easy to watch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: { uuid: 'T3-DOORS', name: 'Doors Open', index: 2 },
|
||||||
|
allows_overrun: false,
|
||||||
|
count_down_to_time: { time_of_day: 9 * 3600, period: 'am' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: { uuid: 'T4-SERVICE', name: 'Service Elapsed', index: 3 },
|
||||||
|
allows_overrun: false,
|
||||||
|
elapsed: { start_time: 0, end_time: null },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ── live values (the /v1/timers/current document) ───────────────── */
|
||||||
|
|
||||||
|
/** Format seconds the way ProPresenter reports timer values: "HH:MM:SS",
|
||||||
|
with a leading "-" once a timer overruns. */
|
||||||
|
function hms(totalSeconds) {
|
||||||
|
const sign = totalSeconds < 0 ? '-' : '';
|
||||||
|
const s = Math.abs(totalSeconds);
|
||||||
|
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||||
|
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
return `${sign}${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTimes() {
|
||||||
|
const t = elapsedSec();
|
||||||
|
const sermonLeft = Math.max(0, 25 * 60 - t);
|
||||||
|
const walkinLeft = 20 - t;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: TIMERS[0].id,
|
||||||
|
time: hms(sermonLeft),
|
||||||
|
state: sermonLeft > 0 ? 'running' : 'complete',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TIMERS[1].id,
|
||||||
|
time: hms(walkinLeft),
|
||||||
|
state: walkinLeft > 0 ? 'running' : 'overrunning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TIMERS[2].id,
|
||||||
|
time: hms(10 * 60),
|
||||||
|
state: 'stopped',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TIMERS[3].id,
|
||||||
|
time: hms(t),
|
||||||
|
state: 'running',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── LTC timecode: 45s receiving / 15s no-signal, repeating ──────── */
|
||||||
|
|
||||||
|
let frozenTimecode = '00:00:00:00';
|
||||||
|
|
||||||
|
function timecodeStatus() {
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
const receiving = (Math.floor(ms / 1000) % 60) < 45;
|
||||||
|
if (!receiving) return { time: frozenTimecode, receiving: false };
|
||||||
|
const frames = Math.floor(ms / (1000 / FPS));
|
||||||
|
const ff = String(frames % FPS).padStart(2, '0');
|
||||||
|
frozenTimecode = `${hms(Math.floor(ms / 1000)).replace(/^-/, '')}:${ff}`;
|
||||||
|
return { time: frozenTimecode, receiving: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── server ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function sendJson(res, body, status = 200) {
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
||||||
|
res.end(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const p = new URL(req.url, 'http://x').pathname;
|
||||||
|
console.log(`[pp-timers-mock] ${req.method} ${req.url}`);
|
||||||
|
|
||||||
|
if (p === '/version') {
|
||||||
|
return sendJson(res, { name: 'NA-ProP (timers mock)', platform: 'mac', host_description: 'ProPresenter 20.0.1', api_version: 'v1' });
|
||||||
|
}
|
||||||
|
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||||
|
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
||||||
|
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||||
|
|
||||||
|
sendJson(res, { error: 'not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user