diff --git a/README.md b/README.md index 9edf76c..c2b5201 100644 --- a/README.md +++ b/README.md @@ -83,16 +83,16 @@ are written by the app itself. - **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. - Admin config: ProdCom URL, API key. + Admin config: ProdCom server (host : port), API key. - **ProPresenter** — what's live and what's next, with slide progress, driven by the shared `propresenter-core` client (ProPresenter 7/20). - Admin config: host, port, password. + Admin config: ProPresenter endpoint (host : port), password. - **Timers** — live ProPresenter timers (countdown, countdown-to-time, 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. Read-only — no start/stop controls. Admin config: host, - port, 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. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index bcb027d..87786db 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -64,7 +64,7 @@ shell code changes, ever. Both schemas map field names to specs: ``` -{ "type": "string" | "number" | "boolean" | "select" | "password", +{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint", "label": "Shown next to the field", "default": , "options": [ { "value": "a", "label": "A" }, … ] // "select" only @@ -77,6 +77,34 @@ browsers** — not in `/api/modules`, and never echoed back into the admin form. Anything upstream that needs the secret must go through your server routes. +### Connection settings: use `endpoint` + +Anything that points at a device or service on the network — ProPresenter, +ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every +module's connection is entered the same way. The admin page renders an +endpoint as a single "Host / IP : Port" control; the value is stored and +handed to your server as an object: + +```json +"propresenter": { + "type": "endpoint", + "label": "ProPresenter", + "default": { "host": "", "port": 1025 } +} +``` + +```js +// in your server entry +const { host, port } = config.propresenter; +const base = `http://${host}:${port}`; +``` + +Do **not** split host and port into separate `string`/`number` fields, and +do **not** ask for a full `http://…` URL unless the setting genuinely is a +web address where protocol or path matter (a page to embed, a webhook). +Endpoints belong in admin config, not per-tile settings — connections are +server-wide by design. + Admin config lives in `config/modules.json` (written by the admin page — your module never touches that file). Per-tile settings live inside each browser's layout. diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index 42d808f..e35b928 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -9,10 +9,10 @@ "minSize": { "w": 3, "h": 2 }, "defaultSize": { "w": 4, "h": 4 }, "configSchema": { - "prodcomUrl": { - "type": "string", - "label": "ProdCom URL", - "default": "http://10.3.11.152:24480" + "prodcom": { + "type": "endpoint", + "label": "ProdCom server", + "default": { "host": "10.3.11.152", "port": 24480 } }, "apiKey": { "type": "password", diff --git a/modules/prodcom-transcript/server.js b/modules/prodcom-transcript/server.js index 13fa59f..0ee3aaa 100644 --- a/modules/prodcom-transcript/server.js +++ b/modules/prodcom-transcript/server.js @@ -71,11 +71,22 @@ function proxy(req, res, target, apiKey) { req.pipe(preq); } +/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved + before the endpoint field existed) can only still be present until the + admin form is re-saved, so when it exists it wins over the endpoint's + schema default. */ +function resolveTarget(config) { + if (config.prodcomUrl) return new URL(config.prodcomUrl); + const ep = config.prodcom; + if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`); + return null; +} + module.exports = { init({ config }) { - health = config.prodcomUrl + health = resolveTarget(config) ? { status: 'connecting', message: 'No traffic yet' } - : { status: 'error', message: 'No ProdCom URL configured' }; + : { status: 'error', message: 'No ProdCom server configured' }; return { stop() { /* nothing persistent to tear down — streams die with their sockets */ }, health: () => health, @@ -83,8 +94,8 @@ module.exports = { }, routes({ config, log }) { - // An invalid URL throws here, which surfaces as a mount error in /admin. - const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480'); + // An invalid host/URL throws here, which surfaces as a mount error in /admin. + const target = resolveTarget(config) || new URL('http://10.3.11.152:24480'); log(`proxying /prodcom/* → ${target.origin}`); return { '* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''), diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index 24d0cc5..5600866 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -9,15 +9,10 @@ "minSize": { "w": 3, "h": 3 }, "defaultSize": { "w": 4, "h": 5 }, "configSchema": { - "host": { - "type": "string", - "label": "ProPresenter host / IP", - "default": "" - }, - "port": { - "type": "number", - "label": "ProPresenter network port", - "default": 1025 + "propresenter": { + "type": "endpoint", + "label": "ProPresenter", + "default": { "host": "", "port": 1025 } }, "password": { "type": "password", diff --git a/modules/propresenter-now-next/server.js b/modules/propresenter-now-next/server.js index 4ef26e4..781cccb 100644 --- a/modules/propresenter-now-next/server.js +++ b/modules/propresenter-now-next/server.js @@ -23,7 +23,13 @@ let current = null; module.exports = { init({ config, log }) { const streams = new Set(); - const enabled = Boolean(config.host && Number(config.port)); + // Endpoint config. Legacy flat host/port keys (saved before the endpoint + // field existed) can only still be present until the admin form is + // re-saved, so when they exist they win over the endpoint's default. + const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; + const host = String(config.host ?? ep.host ?? ''); + const port = Number(config.port ?? ep.port) || 1025; + const enabled = Boolean(host && port); let latest = clearedViewModel(); let lastFrame = ''; @@ -54,14 +60,14 @@ module.exports = { client.configure({ enabled, - host: String(config.host || ''), - port: Number(config.port) || 1025, + host, + port, password: String(config.password || ''), }); if (enabled) { client.start(); - log(`following ProPresenter at ${config.host}:${config.port}`); + log(`following ProPresenter at ${host}:${port}`); } else { log('no ProPresenter host configured — set one in /admin'); } @@ -91,7 +97,7 @@ module.exports = { }, health() { if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; - if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` }; + if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` }; return { status: 'error', message: latest.lastError diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index b5b4603..4818d69 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -9,15 +9,10 @@ "minSize": { "w": 2, "h": 1 }, "defaultSize": { "w": 4, "h": 2 }, "configSchema": { - "host": { - "type": "string", - "label": "ProPresenter host / IP", - "default": "" - }, - "port": { - "type": "number", - "label": "ProPresenter network port", - "default": 1025 + "propresenter": { + "type": "endpoint", + "label": "ProPresenter", + "default": { "host": "", "port": 1025 } }, "password": { "type": "password", diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 08bc3f9..ae6fd26 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -65,8 +65,12 @@ function clearedState(enabled) { module.exports = { init({ config, log }) { const streams = new Set(); - const host = String(config.host || ''); - const port = Number(config.port) || 1025; + // Endpoint config. Legacy flat host/port keys (saved before the endpoint + // field existed) can only still be present until the admin form is + // re-saved, so when they exist they win over the endpoint's default. + const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; + const host = String(config.host ?? ep.host ?? ''); + const port = Number(config.port ?? ep.port) || 1025; const password = String(config.password || ''); const enabled = Boolean(host && port); diff --git a/public/admin/admin.css b/public/admin/admin.css index 5f07ca1..2e54255 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -93,6 +93,12 @@ body { overflow: auto; } .module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; } .module-form .field { max-width: 420px; } + +/* endpoint fields: every module's upstream is entered the same way */ +.field .endpoint-row { display: flex; align-items: center; gap: 7px; } +.endpoint-row .ep-host { flex: 1; min-width: 0; } +.endpoint-row .ep-port { width: 92px; flex: none; } +.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } .module-actions { diff --git a/public/admin/admin.js b/public/admin/admin.js index b137d68..fdcd56c 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -96,6 +96,32 @@ function fieldFor(key, spec, value, passwordSet) { input.checked = Boolean(value); field.append(input, document.createTextNode(label)); read = () => input.checked; + } else if (type === 'endpoint') { + // One consistent "Host / IP : Port" control for every module's upstream. + const span = document.createElement('span'); + span.textContent = label; + const row = document.createElement('span'); + row.className = 'endpoint-row'; + const host = document.createElement('input'); + host.type = 'text'; + host.className = 'ep-host'; + host.placeholder = 'Host / IP'; + host.autocomplete = 'off'; + host.spellcheck = false; + host.value = value?.host ? String(value.host) : ''; + const sep = document.createElement('span'); + sep.className = 'ep-sep'; + sep.textContent = ':'; + const port = document.createElement('input'); + port.type = 'number'; + port.className = 'ep-port'; + port.placeholder = 'Port'; + port.min = '1'; + port.max = '65535'; + port.value = value?.port ? String(value.port) : ''; + row.append(host, sep, port); + field.append(span, row); + read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); } else if (type === 'select' && Array.isArray(spec.options)) { const span = document.createElement('span'); span.textContent = label; diff --git a/server.js b/server.js index d9055fe..8204103 100644 --- a/server.js +++ b/server.js @@ -413,7 +413,13 @@ function applyConfigPatch(id, patch) { } if (type === 'number') value = Number(value) || 0; else if (type === 'boolean') value = Boolean(value); - else value = value === undefined || value === null ? '' : String(value); + else if (type === 'endpoint') { + const src = value && typeof value === 'object' ? value : {}; + value = { + host: String(src.host ?? '').trim(), + port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)), + }; + } else value = value === undefined || value === null ? '' : String(value); next[key] = value; } modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };