Merge Timers: each picker entry carries the gear settings that fit its tile

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-14 15:39:38 -04:00
co-authored by Claude Fable 5.1
3 changed files with 107 additions and 17 deletions
+33 -9
View File
@@ -18,6 +18,10 @@
"Timers ▾" header menu, which hides any single timer — keyed `source:id` "Timers ▾" header menu, which hides any single timer — keyed `source:id`
and saved with the layout via saveInstanceSettings. The header can be and saved with the layout via saveInstanceSettings. The header can be
hidden by the user, so the static switches stay the primary controls. hidden by the user, so the static switches stay the primary controls.
A solo tile's gear carries only the settings its one card can use (the
picker entry's own schema — see tiles() in server.js), so a setting may
simply be absent here: every read goes through pref(), which falls back
to DEFAULTS rather than to whatever schema the tile happens to have.
Feedback — the state colours, and the thresholds behind them: Feedback — the state colours, and the thresholds behind them:
running elapsed, no planned length running running elapsed, no planned length running
@@ -56,6 +60,22 @@ const PAINT_MS = 200;
const LTC_KEY = 'ltc:ltc'; const LTC_KEY = 'ltc:ltc';
/** What the tile assumes for a setting its schema doesn't carry (a solo
tile offers only what applies to its card) or that was never saved. The
switches default to on, the clock to 24-hour with seconds and no date —
the same values module.json gives the full "All timers" schema. */
const DEFAULTS = {
showClock: true,
showProPresenter: true,
showLtc: true,
showModules: true,
showHeadings: true,
showStatus: true,
clockFormat: '24h',
clockSeconds: true,
clockDate: false,
};
/* ── SMPTE timecode math (module-scope, exported for tests) ───────── /* ── SMPTE timecode math (module-scope, exported for tests) ─────────
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
counts every frame in between locally. That needs exact frame↔timecode counts every frame in between locally. That needs exact frame↔timecode
@@ -174,6 +194,12 @@ export default function create({ root, moduleApi }) {
let needFit = true; let needFit = true;
const settings = () => moduleApi.instanceSettings; const settings = () => moduleApi.instanceSettings;
/** One setting, resolved here: what this tile saved, else DEFAULTS — never
whatever its (possibly slimmer) schema would or wouldn't default. */
const pref = (key) => {
const v = settings()[key];
return v === undefined || v === null ? DEFAULTS[key] : v;
};
const keyOf = (source, timer) => `${source.id}:${timer.id}`; const keyOf = (source, timer) => `${source.id}:${timer.id}`;
/* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier /* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier
@@ -324,7 +350,7 @@ export default function create({ root, moduleApi }) {
function sourceShown(source) { function sourceShown(source) {
const key = SOURCE_SWITCH[source.kind] || 'showModules'; const key = SOURCE_SWITCH[source.kind] || 'showModules';
return settings()[key] !== false; return pref(key) !== false;
} }
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ /** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
@@ -424,13 +450,12 @@ export default function create({ root, moduleApi }) {
/* ── clocks ─────────────────────────────────────────────────────── */ /* ── clocks ─────────────────────────────────────────────────────── */
function clockOptions() { function clockOptions() {
const s = settings(); const h12 = pref('clockFormat') === '12h';
const h12 = s.clockFormat === '12h';
return { return {
hour12: h12, hour12: h12,
hour: h12 ? 'numeric' : '2-digit', hour: h12 ? 'numeric' : '2-digit',
minute: '2-digit', minute: '2-digit',
...(s.clockSeconds !== false ? { second: '2-digit' } : {}), ...(pref('clockSeconds') !== false ? { second: '2-digit' } : {}),
}; };
} }
@@ -529,7 +554,7 @@ export default function create({ root, moduleApi }) {
function paint() { function paint() {
if (!state) return; if (!state) return;
const now = serverNow(); const now = serverNow();
const showStatus = settings().showStatus !== false; const showStatus = pref('showStatus') !== false;
let charsChanged = false; let charsChanged = false;
for (const card of cards.values()) { for (const card of cards.values()) {
const { item } = card; const { item } = card;
@@ -559,7 +584,7 @@ export default function create({ root, moduleApi }) {
} else if (source.kind === 'clock') { } else if (source.kind === 'clock') {
text = wallClockText(); text = wallClockText();
tone = 'plain'; tone = 'plain';
statusText = settings().clockDate ? dateText() : ''; statusText = pref('clockDate') ? dateText() : '';
statusTone = 'muted'; statusTone = 'muted';
} else { } else {
text = providerClockText(timer, source, now); text = providerClockText(timer, source, now);
@@ -664,9 +689,8 @@ export default function create({ root, moduleApi }) {
wrap.classList.toggle('tm-mono', mono); wrap.classList.toggle('tm-mono', mono);
needFit = true; needFit = true;
} }
const s = settings(); const showHeadings = !solo && pref('showHeadings') !== false;
const showHeadings = !solo && s.showHeadings !== false; const showStatus = pref('showStatus') !== false;
const showStatus = s.showStatus !== false;
wrap.classList.toggle('tm-headings', showHeadings); wrap.classList.toggle('tm-headings', showHeadings);
wrap.classList.toggle('tm-nostatus', !showStatus); wrap.classList.toggle('tm-nostatus', !showStatus);
+2 -2
View File
@@ -89,7 +89,7 @@
}, },
"instanceGroups": { "instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }, "Show": { "collapsed": false, "columns": 2 },
"Clock": { "collapsed": false }, "Clock": { "collapsed": false, "toggle": "showClock" },
"Colours": { "Colours": {
"collapsed": true, "collapsed": true,
"columns": 2, "columns": 2,
@@ -97,12 +97,12 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" },
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" }, "showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" },
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" }, "showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" },
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" }, "showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" },
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" }, "showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" },
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" }, "showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
"showClock": { "type": "switch", "label": "Clock", "default": true, "group": "Clock" },
"clockFormat": { "clockFormat": {
"type": "select", "type": "select",
"label": "Clock format", "label": "Clock format",
+72 -6
View File
@@ -82,6 +82,7 @@ const http = require('http');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener'); const { createLtcListener } = require('./ltc-listener');
const manifest = require('./module.json');
/** This module's id — left out of provider discovery (we don't consume ourselves). */ /** This module's id — left out of provider discovery (we don't consume ourselves). */
const SELF_ID = 'propresenter-timers'; const SELF_ID = 'propresenter-timers';
@@ -873,6 +874,67 @@ function createModuleDiscovery({ shellPort, log, flags, onAdd, onRemove }) {
}; };
} }
/* ── what each tile's gear offers ─────────────────────────────────────
module.json's instanceSchema is the full set — what an "All timers" tile
offers, and what the shell falls back to for a tile with no picker entry
(one added before the picker listed timers). A solo tile shows one card,
so its picker entry carries a schema of just what that card can use, and
the shell renders that instead (module guide, "Presenting multiple
tiles"): a Message Timer has no use for the clock's format switches.
Every field is drawn from the manifest so it is defined once; the client
falls back to its own defaults for keys a slimmer schema leaves out. */
const FULL_SCHEMA = manifest.instanceSchema || {};
const FULL_GROUPS = manifest.instanceGroups || {};
/**
* A gear schema of just `keys` (manifest order kept) with the groups they
* use. `loose` names keys shown outside their group; `groups` overrides a
* group's meta. A group toggle whose switch isn't among the keys is dropped.
*/
function gearOf(keys, { loose = [], groups = {} } = {}) {
const instanceSchema = {};
for (const key of Object.keys(FULL_SCHEMA)) {
if (!keys.includes(key)) continue;
const spec = { ...FULL_SCHEMA[key] };
if (loose.includes(key)) delete spec.group;
instanceSchema[key] = spec;
}
const instanceGroups = {};
for (const spec of Object.values(instanceSchema)) {
if (!spec.group || instanceGroups[spec.group]) continue;
const meta = { ...(FULL_GROUPS[spec.group] || {}), ...(groups[spec.group] || {}) };
if (meta.toggle && !(meta.toggle in instanceSchema)) delete meta.toggle;
instanceGroups[spec.group] = meta;
}
return { instanceSchema, instanceGroups };
}
const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor'];
/** With one card there is little else to see: the colours start unfolded. */
const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } };
const GEAR = {
/** Every source, the headings, the clock's format, the tones. */
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
/** One ProPresenter or module timer: its lines, and the tone its digits take per state. */
timer: gearOf(['showStatus', ...COLOURS], { loose: ['showStatus'], ...OPEN_COLOURS }),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns. */
ltc: gearOf(['showStatus', 'runningColor', 'warningColor', 'idleColor'], { loose: ['showStatus'], ...OPEN_COLOURS }),
/** The time of day: plain digits, so only how they read. */
clock: gearOf(['clockFormat', 'clockSeconds', 'clockDate']),
/** A clock another module offers: read like the clock, muted while it isn't running. */
providerClock: gearOf(['showStatus', 'clockFormat', 'clockSeconds', 'idleColor'], { loose: ['showStatus'], ...OPEN_COLOURS }),
};
/** The gear a solo card of this timer gets — by what the card can show. */
function gearFor(source, timer) {
if (source.kind === 'ltc') return GEAR.ltc;
if (source.kind === 'clock') return GEAR.clock;
if (timer.kind === 'clock') return GEAR.providerClock;
return GEAR.timer;
}
/* ── the module ───────────────────────────────────────────────────────── */ /* ── the module ───────────────────────────────────────────────────────── */
/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */ /** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */
@@ -1058,19 +1120,23 @@ module.exports = {
* The Add-tile picker's entries: "All timers" plus one solo card per known * The Add-tile picker's entries: "All timers" plus one solo card per known
* timer from any source (moduleApi.variant carries the entry id — 'all', * timer from any source (moduleApi.variant carries the entry id — 'all',
* or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep * or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep
* working). Answered from the merged state, never from upstream — the * working). Each entry carries the gear settings that fit its tile (see
* picker must stay instant and a dead upstream must not stall it. * GEAR above); descriptions are the picker's tooltips, one sentence each.
* Answered from the merged state, never from upstream — the picker must
* stay instant and a dead upstream must not stall it.
*/ */
tiles() { tiles() {
const state = current ? current.getState() : null; const state = current ? current.getState() : null;
if (!state) return []; if (!state) return [];
const KIND_LABEL = { countdown: 'Countdown', elapsed: 'Elapsed timer', clock: 'Clock' }; const KIND_WORD = { countdown: 'countdown', elapsed: 'elapsed timer', clock: 'clock' };
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }; const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
const out = [{ id: 'all', name: 'All timers', description: 'Every timer, clock and timecode — choose which per tile' }]; const out = [{ id: 'all', name: 'All timers', description: 'Every timer and clock, grouped by source', ...GEAR.all }];
for (const src of state.sources) { for (const src of state.sources) {
for (const t of src.timers) { for (const t of src.timers) {
const what = src.id === 'ltc' ? 'Incoming timecode (HH:MM:SS:FF)' : src.id === 'clock' ? 'Time of day' : KIND_LABEL[t.kind] || 'Timer'; const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description: `${src.label} · ${what}`, ...oneCard }); : src.kind === 'clock' ? 'The time of day on its own'
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t) });
} }
} }
return out; return out;