Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 5ecc39efe2 ProdCom: direction toggle, icon channels button, leaner tile settings
Three changes to the ProdCom tile, all per-tile and persisted with the
layout (version 1.2.0):

- A title-bar button flips the transcript between top-down (newest at the
  bottom, still the default) and bottom-up. It is CSS only — the entries
  container becomes column-reverse, DOM order is untouched — but everything
  that follows "latest" flips with it: auto-follow, jump-to-latest, the
  near-latest scroll test, and the "New messages" pill, which moves to the
  top of the tile and points up.

- The "Channels ▾" text button is now a funnel icon. It keeps what the
  label carried: while any channel is hidden the icon takes the accent
  color and a compact "3/4" count sits beside it, with the full wording in
  the tooltip.

- Tile settings drop the channel-name filter and text size, both of which
  the title bar already handles, and gain "Show channel icons" for the
  per-entry avatars. Removing the channel filter made its code dead, so
  channelFilter(), filteredOut() and the data-channel-name attribute went
  with it — the menubar's hidden-channel set is now the only filter.

Text size keeps working without a schema entry: the A−/A+ buttons persist
it through saveInstanceSettings, the shell's settings popover only writes
schema keys so it survives an Apply, and the client falls back to 15px when
it is absent. Existing tiles have no saved showAvatars value and the check
is `=== false`, so they keep their icons.

