diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..ffa1c6e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "start", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "start" + ], + "port": 3000 + } + ] +} diff --git a/README.md b/README.md index 80696b3..a56fa13 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ with this machine's shell settings — the same keys as the checked-in | --- | --- | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | +| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path @@ -111,6 +112,22 @@ so and falls back to the app's `config/` folder. directory, so nothing has to be re-entered. The old files are left in place and ignored from then on. +### Themes + +The whole dashboard — every tile, on every browser — draws with one palette, +chosen in the admin page's **Theme** section and switched live: pick a swatch +and open dashboards change without a reload. Five presets ship: **Booth** +(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite** +(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange) +and **Daylight**, a light palette for a front-of-house desk in daytime. Each +keeps the status colours apart — the accent is never near the danger red or +the warning amber — and muted text readable in a dark booth. The choice is +stored as `theme` in the data directory's `proddash.json` (table above), so +it holds across restarts and updates; a browser remembers the last theme it +saw and applies it before the server answers, so nothing flashes. Modules +style with the shell's colour variables, which is why they follow; a colour +set on a tile itself (a PCO Plan header colour, say) is deliberately kept. + ## Installing modules and updating Everything below happens in the **admin page**; ProdDash talks to its GitHub diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 64ca0c2..7593f1a 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -92,6 +92,7 @@ the app); its settings are kept for a later reinstall. | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `description` | no | Shown in the admin page / picker. | | `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. | +| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. | | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `style` | no | A stylesheet the shell injects once per module. | @@ -115,8 +116,13 @@ Both schemas map field names to specs: } ``` -`boolean` renders as a checkbox; `switch` is the same true/false value shown -as a toggle (use it for a feature on/off that other fields depend on). +`boolean` and `switch` are the same true/false value. In a tile's gear popover +both render as a switch; in the admin page `switch` is the toggle and +`boolean` the plainer checkbox (use `switch` for a feature on/off that other +fields depend on). **Label a switch with the noun of what it turns on** — +"Seconds", "Top bar", "Section headers" — never "Show seconds": on means +shown or enabled, off means hidden or disabled, and the label shouldn't say +so twice. `color` renders a swatch picker and stores a `#rrggbb` string (give it a hex default; the tile applies it as a CSS custom property — see `pco-plan`). @@ -141,7 +147,15 @@ the group name: still empty. Omit it and the group is a plain, always-open block. - `columns`: 2–4 lays the group's fields out in a grid — the way to keep a long checklist of booleans compact. -- `help`: a line of guidance at the top of the group. +- `help`: guidance for the whole group. The admin page shows it as an + ⓘ beside the group title (click or hover to read), so a group can carry a + paragraph without pushing its fields down the page. Say where a value comes + from, not what a field is. +- `toggle`: the key of a `switch` field in this group. It renders in the + group's title row, at the right, as the group's on/off; the rest of the group + dims while it is off, and the field is not repeated in the body. This is how + an integration is disabled without unmounting the module — ProPresenter + timers, say, on a Sunday when ProPresenter isn't running. `help` on a field puts one line of guidance under its control — allowed placeholders, where a value comes from. Keep labels short and put the @@ -363,9 +377,15 @@ own in `stop()` anyway. 2. **Style with the shell's CSS variables — no hardcoded colors.** The palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, - `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with + `--muted`, `--accent`, `--accent-text` (text that sits on the accent — + dark on most themes, white where the accent is deep), `--danger`, + `--warn`. Derive tints with `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes - every module look native on the dark, high-contrast booth theme. + every module look native — and what makes the dashboard's **themes** + (Admin → Theme) reach into every tile: a theme swaps those variables and + nothing else. A colour a tile is configured with (a `color` instance + setting, applied as a custom property on the tile) is set closer than the + theme and wins over it, by design. 3. **No external network calls from the client.** Browsers talk only to ProdDash. Anything upstream (your device, service, API) goes through your @@ -385,7 +405,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of: ```js module.exports = { - init({ config, log }) { + init({ config, log, shell }) { // Called on startup / re-init, BEFORE routes(). Start your upstream // connection here. Return a handle: return { @@ -394,7 +414,7 @@ module.exports = { }; }, - routes({ config, log }) { + routes({ config, log, shell }) { // Route table, mounted under /api/modules// return { 'GET /state': (req, res) => { … }, @@ -410,6 +430,9 @@ module.exports = { - `config` is your admin config: `configSchema` defaults overlaid with what the admin page saved — **including password fields**. - `log(...)` prefixes output with your module id. +- `shell.port` is the port ProdDash is serving on — what you need to read + another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/ + modules//…`). Never guess it. - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is the matched remainder (leading `/` included) and `req.search` is the query string (with `?`, or `''`) — so a proxy rebuilds the upstream path as @@ -496,6 +519,71 @@ per-client `res.write` in try/catch (drop the client on failure). The `propresenter-now-next` module is the reference implementation of this pattern. +## Working with other modules + +Modules don't import each other. When one needs what another knows, it reads +that module's **own server routes over loopback**, exactly as a browser would: +`http://127.0.0.1:${shell.port}/api/modules//…`. The PCO Plan module +follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream` +rather than talking to ProPresenter a second time. A consumer must survive the +provider being disabled, uninstalled, or restarting mid-service (404s and +dropped connections are normal; retry with backoff and say so in `health()`). + +### Offering timers to the Timers module + +The Timers module shows timers from anywhere: ProPresenter, LTC, its own +clock, and any module that offers some. A module offers timers by: + +1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell + forwards `provides` in `/api/modules`, which is how the Timers module finds + providers without a list anyone maintains. +2. Serving `GET /timers` from its server routes, returning a snapshot: + +```json +{ + "source": "pco-plan", + "label": "PCO Plan", + "updatedAt": 1757800000000, + "timers": [ + { + "id": "service", + "label": "Service", + "kind": "elapsed", + "state": "running", + "startedAt": 1757799000000, + "elapsedMs": 1000000, + "remainingMs": null, + "targetMs": 4500000, + "status": { "text": "Welcome & Announcements", "tone": "ok" }, + "detail": "Item 3 of 12" + } + ] +} +``` + +| Field | Meaning | +| --- | --- | +| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. | +| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. | +| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. | +| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). | +| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). | +| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. | +| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. | +| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. | +| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. | +| `detail` | Optional second line. | + +The consumer derives the displayed time locally from `startedAt`/`elapsedMs` +and its own clock, so a snapshot every couple of seconds is plenty — `/timers` +must be cheap to answer (serve from state you already hold; never call +upstream to answer it). Optionally also serve `GET /timers/stream` as SSE +(`event: timers`, `data:` the same object, on every change plus a heartbeat +comment every 15 s); the consumer uses the stream when it exists and polls +`/timers` otherwise. Only offer timers a production tech would want on a +wall — the Timers module lets them hide any of them per tile, but every entry +you add is a decision someone has to make. + ## Complete minimal example A working clock — copy-paste this as your starter. (A slightly fancier diff --git a/modules/clock/module.json b/modules/clock/module.json index f95087f..d90051c 100644 --- a/modules/clock/module.json +++ b/modules/clock/module.json @@ -1,9 +1,8 @@ { "id": "clock", "name": "Clock", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.1.0", - "description": "Big booth clock — the smallest possible ProdDash module", "client": "client.js", "style": "style.css", "minSize": { "w": 2, "h": 1 }, @@ -18,7 +17,7 @@ { "value": "12h", "label": "12-hour" } ] }, - "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, - "showDate": { "type": "boolean", "label": "Show date", "default": false } + "showSeconds": { "type": "boolean", "label": "Seconds", "default": true }, + "showDate": { "type": "boolean", "label": "Date", "default": false } } } diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 8e88a70..9e8cdf7 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -209,36 +209,55 @@ export default function create({ root, moduleApi }) { renderClock(); } + /** + * The header clock follows the service the server says owns the clock + * (state.service: idle → running → stopped, by schedule or by the admin's + * ProPresenter document cues): a countdown before it, the running time + * while it runs (with ahead/behind when following ProPresenter), the final + * time once it has ended. + */ function renderClock() { const plan = state?.plan; const live = state?.live || {}; + const svc = state?.service || {}; clockLabelEl.textContent = ''; clockValueEl.textContent = ''; clockValueEl.className = 'pp-clock-value'; if (!plan || headEl.hidden) return; const now = serverNow(); - if (on('showRunningClock') && live.following && live.serviceStartedAt) { + const startedAt = svc.startedAt || live.serviceStartedAt || 0; + if (on('showRunningClock') && svc.phase === 'running' && startedAt) { clockLabelEl.textContent = 'Running'; - clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000); - // Ahead of / behind the plan: elapsed minus what the completed items were planned to take. - const done = state.items.filter((it) => live.history?.[it.id]?.endedAt); - const planned = done.reduce((sum, it) => sum + (it.length || 0), 0); - const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0); - const drift = actual - planned; - if (planned && Math.abs(drift) >= 30) { - clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; - clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); + clockValueEl.textContent = fmtDur((now - startedAt) / 1000); + if (live.following) { + // Ahead of / behind the plan: elapsed minus what the completed items were planned to take. + const done = state.items.filter((it) => live.history?.[it.id]?.endedAt); + const planned = done.reduce((sum, it) => sum + (it.length || 0), 0); + const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0); + const drift = actual - planned; + if (planned && Math.abs(drift) >= 30) { + clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; + clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); + } } return; } - if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) { - clockLabelEl.textContent = 'Starts in'; - clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000); + if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) { + clockLabelEl.textContent = 'Ended'; + clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000); return; } - if (on('showRunningClock') && plan.serviceStartsAt) { - clockLabelEl.textContent = 'Since start'; - clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000); + const startsAt = svc.startsAt || plan.serviceStartsAt || 0; + if (on('showCountdown') && startsAt > now) { + clockLabelEl.textContent = 'Starts in'; + clockValueEl.textContent = fmtDur((startsAt - now) / 1000); + return; + } + if (on('showRunningClock') && startsAt) { + // Past the scheduled start and not started: waiting for the admin's + // start document to go live in ProPresenter (or its failsafe). + clockLabelEl.textContent = 'Scheduled'; + clockValueEl.textContent = fmtClock(startsAt); } } diff --git a/modules/pco-plan/matching.js b/modules/pco-plan/matching.js new file mode 100644 index 0000000..0ed052b --- /dev/null +++ b/modules/pco-plan/matching.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Title matching for the PCO Plan module — the one answer to "is this + * ProPresenter document that Planning Center thing?", used twice: + * + * - mapping the document ProPresenter has live to a plan item (the + * highlight and the item runtimes), and + * - recognising the documents an admin picked as the service start / end + * cues (see service-timing.js). + * + * A ProPresenter name and a Planning Center title rarely agree letter for + * letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides + * are tidied the way propresenter-core.cleanItemName does (tags in brackets, + * leading date codes and dash separators dropped), lower-cased, stripped of + * punctuation and compared: + * + * 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop" + * 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements" + * Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67 + * + * Filler words ("the", "of", "and"…) are ignored. The thresholds: + * + * MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because + * the item that scores best *and* is next in the + * plan wins, so a near miss lands on the right row. + * TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service + * clock, so one wrong word must not fire it: the + * pre-service loop (0.67 against "Post Service Loop") + * stays a non-match, while a renamed + * "Post-Service Loop", a tagged "Post Service Loop + * [Full]" (1.0) or a shortened "Post Service" (0.85) + * still trigger. + */ + +const MATCH_THRESHOLD = 0.6; +const TRIGGER_THRESHOLD = 0.75; + +/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */ +function cleanTitle(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); + s = s.replace(/\s[-–—]+\s/g, ' '); + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); + s = s.replace(/\s{2,}/g, ' ').trim(); + return s || original; +} + +function normalize(raw) { + return cleanTitle(raw) + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[’'`]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']); +function tokens(norm) { + return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t))); +} + +/** 0..1 similarity between two titles. */ +function similarity(a, b) { + const na = normalize(a); + const nb = normalize(b); + if (!na || !nb) return 0; + if (na === nb) return 1; + if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85; + const ta = tokens(na); + const tb = tokens(nb); + if (!ta.size || !tb.size) return 0; + let common = 0; + for (const t of ta) if (tb.has(t)) common += 1; + return (2 * common) / (ta.size + tb.size); // Dice coefficient +} + +/** Does a live ProPresenter document count as the cue document an admin picked? */ +function isCue(liveName, cueDocument) { + return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD; +} + +module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue }; diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 25537dd..9ab9699 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,9 +1,10 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.3.0", - "proddash": ">=1.3.0", - "description": "Order of service from Planning Center Services, following ProPresenter live", + "version": "1.4.0", + "proddash": ">=1.4.0", + "description": "Order of service from Planning Center Services", + "provides": ["timers"], "client": "client.js", "server": "server.js", "style": "style.css", @@ -12,13 +13,16 @@ "configGroups": { "Planning Center credentials": { "collapsed": "whenSet", - "help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers." + "help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers." + }, + "Service timing": { + "help": "Each service in the plan takes its turn owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time — or, with document triggers on, the moment the chosen Start document goes live in ProPresenter (falling back to the schedule after the start failsafe) — and ends when the End document goes live, or when the next service takes over. The document lists hold what ProPresenter has shown or listed since ProdDash started plus what is saved here; names are matched loosely, so a renamed \"Post-Service Loop\" still counts." } }, "configSchema": { "appId": { "type": "string", - "label": "Application ID", + "label": "Client ID", "default": "", "group": "Planning Center credentials" }, @@ -30,7 +34,7 @@ }, "accessToken": { "type": "password", - "label": "OAuth 2.0 access token (instead of Application ID + Secret)", + "label": "OAuth 2.0 access token (instead of Client ID + Secret)", "default": "", "group": "Planning Center credentials" }, @@ -38,8 +42,7 @@ "type": "select", "label": "Service type", "default": "", - "optionsRoute": "/service-types", - "help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty." + "optionsRoute": "/service-types" }, "planId": { "type": "select", @@ -47,18 +50,67 @@ "default": "", "options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }], "optionsRoute": "/plans", - "optionsDependsOn": "serviceTypeId", - "help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service." + "optionsDependsOn": "serviceTypeId" }, "pollSeconds": { "type": "number", - "label": "Re-read the plan every N seconds", + "label": "Refresh Interval (s)", "default": 30 }, "followProPresenter": { "type": "switch", - "label": "Follow the ProPresenter module's live item to highlight the current plan item and time it", + "label": "Follow ProPresenter Module", "default": true + }, + "preRollMinutes": { + "type": "number", + "label": "Pre-roll (min)", + "default": 30, + "group": "Service timing", + "help": "How long before a service's scheduled start the clock, item timing and countdown switch to it." + }, + "useDocumentTriggers": { + "type": "switch", + "label": "ProPresenter document triggers", + "default": false, + "group": "Service timing" + }, + "serviceStartTrigger": { + "type": "select", + "label": "Service start", + "default": "schedule", + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "options": [ + { "value": "schedule", "label": "Scheduled service time" }, + { "value": "document", "label": "ProPresenter document" } + ] + }, + "serviceStartDocument": { + "type": "select", + "label": "Start document", + "default": "", + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "optionsRoute": "/documents", + "help": "The document that goes live as the service begins." + }, + "startGraceMinutes": { + "type": "number", + "label": "Start failsafe (min)", + "default": 10, + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway." + }, + "serviceEndDocument": { + "type": "select", + "label": "End document", + "default": "", + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "optionsRoute": "/documents", + "help": "The document that goes live as the service ends; it freezes the service clock." } }, "instanceGroups": { @@ -72,7 +124,7 @@ "Show / hide": { "collapsed": true, "columns": 3, - "help": "Untick anything this tile should not show." + "help": "Switch off anything this tile should not show." } }, "instanceSchema": { diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index 0b3c937..77f4852 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -13,9 +13,13 @@ * GET /service-types → { options } for the admin "Service type" select, * grouped by Planning Center folder path * GET /plans?serviceTypeId=… → { options } for the admin "Plan" select + * GET /documents → { options } for the admin start / end document selects + * (documents ProPresenter has shown or lists — see below) + * GET /timers → snapshot for the Timers module (guide: "Offering + * GET /timers/stream → SSE, `timers` events timers to the Timers module") * * Planning Center API v2 (https://api.planningcenteronline.com/services/v2): - * Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0 + * Auth: Personal Access Token (HTTP Basic client_id:secret) or an OAuth 2.0 * bearer token. Responses are JSON:API — `data`, `included`, `links.next`. * GET /folders, /service_types (setup browsing) * GET /service_types/{st}/plans?filter=future|past (pick the plan) @@ -23,13 +27,19 @@ * GET …/plans/{id}/plan_times (service start times) * GET …/plans/{id}/items?include=song,arrangement,item_notes * - * Phase 2 — following ProPresenter: instead of talking to ProPresenter a - * second time, this module reads the propresenter-now-next module's own SSE - * feed over loopback (/api/modules/propresenter-now-next/stream) and matches - * the live presentation name to a plan item. Item start/end timestamps are - * tracked here, once, so every tile agrees on the runtime. The shell's port - * is guessed the way the shell decides it and then confirmed from the Host - * header of the first tile request. + * Following ProPresenter: instead of talking to ProPresenter a second time, + * this module reads the propresenter-now-next module's own SSE feed over + * loopback (/api/modules/propresenter-now-next/stream, on shell.port) and + * matches the live presentation name to a plan item. Item start/end + * timestamps are tracked here, once, so every tile agrees on the runtime. + * + * Service timing: a plan's services (9:00, 11:00…) take turns owning the + * service clock — the rules live in service-timing.js. A service starts at + * its scheduled time, or (admin "Service timing") when a chosen ProPresenter + * document goes live, and ends when the chosen end document goes live, with + * schedule failsafes for both. The result is offered to the Timers module as + * three timers: the service, the current item and the countdown to the + * service. * * PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at * tools/pco-mock.js for development. @@ -39,6 +49,16 @@ const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { MATCH_THRESHOLD, similarity } = require('./matching'); +const { + MINUTE, + serviceTimes, + serviceWindows, + selectService, + freshService, + advance, + fmtTime, +} = require('./service-timing'); const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); @@ -56,8 +76,16 @@ const HEARTBEAT_MS = 15000; const NOW_NEXT_ID = 'propresenter-now-next'; const NOW_NEXT_RETRY_MS = 3000; const NOW_NEXT_MISSING_RETRY_MS = 15000; -/** Minimum name-similarity to call a ProPresenter document a plan item. */ -const MATCH_THRESHOLD = 0.6; +/** How many distinct documents seen live are remembered for the admin selects. */ +const SEEN_DOCUMENTS_MAX = 200; +/** + * Service timer tone (the Timers module colours by it): running behind the + * plan — completed items over their planned length, plus the current item's + * overrun — by BEHIND_WARN_MS is `warn`, by BEHIND_DANGER_MS `danger`; past + * the planned total length is `warn`, by more than BEHIND_DANGER_MS `danger`. + */ +const BEHIND_WARN_MS = 2 * MINUTE; +const BEHIND_DANGER_MS = 5 * MINUTE; /** Set by init(), read by the routes — both are rebuilt together on remount. */ let current = null; @@ -76,6 +104,8 @@ function clearedState() { plan: null, items: [], live: clearedLive(), + service: clearedService(), + timing: null, // the admin's service-timing choices (no secrets) }; } @@ -90,17 +120,39 @@ function clearedLive() { matched: false, // activeName maps to a plan item currentItemId: '', currentScore: 0, - serviceStartedAt: 0, // first matched item start, ms epoch + serviceStartedAt: 0, // the service's actual start, ms epoch (0 until it starts) + serviceEndedAt: 0, // …and its end, once it has one history: {}, // itemId → { startedAt, endedAt } }; } +/** The service that owns the clock, as tiles and the timers see it. */ +function clearedService() { + return { + id: '', + phase: 'none', // 'none' (no plan / no services) | 'idle' | 'running' | 'stopped' + startedAt: 0, + endedAt: 0, + startTrigger: '', + endTrigger: '', + cue: '', + name: '', + label: '', + index: -1, + count: 0, + startsAt: 0, + endsAt: 0, + windowFrom: 0, + windowUntil: 0, + }; +} + /** - * First guess at the shell's port, so the loopback Now/Next feed lands on this - * ProdDash before any browser has talked to us. Mirrors how the shell decides: + * Fallback for a shell that does not hand us its port (init() without + * `shell`, i.e. ProdDash before 1.4.0): guess the way the shell decides — * PORT env, then this machine's proddash.json in the data directory, then the - * checked-in config/proddash.json, then 24500. The guess is corrected from the - * Host header of the first request that reaches a route (see learnPort). + * checked-in config/proddash.json, then 24500 — and correct the guess from + * the Host header of the first request that reaches a route (see learnPort). */ function guessShellPort() { const env = Number(process.env.PORT); @@ -138,45 +190,15 @@ function localYmd(d) { return `${y}-${m}-${day}`; } -/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */ -function cleanTitle(raw) { - const original = String(raw || '').trim(); - let s = original; - s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); - s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); - s = s.replace(/\s[-–—]+\s/g, ' '); - s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); - s = s.replace(/\s{2,}/g, ' ').trim(); - return s || original; -} - -function normalize(raw) { - return cleanTitle(raw) - .toLowerCase() - .replace(/&/g, ' and ') - .replace(/[’'`]/g, '') - .replace(/[^a-z0-9]+/g, ' ') - .trim(); -} - -const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']); -function tokens(norm) { - return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t))); -} - -/** 0..1 similarity between two titles. */ -function similarity(a, b) { - const na = normalize(a); - const nb = normalize(b); - if (!na || !nb) return 0; - if (na === nb) return 1; - if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85; - const ta = tokens(na); - const tb = tokens(nb); - if (!ta.size || !tb.size) return 0; - let common = 0; - for (const t of ta) if (tb.has(t)) common += 1; - return (2 * common) / (ta.size + tb.size); // Dice coefficient +/** "1:02:03" / "4:05" for a duration in ms (for logs and status lines). */ +function fmtDur(ms) { + const s = Math.max(0, Math.round(Math.abs(Number(ms) || 0) / 1000)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return h + ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` + : `${m}:${String(sec).padStart(2, '0')}`; } /* ── Planning Center client ─────────────────────────────────────────── */ @@ -244,16 +266,6 @@ function createPcoClient(config) { /* ── plan shaping ───────────────────────────────────────────────────── */ -function pickServiceTime(planTimes, nowMs) { - const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt); - const pool = services.length ? services : planTimes.filter((t) => t.startsAt); - if (!pool.length) return null; - pool.sort((a, b) => a.startsAt - b.startsAt); - // The service that is running or up next; after the last one, the last one. - const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs); - return upcoming || pool[pool.length - 1]; -} - /** Scheduled start of every item from the chosen service start time. */ function scheduleItems(items, serviceStartMs) { for (const item of items) item.startsAt = 0; @@ -280,6 +292,13 @@ function scheduleItems(items, serviceStartMs) { } } +/** Planned length of the service proper (the "during" items), ms. */ +function plannedLengthMs(items) { + return (items || []) + .filter((it) => it.servicePosition === 'during') + .reduce((sum, it) => sum + (Number(it.length) || 0) * 1000, 0); +} + function shapeItems(itemsBody) { const included = new Map(); for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec); @@ -344,27 +363,72 @@ function shapePlan(planRec, serviceTypeId, planTimes) { }; } +/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */ +function serviceLabel(win) { + if (!win || !win.startsAt) return ''; + const name = String(win.name || '').trim(); + return `${fmtTime(win.startsAt)} ${name && !/^service$/i.test(name) ? name : 'service'}`; +} + +/** What /timers answers while the module is not running. */ +function emptyTimers(now) { + return { source: 'pco-plan', label: 'PCO Plan', updatedAt: now, timers: [] }; +} + /* ── the module ─────────────────────────────────────────────────────── */ module.exports = { - init({ config, log }) { - const streams = new Set(); + init({ config, log, shell }) { + const streams = new Set(); // /stream clients (tiles) + const timerStreams = new Set(); // /timers/stream clients (the Timers module) const pco = createPcoClient(config); const serviceTypeId = String(config.serviceTypeId || '').trim(); const pinnedPlanId = String(config.planId || '').trim(); const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000; const follow = config.followProPresenter !== false; + // Service timing choices (admin "Service timing"). A blank number field + // reaches us as 0, which is a legitimate choice; an absent one as the + // schema default. + const minutes = (value, dflt, max) => { + const n = Number(value); + return Number.isFinite(n) && n >= 0 && value !== undefined && value !== null && value !== '' + ? Math.min(max, n) + : dflt; + }; + const triggersOn = Boolean(config.useDocumentTriggers); + const startDocument = triggersOn ? String(config.serviceStartDocument || '').trim() : ''; + const endDocument = triggersOn ? String(config.serviceEndDocument || '').trim() : ''; + const wantsDocumentStart = triggersOn && String(config.serviceStartTrigger || 'schedule') === 'document'; + const timing = { + preRollMs: minutes(config.preRollMinutes, 30, 240) * MINUTE, + startGraceMs: minutes(config.startGraceMinutes, 10, 120) * MINUTE, + triggersOn, + startMode: wantsDocumentStart && startDocument ? 'document' : 'schedule', + startDocument, + endDocument, + }; + const serviceOpts = { + startMode: timing.startMode, + startDocument: timing.startDocument, + endDocument: timing.endDocument, + startGraceMs: timing.startGraceMs, + }; + // Document cues need the ProPresenter feed even when the tiles don't follow it. + const wantFeed = follow || triggersOn; + let state = clearedState(); state.configured = pco.configured; state.serviceTypeSet = Boolean(serviceTypeId); state.serviceType = { id: serviceTypeId, name: '' }; state.live.following = follow; + state.timing = { ...timing }; let stopped = false; let pollTimer = null; let inFlight = null; let lastFrame = ''; + let lastTimersSig = ''; /* — broadcasting — */ @@ -372,19 +436,40 @@ module.exports = { return JSON.stringify(state); } + function writeAll(set, payload) { + for (const res of set) { + try { + res.write(payload); + } catch { + set.delete(res); + } + } + } + function broadcast(force) { const frame = frameOf(); if (!force && frame === lastFrame) return; lastFrame = frame; if (!streams.size) return; - const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`; - for (const res of streams) { - try { - res.write(payload); - } catch { - streams.delete(res); - } - } + writeAll(streams, `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`); + } + + /** + * The timers change "structurally" far less often than their displayed + * value does (the consumer extrapolates from startedAt), so a frame goes + * out only when something other than the running values changed. + */ + function timersSignature(snap) { + return JSON.stringify(snap.timers.map((t) => [t.id, t.state, t.startedAt, t.targetMs, t.status, t.detail])); + } + + function broadcastTimers(force) { + const snap = timersSnapshot(Date.now()); + const sig = timersSignature(snap); + if (!force && sig === lastTimersSig) return; + lastTimersSig = sig; + if (!timerStreams.size) return; + writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`); } /* — plan reading — */ @@ -474,32 +559,36 @@ module.exports = { } } if (!planRec) { + if (state.plan) log('no plan found any more — service timing reset'); state.plan = null; state.items = []; state.reachable = true; state.lastError = ''; state.lastFetched = Date.now(); + syncServiceSafe(Date.now(), null); return; } const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]); const plan = shapePlan(planRec, serviceTypeId, planTimes); - const service = pickServiceTime(planTimes, Date.now()); - if (service) { - plan.serviceStartsAt = service.startsAt; - plan.serviceEndsAt = service.endsAt; - plan.serviceName = service.name; - } - scheduleItems(items, plan.serviceStartsAt || plan.sortDate); - if (state.plan?.id !== plan.id) { - if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`); + const planChanged = state.plan?.id !== plan.id; + if (planChanged) { + if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title}) — service timing reset`); else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim()); - resetLiveTracking(); } state.plan = plan; state.items = items; state.reachable = true; state.lastError = ''; state.lastFetched = Date.now(); + if (planChanged) { + // A new plan: the next syncService picks its first window afresh + // (and resets the item timing while doing so). + win = null; + svc = freshService(null); + } + // Service selection, item schedule and the running clock all follow + // from the plan's service times — decided in one place. + syncServiceSafe(Date.now(), null); } function schedulePoll(ms) { @@ -523,6 +612,7 @@ module.exports = { log(`plan read failed: ${state.lastError}`); } broadcast(); + broadcastTimers(); schedulePoll(delay); })(); try { @@ -532,29 +622,324 @@ module.exports = { } } - /* — phase 2: follow the ProPresenter Now/Next feed over loopback — */ + /* — service timing: which service owns the clock, and its start / end — */ + + let windows = []; // serviceWindows() of the current plan + let win = null; // the window that owns the clock now + let svc = freshService(null); // its idle → running → stopped state (service-timing.js) + let lastTimingError = ''; + let countdownTargetId = ''; + let countdownSince = 0; + + /** A plan with no plan times still has a date — treat it as one service starting then. */ + function planServices(plan) { + if (serviceTimes(plan.planTimes).length) return plan.planTimes; + return plan.sortDate ? [{ id: 'plan', name: '', timeType: 'service', startsAt: plan.sortDate, endsAt: 0 }] : []; + } + + /** The plan's header times and every item's scheduled start follow the current service. */ + function applyServiceTimes() { + const plan = state.plan; + if (!plan) return; + plan.serviceStartsAt = win ? win.startsAt : 0; + plan.serviceEndsAt = win ? win.endsAt : 0; + plan.serviceName = win ? win.name : ''; + scheduleItems(state.items, plan.serviceStartsAt || plan.sortDate); + } + + /** state.service and the live mirror fields, from `win` + `svc`. */ + function mirrorService() { + const finite = (v) => (Number.isFinite(v) ? v : 0); + state.service = { + ...clearedService(), + ...svc, + name: win ? win.name : '', + label: serviceLabel(win), + index: win ? win.index : -1, + count: win ? win.count : 0, + startsAt: win ? win.startsAt : 0, + endsAt: win ? win.endsAt : 0, + windowFrom: win ? finite(win.windowFrom) : 0, + windowUntil: win ? finite(win.windowUntil) : 0, + }; + state.live.serviceStartedAt = svc.phase === 'running' || svc.phase === 'stopped' ? svc.startedAt : 0; + state.live.serviceEndedAt = svc.phase === 'stopped' ? svc.endedAt : 0; + } + + /** + * Bring the service timing up to `now`, optionally with a document that + * has just gone live in ProPresenter (`cue`). Selects the window that + * owns the clock (flipping — and resetting item timing — when the next + * service's window has opened), then advances that service's own + * idle → running → stopped state. Pure logic in service-timing.js. + */ + function syncService(now, cue) { + const plan = state.plan; + if (!plan) { + if (win || svc.phase !== 'none') { + win = null; + windows = []; + svc = freshService(null); + mirrorService(); + } + return; + } + const endedAt = svc.phase === 'stopped' && svc.endedAt ? { [svc.id]: svc.endedAt } : {}; + windows = serviceWindows(planServices(plan), { + preRollMs: timing.preRollMs, + plannedMs: plannedLengthMs(state.items), + endedAt, + }); + const next = selectService(windows, now); + if (!next) { + // A plan with neither service times nor a date: nothing to time. + if (win || svc.phase !== 'none') { + const hadWindow = Boolean(win); + win = null; + svc = freshService(null); + if (hadWindow) resetLiveTracking(); + applyServiceTimes(); + mirrorService(); + } + return; + } + if (!win || win.id !== next.id) { + // Leaving a window: a service still running is stopped at the boundary + // (the "next service's start" failsafe), then the new service takes + // over with fresh item timing. + if (win && svc.id === win.id && svc.phase === 'running') { + const leaving = windows.find((w) => w.id === win.id) || win; + const out = advance(svc, leaving, serviceOpts, Math.max(now, leaving.failsafeEndAt), null); + const endedAtMs = out.changed ? Math.min(out.svc.endedAt, now) : now; + log(`service ${serviceLabel(leaving)} ${out.changed ? out.event : 'left its window while running'} (ran ${fmtDur(endedAtMs - svc.startedAt)})`); + } + win = next; + svc = freshService(win); + log(`service window → ${serviceLabel(win)} (${win.index + 1} of ${win.count}; scheduled start ${fmtTime(win.startsAt)}, start by ${timing.startMode}${timing.endDocument ? ', end by document' : ''})`); + resetLiveTracking(); + applyServiceTimes(); + } else { + win = next; // refreshed fields — the plan's times may have been edited + if (plan.serviceStartsAt !== win.startsAt || plan.serviceEndsAt !== win.endsAt || plan.serviceName !== win.name) { + applyServiceTimes(); + } + } + const out = advance(svc, win, serviceOpts, now, cue); + if (out.changed) { + svc = out.svc; + log(`service ${serviceLabel(win)} ${out.event}`); + } + mirrorService(); + } + + /** syncService that can never take the plan display down with it. */ + function syncServiceSafe(now, cue) { + try { + syncService(now, cue); + } catch (err) { + const msg = err?.message || String(err); + if (msg !== lastTimingError) { + lastTimingError = msg; + log(`service timing error (plan display unaffected): ${msg}`); + } + } + } + + /* — timers offered to the Timers module — */ + + /** How far behind the plan the service runs, ms (negative = ahead). */ + function behindMs(now) { + const history = state.live.history || {}; + let drift = 0; + let any = false; + for (const it of state.items) { + const h = history[it.id]; + if (!h) continue; + if (h.endedAt) { + drift += (h.endedAt - h.startedAt) - (it.length || 0) * 1000; + any = true; + } else if (it.length) { + const over = (now - h.startedAt) - it.length * 1000; + if (over > 0) { + drift += over; + any = true; + } + } + } + return any ? drift : 0; + } + + function serviceTone(elapsedMs, plannedMs, behind) { + const over = plannedMs ? elapsedMs - plannedMs : 0; + if (over > BEHIND_DANGER_MS || behind >= BEHIND_DANGER_MS) return 'danger'; + if (over > 0 || behind >= BEHIND_WARN_MS) return 'warn'; + return 'ok'; + } + + /** The service the countdown counts to: the idle current one, or the next one once this one ended. */ + function countdownTarget() { + if (!win) return null; + if (svc.phase === 'idle') return win; + if (svc.phase === 'stopped') return windows[win.index + 1] || null; + return null; + } + + function timersSnapshot(now) { + const plan = state.plan; + const live = state.live; + const items = state.items.filter((it) => it.itemType !== 'header'); + const idx = live.currentItemId ? items.findIndex((it) => it.id === live.currentItemId) : -1; + const item = idx >= 0 ? items[idx] : null; + const hist = item ? live.history[item.id] : null; + const label = serviceLabel(win); + const itemDetail = item ? `Item ${idx + 1} of ${items.length}` : ''; + const itemTitle = item ? item.title || item.song?.title || '(untitled)' : ''; + const plannedMs = plannedLengthMs(state.items) + || (plan?.totalLength ? plan.totalLength * 1000 : 0) + || (win && win.endsAt > win.startsAt ? win.endsAt - win.startsAt : 0) + || null; + const timer = (id, name, kind) => ({ + id, + label: name, + kind, + state: 'idle', + startedAt: null, + elapsedMs: null, + remainingMs: null, + targetMs: null, + status: null, + detail: '', + }); + + // "Service" — elapsed since the service started, against its planned length. + const service = timer('service', 'Service', 'elapsed'); + service.targetMs = plannedMs; + if (!plan || !win) { + service.status = { text: plan ? 'No service times in this plan' : 'No plan', tone: 'muted' }; + } else if (svc.phase === 'running') { + const elapsed = now - svc.startedAt; + const behind = behindMs(now); + service.state = 'running'; + service.startedAt = svc.startedAt; + service.elapsedMs = elapsed; + // The live item is the headline; without one (not following + // ProPresenter, nothing matched yet) say how far behind, else when + // the service began. + service.status = { + text: itemTitle || (behind >= BEHIND_WARN_MS ? `Behind ${fmtDur(behind)}` : `Started ${fmtTime(svc.startedAt)}`), + tone: serviceTone(elapsed, plannedMs, behind), + }; + service.detail = [label, itemDetail].filter(Boolean).join(' · '); + } else if (svc.phase === 'stopped') { + service.state = 'stopped'; + service.startedAt = svc.startedAt; + service.elapsedMs = svc.endedAt - svc.startedAt; + service.status = { text: `Ended ${fmtTime(svc.endedAt)}`, tone: 'muted' }; + service.detail = label; + } else { + const waiting = timing.startMode === 'document' && now >= win.startsAt; + service.status = waiting + ? { text: 'Waiting for the start document', tone: 'warn' } + : { text: `Starts ${fmtTime(win.startsAt)}`, tone: 'muted' }; + service.detail = label; + } + + // "Current item" — the live plan item's runtime against its planned length. + const currentItem = timer('item', 'Current item', 'elapsed'); + if (item && hist) { + const elapsed = (hist.endedAt || now) - hist.startedAt; + const planned = item.length ? item.length * 1000 : 0; + const over = planned ? elapsed - planned : 0; + currentItem.state = hist.endedAt ? 'stopped' : 'running'; + currentItem.startedAt = hist.startedAt; + currentItem.elapsedMs = elapsed; + currentItem.targetMs = planned || null; + // With ProPresenter (or its module) unreachable this is the last item + // we knew of — still counting, but muted so the wall says "stale". + const stale = !live.available || !live.ppReachable; + currentItem.status = { + text: itemTitle, + tone: stale ? 'muted' : over > planned * 0.25 + 30000 ? 'danger' : over > 0 ? 'warn' : 'ok', + }; + currentItem.detail = stale ? `${itemDetail} · ProPresenter unreachable` : itemDetail; + } else { + currentItem.status = { + text: !live.following ? 'Not following ProPresenter' : !live.available ? 'ProPresenter feed unavailable' : 'Nothing live', + tone: 'muted', + }; + } + + // "Countdown to service" — to the current service's start, then to the + // next one once this one has ended; frozen at zero while it runs; idle + // when today holds no further service. + const countdown = timer('countdown', 'Countdown to service', 'countdown'); + const target = countdownTarget(); + if (target && localYmd(new Date(target.startsAt)) === localYmd(new Date(now))) { + if (target.id !== countdownTargetId) { + countdownTargetId = target.id; + countdownSince = now; + } + const remaining = Math.max(0, target.startsAt - now); + countdown.detail = serviceLabel(target); + if (remaining > 0) { + countdown.state = 'running'; + countdown.startedAt = countdownSince; + countdown.targetMs = target.startsAt - countdownSince; + countdown.remainingMs = remaining; + } else { + countdown.state = 'stopped'; + countdown.remainingMs = 0; + countdown.status = { + text: timing.startMode === 'document' ? 'Waiting for the start document' : 'Service time', + tone: 'muted', + }; + } + } else if (win && svc.phase === 'running') { + countdown.state = 'stopped'; + countdown.remainingMs = 0; + countdown.detail = label; + } else { + countdown.status = { text: 'No upcoming service today', tone: 'muted' }; + } + + return { source: 'pco-plan', label: 'PCO Plan', updatedAt: now, timers: [service, currentItem, countdown] }; + } + + /* — the ProPresenter Now/Next feed over loopback — */ let nnRequest = null; let nnRetryTimer = null; let lastLiveName = null; - const guessedPort = guessShellPort(); + let lastView = null; // the latest Now/Next view-model (playlist items → document list) + const seenDocuments = new Map(); // document name → last seen live, ms + // The shell tells us its port (ProdDash ≥ 1.4.0). Without it, fall back to + // guessing and learning it from the first browser's Host header. + const shellPort = Number(shell?.port) || 0; + const guessedPort = shellPort || guessShellPort(); let nnPort = guessedPort; /** Learned ports that refused the loopback connection (a reverse proxy's port, say). */ const badPorts = new Set(); - /** A browser reached us on this Host — normally the port the shell really serves on. */ + /** Fallback only: a browser reached us on this Host — normally the shell's real port. */ function learnPort(hostHeader) { + if (shellPort) return; // the shell told us; nothing to learn const port = portFromHost(hostHeader); if (!port || port === nnPort || badPorts.has(port)) return; log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`); nnPort = port; - if (!follow || stopped) return; + if (!wantFeed || stopped) return; clearTimeout(nnRetryTimer); try { nnRequest?.destroy(); } catch { /* gone */ } nnRequest = null; connectNowNext(); } + /** + * Forget the item timing (a new plan, or the next service's window). Only + * a *change* of document is a cue from here on — whatever is live at this + * moment (the previous service's loop, a document left up) must not start + * or end the new service — but the highlight may pick it straight back up. + */ function resetLiveTracking() { const keep = state.live; state.live = { @@ -566,7 +951,15 @@ module.exports = { ppReachable: keep.ppReachable, activeName: keep.activeName, }; - lastLiveName = null; + lastLiveName = keep.activeName || ''; + if (follow && keep.activeName) { + const hit = matchItem(keep.activeName); + state.live.matched = Boolean(hit); + if (hit) { + state.live.currentScore = hit.score; + startItem(hit.item.id); + } + } } /** Best plan item for a live presentation name, preferring the ones ahead of us. */ @@ -592,7 +985,54 @@ module.exports = { return best; } + /** Remember a document ProPresenter put live, most recent first, for the admin selects. */ + function rememberDocument(name) { + const n = String(name || '').trim(); + if (!n) return; + seenDocuments.delete(n); + seenDocuments.set(n, Date.now()); + while (seenDocuments.size > SEEN_DOCUMENTS_MAX) seenDocuments.delete(seenDocuments.keys().next().value); + } + + /** + * Options for the admin "Start document" / "End document" selects. The + * Now/Next module exposes the live document and the active playlist's + * items — not ProPresenter's library — so the list is: what is live now, + * the active playlist, every document seen live since ProdDash started, + * and whatever is saved (kept selectable even when ProPresenter doesn't + * show it right now). + */ + function documentOptions() { + const options = [{ value: '', label: 'None' }]; + const seen = new Set(['']); + const add = (name, group) => { + const n = String(name || '').trim(); + if (!n || seen.has(n)) return; + seen.add(n); + options.push({ value: n, label: n, group }); + }; + const live = state.live; + if (live.activeName) add(live.activeName, 'Live now'); + const playlist = Array.isArray(lastView?.items) ? lastView.items : []; + const playlistGroup = lastView?.playlistName ? `Playlist: ${lastView.playlistName}` : 'Active playlist'; + for (const it of playlist) { + if (!it || it.isHidden || it.type === 'header') continue; + add(it.name, playlistGroup); + } + const recent = [...seenDocuments.entries()].sort((a, b) => b[1] - a[1]); + for (const [name] of recent) add(name, 'Seen live since ProdDash started'); + for (const saved of [timing.startDocument, timing.endDocument]) { + if (saved && !seen.has(saved)) add(saved, 'Saved (not in ProPresenter right now)'); + } + let note = ''; + if (!wantFeed) note = 'Turn on ProPresenter document triggers (or Follow ProPresenter Module), save, then reopen this list.'; + else if (!live.available) note = `ProPresenter module feed unavailable — ${live.reason || 'reconnecting'}; showing what was seen earlier and what is saved.`; + else if (!live.ppReachable) note = 'ProPresenter is unreachable — showing what was seen earlier and what is saved.'; + return { options, note }; + } + function applyLiveView(view) { + lastView = view && typeof view === 'object' ? view : null; const live = state.live; live.available = true; live.reason = ''; @@ -605,17 +1045,27 @@ module.exports = { // Cleared output between items is normal — the current item stays current. lastLiveName = ''; broadcast(); + broadcastTimers(); return; } - if (name === lastLiveName) return; - lastLiveName = name; - const hit = matchItem(name); - live.matched = Boolean(hit); - if (hit) { - live.currentScore = hit.score; - if (hit.item.id !== live.currentItemId) startItem(hit.item.id); + if (name === lastLiveName) { + broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise + return; } + lastLiveName = name; + rememberDocument(name); + if (follow) { + const hit = matchItem(name); + live.matched = Boolean(hit); + if (hit) { + live.currentScore = hit.score; + if (hit.item.id !== live.currentItemId) startItem(hit.item.id); + } + } + // The same document is also a possible service start / end cue. + syncServiceSafe(Date.now(), name); broadcast(); + broadcastTimers(); } function startItem(itemId) { @@ -626,7 +1076,6 @@ module.exports = { } live.currentItemId = itemId; live.history[itemId] = { startedAt: now, endedAt: 0 }; - if (!live.serviceStartedAt) live.serviceStartedAt = now; const item = state.items.find((it) => it.id === itemId); log(`live item → ${item?.title || itemId}`); } @@ -638,6 +1087,7 @@ module.exports = { live.reason = reason; live.ppReachable = false; broadcast(); + broadcastTimers(); } if (stopped) return; clearTimeout(nnRetryTimer); @@ -646,7 +1096,7 @@ module.exports = { } function connectNowNext() { - if (stopped || !follow) return; + if (stopped || !wantFeed) return; const port = nnPort; const req = http.get( { host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } }, @@ -684,7 +1134,7 @@ module.exports = { ); req.on('error', (err) => { if (req !== nnRequest) return; // superseded by a reconnect on the learned port - if (port !== guessedPort) { + if (!shellPort && port !== guessedPort) { // The Host header lied (reverse proxy, port forward) — that port is // not this ProdDash. Go back to the guess and never trust it again. badPorts.add(port); @@ -700,7 +1150,7 @@ module.exports = { /* — start — */ if (!pco.configured) { - state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin'; + state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin'; log(state.lastError); } else if (!serviceTypeId) { state.lastError = 'No service type selected — choose one under PCO Plan in /admin'; @@ -709,24 +1159,50 @@ module.exports = { log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`); schedulePoll(0); } - if (follow) { + log( + `service timing: pre-roll ${Math.round(timing.preRollMs / MINUTE)} min; start by ${ + timing.startMode === 'document' + ? `document "${timing.startDocument}" (failsafe ${Math.round(timing.startGraceMs / MINUTE)} min after the scheduled start)` + : 'the scheduled time' + }; end ${timing.endDocument ? `by document "${timing.endDocument}"` : 'at the next service / plan end'}` + ); + if (wantsDocumentStart && !startDocument) { + log('service start is set to "ProPresenter document" but no start document is chosen — starting by the scheduled time instead'); + } + if (wantFeed) { state.live.reason = 'Connecting to the ProPresenter module feed'; connectNowNext(); } + mirrorService(); const heartbeat = setInterval(() => { - for (const res of streams) { - try { - res.write(': ping\n\n'); - } catch { - streams.delete(res); - } - } + writeAll(streams, ': ping\n\n'); + writeAll(timerStreams, ': ping\n\n'); }, HEARTBEAT_MS); heartbeat.unref?.(); + // The clock moves services along on its own: scheduled starts, window + // flips, failsafes, the countdown reaching zero. Every step is guarded — + // timing can never take the plan display down. + const ticker = setInterval(() => { + if (stopped) return; + syncServiceSafe(Date.now(), null); + try { + broadcast(); + broadcastTimers(); + } catch (err) { + const msg = err?.message || String(err); + if (msg !== lastTimingError) { + lastTimingError = msg; + log(`broadcast error: ${msg}`); + } + } + }, 1000); + ticker.unref?.(); + current = { streams, + timerStreams, pco, getState: () => state, learnPort, @@ -736,19 +1212,24 @@ module.exports = { }, listPlans, pickPlan, + timersSnapshot, + documentOptions, }; return { stop() { stopped = true; clearInterval(heartbeat); + clearInterval(ticker); clearTimeout(pollTimer); clearTimeout(nnRetryTimer); try { nnRequest?.destroy(); } catch { /* gone */ } - for (const res of streams) { - try { res.end(); } catch { /* already gone */ } + for (const set of [streams, timerStreams]) { + for (const res of set) { + try { res.end(); } catch { /* already gone */ } + } + set.clear(); } - streams.clear(); if (current && current.streams === streams) current = null; }, health() { @@ -759,8 +1240,9 @@ module.exports = { } const name = state.serviceType.name || serviceTypeId; const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found'; - const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : ''; - return { status: 'ok', message: `Following ${name}${plan}${live}` }; + const service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : ''; + const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : ''; + return { status: 'ok', message: `Following ${name}${plan}${service}${live}` }; }, }; }, @@ -770,6 +1252,15 @@ module.exports = { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); }; + const sseHead = (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'); + }; return { 'GET /state': (req, res) => { @@ -779,13 +1270,7 @@ module.exports = { 'GET /stream': (req, res) => { current?.learnPort(req.headers.host); - 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'); + sseHead(res); const state = current ? current.getState() : clearedState(); res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`); if (!current) return void res.end(); @@ -803,6 +1288,30 @@ module.exports = { ); }, + /* — timers for the Timers module (module guide: "Offering timers") — */ + + 'GET /timers': (req, res) => { + const now = Date.now(); + json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now)); + }, + + 'GET /timers/stream': (req, res) => { + sseHead(res); + const now = Date.now(); + res.write(`event: timers\ndata: ${JSON.stringify(current ? current.timersSnapshot(now) : emptyTimers(now))}\n\n`); + if (!current) return void res.end(); + const { timerStreams } = current; + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + timerStreams.add(res); + req.on('close', () => timerStreams.delete(res)); + }, + + /** Options for the admin "Start document" / "End document" selects. */ + 'GET /documents': (req, res) => { + if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' }); + json(res, 200, current.documentOptions()); + }, + /** * Options for the admin page's "Service type" select: every service * type, grouped by its Planning Center folder path — so the folders diff --git a/modules/pco-plan/service-timing.js b/modules/pco-plan/service-timing.js new file mode 100644 index 0000000..d180e9e --- /dev/null +++ b/modules/pco-plan/service-timing.js @@ -0,0 +1,238 @@ +'use strict'; + +/** + * Service timing for the PCO Plan module — pure functions, no I/O, no + * timers. server.js feeds them the plan's service times, the documents + * ProPresenter puts live and the clock; service-timing.test.js calls them + * directly. Two questions are answered here: + * + * 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService + * 2. When does that service count as started, and as ended? → advance + * + * ── Service windows ──────────────────────────────────────────────────── + * + * A plan can have several services (plan_times of type "service": 9:00 and + * 11:00, say). Only ONE owns the service-elapsed clock, the current-item + * timing and the countdown at any moment, and moving into the next + * service's window resets all three. Each service's window runs from the + * end of the previous one to a boundary the next service's pre-roll sets: + * + * boundary(i → i+1) = min( start[i+1], + * max( start[i+1] − preRoll, endOf[i] ) ) + * + * endOf[i] = the moment service i ended (its end cue fired), else its + * scheduled end (plan_time ends_at, else start + the planned + * length of the "during" items, else 90 min). + * + * In words: the next service takes over `preRoll` before its scheduled + * start (30 min by default; admin "Pre-roll"), but never before the current + * one has ended or was due to end, and never later than its own start. A + * service still running at the boundary is stopped there — that is the + * "next service's start" failsafe the end cue has. The first window opens at + * the beginning of time (before 9:00 the day belongs to the 9:00 service: + * countdown running, service idle); the last window never closes — the last + * service stays "stopped" until the plan itself changes (the next day's plan + * takes over through the plan picker). The last service also has a failsafe + * end: its scheduled end + LAST_SERVICE_OVERRUN_MS. + * + * ── Start and end of a service ───────────────────────────────────────── + * + * phase: idle → running → stopped, never backwards within one window. + * + * start, schedule mode (default) at the scheduled start; startedAt = it. + * start, document mode when the start document goes live + * (edge: it has to *become* live, from + * pre-roll before the scheduled start + * onwards — a rehearsal an hour earlier + * that shows the same document is not the + * service). Failsafe: if the document has + * not gone live by start + grace (admin + * "Start failsafe", 10 min default), the + * service starts anyway, counting from + * the scheduled start — the best estimate + * of when it really began. + * end, document when the end document goes live while + * the service is running (an end document + * live before the start — the previous + * service's loop — is ignored). Failsafe + * either way: the window boundary (see + * above), or, for the last service, its + * scheduled end + LAST_SERVICE_OVERRUN_MS. + * + * Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see + * matching.js for the tolerance), so "Post-Service Loop" still ends the + * service when ProPresenter calls it "Post Service Loop". + */ + +const { similarity, TRIGGER_THRESHOLD } = require('./matching'); + +const MINUTE = 60000; +/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */ +const DEFAULT_PRE_ROLL_MS = 30 * MINUTE; +/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */ +const DEFAULT_START_GRACE_MS = 10 * MINUTE; +/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */ +const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE; +/** A service with no end time and no planned item lengths is assumed this long. */ +const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE; + +/** The plan times that are services (every timed one when none is typed "service"), earliest first. */ +function serviceTimes(planTimes) { + const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : []; + const services = list.filter((t) => t.timeType === 'service'); + return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt); +} + +/** + * The window each service owns (see the file comment). + * + * @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs) + * @param {object} [opts] + * @param {number} [opts.preRollMs] how early the next service may take over (default 30 min) + * @param {number} [opts.plannedMs] planned length of the service — for services without ends_at + * @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue + * @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>} + */ +function serviceWindows(planTimes, opts = {}) { + const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS; + const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0; + const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {}; + const services = serviceTimes(planTimes); + const windows = services.map((svc, i) => { + const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS); + const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd; + const next = services[i + 1]; + const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity; + return { + id: String(svc.id), + name: String(svc.name || ''), + index: i, + count: services.length, + startsAt: svc.startsAt, + endsAt: scheduledEnd, + windowFrom: -Infinity, // filled in below + windowUntil, + cueFrom: svc.startsAt - preRoll, // a start document counts from here on + failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS, + isLast: !next, + }; + }); + for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil; + return windows; +} + +/** The window that holds `now` — null only when there are no services. */ +function selectService(windows, now) { + if (!Array.isArray(windows) || !windows.length) return null; + return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1]; +} + +/** Timing state for a service that has just come into its window. */ +function freshService(window) { + return { + id: window ? String(window.id) : '', + phase: window ? 'idle' : 'none', + startedAt: 0, + endedAt: 0, + startTrigger: '', // 'schedule' | 'document' | 'failsafe' + endTrigger: '', // 'document' | 'next-service' | 'plan-end' + cue: '', // the document that started / ended it + }; +} + +function fmtTime(ms) { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); +} + +/** + * Move a service's timing on by a clock tick or by a document going live. + * Pure: returns a new state and a one-line description of what happened + * (for the log), or `changed: false`. + * + * @param {object} svc state from freshService(window) / a previous advance() + * @param {object} window from serviceWindows() + * @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs } + * @param {number} now ms epoch + * @param {string} [cue] the document that just went live (omit on a plain tick) + * @returns {{ svc: object, changed: boolean, event: string }} + */ +function advance(svc, window, opts, now, cue) { + const o = opts || {}; + const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS; + const cueName = String(cue || '').trim(); + const same = { svc, changed: false, event: '' }; + if (!svc || !window || svc.id !== String(window.id)) return same; + const at = fmtTime(window.startsAt); + + if (svc.phase === 'idle') { + const documentMode = o.startMode === 'document' && o.startDocument; + if (documentMode) { + if (cueName && now >= window.cueFrom) { + const score = similarity(cueName, o.startDocument); + if (score >= TRIGGER_THRESHOLD) { + return { + svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName }, + changed: true, + event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`, + }; + } + } + if (now >= window.startsAt + grace) { + return { + svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' }, + changed: true, + event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`, + }; + } + return same; + } + if (now >= window.startsAt) { + return { + svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' }, + changed: true, + event: `started — scheduled ${at}`, + }; + } + return same; + } + + if (svc.phase === 'running') { + if (o.endDocument && cueName) { + const score = similarity(cueName, o.endDocument); + if (score >= TRIGGER_THRESHOLD) { + return { + svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName }, + changed: true, + event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`, + }; + } + } + if (now >= window.failsafeEndAt) { + const endedAt = Math.min(now, window.failsafeEndAt); + return { + svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' }, + changed: true, + event: window.isLast + ? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}` + : `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`, + }; + } + return same; + } + + return same; +} + +module.exports = { + MINUTE, + DEFAULT_PRE_ROLL_MS, + DEFAULT_START_GRACE_MS, + LAST_SERVICE_OVERRUN_MS, + DEFAULT_SERVICE_LENGTH_MS, + serviceTimes, + serviceWindows, + selectService, + freshService, + advance, + fmtTime, +}; diff --git a/modules/pco-plan/service-timing.test.js b/modules/pco-plan/service-timing.test.js new file mode 100644 index 0000000..8ee3479 --- /dev/null +++ b/modules/pco-plan/service-timing.test.js @@ -0,0 +1,229 @@ +'use strict'; + +/** + * Unit tests for the PCO Plan module's pure service-timing logic. + * + * node modules/pco-plan/service-timing.test.js + * + * Plain Node, no dependencies; exits non-zero on the first failure. Times are + * built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min + * pre-roll) so the expectations read like the day would. + */ + +const assert = require('assert'); +const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching'); +const { + MINUTE, + LAST_SERVICE_OVERRUN_MS, + serviceWindows, + selectService, + freshService, + advance, +} = require('./service-timing'); + +const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight +const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE; +const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + +const PLAN_TIMES = [ + { id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) }, + { id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) }, + { id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) }, +]; +const PRE_ROLL = 30 * MINUTE; +const OPTS = { preRollMs: PRE_ROLL }; + +let passed = 0; +function test(name, fn) { + try { + fn(); + passed += 1; + console.log(` ok ${name}`); + } catch (err) { + console.log(` FAIL ${name}\n ${err.message}`); + process.exitCode = 1; + throw err; + } +} + +console.log('service windows'); + +test('rehearsal is not a service; windows are contiguous and ordered', () => { + const w = serviceWindows(PLAN_TIMES, OPTS); + assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']); + assert.strictEqual(w[0].windowFrom, -Infinity); + assert.strictEqual(w[0].windowUntil, w[1].windowFrom); + assert.strictEqual(w[1].windowUntil, Infinity); + assert.strictEqual(w[0].count, 2); +}); + +test('the boundary is 30 min before the next start once the first was due to end', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + // 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30 + assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30))); + assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary'); +}); + +test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => { + const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE }); + assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)'); + const [tight] = serviceWindows( + [ + { id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) }, + { id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) }, + ], + OPTS + ); + assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start'); +}); + +test('an early actual end lets the next window open at pre-roll', () => { + const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } }); + assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05'); + const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } }); + assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30'); +}); + +test('a service without ends_at gets the planned length, else 90 min', () => { + const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE }); + assert.strictEqual(hm(w.endsAt), hm(at(10, 10))); + const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {}); + assert.strictEqual(hm(w90.endsAt), hm(at(10, 30))); + assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS); +}); + +test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => { + const w = serviceWindows(PLAN_TIMES, OPTS); + assert.strictEqual(selectService(w, at(8, 50)).id, 's1'); + assert.strictEqual(selectService(w, at(9, 30)).id, 's1'); + assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary'); + assert.strictEqual(selectService(w, at(10, 55)).id, 's2'); + assert.strictEqual(selectService(w, at(11, 5)).id, 's2'); + assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service'); + assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service'); + assert.strictEqual(selectService([], at(9, 0)), null); +}); + +console.log('service start'); + +const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE }; +const DOCUMENT = { + startMode: 'document', + startDocument: 'Welcome & Announcements', + endDocument: 'Post Service Loop', + startGraceMs: 10 * MINUTE, +}; + +test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + let s = freshService(w1); + assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode'); + assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false); + const out = advance(s, w1, SCHEDULE, at(9, 0, 30)); + assert.strictEqual(out.changed, true); + s = out.svc; + assert.strictEqual(s.phase, 'running'); + assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed'); + assert.strictEqual(s.startTrigger, 'schedule'); +}); + +test('document mode: the start document going live starts the service at that moment', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = freshService(w1); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it'); + const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]'); + assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts'); + assert.strictEqual(out.svc.phase, 'running'); + assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start'); + assert.strictEqual(out.svc.startTrigger, 'document'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing'); +}); + +test('document mode: the start document is only a cue from pre-roll before the start', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = freshService(w1); + assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts'); +}); + +test('document mode: failsafe at start + grace counts from the scheduled start', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = freshService(w1); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false); + const out = advance(s, w1, DOCUMENT, at(9, 10)); + assert.strictEqual(out.changed, true); + assert.strictEqual(out.svc.phase, 'running'); + assert.strictEqual(out.svc.startedAt, at(9, 0)); + assert.strictEqual(out.svc.startTrigger, 'failsafe'); + assert.match(out.event, /failsafe/); +}); + +test('document mode: a cue and the failsafe in the same tick — the cue wins', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements'); + assert.strictEqual(out.svc.startTrigger, 'document'); + assert.strictEqual(out.svc.startedAt, at(9, 12)); +}); + +console.log('service end'); + +test('the end document freezes a running service; a second start document is ignored', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc; + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)'); + const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop'); + assert.strictEqual(out.changed, true, 'a hyphenated end document still counts'); + s = out.svc; + assert.strictEqual(s.phase, 'stopped'); + assert.strictEqual(s.endedAt, at(10, 12)); + assert.strictEqual(s.endTrigger, 'document'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false); +}); + +test('failsafe: a service still running when the next window opens stops at the boundary', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc; + assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed'); + const out = advance(s, w1, SCHEDULE, at(10, 31)); + assert.strictEqual(out.changed, true); + assert.strictEqual(out.svc.phase, 'stopped'); + assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick'); + assert.strictEqual(out.svc.endTrigger, 'next-service'); +}); + +test('failsafe: the last service stops an hour after its scheduled end', () => { + const [, w2] = serviceWindows(PLAN_TIMES, OPTS); + const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc; + assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false); + const out = advance(s, w2, SCHEDULE, at(13, 15)); + assert.strictEqual(out.changed, true); + assert.strictEqual(out.svc.phase, 'stopped'); + assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15))); + assert.strictEqual(out.svc.endTrigger, 'plan-end'); +}); + +test('the end document does not stop a service in the wrong window', () => { + const w = serviceWindows(PLAN_TIMES, OPTS); + const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc; + assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together'); +}); + +console.log('matching'); + +test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => { + assert.strictEqual(MATCH_THRESHOLD, 0.6); + assert.strictEqual(TRIGGER_THRESHOLD, 0.75); + assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1); + assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1); + assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85); + assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001); + assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false); + assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true); + assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true); + assert.strictEqual(isCue('Anything', ''), false); +}); + +console.log(`\n${passed} tests passed`); diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 94dd235..4050e82 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -1,15 +1,60 @@ -/* ProPresenter Timers — client part. +/* Timers — client part. - The server part does all the ProPresenter talking and streams one merged - state object here over SSE. Each tile renders whichever timers (and/or the - LTC timecode) it has enabled, as auto-sized cards in a responsive grid. - Which items a tile shows is per-tile state, picked in a "Timers ▾" header - menu and persisted with the layout — the timer list is dynamic, so a - static instanceSchema field can't list them (same approach as the ProdCom - module's channels menu). Timers this tile has never seen default to - enabled: only explicit un-ticks are stored. */ + The server part follows every source (ProPresenter, the LTC listener, the + time of day, other modules offering timers) and streams one merged state + object here over SSE: sources, each with its health and its timers as the + module guide's standard timer objects. This tile renders every timer from + every source it has switched on, grouped under the source's heading, as + auto-sized cards — label, the big time, a status line, a detail line. -const LTC_KEY = '__ltc__'; + The time is derived LOCALLY. A snapshot says what a timer's value was at + its source's updatedAt; a running card counts on from there with this + browser's clock, offset-corrected against the server's, so digits tick + smoothly between snapshots and a slow provider never needs a fast poll. + The LTC card counts individual frames the same way (see the SMPTE math). + + Per-tile choices: the gear popover's static switches (whole sources, + headings, status lines, the clock's format, the state colours) and the + "Timers ▾" header menu, which hides any single timer — keyed `source:id` + and saved with the layout via saveInstanceSettings. The header can be + hidden by the user, so the static switches stay the primary controls. + + Feedback — the state colours, and the thresholds behind them: + running elapsed, no planned length running + running elapsed with a planned length warning within + WARN_FLOOR_MS or WARN_FRACTION of the length (whichever is larger, + but never more than half the length — a 20 s countdown warns + under 10 s, a 25 min one under 2:30) of the end; overrun past it + running countdown warning with that much + left; overrun at zero and below + stopped countdown at zero ("complete") warning + paused / idle / stopped idle (muted) + the wall clock plain text colour + LTC running = running colour, + stopped = idle, no signal = idle digits + blinking warning line + The colours are the tile's Running / Warning / Overrun / Idle settings, + set as custom properties on the tile root so they win over the dashboard + theme; left at their defaults they follow the theme's variables. */ + +/** Which instance switch governs a source kind. */ +const SOURCE_SWITCH = { propresenter: 'showProPresenter', ltc: 'showLtc', clock: 'showClock', module: 'showModules' }; + +/** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */ +const COLOR_SETTINGS = [ + ['runningColor', '--tm-ok', '#2ee59a'], + ['warningColor', '--tm-warn', '#f0b429'], + ['overrunColor', '--tm-danger', '#f4433c'], + ['idleColor', '--tm-muted', '#8b98a5'], +]; + +/** Warning thresholds (see the header comment). */ +const WARN_FLOOR_MS = 60000; +const WARN_FRACTION = 0.1; + +/** How often running digits are repainted. Whole seconds, so 5×/s is plenty. */ +const PAINT_MS = 200; + +const LTC_KEY = 'ltc:ltc'; /* ── SMPTE timecode math (module-scope, exported for tests) ───────── The server anchors us ~4×/s with { time, fps, df, ageMs }; the card @@ -50,23 +95,106 @@ export function framesToTc(n, nominal, df) { return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`; } +/* ── timer math (module-scope, exported for tests) ──────────────────── */ + +/** + * A timer's value at server time `now`: remaining ms for a countdown, + * elapsed ms otherwise, or null when the snapshot can't say. The values as + * of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried + * forward while running; `startedAt` is the fallback when they are absent. + */ +export function valueAt(timer, sourceUpdatedAt, now) { + const running = timer.state === 'running'; + const at = Number(sourceUpdatedAt) || now; + const dt = running ? Math.max(0, now - at) : 0; + const has = (v) => v !== null && v !== undefined && Number.isFinite(v); + if (timer.kind === 'countdown') { + if (has(timer.remainingMs)) return timer.remainingMs - dt; + if (has(timer.targetMs) && has(timer.elapsedMs)) return timer.targetMs - timer.elapsedMs - dt; + if (running && has(timer.targetMs) && has(timer.startedAt)) return timer.targetMs - (now - timer.startedAt); + return null; + } + if (has(timer.elapsedMs)) return timer.elapsedMs + dt; + if (running && has(timer.startedAt)) return now - timer.startedAt; + return null; +} + +/** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */ +export function fmtDuration(ms) { + const sign = ms < 0 ? '-' : ''; + const s = Math.floor(Math.abs(ms) / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (v) => String(v).padStart(2, '0'); + return h ? `${sign}${h}:${pad(m)}:${pad(sec)}` : `${sign}${m}:${pad(sec)}`; +} + +function warnWindow(targetMs) { + if (!targetMs) return WARN_FLOOR_MS; + return Math.min(Math.max(WARN_FLOOR_MS, targetMs * WARN_FRACTION), targetMs / 2); +} + +/** The state colour for a (non-clock) timer given its current value. */ +export function toneFor(timer, value) { + if (timer.state === 'running') { + if (value === null) return 'ok'; + if (timer.kind === 'countdown') { + if (value <= 0) return 'danger'; + if (value <= warnWindow(timer.targetMs)) return 'warn'; + return 'ok'; + } + if (timer.targetMs !== null && timer.targetMs !== undefined && Number.isFinite(timer.targetMs)) { + if (value >= timer.targetMs) return 'danger'; + if (value >= timer.targetMs - warnWindow(timer.targetMs)) return 'warn'; + } + return 'ok'; + } + if (timer.state === 'stopped' && timer.kind === 'countdown' && value !== null && value <= 0) return 'warn'; + return 'muted'; +} + +function defaultStatus(timer, value, tone) { + if (timer.state === 'running') return tone === 'danger' ? 'Overrun' : 'Running'; + if (timer.state === 'paused') return 'Paused'; + if (timer.state === 'idle') return 'Ready'; + return timer.kind === 'countdown' && value !== null && value <= 0 ? 'Complete' : 'Stopped'; +} + +/* ── the tile ─────────────────────────────────────────────────────────── */ + export default function create({ root, moduleApi }) { let state = null; let feedOffline = false; let stream = null; let resizeObserver = null; - let hiddenItems = new Set(moduleApi.instanceSettings.hiddenItems || []); + let paintTimer = null; + /** Date.now() minus the server's clock, refined from every snapshot. */ + let clockOffset = NaN; + let needFit = true; - // Which picker entry this tile was added as (the module's tiles() list): - // '' or 'all' = every item, selectable via the Timers ▾ menu; - // 'timer:' or 'ltc' = exactly one card, no menu. + const settings = () => moduleApi.instanceSettings; + const keyOf = (source, timer) => `${source.id}:${timer.id}`; + + /* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier + ProPresenter-only tile stored `hiddenItems` (uuids, and __ltc__ for the + timecode) — read those too, and write them back in the new key. */ + const hidden = new Set(Array.isArray(settings().hiddenTimers) ? settings().hiddenTimers.map(String) : []); + for (const old of Array.isArray(settings().hiddenItems) ? settings().hiddenItems : []) { + hidden.add(old === '__ltc__' ? LTC_KEY : `propresenter:${old}`); + } + + /* Which picker entry this tile was added as (the module's tiles() list): + '' or 'all' = every timer, selectable via the Timers ▾ menu; + 'solo::' = exactly one card, no menu, no headings. The + earlier entries 'timer:' and 'ltc' map onto the new keys. */ const variant = String(moduleApi.variant || ''); - const soloKey = variant === 'ltc' ? LTC_KEY - : variant.startsWith('timer:') ? variant.slice('timer:'.length) - : ''; - /** Last name seen for a solo timer, so its card stays labeled if the - timer is later deleted in ProPresenter. */ - let lastSoloName = ''; + const solo = variant === 'ltc' ? LTC_KEY + : variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}` + : variant.startsWith('solo:') ? variant.slice('solo:'.length) + : ''; + /** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */ + let lastSoloLabel = ''; /* ── DOM skeleton ───────────────────────────────────────────────── */ @@ -74,45 +202,72 @@ export default function create({ root, moduleApi }) {
-
+
`; const wrap = root.querySelector('.tm-wrap'); const bannerEl = root.querySelector('.tm-banner'); const hintEl = root.querySelector('.tm-hint'); - const gridEl = root.querySelector('.tm-grid'); + const groupsEl = root.querySelector('.tm-groups'); - /** key (uuid or LTC_KEY) → { el, nameEl, timeEl, stateEl } */ + /** key → { key, el, nameEl, timeEl, statusEl, detailEl, item, chars, cls } */ const cards = new Map(); + /** group id (source id) → { el, headEl, titleEl, noteEl, emptyEl, gridEl, count } */ + const groupEls = new Map(); - /* ── title-bar menu: pick which timers / LTC this tile shows ────── */ + /** The tile's colour settings → custom properties (only when changed from the theme-following default). */ + function applyColors() { + const s = settings(); + for (const [key, prop, fallback] of COLOR_SETTINGS) { + const v = String(s[key] || '').trim().toLowerCase(); + if (/^#[0-9a-f]{6}$/.test(v) && v !== fallback) wrap.style.setProperty(prop, v); + else wrap.style.removeProperty(prop); + } + } + + /* ── server clock ───────────────────────────────────────────────── */ + + function noteServerClock(serverNow) { + if (!Number.isFinite(serverNow)) return; + const sample = Date.now() - serverNow; + // A step (first sample, a resumed laptop) snaps; jitter is smoothed. + if (!Number.isFinite(clockOffset) || Math.abs(sample - clockOffset) > 1000) clockOffset = sample; + else clockOffset += (sample - clockOffset) * 0.2; + } + + const serverNow = () => Date.now() - (Number.isFinite(clockOffset) ? clockOffset : 0); + + /* ── title-bar menu: hide / show individual timers ──────────────── */ let menuEl = null; // the open menu's element (filled on each open) - const itemsMenu = soloKey ? null : moduleApi.header.addMenu({ + const itemsMenu = solo ? null : moduleApi.header.addMenu({ label: 'Timers ▾', title: 'Choose which timers this tile shows', build(menu) { + // the shell hands over an emptied element on every open menu.classList.add('tm-menu'); menuEl = menu; - rebuildMenu(); + rebuildMenu(true); }, }); function menuRows() { - const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name })); - // No LTC row while the listener is off in Admin — the item shouldn't - // even be offerable. - if (state?.ltcEnabled) rows.push({ key: LTC_KEY, name: 'LTC timecode' }); + const rows = []; + for (const source of state?.sources || []) { + for (const t of source.timers) rows.push({ key: keyOf(source, t), text: `${source.label} › ${t.label}` }); + } return rows; } function persistHidden() { - moduleApi.saveInstanceSettings({ hiddenItems: [...hiddenItems] }); + // `hiddenItems` was the old key: clearing it keeps a layout from + // re-hiding something the user has since re-enabled here. + moduleApi.saveInstanceSettings({ hiddenTimers: [...hidden], hiddenItems: undefined }); } - function toggleItem(key) { - if (hiddenItems.has(key)) hiddenItems.delete(key); - else hiddenItems.add(key); + function toggleTimer(key) { + if (hidden.has(key)) hidden.delete(key); + else hidden.add(key); persistHidden(); rebuildMenu(); render(); @@ -121,129 +276,179 @@ export default function create({ root, moduleApi }) { function updateMenuLabel() { if (!itemsMenu) return; const rows = menuRows(); - const visible = rows.filter((r) => !hiddenItems.has(r.key)).length; - itemsMenu.setLabel(visible === rows.length - ? 'Timers ▾' - : `Timers (${visible}/${rows.length}) ▾`); + const visible = rows.filter((r) => !hidden.has(r.key)).length; + itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`); } - function rebuildMenu() { + let menuSignature = ''; + function rebuildMenu(force = false) { updateMenuLabel(); if (!menuEl) return; // menu not opened yet — the label is enough - menuEl.innerHTML = ''; const rows = menuRows(); - if (rows.length === 1) { + // Snapshots arrive several times a second; rebuilding the open menu's + // rows each time would replace them under the user's finger. Only a + // changed row set (or a toggled switch) is worth touching the DOM for. + const signature = JSON.stringify(rows.map((r) => [r.key, r.text, hidden.has(r.key)])); + if (!force && signature === menuSignature && menuEl.childElementCount) return; + menuSignature = signature; + menuEl.innerHTML = ''; + if (!rows.length) { const note = document.createElement('div'); note.className = 'tm-menu-note'; - note.textContent = 'No timers discovered yet'; + note.textContent = 'No timers known yet'; menuEl.appendChild(note); } for (const row of rows) { - const on = !hiddenItems.has(row.key); + const on = !hidden.has(row.key); const item = document.createElement('button'); item.className = 'tm-menu-item' + (on ? ' on' : ''); item.setAttribute('role', 'menuitemcheckbox'); item.setAttribute('aria-checked', String(on)); - const check = document.createElement('span'); - check.className = 'check'; - check.textContent = '✓'; const name = document.createElement('span'); name.className = 'name'; - name.textContent = row.name; - item.append(check, name); + name.textContent = row.text; + const sw = document.createElement('span'); + sw.className = 'tm-switch'; + item.append(name, sw); item.addEventListener('click', (e) => { // keep the menu open: the rebuild detaches this item, which would - // make the outside-click handler think we clicked outside + // make the shell's outside-click handler think we clicked outside e.stopPropagation(); - toggleItem(row.key); + toggleTimer(row.key); }); menuEl.appendChild(item); } } - /* ── state → cards ──────────────────────────────────────────────── */ + /* ── state → what this tile shows ───────────────────────────────── */ - /** Map the API's state string onto our four looks. The spec's enum is - stopped|running|complete|overrunning|overran, but its own response - example says "overrun" — match the substring. */ - function classify(apiState) { - const s = String(apiState || '').toLowerCase(); - if (s.includes('overr')) return 'overrun'; - if (s === 'running') return 'running'; - if (s === 'complete') return 'complete'; - return 'stopped'; + function sourceShown(source) { + const key = SOURCE_SWITCH[source.kind] || 'showModules'; + return settings()[key] !== false; } - const STATE_TEXT = { running: 'Running', stopped: 'Stopped', complete: 'Complete', overrun: 'Overrun' }; - - /** Show the value the way ProPresenter reports it (sign included); - only the ".00" hundredths some builds append are dropped for fit. */ - function displayTime(raw) { - const s = String(raw || '').replace(/\.\d+$/, ''); - return s || '—'; - } - - function visibleItems() { - const ltcState = state?.ltc || { supported: null, time: '', receiving: false }; - if (soloKey === LTC_KEY) return [{ key: LTC_KEY, ltc: ltcState }]; - if (soloKey) { - const timer = (state?.timers || []).find((t) => t.uuid === soloKey); - if (timer) { - lastSoloName = timer.name; - return [{ key: soloKey, timer }]; + /** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ + function visibleGroups() { + if (!state) return []; + if (solo) { + for (const source of state.sources) { + for (const timer of source.timers) { + if (keyOf(source, timer) !== solo) continue; + lastSoloLabel = timer.label; + return [{ id: source.id, source, items: [{ key: solo, source, timer }] }]; + } } - // The timer this tile was added for isn't in ProPresenter's list (yet, - // or any more) — keep a clearly-labeled card up rather than erroring. - return [{ key: soloKey, missing: true }]; + // The timer this tile was added for isn't offered (yet, or any more) — + // keep a clearly-labeled card up rather than erroring. + const sourceId = solo.slice(0, solo.indexOf(':')); + const source = state.sources.find((s) => s.id === sourceId) || null; + return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }]; } - const items = []; - for (const t of state?.timers || []) { - if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t }); + const groups = []; + for (const source of state.sources) { + if (!sourceShown(source)) continue; + const items = []; + for (const timer of source.timers) { + const key = keyOf(source, timer); + if (!hidden.has(key)) items.push({ key, source, timer }); + } + // Every timer of a live source hidden on purpose: nothing to say. + if (!items.length && source.timers.length) continue; + groups.push({ id: source.id, source, items }); } - if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState }); - return items; + return groups; } + /* ── cards ──────────────────────────────────────────────────────── */ + function makeCard(item) { const el = document.createElement('div'); - el.className = 'tm-card' + (item.ltc ? ' tm-card-ltc' : ''); + el.className = 'tm-card'; const nameEl = document.createElement('div'); nameEl.className = 'tm-name'; + const labelEl = document.createElement('span'); + labelEl.className = 'tm-label'; + nameEl.appendChild(labelEl); const timeEl = document.createElement('div'); timeEl.className = 'tm-time'; - const stateEl = document.createElement('div'); - stateEl.className = 'tm-state'; - if (item.ltc) { - const badge = document.createElement('span'); - badge.className = 'tm-ltc-badge'; - badge.textContent = 'LTC'; - nameEl.append(badge, document.createTextNode('Timecode')); + const statusEl = document.createElement('div'); + statusEl.className = 'tm-status'; + const detailEl = document.createElement('div'); + detailEl.className = 'tm-detail'; + el.append(nameEl, timeEl, statusEl, detailEl); + return { key: item.key, el, nameEl, labelEl, timeEl, statusEl, detailEl, item, chars: 5, cls: '' }; + } + + function setText(el, text) { + if (el.textContent !== text) el.textContent = text; + } + + function setCardClass(card, cls) { + if (card.cls === cls) return; + card.cls = cls; + card.el.className = cls; + } + + /** The parts of a card that only change with the snapshot (label, kind). */ + function updateCard(card, item) { + card.item = item; + const isLtc = item.source?.kind === 'ltc'; + const badge = card.nameEl.querySelector('.tm-ltc-badge'); + if (isLtc && !badge) { + const b = document.createElement('span'); + b.className = 'tm-ltc-badge'; + b.textContent = 'LTC'; + card.nameEl.prepend(b); + } else if (!isLtc && badge) { + badge.remove(); } - el.append(nameEl, timeEl, stateEl); - return { el, nameEl, timeEl, stateEl }; + setText(card.labelEl, item.missing ? (lastSoloLabel || 'Timer') : item.timer.label); } - /** A solo tile whose timer vanished from ProPresenter's list. While the - server is unreachable the list is merely unknown — the banner already - says so, so only claim "not in ProPresenter" when actually connected. */ function updateMissingCard(card) { - card.nameEl.textContent = lastSoloName || 'Timer'; - card.timeEl.textContent = '—'; - if (state?.reachable) { - card.stateEl.textContent = 'Not in ProPresenter'; - card.el.className = 'tm-card is-missing'; - } else { - card.stateEl.textContent = '—'; - card.el.className = 'tm-card is-stopped'; - } + const source = card.item.source; + setText(card.timeEl, '—'); + card.chars = 5; + let note; + if (!source) note = 'Source not available — check Admin'; + else if (source.status === 'ok') note = `Not offered by ${source.label}`; + else note = '—'; + setText(card.statusEl, note); + card.statusEl.hidden = false; + card.statusEl.className = 'tm-status is-note'; + card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted'; + card.detailEl.hidden = true; + setCardClass(card, 'tm-card is-muted is-missing'); } - function updateTimerCard(card, timer) { - card.nameEl.textContent = timer.name; - card.timeEl.textContent = displayTime(timer.time); - const kind = classify(timer.state); - card.stateEl.textContent = timer.state ? (STATE_TEXT[kind] || timer.state) : '—'; - card.el.className = `tm-card is-${kind}`; + /* ── clocks ─────────────────────────────────────────────────────── */ + + function clockOptions() { + const s = settings(); + const h12 = s.clockFormat === '12h'; + return { + hour12: h12, + hour: h12 ? 'numeric' : '2-digit', + minute: '2-digit', + ...(s.clockSeconds !== false ? { second: '2-digit' } : {}), + }; + } + + function wallClockText() { + return new Date().toLocaleTimeString([], clockOptions()); + } + + function dateText() { + return new Date().toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); + } + + /** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */ + function providerClockText(timer, source, now) { + const raw = String(timer.status?.text || ''); + const d = new Date(raw); + if (!raw || Number.isNaN(d.getTime())) return raw || '—'; + const dt = timer.state === 'running' ? Math.max(0, now - (Number(source.updatedAt) || now)) : 0; + return new Date(d.getTime() + dt).toLocaleTimeString([], clockOptions()); } /* ── real-time LTC: count every frame between server anchors ─────── */ @@ -260,10 +465,15 @@ export default function create({ root, moduleApi }) { frames the next update would visibly rewind. */ const ANCHOR_FRESH_MS = 350; + function ltcTimer() { + const source = state?.sources.find((s) => s.kind === 'ltc'); + return source ? source.timers.find((t) => t.id === 'ltc') || null : null; + } + function updateLtcAnchor() { - const l = state?.ltc; + const l = ltcTimer()?.ltc; // The listener is extrapolatable — it knows the frame rate. - const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener'; + const live = l && l.status === 'running' && l.fps > 0; const tc = live ? parseTc(l.time) : null; if (!tc) { ltcAnchor = null; @@ -287,9 +497,9 @@ export default function create({ root, moduleApi }) { } /** Paint the frame the anchor implies for right now. Called both by the - rAF loop (smooth, per-frame, on visible tabs) and by render() at the - 4×/s anchor rate — so a throttled/backgrounded tab, where rAF is - paused, still advances at the anchor rate instead of freezing. */ + rAF loop (smooth, per-frame, on visible tabs) and by paint() at the + anchor rate — so a throttled/backgrounded tab, where rAF is paused, + still advances instead of freezing. */ function paintLtcFrame() { const a = ltcAnchor; const card = cards.get(LTC_KEY); @@ -314,48 +524,83 @@ export default function create({ root, moduleApi }) { ltcRaf = requestAnimationFrame(ltcTick); } - function updateLtcCard(card, ltc) { - let mode; - // Only the audio sources know the frame rate (decoded from the LTC - // bits); a set drop-frame flag implies 29.97 whatever fps rounds to. - const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : ''; - if (ltc.supported === false) { - mode = 'unavailable'; - card.timeEl.textContent = '—'; - // The server says why when it knows (listener disabled, no device - // selected, capture failed, starting up…) — surface that. - card.stateEl.textContent = ltc.note || 'LTC not available'; - } else if (ltc.receiving) { - mode = 'receiving'; - // With an anchor active, paint the frame it implies for now (this - // keeps a throttled tab advancing at the 4×/s anchor rate); the rAF - // loop refines it to every frame when the tab is visible. Without an - // anchor (rate unknown), fall back to the raw anchor timecode. - if (ltcAnchor) paintLtcFrame(); - else card.timeEl.textContent = displayTime(ltc.time); - card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running'; - } else if (ltc.status === 'stopped' || ltc.time) { - // LTC ceased but we know where it stopped — hold the last frame. - mode = 'stopped'; - card.timeEl.textContent = displayTime(ltc.time); - card.stateEl.textContent = 'Stopped'; - } else { - mode = 'nosignal'; - card.timeEl.textContent = '—'; - card.stateEl.textContent = 'No signal'; + /* ── painting the numbers (every PAINT_MS, and on every snapshot) ── */ + + function paint() { + if (!state) return; + const now = serverNow(); + const showStatus = settings().showStatus !== false; + let charsChanged = false; + for (const card of cards.values()) { + const { item } = card; + if (item.missing) { + updateMissingCard(card); + continue; + } + const { source, timer } = item; + let text; + let tone; + let statusText = ''; + let statusTone = ''; + let detailText = ''; + let extra = ''; + if (timer.kind === 'clock') { + if (source.kind === 'ltc') { + const l = timer.ltc || {}; + text = l.time || timer.status?.text || '—'; + if (timer.state === 'running') tone = 'ok'; + else if (timer.state === 'stopped') tone = 'muted'; + else { + tone = 'muted'; + extra = l.status === 'nosignal' ? ' is-nosignal' : ' is-unavailable'; + } + statusText = timer.detail || ''; + statusTone = timer.status?.tone || (tone === 'ok' ? 'ok' : extra ? 'warn' : 'muted'); + } else if (source.kind === 'clock') { + text = wallClockText(); + tone = 'plain'; + statusText = settings().clockDate ? dateText() : ''; + statusTone = 'muted'; + } else { + text = providerClockText(timer, source, now); + tone = timer.state === 'running' ? 'plain' : 'muted'; + statusText = timer.detail || ''; + statusTone = timer.status?.tone || 'muted'; + } + } else { + const value = valueAt(timer, source.updatedAt, now); + text = value === null ? '—' : fmtDuration(value); + tone = toneFor(timer, value); + statusText = timer.status?.text || defaultStatus(timer, value, tone); + statusTone = timer.status?.tone || tone; + detailText = timer.detail || ''; + if (tone === 'danger' && timer.state === 'running') extra = ' is-overrun'; + } + // The LTC card's digits belong to the frame counter while an anchor is live. + if (!(card.key === LTC_KEY && ltcAnchor)) setText(card.timeEl, text); + else paintLtcFrame(); + const chars = Math.max(5, text.length); + if (chars !== card.chars) { + card.chars = chars; + charsChanged = true; + } + // Notes that must survive the compact look: an unavailable LTC input. + const isNote = extra === ' is-unavailable'; + setText(card.statusEl, statusText); + card.statusEl.hidden = !statusText || (!showStatus && !isNote); + card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : ''); + card.statusEl.dataset.tone = statusTone; + setText(card.detailEl, detailText); + card.detailEl.hidden = !detailText || !showStatus; + const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : ''; + setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`); } - // The built-in listener is independent of ProPresenter, so its card - // must not gray out with the rest when only ProPresenter is unreachable. - const live = ltc.source === 'listener' ? ' src-reader' : ''; - card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`; + if (charsChanged) fit(); } - /* ── status dot + degraded banners ──────────────────────────────── */ + /* ── status dot + degraded banner ───────────────────────────────── */ function renderStatus() { - wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false)); - // The stale gray-out spares the reader-fed LTC card — unless the feed - // from the ProdDash server itself is down, when everything is stale. wrap.classList.toggle('is-feed-down', feedOffline); if (feedOffline) { bannerEl.hidden = false; @@ -363,30 +608,49 @@ export default function create({ root, moduleApi }) { moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); return; } + bannerEl.hidden = true; if (!state) { - bannerEl.hidden = true; moduleApi.setStatus('connecting', 'Connecting…'); return; } - if (!state.enabled) { - bannerEl.hidden = true; - // The LTC listener feeds us with or without ProPresenter — an - // LTC-only setup is working, not broken. - if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC only — no ProPresenter host configured'); - else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + const groups = visibleGroups(); + const shown = groups.map((g) => g.source).filter(Boolean); + const say = (list) => list.map((s) => `${s.label}: ${s.message}`).join(' · '); + const bad = shown.filter((s) => s.status === 'error'); + if (bad.length) { + moduleApi.setStatus('error', say(bad)); return; } - if (!state.reachable) { - bannerEl.hidden = false; - bannerEl.textContent = 'ProPresenter unreachable — reconnecting…'; - moduleApi.setStatus('error', 'ProPresenter unreachable — reconnecting…'); + const waiting = shown.filter((s) => s.status === 'connecting'); + if (waiting.length) { + moduleApi.setStatus('connecting', say(waiting)); return; } - bannerEl.hidden = true; - moduleApi.setStatus('ok', 'Following ProPresenter timers'); + const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0); + moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`); } - /* ── main render: diff the card set, update in place ────────────── */ + /* ── main render: diff groups and cards, update in place ────────── */ + + function makeGroup(id) { + const el = document.createElement('section'); + el.className = 'tm-group'; + el.dataset.source = id; + const headEl = document.createElement('div'); + headEl.className = 'tm-head'; + const titleEl = document.createElement('span'); + titleEl.className = 'tm-title'; + const noteEl = document.createElement('span'); + noteEl.className = 'tm-note'; + headEl.append(titleEl, noteEl); + const emptyEl = document.createElement('div'); + emptyEl.className = 'tm-empty'; + emptyEl.hidden = true; + const gridEl = document.createElement('div'); + gridEl.className = 'tm-grid'; + el.append(headEl, emptyEl, gridEl); + return { el, headEl, titleEl, noteEl, emptyEl, gridEl, count: 0 }; + } function render() { renderStatus(); @@ -398,96 +662,164 @@ export default function create({ root, moduleApi }) { const mono = state?.timerFont === 'monospace'; if (mono !== wrap.classList.contains('tm-mono')) { wrap.classList.toggle('tm-mono', mono); - fit(); + needFit = true; } + const s = settings(); + const showHeadings = !solo && s.showHeadings !== false; + const showStatus = s.showStatus !== false; + wrap.classList.toggle('tm-headings', showHeadings); + wrap.classList.toggle('tm-nostatus', !showStatus); let hint = ''; - if (state && !state.enabled && !state.ltc?.supported) { - hint = 'No ProPresenter connection — set the host and port in Admin.'; - } - const items = hint ? [] : visibleItems(); - if (!hint && state?.reachable && !items.length) { - hint = 'Nothing selected — pick timers in the Timers ▾ menu.'; - } + const groups = visibleGroups(); + if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.'; + else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.'; hintEl.hidden = !hint; hintEl.textContent = hint; - gridEl.hidden = Boolean(hint); - if (hint) return; + groupsEl.hidden = Boolean(hint); + if (hint || !state) return; - const wanted = new Set(items.map((i) => i.key)); let structureChanged = false; + const wantedKeys = new Set(); + const orderedGroups = []; + for (const g of groups) { + let ge = groupEls.get(g.id); + if (!ge) { + ge = makeGroup(g.id); + groupEls.set(g.id, ge); + structureChanged = true; + } + const source = g.source; + const status = source ? source.status : 'ok'; + const message = source ? source.message : ''; + ge.headEl.hidden = !showHeadings; + setText(ge.titleEl, source ? source.label : 'Timer'); + setText(ge.noteEl, status !== 'ok' ? message : g.items.length ? '' : 'no timers'); + // Without headings the source's trouble still has to be said somewhere. + const emptyText = status !== 'ok' ? message : 'No timers'; + const showEmpty = !showHeadings && (status !== 'ok' || !g.items.length); + ge.emptyEl.hidden = !showEmpty; + setText(ge.emptyEl, showEmpty ? emptyText : ''); + ge.el.classList.toggle('is-down', status !== 'ok'); + ge.el.classList.toggle('is-stale', status === 'error' && g.items.length > 0); + + const els = []; + for (const item of g.items) { + let card = cards.get(item.key); + if (!card) { + card = makeCard(item); + cards.set(item.key, card); + structureChanged = true; + } + updateCard(card, item); + wantedKeys.add(item.key); + els.push(card.el); + } + if (ge.count !== els.length) structureChanged = true; + ge.count = els.length; + if (els.length !== ge.gridEl.children.length || els.some((el, i) => ge.gridEl.children[i] !== el)) { + ge.gridEl.replaceChildren(...els); + structureChanged = true; + } + orderedGroups.push(ge.el); + } for (const [key, card] of cards) { - if (!wanted.has(key)) { - card.el.remove(); - cards.delete(key); - structureChanged = true; - } + if (wantedKeys.has(key)) continue; + card.el.remove(); + cards.delete(key); + structureChanged = true; } - for (const item of items) { - let card = cards.get(item.key); - if (!card) { - card = makeCard(item); - cards.set(item.key, card); - structureChanged = true; - } - if (item.ltc) updateLtcCard(card, item.ltc); - else if (item.missing) updateMissingCard(card); - else updateTimerCard(card, item.timer); + for (const [id, ge] of groupEls) { + if (groups.some((g) => g.id === id)) continue; + ge.el.remove(); + groupEls.delete(id); + structureChanged = true; } - // Keep DOM order in step with the state's order (config-list order). - const orderedEls = items.map((i) => cards.get(i.key).el); - if (structureChanged || orderedEls.some((el, i) => gridEl.children[i] !== el)) { - gridEl.replaceChildren(...orderedEls); + if (orderedGroups.length !== groupsEl.children.length || orderedGroups.some((el, i) => groupsEl.children[i] !== el)) { + groupsEl.replaceChildren(...orderedGroups); + structureChanged = true; + } + paint(); + if (structureChanged || needFit) { + needFit = false; fit(); } } - /* ── sizing: pick the column count that gives the biggest digits ── */ + /* ── sizing: the column count that gives the biggest digits ─────── */ function fit() { - const n = Math.max(1, gridEl.childElementCount); + const list = [...cards.values()]; + const n = list.length; const W = root.clientWidth; const H = root.clientHeight; - if (!W || !H) return; + if (!n || !W || !H) return; const PAD = H < 120 ? 4 : 10; const GAP = H < 120 ? 5 : 8; const CARD_PAD = 12; // a card's own vertical padding + borders + const HEAD_H = 20; // a source heading line, margin included + const groups = [...groupEls.values()].filter((g) => g.el.isConnected); + const headings = wrap.classList.contains('tm-headings') ? groups.length : 0; + const showStatus = !wrap.classList.contains('tm-nostatus'); + // Lines under the digits besides the label: status, and detail if any card has one. + const metaLines = showStatus ? 1 + (list.some((c) => !c.detailEl.hidden) ? 1 : 0) : 0; - // For each column count, budget a card's full stack (name line, digits, - // state line) and score by the digit size it affords ("-00:00:00" ≈ 9 - // tabular chars wide). Cards too short for three readable lines drop to - // a compact look — no state text, the card color carries the state — - // but any layout with a readable full stack beats every compact one. + // For each column count, budget a card's full stack (label, digits, + // lines) and score by the digit size it affords — the SMALLEST card's, + // so no timer ends up unreadable. Cards too short for the full stack + // drop to a compact look (label + digits, the colour carries the + // state), but any layout with a readable full stack beats every + // compact one. let best = null; for (let cols = 1; cols <= n; cols += 1) { - const rows = Math.ceil(n / cols); + const rows = headings + ? groups.reduce((a, g) => a + Math.max(1, Math.ceil(g.count / cols)), 0) + : Math.ceil(n / cols); const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18; - const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows - CARD_PAD; + const ch = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1) - (headings ? (headings - 1) * GAP : 0)) / rows - CARD_PAD; if (cw <= 0 || ch <= 0) continue; const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16))); - const byWidth = (cw * 1.55) / 9; - const full = Math.min(byWidth, ch - 2.6 * meta); - const compact = Math.min(byWidth, ch - 1.3 * meta); + const fullBudget = ch - (1 + metaLines) * 1.3 * meta; + const compactBudget = ch - 1.3 * meta; + let full = Infinity; + let compact = Infinity; + for (const c of list) { + const byWidth = (cw * 1.55) / c.chars; + full = Math.min(full, byWidth, fullBudget); + compact = Math.min(compact, byWidth, compactBudget); + } const mode = full >= 14 ? 'full' : 'compact'; const size = mode === 'full' ? full : compact; const score = (mode === 'full' ? 1000 : 0) + size; - if (!best || score > best.score) best = { cols, meta, mode, size, cw, score }; + if (!best || score > best.score) best = { cols, meta, mode, cw, budget: mode === 'full' ? fullBudget : compactBudget, score }; } if (!best) return; - gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`; + for (const g of groups) { + g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`; + // Space is shared in proportion to each group's rows, so a card is + // the same height under every heading. + g.el.style.flexGrow = String(Math.max(1, Math.ceil(g.count / best.cols))); + } wrap.style.setProperty('--tm-pad', PAD + 'px'); wrap.style.setProperty('--tm-gap', GAP + 'px'); - wrap.classList.toggle('tm-compact', best.mode === 'compact'); - const timeSize = Math.max(11, Math.floor(best.size)); - // LTC digits ("00:00:00:00") are ~11.5 tabular chars wide in the same cell. - const ltcSize = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / 11.5, best.size))); - wrap.style.setProperty('--tm-time-size', timeSize + 'px'); - wrap.style.setProperty('--tm-ltc-size', ltcSize + 'px'); wrap.style.setProperty('--tm-meta-size', best.meta + 'px'); + wrap.classList.toggle('tm-compact', best.mode === 'compact'); + for (const c of list) { + const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, best.budget))); + c.timeEl.style.fontSize = px + 'px'; + } } /* ── live feed ──────────────────────────────────────────────────── */ + function takeState(next) { + if (!next || typeof next !== 'object' || !Array.isArray(next.sources)) return; + noteServerClock(Number(next.serverNow)); + state = next; + feedOffline = false; + render(); + } + function connect() { stream = moduleApi.sse('/stream', { open() { @@ -502,13 +834,13 @@ export default function create({ root, moduleApi }) { }, events: { state(ev) { + let parsed; try { - state = JSON.parse(ev.data); + parsed = JSON.parse(ev.data); } catch { return; } - feedOffline = false; - render(); + takeState(parsed); }, }, }); @@ -518,15 +850,13 @@ export default function create({ root, moduleApi }) { return { start() { + applyColors(); moduleApi.setStatus('connecting', 'Connecting…'); // Instant paint from the snapshot, then live updates. moduleApi.fetch('/state') .then((res) => (res.ok ? res.json() : null)) .then((body) => { - if (body && body.state && !state) { - state = body.state; - render(); - } + if (body && body.state && !state) takeState(body.state); }) .catch(() => { /* the stream will cover it */ }); connect(); @@ -542,17 +872,22 @@ export default function create({ root, moduleApi }) { fit(); }); resizeObserver.observe(root); + paintTimer = setInterval(paint, PAINT_MS); render(); }, stop() { stream?.close(); stream = null; + clearInterval(paintTimer); + paintTimer = null; resizeObserver?.disconnect(); resizeObserver = null; if (ltcRaf) { cancelAnimationFrame(ltcRaf); ltcRaf = 0; } + cards.clear(); + groupEls.clear(); root.innerHTML = ''; // header controls are removed by the shell }, onResize() { diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index c6f56b1..c1ad32e 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,9 +1,10 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.2.1", - "proddash": ">=1.2.0", - "description": "Live ProPresenter timers and LTC timecode", + "version": "1.3.0", + "proddash": ">=1.4.0", + "description": "Timers, Clocks, and LTC From configured Modules", + "provides": ["timers"], "permissions": [ { "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" }, { "kind": "local-network", "reason": "Reaching ProPresenter on this network" } @@ -12,8 +13,28 @@ "server": "server.js", "style": "style.css", "minSize": { "w": 2, "h": 1 }, - "defaultSize": { "w": 4, "h": 2 }, + "defaultSize": { "w": 4, "h": 3 }, + "configGroups": { + "ProPresenter": { + "toggle": "ppEnabled", + "help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled." + }, + "LTC Timecode": { + "toggle": "ltcEnabled", + "help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)." + }, + "PCO Plan": { + "toggle": "pcoEnabled", + "help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side." + } + }, "configSchema": { + "ppEnabled": { + "type": "switch", + "label": "ProPresenter timers", + "group": "ProPresenter", + "default": true + }, "propresenter": { "type": "endpoint", "label": "ProPresenter", @@ -22,13 +43,14 @@ }, "password": { "type": "password", - "label": "API password (only if set in ProPresenter)", + "label": "API password", "group": "ProPresenter", - "default": "" + "default": "", + "help": "Only if a password is set in ProPresenter's network preferences." }, "ltcEnabled": { "type": "switch", - "label": "LTC listener — decode timecode from an audio input on this machine", + "label": "LTC timecode", "group": "LTC Timecode", "default": false }, @@ -42,10 +64,17 @@ }, "ltcChannel": { "type": "number", - "label": "Channel on that device (1-based)", + "label": "Channel", "group": "LTC Timecode", "showWhen": "ltcEnabled", - "default": 1 + "default": 1, + "help": "The channel on that device carrying the timecode, counted from 1." + }, + "pcoEnabled": { + "type": "switch", + "label": "PCO Plan timers", + "group": "PCO Plan", + "default": true }, "timerFont": { "type": "select", @@ -57,5 +86,38 @@ { "value": "monospace", "label": "Monospace — steadier changing digits" } ] } + }, + "instanceGroups": { + "Show": { "collapsed": false, "columns": 2 }, + "Clock": { "collapsed": false }, + "Colours": { + "collapsed": true, + "columns": 2, + "help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it." + } + }, + "instanceSchema": { + "showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" }, + "showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" }, + "showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" }, + "showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" }, + "showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" }, + "showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" }, + "clockFormat": { + "type": "select", + "label": "Clock format", + "default": "24h", + "group": "Clock", + "options": [ + { "value": "24h", "label": "24-hour" }, + { "value": "12h", "label": "12-hour" } + ] + }, + "clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" }, + "clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" }, + "runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" }, + "warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" }, + "overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" }, + "idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 41b2926..950516b 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -1,393 +1,1106 @@ 'use strict'; /** - * ProPresenter Timers — server part. + * Timers — server part. * - * Polls the ProPresenter openAPI for the configured timer list and the live - * timer values, decodes LTC timecode from an audio input, and fans one - * merged state object out to every tile over SSE. Browsers never talk to - * ProPresenter or the audio hardware directly. + * One tile for every timer and clock in the room, from any source. Each + * SOURCE turns its own world into the module guide's standard timer objects + * ({ id, label, kind, state, startedAt, elapsedMs, remainingMs, targetMs, + * status, detail } — see "Offering timers to the Timers module") and reports + * its health. This file merges the sources, fans the merged state out to the + * tiles over SSE, and offers the whole set to the rest of ProdDash as a + * timers provider itself. Browsers never talk to ProPresenter, the audio + * hardware or other modules directly. * - * GET /state → { state: } (instant paint for new tiles) - * GET /stream → SSE, `state` events (change-detected, heartbeated) - * GET /ltc/devices → this machine's audio input devices, for the admin - * page's device picker (select + optionsRoute). + * Sources (one factory each, below): + * propresenter the ProPresenter openAPI timer list + live values, polled + * exactly as before (admin switch: ppEnabled) + * ltc SMPTE LTC decoded from an audio input by the built-in + * listener (ltc-listener.js / ltc.js — untouched), shown as a + * kind:"clock" timer whose status.text is the timecode and + * whose `ltc` extension carries fps/df/ageMs so the tile can + * count frames between reports (admin switch: ltcEnabled) + * clock time of day. Trivial here — the tile paints its own wall + * clock — it exists so the clock is one more entry in + * /timers and in the tile's list, like everything else + * modules every other module whose manifest says + * "provides": ["timers"], discovered from the shell's + * /api/modules over loopback (shell.port, never guessed) and + * read through its /timers/stream when that answers as SSE, + * else its /timers polled every 2 s. Re-discovered every 30 s + * so a newly installed provider appears without a restart. + * PCO Plan sits behind the admin pcoEnabled switch; any other + * provider is consumed as soon as it is found (give it a + * switch of its own in module.json the day one needs it). * - * Endpoints used, verified against openapi.propresenter.com (API v1): + * ADDING A SOURCE (another timer platform) is a dozen lines. Write a factory + * that returns + * + * { id, label, kind, start(emit), stop(), health() } + * + * id short, stable ('propresenter', 'ltc', …) — the tile keys its + * show/hide settings on `${id}:${timer.id}` + * label the heading the tile puts over this source's timers + * kind which tile switch governs it: 'propresenter' | 'ltc' | 'clock' + * | 'module' (a new platform would add its own switch and kind) + * start(emit) begins following the upstream and calls + * emit({ status, message, timers, updatedAt }) whenever the view + * changes — status 'ok' | 'connecting' | 'error', message one + * human line (shown under the heading while not ok), timers an + * array of standard timer objects as of updatedAt + * stop() tears everything down (a re-init follows every admin save) + * health() → { status, message } for the admin health line + * + * then, in init(), push it onto `fixed` behind its own admin `switch` field + * (named as that group's `toggle` in module.json). Merging, change + * detection, both SSE feeds, /timers and the tile are generic — nothing + * else changes. + * + * Routes: + * GET /state → { state } tile snapshot (instant paint) + * GET /stream → SSE `state` events tile feed, change-detected + * GET /timers → provider snapshot the module guide's contract + * GET /timers/stream → SSE `timers` events same, for other consumers + * GET /ltc/devices → this machine's audio inputs, for the admin page's + * device picker (select + optionsRoute) + * + * ProPresenter endpoints, verified against openapi.propresenter.com (v1): * GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun, * countdown:{duration} | count_down_to_time:{time_of_day,period} * | elapsed:{start_time,end_time} } ] * GET /v1/timers/current → [ { id:{uuid,name,index}, time:"HH:MM:SS", state } ] * `time` may be negative ("-00:00:02") while overrunning, and some builds - * append hundredths ("00:00:01.00" appears in the spec's schema example). - * `state` enum is stopped|running|complete|overrunning|overran — but the - * spec's own response example says "overrun", so treat anything containing - * "overr" as overrun. The API exposes NO per-timer color anywhere. - * - * LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has - * no timecode route, and the classic stage-display websocket drops the - * field — both verified dead on ProPresenter 21.4), so LTC is read from the - * audio signal itself. Behind the admin "LTC listener" switch: off means no - * LTC tile in the picker and nothing captured; on, the built-in listener - * (ltc-listener.js) captures the admin-selected device/channel and decodes - * in-process. It is the module's one and only LTC source. - * - * ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path - * notes in ../propresenter-now-next/propresenter-core). So: requests run - * strictly sequentially, the next tick is scheduled only after the previous - * one finished, live values are the only every-tick call, and the timer list - * rides a slow lane. The endpoints do offer `?chunked` streaming, but polling - * matches how propresenter-core survives real-world ProPresenter quirks. + * append hundredths. `state` enum is stopped|running|complete|overrunning| + * overran — but the spec's own example says "overrun", so anything + * containing "overr" is overrun. The API exposes NO per-timer color. + * ProPresenter's HTTP server is easily overwhelmed, so requests run strictly + * sequentially, live values are the only every-tick call, and the timer + * list rides a slow lane. */ -/** Hot-path cadence — live timer values. */ -const POLL_MS = 500; -/** Backoff while ProPresenter is unreachable. */ -const RETRY_MS = 2500; -/** The configured-timer list refreshes every Nth tick (~2s at 500ms). */ -const SLOW_EVERY = 4; -/** Abort a hung request well before it can stall the loop for good. */ -const REQUEST_TIMEOUT_MS = 4000; -/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ -const HEARTBEAT_MS = 15000; - +const http = require('http'); +const path = require('path'); const { spawn } = require('child_process'); const { createLtcListener } = require('./ltc-listener'); -/** Set by init(), read by the routes — both are rebuilt together on remount. */ +/** This module's id — left out of provider discovery (we don't consume ourselves). */ +const SELF_ID = 'propresenter-timers'; +/** The provider the admin "PCO Plan" switch governs; every other provider is always on. */ +const PCO_ID = 'pco-plan'; + +/* ProPresenter cadence. */ +const POLL_MS = 500; +const RETRY_MS = 2500; +const SLOW_EVERY = 4; +const REQUEST_TIMEOUT_MS = 4000; + +/* Timers offered by other modules. */ +const DISCOVER_MS = 30000; // re-read /api/modules: a newly installed provider shows up without a restart +const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot) +const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens +const MODULE_POLL_MS = 2000; // GET /timers cadence for a provider with no stream +const BACKOFF_MIN_MS = 3000; +const BACKOFF_MAX_MS = 15000; +const GRACE_MS = 10000; // a provider's timers survive this long without a fresh snapshot +const STREAM_SILENCE_MS = 45000; // a stream with no bytes for this long (heartbeats are 15 s) is dead +const MAX_TIMERS_PER_PROVIDER = 50; + +/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ +const HEARTBEAT_MS = 15000; + +const KINDS = new Set(['elapsed', 'countdown', 'clock']); +const STATES = new Set(['running', 'paused', 'stopped', 'idle']); +const TONES = new Set(['ok', 'warn', 'danger', 'muted']); + +/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */ let current = null; -function clearedState(enabled, ltcEnabled = false, timerFont = 'default') { +/* ── small helpers ────────────────────────────────────────────────────── */ + +function sendJson(res, code, body) { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); +} + +/** Open an SSE response, send the first frame, and register it in `set`. */ +function openSse(req, res, set, firstFrame) { + 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(firstFrame); + if (!set) return void res.end(); + // 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 */ } + set.add(res); + req.on('close', () => set.delete(res)); +} + +function writeAll(set, payload) { + for (const res of set) { + try { + res.write(payload); + } catch { + set.delete(res); + } + } +} + +/** "HH:MM:SS", "-HH:MM:SS", "MM:SS", optional ".hh" → signed milliseconds, or null. */ +function hmsToMs(str) { + const m = /^\s*(-)?(?:(\d+):)?(\d{1,2}):(\d{2})(?:\.(\d+))?\s*$/.exec(String(str || '')); + if (!m) return null; + const h = m[2] ? Number(m[2]) : 0; + let ms = (h * 3600 + Number(m[3]) * 60 + Number(m[4])) * 1000; + if (m[5]) ms += Math.round(Number('0.' + m[5]) * 1000); + return m[1] ? -ms : ms; +} + +/** ms → "M:SS" / "H:MM:SS" (for detail lines). */ +function fmtMs(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (v) => String(v).padStart(2, '0'); + return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; +} + +/** ProPresenter's count_down_to_time target: seconds since midnight + am/pm/24h. */ +function timeOfDayText(seconds, period) { + const s = Number(seconds); + if (!Number.isFinite(s)) return ''; + const h = Math.floor(s / 3600) % 24; + const m = Math.floor((s % 3600) / 60); + const p = String(period || '').toLowerCase(); + if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${p}`; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +function errorText(err) { + return String(err?.cause?.code || err?.code || err?.message || err || 'failed'); +} + +/* ── validation of what other modules hand us ─────────────────────────── */ + +function numOrNull(v) { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** One timer object from a provider, coerced to the contract (or null to drop it). */ +function sanitizeTimer(t) { + if (!t || typeof t !== 'object') return null; + const id = t.id === null || t.id === undefined ? '' : String(t.id); + if (!id) return null; + let status = null; + if (t.status && typeof t.status === 'object' && t.status.text !== undefined && t.status.text !== null) { + status = { text: String(t.status.text).slice(0, 200) }; + if (TONES.has(t.status.tone)) status.tone = t.status.tone; + } return { - enabled, - ltcEnabled, - timerFont, - reachable: false, - lastError: '', - timers: [], - // supported true once the listener has a verdict; false when the - // listener is off, has no device, or its capture failed (note says - // why). status is running|stopped|nosignal; while running, ageMs lets - // clients count frames locally between updates. - ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' }, + id, + label: String(t.label || id).slice(0, 120), + kind: KINDS.has(t.kind) ? t.kind : 'elapsed', + state: STATES.has(t.state) ? t.state : 'stopped', + startedAt: numOrNull(t.startedAt), + elapsedMs: numOrNull(t.elapsedMs), + remainingMs: numOrNull(t.remainingMs), + targetMs: numOrNull(t.targetMs), + status, + detail: t.detail === null || t.detail === undefined ? '' : String(t.detail).slice(0, 200), }; } -module.exports = { - init({ config, log }) { - const streams = new Set(); - // 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); - const ltcEnabled = Boolean(config.ltcEnabled); - const ltcDevice = String(config.ltcDevice || ''); - const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1); - const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default'; +/** A provider's /timers document → { label, timers, updatedAt }, or null when it isn't one. */ +function sanitizeSnapshot(raw) { + if (!raw || typeof raw !== 'object' || !Array.isArray(raw.timers)) return null; + return { + label: raw.label ? String(raw.label).slice(0, 60) : '', + timers: raw.timers.map(sanitizeTimer).filter(Boolean).slice(0, MAX_TIMERS_PER_PROVIDER), + updatedAt: Number(raw.updatedAt) || Date.now(), + }; +} - let latest = clearedState(enabled, ltcEnabled, timerFont); - let lastFrame = ''; - let stopped = false; - let pollTimer = null; - let tick = 0; +/* ── source: ProPresenter ─────────────────────────────────────────────── */ - /** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */ - let timerConfigs = new Map(); - /** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */ - let listenerLtc = null; +function createProPresenterSource({ config, log }) { + // 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 configured = Boolean(host && port); - /** The LTC verdict — the built-in listener is the only source. */ - function composeLtc() { - if (!ltcEnabled) { - return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' }; + let emit = () => {}; + let stopped = false; + let pollTimer = null; + let tick = 0; + let reachable = false; + let lastError = ''; + let timers = []; + let updatedAt = 0; + /** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */ + let timerConfigs = new Map(); + + 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 describe(entry) { + if (entry.countdown) { + const targetMs = Number(entry.countdown.duration) > 0 ? Number(entry.countdown.duration) * 1000 : null; + return { type: 'countdown', targetMs, detail: targetMs !== null ? `Countdown ${fmtMs(targetMs)}` : 'Countdown' }; + } + if (entry.count_down_to_time) { + const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period); + return { type: 'countdown_to_time', targetMs: null, detail: at ? `Counts down to ${at}` : 'Counts down to a time of day' }; + } + if (entry.elapsed) { + const end = Number(entry.elapsed.end_time); + const targetMs = Number.isFinite(end) && end > 0 ? end * 1000 : null; + return { type: 'elapsed', targetMs, detail: targetMs !== null ? `Elapsed · to ${fmtMs(targetMs)}` : 'Elapsed' }; + } + return { type: 'unknown', targetMs: null, detail: '' }; + } + + function applyTimerList(list) { + if (!Array.isArray(list)) return; + const next = new Map(); + for (const entry of list) { + const uuid = entry?.id?.uuid; + if (!uuid) continue; + next.set(uuid, { + name: String(entry.id.name || 'Timer'), + index: Number(entry.id.index) || 0, + allowsOverrun: Boolean(entry.allows_overrun), + ...describe(entry), + }); + } + timerConfigs = next; + } + + /** One ProPresenter timer (configured entry + live value) → a standard timer object. */ + function toTimer(uuid, cfg, live, now) { + const rawState = String(live?.state || '').toLowerCase(); + const overrun = rawState.includes('overr'); + const value = live ? hmsToMs(live.time) : null; + const type = cfg ? cfg.type : 'unknown'; + // A timer reported live but missing from the (stale) config list has an + // unknown type until the slow lane catches up (~2 s); treat it as elapsed + // meanwhile — the whole-second display can't drift wrong in that time. + const kind = type === 'countdown' || type === 'countdown_to_time' ? 'countdown' : 'elapsed'; + const targetMs = cfg ? cfg.targetMs : null; + let state; + if (!live) state = 'idle'; + else if (rawState === 'running' || overrun) state = 'running'; + else if (rawState === 'complete') state = 'stopped'; + else { + // ProPresenter's "stopped" covers both a timer that never started + // (still showing its full length) and one paused mid-way. + const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0); + state = atStart ? 'idle' : 'paused'; + } + const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null; + const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null; + const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null; + const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped'; + const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted'; + return { + id: uuid, + label: (live && live.name) || (cfg && cfg.name) || 'Timer', + kind, + state, + startedAt, + elapsedMs, + remainingMs, + targetMs, + status: { text, tone }, + detail: cfg ? cfg.detail : '', + }; + } + + /** Merge configured timers (order, type) with live values into one list. */ + function mergeTimers(currentTimes, now) { + const live = new Map(); + if (Array.isArray(currentTimes)) { + for (const entry of currentTimes) { + const uuid = entry?.id?.uuid; + if (!uuid) continue; + live.set(uuid, { + name: String(entry.id.name || ''), + time: typeof entry.time === 'string' ? entry.time : '', + state: typeof entry.state === 'string' ? entry.state : '', + }); } - if (!listenerLtc) { - return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' }; + } + const out = []; + for (const [uuid, cfg] of timerConfigs) { + const now_ = live.get(uuid) || null; + live.delete(uuid); + out.push(toTimer(uuid, cfg, now_, now)); + } + // Timers reported live but missing from the (possibly stale) config + // list still deserve a card — type unknown until the slow lane catches up. + for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now)); + return out; + } + + function status() { + if (!configured) return 'error'; + if (reachable) return 'ok'; + return lastError ? 'error' : 'connecting'; + } + + function message() { + if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off'; + if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`; + return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`; + } + + function publish() { + if (stopped) return; + emit({ status: status(), message: message(), timers, updatedAt }); + } + + async function pollTick() { + if (stopped) return; + try { + tick += 1; + // Slow lane first on the very first tick so cards get names/types + // immediately; afterwards every SLOW_EVERY ticks. + if (tick % SLOW_EVERY === 1 || !timerConfigs.size) { + applyTimerList(await requestJson('/v1/timers')); } - if (listenerLtc.error) { - return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error }; + // THE HOT PATH — live values every tick. + const currentTimes = await requestJson('/v1/timers/current'); + updatedAt = Date.now(); + timers = mergeTimers(currentTimes, updatedAt); + reachable = true; + lastError = ''; + } catch (err) { + reachable = false; + lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed" + } + publish(); + if (stopped) return; + pollTimer = setTimeout(pollTick, reachable ? POLL_MS : RETRY_MS); + } + + return { + id: 'propresenter', + label: 'ProPresenter', + kind: 'propresenter', + start(e) { + emit = e; + if (!configured) { + log('ProPresenter timers on, but no host configured — set one in /admin'); + publish(); + return; } - const running = listenerLtc.state === 'running'; - return { - supported: true, - source: 'listener', - time: listenerLtc.time, - receiving: running, - status: listenerLtc.state, - fps: listenerLtc.fps, - df: listenerLtc.df, - // While running: how old this timecode is, so clients can pin the - // anchor to their own clock and count frames between updates. - ...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}), - note: '', + log(`polling timers on ${host}:${port}`); + pollTick(); + }, + stop() { + stopped = true; + clearTimeout(pollTimer); + }, + health() { + return { status: status(), message: message() }; + }, + }; +} + +/* ── source: LTC timecode (built-in listener) ─────────────────────────── */ + +function createLtcSource({ config, log, moduleDir }) { + const device = String(config.ltcDevice || ''); + const channel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1); + + let emit = () => {}; + let listener = null; + let stopped = false; + /** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */ + let word = null; + + function rateText() { + return word?.df ? '29.97 DF' : word?.fps ? `${word.fps} fps` : ''; + } + + /** The LTC timecode as one standard timer object (kind clock, timecode in status.text). */ + function timer() { + const base = { id: 'ltc', label: 'Timecode', kind: 'clock', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null }; + if (!word || word.error) { + const note = word ? word.error : 'Starting LTC listener…'; + return { ...base, state: 'idle', status: { text: '', tone: 'muted' }, detail: note, ltc: { time: '', fps: 0, df: false, status: '', note } }; + } + const running = word.state === 'running'; + const rate = rateText(); + const ltc = { time: word.time || '', fps: word.fps || 0, df: Boolean(word.df), status: word.state, note: '' }; + // While running: how old this timecode is, so the tile can pin the + // anchor to its own clock and count frames between reports. + if (running && Number.isFinite(word.ageMs)) ltc.ageMs = Math.max(0, word.ageMs); + return { + ...base, + state: running ? 'running' : word.state === 'stopped' ? 'stopped' : 'idle', + status: { text: word.time || '', tone: running ? 'ok' : word.state === 'stopped' ? 'muted' : 'warn' }, + detail: running ? (rate ? `Running · ${rate}` : 'Running') : word.state === 'stopped' ? 'Stopped' : 'No signal', + ltc, + }; + } + + function status() { + if (!word) return 'connecting'; + return word.error ? 'error' : 'ok'; + } + + function message() { + if (!word) return 'Starting LTC listener…'; + if (word.error) return word.error; + const rate = rateText(); + return word.state === 'running' ? `LTC running${rate ? ` · ${rate}` : ''}` : word.state === 'stopped' ? 'LTC stopped' : 'LTC no signal'; + } + + function publish() { + if (stopped) return; + emit({ status: status(), message: message(), timers: [timer()], updatedAt: Date.now() }); + } + + return { + id: 'ltc', + label: 'LTC', + kind: 'ltc', + start(e) { + emit = e; + if (!device) { + word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' }; + publish(); + return; + } + publish(); + listener = createLtcListener({ + device, + channel, + moduleDir, + log, + onUpdate(update) { + word = update; + publish(); + }, + }); + }, + stop() { + stopped = true; + listener?.stop(); + listener = null; + }, + health() { + return { status: status(), message: message() }; + }, + }; +} + +/* ── source: time of day ──────────────────────────────────────────────── */ + +function createClockSource() { + return { + id: 'clock', + label: 'Clock', + kind: 'clock', + start(emit) { + // status.text (the ISO time) is stamped when a snapshot is served — + // see stampClock() — so this never has to re-emit. + emit({ + status: 'ok', + message: 'ticking', + updatedAt: Date.now(), + timers: [{ id: 'local', label: 'Clock', kind: 'clock', state: 'running', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null, status: { text: '', tone: 'ok' }, detail: '' }], + }); + }, + stop() {}, + health() { + return { status: 'ok', message: '' }; + }, + }; +} + +/* ── source: another module's timers (over loopback) ─────────────────── */ + +function createModuleSource({ moduleId, name, shellPort, log }) { + const base = `/api/modules/${encodeURIComponent(moduleId)}`; + let emit = () => {}; + let stopped = false; + let label = String(name || moduleId); + let timers = []; + let updatedAt = 0; + let lastOkAt = 0; + let lastError = ''; + let state = 'connecting'; // 'ok' | 'connecting' | 'error' + let via = ''; // 'stream' | 'poll' once we know which the provider offers + let stream = null; // the open http.ClientRequest of /timers/stream + let pollTimer = null; + let pollInflight = false; + let retryTimer = null; + let silenceTimer = null; + let backoff = BACKOFF_MIN_MS; + + function message() { + if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`; + if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…'; + return `unreachable — ${lastError}`; + } + + function publish() { + if (stopped) return; + emit({ label, status: state, message: message(), timers, updatedAt }); + } + + function accept(raw) { + const snap = sanitizeSnapshot(raw); + if (!snap) { + fail('malformed /timers snapshot'); + return; + } + if (snap.label) label = snap.label; + timers = snap.timers; + updatedAt = snap.updatedAt; + lastOkAt = Date.now(); + lastError = ''; + state = 'ok'; + backoff = BACKOFF_MIN_MS; + publish(); + } + + /** A provider restarting mid-service drops its stream for a moment; its + timers stay up through the grace period and only then disappear. */ + function fail(why) { + lastError = why; + if (Date.now() - lastOkAt > GRACE_MS) { + timers = []; + state = 'error'; + } else { + state = 'connecting'; + } + publish(); + } + + function later(fn, ms) { + const t = setTimeout(() => { if (!stopped) fn(); }, ms); + t.unref?.(); + return t; + } + + function backoffLater(fn) { + clearTimeout(retryTimer); + retryTimer = later(fn, backoff); + backoff = Math.min(BACKOFF_MAX_MS, backoff * 2); + } + + function closeStream() { + clearTimeout(silenceTimer); + silenceTimer = null; + if (stream) { + const s = stream; + stream = null; + try { s.destroy(); } catch { /* already gone */ } + } + } + + function connectStream() { + if (stopped || stream) return; + let done = false; + const drop = (why) => { + if (done || stopped) return; + done = true; + closeStream(); + fail(why); + backoffLater(connectStream); + }; + const req = http.get({ host: '127.0.0.1', port: shellPort, path: `${base}/timers/stream`, headers: { Accept: 'text/event-stream' } }, (res) => { + const type = String(res.headers['content-type'] || ''); + if (res.statusCode !== 200 || !/text\/event-stream/i.test(type)) { + // No stream here (404: the provider only serves /timers; 502: it + // failed to start) — poll instead, and try the stream again at the + // next discovery pass. + done = true; + res.resume(); + stream = null; + via = 'poll'; + poll(); + return; + } + via = 'stream'; + clearTimeout(pollTimer); + pollTimer = null; + res.setEncoding('utf8'); + let buf = ''; + const armSilence = () => { + clearTimeout(silenceTimer); + silenceTimer = later(() => drop('stream went silent'), STREAM_SILENCE_MS); }; - } + armSilence(); + res.on('data', (chunk) => { + armSilence(); + buf += chunk.replace(/\r\n/g, '\n'); + let at; + while ((at = buf.indexOf('\n\n')) >= 0) { + const block = buf.slice(0, at); + buf = buf.slice(at + 2); + let event = 'message'; + const data = []; + for (const line of block.split('\n')) { + if (!line || line.startsWith(':')) continue; + const colon = line.indexOf(':'); + const field = colon < 0 ? line : line.slice(0, colon); + let value = colon < 0 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'event') event = value; + else if (field === 'data') data.push(value); + } + if (!data.length || (event !== 'timers' && event !== 'message')) continue; + try { + accept(JSON.parse(data.join('\n'))); + } catch { /* not for us */ } + } + if (buf.length > 1e6) buf = ''; // runaway guard: never an SSE frame this size + }); + res.on('end', () => drop('stream ended')); + res.on('close', () => drop('stream closed')); + res.on('error', (err) => drop(errorText(err))); + }); + stream = req; + req.on('error', (err) => drop(errorText(err))); + } - 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), { + async function pollOnce() { + pollTimer = null; + if (stopped || pollInflight || via === 'stream') return; + pollInflight = true; + let ok = false; + try { + const res = await fetch(`http://127.0.0.1:${shellPort}${base}/timers`, { 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; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (via === 'stream' || stopped) return; // the stream came up meanwhile + accept(body); + ok = true; + } catch (err) { + if (!stopped && via !== 'stream') fail(errorText(err)); + } finally { + pollInflight = false; } - - function timerType(entry) { - if (entry.countdown) return 'countdown'; - if (entry.count_down_to_time) return 'countdown_to_time'; - if (entry.elapsed) return 'elapsed'; - return 'unknown'; + if (stopped || via === 'stream') return; + if (ok) { + pollTimer = later(pollOnce, MODULE_POLL_MS); + } else { + pollTimer = later(pollOnce, backoff); + backoff = Math.min(BACKOFF_MAX_MS, backoff * 2); } + } - function applyTimerList(list) { - if (!Array.isArray(list)) return; - const next = new Map(); - for (const entry of list) { - const uuid = entry?.id?.uuid; - if (!uuid) continue; - next.set(uuid, { - name: String(entry.id.name || 'Timer'), - index: Number(entry.id.index) || 0, - type: timerType(entry), - allowsOverrun: Boolean(entry.allows_overrun), - }); - } - timerConfigs = next; - } + function poll() { + if (stopped || pollTimer || pollInflight) return; + pollTimer = later(pollOnce, 0); + } - /** Merge configured timers (order, type) with live values into one list. */ - function mergeTimers(currentTimes) { - const live = new Map(); - if (Array.isArray(currentTimes)) { - for (const entry of currentTimes) { - const uuid = entry?.id?.uuid; - if (!uuid) continue; - live.set(uuid, { - name: String(entry.id.name || ''), - time: typeof entry.time === 'string' ? entry.time : '', - state: typeof entry.state === 'string' ? entry.state : '', - }); + return { + id: moduleId, + kind: 'module', + get label() { + return label; + }, + start(e) { + emit = e; + publish(); + connectStream(); + }, + stop() { + stopped = true; + closeStream(); + clearTimeout(retryTimer); + clearTimeout(pollTimer); + retryTimer = null; + pollTimer = null; + }, + /** Called on every discovery pass: a provider that had no stream may have one now. */ + retryStream() { + if (!stopped && !stream && via === 'poll') connectStream(); + }, + health() { + return { status: state, message: message() }; + }, + }; +} + +/** + * Finds timer providers — every active module whose manifest says + * "provides": ["timers"] — by reading the shell's own /api/modules over + * loopback, and keeps one module source per provider alive. + */ +function createModuleDiscovery({ shellPort, log, flags, onAdd, onRemove }) { + const sources = new Map(); // module id → source + let timer = null; + let stopped = false; + let listed = null; // providers named by the last successful read, [{ id, name }] + let listError = ''; + + function wanted(id) { + return id === PCO_ID ? flags.pco : true; + } + + async function discover() { + timer = null; + if (stopped) return; + try { + if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)'); + const res = await fetch(`http://127.0.0.1:${shellPort}/api/modules`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (stopped) return; + const mods = Array.isArray(body?.modules) ? body.modules : []; + listed = mods + .filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes('timers')) + .map((m) => ({ id: m.id, name: String(m.name || m.id) })); + listError = ''; + const keep = new Set(); + for (const m of listed) { + if (!wanted(m.id)) continue; + keep.add(m.id); + const have = sources.get(m.id); + if (have) { + have.retryStream(); + } else { + const source = createModuleSource({ moduleId: m.id, name: m.name, shellPort, log }); + sources.set(m.id, source); + log(`following timers offered by module "${m.id}"`); + onAdd(source); } } - const out = []; - for (const [uuid, cfg] of timerConfigs) { - const now = live.get(uuid); - live.delete(uuid); - out.push({ - uuid, - name: (now && now.name) || cfg.name, - type: cfg.type, - allowsOverrun: cfg.allowsOverrun, - time: now ? now.time : '', - state: now ? now.state : '', - }); + // Disabled, uninstalled, or no longer offering timers: the shell is + // authoritative, so its timers go now rather than after the grace. + for (const [id, source] of sources) { + if (keep.has(id)) continue; + source.stop(); + sources.delete(id); + log(`module "${id}" no longer offers timers`); + onRemove(id); } - // Timers reported live but missing from the (possibly stale) config - // list still deserve a card — type unknown until the slow lane catches up. - for (const [uuid, now] of live) { - out.push({ uuid, name: now.name || 'Timer', type: 'unknown', allowsOverrun: false, time: now.time, state: now.state }); + } catch (err) { + listError = errorText(err); + } + if (!stopped) { + timer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS); + timer.unref?.(); + } + } + + return { + start() { + timer = setTimeout(discover, DISCOVER_FIRST_MS); + timer.unref?.(); + }, + stop() { + stopped = true; + clearTimeout(timer); + timer = null; + for (const source of sources.values()) source.stop(); + sources.clear(); + }, + /** Health lines for the admin page: one for PCO Plan, one per other provider. */ + health() { + const parts = []; + let status = 'ok'; + const bump = (s) => { + if (s === 'error') status = 'error'; + else if (s === 'connecting' && status === 'ok') status = 'connecting'; + }; + if (!flags.pco) parts.push('PCO Plan: off'); + if (listed === null) { + parts.push(listError ? `Modules: waiting for the shell (${listError})` : 'Modules: discovering…'); + return { status: 'connecting', parts }; } - return out; + if (listError) { + parts.push(`Modules: cannot re-read /api/modules (${listError})`); + bump('error'); + } + if (flags.pco) { + const pco = sources.get(PCO_ID); + if (pco) { + const h = pco.health(); + parts.push(`PCO Plan: ${h.message}`); + bump(h.status); + } else { + parts.push('PCO Plan: not installed, or not offering timers yet'); + } + } + // Every other provider found is listed under its own name. + for (const s of [...sources.values()].filter((x) => x.id !== PCO_ID)) { + const h = s.health(); + parts.push(`${s.label}: ${h.message}`); + bump(h.status); + } + return { status, parts }; + }, + }; +} + +/* ── the module ───────────────────────────────────────────────────────── */ + +/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */ +const ORDER = { propresenter: 0, ltc: 1, clock: 2, [PCO_ID]: 3 }; + +function emptyState(timerFont = 'default') { + return { serverNow: Date.now(), timerFont, sources: [] }; +} + +function emptyProvider() { + return { source: SELF_ID, label: 'Timers', updatedAt: Date.now(), timers: [] }; +} + +module.exports = { + init({ config, log, shell }) { + const shellPort = Number(shell && shell.port) || 0; + const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default'; + const flags = { + pp: config.ppEnabled !== false, + ltc: Boolean(config.ltcEnabled), + pco: config.pcoEnabled !== false, + }; + + const tileStreams = new Set(); + const providerStreams = new Set(); + /** source id → { id, kind, label, status, message, timers, updatedAt } */ + const views = new Map(); + let latest = emptyState(timerFont); + let lastKey = ''; + let stopped = false; + + const orderedViews = () => [...views.values()].sort((a, b) => ((ORDER[a.id] ?? 10) - (ORDER[b.id] ?? 10)) || a.label.localeCompare(b.label)); + + /** The tile state: every source, its health, its timers as of updatedAt. */ + function buildState() { + return { + serverNow: Date.now(), + timerFont, + sources: orderedViews().map((v) => ({ id: v.id, label: v.label, kind: v.kind, status: v.status, message: v.message, updatedAt: v.updatedAt, timers: v.timers })), + }; } - function broadcast() { - latest = { - enabled, - ltcEnabled, - timerFont, - reachable: latest.reachable, - lastError: latest.lastError, - timers: latest.timers, - ltc: composeLtc(), - }; - if (!streams.size) return; - let frame; + /** + * The provider snapshot (the module guide's contract): all enabled + * sources merged, ids prefixed with their source so they stay unique, + * every duration re-anchored to this snapshot's own instant. + */ + function buildProviderSnapshot() { + const now = Date.now(); + const timers = []; + for (const v of orderedViews()) { + for (const t of v.timers) { + const out = { ...t, id: `${v.id}:${t.id}`, group: v.label }; + if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' }; + if (t.state === 'running' && v.updatedAt > 0) { + const dt = Math.max(0, now - v.updatedAt); + if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt; + if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt; + if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt }; + } + timers.push(out); + } + } + return { source: SELF_ID, label: 'Timers', updatedAt: now, timers }; + } + + /** Recompute the merged state; broadcast only when something other than the clocks moved. */ + function rebuild() { + if (stopped) return; + latest = buildState(); + // updatedAt / serverNow move on every poll, and a startedAt derived + // from a whole-second value jitters with them; the tiles extrapolate + // from the values but don't need a frame for that alone. + let key; try { - frame = JSON.stringify(latest); + key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers.map(({ startedAt, ...rest }) => rest)])); } catch { return; } - 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); - } - } + if (key === lastKey) return; + lastKey = key; + if (tileStreams.size) writeAll(tileStreams, `event: state\ndata: ${JSON.stringify(latest)}\n\n`); + if (providerStreams.size) writeAll(providerStreams, `event: timers\ndata: ${JSON.stringify(buildProviderSnapshot())}\n\n`); } - async function pollTick() { + const emitFor = (source) => (view) => { if (stopped) return; + views.set(source.id, { + id: source.id, + kind: source.kind, + label: String(view.label || source.label), + status: view.status === 'ok' || view.status === 'connecting' || view.status === 'error' ? view.status : 'ok', + message: String(view.message || ''), + timers: Array.isArray(view.timers) ? view.timers : [], + updatedAt: Number(view.updatedAt) || Date.now(), + }); + rebuild(); + }; + + // The fixed sources, each behind its admin switch. Add a platform here. + const fixed = []; + if (flags.pp) fixed.push(createProPresenterSource({ config, log })); + else log('ProPresenter timers are off (admin switch)'); + if (flags.ltc) fixed.push(createLtcSource({ config, log, moduleDir: __dirname })); + fixed.push(createClockSource()); + + const discovery = createModuleDiscovery({ + shellPort, + log, + flags, + onAdd(source) { + source.start(emitFor(source)); + }, + onRemove(id) { + views.delete(id); + rebuild(); + }, + }); + + for (const source of fixed) { try { - tick += 1; - // Slow lane first on the very first tick so cards get names/types - // immediately; afterwards every SLOW_EVERY ticks. - if (tick % SLOW_EVERY === 1 || !timerConfigs.size) { - applyTimerList(await requestJson('/v1/timers')); - } - // THE HOT PATH — live values every tick. - const currentTimes = await requestJson('/v1/timers/current'); - latest.timers = mergeTimers(currentTimes); - latest.reachable = true; - latest.lastError = ''; + source.start(emitFor(source)); } catch (err) { - latest.reachable = false; - latest.lastError = err instanceof Error ? err.message : String(err); - } - broadcast(); - if (stopped) return; - pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS); - } - - if (enabled) { - pollTick(); - log(`polling timers on ${host}:${port}`); - } else { - log('no ProPresenter host configured — set one in /admin'); - } - - // The built-in listener is independent of ProPresenter — LTC-only - // setups (no host configured) still get the LTC card. - let ltcListener = null; - if (ltcEnabled) { - if (ltcDevice) { - ltcListener = createLtcListener({ - device: ltcDevice, - channel: ltcChannel, - moduleDir: __dirname, - log, - onUpdate(update) { - listenerLtc = update; - broadcast(); - }, - }); - } else { - listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' }; - broadcast(); + log(`${source.id} failed to start: ${err.message}`); } } + discovery.start(); + if (!flags.pco) log('PCO Plan timers are off (admin switch)'); const heartbeat = setInterval(() => { - for (const res of streams) { - try { - res.write(': ping\n\n'); - } catch { - streams.delete(res); - } - } + writeAll(tileStreams, ': ping\n\n'); + writeAll(providerStreams, ': ping\n\n'); }, HEARTBEAT_MS); heartbeat.unref?.(); - current = { streams, getLatest: () => latest }; + current = { + tileStreams, + providerStreams, + getState: () => ({ ...latest, serverNow: Date.now() }), + getProviderSnapshot: buildProviderSnapshot, + }; return { stop() { stopped = true; - clearTimeout(pollTimer); clearInterval(heartbeat); - ltcListener?.stop(); - for (const res of streams) { - try { res.end(); } catch { /* already gone */ } + discovery.stop(); + for (const source of fixed) { + try { source.stop(); } catch { /* already down */ } } - streams.clear(); - if (current && current.streams === streams) current = null; + for (const set of [tileStreams, providerStreams]) { + for (const res of set) { + try { res.end(); } catch { /* already gone */ } + } + set.clear(); + } + if (current && current.tileStreams === tileStreams) current = null; }, health() { - // LTC status suffix for the admin health line. - const ltcNote = !ltcEnabled - ? '' - : latest.ltc.supported - ? `, LTC ${latest.ltc.status}` - : (latest.ltc.note ? ` (LTC: ${latest.ltc.note})` : ''); - if (!enabled) { - // The LTC listener works regardless of ProPresenter — an - // LTC-only setup is half-configured but working. - if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only, ${latest.ltc.status}` }; - return { status: 'error', message: 'No ProPresenter host configured' }; - } - if (latest.reachable) { - return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` }; - } - return { - status: 'error', - message: latest.lastError - ? `ProPresenter unreachable: ${latest.lastError}` - : 'Trying to reach ProPresenter…', + const parts = []; + let status = 'ok'; + const bump = (s) => { + if (s === 'error') status = 'error'; + else if (s === 'connecting' && status === 'ok') status = 'connecting'; }; + for (const source of fixed) { + if (source.id === 'clock') continue; + const h = source.health(); + parts.push(`${source.label}: ${h.message}`); + bump(h.status); + } + if (!flags.pp) parts.push('ProPresenter: off'); + if (!flags.ltc) parts.push('LTC: off'); + const d = discovery.health(); + parts.push(...d.parts); + bump(d.status); + return { status, message: parts.join(' · ') }; }, }; }, /** - * The Add-tile picker's entries: one per discovered timer, plus "All - * timers" and the LTC card (moduleApi.variant carries the entry id — - * 'all', 'timer:' or 'ltc'). Answered from the poller's latest - * state, never from a fresh upstream request — the picker must stay - * instant, and a dead ProPresenter must not stall it. + * The Add-tile picker's entries: "All timers" plus one solo card per known + * timer from any source (moduleApi.variant carries the entry id — 'all', + * or 'solo::'; the older 'timer:' / 'ltc' ids keep + * working). Answered from the merged state, never from upstream — the + * picker must stay instant and a dead upstream must not stall it. */ tiles() { - const state = current ? current.getLatest() : null; - if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry - const TYPE_LABEL = { - countdown: 'Countdown timer', - countdown_to_time: 'Countdown to a time of day', - elapsed: 'Elapsed timer', - unknown: 'Timer', - }; + const state = current ? current.getState() : null; + if (!state) return []; + const KIND_LABEL = { countdown: 'Countdown', elapsed: 'Elapsed timer', clock: 'Clock' }; const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }; - return [ - { - id: 'all', - name: 'All timers', - description: state.ltcEnabled - ? 'Every timer and the LTC timecode, selectable per tile' - : 'Every timer, selectable per tile', - }, - ...state.timers.map((t) => ({ - id: `timer:${t.uuid}`, - name: t.name, - description: TYPE_LABEL[t.type] || 'Timer', - ...oneCard, - })), - // The LTC entry exists only while the admin switch is on. - ...(state.ltcEnabled - ? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }] - : []), - ]; + const out = [{ id: 'all', name: 'All timers', description: 'Every timer, clock and timecode — choose which per tile' }]; + for (const src of state.sources) { + for (const t of src.timers) { + const what = src.id === 'ltc' ? 'Incoming timecode (HH:MM:SS:FF)' : src.id === 'clock' ? 'Time of day' : KIND_LABEL[t.kind] || 'Timer'; + out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description: `${src.label} · ${what}`, ...oneCard }); + } + } + return out; }, routes() { - const sendJson = (res, code, body) => { - res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify(body)); - }; return { 'GET /state': (req, res) => { - sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) }); + sendJson(res, 200, { state: current ? current.getState() : emptyState() }); + }, + + 'GET /stream': (req, res) => { + const state = current ? current.getState() : emptyState(); + openSse(req, res, current ? current.tileStreams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`); + }, + + // ProdDash itself as a timers provider — the module guide's contract. + 'GET /timers': (req, res) => { + sendJson(res, 200, current ? current.getProviderSnapshot() : emptyProvider()); + }, + + 'GET /timers/stream': (req, res) => { + const snap = current ? current.getProviderSnapshot() : emptyProvider(); + openSse(req, res, current ? current.providerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`); }, // The admin device picker asks this machine what it can hear. The // options shape matches the admin select's optionsRoute contract. 'GET /ltc/devices': (req, res) => { - const bin = require('path').join(__dirname, 'ltc-capture'); + const bin = path.join(__dirname, 'ltc-capture'); const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); let err = ''; child.stderr.setEncoding('utf8'); @@ -405,25 +1118,6 @@ module.exports = { sendJson(res, 200, { options }); }); }, - - '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'); - const state = current ? current.getLatest() : clearedState(false); - res.write(`event: state\ndata: ${JSON.stringify(state)}\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)); - }, }; }, }; diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index 129771e..9a9c8a4 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -1,11 +1,16 @@ -/* ProPresenter Timers module — scoped to its tiles, shell variables only. - The API exposes no per-timer color, so a card's color IS its state: - accent = running, muted = stopped, warn = complete, danger = overrun. - Overrun must be unmistakable at a distance — danger fill + pulse. */ +/* Timers module — scoped to its tiles, shell variables only. + A card's colour IS its state: running / warning / overrun / idle. The four + tones are custom properties on the tile root: they default to the shell's + palette (so a dashboard theme reaches them) and the tile's own colour + settings, when changed, are set inline on .tm-wrap and win — the + documented mechanism. Overrun must be unmistakable across the room: + danger fill + pulse. */ [data-module="propresenter-timers"] .tm-wrap { - --tm-time-size: 32px; - --tm-ltc-size: 26px; + --tm-ok: var(--accent); + --tm-warn: var(--warn); + --tm-danger: var(--danger); + --tm-muted: var(--muted); --tm-meta-size: 11px; position: absolute; inset: 0; @@ -15,7 +20,7 @@ -webkit-user-select: none; } -/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */ +/* ── degraded-state banner (the ProdDash feed itself is down) ── */ [data-module="propresenter-timers"] .tm-banner { flex: none; @@ -30,12 +35,11 @@ } [data-module="propresenter-timers"] .tm-banner[hidden] { display: none; } -/* Values still on screen while the feed is down are stale — say so visually. - Per-card, because the reader-fed LTC card is not stale when only - ProPresenter is unreachable; it dims only when the ProdDash feed itself - is down (is-feed-down) and everything on screen really is old. */ -[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; } -[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; } +/* Values still on screen while their source is down are stale — say so + visually, per group: the LTC card keeps ticking when only ProPresenter + is unreachable. When the ProdDash feed is down everything is old. */ +[data-module="propresenter-timers"] .tm-group.is-stale .tm-card, +[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; } [data-module="propresenter-timers"] .tm-hint { flex: 1; @@ -49,7 +53,69 @@ } [data-module="propresenter-timers"] .tm-hint[hidden] { display: none; } -/* ── card grid (column count set by client.js fit()) ── */ +/* ── source groups: heading + card grid (column count set by fit()) ── */ + +[data-module="propresenter-timers"] .tm-groups { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--tm-gap, 8px); + padding: var(--tm-pad, 10px); +} +[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; } + +[data-module="propresenter-timers"] .tm-group { + min-height: 0; + display: flex; + flex-direction: column; + flex: 1 1 0; +} + +[data-module="propresenter-timers"] .tm-head { + flex: none; + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + margin: 0 2px 4px; + font-size: 11px; + line-height: 16px; + color: var(--muted); +} +[data-module="propresenter-timers"] .tm-head[hidden] { display: none; } + +[data-module="propresenter-timers"] .tm-title { + flex: none; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* The source's own word while it isn't OK ("Unreachable at …"). */ +[data-module="propresenter-timers"] .tm-note { + flex: 1; + min-width: 0; + font-weight: 500; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); } +[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); } + +/* Headings off: a source in trouble, or with nothing to show, still says so. */ +[data-module="propresenter-timers"] .tm-empty { + flex: none; + padding: 2px 4px; + color: var(--muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; } +[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); } [data-module="propresenter-timers"] .tm-grid { flex: 1; @@ -57,16 +123,16 @@ display: grid; grid-auto-rows: 1fr; gap: var(--tm-gap, 8px); - padding: var(--tm-pad, 10px); } -[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; } +[data-module="propresenter-timers"] .tm-grid:empty { display: none; } -/* Compact cards (short tiles): the state line goes, its color stays. - The one exception is the LTC card with no timecode API behind it — - that message must never be hidden. */ -[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; } -[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; } -[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; } +/* Compact cards (short tiles) and "Status lines" off: the lines under the + digits go, the colour stays. A note that explains a missing value (no + LTC input, a solo timer no longer offered) must never be hidden. */ +[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note), +[data-module="propresenter-timers"] .tm-compact .tm-detail, +[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note), +[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; } /* ── one timer card ── */ @@ -98,13 +164,20 @@ text-transform: uppercase; color: var(--muted); white-space: nowrap; +} + +/* The label is its own box so a long name ends in an ellipsis (a flex + container's bare text won't). */ +[data-module="propresenter-timers"] .tm-label { + min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } [data-module="propresenter-timers"] .tm-time { flex: none; - font-size: var(--tm-time-size); + font-size: 32px; /* fit() sets the real size per card */ font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; @@ -113,14 +186,14 @@ white-space: nowrap; } -/* Admin "Timer font: Monospace" — steadier digits as the numbers change. - Applies to timer and LTC values alike (both are .tm-time). */ +/* Admin "Timer font: Monospace" — steadier digits as the numbers change. */ [data-module="propresenter-timers"] .tm-mono .tm-time { font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace; letter-spacing: 0; } -[data-module="propresenter-timers"] .tm-state { +[data-module="propresenter-timers"] .tm-status, +[data-module="propresenter-timers"] .tm-detail { flex: none; max-width: 100%; font-size: var(--tm-meta-size); @@ -132,102 +205,98 @@ overflow: hidden; text-overflow: ellipsis; } +[data-module="propresenter-timers"] .tm-status[hidden], +[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; } -/* ── state looks ── */ - -[data-module="propresenter-timers"] .tm-card.is-running { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, transparent); -} -[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); } -[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); } - -[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); } - -[data-module="propresenter-timers"] .tm-card.is-complete { - border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); - background: color-mix(in srgb, var(--warn) 10%, transparent); -} -[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); } -[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); } - -/* A solo tile whose timer was deleted in ProPresenter. */ -[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; } -[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); } -[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); } - -/* Overrun: readable across the room even on a wall display. */ -[data-module="propresenter-timers"] .tm-card.is-overrun { - border-color: var(--danger); - background: color-mix(in srgb, var(--danger) 22%, transparent); - animation: tm-overrun-pulse 1.2s ease-in-out infinite; -} -[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); } -[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state { - color: var(--danger); - font-weight: 800; +[data-module="propresenter-timers"] .tm-detail { + font-weight: 600; + letter-spacing: 0.04em; + text-transform: none; + opacity: 0.85; } -@keyframes tm-overrun-pulse { - 50% { background: color-mix(in srgb, var(--danger) 38%, transparent); } -} +/* A provider's status line carries its own tone ("ahead 1:20" in green). */ +[data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); } +[data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); } +[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); } +[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); } -/* ── the LTC card: visually its own thing ── */ - -[data-module="propresenter-timers"] .tm-card-ltc { - border-style: dashed; -} -[data-module="propresenter-timers"] .tm-card-ltc .tm-time { - font-size: var(--tm-ltc-size); -} - -[data-module="propresenter-timers"] .tm-ltc-badge { - flex: none; - padding: 0.15em 0.55em; - border-radius: 5px; - background: color-mix(in srgb, var(--accent) 24%, transparent); - color: var(--text); - font-size: 0.95em; - letter-spacing: 0.12em; -} - -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, transparent); -} -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); } -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); } - -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal { - border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); -} -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); } -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state { - color: var(--warn); - animation: tm-nosignal-blink 1.6s steps(2) infinite; -} - -@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } - -/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors, - stopping the clock is usually somebody doing their job. */ -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); } - -/* No timecode API on this ProPresenter: say so, quietly. */ -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); } -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state { +/* Explanatory notes read as sentences, not badges. */ +[data-module="propresenter-timers"] .tm-status.is-note { text-transform: none; letter-spacing: 0.02em; white-space: normal; text-align: center; } +/* ── state looks (feedbacks) ── */ + +[data-module="propresenter-timers"] .tm-card.is-ok { + border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border)); + background: color-mix(in srgb, var(--tm-ok) 10%, transparent); +} +[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); } + +[data-module="propresenter-timers"] .tm-card.is-warn { + border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border)); + background: color-mix(in srgb, var(--tm-warn) 10%, transparent); +} +[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); } + +[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); } + +/* The wall clock is information, not a state — plain text colour. */ +[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); } + +/* Overrun: readable across the room even on a wall display. */ +[data-module="propresenter-timers"] .tm-card.is-danger { + border-color: var(--tm-danger); + background: color-mix(in srgb, var(--tm-danger) 22%, transparent); +} +[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); } +[data-module="propresenter-timers"] .tm-card.is-overrun { + animation: tm-overrun-pulse 1.2s ease-in-out infinite; +} +[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; } + +@keyframes tm-overrun-pulse { + 50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); } +} + +/* A solo tile whose timer is no longer offered. */ +[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; } + +/* ── the LTC card: visually its own thing ── */ + +[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; } + +[data-module="propresenter-timers"] .tm-ltc-badge { + flex: none; + padding: 0.15em 0.55em; + border-radius: 5px; + background: color-mix(in srgb, var(--tm-ok) 24%, transparent); + color: var(--text); + font-size: 0.95em; + letter-spacing: 0.12em; +} + +/* No signal: muted digits, a blinking warning line. */ +[data-module="propresenter-timers"] .tm-card.is-nosignal { + border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border)); +} +[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status { + animation: tm-nosignal-blink 1.6s steps(2) infinite; +} + +@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } + /* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ [data-module="propresenter-timers"] .tm-menu-item { display: flex; align-items: center; - gap: 8px; + justify-content: space-between; + gap: 14px; width: 100%; padding: 6px 9px; background: none; @@ -241,16 +310,41 @@ white-space: nowrap; } [data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); } - -[data-module="propresenter-timers"] .tm-menu-item .check { - width: 14px; - flex: none; - color: var(--accent); - font-weight: 700; - visibility: hidden; -} -[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; } [data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; } +[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); } + +/* A small switch: off = hollow pill, on = filled with the accent. (The menu + hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour + properties don't reach it — the shell's palette does.) */ +[data-module="propresenter-timers"] .tm-switch { + flex: none; + position: relative; + width: 26px; + height: 14px; + border-radius: 7px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 25%, transparent); + transition: background 0.15s ease, border-color 0.15s ease; +} +[data-module="propresenter-timers"] .tm-switch::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text); + transition: transform 0.15s ease; +} +[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch { + border-color: var(--accent); + background: var(--accent); +} +[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after { + transform: translateX(12px); + background: var(--bg); +} [data-module="propresenter-timers"] .tm-menu-note { padding: 6px 9px; diff --git a/public/admin/admin.css b/public/admin/admin.css index bb9816f..e895423 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -94,6 +94,35 @@ body { overflow: auto; } } .module-health.error { color: var(--danger); } +/* a module card's own status line ("polling timers on 10.3.11.20:1025", + "no ProPresenter host configured"): a readout, so it sits in a bordered + monospace box a shade lighter than the card; ok / connecting / error + show as the text (and border) colour, the box itself never changes */ +.module-status { + display: inline-block; + max-width: 100%; + margin-top: 8px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--text) 5%, var(--panel)); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + line-height: 1.45; + color: var(--muted); + overflow-wrap: anywhere; +} +.module-status:empty { display: none; } +.module-status.ok { color: var(--text); } +.module-status.connecting { + color: var(--warn); + border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); +} +.module-status.error { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); +} + .module-mount-error { margin-top: 8px; padding: 8px 10px; @@ -121,6 +150,10 @@ body { overflow: auto; } border-radius: 9px; } .field-group-title { + display: flex; + align-items: center; + gap: 7px; + position: relative; /* the ⓘ popover hangs off this row */ font-size: 11px; font-weight: 700; text-transform: uppercase; @@ -128,8 +161,82 @@ body { overflow: auto; } color: var(--muted); margin-bottom: 10px; } +.field-group-name { min-width: 0; } .field-group .field:last-child { margin-bottom: 0; } +/* group help (configGroups[name].help): an ⓘ beside the title. Hover or + focus peeks at the text; a click pins it open until the next click. */ +.group-info { display: inline-flex; align-items: center; } +.group-info-btn { + width: 16px; + height: 16px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--muted); + border-radius: 50%; + background: transparent; + color: var(--muted); + font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif; + text-transform: none; + letter-spacing: 0; + cursor: help; +} +.group-info-btn:hover, +.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); } +.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.group-info-pop { + display: none; + position: absolute; + left: 0; + top: calc(100% + 6px); + z-index: 5; + max-width: min(440px, 100%); + padding: 8px 10px; + background: color-mix(in srgb, var(--text) 6%, var(--panel)); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + color: var(--text); + font-size: 12px; + font-weight: 400; + line-height: 1.5; + letter-spacing: normal; + text-transform: none; + white-space: normal; + cursor: auto; +} +/* keeps the popover open while the pointer crosses the gap down to it */ +.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; } +.group-info:hover .group-info-pop, +.group-info-btn:focus-visible + .group-info-pop, +.group-info.is-open .group-info-pop { display: block; } + +/* group toggle (configGroups[name].toggle): the named switch sits at the + right of the title row as the group's on/off; the body dims while it is + off but stays editable */ +.field-group-toggle { + margin-left: auto; + display: inline-flex; + align-items: center; + flex: none; +} +.field-group-toggle .field { + margin: 0; + max-width: none; + font-size: 12px; + font-weight: 600; + letter-spacing: normal; + text-transform: none; + color: var(--text); +} +.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; } +.field-group-toggle .field.switch-field .switch-label { font-size: 12px; } +.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; } +.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; } +.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; } + /* collapsible groups (manifest configGroups[name].collapsed) */ details.field-group > summary.field-group-title { cursor: pointer; @@ -160,18 +267,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); } gap: 4px 16px; } .field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } -.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } @media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } } -.field-group-help, +/* one line of guidance under a control (field `help`) */ .field-help { display: block; font-size: 11.5px; line-height: 1.45; color: var(--muted); + margin-top: 2px; } -.field-group-help { margin: -4px 0 8px; } -.field-help { margin-top: 2px; } .field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } /* showWhen: a hidden dependent field must actually collapse — `.field`'s @@ -190,12 +295,19 @@ details.field-group > summary.field-group-title:hover { color: var(--text); } } .field.switch-field .switch-label { font-size: 13px; } +/* the card's one action row: status on the left, Configure / Apply on the right */ .module-actions { display: flex; - align-items: center; + align-items: flex-end; /* the button stays bottom-right when a readout wraps */ gap: 10px; - margin-top: 4px; + margin-top: 10px; } +.module-name.is-toggle { cursor: pointer; } +.module-name.is-toggle:hover, +.module-name.is-toggle:focus-visible { color: var(--accent); outline: none; } +.module-actions .admin-status { flex: 1; margin-top: 0; } +.module-actions .module-status { flex: 0 1 auto; min-width: 0; margin-top: 0; } +.module-actions .btn { flex: none; margin-left: auto; } .admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; } .admin-status.error { color: var(--danger); } @@ -312,3 +424,69 @@ a.btn { text-decoration: none; } .catalog-title { display: flex; align-items: center; gap: 8px; } .catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-health { margin-top: 2px; } + +/* ── theme ──────────────────────────────────────────────────────────── */ + +/* one swatch per preset: a miniature dashboard (page, a tile on it, the + accent as a status dot) and the name; the current one wears the accent */ +.theme-swatches { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 10px; + margin-bottom: 6px; +} +.theme-swatch { + display: flex; + flex-direction: column; + gap: 7px; + padding: 8px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + font: inherit; + font-size: 12px; + font-weight: 600; + text-align: left; + cursor: pointer; +} +.theme-swatch:hover { border-color: var(--muted); } +.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.theme-preview { + position: relative; + display: block; + height: 46px; + border-radius: 6px; + background: var(--sw-bg); + border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent)); + overflow: hidden; +} +.theme-preview::before { + content: ""; + position: absolute; + left: 8px; right: 8px; top: 10px; bottom: 8px; + border-radius: 4px; + background: var(--sw-panel); + border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent)); +} +.theme-preview::after { + content: ""; + position: absolute; + left: 14px; top: 16px; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--sw-accent); + box-shadow: 0 0 6px var(--sw-accent); +} +.theme-name { display: flex; align-items: center; gap: 6px; } +.theme-swatch.is-current .theme-name::after { + content: "current"; + margin-left: auto; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); +} +#theme-status { margin-top: 4px; } diff --git a/public/admin/admin.js b/public/admin/admin.js index f44bb81..ff771cc 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status'); const updateBtn = document.getElementById('update-btn'); const updateChanges = document.getElementById('update-changes'); const shellVersionEl = document.getElementById('shell-version'); +const themeSection = document.getElementById('theme-section'); +const themeSwatches = document.getElementById('theme-swatches'); +const themeStatus = document.getElementById('theme-status'); /** The shell version the page loaded with — the update flow watches it change. */ let shellVersion = ''; @@ -58,6 +61,7 @@ function showLogin() { availableSection.hidden = true; layoutsSection.hidden = true; updateCard.hidden = true; + themeSection.hidden = true; passcodeInput.focus(); } @@ -277,6 +281,99 @@ function groupHasValues(fields, mod) { }); } +/* ── group title row: the ⓘ and the group toggle ────────────────────── */ + +let groupInfoSeq = 0; + +function closeGroupInfos(except) { + for (const open of document.querySelectorAll('.group-info.is-open')) { + if (open === except) continue; + open.classList.remove('is-open'); + open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false'); + } +} + +/** + * configGroups[name].help as an ⓘ beside the group title: click toggles a + * small popover with the text (hover and keyboard focus show it too), so a + * group can carry a paragraph without pushing its fields down the page. A + * real button, labelled, so it works from the keyboard; inside a + * it must never fold the group. + */ +function groupInfo(groupName, text) { + const wrap = document.createElement('span'); + wrap.className = 'group-info'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'group-info-btn'; + btn.textContent = 'i'; + btn.setAttribute('aria-label', `About ${groupName}`); + btn.setAttribute('aria-expanded', 'false'); + const pop = document.createElement('span'); + pop.className = 'group-info-pop'; + pop.id = `group-info-${++groupInfoSeq}`; + pop.setAttribute('role', 'note'); + pop.textContent = text; + btn.setAttribute('aria-describedby', pop.id); + wrap.append(btn, pop); + const setOpen = (open) => { + wrap.classList.toggle('is-open', open); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }; + btn.addEventListener('click', (e) => { + e.preventDefault(); // a click in a would otherwise fold the group + e.stopPropagation(); + const open = !wrap.classList.contains('is-open'); + closeGroupInfos(wrap); + setOpen(open); + }); + pop.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + wrap.addEventListener('keydown', (e) => { + // Enter / Space activate the button (a click); they must not reach a + // above as well. Escape closes and hands focus back. + if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); + if (e.key === 'Escape' && wrap.classList.contains('is-open')) { + e.stopPropagation(); + setOpen(false); + btn.focus(); + } + }); + return wrap; +} + +// A click anywhere else, or Escape, closes an open popover. +document.addEventListener('pointerdown', (e) => { + if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeGroupInfos(); +}); + +/** + * configGroups[name].toggle: the named switch/boolean field is the group's + * on/off. It moves into the title row, right-aligned, and the body dims + * while it is off (fields stay editable). The field itself is untouched — + * it is still read on save and still drives showWhen dependents. + */ +function mountGroupToggle(group, field, control) { + const slot = document.createElement('span'); + slot.className = 'field-group-toggle'; + slot.appendChild(field); + // Inside a , flipping the switch must not fold the group. + slot.addEventListener('click', (e) => e.stopPropagation()); + slot.addEventListener('keydown', (e) => { + if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); + }); + group.title.appendChild(slot); + group.el.classList.add('has-toggle'); + const sync = () => group.el.classList.toggle('is-off', !control.checked); + control.addEventListener('change', sync); + sync(); +} + function buildModuleCard(mod) { const card = document.createElement('section'); card.className = 'admin-card'; @@ -351,8 +448,11 @@ function buildModuleCard(mod) { desc.textContent = mod.description; card.appendChild(desc); + // The module's own words about its upstream ("polling timers on + // 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box + // and the state (ok / connecting / error) is its text colour. const healthEl = document.createElement('div'); - healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + healthEl.className = 'module-status ' + healthClass(mod); healthEl.textContent = healthText(mod); card.appendChild(healthEl); @@ -395,7 +495,9 @@ function buildModuleCard(mod) { const collapsibles = []; // { el, meta, fields } groups that fold // Fields render in schema order; a `group` starts a labeled subsection // and consecutive same-group fields share it. The manifest's optional - // `configGroups[name]` adds { collapsed, columns, help } for that group. + // `configGroups[name]` adds { collapsed, columns, help, toggle } for + // that group: help is an ⓘ beside the title, toggle names the switch + // that is the group's on/off and lives in the title row. const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; let groupName = null; let container = form; @@ -412,7 +514,11 @@ function buildModuleCard(mod) { group.className = 'field-group'; const title = document.createElement(folds ? 'summary' : 'div'); title.className = 'field-group-title'; - title.textContent = g; + const titleName = document.createElement('span'); + titleName.className = 'field-group-name'; + titleName.textContent = g; + title.appendChild(titleName); + if (meta.help) title.appendChild(groupInfo(g, String(meta.help))); group.appendChild(title); const body = document.createElement('div'); body.className = 'field-group-body'; @@ -421,16 +527,16 @@ function buildModuleCard(mod) { body.classList.add('is-columns'); body.style.setProperty('--cols', String(cols)); } - if (meta.help) { - const help = document.createElement('div'); - help.className = 'field-group-help'; - help.textContent = meta.help; - body.appendChild(help); - } group.appendChild(body); form.appendChild(group); container = body; - currentGroup = { el: group, meta, fields: [] }; + currentGroup = { + el: group, + title, + meta, + fields: [], + toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '', + }; if (folds) collapsibles.push(currentGroup); } else { container = form; @@ -444,7 +550,13 @@ function buildModuleCard(mod) { else if (refetch) reloaders.push(() => refetch()); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (currentGroup) currentGroup.fields.push({ key, spec }); - container.appendChild(field); + if (currentGroup && control && key === currentGroup.toggleKey) { + // The group's on/off: title row, not the body (never both). A + // toggle naming a non-boolean field is ignored and renders normally. + mountGroupToggle(currentGroup, field, control); + } else { + container.appendChild(field); + } } // A field with showWhen:"K" is visible only while K's toggle is on. for (const dep of dependents) { @@ -473,26 +585,52 @@ function buildModuleCard(mod) { const actions = document.createElement('div'); actions.className = 'module-actions'; const save = document.createElement('button'); - save.className = 'btn primary'; - save.textContent = 'Save & re-init'; const status = document.createElement('span'); status.className = 'admin-status'; - actions.append(save, status); - form.appendChild(actions); + actions.append(status, save); + // A card is a status line until someone wants to change something: the + // form starts folded, and the row at the bottom holds one button that + // does both jobs — "Configure" opens the form and becomes "Apply", which + // saves without closing it. The name is the other way in and out. card.appendChild(form); + card.appendChild(actions); + const openKey = 'proddash:admin:open:' + mod.id; + const setOpen = (open) => { + form.hidden = !open; + status.hidden = !open; + save.textContent = open ? 'Apply' : 'Configure'; + save.className = open ? 'btn primary' : 'btn'; + // Folded, the readout shares the bottom row with Configure instead of + // taking a line of its own; open, it goes back above the form. + if (open) card.insertBefore(healthEl, mountError); + else actions.insertBefore(healthEl, status); + try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ } + }; + let wasOpen = false; + try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ } + setOpen(wasOpen); + name.classList.add('is-toggle'); + name.title = 'Show or hide this module\'s settings'; + name.tabIndex = 0; + name.setAttribute('role', 'button'); + name.addEventListener('click', () => setOpen(form.hidden)); + name.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); } + }); save.addEventListener('click', async () => { + if (form.hidden) return setOpen(true); const config = {}; for (const [key, read] of readers) config[key] = read(); save.disabled = true; - status.textContent = 'Saving…'; + status.textContent = 'Applying…'; status.className = 'admin-status'; try { await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, { method: 'PUT', body: JSON.stringify({ config }), }); - status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.'; + status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.'; status.className = 'admin-status ok'; refreshStatus(); // What the module can list may have changed (credentials just @@ -531,7 +669,7 @@ async function refreshStatus() { if (!els) continue; els.dot.className = 'dot ' + healthClass(mod); els.healthEl.textContent = healthText(mod); - els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + els.healthEl.className = 'module-status ' + healthClass(mod); if (!mod.incompatible) { els.mountError.hidden = !mod.mountError; els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; @@ -543,7 +681,7 @@ async function refreshStatus() { } } -/* ── named layouts ──────────────────────────────────────────────────── */ +/* ── saved layouts ──────────────────────────────────────────────────── */ async function loadLayouts() { let body; @@ -559,7 +697,7 @@ async function loadLayouts() { if (!layouts.length) { const none = document.createElement('div'); none.className = 'layouts-empty'; - none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.'; + none.textContent = 'No layouts saved yet. Save one from the dashboard’s Layout menu.'; layoutsList.appendChild(none); return; } @@ -640,10 +778,133 @@ async function loadState() { for (const mod of state.modules || []) { modulesList.appendChild(buildModuleCard(mod)); } + loadTheme(); loadLayouts(); loadCatalog(false); } +/* ── theme ──────────────────────────────────────────────────────────── */ + +/* The dashboard palette is a server-wide choice. The server lists the + presets (GET /api/theme — public, dashboards need it too) and the admin + page shows them as swatches; picking one POSTs it, applies it here at + once, and the server's `theme` shell event switches every open dashboard + — and any other admin page — live. The palettes themselves are + html[data-theme="…"] variable sets in the shell's style.css, which this + page shares, so the admin page follows its own choice. */ + +const LS_THEME = 'proddash:theme'; +let themeList = []; +let currentTheme = ''; + +function applyTheme(id) { + const theme = String(id || ''); + if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return; + document.documentElement.dataset.theme = theme; + try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ } +} + +// The last-known theme first (shared with the dashboard, same origin), so +// the page doesn't flash the default palette before the server answers. +try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ } + +function themeName(id) { + return themeList.find((t) => t.id === id)?.name || id; +} + +function renderThemes() { + themeSwatches.innerHTML = ''; + for (const t of themeList) { + const current = t.id === currentTheme; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'theme-swatch' + (current ? ' is-current' : ''); + btn.setAttribute('role', 'radio'); + btn.setAttribute('aria-checked', current ? 'true' : 'false'); + btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`; + const preview = document.createElement('span'); + preview.className = 'theme-preview'; + preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000')); + preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222')); + preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff')); + const name = document.createElement('span'); + name.className = 'theme-name'; + name.textContent = t.name; + btn.append(preview, name); + btn.addEventListener('click', () => chooseTheme(t.id)); + themeSwatches.appendChild(btn); + } +} + +/** + * Fetch the server's theme and list. Applied to this page either way (the + * endpoint is public, so even the login card wears the right palette); the + * swatch section itself is only revealed for an unlocked page. + */ +async function loadTheme(reveal = true) { + let body; + try { + body = await api('/api/theme'); + } catch { + if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer + return; + } + themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : []; + currentTheme = String(body.theme || ''); + applyTheme(currentTheme); + renderThemes(); + if (reveal) themeSection.hidden = !themeList.length; +} + +loadTheme(false); + +async function chooseTheme(id) { + if (id === currentTheme) return; + const previous = currentTheme; + currentTheme = id; + applyTheme(id); // at once, here — the server's event confirms it everywhere else + renderThemes(); + themeStatus.textContent = ''; + themeStatus.className = 'admin-status'; + try { + await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) }); + themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`; + themeStatus.className = 'admin-status ok'; + } catch (e) { + currentTheme = previous; + applyTheme(previous); + renderThemes(); + themeStatus.textContent = e.message; + themeStatus.className = 'admin-status error'; + if (e.authRequired) showLogin(); + } +} + +// Follow a theme change made elsewhere (another admin page) live. +let themeEvents = null; +let themeEventsRetry = null; + +function subscribeThemeEvents() { + try { themeEvents?.close(); } catch { /* not open */ } + themeEvents = new EventSource('/api/events'); + themeEvents.addEventListener('theme', (e) => { + let theme = ''; + try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; } + if (!theme || theme === currentTheme) return; + currentTheme = theme; + applyTheme(theme); + renderThemes(); + }); + themeEvents.onerror = () => { + if (themeEvents.readyState === EventSource.CLOSED) { + clearTimeout(themeEventsRetry); + themeEventsRetry = setTimeout(subscribeThemeEvents, 4000); + } + }; +} + +subscribeThemeEvents(); + /* ── the repo: available modules, module updates, shell update ──────── */ function fmtTime(ms) { diff --git a/public/admin/index.html b/public/admin/index.html index ec8864c..ecccb6c 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -39,11 +39,14 @@
+ +