Timers: present each timer and LTC in the Add-tile picker
Adopt main's new multi-tile contract: the module's server exports tiles() (answered from the poller's held state, never a fresh upstream request) listing All timers, one entry per discovered timer, and LTC timecode. The client branches on moduleApi.variant — solo tiles render exactly one card with no selection menu, and a solo timer that vanishes from ProPresenter shows a labeled "Not in ProPresenter" card instead of erroring (visible even in the compact layout). pp-timers-mock gains --drop=<uuid,...> to simulate a timer deleted in ProPresenter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a8a382c171
commit
12245cba93
@@ -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.
|
||||
|
||||
|
||||
@@ -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:<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 = `
|
||||
@@ -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).
|
||||
|
||||
@@ -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:<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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=<uuid,...>]
|
||||
*
|
||||
* 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=<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';
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user