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).
|
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||||
Admin config: host, port, password.
|
Admin config: host, port, password.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards; each tile picks which to
|
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||||
show via its "Timers ▾" menu, overruns are unmistakably red. Read-only —
|
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||||
no start/stop controls. Admin config: host, port, password.
|
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
|
unmistakably red. Read-only — no start/stop controls. Admin config: host,
|
||||||
|
port, password.
|
||||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ export default function create({ root, moduleApi }) {
|
|||||||
let resizeObserver = null;
|
let resizeObserver = null;
|
||||||
let hiddenItems = new Set(moduleApi.instanceSettings.hiddenItems || []);
|
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 ───────────────────────────────────────────────── */
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -38,7 +49,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
/* ── title-bar menu: pick which timers / LTC this tile shows ────── */
|
/* ── title-bar menu: pick which timers / LTC this tile shows ────── */
|
||||||
|
|
||||||
let menuEl = null; // the open menu's element (filled on each open)
|
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 ▾',
|
label: 'Timers ▾',
|
||||||
title: 'Choose which timers this tile shows',
|
title: 'Choose which timers this tile shows',
|
||||||
build(menu) {
|
build(menu) {
|
||||||
@@ -67,6 +78,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateMenuLabel() {
|
function updateMenuLabel() {
|
||||||
|
if (!itemsMenu) return;
|
||||||
const rows = menuRows();
|
const rows = menuRows();
|
||||||
const visible = rows.filter((r) => !hiddenItems.has(r.key)).length;
|
const visible = rows.filter((r) => !hiddenItems.has(r.key)).length;
|
||||||
itemsMenu.setLabel(visible === rows.length
|
itemsMenu.setLabel(visible === rows.length
|
||||||
@@ -131,11 +143,23 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visibleItems() {
|
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 = [];
|
const items = [];
|
||||||
for (const t of state?.timers || []) {
|
for (const t of state?.timers || []) {
|
||||||
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
|
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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +182,21 @@ export default function create({ root, moduleApi }) {
|
|||||||
return { el, 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) {
|
function updateTimerCard(card, timer) {
|
||||||
card.nameEl.textContent = timer.name;
|
card.nameEl.textContent = timer.name;
|
||||||
card.timeEl.textContent = displayTime(timer.time);
|
card.timeEl.textContent = displayTime(timer.time);
|
||||||
@@ -254,6 +293,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
structureChanged = true;
|
structureChanged = true;
|
||||||
}
|
}
|
||||||
if (item.ltc) updateLtcCard(card, item.ltc);
|
if (item.ltc) updateLtcCard(card, item.ltc);
|
||||||
|
else if (item.missing) updateMissingCard(card);
|
||||||
else updateTimerCard(card, item.timer);
|
else updateTimerCard(card, item.timer);
|
||||||
}
|
}
|
||||||
// Keep DOM order in step with the state's order (config-list order).
|
// 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() {
|
routes() {
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
that message must never be hidden. */
|
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 .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-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 ── */
|
/* ── 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-time { color: var(--warn); }
|
||||||
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { 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. */
|
/* Overrun: readable across the room even on a wall display. */
|
||||||
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* propresenter-timers module without a live ProPresenter (the older
|
* propresenter-timers module without a live ProPresenter (the older
|
||||||
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
|
* 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:
|
* Serves the endpoint shapes verified against openapi.propresenter.com:
|
||||||
* GET /version (reachability probe)
|
* GET /version (reachability probe)
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
* Pass --no-ltc to 404 it and exercise the
|
* Pass --no-ltc to 404 it and exercise the
|
||||||
* "not available on this ProPresenter" path.
|
* "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.
|
* Every incoming request is logged so we can confirm what the module fetches.
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -28,6 +32,7 @@ const http = require('http');
|
|||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
|
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
|
||||||
const NO_LTC = args.includes('--no-ltc');
|
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 FPS = 30;
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -128,8 +133,8 @@ const server = http.createServer((req, res) => {
|
|||||||
if (p === '/version') {
|
if (p === '/version') {
|
||||||
return sendJson(res, { name: 'NA-ProP (timers mock)', platform: 'mac', host_description: 'ProPresenter 20.0.1', api_version: 'v1' });
|
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') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||||
if (p === '/v1/timers/current') return sendJson(res, currentTimes());
|
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());
|
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||||
|
|
||||||
sendJson(res, { error: 'not found' }, 404);
|
sendJson(res, { error: 'not found' }, 404);
|
||||||
|
|||||||
Reference in New Issue
Block a user