diff --git a/README.md b/README.md
index c469264..d435139 100644
--- a/README.md
+++ b/README.md
@@ -87,13 +87,18 @@ are written by the app itself.
- **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20).
Admin config: host, port, password.
+- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
+ elapsed) and LTC timecode as auto-sized cards; each tile picks which to
+ show via its "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
reference for module authors.
To develop modules against mocks instead of real gear:
-`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`) and
-`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter on
-`127.0.0.1:1599`). See the testing section of the
+`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
+`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
+`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).
## Install as a desktop web app
diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md
index 65e7d7c..bcb027d 100644
--- a/docs/MODULE-GUIDE.md
+++ b/docs/MODULE-GUIDE.md
@@ -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
and an advancing slide index (including the arrangement-repeat case).
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:
diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js
new file mode 100644
index 0000000..c55cb91
--- /dev/null
+++ b/modules/propresenter-timers/client.js
@@ -0,0 +1,380 @@
+/* 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 || []);
+
+ /* ── DOM skeleton ───────────────────────────────────────────────── */
+
+ root.innerHTML = `
+
`;
+
+ 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 = 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() {
+ 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 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: state?.ltc || { supported: null, time: '', receiving: false } });
+ 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 };
+ }
+
+ 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 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();
+ },
+ };
+}
diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json
new file mode 100644
index 0000000..b5b4603
--- /dev/null
+++ b/modules/propresenter-timers/module.json
@@ -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": ""
+ }
+ }
+}
diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js
new file mode 100644
index 0000000..fcdb0fe
--- /dev/null
+++ b/modules/propresenter-timers/server.js
@@ -0,0 +1,344 @@
+'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: } (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…',
+ };
+ },
+ };
+ },
+
+ 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));
+ },
+ };
+ },
+};
diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css
new file mode 100644
index 0000000..b932a6c
--- /dev/null
+++ b/modules/propresenter-timers/style.css
@@ -0,0 +1,241 @@
+/* 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; }
+
+/* ── 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); }
+
+/* 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;
+}
diff --git a/tools/pp-timers-mock.js b/tools/pp-timers-mock.js
new file mode 100644
index 0000000..6c5de83
--- /dev/null
+++ b/tools/pp-timers-mock.js
@@ -0,0 +1,140 @@
+/**
+ * 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]
+ *
+ * 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.
+ *
+ * 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 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);
+ if (p === '/v1/timers/current') return sendJson(res, currentTimes());
+ 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)' : ''}`);
+});