Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49f8a82fbd | ||
|
|
70f56c7909 | ||
|
|
cd13ecaf7c | ||
|
|
09f59c1bef | ||
|
|
3bfea6010a | ||
|
|
7c0579cbf9 |
@@ -0,0 +1,55 @@
|
|||||||
|
# ProPresenter Look — a ProdDash module
|
||||||
|
|
||||||
|
Shows the audience **look** live in ProPresenter right now, as big as the
|
||||||
|
tile allows, so anyone away from the ProPresenter machine can tell at a glance
|
||||||
|
which look the outputs are on.
|
||||||
|
|
||||||
|
- The look's name fills the tile and **flashes** when it changes.
|
||||||
|
- **Modified** appears when someone changed the look by hand and it no longer
|
||||||
|
matches any saved look.
|
||||||
|
- **since 10:42 AM** says when the look last changed.
|
||||||
|
- If ProPresenter stops answering, the status dot goes red and the tile keeps
|
||||||
|
the **last known** look on screen, dimmed, until it comes back by itself.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
This folder is a standard ProdDash module (id `propresenter-looks`). Per the
|
||||||
|
ProdDash module guide, it installs one of two ways:
|
||||||
|
|
||||||
|
1. **From the admin page's catalog**, once the folder is in the ProdDash repo
|
||||||
|
at `modules/propresenter-looks/`: `/admin` → Available modules →
|
||||||
|
*ProPresenter Look* → Install.
|
||||||
|
2. **Drop it in**: copy this folder to the ProdDash data directory's
|
||||||
|
`modules/propresenter-looks/` and restart ProdDash.
|
||||||
|
- macOS: `~/Library/Application Support/ProdDash/modules/`
|
||||||
|
- Windows: `%APPDATA%\ProdDash\modules\`
|
||||||
|
|
||||||
|
Then add a tile from **+ Add tile → ProPresenter Look**.
|
||||||
|
|
||||||
|
## Configure (in `/admin`)
|
||||||
|
|
||||||
|
| Setting | What to enter |
|
||||||
|
| --- | --- |
|
||||||
|
| ProPresenter | Host / IP and port from ProPresenter's Settings › Network (Network enabled). |
|
||||||
|
| Password | Only if ProPresenter's network API asks for one. |
|
||||||
|
|
||||||
|
Each tile's gear has three switches: **Heading**, **Live status**, **Since**.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The server entry polls ProPresenter's network API once a second
|
||||||
|
(`GET /v1/look/current`, plus `GET /v1/looks` every ten seconds to name the
|
||||||
|
saved look it matches) and streams the result to tiles over SSE. Browsers
|
||||||
|
never talk to ProPresenter. No dependencies. Needs ProdDash 1.5.0 or later and
|
||||||
|
ProPresenter 7.9+ (or 20+).
|
||||||
|
|
||||||
|
## Testing without ProPresenter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/pp-looks-mock.js 1601
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the module at host `127.0.0.1`, port `1601`. The live look changes every
|
||||||
|
8 seconds (`--every 3` for faster) and every fourth change is a hand-modified
|
||||||
|
look. `--password <pw>` demands a password; `--no-list` acts like a
|
||||||
|
ProPresenter without `GET /v1/looks`.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/* ProPresenter Look — client part.
|
||||||
|
|
||||||
|
The server part polls ProPresenter and streams the live audience look
|
||||||
|
here over SSE; the tile only renders. The look's name fills the tile so
|
||||||
|
it reads from across the booth, flashes once when it changes, and stays
|
||||||
|
on screen dimmed — marked "last known" — while ProPresenter is
|
||||||
|
unreachable. */
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let view = null;
|
||||||
|
let feedOffline = false;
|
||||||
|
let stream = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
let flashTimer = null;
|
||||||
|
let lastName = null;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pl-screen">
|
||||||
|
<header class="pl-top">
|
||||||
|
<div class="pl-heading">Look</div>
|
||||||
|
<div class="pl-live"><span class="pl-live-dot"></span><span class="pl-live-text">Connecting…</span></div>
|
||||||
|
</header>
|
||||||
|
<div class="pl-name-box"><div class="pl-name is-waiting">Connecting…</div></div>
|
||||||
|
<footer class="pl-meta">
|
||||||
|
<span class="pl-tag" hidden>Modified</span>
|
||||||
|
<span class="pl-since" hidden></span>
|
||||||
|
</footer>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const screenEl = root.querySelector('.pl-screen');
|
||||||
|
const topEl = root.querySelector('.pl-top');
|
||||||
|
const headingEl = root.querySelector('.pl-heading');
|
||||||
|
const liveEl = root.querySelector('.pl-live');
|
||||||
|
const liveTextEl = root.querySelector('.pl-live-text');
|
||||||
|
const nameBox = root.querySelector('.pl-name-box');
|
||||||
|
const nameEl = root.querySelector('.pl-name');
|
||||||
|
const metaEl = root.querySelector('.pl-meta');
|
||||||
|
const tagEl = root.querySelector('.pl-tag');
|
||||||
|
const sinceEl = root.querySelector('.pl-since');
|
||||||
|
|
||||||
|
const on = (key) => moduleApi.instanceSettings[key] !== false;
|
||||||
|
|
||||||
|
function timeText(ms) {
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Biggest font size at which the name fits its box without breaking a word. */
|
||||||
|
function fitName() {
|
||||||
|
const MIN = 11;
|
||||||
|
const boxHeight = nameBox.clientHeight;
|
||||||
|
const boxWidth = nameBox.clientWidth;
|
||||||
|
nameEl.style.overflowWrap = 'normal';
|
||||||
|
if (boxHeight < MIN || !boxWidth) {
|
||||||
|
nameEl.style.fontSize = `${MIN}px`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fits = (size) => {
|
||||||
|
nameEl.style.fontSize = `${size}px`;
|
||||||
|
return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1;
|
||||||
|
};
|
||||||
|
let lo = MIN;
|
||||||
|
let hi = Math.max(MIN, Math.floor(boxHeight));
|
||||||
|
let best = MIN;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
|
||||||
|
}
|
||||||
|
nameEl.style.fontSize = `${best}px`;
|
||||||
|
// One word too long even at MIN: let it break rather than overflow.
|
||||||
|
if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere';
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash() {
|
||||||
|
screenEl.classList.remove('is-changed');
|
||||||
|
void screenEl.offsetWidth; // restart the animation
|
||||||
|
screenEl.classList.add('is-changed');
|
||||||
|
clearTimeout(flashTimer);
|
||||||
|
flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus() {
|
||||||
|
if (feedOffline) {
|
||||||
|
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||||
|
} else if (!view) {
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
} else if (!view.configured) {
|
||||||
|
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
||||||
|
} else if (view.reachable) {
|
||||||
|
moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`);
|
||||||
|
} else if (!view.updatedAt && !view.lastError) {
|
||||||
|
moduleApi.setStatus('connecting', 'Reaching ProPresenter…');
|
||||||
|
} else {
|
||||||
|
moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
setStatus();
|
||||||
|
const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError));
|
||||||
|
const look = view && view.look;
|
||||||
|
|
||||||
|
screenEl.classList.toggle('is-offline', offline);
|
||||||
|
headingEl.hidden = !on('showHeading');
|
||||||
|
liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows
|
||||||
|
topEl.hidden = headingEl.hidden && liveEl.hidden;
|
||||||
|
|
||||||
|
liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable));
|
||||||
|
liveEl.classList.toggle('is-down', offline);
|
||||||
|
if (feedOffline) liveTextEl.textContent = 'ProdDash offline';
|
||||||
|
else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable';
|
||||||
|
else if (view && view.reachable) liveTextEl.textContent = 'Live';
|
||||||
|
else liveTextEl.textContent = 'Connecting…';
|
||||||
|
|
||||||
|
let name;
|
||||||
|
let waiting = false;
|
||||||
|
if (look) {
|
||||||
|
name = look.name;
|
||||||
|
} else if (view && !view.configured) {
|
||||||
|
name = 'Set up in Admin';
|
||||||
|
waiting = true;
|
||||||
|
} else if (offline) {
|
||||||
|
name = 'ProPresenter unreachable';
|
||||||
|
waiting = true;
|
||||||
|
} else {
|
||||||
|
name = 'Connecting…';
|
||||||
|
waiting = true;
|
||||||
|
}
|
||||||
|
nameEl.textContent = name;
|
||||||
|
nameEl.classList.toggle('is-waiting', waiting);
|
||||||
|
|
||||||
|
if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash();
|
||||||
|
if (look) lastName = `${look.name}|${look.modified}`;
|
||||||
|
|
||||||
|
tagEl.hidden = !(look && look.modified);
|
||||||
|
const since = look && view.changedAt && on('showSince');
|
||||||
|
sinceEl.hidden = !since;
|
||||||
|
if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`;
|
||||||
|
metaEl.hidden = tagEl.hidden && sinceEl.hidden;
|
||||||
|
|
||||||
|
fitName();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
open() {
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
feedOffline = true;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
state(e) {
|
||||||
|
try {
|
||||||
|
view = JSON.parse(e.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
render();
|
||||||
|
moduleApi.fetch('/state')
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (body && body.state && !view) {
|
||||||
|
view = body.state;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
connect();
|
||||||
|
resizeObserver = new ResizeObserver(() => fitName());
|
||||||
|
resizeObserver.observe(nameBox);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (stream) stream.close();
|
||||||
|
stream = null;
|
||||||
|
if (resizeObserver) resizeObserver.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
clearTimeout(flashTimer);
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"id": "propresenter-looks",
|
||||||
|
"name": "ProPresenter Look",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.5.0",
|
||||||
|
"description": "The audience look live in ProPresenter right now",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"kind": "local-network",
|
||||||
|
"reason": "Reaching ProPresenter on this network"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": {
|
||||||
|
"w": 2,
|
||||||
|
"h": 1
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 4,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"propresenter": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"label": "ProPresenter",
|
||||||
|
"default": {
|
||||||
|
"host": "",
|
||||||
|
"port": 1025
|
||||||
|
},
|
||||||
|
"help": "The host and port shown in ProPresenter's Settings › Network, with Network enabled."
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "Password",
|
||||||
|
"default": "",
|
||||||
|
"help": "Only if ProPresenter's network API asks for one. Leave empty otherwise."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"showHeading": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Heading",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showLive": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Live status",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showSince": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Since",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProPresenter Look — server part.
|
||||||
|
*
|
||||||
|
* Polls ProPresenter's network API for the live audience look and fans it
|
||||||
|
* out to every tile over SSE. Browsers never talk to ProPresenter directly.
|
||||||
|
*
|
||||||
|
* GET /state → { state: <view> } (instant paint for new tiles)
|
||||||
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
*
|
||||||
|
* ProPresenter's routes (ProPresenter 7.9+ / 20+):
|
||||||
|
* GET /v1/look/current → { id: { uuid, name, index }, screens: [...] }
|
||||||
|
* GET /v1/looks → [ { id: { uuid, name, index }, screens: [...] }, … ]
|
||||||
|
* The current look carries a UUID of its own, never one from /v1/looks. Its
|
||||||
|
* `index` points at the saved look it matches, or equals the number of saved
|
||||||
|
* looks when someone has changed it by hand and it matches none.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const POLL_MS = 1000; // the look matters the moment it changes
|
||||||
|
const RETRY_MS = 3000; // while ProPresenter is unreachable
|
||||||
|
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
|
||||||
|
const REQUEST_TIMEOUT_MS = 2500;
|
||||||
|
|
||||||
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
|
||||||
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
function emptyView(configured = false) {
|
||||||
|
return {
|
||||||
|
configured,
|
||||||
|
reachable: false,
|
||||||
|
lastError: '',
|
||||||
|
look: null, // { name, modified, screens }
|
||||||
|
changedAt: 0, // ms epoch the look last changed while we watched; 0 = unknown
|
||||||
|
updatedAt: 0, // ms epoch of the last good answer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(err) {
|
||||||
|
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
|
||||||
|
function lookName(entry) {
|
||||||
|
return String(entry?.id?.name ?? entry?.name ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const streams = new Set();
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(ep.host || '').trim();
|
||||||
|
const port = Number(ep.port) || 1025;
|
||||||
|
const password = String(config.password || '');
|
||||||
|
const configured = Boolean(host && port);
|
||||||
|
|
||||||
|
let view = emptyView(configured);
|
||||||
|
let lastFrame = '';
|
||||||
|
let saved = []; // names of the saved looks, in ProPresenter's order
|
||||||
|
let savedOk = false;
|
||||||
|
let tick = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function url(target) {
|
||||||
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
|
if (password) built.searchParams.set('password', password);
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(target) {
|
||||||
|
const response = await fetch(url(target), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
|
||||||
|
err.status = response.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast() {
|
||||||
|
let frame;
|
||||||
|
try {
|
||||||
|
frame = JSON.stringify(view);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Polls land every second whether anything changed or not; only real
|
||||||
|
// changes go out, so idle tiles stay idle.
|
||||||
|
if (frame === lastFrame) return;
|
||||||
|
lastFrame = frame;
|
||||||
|
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSaved() {
|
||||||
|
try {
|
||||||
|
const list = await requestJson('/v1/looks');
|
||||||
|
saved = Array.isArray(list) ? list.map(lookName) : [];
|
||||||
|
savedOk = true;
|
||||||
|
} catch (err) {
|
||||||
|
// An older ProPresenter without the list still answers look/current;
|
||||||
|
// the live look's own name is shown then.
|
||||||
|
if (err.status !== 404) throw err;
|
||||||
|
saved = [];
|
||||||
|
savedOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLook(live) {
|
||||||
|
const index = Number(live?.id?.index);
|
||||||
|
const own = lookName(live);
|
||||||
|
const screens = Array.isArray(live?.screens) ? live.screens.length : 0;
|
||||||
|
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
|
||||||
|
return { name: saved[index] || own || `Look ${index + 1}`, modified: false, screens };
|
||||||
|
}
|
||||||
|
// Changed by hand, matching no saved look.
|
||||||
|
const modified = savedOk && Number.isInteger(index) && index >= saved.length;
|
||||||
|
return { name: own || (modified ? 'Custom look' : 'Unnamed look'), modified, screens };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollTick() {
|
||||||
|
if (stopped) return;
|
||||||
|
const next = { ...view };
|
||||||
|
try {
|
||||||
|
tick += 1;
|
||||||
|
if (tick % LOOKS_EVERY === 1 || !savedOk) await refreshSaved();
|
||||||
|
const live = await requestJson('/v1/look/current');
|
||||||
|
let look = resolveLook(live);
|
||||||
|
// A look saved since our last read: refresh the list now rather than
|
||||||
|
// calling it "Custom" for up to ten seconds.
|
||||||
|
if (look.modified && !view.look?.modified && tick % LOOKS_EVERY !== 1) {
|
||||||
|
await refreshSaved();
|
||||||
|
look = resolveLook(live);
|
||||||
|
}
|
||||||
|
const before = view.look;
|
||||||
|
if (before && (before.name !== look.name || before.modified !== look.modified)) {
|
||||||
|
next.changedAt = Date.now();
|
||||||
|
log(`look → ${look.name}${look.modified ? ' (modified)' : ''}`);
|
||||||
|
} else if (!before) {
|
||||||
|
log(`look is ${look.name}${look.modified ? ' (modified)' : ''}`);
|
||||||
|
}
|
||||||
|
next.look = look;
|
||||||
|
next.reachable = true;
|
||||||
|
next.lastError = '';
|
||||||
|
next.updatedAt = Date.now();
|
||||||
|
} catch (err) {
|
||||||
|
if (view.reachable) log(`ProPresenter unreachable: ${errorText(err)}`);
|
||||||
|
next.reachable = false;
|
||||||
|
next.lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
|
||||||
|
tick = 0; // re-read the saved looks first when it comes back
|
||||||
|
}
|
||||||
|
if (stopped) return;
|
||||||
|
view = next;
|
||||||
|
broadcast();
|
||||||
|
pollTimer = setTimeout(pollTick, view.reachable ? POLL_MS : RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configured) {
|
||||||
|
log(`following the live look on ${host}:${port}`);
|
||||||
|
pollTick();
|
||||||
|
} else {
|
||||||
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
current = { streams, getView: () => view };
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
if (!configured) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
|
if (view.reachable) {
|
||||||
|
return { status: 'ok', message: `Live look on ${host}:${port}: ${view.look ? view.look.name : '—'}` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: view.updatedAt || view.lastError ? 'error' : 'connecting',
|
||||||
|
message: view.lastError ? `ProPresenter unreachable: ${view.lastError}` : `Reaching ${host}:${port}…`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
routes() {
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify({ state: current ? current.getView() : emptyView() }));
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getView() : emptyView())}\n\n`);
|
||||||
|
if (!current) return void res.end();
|
||||||
|
const { streams } = current;
|
||||||
|
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||||
|
// not queue frames forever; keepalive probes make the OS notice.
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/* ProPresenter Look — the live look's name, as big as the tile allows.
|
||||||
|
Colors come from the shell's variables only. */
|
||||||
|
|
||||||
|
[data-module="propresenter-looks"] .pl-screen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
160deg,
|
||||||
|
color-mix(in srgb, var(--accent) 12%, transparent),
|
||||||
|
color-mix(in srgb, var(--bg) 60%, transparent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── top row: heading and live status ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-looks"] .pl-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-top[hidden],
|
||||||
|
[data-module="propresenter-looks"] .pl-heading[hidden],
|
||||||
|
[data-module="propresenter-looks"] .pl-live[hidden],
|
||||||
|
[data-module="propresenter-looks"] .pl-meta[hidden],
|
||||||
|
[data-module="propresenter-looks"] .pl-tag[hidden],
|
||||||
|
[data-module="propresenter-looks"] .pl-since[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-looks"] .pl-heading {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-looks"] .pl-live {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-live.is-live .pl-live-dot {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-dot {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the look's name ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-looks"] .pl-name-box {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-name {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: normal;
|
||||||
|
hyphens: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-name.is-waiting {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unreachable: the last known look stays, visibly stale. */
|
||||||
|
[data-module="propresenter-looks"] .pl-screen.is-offline {
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-screen.is-offline .pl-name:not(.is-waiting) {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── footer: modified tag and when it changed ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-looks"] .pl-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-looks"] .pl-tag {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--warn);
|
||||||
|
background: color-mix(in srgb, var(--warn) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A new look: one bright pulse so a glance catches the change. */
|
||||||
|
[data-module="propresenter-looks"] .pl-screen.is-changed {
|
||||||
|
animation: pl-changed 2.6s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes pl-changed {
|
||||||
|
0%, 20% { box-shadow: inset 0 0 0 3px var(--accent); background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||||
|
100% { box-shadow: inset 0 0 0 3px transparent; background-color: transparent; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock ProPresenter looks API, for developing the ProPresenter Look module
|
||||||
|
* without the real thing.
|
||||||
|
*
|
||||||
|
* node tools/pp-looks-mock.js [port] (default 1601)
|
||||||
|
* --every <seconds> how often the live look changes (default 8)
|
||||||
|
* --password <pw> demand ?password=<pw> like a locked ProPresenter
|
||||||
|
* --no-list answer 404 for /v1/looks (an older ProPresenter)
|
||||||
|
*
|
||||||
|
* Point the module at host 127.0.0.1 and this port. Every few changes the
|
||||||
|
* live look is "modified by hand", so it matches no saved look.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const flag = (name) => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i >= 0 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const port = Number(args.find((a) => /^\d+$/.test(a))) || 1601;
|
||||||
|
const every = Number(flag('--every')) || 8;
|
||||||
|
const password = flag('--password') || '';
|
||||||
|
const noList = args.includes('--no-list');
|
||||||
|
|
||||||
|
const screen = (on) => ({ video_input: false, media: on, slide: on, announcements: on, props: on, messages: on, presentation: null, mask: null });
|
||||||
|
const saved = ['Normal', 'Lyrics Only', 'Sermon', 'Countdown', 'Video Only'].map((name, index) => ({
|
||||||
|
id: { uuid: crypto.randomUUID(), name, index },
|
||||||
|
screens: [screen(true), screen(index !== 4)],
|
||||||
|
}));
|
||||||
|
|
||||||
|
let live = 0;
|
||||||
|
let modified = false;
|
||||||
|
let changes = 0;
|
||||||
|
const liveUuid = crypto.randomUUID();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
changes += 1;
|
||||||
|
modified = changes % 4 === 0;
|
||||||
|
if (!modified) live = (live + 1) % saved.length;
|
||||||
|
console.log(`live look → ${saved[live].id.name}${modified ? ' (modified)' : ''}`);
|
||||||
|
}, every * 1000);
|
||||||
|
|
||||||
|
function currentLook() {
|
||||||
|
return {
|
||||||
|
id: { uuid: liveUuid, name: saved[live].id.name, index: modified ? saved.length : live },
|
||||||
|
screens: saved[live].screens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://x');
|
||||||
|
const send = (code, body) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(body === undefined ? '' : JSON.stringify(body));
|
||||||
|
};
|
||||||
|
if (password && url.searchParams.get('password') !== password) return send(401, { error: 'password' });
|
||||||
|
if (url.pathname === '/version') return send(200, { name: 'Mock', platform: 'mac', os_version: '15', host_description: 'ProPresenter 21.0 (mock)' });
|
||||||
|
if (url.pathname === '/v1/looks') return noList ? send(404) : send(200, saved);
|
||||||
|
if (url.pathname === '/v1/look/current') return send(200, currentLook());
|
||||||
|
send(404);
|
||||||
|
}).listen(port, () => {
|
||||||
|
console.log(`mock ProPresenter looks on http://127.0.0.1:${port} — live look changes every ${every}s`);
|
||||||
|
console.log(`live look → ${saved[live].id.name}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user