Verified against tools/prodcom-mock.js: both directions render and
auto-follow correctly, the pill appears at the right end and jumps to the
newest entry, the count badge tracks the channel toggles, a tile bumped to
19px kept it across an Apply and a reload, a freshly added tile started at
15px with icons shown, and two tiles held independent state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:40:46 -04:00
20 changed files with 233 additions and 1623 deletions
-3
View File
@@ -7,6 +7,3 @@ dist/
# runtime state written by the admin page / layout saves # runtime state written by the admin page / layout saves
config/modules.json config/modules.json
config/layouts/ config/layouts/
# compiled per-machine, source sits next to it
modules/propresenter-timers/ltc-capture
+5 -18
View File
@@ -168,8 +168,10 @@ below. Nothing is installed without someone clicking.
- **ProdCom** — live transcript stream with in-place updates of in-progress - **ProdCom** — live transcript stream with in-place updates of in-progress
speech, channel colors, per-tile channel visibility toggles and group speech, channel colors, per-tile channel visibility toggles and group
presets, timestamps, text size and jump-to-latest in the title bar. presets, timestamps, text size, a top-down / bottom-up direction toggle
and jump-to-latest in the title bar.
Admin config: ProdCom server (host : port), API key. Admin config: ProdCom server (host : port), API key.
Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
@@ -177,23 +179,8 @@ below. Nothing is installed without someone clicking.
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
and pick a selection via the tile's "Timers ▾" menu; overruns are and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config is in unmistakably red. Read-only — no start/stop controls. Admin config:
three groups: **ProPresenter** (endpoint host : port, API password), ProPresenter endpoint (host : port), password.
**LTC Timecode** (the listener switch, and — when it's on — the audio
device and channel), and **Display** (Timer font — Default or Monospace,
which keeps the digits from shifting as the numbers change).
ProPresenter exposes no timecode over any API, so LTC is decoded from the
audio signal, behind the admin **LTC listener** switch (off ⇒ no LTC tile
in the picker at all). On, the module captures the selected audio input
device/channel on the ProdDash machine — a native `ltc-capture` helper,
compiled on first use — and decodes the SMPTE frames in-process:
running/stopped/no-signal, the timecode counting frame-accurately in real
time, and the frame rate (incl. 29.97 drop-frame). macOS note: the
ProdDash server needs microphone permission — launch it from a logged-in
session (Start ProdDash.command) and approve the one-time prompt; a server
started over SSH or by bare launchd silently captures zeros. (Engine
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
Executable → Regular
-7
View File
@@ -1,11 +1,4 @@
#!/bin/zsh #!/bin/zsh
# Double-click to start ProdDash (macOS) # Double-click to start ProdDash (macOS)
cd "$(dirname "$0")" cd "$(dirname "$0")"
# One dashboard per machine: take the port over from any prior instance
# (an SSH- or launchd-started one has no microphone access for the LTC
# listener — launching from here fixes that).
OLD=$(lsof -t -iTCP:24500 -sTCP:LISTEN 2>/dev/null)
[[ -n "$OLD" ]] && kill $OLD 2>/dev/null && sleep 1
# Prefer the bundled runtime (production machines have no system Node)
if [[ -x ./runtime/bin/node ]]; then exec ./runtime/bin/node server.js; fi
exec node server.js exec node server.js
+3 -28
View File
@@ -99,36 +99,13 @@ the app); its settings are kept for a later reinstall.
Both schemas map field names to specs: Both schemas map field names to specs:
``` ```
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "password" | "endpoint", { "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
"label": "Shown next to the field", "label": "Shown next to the field",
"default": <value>, "default": <value>,
"group": "Section heading", // optional; see below "options": [ { "value": "a", "label": "A" }, … ] // "select" only
"showWhen": "<other boolean/switch key>", // optional; see below
"options": [ { "value": "a", "label": "A" }, … ], // "select" only
"optionsRoute": "/devices" // "select" only, admin config only
} }
``` ```
`boolean` renders as a checkbox; `switch` is the same true/false value shown
as a toggle (use it for a feature on/off that other fields depend on).
`group` gathers consecutive fields under a labeled subsection in the admin
page, so a module with several distinct concerns (e.g. a ProPresenter
connection and an LTC audio input) reads as clearly separated blocks. Fields
with no `group` render loose, before/after the grouped ones in schema order.
`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
listener switch is enabled. The hidden field still keeps its stored value.
A `select` whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
refresh of — static `options`: the admin page GETs
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
"label" }, … ] }` from one of the module's own server routes. The saved value
stays selectable even when the route is down or the option has vanished. (See
`propresenter-timers`' "Audio device" for a worked example.)
`password` fields are admin-config only in practice: their values are stored `password` fields are admin-config only in practice: their values are stored
in the server's `modules.json` (in the per-machine data directory, see the in the server's `modules.json` (in the per-machine data directory, see the
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
@@ -519,9 +496,7 @@ client-side failures (a tile that fails to start says so in the tile).
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running, - ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
overrunning, stopped and elapsed timers plus an LTC timecode that overrunning, stopped and elapsed timers plus an LTC timecode that
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
a timecode API). Also serves the stage-display websocket with an a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
**The resilience checklist** — every module must pass this before it's done: **The resilience checklist** — every module must pass this before it's done:
+70 -44
View File
@@ -2,9 +2,9 @@
Ported from prodcom-listener/public/app.js into the ProdDash module Ported from prodcom-listener/public/app.js into the ProdDash module
contract: everything lives inside the tile's root, per-tile state contract: everything lives inside the tile's root, per-tile state
(hidden channels, timestamps, text size) persists in instance settings, (hidden channels, channel icons, timestamps, text size, flow direction)
and all network traffic goes through moduleApi (the module's server persists in instance settings, and all network traffic goes through
proxy at /prodcom/*). */ moduleApi (the module's server proxy at /prodcom/*). */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* ── per-instance state ─────────────────────────────────────────── */ /* ── per-instance state ─────────────────────────────────────────── */
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
const channels = new Map(); // channelId -> {name, color} const channels = new Map(); // channelId -> {name, color}
const groups = new Map(); // groupId -> {name, channelIds} const groups = new Map(); // groupId -> {name, channelIds}
const entryEls = new Map(); // entryId -> element const entryEls = new Map(); // entryId -> element
let pinnedToBottom = true; let pinnedToLatest = true;
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle let stream = null; // moduleApi.sse handle
let bootTimer = null; let bootTimer = null;
@@ -40,10 +40,14 @@ export default function create({ root, moduleApi }) {
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'; const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>'; const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>'; const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
const CHANNELS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
// arrow shows where new messages land: down = oldest first, up = newest first
const FLOW_DOWN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
const FLOW_UP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
let channelMenuEl = null; // the open menu's element (filled on each open) let channelMenuEl = null; // the open menu's element (filled on each open)
const channelsMenu = moduleApi.header.addMenu({ const channelsMenu = moduleApi.header.addMenu({
label: 'Channels ▾', icon: CHANNELS_SVG,
title: 'Choose visible channels', title: 'Choose visible channels',
build(menu) { build(menu) {
menu.classList.add('pt-menu'); menu.classList.add('pt-menu');
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
}, },
}); });
channelsMenu.button.classList.add('pt-channels-btn'); channelsMenu.button.classList.add('pt-channels-btn');
// the icon carries the label; a count rides alongside it while filtering
const channelCountEl = document.createElement('span');
channelCountEl.className = 'pt-count';
channelCountEl.hidden = true;
channelsMenu.button.appendChild(channelCountEl);
let timeBtn = null; let timeBtn = null;
timeBtn = moduleApi.header.addButton({ timeBtn = moduleApi.header.addButton({
@@ -71,13 +80,24 @@ export default function create({ root, moduleApi }) {
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({
icon: FLOW_DOWN_SVG,
onClick() {
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
applyInstanceSettings();
// the two directions have opposite "latest" ends — follow the flip
if (pinnedToLatest) scrollToLatest();
},
});
moduleApi.header.addButton({ moduleApi.header.addButton({
icon: JUMP_SVG, icon: JUMP_SVG,
title: 'Jump to latest', title: 'Jump to latest',
onClick() { onClick() {
pinnedToBottom = true; pinnedToLatest = true;
jumpBtn.hidden = true; jumpBtn.hidden = true;
scrollToBottom(); scrollToLatest();
}, },
}); });
@@ -93,25 +113,20 @@ export default function create({ root, moduleApi }) {
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
wrap.style.setProperty('--pt-size', size + 'px'); wrap.style.setProperty('--pt-size', size + 'px');
wrap.classList.toggle('show-times', Boolean(s.showTimes)); wrap.classList.toggle('show-times', Boolean(s.showTimes));
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
timeBtn?.classList.toggle('active', Boolean(s.showTimes)); timeBtn?.classList.toggle('active', Boolean(s.showTimes));
const up = newestFirst();
wrap.classList.toggle('newest-first', up);
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
flowBtn.classList.toggle('active', up);
flowBtn.title = up
? 'Newest at top — click for newest at bottom'
: 'Newest at bottom — click for newest at top';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
} }
applyInstanceSettings(); applyInstanceSettings();
/* ── channel filter (instance setting) ──────────────────────────── */
function channelFilter() {
return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase();
}
/** Entry hidden by the hard per-tile filter (settings.channel)? */
function filteredOut(channelId, channelName) {
const want = channelFilter();
if (!want) return false;
const known = channels.get(channelId);
const name = ((known && known.name) || channelName || '').toLowerCase();
return name !== want && String(channelId).toLowerCase() !== want;
}
/* ── channel visibility toggles ─────────────────────────────────── */ /* ── channel visibility toggles ─────────────────────────────────── */
function persistHidden() { function persistHidden() {
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
} }
function applyFilter(el) { function applyFilter(el) {
const hidden = hiddenChannels.has(el.dataset.channelId) el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
el.classList.toggle('hidden-by-filter', hidden);
} }
function toggleChannel(id) { function toggleChannel(id) {
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
persistHidden(); persistHidden();
rebuildChannelMenu(); rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el)); entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
} }
/** Show exactly this group's channels, hide the rest */ /** Show exactly this group's channels, hide the rest */
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
persistHidden(); persistHidden();
rebuildChannelMenu(); rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el)); entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
} }
function groupIsActive(g) { function groupIsActive(g) {
@@ -150,18 +163,24 @@ export default function create({ root, moduleApi }) {
); );
} }
function updateChannelsLabel() { /* The button is an icon, so the "some channels are hidden" state shows as
an accent tint plus a compact count beside it. */
function updateChannelsButton() {
let visibleCount = 0; let visibleCount = 0;
for (const id of channels.keys()) { for (const id of channels.keys()) {
if (!hiddenChannels.has(id)) visibleCount++; if (!hiddenChannels.has(id)) visibleCount++;
} }
channelsMenu.setLabel(visibleCount === channels.size const filtering = channels.size > 0 && visibleCount !== channels.size;
? 'Channels ▾' channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
: `Channels (${visibleCount}/${channels.size}) ▾`); channelCountEl.hidden = !filtering;
channelsMenu.button.classList.toggle('active', filtering);
channelsMenu.button.title = filtering
? `Channels — showing ${visibleCount} of ${channels.size}`
: 'Choose visible channels';
} }
function rebuildChannelMenu() { function rebuildChannelMenu() {
updateChannelsLabel(); updateChannelsButton();
const channelMenu = channelMenuEl; const channelMenu = channelMenuEl;
if (!channelMenu) return; // menu not opened yet — the label is enough if (!channelMenu) return; // menu not opened yet — the label is enough
channelMenu.innerHTML = ''; channelMenu.innerHTML = '';
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
/* ── scrolling ──────────────────────────────────────────────────── */ /* ── scrolling ──────────────────────────────────────────────────── */
/** Newest entry at the top (bottom-up) instead of at the bottom? */
function newestFirst() {
return Boolean(moduleApi.instanceSettings.newestFirst);
}
streamEl.addEventListener('scroll', () => { streamEl.addEventListener('scroll', () => {
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; const near = newestFirst()
pinnedToBottom = nearBottom; ? streamEl.scrollTop < 60
if (nearBottom) jumpBtn.hidden = true; : streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToLatest = near;
if (near) jumpBtn.hidden = true;
}); });
jumpBtn.addEventListener('click', () => { jumpBtn.addEventListener('click', () => {
pinnedToBottom = true; pinnedToLatest = true;
jumpBtn.hidden = true; jumpBtn.hidden = true;
scrollToBottom(); scrollToLatest();
}); });
function scrollToBottom() { /** The latest entry is at the top when flipped, at the bottom otherwise. */
streamEl.scrollTop = streamEl.scrollHeight; function scrollToLatest() {
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
} }
function afterInsert(el) { function afterInsert(el) {
if (el.classList.contains('hidden-by-filter')) return; if (el.classList.contains('hidden-by-filter')) return;
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
else jumpBtn.hidden = false; else jumpBtn.hidden = false;
} }
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
el = document.createElement('div'); el = document.createElement('div');
el.className = 'pt-entry'; el.className = 'pt-entry';
el.dataset.channelId = entry.channelId || ''; el.dataset.channelId = entry.channelId || '';
el.dataset.channelName = entry.channelName || '';
const info = channelInfo(entry); const info = channelInfo(entry);
el.style.setProperty('--ch', info.color); el.style.setProperty('--ch', info.color);
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
afterInsert(el); afterInsert(el);
} else { } else {
updateEntryEl(el, entry); updateEntryEl(el, entry);
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom(); if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
} }
} }
@@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) {
bootTimer = setTimeout(boot, 5000); bootTimer = setTimeout(boot, 5000);
return; return;
} }
scrollToBottom(); scrollToLatest();
connect(); connect();
} }
@@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) {
root.innerHTML = ''; // header controls are removed by the shell root.innerHTML = ''; // header controls are removed by the shell
}, },
onResize() { onResize() {
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
}, },
}; };
} }
+9 -9
View File
@@ -1,7 +1,7 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.0.0", "version": "1.2.0",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProdCom transcript stream", "description": "Live ProdCom transcript stream",
"client": "client.js", "client": "client.js",
@@ -22,15 +22,15 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"channel": { "showAvatars": {
"type": "string", "type": "boolean",
"label": "Channel filter (channel name; empty = all)", "label": "Show channel icons",
"default": "" "default": true
}, },
"textSize": { "newestFirst": {
"type": "number", "type": "boolean",
"label": "Text size (px)", "label": "Newest first (transcript reads bottom-up)",
"default": 15 "default": false
}, },
"showTimes": { "showTimes": {
"type": "boolean", "type": "boolean",
+24
View File
@@ -9,6 +9,17 @@
flex-direction: column; flex-direction: column;
} }
/* ── channels button ──
Icon-only; the count appears next to it while some channels are hidden. */
[data-module="prodcom-transcript"] .pt-channels-btn { gap: 3px; }
[data-module="prodcom-transcript"] .pt-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
/* ── channels menu ── /* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu); The menu container is created and positioned by the shell (.tile-menu);
it lives inside the tile, so [data-module] scoping still applies. */ it lives inside the tile, so [data-module] scoping still applies. */
@@ -113,6 +124,7 @@
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent); background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
} }
[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; } [data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; }
[data-module="prodcom-transcript"] .hide-avatars .pt-entry .avatar { display: none; }
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; } [data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
@@ -143,6 +155,12 @@
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; } [data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
/* bottom-up: newest entry at the top, the stream reading upwards */
[data-module="prodcom-transcript"] .newest-first .pt-entries {
display: flex;
flex-direction: column-reverse;
}
/* ── jump-to-latest ── */ /* ── jump-to-latest ── */
[data-module="prodcom-transcript"] .pt-jump { [data-module="prodcom-transcript"] .pt-jump {
@@ -163,3 +181,9 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
} }
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; } [data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
/* flipped, "latest" is the top of the stream — so is the pill */
[data-module="prodcom-transcript"] .newest-first .pt-jump {
top: 12px;
bottom: auto;
}
+12 -154
View File
@@ -11,45 +11,6 @@
const LTC_KEY = '__ltc__'; const LTC_KEY = '__ltc__';
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
counts every frame in between locally. That needs exact frame↔timecode
conversion, including 29.97 drop-frame (frames 00 and 01 don't exist at
the top of a minute, except every tenth minute). */
export function parseTc(str) {
const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;.](\d{1,2})$/.exec(String(str || '').trim());
if (!m) return null;
return { h: Number(m[1]), m: Number(m[2]), s: Number(m[3]), f: Number(m[4]) };
}
export function tcToFrames(tc, nominal, df) {
let n = (tc.h * 3600 + tc.m * 60 + tc.s) * nominal + tc.f;
if (df) {
const mins = tc.h * 60 + tc.m;
n -= 2 * (mins - Math.floor(mins / 10));
}
return n;
}
export function framesToTc(n, nominal, df) {
// 10 DF minutes hold 17982 frames (the first minute 1800, the rest 1798).
const perDay = df ? 24 * 6 * 17982 : 24 * 3600 * nominal;
n = ((n % perDay) + perDay) % perDay;
if (df) {
const d = Math.floor(n / 17982);
const m10 = n % 17982;
const extra = m10 < 1800 ? 0 : Math.floor((m10 - 1800) / 1798) + 1;
const totalMin = d * 10 + extra;
// Re-add the two dropped frames per dropped minute, then read as 30fps.
n += 2 * (totalMin - Math.floor(totalMin / 10));
}
const f = n % nominal;
const secs = Math.floor(n / nominal);
const pad = (v) => String(v).padStart(2, '0');
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`;
}
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
@@ -100,9 +61,7 @@ export default function create({ root, moduleApi }) {
function menuRows() { function menuRows() {
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name })); 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 rows.push({ key: LTC_KEY, name: 'LTC timecode' });
// even be offerable.
if (state?.ltcEnabled) rows.push({ key: LTC_KEY, name: 'LTC timecode' });
return rows; return rows;
} }
@@ -200,7 +159,7 @@ export default function create({ root, moduleApi }) {
for (const t of state?.timers || []) { for (const t of state?.timers || []) {
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t }); if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
} }
if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState }); if (!hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
return items; return items;
} }
@@ -246,117 +205,32 @@ export default function create({ root, moduleApi }) {
card.el.className = `tm-card is-${kind}`; card.el.className = `tm-card is-${kind}`;
} }
/* ── real-time LTC: count every frame between server anchors ─────── */
// The server anchors us ~4×/s with the decoded timecode, its rate and
// how old it is (ageMs). Between anchors a requestAnimationFrame loop
// advances the display at the LTC's own rate, so every frame paints —
// without pushing 30 SSE messages a second at every dashboard.
let ltcAnchor = null; // { frames, at, recvAt, rate, nominal, df }
let ltcRaf = 0;
let ltcShownFrames = -1;
/** Anchors arrive ~every 250 ms while running. None for this long means
the feed state is changing — hold rather than freewheel into phantom
frames the next update would visibly rewind. */
const ANCHOR_FRESH_MS = 350;
function updateLtcAnchor() {
const l = state?.ltc;
// The listener is extrapolatable — it knows the frame rate.
const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener';
const tc = live ? parseTc(l.time) : null;
if (!tc) {
ltcAnchor = null;
ltcShownFrames = -1;
if (ltcRaf) {
cancelAnimationFrame(ltcRaf);
ltcRaf = 0;
}
return;
}
const nominal = l.df ? 30 : Math.round(l.fps);
ltcAnchor = {
frames: tcToFrames(tc, nominal, l.df),
at: performance.now() - (Number(l.ageMs) || 0),
recvAt: performance.now(),
rate: l.df ? 30000 / 1001 : nominal,
nominal,
df: l.df,
};
if (!ltcRaf) ltcRaf = requestAnimationFrame(ltcTick);
}
/** 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. */
function paintLtcFrame() {
const a = ltcAnchor;
const card = cards.get(LTC_KEY);
if (!a || !card) return;
const limit = Math.min(performance.now(), a.recvAt + ANCHOR_FRESH_MS);
let n = a.frames + Math.round(((limit - a.at) / 1000) * a.rate);
// Anchor jitter (±1 frame) must never tick the display backwards;
// a genuine jump back (re-strike, seek) is far larger and passes.
if (ltcShownFrames >= 0 && n < ltcShownFrames && ltcShownFrames - n <= 2) n = ltcShownFrames;
if (n !== ltcShownFrames) {
ltcShownFrames = n;
card.timeEl.textContent = framesToTc(n, a.nominal, a.df);
}
}
function ltcTick() {
ltcRaf = 0;
// No anchor, or the LTC card is hidden in this tile: stop — the next
// server anchor (4×/s while running) re-arms the loop via render().
if (!ltcAnchor || !cards.get(LTC_KEY)) return;
paintLtcFrame();
ltcRaf = requestAnimationFrame(ltcTick);
}
function updateLtcCard(card, ltc) { function updateLtcCard(card, ltc) {
let mode; 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) { if (ltc.supported === false) {
mode = 'unavailable'; mode = 'unavailable';
card.timeEl.textContent = '—'; card.timeEl.textContent = '—';
// The server says why when it knows (listener disabled, no device card.stateEl.textContent = 'Timecode not available on this ProPresenter';
// selected, capture failed, starting up…) — surface that. } else if (ltc.supported === null) {
card.stateEl.textContent = ltc.note || 'LTC not available'; mode = 'waiting';
card.timeEl.textContent = displayTime(ltc.time);
card.stateEl.textContent = 'Waiting for ProPresenter…';
} else if (ltc.receiving) { } else if (ltc.receiving) {
mode = '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.timeEl.textContent = displayTime(ltc.time);
card.stateEl.textContent = 'Stopped'; card.stateEl.textContent = 'Receiving';
} else { } else {
mode = 'nosignal'; mode = 'nosignal';
card.timeEl.textContent = '—'; card.timeEl.textContent = displayTime(ltc.time);
card.stateEl.textContent = 'No signal'; card.stateEl.textContent = 'No signal';
} }
// The built-in listener is independent of ProPresenter, so its card card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`;
// 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}`;
} }
/* ── status dot + degraded banners ──────────────────────────────── */ /* ── status dot + degraded banners ──────────────────────────────── */
function renderStatus() { function renderStatus() {
wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false)); 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) { if (feedOffline) {
bannerEl.hidden = false; bannerEl.hidden = false;
bannerEl.textContent = 'Not receiving updates from the ProdDash server.'; bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
@@ -370,10 +244,7 @@ export default function create({ root, moduleApi }) {
} }
if (!state.enabled) { if (!state.enabled) {
bannerEl.hidden = true; bannerEl.hidden = true;
// The LTC listener feeds us with or without ProPresenter — an moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
// 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');
return; return;
} }
if (!state.reachable) { if (!state.reachable) {
@@ -391,18 +262,9 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
renderStatus(); renderStatus();
rebuildMenu(); rebuildMenu();
updateLtcAnchor();
// Admin-chosen digit font. A font swap changes glyph metrics, so re-fit
// the digit sizing when it actually changes.
const mono = state?.timerFont === 'monospace';
if (mono !== wrap.classList.contains('tm-mono')) {
wrap.classList.toggle('tm-mono', mono);
fit();
}
let hint = ''; let hint = '';
if (state && !state.enabled && !state.ltc?.supported) { if (state && !state.enabled) {
hint = 'No ProPresenter connection — set the host and port in Admin.'; hint = 'No ProPresenter connection — set the host and port in Admin.';
} }
const items = hint ? [] : visibleItems(); const items = hint ? [] : visibleItems();
@@ -549,10 +411,6 @@ export default function create({ root, moduleApi }) {
stream = null; stream = null;
resizeObserver?.disconnect(); resizeObserver?.disconnect();
resizeObserver = null; resizeObserver = null;
if (ltcRaf) {
cancelAnimationFrame(ltcRaf);
ltcRaf = 0;
}
root.innerHTML = ''; // header controls are removed by the shell root.innerHTML = ''; // header controls are removed by the shell
}, },
onResize() { onResize() {
@@ -1,207 +0,0 @@
// ltc-capture — minimal CoreAudio input capture for the Timers module's
// LTC listener.
//
// The module decodes LTC in-process (ltc.js) and spawns this to move
// samples from an input device to a pipe. Production machines rarely have
// ffmpeg/sox, and shipping a third-party binary for that one job is
// overkill — this is the whole job, natively. The module compiles it on
// first use when swiftc is available; otherwise build it once on any
// same-architecture Mac and copy the binary next to this source:
//
// swiftc -O -o modules/propresenter-timers/ltc-capture \
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
//
// ltc-capture --list
// every input-capable device: name, input channels, sample rate
// ltc-capture --device "Dante Via 16 Channel"
// streams the device's inputs as interleaved s16le to stdout, at the
// device's own sample rate and channel count (both printed to stderr
// at start). The name matches exactly first, then as a substring.
//
// --device matches case-insensitively on a substring of the device name or
// UID; ambiguity is an error listing the candidates. Capture uses an
// AVAudioEngine input tap pinned to the chosen device, converting Float32
// to clamped Int16 little-endian. stdout is samples only; every message
// goes to stderr.
//
// macOS will ask for microphone permission on first use (the prompt
// belongs to whatever launched this — Terminal, or the ProdDash launcher).
// Over SSH there is no prompt to answer: grant it once at the console, or
// approve the terminal app under System Settings → Privacy → Microphone.
import AVFoundation
import CoreAudio
import Foundation
func fail(_ msg: String) -> Never {
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
exit(2)
}
func note(_ msg: String) {
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
}
/* ── CoreAudio device enumeration ───────────────────────────────────── */
struct InputDevice {
let id: AudioDeviceID
let name: String
let uid: String
let channels: Int
let sampleRate: Double
}
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
var addr = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var ref: Unmanaged<CFString>? = nil
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let err = withUnsafeMutablePointer(to: &ref) {
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
}
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
return value as String
}
func sampleRateProperty(_ id: AudioObjectID) -> Double {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var rate: Float64 = 0
var size = UInt32(MemoryLayout<Float64>.size)
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
return rate
}
func inputChannelCount(_ id: AudioDeviceID) -> Int {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
let listPtr = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { listPtr.deallocate() }
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
let buffers = UnsafeMutableAudioBufferListPointer(listPtr.assumingMemoryBound(to: AudioBufferList.self))
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
}
func listInputDevices() -> [InputDevice] {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr
else { return [] }
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr
else { return [] }
var out: [InputDevice] = []
for id in ids {
let channels = inputChannelCount(id)
if channels == 0 { continue }
out.append(InputDevice(id: id,
name: stringProperty(id, kAudioObjectPropertyName),
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
channels: channels,
sampleRate: sampleRateProperty(id)))
}
return out
}
/* ── args ───────────────────────────────────────────────────────────── */
var wantList = false
var wantDevice: String? = nil
var argIdx = 1
let argv = CommandLine.arguments
while argIdx < argv.count {
switch argv[argIdx] {
case "--list": wantList = true
case "--device":
argIdx += 1
guard argIdx < argv.count else { fail("--device needs a name (see --list)") }
wantDevice = argv[argIdx]
default: fail("unknown flag \(argv[argIdx]) — use --list or --device <name>")
}
argIdx += 1
}
let devices = listInputDevices()
if wantList || wantDevice == nil {
if devices.isEmpty { fail("no input-capable audio devices found") }
note("input devices:")
for d in devices {
note(String(format: " %-28s %2d ch %6.0f Hz uid=%@", (d.name as NSString).utf8String!,
d.channels, d.sampleRate, d.uid))
}
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
exit(wantList ? 0 : 2)
}
let query = wantDevice!.lowercased()
// An exact name/uid match wins outright — device lists like "Media",
// "Media 2", "Media Key" make substring-only matching ambiguous for the
// very names a picker hands us verbatim.
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
let matches = exact.count == 1 ? exact
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
guard matches.count == 1 else {
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
}
let device = matches[0]
/* ── capture: pin the engine's input to the device, tap, s16le out ──── */
let engine = AVAudioEngine()
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
var deviceID = device.id
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global, 0, &deviceID,
UInt32(MemoryLayout<AudioDeviceID>.size))
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
let format = engine.inputNode.inputFormat(forBus: 0)
let channels = Int(format.channelCount)
let rate = Int(format.sampleRate)
guard channels > 0, rate > 0 else {
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
+ "(System Settings → Privacy & Security → Microphone)")
}
note("capturing \"\(device.name)\": \(channels) channels @ \(rate) Hz → s16le stdout")
note("reader flags: --sr \(rate) --channels \(channels) --ch <the LTC channel, 1-based>")
let stdoutHandle = FileHandle.standardOutput
engine.inputNode.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in
let frames = Int(buffer.frameLength)
guard frames > 0, let floats = buffer.floatChannelData else { return }
var bytes = Data(count: frames * channels * 2)
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
let out = raw.bindMemory(to: Int16.self)
for f in 0..<frames {
for c in 0..<channels {
let v = floats[c][f]
let clamped = max(-1.0, min(1.0, v))
out[f * channels + c] = Int16(clamped * 32767.0) // arm64 is little-endian
}
}
}
do { try stdoutHandle.write(contentsOf: bytes) } catch { exit(0) } // downstream pipe closed
}
do { try engine.start() } catch {
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
+ "approve microphone access for the launching app and retry")
}
signal(SIGINT) { _ in exit(0) }
signal(SIGTERM) { _ in exit(0) }
RunLoop.main.run()
-191
View File
@@ -1,191 +0,0 @@
'use strict';
/**
* Built-in LTC listener — capture child + in-process decode.
*
* Spawns the module's native capture tool (ltc-capture, compiled from
* ltc-capture.swift beside it) for the admin-configured audio device, reads
* the interleaved s16le stream, extracts the configured channel, and decodes
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
* onUpdate roughly 4×/s:
*
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
* { error: '<what is wrong and how to fix it>' } not capturing
*
* The capture binary is per-machine (gitignored): if it is missing this
* tries one swiftc compile of the source next to it, and otherwise says to
* build it on any same-architecture Mac and copy it over. A dead child is
* restarted with backoff — fast for a crash, slow for config-shaped
* failures (unknown device, ambiguous name) that never fix themselves.
*
* macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
* from every input (no error!), which shows up here as endless 'nosignal'.
* Launch ProdDash from a logged-in GUI context (Start ProdDash.command)
* and approve the mic prompt once.
*/
const path = require('path');
const fs = require('fs');
const { spawn, spawnSync } = require('child_process');
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device name, ambiguity) — a fast
retry loop would just spam the log. */
const BAD_CONFIG_RETRY_MS = 30000;
const EVAL_MS = 250;
/** Frames must decode AND advance this recently to count as running. */
const RUNNING_WINDOW_MS = 400;
/** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
/**
* @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void}}
*/
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const bin = path.join(moduleDir, 'ltc-capture');
let child = null;
let stopped = false;
let restartTimer = null;
let evalTimer = null;
let startedAt = 0;
let lastTc = '';
let fps = 0;
let df = false;
let lastFrameWall = 0;
let lastChangeWall = 0;
let lastSent = '';
function report(update) {
const key = JSON.stringify(update);
if (key === lastSent) return;
lastSent = key;
onUpdate(update);
}
function snapshotState() {
const now = Date.now();
if (now - lastFrameWall < RUNNING_WINDOW_MS && now - lastChangeWall < RUNNING_WINDOW_MS) return 'running';
if (lastTc) return 'stopped';
return 'nosignal';
}
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
log('ltc-capture binary missing — trying a one-time swiftc build');
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
if (built.status === 0 && fs.existsSync(bin)) {
log('ltc-capture compiled');
return '';
}
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
+ 'and copy it here';
}
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
report({ error: missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
startedAt = Date.now();
let extractor = null;
let decoder = null;
let stderrTail = '';
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
stderrTail = (stderrTail + text).slice(-500);
const m = text.match(CAPTURING_RE);
if (!m) return;
const channels = Number(m[2]);
const rate = Number(m[3]);
if (channel > channels) {
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
try { child.kill(); } catch { /* exiting anyway */ }
return;
}
log(`LTC listener: capturing "${m[1]}" (${channels} ch @ ${rate} Hz), decoding channel ${channel}`);
extractor = new ChannelExtractor(channels, channel);
decoder = new LtcDecoder(rate, (frame) => {
const tc = fmtTc(frame);
const now = Date.now();
if (tc !== lastTc) lastChangeWall = now;
lastTc = tc;
fps = frame.fps;
df = frame.df;
lastFrameWall = now;
});
});
child.stdout.on('data', (buf) => {
if (extractor && decoder) decoder.push(extractor.push(buf));
});
child.on('error', (err) => {
report({ error: `LTC capture failed to start: ${err.message}` });
});
child.on('exit', (code) => {
child = null;
if (stopped) return;
// The capture's own failure text (unknown device, ambiguous name,
// permission hint) is the most useful thing we can show.
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
const quickDeath = Date.now() - startedAt < 3000;
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
});
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => {
restartTimer = null;
start();
}, ms);
restartTimer.unref?.();
}
evalTimer = setInterval(() => {
if (stopped || !child) return;
const state = snapshotState();
// ageMs = how old this report's timecode is (time since that frame
// decoded) — the client pins the anchor to its own clock with it and
// extrapolates frame-accurately between reports. Only while running:
// a held frame has no meaningful age, and a varying field would defeat
// the report dedupe while stopped.
const update = { state, time: lastTc, fps, df };
if (state === 'running') update.ageMs = Date.now() - lastFrameWall;
report(update);
}, EVAL_MS);
evalTimer.unref?.();
start();
return {
stop() {
stopped = true;
clearTimeout(restartTimer);
clearInterval(evalTimer);
if (child) {
try { child.kill(); } catch { /* already gone */ }
child = null;
}
},
};
}
module.exports = { createLtcListener };
-312
View File
@@ -1,312 +0,0 @@
'use strict';
/**
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM,
* interleaved-channel extraction, and an encoder for tests.
*
* Used in-process by the module's built-in LTC listener (ltc-listener.js).
* All design notes and the full protocol commentary live with the classes
* below; the encoder exists so the decode path can be proven end-to-end
* without timecode hardware (see ltc.selftest.js).
*/
/* ══ decoder ═══════════════════════════════════════════════════════════ */
/** Sync word as bits 64–79 (0011111111111101) shift into a 16-bit register. */
const SYNC_WORD = 0x3ffd;
class LtcDecoder {
/**
* @param {number} sampleRate stdin PCM rate in Hz
* @param {(f:{h:number,m:number,s:number,f:number,df:boolean,fps:number})=>void} onFrame
*/
constructor(sampleRate, onFrame) {
this.sr = sampleRate;
this.onFrame = onFrame;
this.mean = 0; // DC offset tracker
this.env = 0; // decaying |sample| envelope, scales the comparator
this.level = 0; // comparator state: 1 / -1 (0 = not seen a crossing yet)
this.n = 0; // absolute sample clock
this.lastEdge = 0;
this.lastEdgeSeen = 0; // for signal-presence checks
// Samples per LTC bit. Start at the 30 fps value (the shortest possible
// bit) and let the interval EMA walk it up to the true rate.
this.T = sampleRate / (80 * 30);
this.halfAt = 0; // first half-interval of a suspected 1 bit, else 0
this.bits = new Uint8Array(80); // last 80 decoded bits, newest at [79]
this.sync = 0; // last 16 bits as an integer, newest at bit 0
this.fresh = 0; // bits decoded since the last reset — see bit()
this.prev = null; // previous valid frame, for rollover observation
this.nominal = 0; // nominal fps locked by two agreeing rollovers
this.rollCandidate = 0;
this.df = false; // drop-frame with two-frame hysteresis
this.dfPending = null;
this.dfRun = 0;
}
/** @param {Int16Array} samples */
push(samples) {
for (let i = 0; i < samples.length; i += 1) {
this.n += 1;
const raw = samples[i];
// Seed the DC tracker from the first sample — a real offset would
// otherwise take ~0.5 s to settle and cost the opening frames.
if (this.n === 1) this.mean = raw;
this.mean += (raw - this.mean) / 8192;
const v = raw - this.mean;
const mag = v < 0 ? -v : v;
this.env = mag > this.env ? mag : this.env * 0.9996;
// Floor keeps line noise from registering as signal during silence.
const th = Math.max(this.env * 0.3, 500);
let crossed = false;
if (this.level >= 0 && v < -th) {
this.level = -1;
crossed = true;
} else if (this.level <= 0 && v > th) {
this.level = 1;
crossed = true;
}
if (!crossed) continue;
const t = this.n - this.lastEdge;
this.lastEdge = this.n;
this.lastEdgeSeen = this.n;
this.interval(t);
}
}
/** One transition-to-transition gap, in samples. */
interval(t) {
if (t > 1.9 * this.T) {
// Silence gap or garbage — drop bit state, keep the period estimate
// (the stream that resumes is usually the same deck). Frame
// continuity broke, so rollover observation restarts too; the locked
// nominal survives — two agreeing rollovers re-lock a changed rate.
this.halfAt = 0;
this.sync = 0;
this.fresh = 0;
this.prev = null;
return;
}
if (t < 0.75 * this.T) {
// Half period: one of a 1-bit's pair.
if (this.halfAt) {
this.T += (this.halfAt + t - this.T) * 0.05;
this.halfAt = 0;
this.bit(1);
} else {
this.halfAt = t;
}
} else {
// Full period: a 0 bit. A lone preceding half means we slipped —
// shed it and let the sync-word search re-align.
this.halfAt = 0;
this.T += (t - this.T) * 0.05;
this.bit(0);
}
}
bit(b) {
this.bits.copyWithin(0, 1);
this.bits[79] = b;
this.sync = ((this.sync << 1) | b) & 0xffff;
if (this.fresh < 80) this.fresh += 1;
// Sync word just completed → bits[0..63] are one whole frame's data —
// but only once 80 real bits have displaced the cold-start/pre-gap
// buffer, or a phantom frame decodes from the padding.
if (this.sync === SYNC_WORD && this.fresh >= 80) this.frame();
}
frame() {
const bits = this.bits;
// Every LTC field is BCD, transmitted LSB-first.
const bcd = (start, len) => {
let v = 0;
for (let i = 0; i < len; i += 1) v |= bits[start + i] << i;
return v;
};
// Strict validation: LTC has no parity worth the name, so a bit error
// can produce a frame that "parses" — every field must be a legal BCD
// digit AND inside its hard ceiling, or the frame is dropped whole.
// (A lax check here once let a corrupt frames field of 35 through, and
// downstream fps logic reported 36 fps to the dashboard.)
const fu = bcd(0, 4);
const su = bcd(16, 4);
const mu = bcd(32, 4);
const hu = bcd(48, 4);
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return;
const f = fu + bcd(8, 2) * 10;
const s = su + bcd(24, 3) * 10;
const m = mu + bcd(40, 3) * 10;
const h = hu + bcd(56, 2) * 10;
if (f > 29 || s > 59 || m > 59 || h > 23) return;
// Drop-frame flag with two-frame hysteresis — one corrupt-but-legal
// frame must not flash 29.97 DF across the dashboard.
const dfBit = bits[10] === 1;
this.dfRun = dfBit === this.dfPending ? this.dfRun + 1 : 1;
this.dfPending = dfBit;
if (this.dfRun >= 2) this.df = dfBit;
// Nominal rate from observed rollovers: the frame number that precedes
// an f=0-with-seconds-advance IS the rate minus one. Two consecutive
// agreeing rollovers lock it — a single corrupt frame can't fake two
// valid, correctly-successive frames twice, and a genuine rate change
// re-locks itself within two seconds.
if (this.prev && f === 0 && s === (this.prev.s + 1) % 60) {
const cand = this.prev.f + 1;
if (cand === 24 || cand === 25 || cand === 30) {
if (cand === this.rollCandidate) this.nominal = cand;
this.rollCandidate = cand;
}
}
this.prev = { h, m, s, f };
this.onFrame({ h, m, s, f, df: this.df, fps: this.fps(this.df) });
}
/**
* Nominal rate: locked by rollover consensus once two second-boundaries
* agree; until then snap the measured bit clock to a standard rate.
* 29.97 vs 30 non-drop differ by 0.1% — beyond the bit-clock estimate's
* accuracy — so non-drop 29.97 reads as 30. Drop-frame is exact: the DF
* bit means 29.97.
*/
fps(df) {
if (df) return 29.97;
if (this.nominal) return this.nominal;
const measured = this.sr / (this.T * 80);
return [24, 25, 30].reduce((a, b) => (Math.abs(b - measured) < Math.abs(a - measured) ? b : a));
}
/** True while transitions are actually arriving (sample-clock based). */
signalPresent() {
return this.n - this.lastEdgeSeen < this.sr * 0.05;
}
}
/* ══ encoder (selftest + demo — and pipeable test audio) ═══════════════ */
/** Advance one frame under the given counting rules (fps = nominal integer). */
function nextTc(tc, fps, df) {
let { h, m, s, f } = tc;
f += 1;
if (f >= fps) {
f = 0;
s += 1;
if (s >= 60) {
s = 0;
m += 1;
if (m >= 60) {
m = 0;
h = (h + 1) % 24;
}
}
}
// Drop-frame: frames 00 and 01 don't exist at the top of a minute,
// except every tenth minute.
if (df && f === 0 && s === 0 && m % 10 !== 0) f = 2;
return { h, m, s, f };
}
/** The 80 bits of one frame, in transmission order. User bits stay zero;
the polarity-correction/parity bit is legal to leave clear. */
function frameBits(tc, df) {
const bits = new Uint8Array(80);
const put = (start, len, v) => {
for (let i = 0; i < len; i += 1) bits[start + i] = (v >> i) & 1;
};
put(0, 4, tc.f % 10);
put(8, 2, Math.floor(tc.f / 10));
bits[10] = df ? 1 : 0;
put(16, 4, tc.s % 10);
put(24, 3, Math.floor(tc.s / 10));
put(32, 4, tc.m % 10);
put(40, 3, Math.floor(tc.m / 10));
put(48, 4, tc.h % 10);
put(56, 2, Math.floor(tc.h / 10));
const sync = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1];
for (let i = 0; i < 16; i += 1) bits[64 + i] = sync[i];
return bits;
}
class LtcEncoder {
/** @param {number} fps true rate (29.97 allowed); counting uses its rounding */
constructor(sampleRate, fps, df, startTc, amplitude = 12000) {
this.nominal = Math.round(fps);
this.df = df;
this.amp = amplitude;
this.spb = sampleRate / (fps * 80); // samples per bit, fractional
this.tc = { ...startTc };
this.bits = frameBits(this.tc, df);
this.bitIdx = 0;
this.halfPending = false;
this.level = 1;
this.t = 0; // sample clock
this.nextEdge = 0; // fractional sample time of the next transition
}
/** Time between this transition and the next (biphase mark). */
nextInterval() {
if (this.halfPending) {
this.halfPending = false;
return this.spb / 2;
}
if (this.bitIdx >= 80) {
this.tc = nextTc(this.tc, this.nominal, this.df);
this.bits = frameBits(this.tc, this.df);
this.bitIdx = 0;
}
const bit = this.bits[this.bitIdx];
this.bitIdx += 1;
if (bit) {
this.halfPending = true;
return this.spb / 2;
}
return this.spb;
}
/** @returns {Int16Array} the next n samples of the running LTC stream */
render(n) {
const out = new Int16Array(n);
for (let i = 0; i < n; i += 1) {
while (this.t >= this.nextEdge) {
this.level = -this.level;
this.nextEdge += this.nextInterval();
}
out[i] = this.level * this.amp;
this.t += 1;
}
return out;
}
}
/* ══ shared bits ═══════════════════════════════════════════════════════ */
/**
* Turns a raw s16le byte stream of interleaved channels into one channel's
* samples. Chunks arrive split anywhere — mid-sample, mid-frame — so the
* tail that doesn't fill a whole frame (channels × 2 bytes) carries over;
* losing frame alignment would silently decode the wrong channel.
*/
class ChannelExtractor {
/** @param {number} channels interleaved channel count @param {number} ch 1-based pick */
constructor(channels, ch) {
this.frameBytes = channels * 2;
this.offset = (ch - 1) * 2;
this.carry = null;
}
/** @param {Buffer} buf @returns {Int16Array} the picked channel's samples */
push(buf) {
if (this.carry) buf = Buffer.concat([this.carry, buf]);
const usable = buf.length - (buf.length % this.frameBytes);
this.carry = buf.length > usable ? buf.subarray(usable) : null;
const out = new Int16Array(usable / this.frameBytes);
for (let i = 0, off = this.offset; i < out.length; i += 1, off += this.frameBytes) {
out[i] = buf.readInt16LE(off);
}
return out;
}
}
const pad2 = (v) => String(v).padStart(2, '0');
const fmtTc = (tc) => `${pad2(tc.h)}:${pad2(tc.m)}:${pad2(tc.s)}:${pad2(tc.f)}`;
module.exports = { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, nextTc, fmtTc, SYNC_WORD };
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* LTC engine self-test — encoder→decoder round trips for the module's LTC
* decoder (ltc.js), which the built-in listener (ltc-listener.js) uses to
* decode timecode from the captured audio. No audio hardware needed.
*
* node modules/propresenter-timers/ltc.selftest.js
*
* Exits non-zero if any check fails, so it doubles as a CI smoke test.
*/
const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } = require("./ltc");
/* ══ selftest ══════════════════════════════════════════════════════════ */
/** Deterministic PRNG so a failure reproduces. */
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = Math.imul(a ^ (a >>> 15), 1 | a);
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
function selftest() {
const SR = 48000;
let failures = 0;
const check = (name, ok, detail = '') => {
console.log(` ${ok ? '✓' : '✗'} ${name}${ok || !detail ? '' : ` — ${detail}`}`);
if (!ok) failures += 1;
};
/** Encode frames, mangle optionally, decode in awkward chunk sizes. */
const roundTrip = (fps, df, start, frames, mangle) => {
const enc = new LtcEncoder(SR, fps, df, start);
let pcm = enc.render(Math.ceil((frames * 80 * SR) / (fps * 80)) + SR / 10);
if (mangle) pcm = mangle(pcm);
const out = [];
const dec = new LtcDecoder(SR, (f) => out.push(f));
for (let off = 0; off < pcm.length; off += 997) {
dec.push(pcm.subarray(off, Math.min(off + 997, pcm.length)));
}
return out;
};
const sequential = (out) =>
out.every((f, i) => i === 0 || fmtTc(f) !== fmtTc(out[i - 1]));
console.log('30 fps:');
{
const out = roundTrip(30, false, { h: 1, m: 0, s: 0, f: 0 }, 60);
check('decodes most frames', out.length >= 55, `${out.length}/60`);
check('starts at 01:00:00:0x', out.length > 0 && out[0].h === 1 && out[0].m === 0 && out[0].s === 0);
check('frames advance', sequential(out));
check('fps snaps to 30', out.length > 30 && out[out.length - 1].fps === 30);
check('df clear', out.every((f) => !f.df));
}
console.log('25 fps:');
{
const out = roundTrip(25, false, { h: 10, m: 59, s: 59, f: 20 }, 50);
check('decodes most frames', out.length >= 45, `${out.length}/50`);
check('rolls over the hour', out.some((f) => f.h === 11 && f.m === 0 && f.s === 0));
check('fps snaps to 25', out.length > 26 && out[out.length - 1].fps === 25);
}
console.log('24 fps:');
{
const out = roundTrip(24, false, { h: 0, m: 5, s: 0, f: 0 }, 50);
check('decodes most frames', out.length >= 45, `${out.length}/50`);
check('fps snaps to 24', out.length > 25 && out[out.length - 1].fps === 24);
}
console.log('29.97 drop-frame:');
{
const out = roundTrip(29.97, true, { h: 0, m: 0, s: 59, f: 20 }, 90);
check('decodes most frames', out.length >= 80, `${out.length}/90`);
// hysteresis: the very first frame legitimately reports df=false
check('df flag set', out.length > 1 && out.slice(1).every((f) => f.df));
check('skips :00:00 and :00:01', !out.some((f) => f.m === 1 && f.s === 0 && f.f < 2));
check('lands on :00:02', out.some((f) => f.m === 1 && f.s === 0 && f.f === 2));
check('fps reads 29.97', out.length > 0 && out[out.length - 1].fps === 29.97);
}
console.log('hostile audio (low level, noise, DC offset):');
{
const rand = mulberry32(1234);
const out = roundTrip(30, false, { h: 2, m: 0, s: 0, f: 0 }, 60, (pcm) => {
const mangled = new Int16Array(pcm.length);
for (let i = 0; i < pcm.length; i += 1) {
// ~10% of the encoder's level (≈ -29 dBFS), ±300 of noise, +3000 of
// DC. Quieter than that sits under the decoder's noise floor by
// design — silence must not decode as bits.
mangled[i] = Math.round(pcm[i] * 0.1 + (rand() - 0.5) * 600 + 3000);
}
return mangled;
});
check('still decodes', out.length >= 50, `${out.length}/60`);
check('frames advance', sequential(out));
}
console.log('signal dropout and recovery:');
{
const enc = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 0, f: 0 });
const a = enc.render(SR); // 1 s ≈ 30 frames
const gap = new Int16Array(SR / 2); // 0.5 s of silence
const enc2 = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 10, f: 0 });
const b = enc2.render(SR);
const out = [];
const dec = new LtcDecoder(SR, (f) => out.push(f));
for (const part of [a, gap, b]) {
for (let off = 0; off < part.length; off += 1024) {
dec.push(part.subarray(off, Math.min(off + 1024, part.length)));
}
}
const before = out.filter((f) => f.s < 5).length;
const after = out.filter((f) => f.s >= 10).length;
check('decodes before the gap', before >= 25, `${before}`);
check('recovers after the gap', after >= 25, `${after}`);
check('no signal during the gap detected via signalPresent', !dec.signalPresent() || true); // informational
}
console.log('corrupt frames must not poison the frame rate:');
{
// Reproduces a field bug: a Dante glitch produced a frame whose frames
// field read 35 yet passed the old lax validation, and max-frame-based
// fps logic reported "36 fps" to the dashboard forever after. Encode a
// clean run, splice in a frame forged to carry f=35 (frames-tens bits
// forced to 3), add PCM-level glitches, and demand the rate holds 30.
const enc = new LtcEncoder(SR, 30, false, { h: 6, m: 0, s: 0, f: 0 });
const clean1 = enc.render(SR); // ~30 valid frames
enc.bits = frameBits({ h: 6, m: 0, s: 1, f: 5 }, false);
enc.bits[8] = 1; // frames-tens bit 0
enc.bits[9] = 1; // frames-tens bit 1 → tens=3 → frames field reads 35
enc.bitIdx = 0;
const forged = enc.render(Math.ceil(SR / 30)); // one poisoned frame
const clean2 = enc.render(SR * 2);
const rand = mulberry32(77);
const glitched = new Int16Array(clean2.length);
glitched.set(clean2);
for (let at = SR / 4; at < glitched.length - 100; at += Math.floor(SR / 3)) {
for (let i = 0; i < 90; i += 1) glitched[at + i] = Math.round((rand() - 0.5) * 24000);
}
const out = [];
const dec = new LtcDecoder(SR, (fr) => out.push(fr));
for (const part of [clean1, forged, glitched]) {
for (let off = 0; off < part.length; off += 997) {
dec.push(part.subarray(off, Math.min(off + 997, part.length)));
}
}
check('still decodes through the damage', out.length >= 70, `${out.length}`);
check('no frame number above 29 survives', out.every((fr) => fr.f <= 29));
check('fps never exceeds 30', out.every((fr) => fr.fps <= 30), `saw ${Math.max(...out.map((fr) => fr.fps))}`);
check('fps ends locked at 30', out.length > 0 && out[out.length - 1].fps === 30);
check('df never flashes on', out.every((fr) => !fr.df));
}
console.log('multichannel extraction (LTC on channel 5 of 8):');
{
// Interleave LTC into one channel of eight, with hot program audio on
// every other channel, and feed the byte stream in 997-byte chunks —
// odd on purpose, so chunk splits land mid-sample and mid-frame and
// the extractor's carry has to keep frame alignment.
const CH = 8;
const PICK = 5;
const enc = new LtcEncoder(SR, 30, false, { h: 4, m: 0, s: 0, f: 0 });
const ltc = enc.render(SR * 2); // 2 s ≈ 60 frames
const bytes = Buffer.alloc(ltc.length * CH * 2);
for (let i = 0; i < ltc.length; i += 1) {
for (let c = 0; c < CH; c += 1) {
const v = c === PICK - 1 ? ltc[i] : Math.round(20000 * Math.sin(i * (0.02 + c * 0.05)));
bytes.writeInt16LE(v, (i * CH + c) * 2);
}
}
const extractor = new ChannelExtractor(CH, PICK);
const out = [];
const dec = new LtcDecoder(SR, (f) => out.push(f));
for (let off = 0; off < bytes.length; off += 997) {
dec.push(extractor.push(bytes.subarray(off, Math.min(off + 997, bytes.length))));
}
check('decodes most frames', out.length >= 55, `${out.length}/60`);
check('starts at 04:00:00:0x', out.length > 0 && out[0].h === 4 && out[0].m === 0 && out[0].s === 0);
check('frames advance', sequential(out));
}
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed');
process.exit(failures ? 1 : 0);
}
selftest();
+2 -35
View File
@@ -1,8 +1,8 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.2.0", "version": "1.0.0",
"proddash": ">=1.2.0", "proddash": ">=1.1.0",
"description": "Live ProPresenter timers and LTC timecode", "description": "Live ProPresenter timers and LTC timecode",
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
@@ -13,45 +13,12 @@
"propresenter": { "propresenter": {
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
"group": "ProPresenter",
"default": { "host": "", "port": 1025 } "default": { "host": "", "port": 1025 }
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "API password (only if set in ProPresenter)",
"group": "ProPresenter",
"default": "" "default": ""
},
"ltcEnabled": {
"type": "switch",
"label": "LTC listener — decode timecode from an audio input on this machine",
"group": "LTC Timecode",
"default": false
},
"ltcDevice": {
"type": "select",
"label": "Audio device",
"group": "LTC Timecode",
"showWhen": "ltcEnabled",
"default": "",
"optionsRoute": "/ltc/devices"
},
"ltcChannel": {
"type": "number",
"label": "Channel on that device (1-based)",
"group": "LTC Timecode",
"showWhen": "ltcEnabled",
"default": 1
},
"timerFont": {
"type": "select",
"label": "Timer font",
"group": "Display",
"default": "default",
"options": [
{ "value": "default", "label": "Default (system)" },
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
]
} }
} }
} }
+83 -135
View File
@@ -3,15 +3,13 @@
/** /**
* ProPresenter Timers — server part. * ProPresenter Timers — server part.
* *
* Polls the ProPresenter openAPI for the configured timer list and the live * Polls the ProPresenter openAPI for the configured timer list, the live
* timer values, decodes LTC timecode from an audio input, and fans one * timer values, and (when the connected build exposes it) LTC timecode, and
* merged state object out to every tile over SSE. Browsers never talk to * fans one merged state object out to every tile over SSE. Browsers never
* ProPresenter or the audio hardware directly. * talk to ProPresenter directly.
* *
* GET /state → { state: <state> } (instant paint for new tiles) * GET /state → { state: <state> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated) * GET /stream → SSE, `state` events (change-detected, heartbeated)
* GET /ltc/devices → this machine's audio input devices, for the admin
* page's device picker (select + optionsRoute).
* *
* Endpoints used, verified against openapi.propresenter.com (API v1): * Endpoints used, verified against openapi.propresenter.com (API v1):
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun, * GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
@@ -23,14 +21,12 @@
* `state` enum is stopped|running|complete|overrunning|overran — but the * `state` enum is stopped|running|complete|overrunning|overran — but the
* spec's own response example says "overrun", so treat anything containing * spec's own response example says "overrun", so treat anything containing
* "overr" as overrun. The API exposes NO per-timer color anywhere. * "overr" as overrun. The API exposes NO per-timer color anywhere.
* * GET /v1/timecode/status → NOT in the published spec. The spec (fetched
* LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has * 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
* no timecode route, and the classic stage-display websocket drops the * allowlist either) documents no timecode/LTC route at all. We probe this
* field — both verified dead on ProPresenter 21.4), so LTC is read from the * candidate route once per connection anyway so a future ProPresenter
* audio signal itself. Behind the admin "LTC listener" switch: off means no * that adds it lights up without a module update; a 404 marks LTC
* LTC tile in the picker and nothing captured; on, the built-in listener * "not available on this ProPresenter" (ltc.supported = false).
* (ltc-listener.js) captures the admin-selected device/channel and decodes
* in-process. It is the module's one and only LTC source.
* *
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path * ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
* notes in ../propresenter-now-next/propresenter-core). So: requests run * notes in ../propresenter-now-next/propresenter-core). So: requests run
@@ -51,25 +47,18 @@ const REQUEST_TIMEOUT_MS = 4000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000; const HEARTBEAT_MS = 15000;
const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener');
/** Set by init(), read by the routes — both are rebuilt together on remount. */ /** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null; let current = null;
function clearedState(enabled, ltcEnabled = false, timerFont = 'default') { function clearedState(enabled) {
return { return {
enabled, enabled,
ltcEnabled,
timerFont,
reachable: false, reachable: false,
lastError: '', lastError: '',
timers: [], timers: [],
// supported true once the listener has a verdict; false when the // supported: null = not probed yet (or ProPresenter unreachable),
// listener is off, has no device, or its capture failed (note says // true/false once a reachable ProPresenter answered the probe.
// why). status is running|stopped|nosignal; while running, ageMs lets ltc: { supported: null, time: '', receiving: false },
// clients count frames locally between updates.
ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
}; };
} }
@@ -84,12 +73,8 @@ module.exports = {
const port = Number(config.port ?? ep.port) || 1025; const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || ''); const password = String(config.password || '');
const enabled = Boolean(host && port); const enabled = Boolean(host && port);
const ltcEnabled = Boolean(config.ltcEnabled);
const ltcDevice = String(config.ltcDevice || '');
const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default';
let latest = clearedState(enabled, ltcEnabled, timerFont); let latest = clearedState(enabled);
let lastFrame = ''; let lastFrame = '';
let stopped = false; let stopped = false;
let pollTimer = null; let pollTimer = null;
@@ -97,35 +82,9 @@ module.exports = {
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */ /** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
let timerConfigs = new Map(); let timerConfigs = new Map();
/** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */ /** null = probe /v1/timecode/status on the next reachable tick. */
let listenerLtc = null; let ltcSupported = null;
let ltcLast = { time: '', receiving: false };
/** The LTC verdict — the built-in listener is the only source. */
function composeLtc() {
if (!ltcEnabled) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
}
if (!listenerLtc) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' };
}
if (listenerLtc.error) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error };
}
const running = listenerLtc.state === 'running';
return {
supported: true,
source: 'listener',
time: listenerLtc.time,
receiving: running,
status: listenerLtc.state,
fps: listenerLtc.fps,
df: listenerLtc.df,
// While running: how old this timecode is, so clients can pin the
// anchor to their own clock and count frames between updates.
...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}),
note: '',
};
}
function url(target) { function url(target) {
const built = new URL(`http://${host}:${port}${target}`); const built = new URL(`http://${host}:${port}${target}`);
@@ -205,15 +164,64 @@ module.exports = {
return out; return out;
} }
/**
* Liberal parse of a hypothetical timecode-status body (no spec documents
* its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped
* string and a receiving/running boolean under likely key names.
*/
function parseLtc(body) {
const out = { time: '', receiving: false };
if (!body || typeof body !== 'object') return out;
const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
for (const key of ['time', 'timecode', 'current_time', 'value']) {
if (typeof body[key] === 'string' && TC.test(body[key])) {
out.time = body[key];
break;
}
}
if (!out.time) {
for (const value of Object.values(body)) {
if (typeof value === 'string' && TC.test(value)) {
out.time = value;
break;
}
}
}
for (const key of ['receiving', 'running', 'is_running', 'active']) {
if (typeof body[key] === 'boolean') {
out.receiving = body[key];
break;
}
}
return out;
}
async function pollLtc() {
if (ltcSupported === false) return;
try {
const body = await requestJson('/v1/timecode/status');
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
ltcSupported = true;
ltcLast = parseLtc(body);
} catch (err) {
// 404 = this ProPresenter has no timecode API (the expected case, as
// of API v1). Anything else is a real failure — let the tick handle it.
if (err && err.status === 404) {
ltcSupported = false;
ltcLast = { time: '', receiving: false };
return;
}
throw err;
}
}
function broadcast() { function broadcast() {
latest = { latest = {
enabled, enabled,
ltcEnabled,
timerFont,
reachable: latest.reachable, reachable: latest.reachable,
lastError: latest.lastError, lastError: latest.lastError,
timers: latest.timers, timers: latest.timers,
ltc: composeLtc(), ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving },
}; };
if (!streams.size) return; if (!streams.size) return;
let frame; let frame;
@@ -245,12 +253,18 @@ module.exports = {
} }
// THE HOT PATH — live values every tick. // THE HOT PATH — live values every tick.
const currentTimes = await requestJson('/v1/timers/current'); const currentTimes = await requestJson('/v1/timers/current');
// Probe timecode once per connection; poll it only when it exists.
await pollLtc();
latest.timers = mergeTimers(currentTimes); latest.timers = mergeTimers(currentTimes);
latest.reachable = true; latest.reachable = true;
latest.lastError = ''; latest.lastError = '';
} catch (err) { } catch (err) {
latest.reachable = false; latest.reachable = false;
latest.lastError = err instanceof Error ? err.message : String(err); latest.lastError = err instanceof Error ? err.message : String(err);
// Re-probe timecode after a reconnect — the unreachable spell may
// have been a ProPresenter upgrade or a different machine.
ltcSupported = null;
ltcLast = { time: '', receiving: false };
} }
broadcast(); broadcast();
if (stopped) return; if (stopped) return;
@@ -264,27 +278,6 @@ module.exports = {
log('no ProPresenter host configured — set one in /admin'); log('no ProPresenter host configured — set one in /admin');
} }
// The built-in listener is independent of ProPresenter — LTC-only
// setups (no host configured) still get the LTC card.
let ltcListener = null;
if (ltcEnabled) {
if (ltcDevice) {
ltcListener = createLtcListener({
device: ltcDevice,
channel: ltcChannel,
moduleDir: __dirname,
log,
onUpdate(update) {
listenerLtc = update;
broadcast();
},
});
} else {
listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' };
broadcast();
}
}
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
for (const res of streams) { for (const res of streams) {
try { try {
@@ -303,7 +296,6 @@ module.exports = {
stopped = true; stopped = true;
clearTimeout(pollTimer); clearTimeout(pollTimer);
clearInterval(heartbeat); clearInterval(heartbeat);
ltcListener?.stop();
for (const res of streams) { for (const res of streams) {
try { res.end(); } catch { /* already gone */ } try { res.end(); } catch { /* already gone */ }
} }
@@ -311,19 +303,9 @@ module.exports = {
if (current && current.streams === streams) current = null; if (current && current.streams === streams) current = null;
}, },
health() { health() {
// LTC status suffix for the admin health line. if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
const ltcNote = !ltcEnabled
? ''
: latest.ltc.supported
? `, LTC ${latest.ltc.status}`
: (latest.ltc.note ? ` (LTC: ${latest.ltc.note})` : '');
if (!enabled) {
// The LTC listener works regardless of ProPresenter — an
// LTC-only setup is half-configured but working.
if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only, ${latest.ltc.status}` };
return { status: 'error', message: 'No ProPresenter host configured' };
}
if (latest.reachable) { if (latest.reachable) {
const ltcNote = latest.ltc.supported ? ' (with timecode)' : '';
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` }; return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
} }
return { return {
@@ -345,7 +327,7 @@ module.exports = {
*/ */
tiles() { tiles() {
const state = current ? current.getLatest() : null; const state = current ? current.getLatest() : null;
if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry if (!state || !state.enabled) return []; // unconfigured → classic single entry
const TYPE_LABEL = { const TYPE_LABEL = {
countdown: 'Countdown timer', countdown: 'Countdown timer',
countdown_to_time: 'Countdown to a time of day', countdown_to_time: 'Countdown to a time of day',
@@ -354,56 +336,22 @@ module.exports = {
}; };
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }; const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
return [ return [
{ { id: 'all', name: 'All timers', description: 'Every timer and the LTC timecode, selectable per tile' },
id: 'all',
name: 'All timers',
description: state.ltcEnabled
? 'Every timer and the LTC timecode, selectable per tile'
: 'Every timer, selectable per tile',
},
...state.timers.map((t) => ({ ...state.timers.map((t) => ({
id: `timer:${t.uuid}`, id: `timer:${t.uuid}`,
name: t.name, name: t.name,
description: TYPE_LABEL[t.type] || 'Timer', description: TYPE_LABEL[t.type] || 'Timer',
...oneCard, ...oneCard,
})), })),
// The LTC entry exists only while the admin switch is on. { id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard },
...(state.ltcEnabled
? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }]
: []),
]; ];
}, },
routes() { routes() {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) }); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
}, res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) }));
// The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract.
'GET /ltc/devices': (req, res) => {
const bin = require('path').join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; });
child.on('error', () => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
});
child.on('exit', () => {
if (res.writableEnded) return;
const options = [];
for (const line of err.split('\n')) {
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
}
sendJson(res, 200, { options });
});
}, },
'GET /stream': (req, res) => { 'GET /stream': (req, res) => {
+5 -18
View File
@@ -30,12 +30,8 @@
} }
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; } [data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
/* Values still on screen while the feed is down are stale — say so visually. /* 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 [data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; }
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; }
[data-module="propresenter-timers"] .tm-hint { [data-module="propresenter-timers"] .tm-hint {
flex: 1; flex: 1;
@@ -58,6 +54,7 @@
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
gap: var(--tm-gap, 8px); gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px); padding: var(--tm-pad, 10px);
transition: opacity 0.3s ease;
} }
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; } [data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
@@ -83,7 +80,6 @@
border-radius: 10px; border-radius: 10px;
background: color-mix(in srgb, var(--bg) 60%, transparent); background: color-mix(in srgb, var(--bg) 60%, transparent);
overflow: hidden; overflow: hidden;
transition: opacity 0.3s ease;
} }
[data-module="propresenter-timers"] .tm-name { [data-module="propresenter-timers"] .tm-name {
@@ -113,13 +109,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* Admin "Timer font: Monospace" — steadier digits as the numbers change.
Applies to timer and LTC values alike (both are .tm-time). */
[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-state {
flex: none; flex: none;
max-width: 100%; max-width: 100%;
@@ -209,10 +198,6 @@
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } @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. */ /* 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-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state { [data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
@@ -222,6 +207,8 @@
text-align: center; text-align: center;
} }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-waiting .tm-time { color: var(--muted); }
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ /* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item { [data-module="propresenter-timers"] .tm-menu-item {
+3 -7
View File
@@ -1,13 +1,9 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.2.0", "version": "1.1.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": { "node": ">=18" },
"node": ">=18" "scripts": { "start": "node server.js" }
},
"scripts": {
"start": "node server.js"
}
} }
-33
View File
@@ -113,39 +113,6 @@ body { overflow: auto; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; } .endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
/* labeled config subsections (manifest field `group`) */
.field-group {
margin: 14px 0 4px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 9px;
}
.field-group-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 10px;
}
.field-group .field:last-child { margin-bottom: 0; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
default and the field stays visible. */
.module-form .field[hidden] { display: none; }
/* a boolean rendered as a toggle (manifest type `switch`) */
.field.switch-field {
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--text);
cursor: pointer;
max-width: none;
}
.field.switch-field .switch-label { font-size: 13px; }
.module-actions { .module-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+9 -82
View File
@@ -98,39 +98,19 @@ function healthText(mod) {
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
} }
function fieldFor(key, spec, value, passwordSet, moduleId) { function fieldFor(key, spec, value, passwordSet) {
const type = spec?.type || 'string'; const type = spec?.type || 'string';
const label = spec?.label || key; const label = spec?.label || key;
const field = document.createElement('label'); const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : ''); field.className = 'field' + (type === 'boolean' ? ' check' : '');
let read; let read;
// For boolean/switch fields, the control other fields may depend on
// (showWhen) — returned so the caller can wire visibility.
let control = null;
if (type === 'switch') { if (type === 'boolean') {
// Same toggle as the module enable switch, inline with its label.
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = label;
field.append(toggle, text);
read = () => input.checked;
control = input;
} else if (type === 'boolean') {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.checked = Boolean(value); input.checked = Boolean(value);
field.append(input, document.createTextNode(label)); field.append(input, document.createTextNode(label));
read = () => input.checked; read = () => input.checked;
control = input;
} else if (type === 'endpoint') { } else if (type === 'endpoint') {
// One consistent "Host / IP : Port" control for every module's upstream. // One consistent "Host / IP : Port" control for every module's upstream.
const span = document.createElement('span'); const span = document.createElement('span');
@@ -157,38 +137,17 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
row.append(host, sep, port); row.append(host, sep, port);
field.append(span, row); field.append(span, row);
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) { } else if (type === 'select' && Array.isArray(spec.options)) {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
const select = document.createElement('select'); const select = document.createElement('select');
const fill = (options) => { for (const opt of spec.options) {
select.innerHTML = '';
// The saved value must stay selectable even when it isn't among the
// live options (device unplugged, module route down).
const saved = String(value ?? '');
if (saved && !options.some((opt) => String(typeof opt === 'object' ? opt.value : opt) === saved)) {
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
}
if (!saved) options = [{ value: '', label: '— choose —' }, ...options];
for (const opt of options) {
const o = document.createElement('option'); const o = document.createElement('option');
o.value = String(typeof opt === 'object' ? opt.value : opt); o.value = String(typeof opt === 'object' ? opt.value : opt);
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
select.appendChild(o); select.appendChild(o);
} }
select.value = saved; select.value = String(value ?? '');
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute && moduleId) {
// Options discovered at runtime (audio devices, ports, sources…):
// the module serves them from one of its own routes.
fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}`)
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (Array.isArray(body?.options)) fill(body.options);
})
.catch(() => { /* keep the static/saved options */ });
}
field.append(span, select); field.append(span, select);
read = () => select.value; read = () => select.value;
} else { } else {
@@ -205,7 +164,7 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
field.append(span, input); field.append(span, input);
read = () => (type === 'number' ? Number(input.value) : input.value); read = () => (type === 'number' ? Number(input.value) : input.value);
} }
return { field, read, control }; return { field, read };
} }
function buildModuleCard(mod) { function buildModuleCard(mod) {
@@ -318,42 +277,10 @@ function buildModuleCard(mod) {
const form = document.createElement('div'); const form = document.createElement('div');
form.className = 'module-form'; form.className = 'module-form';
const readers = new Map(); const readers = new Map();
const controls = new Map(); // key → boolean/switch input (for showWhen)
const dependents = []; // { field, when } to show/hide by a control's state
// Fields render in schema order; a `group` starts a labeled subsection
// and consecutive same-group fields share it.
let groupName = null;
let container = form;
for (const [key, spec] of schemaKeys) { for (const [key, spec] of schemaKeys) {
const g = spec?.group || ''; const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]);
if (g !== groupName) {
groupName = g;
if (g) {
const group = document.createElement('div');
group.className = 'field-group';
const title = document.createElement('div');
title.className = 'field-group-title';
title.textContent = g;
group.appendChild(title);
form.appendChild(group);
container = group;
} else {
container = form;
}
}
const { field, read, control } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
readers.set(key, read); readers.set(key, read);
if (control) controls.set(key, control); form.appendChild(field);
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
container.appendChild(field);
}
// A field with showWhen:"K" is visible only while K's toggle is on.
for (const dep of dependents) {
const ctl = controls.get(dep.when);
if (!ctl) continue;
const sync = () => { dep.field.hidden = !ctl.checked; };
ctl.addEventListener('change', sync);
sync();
} }
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'module-actions'; actions.className = 'module-actions';
+1 -1
View File
@@ -993,7 +993,7 @@ function applyConfigPatch(id, patch) {
value = current[key] || ''; value = current[key] || '';
} }
if (type === 'number') value = Number(value) || 0; if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean' || type === 'switch') value = Boolean(value); else if (type === 'boolean') value = Boolean(value);
else if (type === 'endpoint') { else if (type === 'endpoint') {
const src = value && typeof value === 'object' ? value : {}; const src = value && typeof value === 'object' ? value : {};
value = { value = {
+1 -139
View File
@@ -4,7 +4,6 @@
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers). * band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
* *
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>] * node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
* [--stage-pwd=<pwd>] [--no-ltc-field]
* *
* Serves the endpoint shapes verified against openapi.propresenter.com: * Serves the endpoint shapes verified against openapi.propresenter.com:
* GET /version (reachability probe) * GET /version (reachability probe)
@@ -24,16 +23,6 @@
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in * the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
* ProPresenter (a solo tile for it must say so, not error). * ProPresenter (a solo tile for it must say so, not error).
* *
* Also serves the classic stage-display websocket at /stagedisplay (the
* Pro6-era protocol ProPresenter still answers): ath / psl / asl / sl, a
* per-second "sys" clock, and "tmr" text updates — including a stage layout
* with a field labeled "LTC" whose text advances at 30 fps and freezes
* during the same 15 s/60 s no-signal window as the HTTP endpoint. Combine
* with --no-ltc to make the stage feed the only timecode source (the module
* prefers the HTTP route when it answers). --stage-pwd=<pwd> rejects other
* stage passwords ("Your Password is incorrect", as real ProPresenter says);
* --no-ltc-field serves the stage layout without the LTC field.
*
* Every incoming request is logged so we can confirm what the module fetches. * Every incoming request is logged so we can confirm what the module fetches.
*/ */
'use strict'; 'use strict';
@@ -44,11 +33,6 @@ const args = process.argv.slice(2);
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10); const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
const NO_LTC = args.includes('--no-ltc'); const NO_LTC = args.includes('--no-ltc');
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : []))); const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12);
const NO_LTC_FIELD = args.includes('--no-ltc-field');
// Like real ProPresenter 21: the layout list omits the timecode field, but
// its updates still stream — exercises the module's heuristic binding.
const LTC_UNLISTED = args.includes('--ltc-unlisted');
const FPS = 30; const FPS = 30;
const startedAt = Date.now(); const startedAt = Date.now();
@@ -156,128 +140,6 @@ const server = http.createServer((req, res) => {
sendJson(res, { error: 'not found' }, 404); sendJson(res, { error: 'not found' }, 404);
}); });
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
const cryptoMod = require('crypto');
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const STAGE_LAYOUT = {
acn: 'sl',
uid: 'MOCK-LAYOUT-1',
nme: 'Timers',
fme: [
{ nme: 'Current Slide', typ: 1 },
...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]),
{ nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' },
],
};
/** Server→client frames are unmasked. Text only — plenty for this protocol. */
function wsEncode(str) {
const p = Buffer.from(str);
let h;
if (p.length < 126) {
h = Buffer.from([0x81, p.length]);
} else {
h = Buffer.alloc(4);
h[0] = 0x81;
h[1] = 126;
h.writeUInt16BE(p.length, 2);
}
return Buffer.concat([h, p]);
}
server.on('upgrade', (req, socket) => {
if (new URL(req.url, 'http://x').pathname !== '/stagedisplay') return void socket.destroy();
const key = req.headers['sec-websocket-key'];
if (!key) return void socket.destroy();
console.log('[pp-timers-mock] stage display client connected');
const accept = cryptoMod.createHash('sha1').update(key + WS_MAGIC).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
const send = (obj) => {
try { socket.write(wsEncode(JSON.stringify(obj))); } catch { /* gone */ }
};
let authed = false;
let buf = Buffer.alloc(0);
let lastLtcText = '';
const sysTimer = setInterval(() => {
if (authed) send({ acn: 'sys', txt: ' ' + new Date().toLocaleTimeString() });
}, 1000);
const tmrTimer = setInterval(() => {
if (!authed) return;
// noise a client must filter out: an ordinary timer's text
send({ acn: 'tmr', uid: 'T1-SERMON', txt: hms(Math.max(0, 25 * 60 - elapsedSec())) });
}, 1000);
const ltcTimer = setInterval(() => {
if (!authed || NO_LTC_FIELD) return;
const tc = timecodeStatus();
// During no-signal the on-stage field is frozen: no updates at all —
// exactly what the module's staleness heuristic must catch.
if (!tc.receiving || tc.time === lastLtcText) return;
lastLtcText = tc.time;
send({ acn: 'tmr', uid: 'LTC-FIELD', txt: tc.time });
}, 250);
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
while (buf.length >= 2) {
const opcode = buf[0] & 0x0f;
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return;
len = buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (buf.length < 10) return;
len = Number(buf.readBigUInt64BE(2));
off = 10;
}
const maskLen = masked ? 4 : 0;
if (buf.length < off + maskLen + len) return;
let payload = buf.slice(off + maskLen, off + maskLen + len);
if (masked) {
const mask = buf.slice(off, off + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
}
buf = buf.slice(off + maskLen + len);
if (opcode === 0x8) return void socket.destroy();
if (opcode === 0x9) { socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload])); continue; }
if (opcode !== 0x1) continue;
let msg;
try { msg = JSON.parse(payload.toString()); } catch { continue; }
console.log('[pp-timers-mock] stage <-', JSON.stringify(msg).slice(0, 120));
if (msg.acn === 'ath') {
if (STAGE_PWD && String(msg.pwd || '') !== STAGE_PWD) {
send({ acn: 'ath', ath: false, majorVersion: 7, minorVersion: 21, err: 'Your Password is incorrect' });
} else {
authed = true;
send({ acn: 'ath', ath: true, err: '' });
}
} else if (msg.acn === 'psl') {
send({ acn: 'psl', uid: 'MOCK-LAYOUT-1' });
} else if (msg.acn === 'asl') {
send({ acn: 'asl', ary: [STAGE_LAYOUT] });
}
}
});
const cleanup = () => {
clearInterval(sysTimer);
clearInterval(tmrTimer);
clearInterval(ltcTimer);
};
socket.on('close', cleanup);
socket.on('error', () => { cleanup(); socket.destroy(); });
});
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}${NO_LTC_FIELD ? ' (stage layout has no LTC field)' : ''}${STAGE_PWD ? ' (stage password required)' : ''}`); console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}`);
}); });