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:
co-authored by
Claude Fable 5.1
parent
a6dc2ddb85
commit
bf1cabbc6b
@@ -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)
|
||||
: '';
|
||||
/** 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 }) {
|
||||
<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 || '—';
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
+1022
-323
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user