diff --git a/README.md b/README.md index d435139..9edf76c 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,11 @@ are written by the app itself. 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. + 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. diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index c55cb91..02f728d 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -18,6 +18,17 @@ export default function create({ root, moduleApi }) { 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 = ` @@ -38,7 +49,7 @@ export default function create({ root, moduleApi }) { /* ── 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({ + const itemsMenu = soloKey ? null : moduleApi.header.addMenu({ label: 'Timers ▾', title: 'Choose which timers this tile shows', build(menu) { @@ -67,6 +78,7 @@ export default function create({ root, moduleApi }) { } function updateMenuLabel() { + if (!itemsMenu) return; const rows = menuRows(); const visible = rows.filter((r) => !hiddenItems.has(r.key)).length; itemsMenu.setLabel(visible === rows.length @@ -131,11 +143,23 @@ export default function create({ root, moduleApi }) { } 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: state?.ltc || { supported: null, time: '', receiving: false } }); + if (!hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState }); return items; } @@ -158,6 +182,21 @@ export default function create({ root, moduleApi }) { 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); @@ -254,6 +293,7 @@ export default function create({ root, moduleApi }) { 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). diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index fcdb0fe..08bc3f9 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -314,6 +314,35 @@ module.exports = { }; }, + /** + * 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) => { diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index b932a6c..7ef4f61 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -63,6 +63,7 @@ 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 ── */ @@ -139,6 +140,11 @@ [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); diff --git a/tools/pp-timers-mock.js b/tools/pp-timers-mock.js index 6c5de83..c62887f 100644 --- a/tools/pp-timers-mock.js +++ b/tools/pp-timers-mock.js @@ -3,7 +3,7 @@ * 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] + * node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=] * * Serves the endpoint shapes verified against openapi.propresenter.com: * GET /version (reachability probe) @@ -19,6 +19,10 @@ * 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'; @@ -28,6 +32,7 @@ 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(); @@ -128,8 +133,8 @@ const server = http.createServer((req, res) => { 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/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);