Timers: the gear chooses what a card shows, not which timers; a 24-hour switch; a date that fits

Which timers appear is the header's Timers ▾ menu's job, so the gear's
source switches (ProPresenter / LTC / module timers, the Clock on/off)
are gone. In their place the Show group offers the card's elements —
Names, Status lines, Detail lines, Seconds, Leading zeros, Source
headings; without seconds a countdown reads in whole minutes, rounded
up. The clock's format select is a 24-hour switch (an older layout's
"12h" choice is honoured); the clock card has no CLOCK label; Short day
and Short month appear once Date is on, and the date fits its card — one
line when it can, else a break after the day and a size that fits the
longer half. Solo tiles get the same element switches.

Shell: the settings window's grab bar is the accent colour and the gear
that opened it wears the same colour while it is open; showWhen works in
a tile's gear as it does on the admin page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-15 09:53:41 -04:00
co-authored by Claude Fable 5.1
parent 1db6c2c89f
commit efbcf960dd
7 changed files with 319 additions and 95 deletions
+2
View File
@@ -197,6 +197,8 @@ for a clock's format switches — give it a schema of what applies.
`showWhen: "<key>"` hides a field until the boolean/`switch` field named `showWhen: "<key>"` hides a field until the boolean/`switch` field named
`<key>` is on — e.g. the audio device and channel appear only once the LTC `<key>` is on — e.g. the audio device and channel appear only once the LTC
listener switch is enabled. The hidden field still keeps its stored value. listener switch is enabled. The hidden field still keeps its stored value.
It works the same in a tile's gear (`instanceSchema`): "Short day" and
"Short month" appear once the clock's "Date" is on.
A `select` whose choices are only knowable at runtime (audio devices, serial A `select` whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
+125 -47
View File
@@ -41,8 +41,6 @@
theme; left at their defaults they follow the theme's variables. */ theme; left at their defaults they follow the theme's variables. */
/** Which instance switch governs a source kind. */ /** 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". */ /** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */
const COLOR_SETTINGS = [ const COLOR_SETTINGS = [
['runningColor', '--tm-ok', '#2ee59a'], ['runningColor', '--tm-ok', '#2ee59a'],
@@ -65,15 +63,17 @@ const LTC_KEY = 'ltc:ltc';
switches default to on, the clock to 24-hour with seconds and no date — switches default to on, the clock to 24-hour with seconds and no date —
the same values module.json gives the full "All timers" schema. */ the same values module.json gives the full "All timers" schema. */
const DEFAULTS = { const DEFAULTS = {
showClock: true, showNames: true,
showProPresenter: true,
showLtc: true,
showModules: true,
showHeadings: true, showHeadings: true,
showStatus: true, showStatus: true,
clockFormat: '24h', showDetail: true,
showSeconds: true,
leadingZeros: false,
clock24h: true,
clockSeconds: true, clockSeconds: true,
clockDate: false, clockDate: false,
dateShortDay: false,
dateShortMonth: false,
}; };
/* ── SMPTE timecode math (module-scope, exported for tests) ───────── /* ── SMPTE timecode math (module-scope, exported for tests) ─────────
@@ -140,14 +140,26 @@ export function valueAt(timer, sourceUpdatedAt, now) {
} }
/** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */ /** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */
export function fmtDuration(ms) { /**
* A duration as digits. `seconds: false` shows minutes only — a countdown
* rounds up (3:59 left reads "4", 0:30 over reads "-1"), an elapsed timer
* rounds down; `zeros` pads the leading field to two digits ("05:07").
*/
export function fmtDuration(ms, { seconds = true, zeros = false, up = false } = {}) {
const sign = ms < 0 ? '-' : ''; const sign = ms < 0 ? '-' : '';
const s = Math.floor(Math.abs(ms) / 1000); const total = Math.abs(ms) / 1000;
const pad = (v) => String(v).padStart(2, '0');
if (!seconds) {
const mins = up ? Math.ceil(total / 60) : Math.floor(total / 60);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}` : `${sign}${zeros ? pad(m) : m}`;
}
const s = Math.floor(total);
const h = Math.floor(s / 3600); const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60); const m = Math.floor((s % 3600) / 60);
const sec = s % 60; const sec = s % 60;
const pad = (v) => String(v).padStart(2, '0'); return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}:${pad(sec)}` : `${sign}${zeros ? pad(m) : m}:${pad(sec)}`;
return h ? `${sign}${h}:${pad(m)}:${pad(sec)}` : `${sign}${m}:${pad(sec)}`;
} }
function warnWindow(targetMs) { function warnWindow(targetMs) {
@@ -198,7 +210,10 @@ export default function create({ root, moduleApi }) {
whatever its (possibly slimmer) schema would or wouldn't default. */ whatever its (possibly slimmer) schema would or wouldn't default. */
const pref = (key) => { const pref = (key) => {
const v = settings()[key]; const v = settings()[key];
return v === undefined || v === null ? DEFAULTS[key] : v; if (v !== undefined && v !== null) return v;
// A layout from before the 24-hour switch chose a "clockFormat".
if (key === 'clock24h' && settings().clockFormat === '12h') return false;
return DEFAULTS[key];
}; };
const keyOf = (source, timer) => `${source.id}:${timer.id}`; const keyOf = (source, timer) => `${source.id}:${timer.id}`;
@@ -348,11 +363,6 @@ export default function create({ root, moduleApi }) {
/* ── state → what this tile shows ───────────────────────────────── */ /* ── state → what this tile shows ───────────────────────────────── */
function sourceShown(source) {
const key = SOURCE_SWITCH[source.kind] || 'showModules';
return pref(key) !== false;
}
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ /** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
function visibleGroups() { function visibleGroups() {
if (!state) return []; if (!state) return [];
@@ -372,7 +382,6 @@ export default function create({ root, moduleApi }) {
} }
const groups = []; const groups = [];
for (const source of state.sources) { for (const source of state.sources) {
if (!sourceShown(source)) continue;
const items = []; const items = [];
for (const timer of source.timers) { for (const timer of source.timers) {
const key = keyOf(source, timer); const key = keyOf(source, timer);
@@ -418,6 +427,7 @@ export default function create({ root, moduleApi }) {
/** The parts of a card that only change with the snapshot (label, kind). */ /** The parts of a card that only change with the snapshot (label, kind). */
function updateCard(card, item) { function updateCard(card, item) {
card.item = item; card.item = item;
card.isClock = !item.missing && item.source?.kind === 'clock';
const isLtc = item.source?.kind === 'ltc'; const isLtc = item.source?.kind === 'ltc';
const badge = card.nameEl.querySelector('.tm-ltc-badge'); const badge = card.nameEl.querySelector('.tm-ltc-badge');
if (isLtc && !badge) { if (isLtc && !badge) {
@@ -428,7 +438,7 @@ export default function create({ root, moduleApi }) {
} else if (!isLtc && badge) { } else if (!isLtc && badge) {
badge.remove(); badge.remove();
} }
const label = item.missing ? (lastSoloLabel || 'Timer') : item.timer.label; const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label;
if (card.labelEl.textContent !== label) { if (card.labelEl.textContent !== label) {
setText(card.labelEl, label); setText(card.labelEl, label);
needFit = true; // the title's size follows its length needFit = true; // the title's size follows its length
@@ -454,7 +464,7 @@ export default function create({ root, moduleApi }) {
/* ── clocks ─────────────────────────────────────────────────────── */ /* ── clocks ─────────────────────────────────────────────────────── */
function clockOptions() { function clockOptions() {
const h12 = pref('clockFormat') === '12h'; const h12 = pref('clock24h') === false;
return { return {
hour12: h12, hour12: h12,
hour: h12 ? 'numeric' : '2-digit', hour: h12 ? 'numeric' : '2-digit',
@@ -467,8 +477,43 @@ export default function create({ root, moduleApi }) {
return new Date().toLocaleTimeString([], clockOptions()); return new Date().toLocaleTimeString([], clockOptions());
} }
function dateText() { /** The date as the day and the rest — the break, when one is needed, falls between them. */
return new Date().toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); function dateParts() {
const d = new Date();
return {
day: d.toLocaleDateString([], { weekday: pref('dateShortDay') ? 'short' : 'long' }),
rest: d.toLocaleDateString([], { month: pref('dateShortMonth') ? 'short' : 'long', day: 'numeric' }),
};
}
/**
* The clock's date under its digits: one line when it fits the card,
* otherwise a line break after the day and a size that fits the longer
* half. Re-done only when the text or the card width changes.
*/
function renderDate(card, d) {
const cw = lastFit.cw;
const lineSize = lastFit.meta * LINE_SCALE;
const whole = `${d.day}, ${d.rest}`;
const key = `${whole}|${Math.round(cw)}|${lineSize}`;
if (card.dateKey === key) return;
card.dateKey = key;
let px = lineSize;
let wrapIt = false;
if (textWidth(whole, lineSize, 0.1) > cw) {
wrapIt = true;
const widest = Math.max(textWidth(`${d.day},`, lineSize, 0.1), textWidth(d.rest, lineSize, 0.1));
px = Math.max(8, Math.floor(lineSize * Math.min(1, cw / widest)));
}
const el = card.statusEl;
el.textContent = '';
const a = document.createElement('span');
a.textContent = `${d.day},`;
const b = document.createElement('span');
b.textContent = d.rest;
el.append(a, document.createTextNode(' '), b);
el.style.fontSize = px === lineSize ? '' : `${px}px`;
card.dateCls = wrapIt ? ' is-date is-wrap' : ' is-date';
} }
/** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */ /** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */
@@ -559,6 +604,8 @@ export default function create({ root, moduleApi }) {
if (!state) return; if (!state) return;
const now = serverNow(); const now = serverNow();
const showStatus = pref('showStatus') !== false; const showStatus = pref('showStatus') !== false;
const showDetail = pref('showDetail') !== false;
const digits = { seconds: pref('showSeconds') !== false, zeros: pref('leadingZeros') === true };
let charsChanged = false; let charsChanged = false;
for (const card of cards.values()) { for (const card of cards.values()) {
const { item } = card; const { item } = card;
@@ -573,6 +620,7 @@ export default function create({ root, moduleApi }) {
let statusTone = ''; let statusTone = '';
let detailText = ''; let detailText = '';
let extra = ''; let extra = '';
let date = null;
if (timer.kind === 'clock') { if (timer.kind === 'clock') {
if (source.kind === 'ltc') { if (source.kind === 'ltc') {
const l = timer.ltc || {}; const l = timer.ltc || {};
@@ -588,7 +636,8 @@ export default function create({ root, moduleApi }) {
} else if (source.kind === 'clock') { } else if (source.kind === 'clock') {
text = wallClockText(); text = wallClockText();
tone = 'plain'; tone = 'plain';
statusText = pref('clockDate') ? dateText() : ''; date = pref('clockDate') ? dateParts() : null;
statusText = date ? `${date.day}, ${date.rest}` : '';
statusTone = 'muted'; statusTone = 'muted';
} else { } else {
text = providerClockText(timer, source, now); text = providerClockText(timer, source, now);
@@ -598,7 +647,7 @@ export default function create({ root, moduleApi }) {
} }
} else { } else {
const value = valueAt(timer, source.updatedAt, now); const value = valueAt(timer, source.updatedAt, now);
text = value === null ? '—' : fmtDuration(value); text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
tone = toneFor(timer, value); tone = toneFor(timer, value);
statusText = timer.status?.text || defaultStatus(timer, value, tone); statusText = timer.status?.text || defaultStatus(timer, value, tone);
statusTone = timer.status?.tone || tone; statusTone = timer.status?.tone || tone;
@@ -615,12 +664,18 @@ export default function create({ root, moduleApi }) {
} }
// Notes that must survive the compact look: an unavailable LTC input. // Notes that must survive the compact look: an unavailable LTC input.
const isNote = extra === ' is-unavailable'; const isNote = extra === ' is-unavailable';
setText(card.statusEl, statusText); setText(card.statusEl, statusText); // a no-op over the date's two spans: same text
card.statusEl.hidden = !statusText || (!showStatus && !isNote); card.statusEl.hidden = !statusText || (!showStatus && !isNote);
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : ''); if (date && !card.statusEl.hidden) renderDate(card, date);
else if (card.dateCls) {
card.dateCls = '';
card.dateKey = '';
card.statusEl.style.fontSize = '';
}
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : '') + (card.dateCls || '');
card.statusEl.dataset.tone = statusTone; card.statusEl.dataset.tone = statusTone;
setText(card.detailEl, detailText); setText(card.detailEl, detailText);
card.detailEl.hidden = !detailText || !showStatus; card.detailEl.hidden = !detailText || !showDetail;
const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : ''; const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : '';
setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`); setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`);
} }
@@ -694,9 +749,17 @@ export default function create({ root, moduleApi }) {
needFit = true; needFit = true;
} }
const showHeadings = !solo && pref('showHeadings') !== false; const showHeadings = !solo && pref('showHeadings') !== false;
const showStatus = pref('showStatus') !== false;
wrap.classList.toggle('tm-headings', showHeadings); wrap.classList.toggle('tm-headings', showHeadings);
wrap.classList.toggle('tm-nostatus', !showStatus); for (const [cls, on] of [
['tm-nostatus', pref('showStatus') === false],
['tm-nodetail', pref('showDetail') === false],
['tm-nonames', pref('showNames') === false],
]) {
if (wrap.classList.contains(cls) !== on) {
wrap.classList.toggle(cls, on);
needFit = true;
}
}
let hint = ''; let hint = '';
const groups = visibleGroups(); const groups = visibleGroups();
@@ -779,20 +842,23 @@ export default function create({ root, moduleApi }) {
/** Status and detail lines are drawn at this share of the label's size (style.css agrees). */ /** Status and detail lines are drawn at this share of the label's size (style.css agrees). */
const LINE_SCALE = 0.8; const LINE_SCALE = 0.8;
/* The title fits its card: measured off-screen, it shrinks (down to a /* Text measured off-screen the way the CSS draws it (bold, uppercase,
floor) before the card would cut it off. Uppercase and letter-spaced letter-spaced), so a title or the date can shrink before the card
like the CSS draws it. */ would cut it off. */
const measurer = document.createElement('canvas').getContext('2d'); const measurer = document.createElement('canvas').getContext('2d');
let labelFamily = ''; let textFamily = '';
function labelWidth(text, px) { function textWidth(text, px, spacingEm) {
if (!labelFamily) labelFamily = getComputedStyle(root).fontFamily || 'sans-serif'; if (!textFamily) textFamily = getComputedStyle(root).fontFamily || 'sans-serif';
measurer.font = `700 ${px}px ${labelFamily}`; measurer.font = `700 ${px}px ${textFamily}`;
const t = String(text || '').toUpperCase(); const t = String(text || '').toUpperCase();
return measurer.measureText(t).width + 0.08 * px * Math.max(0, t.length - 1); return measurer.measureText(t).width + spacingEm * px * Math.max(0, t.length - 1);
} }
/** The last fit's card width and label size — what the date is fitted against. */
let lastFit = { cw: 200, meta: 12 };
function fitLabel(card, meta, avail) { function fitLabel(card, meta, avail) {
if (card.isClock) return; // the clock has no label line
const room = Math.max(24, avail - (card.nameEl.querySelector('.tm-ltc-badge') ? 34 : 0)); const room = Math.max(24, avail - (card.nameEl.querySelector('.tm-ltc-badge') ? 34 : 0));
const natural = labelWidth(card.labelEl.textContent, meta); const natural = textWidth(card.labelEl.textContent, meta, 0.08);
const px = natural > room ? Math.max(8, Math.floor((meta * room) / natural)) : meta; const px = natural > room ? Math.max(8, Math.floor((meta * room) / natural)) : meta;
card.labelEl.style.fontSize = px === meta ? '' : `${px}px`; card.labelEl.style.fontSize = px === meta ? '' : `${px}px`;
} }
@@ -810,15 +876,25 @@ export default function create({ root, moduleApi }) {
const groups = [...groupEls.values()].filter((g) => g.el.isConnected); const groups = [...groupEls.values()].filter((g) => g.el.isConnected);
const headings = wrap.classList.contains('tm-headings') ? groups.length : 0; const headings = wrap.classList.contains('tm-headings') ? groups.length : 0;
const showStatus = !wrap.classList.contains('tm-nostatus'); const showStatus = !wrap.classList.contains('tm-nostatus');
// Lines under the digits besides the label: status, and detail if any card has one. const showDetail = !wrap.classList.contains('tm-nodetail');
const metaLines = showStatus ? 1 + (list.some((c) => !c.detailEl.hidden) ? 1 : 0) : 0; const namesOn = !wrap.classList.contains('tm-nonames');
// Lines under the digits: status, and detail if any card has one.
const metaLines = (showStatus ? 1 : 0) + (showDetail && list.some((c) => !c.detailEl.hidden) ? 1 : 0);
const date = showStatus && pref('clockDate') && list.some((c) => c.isClock) ? dateParts() : null;
const dateWhole = date ? `${date.day}, ${date.rest}` : '';
// For each column count, budget a card's full stack (label, digits, // 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, // 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 // so no timer ends up unreadable. Cards too short for the full stack
// drop to a compact look (label + digits, the colour carries the // drop to a compact look (label + digits, the colour carries the
// state), but any layout with a readable full stack beats every // state), but any layout with a readable full stack beats every
// compact one. // compact one. The clock has no label line, and its date may take two.
const budgetFor = (c, ch, meta, mode, dateWrap) => {
const nameLine = namesOn && !c.isClock ? 1.3 * meta : 0;
if (mode === 'compact') return ch - nameLine;
const lines = metaLines + (c.isClock && dateWrap ? 1 : 0);
return ch - nameLine - lines * 1.3 * meta * LINE_SCALE;
};
let best = null; let best = null;
for (let cols = 1; cols <= n; cols += 1) { for (let cols = 1; cols <= n; cols += 1) {
const rows = headings const rows = headings
@@ -828,19 +904,18 @@ export default function create({ root, moduleApi }) {
const ch = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1) - (headings ? (headings - 1) * GAP : 0)) / 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; if (cw <= 0 || ch <= 0) continue;
const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16))); const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16)));
const fullBudget = ch - 1.3 * meta - metaLines * 1.3 * meta * LINE_SCALE; const dateWrap = Boolean(dateWhole) && textWidth(dateWhole, meta * LINE_SCALE, 0.1) > cw;
const compactBudget = ch - 1.3 * meta;
let full = Infinity; let full = Infinity;
let compact = Infinity; let compact = Infinity;
for (const c of list) { for (const c of list) {
const byWidth = (cw * 1.55) / c.chars; const byWidth = (cw * 1.55) / c.chars;
full = Math.min(full, byWidth, fullBudget); full = Math.min(full, byWidth, budgetFor(c, ch, meta, 'full', dateWrap));
compact = Math.min(compact, byWidth, compactBudget); compact = Math.min(compact, byWidth, budgetFor(c, ch, meta, 'compact', dateWrap));
} }
const mode = full >= 14 ? 'full' : 'compact'; const mode = full >= 14 ? 'full' : 'compact';
const size = mode === 'full' ? full : compact; const size = mode === 'full' ? full : compact;
const score = (mode === 'full' ? 1000 : 0) + size; const score = (mode === 'full' ? 1000 : 0) + size;
if (!best || score > best.score) best = { cols, meta, mode, cw, budget: mode === 'full' ? fullBudget : compactBudget, score }; if (!best || score > best.score) best = { cols, meta, mode, cw, ch, dateWrap, score };
} }
if (!best) return; if (!best) return;
for (const g of groups) { for (const g of groups) {
@@ -853,10 +928,13 @@ export default function create({ root, moduleApi }) {
wrap.style.setProperty('--tm-gap', GAP + 'px'); wrap.style.setProperty('--tm-gap', GAP + 'px');
wrap.style.setProperty('--tm-meta-size', best.meta + 'px'); wrap.style.setProperty('--tm-meta-size', best.meta + 'px');
wrap.classList.toggle('tm-compact', best.mode === 'compact'); wrap.classList.toggle('tm-compact', best.mode === 'compact');
lastFit = { cw: best.cw, meta: best.meta };
for (const c of list) { for (const c of list) {
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, best.budget))); const budget = budgetFor(c, best.ch, best.meta, best.mode, best.dateWrap);
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, budget)));
c.timeEl.style.fontSize = px + 'px'; c.timeEl.style.fontSize = px + 'px';
fitLabel(c, best.meta, best.cw); fitLabel(c, best.meta, best.cw);
c.dateKey = ''; // the date re-fits to the new card width on the next paint
} }
} }
+130 -31
View File
@@ -4,16 +4,30 @@
"version": "1.3.0", "version": "1.3.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": ["timers"], "provides": [
"timers"
],
"permissions": [ "permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" }, {
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } "kind": "microphone",
"reason": "Decoding SMPTE LTC timecode from an audio input on this machine"
},
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
], ],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": {
"defaultSize": { "w": 4, "h": 3 }, "w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 3
},
"configGroups": { "configGroups": {
"ProPresenter": { "ProPresenter": {
"toggle": "ppEnabled", "toggle": "ppEnabled",
@@ -39,7 +53,10 @@
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
"group": "ProPresenter", "group": "ProPresenter",
"default": { "host": "", "port": 1025 } "default": {
"host": "",
"port": 1025
}
}, },
"password": { "password": {
"type": "password", "type": "password",
@@ -82,14 +99,26 @@
"group": "Display", "group": "Display",
"default": "default", "default": "default",
"options": [ "options": [
{ "value": "default", "label": "Default (system)" }, {
{ "value": "monospace", "label": "Monospace — steadier changing digits" } "value": "default",
"label": "Default (system)"
},
{
"value": "monospace",
"label": "Monospace — steadier changing digits"
}
] ]
} }
}, },
"instanceGroups": { "instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }, "Show": {
"Clock": { "collapsed": false, "toggle": "showClock" }, "collapsed": false,
"columns": 2
},
"Clock": {
"collapsed": false,
"columns": 2
},
"Colours": { "Colours": {
"collapsed": true, "collapsed": true,
"columns": 2, "columns": 2,
@@ -97,27 +126,97 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" }, "showNames": {
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" }, "type": "switch",
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" }, "label": "Names",
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" }, "default": true,
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" }, "group": "Show"
"showClock": { "type": "switch", "label": "Clock", "default": true, "group": "Clock" },
"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" }, "showStatus": {
"clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" }, "type": "switch",
"runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" }, "label": "Status lines",
"warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" }, "default": true,
"overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" }, "group": "Show"
"idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" } },
"showDetail": {
"type": "switch",
"label": "Detail lines",
"default": true,
"group": "Show"
},
"showSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Show"
},
"leadingZeros": {
"type": "switch",
"label": "Leading zeros",
"default": false,
"group": "Show"
},
"showHeadings": {
"type": "switch",
"label": "Source headings",
"default": true,
"group": "Show"
},
"clock24h": {
"type": "switch",
"label": "24-hour",
"default": true,
"group": "Clock"
},
"clockSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Clock"
},
"clockDate": {
"type": "switch",
"label": "Date",
"default": false,
"group": "Clock"
},
"dateShortDay": {
"type": "switch",
"label": "Short day",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"dateShortMonth": {
"type": "switch",
"label": "Short month",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"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"
}
} }
} }
+10 -7
View File
@@ -911,20 +911,23 @@ function gearOf(keys, { loose = [], groups = {} } = {}) {
} }
const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor']; const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor'];
/** What a timer card can show or hide of itself (which timers appear is the header menu's job). */
const TIMER_ELEMENTS = ['showNames', 'showStatus', 'showDetail', 'showSeconds', 'leadingZeros'];
const CLOCK_ELEMENTS = ['clock24h', 'clockSeconds', 'clockDate', 'dateShortDay', 'dateShortMonth'];
/** With one card there is little else to see: the colours start unfolded. */ /** With one card there is little else to see: the colours start unfolded. */
const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } }; const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } };
const GEAR = { const GEAR = {
/** Every source, the headings, the clock's format, the tones. */ /** Every card's elements, the headings, how the clock reads, the tones. */
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS }, all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
/** One ProPresenter or module timer: its lines, and the tone its digits take per state. */ /** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
timer: gearOf(['showStatus', ...COLOURS], { loose: ['showStatus'], ...OPEN_COLOURS }), timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns. */ /** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
ltc: gearOf(['showStatus', 'runningColor', 'warningColor', 'idleColor'], { loose: ['showStatus'], ...OPEN_COLOURS }), ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
/** The time of day: plain digits, so only how they read. */ /** The time of day: plain digits, so only how they read. */
clock: gearOf(['clockFormat', 'clockSeconds', 'clockDate']), clock: gearOf(CLOCK_ELEMENTS),
/** A clock another module offers: read like the clock, muted while it isn't running. */ /** A clock another module offers: read like the clock, muted while it isn't running. */
providerClock: gearOf(['showStatus', 'clockFormat', 'clockSeconds', 'idleColor'], { loose: ['showStatus'], ...OPEN_COLOURS }), providerClock: gearOf(['showNames', 'showStatus', 'clock24h', 'clockSeconds', 'idleColor'], OPEN_COLOURS),
}; };
/** The gear a solo card of this timer gets — by what the card can show. */ /** The gear a solo card of this timer gets — by what the card can show. */
+16 -1
View File
@@ -132,7 +132,10 @@
[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note), [data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-compact .tm-detail, [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-status:not(.is-note),
[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; } [data-module="propresenter-timers"] .tm-nodetail .tm-detail { display: none; }
/* "Names" off hides every title; the clock never carries one — its digits say what it is */
[data-module="propresenter-timers"] .tm-nonames .tm-name,
[data-module="propresenter-timers"] .tm-card-clock .tm-name { display: none; }
/* ── one timer card ── */ /* ── one timer card ── */
@@ -221,6 +224,18 @@
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); } [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); } [data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
/* The clock's date: one line when it fits, else the day on its own line
(renderDate() sizes it to the longer half). */
[data-module="propresenter-timers"] .tm-status.is-date {
white-space: normal;
overflow: visible;
text-overflow: clip;
text-align: center;
line-height: 1.15;
}
[data-module="propresenter-timers"] .tm-status.is-date span { white-space: nowrap; }
[data-module="propresenter-timers"] .tm-status.is-date.is-wrap span { display: block; }
/* Explanatory notes read as sentences, not badges. */ /* Explanatory notes read as sentences, not badges. */
[data-module="propresenter-timers"] .tm-status.is-note { [data-module="propresenter-timers"] .tm-status.is-note {
text-transform: none; text-transform: none;
+27 -3
View File
@@ -286,7 +286,7 @@ function buildTile(tile) {
gear.innerHTML = GEAR_SVG; gear.innerHTML = GEAR_SVG;
gear.addEventListener('click', (e) => { gear.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
toggleSettingsPopover(tile, el); toggleSettingsPopover(tile, el, gear);
}); });
head.appendChild(gear); head.appendChild(gear);
} }
@@ -1108,6 +1108,7 @@ function tileSchema(tile, man) {
function closeSettingsPopovers() { function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => { document.querySelectorAll('.tile-settings').forEach((p) => {
p._cleanup?.(); p._cleanup?.();
p._button?.classList.remove('is-open');
p.remove(); p.remove();
}); });
} }
@@ -1143,7 +1144,8 @@ function clampPopover(pop) {
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px'; pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
} }
function toggleSettingsPopover(tile, el) { /** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */
function toggleSettingsPopover(tile, el, button = null) {
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`); const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
closeSettingsPopovers(); closeSettingsPopovers();
if (existing) return; if (existing) return;
@@ -1156,6 +1158,11 @@ function toggleSettingsPopover(tile, el) {
pop.dataset.tileId = tile.id; pop.dataset.tileId = tile.id;
pop.setAttribute('role', 'dialog'); pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', `${tile.title || man.name} settings`); pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
// The gear that opened it wears the window's colour while it is open.
if (button) {
button.classList.add('is-open');
pop._button = button;
}
// A slim bar to move it by — nothing else. No title (the window sits beside // A slim bar to move it by — nothing else. No title (the window sits beside
// the tile it belongs to) and no close button: a click anywhere else, or // the tile it belongs to) and no close button: a click anywhere else, or
@@ -1168,6 +1175,7 @@ function toggleSettingsPopover(tile, el) {
pop.append(bar, body); pop.append(bar, body);
const inputs = new Map(); const inputs = new Map();
const shownWhen = []; // fields that appear only while another has a given value
/* Display choices apply as they change and save with the layout — nothing /* Display choices apply as they change and save with the layout — nothing
to confirm, so no Apply. Typing is debounced because applying means to confirm, so no Apply. Typing is debounced because applying means
@@ -1446,6 +1454,8 @@ function toggleSettingsPopover(tile, el) {
field.appendChild(help); field.appendChild(help);
} }
if (typeof spec?.showWhen === 'string' && spec.showWhen) shownWhen.push({ field, key: spec.showWhen });
if (current && switchInput && key === current.toggleKey) { if (current && switchInput && key === current.toggleKey) {
// The group's on/off lives in its title row, never in the body, and a // The group's on/off lives in its title row, never in the body, and a
// click on it must not fold a <details>. // click on it must not fold a <details>.
@@ -1461,7 +1471,21 @@ function toggleSettingsPopover(tile, el) {
} }
} }
// Nothing to press: click away, press Escape, the ✕, or the gear again. // "Short day" appears once "Date" is on: a field with showWhen:"K" follows
// K's switch, live — the same rule the admin page applies.
if (shownWhen.length) {
const refresh = () => {
for (const rule of shownWhen) {
const read = inputs.get(rule.key);
rule.field.hidden = read ? !read() : false;
}
};
refresh();
body.addEventListener('change', refresh);
body.addEventListener('input', refresh);
}
// Nothing to press: click away, press Escape, or the gear again.
// (The gear's own click stops propagating, so it toggles rather than // (The gear's own click stops propagating, so it toggles rather than
// closing and reopening.) // closing and reopening.)
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); }; const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
+9 -6
View File
@@ -306,6 +306,8 @@ body.menu-hidden #topbar {
.tile-btn:hover { background: var(--border); color: var(--text); } .tile-btn:hover { background: var(--border); color: var(--text); }
.tile-btn.close:hover { color: var(--danger); } .tile-btn.close:hover { color: var(--danger); }
.tile-btn.active { color: var(--accent); } .tile-btn.active { color: var(--accent); }
.tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
.tile-btn svg { width: 13px; height: 13px; display: block; } .tile-btn svg { width: 13px; height: 13px; display: block; }
/* controls a module adds to its own title bar (moduleApi.header) */ /* controls a module adds to its own title bar (moduleApi.header) */
@@ -554,15 +556,15 @@ body.fullscreen #grid { padding-bottom: 0; }
border-radius: 10px; border-radius: 10px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55); box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
} }
/* the grab bar: a slim strip with a grip mark, nothing to read or press */ /* the grab bar: a slim accent strip with a grip mark, nothing to read or
press — the gear that opened the window wears the same colour meanwhile */
.tile-settings-bar { .tile-settings-bar {
flex: none; flex: none;
height: 14px; height: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.04); background: var(--accent);
border-bottom: 1px solid var(--border);
cursor: grab; cursor: grab;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
@@ -573,11 +575,12 @@ body.fullscreen #grid { padding-bottom: 0; }
width: 34px; width: 34px;
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
background: var(--muted); background: var(--accent-text);
opacity: 0.55; opacity: 0.7;
transition: opacity 0.12s; transition: opacity 0.12s;
} }
.tile-settings-bar:hover::before { opacity: 0.9; } .tile-settings-bar:hover::before { opacity: 1; }
.tile-settings .field[hidden] { display: none; }
.tile-settings-bar:active { cursor: grabbing; } .tile-settings-bar:active { cursor: grabbing; }
.tile-settings-body { .tile-settings-body {
flex: 1; flex: 1;