Timers: steadier menu, ellipsised labels, saner warning window
- The Timers ▾ menu's rows are rebuilt only when the row set or a switch changes, not on every snapshot — rows no longer vanish from under a click. Their on-state fill uses the shell's accent: the menu hangs off the title bar, outside the tile root, where the tile's colour properties don't reach. - A long timer label ends in an ellipsis (the label is its own box inside the flex name row). - The warning window is capped at half a timer's length, so a 20 s countdown isn't amber for its whole run (it warns under 10 s; a 25 min one under 2:30). - Server: the change key that gates SSE frames ignores startedAt (derived from a whole-second value, it jittered ±500 ms per poll and doubled the frame rate); ProPresenter errors name the cause (ECONNREFUSED) rather than fetch's "fetch failed". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
1e92454e76
commit
555bc482ce
@@ -22,8 +22,9 @@
|
|||||||
Feedback — the state colours, and the thresholds behind them:
|
Feedback — the state colours, and the thresholds behind them:
|
||||||
running elapsed, no planned length running
|
running elapsed, no planned length running
|
||||||
running elapsed with a planned length warning within
|
running elapsed with a planned length warning within
|
||||||
WARN_FLOOR_MS or WARN_FRACTION of the length (whichever is larger)
|
WARN_FLOOR_MS or WARN_FRACTION of the length (whichever is larger,
|
||||||
of the end; overrun once past it
|
but never more than half the length — a 20 s countdown warns
|
||||||
|
under 10 s, a 25 min one under 2:30) of the end; overrun past it
|
||||||
running countdown warning with that much
|
running countdown warning with that much
|
||||||
left; overrun at zero and below
|
left; overrun at zero and below
|
||||||
stopped countdown at zero ("complete") warning
|
stopped countdown at zero ("complete") warning
|
||||||
@@ -130,7 +131,8 @@ export function fmtDuration(ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function warnWindow(targetMs) {
|
function warnWindow(targetMs) {
|
||||||
return targetMs ? Math.max(WARN_FLOOR_MS, targetMs * WARN_FRACTION) : WARN_FLOOR_MS;
|
if (!targetMs) return WARN_FLOOR_MS;
|
||||||
|
return Math.min(Math.max(WARN_FLOOR_MS, targetMs * WARN_FRACTION), targetMs / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The state colour for a (non-clock) timer given its current value. */
|
/** The state colour for a (non-clock) timer given its current value. */
|
||||||
@@ -242,9 +244,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
label: 'Timers ▾',
|
label: 'Timers ▾',
|
||||||
title: 'Choose which timers this tile shows',
|
title: 'Choose which timers this tile shows',
|
||||||
build(menu) {
|
build(menu) {
|
||||||
|
// the shell hands over an emptied element on every open
|
||||||
menu.classList.add('tm-menu');
|
menu.classList.add('tm-menu');
|
||||||
menuEl = menu;
|
menuEl = menu;
|
||||||
rebuildMenu();
|
rebuildMenu(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,11 +280,18 @@ export default function create({ root, moduleApi }) {
|
|||||||
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`);
|
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildMenu() {
|
let menuSignature = '';
|
||||||
|
function rebuildMenu(force = false) {
|
||||||
updateMenuLabel();
|
updateMenuLabel();
|
||||||
if (!menuEl) return; // menu not opened yet — the label is enough
|
if (!menuEl) return; // menu not opened yet — the label is enough
|
||||||
menuEl.innerHTML = '';
|
|
||||||
const rows = menuRows();
|
const rows = menuRows();
|
||||||
|
// Snapshots arrive several times a second; rebuilding the open menu's
|
||||||
|
// rows each time would replace them under the user's finger. Only a
|
||||||
|
// changed row set (or a toggled switch) is worth touching the DOM for.
|
||||||
|
const signature = JSON.stringify(rows.map((r) => [r.key, r.text, hidden.has(r.key)]));
|
||||||
|
if (!force && signature === menuSignature && menuEl.childElementCount) return;
|
||||||
|
menuSignature = signature;
|
||||||
|
menuEl.innerHTML = '';
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
const note = document.createElement('div');
|
const note = document.createElement('div');
|
||||||
note.className = 'tm-menu-note';
|
note.className = 'tm-menu-note';
|
||||||
@@ -356,6 +366,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
el.className = 'tm-card';
|
el.className = 'tm-card';
|
||||||
const nameEl = document.createElement('div');
|
const nameEl = document.createElement('div');
|
||||||
nameEl.className = 'tm-name';
|
nameEl.className = 'tm-name';
|
||||||
|
const labelEl = document.createElement('span');
|
||||||
|
labelEl.className = 'tm-label';
|
||||||
|
nameEl.appendChild(labelEl);
|
||||||
const timeEl = document.createElement('div');
|
const timeEl = document.createElement('div');
|
||||||
timeEl.className = 'tm-time';
|
timeEl.className = 'tm-time';
|
||||||
const statusEl = document.createElement('div');
|
const statusEl = document.createElement('div');
|
||||||
@@ -363,7 +376,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
const detailEl = document.createElement('div');
|
const detailEl = document.createElement('div');
|
||||||
detailEl.className = 'tm-detail';
|
detailEl.className = 'tm-detail';
|
||||||
el.append(nameEl, timeEl, statusEl, detailEl);
|
el.append(nameEl, timeEl, statusEl, detailEl);
|
||||||
return { key: item.key, el, nameEl, timeEl, statusEl, detailEl, item, chars: 5, cls: '' };
|
return { key: item.key, el, nameEl, labelEl, timeEl, statusEl, detailEl, item, chars: 5, cls: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function setText(el, text) {
|
function setText(el, text) {
|
||||||
@@ -380,18 +393,16 @@ export default function create({ root, moduleApi }) {
|
|||||||
function updateCard(card, item) {
|
function updateCard(card, item) {
|
||||||
card.item = item;
|
card.item = item;
|
||||||
const isLtc = item.source?.kind === 'ltc';
|
const isLtc = item.source?.kind === 'ltc';
|
||||||
if (isLtc && !card.nameEl.querySelector('.tm-ltc-badge')) {
|
const badge = card.nameEl.querySelector('.tm-ltc-badge');
|
||||||
const badge = document.createElement('span');
|
if (isLtc && !badge) {
|
||||||
badge.className = 'tm-ltc-badge';
|
const b = document.createElement('span');
|
||||||
badge.textContent = 'LTC';
|
b.className = 'tm-ltc-badge';
|
||||||
card.nameEl.replaceChildren(badge, document.createTextNode(''));
|
b.textContent = 'LTC';
|
||||||
} else if (!isLtc && card.nameEl.querySelector('.tm-ltc-badge')) {
|
card.nameEl.prepend(b);
|
||||||
card.nameEl.replaceChildren(document.createTextNode(''));
|
} else if (!isLtc && badge) {
|
||||||
|
badge.remove();
|
||||||
}
|
}
|
||||||
const label = item.missing ? (lastSoloLabel || 'Timer') : item.timer.label;
|
setText(card.labelEl, 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMissingCard(card) {
|
function updateMissingCard(card) {
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
lastError = '';
|
lastError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reachable = false;
|
reachable = false;
|
||||||
lastError = err instanceof Error ? err.message : String(err);
|
lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
|
||||||
}
|
}
|
||||||
publish();
|
publish();
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
@@ -949,11 +949,12 @@ module.exports = {
|
|||||||
function rebuild() {
|
function rebuild() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
latest = buildState();
|
latest = buildState();
|
||||||
// updatedAt / serverNow move on every poll; the tiles extrapolate from
|
// updatedAt / serverNow move on every poll, and a startedAt derived
|
||||||
// them but don't need a frame for that alone.
|
// from a whole-second value jitters with them; the tiles extrapolate
|
||||||
|
// from the values but don't need a frame for that alone.
|
||||||
let key;
|
let key;
|
||||||
try {
|
try {
|
||||||
key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers]));
|
key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers.map(({ startedAt, ...rest }) => rest)]));
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,8 +164,15 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The label is its own box so a long name ends in an ellipsis (a flex
|
||||||
|
container's bare text won't). */
|
||||||
|
[data-module="propresenter-timers"] .tm-label {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-time {
|
[data-module="propresenter-timers"] .tm-time {
|
||||||
@@ -306,7 +313,9 @@
|
|||||||
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
||||||
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
|
[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. */
|
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
|
||||||
|
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
|
||||||
|
properties don't reach it — the shell's palette does.) */
|
||||||
[data-module="propresenter-timers"] .tm-switch {
|
[data-module="propresenter-timers"] .tm-switch {
|
||||||
flex: none;
|
flex: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -329,8 +338,8 @@
|
|||||||
transition: transform 0.15s ease;
|
transition: transform 0.15s ease;
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
|
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
|
||||||
border-color: var(--tm-ok);
|
border-color: var(--accent);
|
||||||
background: var(--tm-ok);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
|
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
|
||||||
transform: translateX(12px);
|
transform: translateX(12px);
|
||||||
|
|||||||
Reference in New Issue
Block a user