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:
@@ -18,6 +18,10 @@
|
||||
"Timers ▾" header menu, which hides any single timer — keyed `source:id`
|
||||
and saved with the layout via saveInstanceSettings. The header can be
|
||||
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:
|
||||
running elapsed, no planned length running
|
||||
@@ -56,6 +60,22 @@ const PAINT_MS = 200;
|
||||
|
||||
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) ─────────
|
||||
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
|
||||
counts every frame in between locally. That needs exact frame↔timecode
|
||||
@@ -174,6 +194,12 @@ export default function create({ root, moduleApi }) {
|
||||
let needFit = true;
|
||||
|
||||
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}`;
|
||||
|
||||
/* 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) {
|
||||
const key = SOURCE_SWITCH[source.kind] || 'showModules';
|
||||
return settings()[key] !== false;
|
||||
return pref(key) !== false;
|
||||
}
|
||||
|
||||
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
|
||||
@@ -424,13 +450,12 @@ export default function create({ root, moduleApi }) {
|
||||
/* ── clocks ─────────────────────────────────────────────────────── */
|
||||
|
||||
function clockOptions() {
|
||||
const s = settings();
|
||||
const h12 = s.clockFormat === '12h';
|
||||
const h12 = pref('clockFormat') === '12h';
|
||||
return {
|
||||
hour12: h12,
|
||||
hour: h12 ? 'numeric' : '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() {
|
||||
if (!state) return;
|
||||
const now = serverNow();
|
||||
const showStatus = settings().showStatus !== false;
|
||||
const showStatus = pref('showStatus') !== false;
|
||||
let charsChanged = false;
|
||||
for (const card of cards.values()) {
|
||||
const { item } = card;
|
||||
@@ -559,7 +584,7 @@ export default function create({ root, moduleApi }) {
|
||||
} else if (source.kind === 'clock') {
|
||||
text = wallClockText();
|
||||
tone = 'plain';
|
||||
statusText = settings().clockDate ? dateText() : '';
|
||||
statusText = pref('clockDate') ? dateText() : '';
|
||||
statusTone = 'muted';
|
||||
} else {
|
||||
text = providerClockText(timer, source, now);
|
||||
@@ -664,9 +689,8 @@ export default function create({ root, moduleApi }) {
|
||||
wrap.classList.toggle('tm-mono', mono);
|
||||
needFit = true;
|
||||
}
|
||||
const s = settings();
|
||||
const showHeadings = !solo && s.showHeadings !== false;
|
||||
const showStatus = s.showStatus !== false;
|
||||
const showHeadings = !solo && pref('showHeadings') !== false;
|
||||
const showStatus = pref('showStatus') !== false;
|
||||
wrap.classList.toggle('tm-headings', showHeadings);
|
||||
wrap.classList.toggle('tm-nostatus', !showStatus);
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": { "collapsed": false, "columns": 2 },
|
||||
"Clock": { "collapsed": false },
|
||||
"Clock": { "collapsed": false, "toggle": "showClock" },
|
||||
"Colours": {
|
||||
"collapsed": true,
|
||||
"columns": 2,
|
||||
@@ -97,12 +97,12 @@
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" },
|
||||
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" },
|
||||
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" },
|
||||
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" },
|
||||
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" },
|
||||
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
|
||||
"showClock": { "type": "switch", "label": "Clock", "default": true, "group": "Clock" },
|
||||
"clockFormat": {
|
||||
"type": "select",
|
||||
"label": "Clock format",
|
||||
|
||||
@@ -82,6 +82,7 @@ const http = require('http');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { createLtcListener } = require('./ltc-listener');
|
||||
const manifest = require('./module.json');
|
||||
|
||||
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
||||
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 ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** 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
|
||||
* timer from any source (moduleApi.variant carries the entry id — 'all',
|
||||
* or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep
|
||||
* working). Answered from the merged state, never from upstream — the
|
||||
* picker must stay instant and a dead upstream must not stall it.
|
||||
* working). Each entry carries the gear settings that fit its tile (see
|
||||
* 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() {
|
||||
const state = current ? current.getState() : null;
|
||||
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 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 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';
|
||||
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description: `${src.label} · ${what}`, ...oneCard });
|
||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||
: 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;
|
||||
|
||||
Reference in New Issue
Block a user