diff --git a/README.md b/README.md index c469264..9edf76c 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,20 @@ 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. 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 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..02f728d --- /dev/null +++ b/modules/propresenter-timers/client.js @@ -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:' 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 = ` +
+ + +
+
`; + + 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(); + }, + }; +} 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..08bc3f9 --- /dev/null +++ b/modules/propresenter-timers/server.js @@ -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: } (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:' 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)); + }, + }; + }, +}; diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css new file mode 100644 index 0000000..7ef4f61 --- /dev/null +++ b/modules/propresenter-timers/style.css @@ -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; +} diff --git a/tools/pp-timers-mock.js b/tools/pp-timers-mock.js new file mode 100644 index 0000000..c62887f --- /dev/null +++ b/tools/pp-timers-mock.js @@ -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=] + * + * 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= 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)' : ''}`); +});