From bf1cabbc6b58689231612a4013efb47b68c320f1 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 13:03:30 -0400 Subject: [PATCH] Timers: every timer and clock in the room, from any source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Timers module becomes the one place a production tech looks for every timer and clock: ProPresenter's timers as before, the LTC timecode, a time-of-day clock (the Clock module's format / seconds / date, folded in as a sub-part — modules/clock itself is untouched), and timers other modules offer over the module guide's provider contract: PCO Plan's service / current item / countdown, or anything else declaring provides: ["timers"]. Server: a small `sources` abstraction. Each source turns its own world into the guide's standard timer objects and reports health — ProPresenter (the existing poller, wrapped, behind ppEnabled), LTC (the existing listener, untouched, as a kind:"clock" timer whose status.text is the timecode, with fps/df/ageMs in an `ltc` extension), Clock, and one module source per provider found in the shell's /api/modules over loopback (shell.port): /timers/stream when it answers as SSE, else /timers polled every 2 s; re-discovered every 30 s; backoff 3 s → 15 s; a provider's timers survive a 10 s grace and then go. PCO Plan sits behind pcoEnabled, the rest behind moduleTimersEnabled. The module is itself a provider: GET /timers and GET /timers/stream serve the merged set. The header comment says how to add a platform in a dozen lines. Client: one tile, every timer, grouped under source headings — label, the big time derived locally from the snapshot and a server-offset-corrected clock (so digits tick smoothly between snapshots), a status line, a detail line. Countdowns count down, elapsed count up, clocks show the time; the LTC card still counts frames. Feedback colours (running / warning near the end / overrun / idle) are per-tile colour settings applied as custom properties on the tile root, so they win over dashboard themes; left at their defaults they follow the theme. Gear settings: Clock, ProPresenter timers, LTC timecode, Module timers, Source headings, Status lines, the clock's format / seconds / date; a "Timers ▾" header menu hides any single timer (source:id, saved with the layout; the old hiddenItems and timer: / ltc picker variants keep working). Manifest: 1.3.0, proddash >=1.4.0, provides timers, configGroups with a toggle switch per integration (ProPresenter / LTC Timecode / PCO Plan / Other modules) and group help; switch labels are nouns. Co-Authored-By: Claude Fable 5.1 --- modules/propresenter-timers/client.js | 766 +++++++++---- modules/propresenter-timers/module.json | 90 +- modules/propresenter-timers/server.js | 1345 +++++++++++++++++------ modules/propresenter-timers/style.css | 317 ++++-- 4 files changed, 1849 insertions(+), 669 deletions(-) diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 94dd235..8e0920d 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -1,15 +1,59 @@ -/* ProPresenter Timers — client part. +/* Timers — client part. - The server part does all the ProPresenter talking and streams one merged - state object here over SSE. Each tile renders whichever timers (and/or the - LTC timecode) it has enabled, as auto-sized cards in a responsive grid. - Which items a tile shows is per-tile state, picked in a "Timers ▾" header - menu and persisted with the layout — the timer list is dynamic, so a - static instanceSchema field can't list them (same approach as the ProdCom - module's channels menu). Timers this tile has never seen default to - enabled: only explicit un-ticks are stored. */ + The server part follows every source (ProPresenter, the LTC listener, the + time of day, other modules offering timers) and streams one merged state + object here over SSE: sources, each with its health and its timers as the + module guide's standard timer objects. This tile renders every timer from + every source it has switched on, grouped under the source's heading, as + auto-sized cards — label, the big time, a status line, a detail line. -const LTC_KEY = '__ltc__'; + The time is derived LOCALLY. A snapshot says what a timer's value was at + its source's updatedAt; a running card counts on from there with this + browser's clock, offset-corrected against the server's, so digits tick + smoothly between snapshots and a slow provider never needs a fast poll. + The LTC card counts individual frames the same way (see the SMPTE math). + + Per-tile choices: the gear popover's static switches (whole sources, + headings, status lines, the clock's format, the state colours) and the + "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. + + Feedback — the state colours, and the thresholds behind them: + running elapsed, no planned length running + running elapsed with a planned length warning within + WARN_FLOOR_MS or WARN_FRACTION of the length (whichever is larger) + of the end; overrun once past it + running countdown warning with that much + left; overrun at zero and below + stopped countdown at zero ("complete") warning + paused / idle / stopped idle (muted) + the wall clock plain text colour + LTC running = running colour, + stopped = idle, no signal = idle digits + blinking warning line + The colours are the tile's Running / Warning / Overrun / Idle settings, + set as custom properties on the tile root so they win over the dashboard + theme; left at their defaults they follow the theme's variables. */ + +/** Which instance switch governs a source kind. */ +const SOURCE_SWITCH = { propresenter: 'showProPresenter', ltc: 'showLtc', clock: 'showClock', module: 'showModules' }; + +/** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */ +const COLOR_SETTINGS = [ + ['runningColor', '--tm-ok', '#2ee59a'], + ['warningColor', '--tm-warn', '#f0b429'], + ['overrunColor', '--tm-danger', '#f4433c'], + ['idleColor', '--tm-muted', '#8b98a5'], +]; + +/** Warning thresholds (see the header comment). */ +const WARN_FLOOR_MS = 60000; +const WARN_FRACTION = 0.1; + +/** How often running digits are repainted. Whole seconds, so 5×/s is plenty. */ +const PAINT_MS = 200; + +const LTC_KEY = 'ltc:ltc'; /* ── SMPTE timecode math (module-scope, exported for tests) ───────── The server anchors us ~4×/s with { time, fps, df, ageMs }; the card @@ -50,23 +94,105 @@ export function framesToTc(n, nominal, df) { return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`; } +/* ── timer math (module-scope, exported for tests) ──────────────────── */ + +/** + * A timer's value at server time `now`: remaining ms for a countdown, + * elapsed ms otherwise, or null when the snapshot can't say. The values as + * of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried + * forward while running; `startedAt` is the fallback when they are absent. + */ +export function valueAt(timer, sourceUpdatedAt, now) { + const running = timer.state === 'running'; + const at = Number(sourceUpdatedAt) || now; + const dt = running ? Math.max(0, now - at) : 0; + const has = (v) => v !== null && v !== undefined && Number.isFinite(v); + if (timer.kind === 'countdown') { + if (has(timer.remainingMs)) return timer.remainingMs - dt; + if (has(timer.targetMs) && has(timer.elapsedMs)) return timer.targetMs - timer.elapsedMs - dt; + if (running && has(timer.targetMs) && has(timer.startedAt)) return timer.targetMs - (now - timer.startedAt); + return null; + } + if (has(timer.elapsedMs)) return timer.elapsedMs + dt; + if (running && has(timer.startedAt)) return now - timer.startedAt; + return null; +} + +/** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */ +export function fmtDuration(ms) { + const sign = ms < 0 ? '-' : ''; + const s = Math.floor(Math.abs(ms) / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (v) => String(v).padStart(2, '0'); + return h ? `${sign}${h}:${pad(m)}:${pad(sec)}` : `${sign}${m}:${pad(sec)}`; +} + +function warnWindow(targetMs) { + return targetMs ? Math.max(WARN_FLOOR_MS, targetMs * WARN_FRACTION) : WARN_FLOOR_MS; +} + +/** The state colour for a (non-clock) timer given its current value. */ +export function toneFor(timer, value) { + if (timer.state === 'running') { + if (value === null) return 'ok'; + if (timer.kind === 'countdown') { + if (value <= 0) return 'danger'; + if (value <= warnWindow(timer.targetMs)) return 'warn'; + return 'ok'; + } + if (timer.targetMs !== null && timer.targetMs !== undefined && Number.isFinite(timer.targetMs)) { + if (value >= timer.targetMs) return 'danger'; + if (value >= timer.targetMs - warnWindow(timer.targetMs)) return 'warn'; + } + return 'ok'; + } + if (timer.state === 'stopped' && timer.kind === 'countdown' && value !== null && value <= 0) return 'warn'; + return 'muted'; +} + +function defaultStatus(timer, value, tone) { + if (timer.state === 'running') return tone === 'danger' ? 'Overrun' : 'Running'; + if (timer.state === 'paused') return 'Paused'; + if (timer.state === 'idle') return 'Ready'; + return timer.kind === 'countdown' && value !== null && value <= 0 ? 'Complete' : 'Stopped'; +} + +/* ── the tile ─────────────────────────────────────────────────────────── */ + export default function create({ root, moduleApi }) { let state = null; let feedOffline = false; let stream = null; let resizeObserver = null; - let hiddenItems = new Set(moduleApi.instanceSettings.hiddenItems || []); + let paintTimer = null; + /** Date.now() minus the server's clock, refined from every snapshot. */ + let clockOffset = NaN; + let needFit = true; - // Which picker entry this tile was added as (the module's tiles() list): - // '' or 'all' = every item, selectable via the Timers ▾ menu; - // 'timer:' or 'ltc' = exactly one card, no menu. + const settings = () => moduleApi.instanceSettings; + const keyOf = (source, timer) => `${source.id}:${timer.id}`; + + /* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier + ProPresenter-only tile stored `hiddenItems` (uuids, and __ltc__ for the + timecode) — read those too, and write them back in the new key. */ + const hidden = new Set(Array.isArray(settings().hiddenTimers) ? settings().hiddenTimers.map(String) : []); + for (const old of Array.isArray(settings().hiddenItems) ? settings().hiddenItems : []) { + hidden.add(old === '__ltc__' ? LTC_KEY : `propresenter:${old}`); + } + + /* Which picker entry this tile was added as (the module's tiles() list): + '' or 'all' = every timer, selectable via the Timers ▾ menu; + 'solo::' = exactly one card, no menu, no headings. The + earlier entries 'timer:' and 'ltc' map onto the new keys. */ 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 = ''; + const solo = variant === 'ltc' ? LTC_KEY + : variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}` + : variant.startsWith('solo:') ? variant.slice('solo:'.length) + : ''; + /** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */ + let lastSoloLabel = ''; /* ── DOM skeleton ───────────────────────────────────────────────── */ @@ -74,21 +200,45 @@ export default function create({ root, moduleApi }) {
-
+
`; const wrap = root.querySelector('.tm-wrap'); const bannerEl = root.querySelector('.tm-banner'); const hintEl = root.querySelector('.tm-hint'); - const gridEl = root.querySelector('.tm-grid'); + const groupsEl = root.querySelector('.tm-groups'); - /** key (uuid or LTC_KEY) → { el, nameEl, timeEl, stateEl } */ + /** key → { key, el, nameEl, timeEl, statusEl, detailEl, item, chars, cls } */ const cards = new Map(); + /** group id (source id) → { el, headEl, titleEl, noteEl, emptyEl, gridEl, count } */ + const groupEls = new Map(); - /* ── title-bar menu: pick which timers / LTC this tile shows ────── */ + /** The tile's colour settings → custom properties (only when changed from the theme-following default). */ + function applyColors() { + const s = settings(); + for (const [key, prop, fallback] of COLOR_SETTINGS) { + const v = String(s[key] || '').trim().toLowerCase(); + if (/^#[0-9a-f]{6}$/.test(v) && v !== fallback) wrap.style.setProperty(prop, v); + else wrap.style.removeProperty(prop); + } + } + + /* ── server clock ───────────────────────────────────────────────── */ + + function noteServerClock(serverNow) { + if (!Number.isFinite(serverNow)) return; + const sample = Date.now() - serverNow; + // A step (first sample, a resumed laptop) snaps; jitter is smoothed. + if (!Number.isFinite(clockOffset) || Math.abs(sample - clockOffset) > 1000) clockOffset = sample; + else clockOffset += (sample - clockOffset) * 0.2; + } + + const serverNow = () => Date.now() - (Number.isFinite(clockOffset) ? clockOffset : 0); + + /* ── title-bar menu: hide / show individual timers ──────────────── */ let menuEl = null; // the open menu's element (filled on each open) - const itemsMenu = soloKey ? null : moduleApi.header.addMenu({ + const itemsMenu = solo ? null : moduleApi.header.addMenu({ label: 'Timers ▾', title: 'Choose which timers this tile shows', build(menu) { @@ -99,20 +249,22 @@ export default function create({ root, moduleApi }) { }); function menuRows() { - const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name })); - // No LTC row while the listener is off in Admin — the item shouldn't - // even be offerable. - if (state?.ltcEnabled) rows.push({ key: LTC_KEY, name: 'LTC timecode' }); + const rows = []; + for (const source of state?.sources || []) { + for (const t of source.timers) rows.push({ key: keyOf(source, t), text: `${source.label} › ${t.label}` }); + } return rows; } function persistHidden() { - moduleApi.saveInstanceSettings({ hiddenItems: [...hiddenItems] }); + // `hiddenItems` was the old key: clearing it keeps a layout from + // re-hiding something the user has since re-enabled here. + moduleApi.saveInstanceSettings({ hiddenTimers: [...hidden], hiddenItems: undefined }); } - function toggleItem(key) { - if (hiddenItems.has(key)) hiddenItems.delete(key); - else hiddenItems.add(key); + function toggleTimer(key) { + if (hidden.has(key)) hidden.delete(key); + else hidden.add(key); persistHidden(); rebuildMenu(); render(); @@ -121,10 +273,8 @@ 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 - ? 'Timers ▾' - : `Timers (${visible}/${rows.length}) ▾`); + const visible = rows.filter((r) => !hidden.has(r.key)).length; + itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`); } function rebuildMenu() { @@ -132,118 +282,162 @@ export default function create({ root, moduleApi }) { if (!menuEl) return; // menu not opened yet — the label is enough menuEl.innerHTML = ''; const rows = menuRows(); - if (rows.length === 1) { + if (!rows.length) { const note = document.createElement('div'); note.className = 'tm-menu-note'; - note.textContent = 'No timers discovered yet'; + note.textContent = 'No timers known yet'; menuEl.appendChild(note); } for (const row of rows) { - const on = !hiddenItems.has(row.key); + const on = !hidden.has(row.key); const item = document.createElement('button'); item.className = 'tm-menu-item' + (on ? ' on' : ''); item.setAttribute('role', 'menuitemcheckbox'); item.setAttribute('aria-checked', String(on)); - const check = document.createElement('span'); - check.className = 'check'; - check.textContent = '✓'; const name = document.createElement('span'); name.className = 'name'; - name.textContent = row.name; - item.append(check, name); + name.textContent = row.text; + const sw = document.createElement('span'); + sw.className = 'tm-switch'; + item.append(name, sw); item.addEventListener('click', (e) => { // keep the menu open: the rebuild detaches this item, which would - // make the outside-click handler think we clicked outside + // make the shell's outside-click handler think we clicked outside e.stopPropagation(); - toggleItem(row.key); + toggleTimer(row.key); }); menuEl.appendChild(item); } } - /* ── state → cards ──────────────────────────────────────────────── */ + /* ── state → what this tile shows ───────────────────────────────── */ - /** Map the API's state string onto our four looks. The spec's enum is - stopped|running|complete|overrunning|overran, but its own response - example says "overrun" — match the substring. */ - function classify(apiState) { - const s = String(apiState || '').toLowerCase(); - if (s.includes('overr')) return 'overrun'; - if (s === 'running') return 'running'; - if (s === 'complete') return 'complete'; - return 'stopped'; + function sourceShown(source) { + const key = SOURCE_SWITCH[source.kind] || 'showModules'; + return settings()[key] !== false; } - const STATE_TEXT = { running: 'Running', stopped: 'Stopped', complete: 'Complete', overrun: 'Overrun' }; - - /** Show the value the way ProPresenter reports it (sign included); - only the ".00" hundredths some builds append are dropped for fit. */ - function displayTime(raw) { - const s = String(raw || '').replace(/\.\d+$/, ''); - return s || '—'; - } - - 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 }]; + /** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ + function visibleGroups() { + if (!state) return []; + if (solo) { + for (const source of state.sources) { + for (const timer of source.timers) { + if (keyOf(source, timer) !== solo) continue; + lastSoloLabel = timer.label; + return [{ id: source.id, source, items: [{ key: solo, source, 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 }]; + // The timer this tile was added for isn't offered (yet, or any more) — + // keep a clearly-labeled card up rather than erroring. + const sourceId = solo.slice(0, solo.indexOf(':')); + const source = state.sources.find((s) => s.id === sourceId) || null; + return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }]; } - const items = []; - for (const t of state?.timers || []) { - if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t }); + const groups = []; + for (const source of state.sources) { + if (!sourceShown(source)) continue; + const items = []; + for (const timer of source.timers) { + const key = keyOf(source, timer); + if (!hidden.has(key)) items.push({ key, source, timer }); + } + // Every timer of a live source hidden on purpose: nothing to say. + if (!items.length && source.timers.length) continue; + groups.push({ id: source.id, source, items }); } - if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState }); - return items; + return groups; } + /* ── cards ──────────────────────────────────────────────────────── */ + function makeCard(item) { const el = document.createElement('div'); - el.className = 'tm-card' + (item.ltc ? ' tm-card-ltc' : ''); + el.className = 'tm-card'; const nameEl = document.createElement('div'); nameEl.className = 'tm-name'; const timeEl = document.createElement('div'); timeEl.className = 'tm-time'; - const stateEl = document.createElement('div'); - stateEl.className = 'tm-state'; - if (item.ltc) { + const statusEl = document.createElement('div'); + statusEl.className = 'tm-status'; + const detailEl = document.createElement('div'); + detailEl.className = 'tm-detail'; + el.append(nameEl, timeEl, statusEl, detailEl); + return { key: item.key, el, nameEl, timeEl, statusEl, detailEl, item, chars: 5, cls: '' }; + } + + function setText(el, text) { + if (el.textContent !== text) el.textContent = text; + } + + function setCardClass(card, cls) { + if (card.cls === cls) return; + card.cls = cls; + card.el.className = cls; + } + + /** The parts of a card that only change with the snapshot (label, kind). */ + function updateCard(card, item) { + card.item = item; + const isLtc = item.source?.kind === 'ltc'; + if (isLtc && !card.nameEl.querySelector('.tm-ltc-badge')) { const badge = document.createElement('span'); badge.className = 'tm-ltc-badge'; badge.textContent = 'LTC'; - nameEl.append(badge, document.createTextNode('Timecode')); + card.nameEl.replaceChildren(badge, document.createTextNode('')); + } else if (!isLtc && card.nameEl.querySelector('.tm-ltc-badge')) { + card.nameEl.replaceChildren(document.createTextNode('')); } - el.append(nameEl, timeEl, stateEl); - return { el, nameEl, timeEl, stateEl }; + const label = item.missing ? (lastSoloLabel || 'Timer') : item.timer.label; + const textNode = card.nameEl.lastChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) setText(textNode, label); + else card.nameEl.appendChild(document.createTextNode(label)); } - /** 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'; - } + const source = card.item.source; + setText(card.timeEl, '—'); + card.chars = 5; + let note; + if (!source) note = 'Source not available — check Admin'; + else if (source.status === 'ok') note = `Not offered by ${source.label}`; + else note = '—'; + setText(card.statusEl, note); + card.statusEl.hidden = false; + card.statusEl.className = 'tm-status is-note'; + card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted'; + card.detailEl.hidden = true; + setCardClass(card, 'tm-card is-muted is-missing'); } - function updateTimerCard(card, timer) { - card.nameEl.textContent = timer.name; - card.timeEl.textContent = displayTime(timer.time); - const kind = classify(timer.state); - card.stateEl.textContent = timer.state ? (STATE_TEXT[kind] || timer.state) : '—'; - card.el.className = `tm-card is-${kind}`; + /* ── clocks ─────────────────────────────────────────────────────── */ + + function clockOptions() { + const s = settings(); + const h12 = s.clockFormat === '12h'; + return { + hour12: h12, + hour: h12 ? 'numeric' : '2-digit', + minute: '2-digit', + ...(s.clockSeconds !== false ? { second: '2-digit' } : {}), + }; + } + + function wallClockText() { + return new Date().toLocaleTimeString([], clockOptions()); + } + + function dateText() { + return new Date().toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); + } + + /** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */ + function providerClockText(timer, source, now) { + const raw = String(timer.status?.text || ''); + const d = new Date(raw); + if (!raw || Number.isNaN(d.getTime())) return raw || '—'; + const dt = timer.state === 'running' ? Math.max(0, now - (Number(source.updatedAt) || now)) : 0; + return new Date(d.getTime() + dt).toLocaleTimeString([], clockOptions()); } /* ── real-time LTC: count every frame between server anchors ─────── */ @@ -260,10 +454,15 @@ export default function create({ root, moduleApi }) { frames the next update would visibly rewind. */ const ANCHOR_FRESH_MS = 350; + function ltcTimer() { + const source = state?.sources.find((s) => s.kind === 'ltc'); + return source ? source.timers.find((t) => t.id === 'ltc') || null : null; + } + function updateLtcAnchor() { - const l = state?.ltc; + const l = ltcTimer()?.ltc; // The listener is extrapolatable — it knows the frame rate. - const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener'; + const live = l && l.status === 'running' && l.fps > 0; const tc = live ? parseTc(l.time) : null; if (!tc) { ltcAnchor = null; @@ -287,9 +486,9 @@ export default function create({ root, moduleApi }) { } /** Paint the frame the anchor implies for right now. Called both by the - rAF loop (smooth, per-frame, on visible tabs) and by render() at the - 4×/s anchor rate — so a throttled/backgrounded tab, where rAF is - paused, still advances at the anchor rate instead of freezing. */ + rAF loop (smooth, per-frame, on visible tabs) and by paint() at the + anchor rate — so a throttled/backgrounded tab, where rAF is paused, + still advances instead of freezing. */ function paintLtcFrame() { const a = ltcAnchor; const card = cards.get(LTC_KEY); @@ -314,48 +513,83 @@ export default function create({ root, moduleApi }) { ltcRaf = requestAnimationFrame(ltcTick); } - function updateLtcCard(card, ltc) { - let mode; - // Only the audio sources know the frame rate (decoded from the LTC - // bits); a set drop-frame flag implies 29.97 whatever fps rounds to. - const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : ''; - if (ltc.supported === false) { - mode = 'unavailable'; - card.timeEl.textContent = '—'; - // The server says why when it knows (listener disabled, no device - // selected, capture failed, starting up…) — surface that. - card.stateEl.textContent = ltc.note || 'LTC not available'; - } else if (ltc.receiving) { - mode = 'receiving'; - // With an anchor active, paint the frame it implies for now (this - // keeps a throttled tab advancing at the 4×/s anchor rate); the rAF - // loop refines it to every frame when the tab is visible. Without an - // anchor (rate unknown), fall back to the raw anchor timecode. - if (ltcAnchor) paintLtcFrame(); - else card.timeEl.textContent = displayTime(ltc.time); - card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running'; - } else if (ltc.status === 'stopped' || ltc.time) { - // LTC ceased but we know where it stopped — hold the last frame. - mode = 'stopped'; - card.timeEl.textContent = displayTime(ltc.time); - card.stateEl.textContent = 'Stopped'; - } else { - mode = 'nosignal'; - card.timeEl.textContent = '—'; - card.stateEl.textContent = 'No signal'; + /* ── painting the numbers (every PAINT_MS, and on every snapshot) ── */ + + function paint() { + if (!state) return; + const now = serverNow(); + const showStatus = settings().showStatus !== false; + let charsChanged = false; + for (const card of cards.values()) { + const { item } = card; + if (item.missing) { + updateMissingCard(card); + continue; + } + const { source, timer } = item; + let text; + let tone; + let statusText = ''; + let statusTone = ''; + let detailText = ''; + let extra = ''; + if (timer.kind === 'clock') { + if (source.kind === 'ltc') { + const l = timer.ltc || {}; + text = l.time || timer.status?.text || '—'; + if (timer.state === 'running') tone = 'ok'; + else if (timer.state === 'stopped') tone = 'muted'; + else { + tone = 'muted'; + extra = l.status === 'nosignal' ? ' is-nosignal' : ' is-unavailable'; + } + statusText = timer.detail || ''; + statusTone = timer.status?.tone || (tone === 'ok' ? 'ok' : extra ? 'warn' : 'muted'); + } else if (source.kind === 'clock') { + text = wallClockText(); + tone = 'plain'; + statusText = settings().clockDate ? dateText() : ''; + statusTone = 'muted'; + } else { + text = providerClockText(timer, source, now); + tone = timer.state === 'running' ? 'plain' : 'muted'; + statusText = timer.detail || ''; + statusTone = timer.status?.tone || 'muted'; + } + } else { + const value = valueAt(timer, source.updatedAt, now); + text = value === null ? '—' : fmtDuration(value); + tone = toneFor(timer, value); + statusText = timer.status?.text || defaultStatus(timer, value, tone); + statusTone = timer.status?.tone || tone; + detailText = timer.detail || ''; + if (tone === 'danger' && timer.state === 'running') extra = ' is-overrun'; + } + // The LTC card's digits belong to the frame counter while an anchor is live. + if (!(card.key === LTC_KEY && ltcAnchor)) setText(card.timeEl, text); + else paintLtcFrame(); + const chars = Math.max(5, text.length); + if (chars !== card.chars) { + card.chars = chars; + charsChanged = true; + } + // Notes that must survive the compact look: an unavailable LTC input. + const isNote = extra === ' is-unavailable'; + setText(card.statusEl, statusText); + card.statusEl.hidden = !statusText || (!showStatus && !isNote); + card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : ''); + card.statusEl.dataset.tone = statusTone; + setText(card.detailEl, detailText); + card.detailEl.hidden = !detailText || !showStatus; + const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : ''; + setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`); } - // The built-in listener is independent of ProPresenter, so its card - // must not gray out with the rest when only ProPresenter is unreachable. - const live = ltc.source === 'listener' ? ' src-reader' : ''; - card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`; + if (charsChanged) fit(); } - /* ── status dot + degraded banners ──────────────────────────────── */ + /* ── status dot + degraded banner ───────────────────────────────── */ function renderStatus() { - wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false)); - // The stale gray-out spares the reader-fed LTC card — unless the feed - // from the ProdDash server itself is down, when everything is stale. wrap.classList.toggle('is-feed-down', feedOffline); if (feedOffline) { bannerEl.hidden = false; @@ -363,30 +597,49 @@ export default function create({ root, moduleApi }) { moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); return; } + bannerEl.hidden = true; if (!state) { - bannerEl.hidden = true; moduleApi.setStatus('connecting', 'Connecting…'); return; } - if (!state.enabled) { - bannerEl.hidden = true; - // The LTC listener feeds us with or without ProPresenter — an - // LTC-only setup is working, not broken. - if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC only — no ProPresenter host configured'); - else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + const groups = visibleGroups(); + const shown = groups.map((g) => g.source).filter(Boolean); + const say = (list) => list.map((s) => `${s.label}: ${s.message}`).join(' · '); + const bad = shown.filter((s) => s.status === 'error'); + if (bad.length) { + moduleApi.setStatus('error', say(bad)); return; } - if (!state.reachable) { - bannerEl.hidden = false; - bannerEl.textContent = 'ProPresenter unreachable — reconnecting…'; - moduleApi.setStatus('error', 'ProPresenter unreachable — reconnecting…'); + const waiting = shown.filter((s) => s.status === 'connecting'); + if (waiting.length) { + moduleApi.setStatus('connecting', say(waiting)); return; } - bannerEl.hidden = true; - moduleApi.setStatus('ok', 'Following ProPresenter timers'); + const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0); + moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`); } - /* ── main render: diff the card set, update in place ────────────── */ + /* ── main render: diff groups and cards, update in place ────────── */ + + function makeGroup(id) { + const el = document.createElement('section'); + el.className = 'tm-group'; + el.dataset.source = id; + const headEl = document.createElement('div'); + headEl.className = 'tm-head'; + const titleEl = document.createElement('span'); + titleEl.className = 'tm-title'; + const noteEl = document.createElement('span'); + noteEl.className = 'tm-note'; + headEl.append(titleEl, noteEl); + const emptyEl = document.createElement('div'); + emptyEl.className = 'tm-empty'; + emptyEl.hidden = true; + const gridEl = document.createElement('div'); + gridEl.className = 'tm-grid'; + el.append(headEl, emptyEl, gridEl); + return { el, headEl, titleEl, noteEl, emptyEl, gridEl, count: 0 }; + } function render() { renderStatus(); @@ -398,96 +651,164 @@ export default function create({ root, moduleApi }) { const mono = state?.timerFont === 'monospace'; if (mono !== wrap.classList.contains('tm-mono')) { wrap.classList.toggle('tm-mono', mono); - fit(); + needFit = true; } + const s = settings(); + const showHeadings = !solo && s.showHeadings !== false; + const showStatus = s.showStatus !== false; + wrap.classList.toggle('tm-headings', showHeadings); + wrap.classList.toggle('tm-nostatus', !showStatus); let hint = ''; - if (state && !state.enabled && !state.ltc?.supported) { - hint = 'No ProPresenter connection — set the host and port in Admin.'; - } - const items = hint ? [] : visibleItems(); - if (!hint && state?.reachable && !items.length) { - hint = 'Nothing selected — pick timers in the Timers ▾ menu.'; - } + const groups = visibleGroups(); + if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.'; + else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.'; hintEl.hidden = !hint; hintEl.textContent = hint; - gridEl.hidden = Boolean(hint); - if (hint) return; + groupsEl.hidden = Boolean(hint); + if (hint || !state) return; - const wanted = new Set(items.map((i) => i.key)); let structureChanged = false; + const wantedKeys = new Set(); + const orderedGroups = []; + for (const g of groups) { + let ge = groupEls.get(g.id); + if (!ge) { + ge = makeGroup(g.id); + groupEls.set(g.id, ge); + structureChanged = true; + } + const source = g.source; + const status = source ? source.status : 'ok'; + const message = source ? source.message : ''; + ge.headEl.hidden = !showHeadings; + setText(ge.titleEl, source ? source.label : 'Timer'); + setText(ge.noteEl, status !== 'ok' ? message : g.items.length ? '' : 'no timers'); + // Without headings the source's trouble still has to be said somewhere. + const emptyText = status !== 'ok' ? message : 'No timers'; + const showEmpty = !showHeadings && (status !== 'ok' || !g.items.length); + ge.emptyEl.hidden = !showEmpty; + setText(ge.emptyEl, showEmpty ? emptyText : ''); + ge.el.classList.toggle('is-down', status !== 'ok'); + ge.el.classList.toggle('is-stale', status === 'error' && g.items.length > 0); + + const els = []; + for (const item of g.items) { + let card = cards.get(item.key); + if (!card) { + card = makeCard(item); + cards.set(item.key, card); + structureChanged = true; + } + updateCard(card, item); + wantedKeys.add(item.key); + els.push(card.el); + } + if (ge.count !== els.length) structureChanged = true; + ge.count = els.length; + if (els.length !== ge.gridEl.children.length || els.some((el, i) => ge.gridEl.children[i] !== el)) { + ge.gridEl.replaceChildren(...els); + structureChanged = true; + } + orderedGroups.push(ge.el); + } for (const [key, card] of cards) { - if (!wanted.has(key)) { - card.el.remove(); - cards.delete(key); - structureChanged = true; - } + if (wantedKeys.has(key)) continue; + card.el.remove(); + cards.delete(key); + structureChanged = true; } - for (const item of items) { - let card = cards.get(item.key); - if (!card) { - card = makeCard(item); - cards.set(item.key, card); - structureChanged = true; - } - if (item.ltc) updateLtcCard(card, item.ltc); - else if (item.missing) updateMissingCard(card); - else updateTimerCard(card, item.timer); + for (const [id, ge] of groupEls) { + if (groups.some((g) => g.id === id)) continue; + ge.el.remove(); + groupEls.delete(id); + structureChanged = true; } - // Keep DOM order in step with the state's order (config-list order). - const orderedEls = items.map((i) => cards.get(i.key).el); - if (structureChanged || orderedEls.some((el, i) => gridEl.children[i] !== el)) { - gridEl.replaceChildren(...orderedEls); + if (orderedGroups.length !== groupsEl.children.length || orderedGroups.some((el, i) => groupsEl.children[i] !== el)) { + groupsEl.replaceChildren(...orderedGroups); + structureChanged = true; + } + paint(); + if (structureChanged || needFit) { + needFit = false; fit(); } } - /* ── sizing: pick the column count that gives the biggest digits ── */ + /* ── sizing: the column count that gives the biggest digits ─────── */ function fit() { - const n = Math.max(1, gridEl.childElementCount); + const list = [...cards.values()]; + const n = list.length; const W = root.clientWidth; const H = root.clientHeight; - if (!W || !H) return; + if (!n || !W || !H) return; const PAD = H < 120 ? 4 : 10; const GAP = H < 120 ? 5 : 8; const CARD_PAD = 12; // a card's own vertical padding + borders + const HEAD_H = 20; // a source heading line, margin included + const groups = [...groupEls.values()].filter((g) => g.el.isConnected); + const headings = wrap.classList.contains('tm-headings') ? groups.length : 0; + const showStatus = !wrap.classList.contains('tm-nostatus'); + // Lines under the digits besides the label: status, and detail if any card has one. + const metaLines = showStatus ? 1 + (list.some((c) => !c.detailEl.hidden) ? 1 : 0) : 0; - // For each column count, budget a card's full stack (name line, digits, - // state line) and score by the digit size it affords ("-00:00:00" ≈ 9 - // tabular chars wide). Cards too short for three readable lines drop to - // a compact look — no state text, the card color carries the state — - // but any layout with a readable full stack beats every compact one. + // For each column count, budget a card's full stack (label, digits, + // lines) and score by the digit size it affords — the SMALLEST card's, + // so no timer ends up unreadable. Cards too short for the full stack + // drop to a compact look (label + digits, the colour carries the + // state), but any layout with a readable full stack beats every + // compact one. let best = null; for (let cols = 1; cols <= n; cols += 1) { - const rows = Math.ceil(n / cols); + const rows = headings + ? groups.reduce((a, g) => a + Math.max(1, Math.ceil(g.count / cols)), 0) + : Math.ceil(n / cols); const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18; - const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows - CARD_PAD; + const ch = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1) - (headings ? (headings - 1) * GAP : 0)) / rows - CARD_PAD; if (cw <= 0 || ch <= 0) continue; const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16))); - const byWidth = (cw * 1.55) / 9; - const full = Math.min(byWidth, ch - 2.6 * meta); - const compact = Math.min(byWidth, ch - 1.3 * meta); + const fullBudget = ch - (1 + metaLines) * 1.3 * meta; + const compactBudget = ch - 1.3 * meta; + let full = Infinity; + let compact = Infinity; + for (const c of list) { + const byWidth = (cw * 1.55) / c.chars; + full = Math.min(full, byWidth, fullBudget); + compact = Math.min(compact, byWidth, compactBudget); + } const mode = full >= 14 ? 'full' : 'compact'; const size = mode === 'full' ? full : compact; const score = (mode === 'full' ? 1000 : 0) + size; - if (!best || score > best.score) best = { cols, meta, mode, size, cw, score }; + if (!best || score > best.score) best = { cols, meta, mode, cw, budget: mode === 'full' ? fullBudget : compactBudget, score }; } if (!best) return; - gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`; + for (const g of groups) { + g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`; + // Space is shared in proportion to each group's rows, so a card is + // the same height under every heading. + g.el.style.flexGrow = String(Math.max(1, Math.ceil(g.count / best.cols))); + } wrap.style.setProperty('--tm-pad', PAD + 'px'); wrap.style.setProperty('--tm-gap', GAP + 'px'); - wrap.classList.toggle('tm-compact', best.mode === 'compact'); - const timeSize = Math.max(11, Math.floor(best.size)); - // LTC digits ("00:00:00:00") are ~11.5 tabular chars wide in the same cell. - const ltcSize = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / 11.5, best.size))); - wrap.style.setProperty('--tm-time-size', timeSize + 'px'); - wrap.style.setProperty('--tm-ltc-size', ltcSize + 'px'); wrap.style.setProperty('--tm-meta-size', best.meta + 'px'); + wrap.classList.toggle('tm-compact', best.mode === 'compact'); + for (const c of list) { + const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, best.budget))); + c.timeEl.style.fontSize = px + 'px'; + } } /* ── live feed ──────────────────────────────────────────────────── */ + function takeState(next) { + if (!next || typeof next !== 'object' || !Array.isArray(next.sources)) return; + noteServerClock(Number(next.serverNow)); + state = next; + feedOffline = false; + render(); + } + function connect() { stream = moduleApi.sse('/stream', { open() { @@ -502,13 +823,13 @@ export default function create({ root, moduleApi }) { }, events: { state(ev) { + let parsed; try { - state = JSON.parse(ev.data); + parsed = JSON.parse(ev.data); } catch { return; } - feedOffline = false; - render(); + takeState(parsed); }, }, }); @@ -518,15 +839,13 @@ export default function create({ root, moduleApi }) { return { start() { + applyColors(); moduleApi.setStatus('connecting', 'Connecting…'); // Instant paint from the snapshot, then live updates. moduleApi.fetch('/state') .then((res) => (res.ok ? res.json() : null)) .then((body) => { - if (body && body.state && !state) { - state = body.state; - render(); - } + if (body && body.state && !state) takeState(body.state); }) .catch(() => { /* the stream will cover it */ }); connect(); @@ -542,17 +861,22 @@ export default function create({ root, moduleApi }) { fit(); }); resizeObserver.observe(root); + paintTimer = setInterval(paint, PAINT_MS); render(); }, stop() { stream?.close(); stream = null; + clearInterval(paintTimer); + paintTimer = null; resizeObserver?.disconnect(); resizeObserver = null; if (ltcRaf) { cancelAnimationFrame(ltcRaf); ltcRaf = 0; } + cards.clear(); + groupEls.clear(); root.innerHTML = ''; // header controls are removed by the shell }, onResize() { diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index c6f56b1..3f11242 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,9 +1,10 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.2.1", - "proddash": ">=1.2.0", - "description": "Live ProPresenter timers and LTC timecode", + "version": "1.3.0", + "proddash": ">=1.4.0", + "description": "Timers, Clocks, and LTC From configured Modules", + "provides": ["timers"], "permissions": [ { "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" }, { "kind": "local-network", "reason": "Reaching ProPresenter on this network" } @@ -12,8 +13,32 @@ "server": "server.js", "style": "style.css", "minSize": { "w": 2, "h": 1 }, - "defaultSize": { "w": 4, "h": 2 }, + "defaultSize": { "w": 4, "h": 3 }, + "configGroups": { + "ProPresenter": { + "toggle": "ppEnabled", + "help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled." + }, + "LTC Timecode": { + "toggle": "ltcEnabled", + "help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)." + }, + "PCO Plan": { + "toggle": "pcoEnabled", + "help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side." + }, + "Other modules": { + "toggle": "moduleTimersEnabled", + "help": "Any other installed module that offers timers (\"provides\": [\"timers\"] in its manifest) is found automatically and listed under its own heading. Nothing to configure here." + } + }, "configSchema": { + "ppEnabled": { + "type": "switch", + "label": "ProPresenter timers", + "group": "ProPresenter", + "default": true + }, "propresenter": { "type": "endpoint", "label": "ProPresenter", @@ -22,13 +47,14 @@ }, "password": { "type": "password", - "label": "API password (only if set in ProPresenter)", + "label": "API password", "group": "ProPresenter", - "default": "" + "default": "", + "help": "Only if a password is set in ProPresenter's network preferences." }, "ltcEnabled": { "type": "switch", - "label": "LTC listener — decode timecode from an audio input on this machine", + "label": "LTC timecode", "group": "LTC Timecode", "default": false }, @@ -42,10 +68,23 @@ }, "ltcChannel": { "type": "number", - "label": "Channel on that device (1-based)", + "label": "Channel", "group": "LTC Timecode", "showWhen": "ltcEnabled", - "default": 1 + "default": 1, + "help": "The channel on that device carrying the timecode, counted from 1." + }, + "pcoEnabled": { + "type": "switch", + "label": "PCO Plan timers", + "group": "PCO Plan", + "default": true + }, + "moduleTimersEnabled": { + "type": "switch", + "label": "Module timers", + "group": "Other modules", + "default": true }, "timerFont": { "type": "select", @@ -57,5 +96,38 @@ { "value": "monospace", "label": "Monospace — steadier changing digits" } ] } + }, + "instanceGroups": { + "Show": { "collapsed": false, "columns": 2 }, + "Clock": { "collapsed": false }, + "Colours": { + "collapsed": true, + "columns": 2, + "help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it." + } + }, + "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" }, + "clockFormat": { + "type": "select", + "label": "Clock format", + "default": "24h", + "group": "Clock", + "options": [ + { "value": "24h", "label": "24-hour" }, + { "value": "12h", "label": "12-hour" } + ] + }, + "clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" }, + "clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" }, + "runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" }, + "warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" }, + "overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" }, + "idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 41b2926..391c9a4 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -1,393 +1,1111 @@ 'use strict'; /** - * ProPresenter Timers — server part. + * Timers — server part. * - * Polls the ProPresenter openAPI for the configured timer list and the live - * timer values, decodes LTC timecode from an audio input, and fans one - * merged state object out to every tile over SSE. Browsers never talk to - * ProPresenter or the audio hardware directly. + * One tile for every timer and clock in the room, from any source. Each + * SOURCE turns its own world into the module guide's standard timer objects + * ({ id, label, kind, state, startedAt, elapsedMs, remainingMs, targetMs, + * status, detail } — see "Offering timers to the Timers module") and reports + * its health. This file merges the sources, fans the merged state out to the + * tiles over SSE, and offers the whole set to the rest of ProdDash as a + * timers provider itself. Browsers never talk to ProPresenter, the audio + * hardware or other modules directly. * - * GET /state → { state: } (instant paint for new tiles) - * GET /stream → SSE, `state` events (change-detected, heartbeated) - * GET /ltc/devices → this machine's audio input devices, for the admin - * page's device picker (select + optionsRoute). + * Sources (one factory each, below): + * propresenter the ProPresenter openAPI timer list + live values, polled + * exactly as before (admin switch: ppEnabled) + * ltc SMPTE LTC decoded from an audio input by the built-in + * listener (ltc-listener.js / ltc.js — untouched), shown as a + * kind:"clock" timer whose status.text is the timecode and + * whose `ltc` extension carries fps/df/ageMs so the tile can + * count frames between reports (admin switch: ltcEnabled) + * clock time of day. Trivial here — the tile paints its own wall + * clock — it exists so the clock is one more entry in + * /timers and in the tile's list, like everything else + * modules every other module whose manifest says + * "provides": ["timers"], discovered from the shell's + * /api/modules over loopback (shell.port, never guessed) and + * read through its /timers/stream when that answers as SSE, + * else its /timers polled every 2 s. Re-discovered every 30 s + * so a newly installed provider appears without a restart. + * PCO Plan sits behind pcoEnabled, everything else behind + * moduleTimersEnabled. * - * Endpoints used, verified against openapi.propresenter.com (API v1): + * ADDING A SOURCE (another timer platform) is a dozen lines. Write a factory + * that returns + * + * { id, label, kind, start(emit), stop(), health() } + * + * id short, stable ('propresenter', 'ltc', …) — the tile keys its + * show/hide settings on `${id}:${timer.id}` + * label the heading the tile puts over this source's timers + * kind which tile switch governs it: 'propresenter' | 'ltc' | 'clock' + * | 'module' (a new platform would add its own switch and kind) + * start(emit) begins following the upstream and calls + * emit({ status, message, timers, updatedAt }) whenever the view + * changes — status 'ok' | 'connecting' | 'error', message one + * human line (shown under the heading while not ok), timers an + * array of standard timer objects as of updatedAt + * stop() tears everything down (a re-init follows every admin save) + * health() → { status, message } for the admin health line + * + * then, in init(), push it onto `fixed` behind its own admin `switch` field + * (named as that group's `toggle` in module.json). Merging, change + * detection, both SSE feeds, /timers and the tile are generic — nothing + * else changes. + * + * Routes: + * GET /state → { state } tile snapshot (instant paint) + * GET /stream → SSE `state` events tile feed, change-detected + * GET /timers → provider snapshot the module guide's contract + * GET /timers/stream → SSE `timers` events same, for other consumers + * GET /ltc/devices → this machine's audio inputs, for the admin page's + * device picker (select + optionsRoute) + * + * ProPresenter endpoints, verified against openapi.propresenter.com (v1): * GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun, * countdown:{duration} | count_down_to_time:{time_of_day,period} * | elapsed:{start_time,end_time} } ] * GET /v1/timers/current → [ { id:{uuid,name,index}, time:"HH:MM:SS", state } ] * `time` may be negative ("-00:00:02") while overrunning, and some builds - * append hundredths ("00:00:01.00" appears in the spec's schema example). - * `state` enum is stopped|running|complete|overrunning|overran — but the - * spec's own response example says "overrun", so treat anything containing - * "overr" as overrun. The API exposes NO per-timer color anywhere. - * - * LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has - * no timecode route, and the classic stage-display websocket drops the - * field — both verified dead on ProPresenter 21.4), so LTC is read from the - * audio signal itself. Behind the admin "LTC listener" switch: off means no - * LTC tile in the picker and nothing captured; on, the built-in listener - * (ltc-listener.js) captures the admin-selected device/channel and decodes - * in-process. It is the module's one and only LTC source. - * - * ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path - * notes in ../propresenter-now-next/propresenter-core). So: requests run - * strictly sequentially, the next tick is scheduled only after the previous - * one finished, live values are the only every-tick call, and the timer list - * rides a slow lane. The endpoints do offer `?chunked` streaming, but polling - * matches how propresenter-core survives real-world ProPresenter quirks. + * append hundredths. `state` enum is stopped|running|complete|overrunning| + * overran — but the spec's own example says "overrun", so anything + * containing "overr" is overrun. The API exposes NO per-timer color. + * ProPresenter's HTTP server is easily overwhelmed, so requests run strictly + * sequentially, live values are the only every-tick call, and the timer + * list rides a slow lane. */ -/** Hot-path cadence — live timer values. */ -const POLL_MS = 500; -/** Backoff while ProPresenter is unreachable. */ -const RETRY_MS = 2500; -/** The configured-timer list refreshes every Nth tick (~2s at 500ms). */ -const SLOW_EVERY = 4; -/** Abort a hung request well before it can stall the loop for good. */ -const REQUEST_TIMEOUT_MS = 4000; -/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ -const HEARTBEAT_MS = 15000; - +const http = require('http'); +const path = require('path'); const { spawn } = require('child_process'); const { createLtcListener } = require('./ltc-listener'); -/** Set by init(), read by the routes — both are rebuilt together on remount. */ +/** This module's id — left out of provider discovery (we don't consume ourselves). */ +const SELF_ID = 'propresenter-timers'; +/** The provider the admin "PCO Plan" switch governs; every other provider follows "Other modules". */ +const PCO_ID = 'pco-plan'; + +/* ProPresenter cadence. */ +const POLL_MS = 500; +const RETRY_MS = 2500; +const SLOW_EVERY = 4; +const REQUEST_TIMEOUT_MS = 4000; + +/* Other modules' timers. */ +const DISCOVER_MS = 30000; // re-read /api/modules: a newly installed provider shows up without a restart +const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot) +const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens +const MODULE_POLL_MS = 2000; // GET /timers cadence for a provider with no stream +const BACKOFF_MIN_MS = 3000; +const BACKOFF_MAX_MS = 15000; +const GRACE_MS = 10000; // a provider's timers survive this long without a fresh snapshot +const STREAM_SILENCE_MS = 45000; // a stream with no bytes for this long (heartbeats are 15 s) is dead +const MAX_TIMERS_PER_PROVIDER = 50; + +/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ +const HEARTBEAT_MS = 15000; + +const KINDS = new Set(['elapsed', 'countdown', 'clock']); +const STATES = new Set(['running', 'paused', 'stopped', 'idle']); +const TONES = new Set(['ok', 'warn', 'danger', 'muted']); + +/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */ let current = null; -function clearedState(enabled, ltcEnabled = false, timerFont = 'default') { +/* ── small helpers ────────────────────────────────────────────────────── */ + +function sendJson(res, code, body) { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); +} + +/** Open an SSE response, send the first frame, and register it in `set`. */ +function openSse(req, res, set, firstFrame) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + res.write(firstFrame); + if (!set) return void res.end(); + // A viewer that stops reading (locked tablet, sleeping laptop) must not + // queue frames forever; keepalive probes make the OS notice. + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + set.add(res); + req.on('close', () => set.delete(res)); +} + +function writeAll(set, payload) { + for (const res of set) { + try { + res.write(payload); + } catch { + set.delete(res); + } + } +} + +/** "HH:MM:SS", "-HH:MM:SS", "MM:SS", optional ".hh" → signed milliseconds, or null. */ +function hmsToMs(str) { + const m = /^\s*(-)?(?:(\d+):)?(\d{1,2}):(\d{2})(?:\.(\d+))?\s*$/.exec(String(str || '')); + if (!m) return null; + const h = m[2] ? Number(m[2]) : 0; + let ms = (h * 3600 + Number(m[3]) * 60 + Number(m[4])) * 1000; + if (m[5]) ms += Math.round(Number('0.' + m[5]) * 1000); + return m[1] ? -ms : ms; +} + +/** ms → "M:SS" / "H:MM:SS" (for detail lines). */ +function fmtMs(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (v) => String(v).padStart(2, '0'); + return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; +} + +/** ProPresenter's count_down_to_time target: seconds since midnight + am/pm/24h. */ +function timeOfDayText(seconds, period) { + const s = Number(seconds); + if (!Number.isFinite(s)) return ''; + const h = Math.floor(s / 3600) % 24; + const m = Math.floor((s % 3600) / 60); + const p = String(period || '').toLowerCase(); + if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${p}`; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +function errorText(err) { + return String(err?.cause?.code || err?.code || err?.message || err || 'failed'); +} + +/* ── validation of what other modules hand us ─────────────────────────── */ + +function numOrNull(v) { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** One timer object from a provider, coerced to the contract (or null to drop it). */ +function sanitizeTimer(t) { + if (!t || typeof t !== 'object') return null; + const id = t.id === null || t.id === undefined ? '' : String(t.id); + if (!id) return null; + let status = null; + if (t.status && typeof t.status === 'object' && t.status.text !== undefined && t.status.text !== null) { + status = { text: String(t.status.text).slice(0, 200) }; + if (TONES.has(t.status.tone)) status.tone = t.status.tone; + } return { - enabled, - ltcEnabled, - timerFont, - reachable: false, - lastError: '', - timers: [], - // supported true once the listener has a verdict; false when the - // listener is off, has no device, or its capture failed (note says - // why). status is running|stopped|nosignal; while running, ageMs lets - // clients count frames locally between updates. - ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' }, + id, + label: String(t.label || id).slice(0, 120), + kind: KINDS.has(t.kind) ? t.kind : 'elapsed', + state: STATES.has(t.state) ? t.state : 'stopped', + startedAt: numOrNull(t.startedAt), + elapsedMs: numOrNull(t.elapsedMs), + remainingMs: numOrNull(t.remainingMs), + targetMs: numOrNull(t.targetMs), + status, + detail: t.detail === null || t.detail === undefined ? '' : String(t.detail).slice(0, 200), }; } -module.exports = { - init({ config, log }) { - const streams = new Set(); - // Endpoint config. Legacy flat host/port keys (saved before the endpoint - // field existed) can only still be present until the admin form is - // re-saved, so when they exist they win over the endpoint's default. - const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; - const host = String(config.host ?? ep.host ?? ''); - const port = Number(config.port ?? ep.port) || 1025; - const password = String(config.password || ''); - const enabled = Boolean(host && port); - const ltcEnabled = Boolean(config.ltcEnabled); - const ltcDevice = String(config.ltcDevice || ''); - const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1); - const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default'; +/** A provider's /timers document → { label, timers, updatedAt }, or null when it isn't one. */ +function sanitizeSnapshot(raw) { + if (!raw || typeof raw !== 'object' || !Array.isArray(raw.timers)) return null; + return { + label: raw.label ? String(raw.label).slice(0, 60) : '', + timers: raw.timers.map(sanitizeTimer).filter(Boolean).slice(0, MAX_TIMERS_PER_PROVIDER), + updatedAt: Number(raw.updatedAt) || Date.now(), + }; +} - let latest = clearedState(enabled, ltcEnabled, timerFont); - let lastFrame = ''; - let stopped = false; - let pollTimer = null; - let tick = 0; +/* ── source: ProPresenter ─────────────────────────────────────────────── */ - /** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */ - let timerConfigs = new Map(); - /** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */ - let listenerLtc = null; +function createProPresenterSource({ config, log }) { + // Endpoint config. Legacy flat host/port keys (saved before the endpoint + // field existed) can only still be present until the admin form is + // re-saved, so when they exist they win over the endpoint's default. + const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; + const host = String(config.host ?? ep.host ?? ''); + const port = Number(config.port ?? ep.port) || 1025; + const password = String(config.password || ''); + const configured = Boolean(host && port); - /** The LTC verdict — the built-in listener is the only source. */ - function composeLtc() { - if (!ltcEnabled) { - return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' }; + let emit = () => {}; + let stopped = false; + let pollTimer = null; + let tick = 0; + let reachable = false; + let lastError = ''; + let timers = []; + let updatedAt = 0; + /** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */ + let timerConfigs = new Map(); + + function url(target) { + const built = new URL(`http://${host}:${port}${target}`); + if (password) built.searchParams.set('password', password); + return built; + } + + async function requestJson(target) { + const response = await fetch(url(target), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + const err = new Error(`ProPresenter ${response.status} ${response.statusText}`); + err.status = response.status; + throw err; + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + function describe(entry) { + if (entry.countdown) { + const targetMs = Number(entry.countdown.duration) > 0 ? Number(entry.countdown.duration) * 1000 : null; + return { type: 'countdown', targetMs, detail: targetMs !== null ? `Countdown ${fmtMs(targetMs)}` : 'Countdown' }; + } + if (entry.count_down_to_time) { + const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period); + return { type: 'countdown_to_time', targetMs: null, detail: at ? `Counts down to ${at}` : 'Counts down to a time of day' }; + } + if (entry.elapsed) { + const end = Number(entry.elapsed.end_time); + const targetMs = Number.isFinite(end) && end > 0 ? end * 1000 : null; + return { type: 'elapsed', targetMs, detail: targetMs !== null ? `Elapsed · to ${fmtMs(targetMs)}` : 'Elapsed' }; + } + return { type: 'unknown', targetMs: null, detail: '' }; + } + + function applyTimerList(list) { + if (!Array.isArray(list)) return; + const next = new Map(); + for (const entry of list) { + const uuid = entry?.id?.uuid; + if (!uuid) continue; + next.set(uuid, { + name: String(entry.id.name || 'Timer'), + index: Number(entry.id.index) || 0, + allowsOverrun: Boolean(entry.allows_overrun), + ...describe(entry), + }); + } + timerConfigs = next; + } + + /** One ProPresenter timer (configured entry + live value) → a standard timer object. */ + function toTimer(uuid, cfg, live, now) { + const rawState = String(live?.state || '').toLowerCase(); + const overrun = rawState.includes('overr'); + const value = live ? hmsToMs(live.time) : null; + const type = cfg ? cfg.type : 'unknown'; + // A timer reported live but missing from the (stale) config list has an + // unknown type until the slow lane catches up (~2 s); treat it as elapsed + // meanwhile — the whole-second display can't drift wrong in that time. + const kind = type === 'countdown' || type === 'countdown_to_time' ? 'countdown' : 'elapsed'; + const targetMs = cfg ? cfg.targetMs : null; + let state; + if (!live) state = 'idle'; + else if (rawState === 'running' || overrun) state = 'running'; + else if (rawState === 'complete') state = 'stopped'; + else { + // ProPresenter's "stopped" covers both a timer that never started + // (still showing its full length) and one paused mid-way. + const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0); + state = atStart ? 'idle' : 'paused'; + } + const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null; + const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null; + const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null; + const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped'; + const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted'; + return { + id: uuid, + label: (live && live.name) || (cfg && cfg.name) || 'Timer', + kind, + state, + startedAt, + elapsedMs, + remainingMs, + targetMs, + status: { text, tone }, + detail: cfg ? cfg.detail : '', + }; + } + + /** Merge configured timers (order, type) with live values into one list. */ + function mergeTimers(currentTimes, now) { + const live = new Map(); + if (Array.isArray(currentTimes)) { + for (const entry of currentTimes) { + const uuid = entry?.id?.uuid; + if (!uuid) continue; + live.set(uuid, { + name: String(entry.id.name || ''), + time: typeof entry.time === 'string' ? entry.time : '', + state: typeof entry.state === 'string' ? entry.state : '', + }); } - if (!listenerLtc) { - return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' }; + } + const out = []; + for (const [uuid, cfg] of timerConfigs) { + const now_ = live.get(uuid) || null; + live.delete(uuid); + out.push(toTimer(uuid, cfg, now_, now)); + } + // Timers reported live but missing from the (possibly stale) config + // list still deserve a card — type unknown until the slow lane catches up. + for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now)); + return out; + } + + function status() { + if (!configured) return 'error'; + if (reachable) return 'ok'; + return lastError ? 'error' : 'connecting'; + } + + function message() { + if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off'; + if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`; + return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`; + } + + function publish() { + if (stopped) return; + emit({ status: status(), message: message(), timers, updatedAt }); + } + + async function pollTick() { + if (stopped) return; + try { + tick += 1; + // Slow lane first on the very first tick so cards get names/types + // immediately; afterwards every SLOW_EVERY ticks. + if (tick % SLOW_EVERY === 1 || !timerConfigs.size) { + applyTimerList(await requestJson('/v1/timers')); } - if (listenerLtc.error) { - return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error }; + // THE HOT PATH — live values every tick. + const currentTimes = await requestJson('/v1/timers/current'); + updatedAt = Date.now(); + timers = mergeTimers(currentTimes, updatedAt); + reachable = true; + lastError = ''; + } catch (err) { + reachable = false; + lastError = err instanceof Error ? err.message : String(err); + } + publish(); + if (stopped) return; + pollTimer = setTimeout(pollTick, reachable ? POLL_MS : RETRY_MS); + } + + return { + id: 'propresenter', + label: 'ProPresenter', + kind: 'propresenter', + start(e) { + emit = e; + if (!configured) { + log('ProPresenter timers on, but no host configured — set one in /admin'); + publish(); + return; } - const running = listenerLtc.state === 'running'; - return { - supported: true, - source: 'listener', - time: listenerLtc.time, - receiving: running, - status: listenerLtc.state, - fps: listenerLtc.fps, - df: listenerLtc.df, - // While running: how old this timecode is, so clients can pin the - // anchor to their own clock and count frames between updates. - ...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}), - note: '', + log(`polling timers on ${host}:${port}`); + pollTick(); + }, + stop() { + stopped = true; + clearTimeout(pollTimer); + }, + health() { + return { status: status(), message: message() }; + }, + }; +} + +/* ── source: LTC timecode (built-in listener) ─────────────────────────── */ + +function createLtcSource({ config, log, moduleDir }) { + const device = String(config.ltcDevice || ''); + const channel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1); + + let emit = () => {}; + let listener = null; + let stopped = false; + /** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */ + let word = null; + + function rateText() { + return word?.df ? '29.97 DF' : word?.fps ? `${word.fps} fps` : ''; + } + + /** The LTC timecode as one standard timer object (kind clock, timecode in status.text). */ + function timer() { + const base = { id: 'ltc', label: 'Timecode', kind: 'clock', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null }; + if (!word || word.error) { + const note = word ? word.error : 'Starting LTC listener…'; + return { ...base, state: 'idle', status: { text: '', tone: 'muted' }, detail: note, ltc: { time: '', fps: 0, df: false, status: '', note } }; + } + const running = word.state === 'running'; + const rate = rateText(); + const ltc = { time: word.time || '', fps: word.fps || 0, df: Boolean(word.df), status: word.state, note: '' }; + // While running: how old this timecode is, so the tile can pin the + // anchor to its own clock and count frames between reports. + if (running && Number.isFinite(word.ageMs)) ltc.ageMs = Math.max(0, word.ageMs); + return { + ...base, + state: running ? 'running' : word.state === 'stopped' ? 'stopped' : 'idle', + status: { text: word.time || '', tone: running ? 'ok' : word.state === 'stopped' ? 'muted' : 'warn' }, + detail: running ? (rate ? `Running · ${rate}` : 'Running') : word.state === 'stopped' ? 'Stopped' : 'No signal', + ltc, + }; + } + + function status() { + if (!word) return 'connecting'; + return word.error ? 'error' : 'ok'; + } + + function message() { + if (!word) return 'Starting LTC listener…'; + if (word.error) return word.error; + const rate = rateText(); + return word.state === 'running' ? `LTC running${rate ? ` · ${rate}` : ''}` : word.state === 'stopped' ? 'LTC stopped' : 'LTC no signal'; + } + + function publish() { + if (stopped) return; + emit({ status: status(), message: message(), timers: [timer()], updatedAt: Date.now() }); + } + + return { + id: 'ltc', + label: 'LTC', + kind: 'ltc', + start(e) { + emit = e; + if (!device) { + word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' }; + publish(); + return; + } + publish(); + listener = createLtcListener({ + device, + channel, + moduleDir, + log, + onUpdate(update) { + word = update; + publish(); + }, + }); + }, + stop() { + stopped = true; + listener?.stop(); + listener = null; + }, + health() { + return { status: status(), message: message() }; + }, + }; +} + +/* ── source: time of day ──────────────────────────────────────────────── */ + +function createClockSource() { + return { + id: 'clock', + label: 'Clock', + kind: 'clock', + start(emit) { + // status.text (the ISO time) is stamped when a snapshot is served — + // see stampClock() — so this never has to re-emit. + emit({ + status: 'ok', + message: 'ticking', + updatedAt: Date.now(), + timers: [{ id: 'local', label: 'Clock', kind: 'clock', state: 'running', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null, status: { text: '', tone: 'ok' }, detail: '' }], + }); + }, + stop() {}, + health() { + return { status: 'ok', message: '' }; + }, + }; +} + +/* ── source: another module's timers (over loopback) ─────────────────── */ + +function createModuleSource({ moduleId, name, shellPort, log }) { + const base = `/api/modules/${encodeURIComponent(moduleId)}`; + let emit = () => {}; + let stopped = false; + let label = String(name || moduleId); + let timers = []; + let updatedAt = 0; + let lastOkAt = 0; + let lastError = ''; + let state = 'connecting'; // 'ok' | 'connecting' | 'error' + let via = ''; // 'stream' | 'poll' once we know which the provider offers + let stream = null; // the open http.ClientRequest of /timers/stream + let pollTimer = null; + let pollInflight = false; + let retryTimer = null; + let silenceTimer = null; + let backoff = BACKOFF_MIN_MS; + + function message() { + if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`; + if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…'; + return `unreachable — ${lastError}`; + } + + function publish() { + if (stopped) return; + emit({ label, status: state, message: message(), timers, updatedAt }); + } + + function accept(raw) { + const snap = sanitizeSnapshot(raw); + if (!snap) { + fail('malformed /timers snapshot'); + return; + } + if (snap.label) label = snap.label; + timers = snap.timers; + updatedAt = snap.updatedAt; + lastOkAt = Date.now(); + lastError = ''; + state = 'ok'; + backoff = BACKOFF_MIN_MS; + publish(); + } + + /** A provider restarting mid-service drops its stream for a moment; its + timers stay up through the grace period and only then disappear. */ + function fail(why) { + lastError = why; + if (Date.now() - lastOkAt > GRACE_MS) { + timers = []; + state = 'error'; + } else { + state = 'connecting'; + } + publish(); + } + + function later(fn, ms) { + const t = setTimeout(() => { if (!stopped) fn(); }, ms); + t.unref?.(); + return t; + } + + function backoffLater(fn) { + clearTimeout(retryTimer); + retryTimer = later(fn, backoff); + backoff = Math.min(BACKOFF_MAX_MS, backoff * 2); + } + + function closeStream() { + clearTimeout(silenceTimer); + silenceTimer = null; + if (stream) { + const s = stream; + stream = null; + try { s.destroy(); } catch { /* already gone */ } + } + } + + function connectStream() { + if (stopped || stream) return; + let done = false; + const drop = (why) => { + if (done || stopped) return; + done = true; + closeStream(); + fail(why); + backoffLater(connectStream); + }; + const req = http.get({ host: '127.0.0.1', port: shellPort, path: `${base}/timers/stream`, headers: { Accept: 'text/event-stream' } }, (res) => { + const type = String(res.headers['content-type'] || ''); + if (res.statusCode !== 200 || !/text\/event-stream/i.test(type)) { + // No stream here (404: the provider only serves /timers; 502: it + // failed to start) — poll instead, and try the stream again at the + // next discovery pass. + done = true; + res.resume(); + stream = null; + via = 'poll'; + poll(); + return; + } + via = 'stream'; + clearTimeout(pollTimer); + pollTimer = null; + res.setEncoding('utf8'); + let buf = ''; + const armSilence = () => { + clearTimeout(silenceTimer); + silenceTimer = later(() => drop('stream went silent'), STREAM_SILENCE_MS); }; - } + armSilence(); + res.on('data', (chunk) => { + armSilence(); + buf += chunk.replace(/\r\n/g, '\n'); + let at; + while ((at = buf.indexOf('\n\n')) >= 0) { + const block = buf.slice(0, at); + buf = buf.slice(at + 2); + let event = 'message'; + const data = []; + for (const line of block.split('\n')) { + if (!line || line.startsWith(':')) continue; + const colon = line.indexOf(':'); + const field = colon < 0 ? line : line.slice(0, colon); + let value = colon < 0 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'event') event = value; + else if (field === 'data') data.push(value); + } + if (!data.length || (event !== 'timers' && event !== 'message')) continue; + try { + accept(JSON.parse(data.join('\n'))); + } catch { /* not for us */ } + } + if (buf.length > 1e6) buf = ''; // runaway guard: never an SSE frame this size + }); + res.on('end', () => drop('stream ended')); + res.on('close', () => drop('stream closed')); + res.on('error', (err) => drop(errorText(err))); + }); + stream = req; + req.on('error', (err) => drop(errorText(err))); + } - function url(target) { - const built = new URL(`http://${host}:${port}${target}`); - if (password) built.searchParams.set('password', password); - return built; - } - - async function requestJson(target) { - const response = await fetch(url(target), { + async function pollOnce() { + pollTimer = null; + if (stopped || pollInflight || via === 'stream') return; + pollInflight = true; + let ok = false; + try { + const res = await fetch(`http://127.0.0.1:${shellPort}${base}/timers`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); - if (!response.ok) { - const err = new Error(`ProPresenter ${response.status} ${response.statusText}`); - err.status = response.status; - throw err; - } - const text = await response.text(); - return text ? JSON.parse(text) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (via === 'stream' || stopped) return; // the stream came up meanwhile + accept(body); + ok = true; + } catch (err) { + if (!stopped && via !== 'stream') fail(errorText(err)); + } finally { + pollInflight = false; } - - function timerType(entry) { - if (entry.countdown) return 'countdown'; - if (entry.count_down_to_time) return 'countdown_to_time'; - if (entry.elapsed) return 'elapsed'; - return 'unknown'; + if (stopped || via === 'stream') return; + if (ok) { + pollTimer = later(pollOnce, MODULE_POLL_MS); + } else { + pollTimer = later(pollOnce, backoff); + backoff = Math.min(BACKOFF_MAX_MS, backoff * 2); } + } - function applyTimerList(list) { - if (!Array.isArray(list)) return; - const next = new Map(); - for (const entry of list) { - const uuid = entry?.id?.uuid; - if (!uuid) continue; - next.set(uuid, { - name: String(entry.id.name || 'Timer'), - index: Number(entry.id.index) || 0, - type: timerType(entry), - allowsOverrun: Boolean(entry.allows_overrun), - }); - } - timerConfigs = next; - } + function poll() { + if (stopped || pollTimer || pollInflight) return; + pollTimer = later(pollOnce, 0); + } - /** Merge configured timers (order, type) with live values into one list. */ - function mergeTimers(currentTimes) { - const live = new Map(); - if (Array.isArray(currentTimes)) { - for (const entry of currentTimes) { - const uuid = entry?.id?.uuid; - if (!uuid) continue; - live.set(uuid, { - name: String(entry.id.name || ''), - time: typeof entry.time === 'string' ? entry.time : '', - state: typeof entry.state === 'string' ? entry.state : '', - }); + return { + id: moduleId, + kind: 'module', + get label() { + return label; + }, + start(e) { + emit = e; + publish(); + connectStream(); + }, + stop() { + stopped = true; + closeStream(); + clearTimeout(retryTimer); + clearTimeout(pollTimer); + retryTimer = null; + pollTimer = null; + }, + /** Called on every discovery pass: a provider that had no stream may have one now. */ + retryStream() { + if (!stopped && !stream && via === 'poll') connectStream(); + }, + health() { + return { status: state, message: message() }; + }, + }; +} + +/** + * Finds timer providers — every active module whose manifest says + * "provides": ["timers"] — by reading the shell's own /api/modules over + * loopback, and keeps one module source per provider alive. + */ +function createModuleDiscovery({ shellPort, log, flags, onAdd, onRemove }) { + const sources = new Map(); // module id → source + let timer = null; + let stopped = false; + let listed = null; // providers named by the last successful read, [{ id, name }] + let listError = ''; + + function wanted(id) { + return id === PCO_ID ? flags.pco : flags.modules; + } + + async function discover() { + timer = null; + if (stopped) return; + try { + if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)'); + const res = await fetch(`http://127.0.0.1:${shellPort}/api/modules`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (stopped) return; + const mods = Array.isArray(body?.modules) ? body.modules : []; + listed = mods + .filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes('timers')) + .map((m) => ({ id: m.id, name: String(m.name || m.id) })); + listError = ''; + const keep = new Set(); + for (const m of listed) { + if (!wanted(m.id)) continue; + keep.add(m.id); + const have = sources.get(m.id); + if (have) { + have.retryStream(); + } else { + const source = createModuleSource({ moduleId: m.id, name: m.name, shellPort, log }); + sources.set(m.id, source); + log(`following timers offered by module "${m.id}"`); + onAdd(source); } } - const out = []; - for (const [uuid, cfg] of timerConfigs) { - const now = live.get(uuid); - live.delete(uuid); - out.push({ - uuid, - name: (now && now.name) || cfg.name, - type: cfg.type, - allowsOverrun: cfg.allowsOverrun, - time: now ? now.time : '', - state: now ? now.state : '', - }); + // Disabled, uninstalled, or no longer offering timers: the shell is + // authoritative, so its timers go now rather than after the grace. + for (const [id, source] of sources) { + if (keep.has(id)) continue; + source.stop(); + sources.delete(id); + log(`module "${id}" no longer offers timers`); + onRemove(id); } - // Timers reported live but missing from the (possibly stale) config - // list still deserve a card — type unknown until the slow lane catches up. - for (const [uuid, now] of live) { - out.push({ uuid, name: now.name || 'Timer', type: 'unknown', allowsOverrun: false, time: now.time, state: now.state }); + } catch (err) { + listError = errorText(err); + } + if (!stopped) { + timer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS); + timer.unref?.(); + } + } + + return { + start() { + if (!flags.pco && !flags.modules) return; + timer = setTimeout(discover, DISCOVER_FIRST_MS); + timer.unref?.(); + }, + stop() { + stopped = true; + clearTimeout(timer); + timer = null; + for (const source of sources.values()) source.stop(); + sources.clear(); + }, + /** Health lines for the admin page: one for PCO Plan, one per other provider. */ + health() { + const parts = []; + let status = 'ok'; + const bump = (s) => { + if (s === 'error') status = 'error'; + else if (s === 'connecting' && status === 'ok') status = 'connecting'; + }; + if (!flags.pco) parts.push('PCO Plan: off'); + if (!flags.modules) parts.push('Other modules: off'); + if (!flags.pco && !flags.modules) return { status, parts }; + if (listed === null) { + parts.push(listError ? `Modules: waiting for the shell (${listError})` : 'Modules: discovering…'); + return { status: 'connecting', parts }; } - return out; + if (listError) { + parts.push(`Modules: cannot re-read /api/modules (${listError})`); + bump('error'); + } + if (flags.pco) { + const pco = sources.get(PCO_ID); + if (pco) { + const h = pco.health(); + parts.push(`PCO Plan: ${h.message}`); + bump(h.status); + } else { + parts.push('PCO Plan: not installed, or not offering timers yet'); + } + } + if (flags.modules) { + const others = [...sources.values()].filter((s) => s.id !== PCO_ID); + if (!others.length) parts.push('Other modules: none offering timers'); + for (const s of others) { + const h = s.health(); + parts.push(`${s.label}: ${h.message}`); + bump(h.status); + } + } + return { status, parts }; + }, + }; +} + +/* ── the module ───────────────────────────────────────────────────────── */ + +/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */ +const ORDER = { propresenter: 0, ltc: 1, clock: 2, [PCO_ID]: 3 }; + +function emptyState(timerFont = 'default') { + return { serverNow: Date.now(), timerFont, sources: [] }; +} + +function emptyProvider() { + return { source: SELF_ID, label: 'Timers', updatedAt: Date.now(), timers: [] }; +} + +module.exports = { + init({ config, log, shell }) { + const shellPort = Number(shell && shell.port) || 0; + const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default'; + const flags = { + pp: config.ppEnabled !== false, + ltc: Boolean(config.ltcEnabled), + pco: config.pcoEnabled !== false, + modules: config.moduleTimersEnabled !== false, + }; + + const tileStreams = new Set(); + const providerStreams = new Set(); + /** source id → { id, kind, label, status, message, timers, updatedAt } */ + const views = new Map(); + let latest = emptyState(timerFont); + let lastKey = ''; + let stopped = false; + + const orderedViews = () => [...views.values()].sort((a, b) => ((ORDER[a.id] ?? 10) - (ORDER[b.id] ?? 10)) || a.label.localeCompare(b.label)); + + /** The tile state: every source, its health, its timers as of updatedAt. */ + function buildState() { + return { + serverNow: Date.now(), + timerFont, + sources: orderedViews().map((v) => ({ id: v.id, label: v.label, kind: v.kind, status: v.status, message: v.message, updatedAt: v.updatedAt, timers: v.timers })), + }; } - function broadcast() { - latest = { - enabled, - ltcEnabled, - timerFont, - reachable: latest.reachable, - lastError: latest.lastError, - timers: latest.timers, - ltc: composeLtc(), - }; - if (!streams.size) return; - let frame; + /** + * The provider snapshot (the module guide's contract): all enabled + * sources merged, ids prefixed with their source so they stay unique, + * every duration re-anchored to this snapshot's own instant. + */ + function buildProviderSnapshot() { + const now = Date.now(); + const timers = []; + for (const v of orderedViews()) { + for (const t of v.timers) { + const out = { ...t, id: `${v.id}:${t.id}`, group: v.label }; + if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' }; + if (t.state === 'running' && v.updatedAt > 0) { + const dt = Math.max(0, now - v.updatedAt); + if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt; + if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt; + if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt }; + } + timers.push(out); + } + } + return { source: SELF_ID, label: 'Timers', updatedAt: now, timers }; + } + + /** Recompute the merged state; broadcast only when something other than the clocks moved. */ + function rebuild() { + if (stopped) return; + latest = buildState(); + // updatedAt / serverNow move on every poll; the tiles extrapolate from + // them but don't need a frame for that alone. + let key; try { - frame = JSON.stringify(latest); + key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers])); } catch { return; } - if (frame === lastFrame) return; - lastFrame = frame; - const payload = `event: state\ndata: ${frame}\n\n`; - for (const res of streams) { - try { - res.write(payload); - } catch { - streams.delete(res); - } - } + if (key === lastKey) return; + lastKey = key; + if (tileStreams.size) writeAll(tileStreams, `event: state\ndata: ${JSON.stringify(latest)}\n\n`); + if (providerStreams.size) writeAll(providerStreams, `event: timers\ndata: ${JSON.stringify(buildProviderSnapshot())}\n\n`); } - async function pollTick() { + const emitFor = (source) => (view) => { if (stopped) return; + views.set(source.id, { + id: source.id, + kind: source.kind, + label: String(view.label || source.label), + status: view.status === 'ok' || view.status === 'connecting' || view.status === 'error' ? view.status : 'ok', + message: String(view.message || ''), + timers: Array.isArray(view.timers) ? view.timers : [], + updatedAt: Number(view.updatedAt) || Date.now(), + }); + rebuild(); + }; + + // The fixed sources, each behind its admin switch. Add a platform here. + const fixed = []; + if (flags.pp) fixed.push(createProPresenterSource({ config, log })); + else log('ProPresenter timers are off (admin switch)'); + if (flags.ltc) fixed.push(createLtcSource({ config, log, moduleDir: __dirname })); + fixed.push(createClockSource()); + + const discovery = createModuleDiscovery({ + shellPort, + log, + flags, + onAdd(source) { + source.start(emitFor(source)); + }, + onRemove(id) { + views.delete(id); + rebuild(); + }, + }); + + for (const source of fixed) { try { - tick += 1; - // Slow lane first on the very first tick so cards get names/types - // immediately; afterwards every SLOW_EVERY ticks. - if (tick % SLOW_EVERY === 1 || !timerConfigs.size) { - applyTimerList(await requestJson('/v1/timers')); - } - // THE HOT PATH — live values every tick. - const currentTimes = await requestJson('/v1/timers/current'); - latest.timers = mergeTimers(currentTimes); - latest.reachable = true; - latest.lastError = ''; + source.start(emitFor(source)); } catch (err) { - latest.reachable = false; - latest.lastError = err instanceof Error ? err.message : String(err); - } - broadcast(); - if (stopped) return; - pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS); - } - - if (enabled) { - pollTick(); - log(`polling timers on ${host}:${port}`); - } else { - log('no ProPresenter host configured — set one in /admin'); - } - - // The built-in listener is independent of ProPresenter — LTC-only - // setups (no host configured) still get the LTC card. - let ltcListener = null; - if (ltcEnabled) { - if (ltcDevice) { - ltcListener = createLtcListener({ - device: ltcDevice, - channel: ltcChannel, - moduleDir: __dirname, - log, - onUpdate(update) { - listenerLtc = update; - broadcast(); - }, - }); - } else { - listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' }; - broadcast(); + log(`${source.id} failed to start: ${err.message}`); } } + discovery.start(); + if (!flags.pco && !flags.modules) log('module timers are off (admin switches)'); const heartbeat = setInterval(() => { - for (const res of streams) { - try { - res.write(': ping\n\n'); - } catch { - streams.delete(res); - } - } + writeAll(tileStreams, ': ping\n\n'); + writeAll(providerStreams, ': ping\n\n'); }, HEARTBEAT_MS); heartbeat.unref?.(); - current = { streams, getLatest: () => latest }; + current = { + tileStreams, + providerStreams, + getState: () => ({ ...latest, serverNow: Date.now() }), + getProviderSnapshot: buildProviderSnapshot, + }; return { stop() { stopped = true; - clearTimeout(pollTimer); clearInterval(heartbeat); - ltcListener?.stop(); - for (const res of streams) { - try { res.end(); } catch { /* already gone */ } + discovery.stop(); + for (const source of fixed) { + try { source.stop(); } catch { /* already down */ } } - streams.clear(); - if (current && current.streams === streams) current = null; + for (const set of [tileStreams, providerStreams]) { + for (const res of set) { + try { res.end(); } catch { /* already gone */ } + } + set.clear(); + } + if (current && current.tileStreams === tileStreams) current = null; }, health() { - // LTC status suffix for the admin health line. - const ltcNote = !ltcEnabled - ? '' - : latest.ltc.supported - ? `, LTC ${latest.ltc.status}` - : (latest.ltc.note ? ` (LTC: ${latest.ltc.note})` : ''); - if (!enabled) { - // The LTC listener works regardless of ProPresenter — an - // LTC-only setup is half-configured but working. - if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only, ${latest.ltc.status}` }; - return { status: 'error', message: 'No ProPresenter host configured' }; - } - if (latest.reachable) { - return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` }; - } - return { - status: 'error', - message: latest.lastError - ? `ProPresenter unreachable: ${latest.lastError}` - : 'Trying to reach ProPresenter…', + const parts = []; + let status = 'ok'; + const bump = (s) => { + if (s === 'error') status = 'error'; + else if (s === 'connecting' && status === 'ok') status = 'connecting'; }; + for (const source of fixed) { + if (source.id === 'clock') continue; + const h = source.health(); + parts.push(`${source.label}: ${h.message}`); + bump(h.status); + } + if (!flags.pp) parts.push('ProPresenter: off'); + if (!flags.ltc) parts.push('LTC: off'); + const d = discovery.health(); + parts.push(...d.parts); + bump(d.status); + return { status, message: parts.join(' · ') }; }, }; }, /** - * 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:' 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. + * 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. */ tiles() { - const state = current ? current.getLatest() : null; - if (!state || (!state.enabled && !state.ltcEnabled)) 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 state = current ? current.getState() : null; + if (!state) return []; + const KIND_LABEL = { countdown: 'Countdown', elapsed: 'Elapsed timer', clock: 'Clock' }; const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }; - return [ - { - id: 'all', - name: 'All timers', - description: state.ltcEnabled - ? 'Every timer and the LTC timecode, selectable per tile' - : 'Every timer, selectable per tile', - }, - ...state.timers.map((t) => ({ - id: `timer:${t.uuid}`, - name: t.name, - description: TYPE_LABEL[t.type] || 'Timer', - ...oneCard, - })), - // The LTC entry exists only while the admin switch is on. - ...(state.ltcEnabled - ? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }] - : []), - ]; + const out = [{ id: 'all', name: 'All timers', description: 'Every timer, clock and timecode — choose which per tile' }]; + 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 }); + } + } + return out; }, routes() { - const sendJson = (res, code, body) => { - res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify(body)); - }; return { 'GET /state': (req, res) => { - sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) }); + sendJson(res, 200, { state: current ? current.getState() : emptyState() }); + }, + + 'GET /stream': (req, res) => { + const state = current ? current.getState() : emptyState(); + openSse(req, res, current ? current.tileStreams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`); + }, + + // ProdDash itself as a timers provider — the module guide's contract. + 'GET /timers': (req, res) => { + sendJson(res, 200, current ? current.getProviderSnapshot() : emptyProvider()); + }, + + 'GET /timers/stream': (req, res) => { + const snap = current ? current.getProviderSnapshot() : emptyProvider(); + openSse(req, res, current ? current.providerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`); }, // The admin device picker asks this machine what it can hear. The // options shape matches the admin select's optionsRoute contract. 'GET /ltc/devices': (req, res) => { - const bin = require('path').join(__dirname, 'ltc-capture'); + const bin = path.join(__dirname, 'ltc-capture'); const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); let err = ''; child.stderr.setEncoding('utf8'); @@ -405,25 +1123,6 @@ module.exports = { sendJson(res, 200, { options }); }); }, - - 'GET /stream': (req, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - res.write('retry: 3000\n\n'); - const state = current ? current.getLatest() : clearedState(false); - res.write(`event: state\ndata: ${JSON.stringify(state)}\n\n`); - if (!current) return void res.end(); - const { streams } = current; - // A viewer that stops reading (locked tablet, sleeping laptop) must - // not queue frames forever; keepalive probes make the OS notice. - try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } - streams.add(res); - req.on('close', () => streams.delete(res)); - }, }; }, }; diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index 129771e..598e3a9 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -1,11 +1,16 @@ -/* ProPresenter Timers module — scoped to its tiles, shell variables only. - The API exposes no per-timer color, so a card's color IS its state: - accent = running, muted = stopped, warn = complete, danger = overrun. - Overrun must be unmistakable at a distance — danger fill + pulse. */ +/* Timers module — scoped to its tiles, shell variables only. + A card's colour IS its state: running / warning / overrun / idle. The four + tones are custom properties on the tile root: they default to the shell's + palette (so a dashboard theme reaches them) and the tile's own colour + settings, when changed, are set inline on .tm-wrap and win — the + documented mechanism. Overrun must be unmistakable across the room: + danger fill + pulse. */ [data-module="propresenter-timers"] .tm-wrap { - --tm-time-size: 32px; - --tm-ltc-size: 26px; + --tm-ok: var(--accent); + --tm-warn: var(--warn); + --tm-danger: var(--danger); + --tm-muted: var(--muted); --tm-meta-size: 11px; position: absolute; inset: 0; @@ -15,7 +20,7 @@ -webkit-user-select: none; } -/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */ +/* ── degraded-state banner (the ProdDash feed itself is down) ── */ [data-module="propresenter-timers"] .tm-banner { flex: none; @@ -30,12 +35,11 @@ } [data-module="propresenter-timers"] .tm-banner[hidden] { display: none; } -/* Values still on screen while the feed is down are stale — say so visually. - Per-card, because the reader-fed LTC card is not stale when only - ProPresenter is unreachable; it dims only when the ProdDash feed itself - is down (is-feed-down) and everything on screen really is old. */ -[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; } -[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; } +/* Values still on screen while their source is down are stale — say so + visually, per group: the LTC card keeps ticking when only ProPresenter + is unreachable. When the ProdDash feed is down everything is old. */ +[data-module="propresenter-timers"] .tm-group.is-stale .tm-card, +[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; } [data-module="propresenter-timers"] .tm-hint { flex: 1; @@ -49,7 +53,69 @@ } [data-module="propresenter-timers"] .tm-hint[hidden] { display: none; } -/* ── card grid (column count set by client.js fit()) ── */ +/* ── source groups: heading + card grid (column count set by fit()) ── */ + +[data-module="propresenter-timers"] .tm-groups { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--tm-gap, 8px); + padding: var(--tm-pad, 10px); +} +[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; } + +[data-module="propresenter-timers"] .tm-group { + min-height: 0; + display: flex; + flex-direction: column; + flex: 1 1 0; +} + +[data-module="propresenter-timers"] .tm-head { + flex: none; + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + margin: 0 2px 4px; + font-size: 11px; + line-height: 16px; + color: var(--muted); +} +[data-module="propresenter-timers"] .tm-head[hidden] { display: none; } + +[data-module="propresenter-timers"] .tm-title { + flex: none; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* The source's own word while it isn't OK ("Unreachable at …"). */ +[data-module="propresenter-timers"] .tm-note { + flex: 1; + min-width: 0; + font-weight: 500; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); } +[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); } + +/* Headings off: a source in trouble, or with nothing to show, still says so. */ +[data-module="propresenter-timers"] .tm-empty { + flex: none; + padding: 2px 4px; + color: var(--muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; } +[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); } [data-module="propresenter-timers"] .tm-grid { flex: 1; @@ -57,16 +123,16 @@ display: grid; grid-auto-rows: 1fr; gap: var(--tm-gap, 8px); - padding: var(--tm-pad, 10px); } -[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; } +[data-module="propresenter-timers"] .tm-grid:empty { display: none; } -/* Compact cards (short tiles): the state line goes, its color stays. - The one exception is the LTC card with no timecode API behind it — - 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; } +/* Compact cards (short tiles) and "Status lines" off: the lines under the + digits go, the colour stays. A note that explains a missing value (no + LTC input, a solo timer no longer offered) must never be hidden. */ +[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note), +[data-module="propresenter-timers"] .tm-compact .tm-detail, +[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note), +[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; } /* ── one timer card ── */ @@ -104,7 +170,7 @@ [data-module="propresenter-timers"] .tm-time { flex: none; - font-size: var(--tm-time-size); + font-size: 32px; /* fit() sets the real size per card */ font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; @@ -113,14 +179,14 @@ white-space: nowrap; } -/* Admin "Timer font: Monospace" — steadier digits as the numbers change. - Applies to timer and LTC values alike (both are .tm-time). */ +/* Admin "Timer font: Monospace" — steadier digits as the numbers change. */ [data-module="propresenter-timers"] .tm-mono .tm-time { font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace; letter-spacing: 0; } -[data-module="propresenter-timers"] .tm-state { +[data-module="propresenter-timers"] .tm-status, +[data-module="propresenter-timers"] .tm-detail { flex: none; max-width: 100%; font-size: var(--tm-meta-size); @@ -132,102 +198,98 @@ overflow: hidden; text-overflow: ellipsis; } +[data-module="propresenter-timers"] .tm-status[hidden], +[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; } -/* ── state looks ── */ - -[data-module="propresenter-timers"] .tm-card.is-running { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, transparent); -} -[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); } -[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); } - -[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); } - -[data-module="propresenter-timers"] .tm-card.is-complete { - border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); - background: color-mix(in srgb, var(--warn) 10%, transparent); -} -[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); - background: color-mix(in srgb, var(--danger) 22%, transparent); - animation: tm-overrun-pulse 1.2s ease-in-out infinite; -} -[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); } -[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state { - color: var(--danger); - font-weight: 800; +[data-module="propresenter-timers"] .tm-detail { + font-weight: 600; + letter-spacing: 0.04em; + text-transform: none; + opacity: 0.85; } -@keyframes tm-overrun-pulse { - 50% { background: color-mix(in srgb, var(--danger) 38%, transparent); } -} +/* A provider's status line carries its own tone ("ahead 1:20" in green). */ +[data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); } +[data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); } +[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); } +[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); } -/* ── the LTC card: visually its own thing ── */ - -[data-module="propresenter-timers"] .tm-card-ltc { - border-style: dashed; -} -[data-module="propresenter-timers"] .tm-card-ltc .tm-time { - font-size: var(--tm-ltc-size); -} - -[data-module="propresenter-timers"] .tm-ltc-badge { - flex: none; - padding: 0.15em 0.55em; - border-radius: 5px; - background: color-mix(in srgb, var(--accent) 24%, transparent); - color: var(--text); - font-size: 0.95em; - letter-spacing: 0.12em; -} - -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, transparent); -} -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); } -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); } - -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal { - border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); -} -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); } -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state { - color: var(--warn); - animation: tm-nosignal-blink 1.6s steps(2) infinite; -} - -@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } - -/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors, - stopping the clock is usually somebody doing their job. */ -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); } - -/* No timecode API on this ProPresenter: say so, quietly. */ -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); } -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state { +/* Explanatory notes read as sentences, not badges. */ +[data-module="propresenter-timers"] .tm-status.is-note { text-transform: none; letter-spacing: 0.02em; white-space: normal; text-align: center; } +/* ── state looks (feedbacks) ── */ + +[data-module="propresenter-timers"] .tm-card.is-ok { + border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border)); + background: color-mix(in srgb, var(--tm-ok) 10%, transparent); +} +[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); } + +[data-module="propresenter-timers"] .tm-card.is-warn { + border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border)); + background: color-mix(in srgb, var(--tm-warn) 10%, transparent); +} +[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); } + +[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); } + +/* The wall clock is information, not a state — plain text colour. */ +[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); } + +/* Overrun: readable across the room even on a wall display. */ +[data-module="propresenter-timers"] .tm-card.is-danger { + border-color: var(--tm-danger); + background: color-mix(in srgb, var(--tm-danger) 22%, transparent); +} +[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); } +[data-module="propresenter-timers"] .tm-card.is-overrun { + animation: tm-overrun-pulse 1.2s ease-in-out infinite; +} +[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; } + +@keyframes tm-overrun-pulse { + 50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); } +} + +/* A solo tile whose timer is no longer offered. */ +[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; } + +/* ── the LTC card: visually its own thing ── */ + +[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; } + +[data-module="propresenter-timers"] .tm-ltc-badge { + flex: none; + padding: 0.15em 0.55em; + border-radius: 5px; + background: color-mix(in srgb, var(--tm-ok) 24%, transparent); + color: var(--text); + font-size: 0.95em; + letter-spacing: 0.12em; +} + +/* No signal: muted digits, a blinking warning line. */ +[data-module="propresenter-timers"] .tm-card.is-nosignal { + border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border)); +} +[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status { + animation: tm-nosignal-blink 1.6s steps(2) infinite; +} + +@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } + /* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ [data-module="propresenter-timers"] .tm-menu-item { display: flex; align-items: center; - gap: 8px; + justify-content: space-between; + gap: 14px; width: 100%; padding: 6px 9px; background: none; @@ -241,16 +303,39 @@ white-space: nowrap; } [data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); } - -[data-module="propresenter-timers"] .tm-menu-item .check { - width: 14px; - flex: none; - color: var(--accent); - font-weight: 700; - visibility: hidden; -} -[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; } [data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; } +[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); } + +/* A small switch: off = hollow pill, on = filled with the running colour. */ +[data-module="propresenter-timers"] .tm-switch { + flex: none; + position: relative; + width: 26px; + height: 14px; + border-radius: 7px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 25%, transparent); + transition: background 0.15s ease, border-color 0.15s ease; +} +[data-module="propresenter-timers"] .tm-switch::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text); + transition: transform 0.15s ease; +} +[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch { + border-color: var(--tm-ok); + background: var(--tm-ok); +} +[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after { + transform: translateX(12px); + background: var(--bg); +} [data-module="propresenter-timers"] .tm-menu-note { padding: 6px 9px;