Timers: every timer and clock in the room, from any source

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:<uuid> / 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 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-13 13:03:30 -04:00
co-authored by Claude Fable 5.1
parent a6dc2ddb85
commit bf1cabbc6b
4 changed files with 1849 additions and 669 deletions
+536 -212
View File
@@ -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:<uuid>' 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:<source>:<id>' = exactly one card, no menu, no headings. The
earlier entries 'timer:<uuid>' 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)
const solo = variant === 'ltc' ? LTC_KEY
: variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}`
: variant.startsWith('solo:') ? variant.slice('solo:'.length)
: '';
/** Last name seen for a solo timer, so its card stays labeled if the
timer is later deleted in ProPresenter. */
let lastSoloName = '';
/** 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 }) {
<div class="tm-wrap">
<div class="tm-banner" hidden></div>
<div class="tm-hint" hidden></div>
<div class="tm-grid"></div>
<div class="tm-groups"></div>
</div>`;
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 || '—';
/** [{ 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 }] }];
}
function visibleItems() {
const ltcState = state?.ltc || { supported: null, time: '', receiving: false };
if (soloKey === LTC_KEY) return [{ key: LTC_KEY, ltc: ltcState }];
if (soloKey) {
const timer = (state?.timers || []).find((t) => t.uuid === soloKey);
if (timer) {
lastSoloName = timer.name;
return [{ key: soloKey, timer }];
}
// The timer this tile was added for isn't in ProPresenter's list (yet,
// or any more) — keep a clearly-labeled card up rather than erroring.
return [{ key: soloKey, missing: true }];
// 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 groups = [];
for (const source of state.sources) {
if (!sourceShown(source)) continue;
const items = [];
for (const t of state?.timers || []) {
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
for (const timer of source.timers) {
const key = keyOf(source, timer);
if (!hidden.has(key)) items.push({ key, source, timer });
}
if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
return items;
// 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 });
}
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;
}
// 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}`;
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}`);
}
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;
}
if (!state) {
bannerEl.hidden = true;
if (!state) {
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;
for (const [key, card] of cards) {
if (!wanted.has(key)) {
card.el.remove();
cards.delete(key);
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;
}
}
for (const item of items) {
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;
}
if (item.ltc) updateLtcCard(card, item.ltc);
else if (item.missing) updateMissingCard(card);
else updateTimerCard(card, item.timer);
updateCard(card, item);
wantedKeys.add(item.key);
els.push(card.el);
}
// 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 (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 (wantedKeys.has(key)) continue;
card.el.remove();
cards.delete(key);
structureChanged = true;
}
for (const [id, ge] of groupEls) {
if (groups.some((g) => g.id === id)) continue;
ge.el.remove();
groupEls.delete(id);
structureChanged = true;
}
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() {
+81 -9
View File
@@ -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" }
}
}
File diff suppressed because it is too large Load Diff
+201 -116
View File
@@ -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;