Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecc39efe2 | ||
|
|
10ac7d1e09 | ||
|
|
706cab2362 | ||
|
|
35ef54e248 |
@@ -140,8 +140,8 @@ below. Nothing is installed without someone clicking.
|
||||
sit flush — when two share a vertical edge, hover the seam and the same
|
||||
kind of pill appears; drag it to trade width (macOS split-view style).
|
||||
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
|
||||
green tab brings it back, minus the Admin button), the occupied columns
|
||||
stretch to the full width, and every column runs to the
|
||||
green lip at the top-left brings it back, minus the Admin button), the
|
||||
occupied columns stretch to the full width, and every column runs to the
|
||||
bottom of the screen — the last tile in each column is stretched to end
|
||||
there. A short tile like the clock is never stretched into a ribbon: under
|
||||
another tile it slides to the bottom edge and the tile above grows into the
|
||||
@@ -150,9 +150,11 @@ below. Nothing is installed without someone clicking.
|
||||
its normal row height and scrolls.
|
||||
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
||||
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||
for a clean wall-display look. The menu bar has the same kind of tab —
|
||||
the green one under the ProdDash name, kept at the left so it never sits
|
||||
on top of a tile's own tab.
|
||||
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
||||
name itself**, wrapped in a translucent green pill at the left — click it
|
||||
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
||||
the bar back. It stays at the left so it never sits on top of a tile's
|
||||
own tab.
|
||||
- The **gear** on a tile holds per-tile settings (filters, text size,
|
||||
display options) — these belong to this browser's layout, not the server.
|
||||
- The **grid icon** — save the current arrangement to the server under a
|
||||
@@ -166,8 +168,10 @@ below. Nothing is installed without someone clicking.
|
||||
|
||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||
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.
|
||||
Tile settings: channel icons, direction and timestamps.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||
Admin config: ProPresenter endpoint (host : port), password.
|
||||
@@ -175,10 +179,8 @@ below. Nothing is installed without someone clicking.
|
||||
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"
|
||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||
unmistakably red. LTC is read from a stage-display layout field labeled
|
||||
"LTC" over ProPresenter's stage websocket (its HTTP API has no timecode
|
||||
route). Read-only — no start/stop controls. Admin config: ProPresenter
|
||||
endpoint (host : port), API password, stage display password.
|
||||
unmistakably red. Read-only — no start/stop controls. Admin config:
|
||||
ProPresenter endpoint (host : port), password.
|
||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||
reference for module authors.
|
||||
|
||||
|
||||
@@ -496,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,
|
||||
overrunning, stopped and elapsed timers plus an LTC timecode that
|
||||
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
||||
a timecode API). Also serves the stage-display websocket with an
|
||||
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`.
|
||||
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
|
||||
|
||||
**The resilience checklist** — every module must pass this before it's done:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||||
contract: everything lives inside the tile's root, per-tile state
|
||||
(hidden channels, timestamps, text size) persists in instance settings,
|
||||
and all network traffic goes through moduleApi (the module's server
|
||||
proxy at /prodcom/*). */
|
||||
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||||
persists in instance settings, and all network traffic goes through
|
||||
moduleApi (the module's server proxy at /prodcom/*). */
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
/* ── per-instance state ─────────────────────────────────────────── */
|
||||
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
|
||||
const channels = new Map(); // channelId -> {name, color}
|
||||
const groups = new Map(); // groupId -> {name, channelIds}
|
||||
const entryEls = new Map(); // entryId -> element
|
||||
let pinnedToBottom = true;
|
||||
let pinnedToLatest = true;
|
||||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||
let stream = null; // moduleApi.sse handle
|
||||
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 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 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)
|
||||
const channelsMenu = moduleApi.header.addMenu({
|
||||
label: 'Channels ▾',
|
||||
icon: CHANNELS_SVG,
|
||||
title: 'Choose visible channels',
|
||||
build(menu) {
|
||||
menu.classList.add('pt-menu');
|
||||
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
});
|
||||
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;
|
||||
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: '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({
|
||||
icon: JUMP_SVG,
|
||||
title: 'Jump to latest',
|
||||
onClick() {
|
||||
pinnedToBottom = true;
|
||||
pinnedToLatest = 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));
|
||||
wrap.style.setProperty('--pt-size', size + 'px');
|
||||
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||||
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
|
||||
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();
|
||||
|
||||
/* ── 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 ─────────────────────────────────── */
|
||||
|
||||
function persistHidden() {
|
||||
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function applyFilter(el) {
|
||||
const hidden = hiddenChannels.has(el.dataset.channelId)
|
||||
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
|
||||
el.classList.toggle('hidden-by-filter', hidden);
|
||||
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|
||||
}
|
||||
|
||||
function toggleChannel(id) {
|
||||
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
|
||||
persistHidden();
|
||||
rebuildChannelMenu();
|
||||
entryEls.forEach((el) => applyFilter(el));
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
}
|
||||
|
||||
/** Show exactly this group's channels, hide the rest */
|
||||
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
|
||||
persistHidden();
|
||||
rebuildChannelMenu();
|
||||
entryEls.forEach((el) => applyFilter(el));
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
}
|
||||
|
||||
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;
|
||||
for (const id of channels.keys()) {
|
||||
if (!hiddenChannels.has(id)) visibleCount++;
|
||||
}
|
||||
channelsMenu.setLabel(visibleCount === channels.size
|
||||
? 'Channels ▾'
|
||||
: `Channels (${visibleCount}/${channels.size}) ▾`);
|
||||
const filtering = channels.size > 0 && visibleCount !== channels.size;
|
||||
channelCountEl.textContent = filtering ? `${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() {
|
||||
updateChannelsLabel();
|
||||
updateChannelsButton();
|
||||
const channelMenu = channelMenuEl;
|
||||
if (!channelMenu) return; // menu not opened yet — the label is enough
|
||||
channelMenu.innerHTML = '';
|
||||
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
/* ── scrolling ──────────────────────────────────────────────────── */
|
||||
|
||||
/** Newest entry at the top (bottom-up) instead of at the bottom? */
|
||||
function newestFirst() {
|
||||
return Boolean(moduleApi.instanceSettings.newestFirst);
|
||||
}
|
||||
|
||||
streamEl.addEventListener('scroll', () => {
|
||||
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||
pinnedToBottom = nearBottom;
|
||||
if (nearBottom) jumpBtn.hidden = true;
|
||||
const near = newestFirst()
|
||||
? streamEl.scrollTop < 60
|
||||
: streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||
pinnedToLatest = near;
|
||||
if (near) jumpBtn.hidden = true;
|
||||
});
|
||||
|
||||
jumpBtn.addEventListener('click', () => {
|
||||
pinnedToBottom = true;
|
||||
pinnedToLatest = true;
|
||||
jumpBtn.hidden = true;
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
streamEl.scrollTop = streamEl.scrollHeight;
|
||||
/** The latest entry is at the top when flipped, at the bottom otherwise. */
|
||||
function scrollToLatest() {
|
||||
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
|
||||
}
|
||||
|
||||
function afterInsert(el) {
|
||||
if (el.classList.contains('hidden-by-filter')) return;
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
else jumpBtn.hidden = false;
|
||||
}
|
||||
|
||||
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'pt-entry';
|
||||
el.dataset.channelId = entry.channelId || '';
|
||||
el.dataset.channelName = entry.channelName || '';
|
||||
const info = channelInfo(entry);
|
||||
el.style.setProperty('--ch', info.color);
|
||||
|
||||
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
|
||||
afterInsert(el);
|
||||
} else {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
connect();
|
||||
}
|
||||
|
||||
@@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) {
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
onResize() {
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript stream",
|
||||
"client": "client.js",
|
||||
@@ -22,15 +22,15 @@
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"label": "Channel filter (channel name; empty = all)",
|
||||
"default": ""
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Show channel icons",
|
||||
"default": true
|
||||
},
|
||||
"textSize": {
|
||||
"type": "number",
|
||||
"label": "Text size (px)",
|
||||
"default": 15
|
||||
"newestFirst": {
|
||||
"type": "boolean",
|
||||
"label": "Newest first (transcript reads bottom-up)",
|
||||
"default": false
|
||||
},
|
||||
"showTimes": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
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 ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
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);
|
||||
}
|
||||
[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; }
|
||||
|
||||
@@ -143,6 +155,12 @@
|
||||
|
||||
[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 ── */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-jump {
|
||||
@@ -163,3 +181,9 @@
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
[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;
|
||||
}
|
||||
|
||||
@@ -210,9 +210,7 @@ export default function create({ root, moduleApi }) {
|
||||
if (ltc.supported === false) {
|
||||
mode = 'unavailable';
|
||||
card.timeEl.textContent = '—';
|
||||
// The server says why when it knows (stage password rejected, no
|
||||
// stage field labeled LTC, …) — surface that over the generic line.
|
||||
card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter';
|
||||
card.stateEl.textContent = 'Timecode not available on this ProPresenter';
|
||||
} else if (ltc.supported === null) {
|
||||
mode = 'waiting';
|
||||
card.timeEl.textContent = displayTime(ltc.time);
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProPresenter timers and LTC timecode",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 2
|
||||
},
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
"defaultSize": { "w": 4, "h": 2 },
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
"default": { "host": "", "port": 1025 }
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "API password (only if set in ProPresenter)",
|
||||
"default": ""
|
||||
},
|
||||
"stagePassword": {
|
||||
"type": "password",
|
||||
"label": "Stage display password (for LTC — ProPresenter Settings → Network)",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,15 +23,10 @@
|
||||
* "overr" as overrun. The API exposes NO per-timer color anywhere.
|
||||
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched
|
||||
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
|
||||
* allowlist either) documents no timecode/LTC route at all — verified
|
||||
* live: ProPresenter 21.4 returns 404. We probe this candidate route
|
||||
* once per connection anyway so a future ProPresenter that adds it
|
||||
* lights up without a module update.
|
||||
*
|
||||
* Because the HTTP API has no timecode, LTC actually comes from the classic
|
||||
* stage-display websocket (see stage-ws.js): a stage layout field labeled
|
||||
* "LTC" streams its text to us. Source precedence: the HTTP route if it ever
|
||||
* answers (authoritative), else the stage field, else "not available".
|
||||
* allowlist either) documents no timecode/LTC route at all. We probe this
|
||||
* candidate route once per connection anyway so a future ProPresenter
|
||||
* that adds it lights up without a module update; a 404 marks LTC
|
||||
* "not available on this ProPresenter" (ltc.supported = false).
|
||||
*
|
||||
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
|
||||
* notes in ../propresenter-now-next/propresenter-core). So: requests run
|
||||
@@ -52,8 +47,6 @@ const REQUEST_TIMEOUT_MS = 4000;
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
const { createStageLtcClient } = require('./stage-ws');
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
@@ -63,10 +56,9 @@ function clearedState(enabled) {
|
||||
reachable: false,
|
||||
lastError: '',
|
||||
timers: [],
|
||||
// supported: null = no verdict yet (probing, or ProPresenter
|
||||
// unreachable); true once a timecode source is live; false when both
|
||||
// sources came up empty (note says why, when we know).
|
||||
ltc: { supported: null, time: '', receiving: false, source: '', note: '' },
|
||||
// supported: null = not probed yet (or ProPresenter unreachable),
|
||||
// true/false once a reachable ProPresenter answered the probe.
|
||||
ltc: { supported: null, time: '', receiving: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,23 +85,6 @@ module.exports = {
|
||||
/** null = probe /v1/timecode/status on the next reachable tick. */
|
||||
let ltcSupported = null;
|
||||
let ltcLast = { time: '', receiving: false };
|
||||
/** Live view of the stage-display websocket's LTC field (stage-ws.js). */
|
||||
let stageLtc = { bound: false, time: '', receiving: false, note: '' };
|
||||
|
||||
/** One LTC verdict from both sources: HTTP route wins if it ever
|
||||
answers (authoritative), else the stage-display field. */
|
||||
function composeLtc() {
|
||||
if (ltcSupported === true) {
|
||||
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, note: '' };
|
||||
}
|
||||
if (stageLtc.bound) {
|
||||
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, note: '' };
|
||||
}
|
||||
if (ltcSupported === null && !stageLtc.note) {
|
||||
return { supported: null, source: '', time: '', receiving: false, note: '' };
|
||||
}
|
||||
return { supported: false, source: '', time: '', receiving: false, note: stageLtc.note || '' };
|
||||
}
|
||||
|
||||
function url(target) {
|
||||
const built = new URL(`http://${host}:${port}${target}`);
|
||||
@@ -222,10 +197,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
async function pollLtc() {
|
||||
// A 404 verdict is retried once a minute — an endpoint appearing
|
||||
// without an unreachable spell (ProPresenter hot-swapped in testing,
|
||||
// or some future in-place upgrade) must not stay invisible forever.
|
||||
if (ltcSupported === false && tick % 120 !== 0) return;
|
||||
if (ltcSupported === false) return;
|
||||
try {
|
||||
const body = await requestJson('/v1/timecode/status');
|
||||
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
|
||||
@@ -249,7 +221,7 @@ module.exports = {
|
||||
reachable: latest.reachable,
|
||||
lastError: latest.lastError,
|
||||
timers: latest.timers,
|
||||
ltc: composeLtc(),
|
||||
ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving },
|
||||
};
|
||||
if (!streams.size) return;
|
||||
let frame;
|
||||
@@ -299,23 +271,9 @@ module.exports = {
|
||||
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
let stageClient = null;
|
||||
if (enabled) {
|
||||
pollTick();
|
||||
log(`polling timers on ${host}:${port}`);
|
||||
stageClient = createStageLtcClient({
|
||||
host,
|
||||
port,
|
||||
password: String(config.stagePassword || ''),
|
||||
log,
|
||||
// The HTTP poller knows every configured timer's uuid — anything
|
||||
// else streaming timecode-shaped text can be heuristically bound.
|
||||
isTimerUid: (uid) => timerConfigs.has(uid),
|
||||
onUpdate(update) {
|
||||
stageLtc = update;
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
@@ -338,7 +296,6 @@ module.exports = {
|
||||
stopped = true;
|
||||
clearTimeout(pollTimer);
|
||||
clearInterval(heartbeat);
|
||||
stageClient?.stop();
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
@@ -348,10 +305,7 @@ module.exports = {
|
||||
health() {
|
||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||
if (latest.reachable) {
|
||||
const ltc = latest.ltc;
|
||||
const ltcNote = ltc.supported
|
||||
? (ltc.source === 'stage' ? ', LTC via stage display' : ', LTC via API')
|
||||
: (ltc.note ? ` (${ltc.note})` : '');
|
||||
const ltcNote = latest.ltc.supported ? ' (with timecode)' : '';
|
||||
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ProPresenter stage-display websocket client — the LTC timecode source.
|
||||
*
|
||||
* ProPresenter's HTTP openAPI exposes no timecode route (see server.js), but
|
||||
* its classic stage-display websocket (ws://host:port/stagedisplay — the
|
||||
* Pro6-era protocol, still answered by ProPresenter 21; verified live: it
|
||||
* responds `{"acn":"ath","ath":…,"majorVersion":7,"minorVersion":21}`) streams
|
||||
* the text of stage-layout fields. So: the operator puts a field labeled
|
||||
* "LTC" on a stage layout, and this client reads that field's live text.
|
||||
*
|
||||
* Protocol (community-documented, github.com/jeffmikels/ProPresenter-API):
|
||||
* → {"pwd":"<stage password>","ptl":610,"acn":"ath"}
|
||||
* ← {"acn":"ath","ath":true|false,"err":"…"}
|
||||
* → {"acn":"psl"} ← {"acn":"psl","uid":"<layout uid>"}
|
||||
* → {"acn":"asl"} ← {"acn":"asl","ary":[{acn:"sl",uid,nme,fme:[…]},…]}
|
||||
* ← {"acn":"sys","txt":" 11:17 AM"} every second — our liveness signal
|
||||
* ← {"acn":"tmr","uid":"…","txt":"…"} live text for a layout field's uid
|
||||
*
|
||||
* A layout's `fme` frames carry `nme` (the label) and, for linked fields, a
|
||||
* `uid`. We bind to the first frame labeled "LTC" (case-insensitive), then
|
||||
* treat any message carrying that uid as the timecode text — matching by uid
|
||||
* rather than acn on purpose, since how ProPresenter 21 tags timecode
|
||||
* updates is undocumented. "Receiving" is a staleness heuristic: LTC text
|
||||
* that hasn't advanced for STALE_MS is a frozen/no-signal feed.
|
||||
*
|
||||
* Zero-dependency by design (like the rest of ProdDash): a minimal RFC6455
|
||||
* client — masked client frames, text + ping/pong + close, fragmented text
|
||||
* reassembly. TLS is not needed (ProPresenter's server is plain ws://).
|
||||
*/
|
||||
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const RECONNECT_MS = 3000;
|
||||
/** Auth rejections retry slowly — a wrong password never fixes itself fast. */
|
||||
const BAD_AUTH_RETRY_MS = 30000;
|
||||
/** ProPresenter sends a clock tick every second; silence this long = dead link. */
|
||||
const LIVENESS_MS = 10000;
|
||||
/** LTC text that has not advanced for this long counts as no-signal. */
|
||||
const STALE_MS = 2500;
|
||||
|
||||
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
const ZERO_UID = '00000000-0000-0000-0000-000000000000';
|
||||
/** Timecode-shaped text: a fourth (frames) group separates it from timers. */
|
||||
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
||||
|
||||
/**
|
||||
* @param {{host:string, port:number, password:string, log:Function,
|
||||
* isTimerUid?:(uid:string)=>boolean,
|
||||
* onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts
|
||||
* @returns {{stop:()=>void}}
|
||||
*/
|
||||
function createStageLtcClient({ host, port, password, log, isTimerUid, onUpdate }) {
|
||||
let sock = null;
|
||||
let stopped = false;
|
||||
let reconnectTimer = null;
|
||||
let livenessTimer = null;
|
||||
let staleTimer = null;
|
||||
|
||||
let ltcUid = ''; // uid of the frame labeled LTC, once bound
|
||||
let boundBy = ''; // 'label' (layout frame) or 'heuristic' (sniffed)
|
||||
let candidateUid = ''; // heuristic candidate awaiting a second, advancing sample
|
||||
let candidateText = '';
|
||||
let lastText = '';
|
||||
let lastAdvance = 0;
|
||||
// Known-but-unused message types; anything else is logged once so a new
|
||||
// ProPresenter's timecode tag shows up in the server log for discovery.
|
||||
const loggedUnknownAcn = new Set(['fv', 'msg', 'vid', 'tmr', 'cs', 'ns', 'csn', 'nsn']);
|
||||
|
||||
function emit(partial) {
|
||||
onUpdate({
|
||||
bound: Boolean(ltcUid),
|
||||
time: lastText,
|
||||
receiving: false,
|
||||
note: '',
|
||||
...partial,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── outgoing frames (client → server must be masked) ───────────── */
|
||||
|
||||
function sendFrame(opcode, payload) {
|
||||
if (!sock || sock.destroyed) return;
|
||||
const mask = crypto.randomBytes(4);
|
||||
const len = payload.length;
|
||||
let header;
|
||||
if (len < 126) {
|
||||
header = Buffer.from([0x80 | opcode, 0x80 | len]);
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 0x80 | 126;
|
||||
header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 0x80 | 127;
|
||||
header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
const masked = Buffer.from(payload);
|
||||
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4];
|
||||
try {
|
||||
sock.write(Buffer.concat([header, mask, masked]));
|
||||
} catch { /* socket died mid-write; liveness/close handling reconnects */ }
|
||||
}
|
||||
|
||||
function sendJson(obj) {
|
||||
sendFrame(0x1, Buffer.from(JSON.stringify(obj)));
|
||||
}
|
||||
|
||||
/* ── protocol: layouts → find the LTC-labeled frame ─────────────── */
|
||||
|
||||
let assignedLayoutUid = '';
|
||||
|
||||
function bindFromLayouts(layouts) {
|
||||
const isLtcFrame = (f) => String(f?.nme || '').trim().toLowerCase() === 'ltc';
|
||||
const pick = (layout) => (Array.isArray(layout?.fme) ? layout.fme.find(isLtcFrame) : null);
|
||||
// Prefer the layout ProPresenter assigned to this client; fall back to
|
||||
// any layout with an LTC-labeled frame.
|
||||
const ordered = layouts.slice().sort((a, b) =>
|
||||
(b.uid === assignedLayoutUid ? 1 : 0) - (a.uid === assignedLayoutUid ? 1 : 0));
|
||||
for (const layout of ordered) {
|
||||
const frame = pick(layout);
|
||||
if (frame && frame.uid) {
|
||||
if (ltcUid !== frame.uid) {
|
||||
ltcUid = String(frame.uid);
|
||||
boundBy = 'label';
|
||||
log(`stage display: bound LTC to field "${frame.nme}" (${ltcUid}) in layout "${layout.nme || layout.uid}"`);
|
||||
emit({ receiving: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame && !frame.uid) {
|
||||
log(`stage display: layout "${layout.nme}" has an LTC field with no uid — it never streams; link it to a timecode/timer source`);
|
||||
}
|
||||
}
|
||||
// Verified on ProPresenter 21.4: the legacy protocol (the only version
|
||||
// it accepts, ptl 610) omits stage objects it predates — a timecode
|
||||
// field never appears in any layout's frame list. So a heuristic
|
||||
// binding sniffed from the live stream must survive layout refreshes.
|
||||
if (boundBy === 'heuristic' && ltcUid) return;
|
||||
ltcUid = '';
|
||||
boundBy = '';
|
||||
emit({ bound: false, time: '', note: 'No LTC field visible on the stage-display feed' });
|
||||
}
|
||||
|
||||
function handleMessage(obj) {
|
||||
const acn = String(obj.acn || '');
|
||||
switch (acn) {
|
||||
case 'ath':
|
||||
if (obj.ath === true) {
|
||||
log('stage display: authenticated');
|
||||
sendJson({ acn: 'psl' }); // which layout are we assigned?
|
||||
sendJson({ acn: 'asl' }); // all layouts, with their frames
|
||||
} else {
|
||||
const err = String(obj.err || 'authentication rejected');
|
||||
log(`stage display: auth failed — ${err}`);
|
||||
emit({ bound: false, time: '', note: 'Stage display password rejected — set it in Admin' });
|
||||
scheduleReconnect(BAD_AUTH_RETRY_MS);
|
||||
destroySocket();
|
||||
}
|
||||
return;
|
||||
case 'psl':
|
||||
assignedLayoutUid = String(obj.uid || '');
|
||||
return;
|
||||
case 'asl':
|
||||
if (Array.isArray(obj.ary)) bindFromLayouts(obj.ary);
|
||||
return;
|
||||
case 'sl':
|
||||
// A layout definition was pushed (assignment or edit) — it may not
|
||||
// be ours, so re-resolve from the authoritative full list instead
|
||||
// of rebinding blindly.
|
||||
sendJson({ acn: 'psl' });
|
||||
sendJson({ acn: 'asl' });
|
||||
return;
|
||||
case 'sys': // per-second clock — liveness only
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Heuristic fallback: with no LTC-labeled frame to bind to, an unknown
|
||||
// uid streaming timecode-shaped text (a fourth :FF group — timers never
|
||||
// have one) that is NOT a configured timer can only be the timecode
|
||||
// field. Require two advancing samples before trusting it.
|
||||
if (!ltcUid && obj.uid && obj.uid !== ZERO_UID && typeof obj.txt === 'string'
|
||||
&& TC_SHAPE.test(obj.txt.trim()) && !(isTimerUid && isTimerUid(obj.uid))) {
|
||||
if (candidateUid === obj.uid && candidateText !== obj.txt) {
|
||||
ltcUid = String(obj.uid);
|
||||
boundBy = 'heuristic';
|
||||
log(`stage display: no LTC-labeled field in any layout, but ${obj.uid} streams timecode-shaped text (acn "${acn}") — binding it as LTC`);
|
||||
// fall through to the normal update path below
|
||||
} else {
|
||||
candidateUid = String(obj.uid);
|
||||
candidateText = obj.txt;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Anything carrying our bound uid is the LTC text, whatever its acn —
|
||||
// ProPresenter 21's tag for timecode fields is undocumented.
|
||||
if (ltcUid && obj.uid === ltcUid && typeof obj.txt === 'string') {
|
||||
const txt = obj.txt.trim();
|
||||
const placeholder = !txt || /^[-–—:;.\s]*$/.test(txt);
|
||||
if (placeholder) {
|
||||
lastText = '';
|
||||
emit({ receiving: false });
|
||||
return;
|
||||
}
|
||||
if (txt !== lastText) {
|
||||
lastText = txt;
|
||||
lastAdvance = Date.now();
|
||||
emit({ receiving: true });
|
||||
clearTimeout(staleTimer);
|
||||
staleTimer = setTimeout(() => {
|
||||
// Frozen text = LTC stopped arriving at ProPresenter.
|
||||
emit({ receiving: false });
|
||||
}, STALE_MS);
|
||||
staleTimer.unref?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (acn && !loggedUnknownAcn.has(acn) && loggedUnknownAcn.size < 30) {
|
||||
loggedUnknownAcn.add(acn);
|
||||
log(`stage display: unhandled message type "${acn}" — ${JSON.stringify(obj).slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── incoming frames ────────────────────────────────────────────── */
|
||||
|
||||
function connect() {
|
||||
if (stopped) return;
|
||||
let buf = Buffer.alloc(0);
|
||||
let upgraded = false;
|
||||
let fragments = null;
|
||||
const key = crypto.randomBytes(16).toString('base64');
|
||||
|
||||
sock = net.connect(port, host);
|
||||
sock.setNoDelay(true);
|
||||
|
||||
const bumpLiveness = () => {
|
||||
clearTimeout(livenessTimer);
|
||||
livenessTimer = setTimeout(() => {
|
||||
log('stage display: silent too long — reconnecting');
|
||||
destroySocket();
|
||||
scheduleReconnect(RECONNECT_MS);
|
||||
}, LIVENESS_MS);
|
||||
livenessTimer.unref?.();
|
||||
};
|
||||
|
||||
sock.on('connect', () => {
|
||||
sock.write(
|
||||
`GET /stagedisplay HTTP/1.1\r\n` +
|
||||
`Host: ${host}:${port}\r\n` +
|
||||
`Upgrade: websocket\r\nConnection: Upgrade\r\n` +
|
||||
`Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
|
||||
bumpLiveness();
|
||||
});
|
||||
|
||||
sock.on('data', (chunk) => {
|
||||
bumpLiveness();
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
|
||||
if (!upgraded) {
|
||||
const idx = buf.indexOf('\r\n\r\n');
|
||||
if (idx === -1) return;
|
||||
const head = buf.slice(0, idx).toString();
|
||||
buf = buf.slice(idx + 4);
|
||||
const statusLine = head.split('\r\n')[0] || '';
|
||||
if (!/ 101 /.test(statusLine + ' ')) {
|
||||
log(`stage display: no websocket upgrade (${statusLine.trim()}) — this ProPresenter may not serve /stagedisplay`);
|
||||
emit({ bound: false, time: '', note: 'No stage display websocket on this ProPresenter' });
|
||||
destroySocket();
|
||||
scheduleReconnect(BAD_AUTH_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
const accept = crypto.createHash('sha1').update(key + WS_MAGIC).digest('base64');
|
||||
const m = head.match(/sec-websocket-accept:\s*(\S+)/i);
|
||||
if (m && m[1] !== accept) log('stage display: Sec-WebSocket-Accept mismatch (continuing)');
|
||||
upgraded = true;
|
||||
sendJson({ pwd: String(password || ''), ptl: 610, acn: 'ath' });
|
||||
}
|
||||
|
||||
// decode server frames (unmasked; tolerate masked anyway)
|
||||
while (buf.length >= 2) {
|
||||
const fin = (buf[0] & 0x80) !== 0;
|
||||
const opcode = buf[0] & 0x0f;
|
||||
const maskBit = (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 = maskBit ? 4 : 0;
|
||||
if (buf.length < off + maskLen + len) return;
|
||||
let payload = buf.slice(off + maskLen, off + maskLen + len);
|
||||
if (maskBit) {
|
||||
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 === 0x9) { // ping → pong
|
||||
sendFrame(0xA, payload);
|
||||
continue;
|
||||
}
|
||||
if (opcode === 0x8) { // close
|
||||
destroySocket();
|
||||
scheduleReconnect(RECONNECT_MS);
|
||||
return;
|
||||
}
|
||||
if (opcode === 0x1 || (opcode === 0x0 && fragments)) {
|
||||
if (!fin || opcode === 0x0) { // fragmented text
|
||||
fragments = fragments ? Buffer.concat([fragments, payload]) : payload;
|
||||
if (!fin) continue;
|
||||
payload = fragments;
|
||||
fragments = null;
|
||||
}
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(payload.toString());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (obj && typeof obj === 'object') handleMessage(obj);
|
||||
}
|
||||
// binary frames (slide images) are ignored
|
||||
}
|
||||
});
|
||||
|
||||
sock.on('error', () => { /* close handler reconnects */ });
|
||||
sock.on('close', () => {
|
||||
clearTimeout(livenessTimer);
|
||||
if (stopped) return;
|
||||
if (!reconnectTimer) {
|
||||
emit({ receiving: false });
|
||||
scheduleReconnect(RECONNECT_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroySocket() {
|
||||
if (sock) {
|
||||
try { sock.destroy(); } catch { /* already gone */ }
|
||||
sock = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(ms) {
|
||||
if (stopped || reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, ms);
|
||||
reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(reconnectTimer);
|
||||
clearTimeout(livenessTimer);
|
||||
clearTimeout(staleTimer);
|
||||
destroySocket();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStageLtcClient };
|
||||
+3
-2
@@ -15,7 +15,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<span id="brand">Prod<span class="accent">Dash</span></span>
|
||||
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
|
||||
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
@@ -50,7 +51,7 @@
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
|
||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
|
||||
|
||||
<main id="gridwrap">
|
||||
<div id="grid">
|
||||
|
||||
+38
-20
@@ -54,36 +54,54 @@ body.menu-hidden #topbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The menubar's own show/hide tab. It sits under the ProdDash brand at the
|
||||
left — not screen-centre, where a tile's own (centred) tab would land
|
||||
right under it once both bars are hidden — and wears the accent colour
|
||||
so it never reads as a tile tab. */
|
||||
/* The menubar's show/hide handle IS the logo: a translucent accent pill
|
||||
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the
|
||||
background colour. It sits at the left — never screen-centre, where a
|
||||
tile's own (centred) tab lands once both bars are hidden. When the bar
|
||||
hides, the pill shrinks to a slim lip at the very top edge, clear of a
|
||||
top-row tile's status dot and title. */
|
||||
.brand-spacer { visibility: hidden; }
|
||||
|
||||
#topbar-notch {
|
||||
position: fixed;
|
||||
/* straddles the bar's bottom edge — mostly on the bar, a short lip below
|
||||
it — so it never covers a top-row tile's status dot or title */
|
||||
top: calc(var(--topbar-h, 46px) - 8px);
|
||||
left: 12px;
|
||||
width: 54px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: var(--accent);
|
||||
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
|
||||
8px past the bar's bottom edge; the logo sits centred in the bar part */
|
||||
top: 0;
|
||||
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */
|
||||
height: calc(var(--topbar-h, 46px) + 8px);
|
||||
min-width: 60px;
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: var(--topbar-h, 46px);
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
border: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
cursor: pointer;
|
||||
z-index: 60;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s ease, top 0.22s ease, height 0.22s ease;
|
||||
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
|
||||
}
|
||||
#topbar-notch:hover { opacity: 1; }
|
||||
/* bar hidden: a slim tab at the very top edge, clear of a top-row tile's dot and title */
|
||||
body.menu-hidden #topbar-notch { top: 0; height: 10px; }
|
||||
#topbar-notch::after {
|
||||
#topbar-notch .accent { color: var(--bg); } /* the cutout */
|
||||
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); }
|
||||
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */
|
||||
body.menu-hidden #topbar-notch {
|
||||
height: 10px;
|
||||
line-height: 10px;
|
||||
font-size: 0;
|
||||
background: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
|
||||
body.menu-hidden #topbar-notch::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
margin: 4px auto 0;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
+1
-139
@@ -4,7 +4,6 @@
|
||||
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
|
||||
*
|
||||
* 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:
|
||||
* GET /version (reachability probe)
|
||||
@@ -24,16 +23,6 @@
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
'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 NO_LTC = args.includes('--no-ltc');
|
||||
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 startedAt = Date.now();
|
||||
@@ -156,128 +140,6 @@ const server = http.createServer((req, res) => {
|
||||
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, () => {
|
||||
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)' : ''}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user