diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 4050e82..2be54b9 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -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); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index c1ad32e..2825eac 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -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", diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 950516b..10dc67f 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -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::'; the older 'timer:' / '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;