Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da6f4c31e | ||
|
|
8e4a0e849e | ||
|
|
df2f0494f8 | ||
|
|
0548cb54fa | ||
|
|
c1152b4ab3 | ||
|
|
a692b6a9e2 | ||
|
|
e670e1ee72 | ||
|
|
4b0f161c93 | ||
|
|
dafb48d9ed | ||
|
|
4c1c7f565f | ||
|
|
e8ad99a4ce | ||
|
|
2893316a1a | ||
|
|
1410cbc169 | ||
|
|
60f82690f3 | ||
|
|
7481d1d9e2 | ||
|
|
79d3237328 | ||
|
|
67f612e371 | ||
|
|
c9e03398a6 | ||
|
|
0f0adb4c60 | ||
|
|
7bb1131378 | ||
|
|
f9cd292693 |
@@ -52,6 +52,7 @@ title-bar controls). The rules that matter most:
|
||||
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`).
|
||||
|
||||
## Reference code (read-only forks — don't develop these here)
|
||||
|
||||
|
||||
@@ -316,10 +316,15 @@ below. Nothing is installed without someone clicking.
|
||||
name, load a named layout (it becomes a local copy — browsers are never
|
||||
linked), or reset this browser's dashboard. Loading says what it did —
|
||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||
now* — with **Undo** in the message to get back the arrangement you had.
|
||||
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||
place and shows nothing, then appears when it is; it is never replaced by
|
||||
the module's default tile. Saving over an existing name asks first, and
|
||||
now; the Layouts menu can remove them* — with **Undo** in the message to
|
||||
get back the arrangement you had. A tile whose module or picker entry
|
||||
isn't offered at the moment keeps its place and shows nothing, then
|
||||
appears when it is; it is never replaced by the module's default tile.
|
||||
While any tile is in that state the same menu has a **Not shown** section
|
||||
listing each one with why — *not offered right now*, *module disabled*,
|
||||
*module not installed* — and a ✕ that removes it and frees its cells, for
|
||||
the tile that is not coming back (a module that stopped offering that
|
||||
kind of tile). Saving over an existing name asks first, and
|
||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||
@@ -403,10 +408,23 @@ below. Nothing is installed without someone clicking.
|
||||
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
|
||||
message and lands on the plan's message item (an item titled Message,
|
||||
Sermon or Teaching, else the first item under such a header). Admin
|
||||
config is the connection: Planning
|
||||
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
||||
token), the service type (a list grouped by your Planning Center folders)
|
||||
and an optional pinned plan. Everything about the display is per tile
|
||||
config is the connection: Planning Center credentials (Personal Access
|
||||
Token app ID + secret, or an OAuth token), the service types to follow (a
|
||||
list grouped by your Planning Center folders) and an optional pinned plan.
|
||||
**Tick as many service types as you like** — the module follows whichever
|
||||
of them has a plan soonest, so a campus running Sunday mornings, a
|
||||
Wednesday night and a youth service needs one dashboard, not three. The
|
||||
ticked types are **numbered in priority order**, nudged up and down with
|
||||
the arrows beside them, and that order decides the winner when two of them
|
||||
have a plan at the same date and time. Every tile's title bar then carries
|
||||
a **service-type control** for reading one of the others: pick a type and
|
||||
every PCO Plan tile on *that* browser's dashboard — plan, teams and song
|
||||
key together — moves to its plan, marked with an amber stripe, while the
|
||||
machine goes on following the live one. The choice is that browser's
|
||||
alone, it sticks until changed, and the control hides itself when only one
|
||||
service type is ticked. Live machinery belongs to the followed type:
|
||||
another type's plan shows its running order without the now marker, the
|
||||
service clock or AbleSet. Everything about the display is per tile
|
||||
(gear menu): the top-bar text (placeholders such as
|
||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||
show/hide checklist for every detail.
|
||||
@@ -503,11 +521,16 @@ below. Nothing is installed without someone clicking.
|
||||
A photo library fills it: point the module at a Google Drive folder shared
|
||||
"anyone with the link" (file name → person's name) or at a folder on the
|
||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||
title bar opens that library — drag a photo onto a slot, drag between slots
|
||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
||||
drops each into the slot matching their position, filling only slots nobody
|
||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
||||
title bar opens that library — drag a photo onto a slot (mouse, or press and
|
||||
hold on a touchscreen), drag between slots to swap, rename or clear.
|
||||
Planning Center is optional auto-fill on top, not the source of truth:
|
||||
**Sync** reads the upcoming plan's team members and places each one. A
|
||||
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
|
||||
that slot — this is how the leader and vocalists, whose position is just
|
||||
"Vocalist", land on the right mic — and everyone else goes to the slot
|
||||
matching their position, one slot per person. Anything placed or moved by
|
||||
hand for that plan stays put through later syncs; last week's people are
|
||||
replaced.
|
||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||
@@ -532,6 +555,36 @@ below. Nothing is installed without someone clicking.
|
||||
soon as it does) and **Marks** (how long a mark lives).
|
||||
Tile settings: campuses, card or list layout, and a **Show** group for the
|
||||
day, role, resi source, campuses fed, notes and the updated line.
|
||||
- **Vista Cues** — which Vista 3 cue lists are live, for a booth where
|
||||
nobody runs lighting and ProPresenter and the tracks rig fire Vista's cue
|
||||
lists over network MIDI. Vista sends no feedback of any kind (its OSC only
|
||||
takes commands), so the module listens instead: it joins the senders'
|
||||
network MIDI sessions as one more participant, the way Vista does, hears
|
||||
every note Vista hears, and turns each one into "Go on Walk-in", "Release
|
||||
Worship 1"… from the cue sheet pasted into admin. The tile shows the list
|
||||
fired last, large, with how long ago and who fired it; the other lists
|
||||
still live; the recent notes; and which senders are connected. What it
|
||||
shows is what Vista was *told*: if Vista is closed or someone works the
|
||||
desk by hand, the two can differ, and the title bar's eraser forgets every
|
||||
live list. Plain JavaScript (RTP-MIDI over UDP), no helper; on macOS it
|
||||
advertises itself over Bonjour so it shows up in Audio MIDI Setup's
|
||||
Directory. Admin config: up to three senders (a name, and the host and
|
||||
port of each machine's network session — ProPresenter, the tracks rig,
|
||||
Companion; ProdDash keeps inviting itself in, so each session's "Who may
|
||||
connect to me" must be Anyone, or connect from the sender's Directory
|
||||
instead), the cue sheet (one line per note: `0 = Pre Service`,
|
||||
`61 release Walk-in`, `127 release all`, `30 flash Blinder Flash 1` for a
|
||||
momentary hit, also pause, back, note names, a channel, program changes),
|
||||
middle C naming, whether a Go releases the others, and a folded **This
|
||||
machine** group for the session name, port (UDP 5040 and 5041) and
|
||||
Bonjour. A sheet with no release notes uses groups: a line `[Color]`
|
||||
starts one, a Go in a group replaces that group's live list, and the
|
||||
first group is the tile's headline — so the tile reads "Message" with
|
||||
Color · Blue and House · House Lights Message beside it. A Go in that
|
||||
first group (the main scene) takes over the look and clears the rest, as
|
||||
Vista's last-takes-precedence does; FX fired after it layer on again
|
||||
(the **Main group takes over** switch). Tile settings: other lists,
|
||||
recent, senders, unmapped notes — text size is − / + in the title bar.
|
||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||
reference for module authors.
|
||||
|
||||
@@ -547,7 +600,10 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
|
||||
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
|
||||
board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI session playing a cue
|
||||
sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
+24
-4
@@ -222,7 +222,9 @@ Both schemas map field names to specs:
|
||||
"help": "One line shown under the control", // optional, admin config only
|
||||
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
||||
"optionsRoute": "/devices", // "select" only, admin config only
|
||||
"optionsDependsOn": "<other key>" // with optionsRoute; see below
|
||||
"optionsDependsOn": "<other key>", // with optionsRoute; see below
|
||||
"orderable": true, // "multiselect", admin config only
|
||||
"addLabel": "Add a service type…" // with "orderable"; the dropdown's own line
|
||||
}
|
||||
```
|
||||
|
||||
@@ -242,8 +244,22 @@ marked, so nothing is dropped behind anyone's back. In a tile's gear popover
|
||||
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
||||
chosen shows every option on and saves nothing until someone flips one, so
|
||||
options that turn up later are on as well (give it no `default` then).
|
||||
A `multiselect` with **`orderable: true`** stops being a ticklist. It renders
|
||||
as a list built on purpose — the picks, numbered in their saved order, each
|
||||
with a nudge either way and a remove — over a dropdown (`addLabel` names it)
|
||||
holding everything not picked yet, `group` and all. The array is stored in
|
||||
that order, so a module can read it as a priority. It is for a setting whose
|
||||
ORDER means something, and it is also what to reach for when the list is long:
|
||||
an account with forty service types is forty switches to read past, but one
|
||||
dropdown and two picked rows. `pco-plan`'s service types are the worked
|
||||
example — several are followed, and when two of them have a plan at the same
|
||||
moment the one higher up wins. Admin config only; the tile gear's own
|
||||
multiselect ignores it.
|
||||
|
||||
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
||||
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
||||
`optionsDependsOn` follows any field, not just a select: a multiselect hands
|
||||
its values over comma-joined.
|
||||
|
||||
`template` (tile settings) is a text with placeholders, built by dragging or
|
||||
clicking the variables the module offers into it: `"variables": [ { "value":
|
||||
@@ -672,8 +688,11 @@ module.exports = {
|
||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||
in your list right now; otherwise the tile keeps its place in the layout,
|
||||
draws nothing, and appears the moment your list offers it again — a
|
||||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||
client is never asked to render an entry you did not offer at mount time.
|
||||
Service timer waits for PCO Plan, a transcript for its channel. The
|
||||
operator can remove such a tile from the Layouts menu's **Not shown**
|
||||
section, so an entry you retire for good (a send box that moved to another
|
||||
module) leaves no dead cells behind. So your client is never asked to
|
||||
render an entry you did not offer at mount time.
|
||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||
placeholder, no error — and the shell folds the tile away at its next
|
||||
@@ -1028,7 +1047,8 @@ client-side failures (a tile that fails to start says so in the tile).
|
||||
|
||||
**Poke the pieces directly:**
|
||||
|
||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
|
||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret
|
||||
config; `inactive` names the installed modules that are not running).
|
||||
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
||||
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
||||
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
||||
|
||||
+111
-47
@@ -1,5 +1,5 @@
|
||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
|
||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||
// (Wed→Tue) rains confetti.
|
||||
@@ -14,26 +14,107 @@ export default function create({ root, moduleApi }) {
|
||||
let selectedLibId = ''; // click-to-place fallback for touch
|
||||
const canvases = new Set();
|
||||
let raf = null;
|
||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||
let dragScrolling = false;
|
||||
let dragY = 0;
|
||||
let dragBody = null;
|
||||
let dragRaf = null;
|
||||
function startDragAutoScroll() {
|
||||
if (dragRaf) return;
|
||||
const tick = () => {
|
||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||
const r = dragBody.getBoundingClientRect();
|
||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||
let dy = 0;
|
||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||
if (dy) dragBody.scrollTop += dy;
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
|
||||
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
|
||||
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
|
||||
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
|
||||
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
|
||||
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
|
||||
* edges. Board updates that arrive during a drag wait until it ends. */
|
||||
const DRAG_MOVE_PX = 6;
|
||||
const TOUCH_HOLD_MS = 220;
|
||||
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
|
||||
let pending = null; // a pointerdown that may become a drag
|
||||
let renderHeld = false; // a state update arrived mid-drag; render when it ends
|
||||
|
||||
function slotUnder(x, y) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
|
||||
return cell && root.contains(cell) ? cell : null;
|
||||
}
|
||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||
function beginDrag(p) {
|
||||
const ghost = el('div', 'bl-drag-ghost');
|
||||
ghost.dataset.module = 'band-lineup'; // lives on <body> (above every tile), so it carries its own scope
|
||||
const src = p.source.querySelector('img');
|
||||
if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); }
|
||||
else ghost.textContent = p.label || '';
|
||||
document.body.appendChild(ghost);
|
||||
drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source };
|
||||
p.source.classList.add('bl-dragging');
|
||||
root.classList.add('bl-is-dragging');
|
||||
moveDrag(p.x, p.y);
|
||||
const tick = () => {
|
||||
if (!drag) return;
|
||||
const b = drag.body;
|
||||
if (b && b.isConnected) {
|
||||
const r = b.getBoundingClientRect(); const zone = 70; let dy = 0;
|
||||
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
|
||||
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
|
||||
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
|
||||
}
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
function moveDrag(x, y) {
|
||||
drag.x = x; drag.y = y;
|
||||
drag.ghost.style.transform = `translate(${x}px, ${y}px)`;
|
||||
const cell = slotUnder(x, y);
|
||||
if (cell !== drag.over) {
|
||||
if (drag.over) drag.over.classList.remove('bl-drop-over');
|
||||
drag.over = cell;
|
||||
if (cell) cell.classList.add('bl-drop-over');
|
||||
}
|
||||
}
|
||||
function endDrag(drop) {
|
||||
const d = drag; drag = null;
|
||||
if (!d) return;
|
||||
if (d.raf) cancelAnimationFrame(d.raf);
|
||||
d.ghost.remove();
|
||||
d.source.classList.remove('bl-dragging');
|
||||
root.classList.remove('bl-is-dragging');
|
||||
if (d.over) d.over.classList.remove('bl-drop-over');
|
||||
if (drop && d.over) {
|
||||
const to = d.over.dataset.index;
|
||||
if (d.kind === 'lib') assign(to, d.id);
|
||||
else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' });
|
||||
}
|
||||
if (renderHeld) { renderHeld = false; render(); }
|
||||
}
|
||||
function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; }
|
||||
// Make `node` draggable. `kind`/`id` say what is being dragged.
|
||||
function makeDraggable(node, kind, id, body, label) {
|
||||
node.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || drag) return;
|
||||
if (e.target.closest('button')) return; // name / clear / delete buttons keep working
|
||||
cancelPending();
|
||||
pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null };
|
||||
if (pending.touch) {
|
||||
const p = pending;
|
||||
p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Document-level move/up so the drag survives leaving the card it started on.
|
||||
function onPointerMove(e) {
|
||||
if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; }
|
||||
if (!pending || e.pointerId !== pending.pointerId) return;
|
||||
const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy);
|
||||
if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll
|
||||
if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); }
|
||||
}
|
||||
function onPointerUp(e) {
|
||||
if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); }
|
||||
cancelPending();
|
||||
}
|
||||
let suppressClick = false; // the click that follows a drop must not also select / place
|
||||
// While a finger drag is live, stop the page from scrolling under it.
|
||||
function onTouchMove(e) { if (drag) e.preventDefault(); }
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.addEventListener('pointercancel', onPointerUp);
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
function stopDragAutoScroll() { cancelPending(); endDrag(false); }
|
||||
|
||||
/* ---------- confetti (always animates) ---------- */
|
||||
function makeParticle(w, h, intensity, seedY) {
|
||||
@@ -79,6 +160,7 @@ export default function create({ root, moduleApi }) {
|
||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||
|
||||
function render() {
|
||||
if (drag) { renderHeld = true; return; }
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
root.innerHTML = '';
|
||||
@@ -163,8 +245,6 @@ export default function create({ root, moduleApi }) {
|
||||
// board on top and the picture picker (library) below it.
|
||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||
const body = el('div', 'bl-editor-body');
|
||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||
|
||||
// Slots (drop targets)
|
||||
const boardEl = el('div', 'bl-edit-board');
|
||||
@@ -176,14 +256,7 @@ export default function create({ root, moduleApi }) {
|
||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||
cell.appendChild(photo);
|
||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||
if (slot.person) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||
}
|
||||
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
|
||||
const nameRow = el('div', 'bl-edit-namerow');
|
||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||
@@ -191,16 +264,7 @@ export default function create({ root, moduleApi }) {
|
||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||
cell.appendChild(nameRow);
|
||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||
// drop
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||
const data = e.dataTransfer.getData('text/plain') || '';
|
||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||
});
|
||||
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
body.appendChild(boardEl);
|
||||
@@ -210,7 +274,7 @@ export default function create({ root, moduleApi }) {
|
||||
const list = state?.library || [];
|
||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||
for (const person of list) {
|
||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||
const item = el('div', 'bl-lib-item'); item.dataset.id = person.id;
|
||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||
@@ -219,12 +283,8 @@ export default function create({ root, moduleApi }) {
|
||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||
item.appendChild(del);
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
item.addEventListener('dragend', stopDragAutoScroll);
|
||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
makeDraggable(item, 'lib', person.id, body, person.name);
|
||||
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
lib.appendChild(item);
|
||||
}
|
||||
body.appendChild(lib);
|
||||
@@ -293,6 +353,10 @@ export default function create({ root, moduleApi }) {
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
stopDragAutoScroll();
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) {
|
||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||
*/
|
||||
const MIN_SCORE = 60;
|
||||
|
||||
/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */
|
||||
function mixNumbers(value) {
|
||||
const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m;
|
||||
while ((m = re.exec(String(value || '')))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a person's Planning Center note names a spot. The booth writes the mic/role into each
|
||||
* vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those
|
||||
* change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a
|
||||
* different mix than the spot is never a match; the role part ("VOX 5") is scored like a position.
|
||||
* 0 means the note says nothing usable about this spot ("NA", blank, or no overlap).
|
||||
*/
|
||||
function noteScore(slotLabel, note) {
|
||||
const text = String(note || '').trim();
|
||||
if (!text || /^n\/?a$/i.test(text)) return 0;
|
||||
const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text);
|
||||
let mixBonus = 0;
|
||||
if (noteMix.size && slotMix.size) {
|
||||
if (![...noteMix].some((n) => slotMix.has(n))) return 0;
|
||||
mixBonus = 100;
|
||||
}
|
||||
const role = text.replace(/\bmix\s*\d+\b/gi, ' ');
|
||||
const roleScore = scoreMatch(slotRoleText(slotLabel), role);
|
||||
if (!roleScore && !mixBonus) return 0;
|
||||
if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) {
|
||||
// The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither.
|
||||
const roleTokens = labelTokens(role);
|
||||
if (roleTokens.length) return 0;
|
||||
}
|
||||
return mixBonus + roleScore;
|
||||
}
|
||||
|
||||
function assignMembersToSlots(slots, members) {
|
||||
const pool = members.slice();
|
||||
return slots.map((slotLabel) => {
|
||||
const result = slots.map(() => null);
|
||||
const take = (chosen) => {
|
||||
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
|
||||
const key = chosen.personId || '';
|
||||
for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1);
|
||||
};
|
||||
// Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first.
|
||||
const pairs = [];
|
||||
slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); }));
|
||||
pairs.sort((a, b) => b.sc - a.sc);
|
||||
for (const { s, m } of pairs) {
|
||||
if (result[s] || !pool.includes(m)) continue;
|
||||
result[s] = m; take(m);
|
||||
}
|
||||
// Pass 2 — everyone else by Planning Center position, into the spots still open.
|
||||
return result.map((placed, s) => {
|
||||
if (placed) return placed;
|
||||
const slotLabel = slots[s];
|
||||
const role = slotRoleText(slotLabel);
|
||||
let bestIndex = -1;
|
||||
let bestScore = MIN_SCORE - 1;
|
||||
@@ -128,7 +180,9 @@ function assignMembersToSlots(slots, members) {
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
return pool.splice(bestIndex, 1)[0];
|
||||
const chosen = pool[bestIndex];
|
||||
take(chosen);
|
||||
return chosen;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,5 +248,7 @@ module.exports = {
|
||||
numbersIn,
|
||||
scoreMatch,
|
||||
assignMembersToSlots,
|
||||
noteScore,
|
||||
mixNumbers,
|
||||
MIN_SCORE,
|
||||
};
|
||||
|
||||
@@ -65,3 +65,41 @@ console.log('assignment:', JSON.stringify(out));
|
||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
|
||||
{
|
||||
const P = (label, name, personId) => ({ label, name, personId });
|
||||
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
|
||||
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
||||
console.log('one spot per person: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
|
||||
{
|
||||
const { noteScore } = require('./matching');
|
||||
const NA_SLOTS = [
|
||||
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
|
||||
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
|
||||
];
|
||||
const P = (label, name, personId, note) => ({ label, name, personId, note });
|
||||
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
|
||||
const oct25 = [
|
||||
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
|
||||
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
|
||||
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
|
||||
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
|
||||
];
|
||||
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
|
||||
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
|
||||
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
|
||||
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
|
||||
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
|
||||
// A note never pulls someone onto a different mix.
|
||||
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
|
||||
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
|
||||
console.log('notes: all assertions passed ✓');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "band-lineup",
|
||||
"name": "Band Lineup",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||
"client": "client.js",
|
||||
|
||||
@@ -247,7 +247,7 @@ module.exports = {
|
||||
return {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||
plan: null,
|
||||
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
|
||||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||
library: [], choir: choirOn,
|
||||
};
|
||||
@@ -493,7 +493,8 @@ module.exports = {
|
||||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||
if (pco.configured && serviceTypeId) {
|
||||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
|
||||
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
|
||||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||
else {
|
||||
|
||||
@@ -511,6 +512,7 @@ module.exports = {
|
||||
personId,
|
||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||
label: String(m.attributes?.team_position_name || '').trim(),
|
||||
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||
});
|
||||
if (personId) seen.add(personId);
|
||||
@@ -521,13 +523,16 @@ module.exports = {
|
||||
|
||||
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
||||
|
||||
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
|
||||
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
|
||||
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
|
||||
const eff = effectiveSlots();
|
||||
const planId = String(plan.id);
|
||||
const openIdx = []; const manualNames = new Set();
|
||||
for (let i = 0; i < eff.length; i += 1) {
|
||||
const e = board[String(i)];
|
||||
if (e && (e.personId || e.name)) {
|
||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||||
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (nm) manualNames.add(normName(nm));
|
||||
} else openIdx.push(i);
|
||||
}
|
||||
@@ -540,8 +545,8 @@ module.exports = {
|
||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||
} else if (board[String(i)] && board[String(i)].pco) {
|
||||
delete board[String(i)]; // was synced, no longer on the plan
|
||||
} else if (board[String(i)]) {
|
||||
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||||
}
|
||||
}
|
||||
saveBoard();
|
||||
@@ -580,6 +585,7 @@ module.exports = {
|
||||
current = {
|
||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||
planId: () => (latest.plan && latest.plan.id) || '',
|
||||
photoUrlFor, libraryList,
|
||||
setChoir, choirOn: () => choirOn,
|
||||
};
|
||||
@@ -670,6 +676,10 @@ module.exports = {
|
||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||
const a = current.board[from]; const b = current.board[to];
|
||||
// Moving a spot is a hand placement for this plan: Sync must not move it back.
|
||||
const plan = current.planId();
|
||||
if (a) a.plan = plan;
|
||||
if (b) b.plan = plan;
|
||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
@@ -684,7 +694,7 @@ module.exports = {
|
||||
const personId = String(q.get('personId') || '').trim();
|
||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||
if (personId) current.board[slot] = { personId, name: '' };
|
||||
if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
|
||||
else delete current.board[slot];
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
@@ -702,6 +712,7 @@ module.exports = {
|
||||
const name = String(q.get('name') || '').trim();
|
||||
const existing = current.board[index] || { personId: '', name: '' };
|
||||
existing.name = name;
|
||||
existing.plan = current.planId();
|
||||
if (!existing.personId && !name) delete current.board[index];
|
||||
else current.board[index] = existing;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
|
||||
@@ -230,3 +230,20 @@
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
/* Drag: the floating photo that follows the pointer (appended to <body>, so it scopes itself). */
|
||||
[data-module="band-lineup"].bl-drag-ghost {
|
||||
position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none;
|
||||
width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden;
|
||||
border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92;
|
||||
}
|
||||
[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||
[data-module="band-lineup"] .bl-lib-item,
|
||||
[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
[data-module="band-lineup"] .bl-edit-slot img,
|
||||
[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; }
|
||||
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
|
||||
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
|
||||
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
|
||||
|
||||
+38
-20
@@ -14,6 +14,7 @@
|
||||
|
||||
import createTeams from "./teams-client.js";
|
||||
import createKey from "./key-client.js";
|
||||
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
||||
import { prettyKey } from "./key-notation.js";
|
||||
|
||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||
@@ -38,6 +39,10 @@ function createPlan({ root, moduleApi }) {
|
||||
let lastCurrentId = null;
|
||||
let lastListKey = '';
|
||||
let absView = null; // what the server says AbleSet is doing, if anything
|
||||
// The plan this dashboard is looking at — usually the one the server
|
||||
// follows, otherwise one of the others it also reads.
|
||||
let view = viewOf(null);
|
||||
let offViewed = null;
|
||||
let userScrolledAt = 0;
|
||||
|
||||
root.innerHTML = `
|
||||
@@ -89,6 +94,7 @@ function createPlan({ root, moduleApi }) {
|
||||
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
||||
applyPrefs();
|
||||
}
|
||||
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||
|
||||
@@ -144,7 +150,7 @@ function createPlan({ root, moduleApi }) {
|
||||
/* ── which items to show ────────────────────────────────────────── */
|
||||
|
||||
function visibleItems() {
|
||||
const items = Array.isArray(state?.items) ? state.items : [];
|
||||
const items = Array.isArray(view.items) ? view.items : [];
|
||||
return items.filter((it) => {
|
||||
if (it.itemType === 'header' && !on('showHeaders')) return false;
|
||||
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
|
||||
@@ -194,14 +200,14 @@ function createPlan({ root, moduleApi }) {
|
||||
|
||||
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
|
||||
function templateValues() {
|
||||
const plan = state?.plan || {};
|
||||
const plan = view.plan || {};
|
||||
const items = state?.items || [];
|
||||
const planned = plannedLength();
|
||||
return {
|
||||
series: on('showSeries') ? plan.seriesTitle || '' : '',
|
||||
part: on('showPart') ? plan.title || '' : '',
|
||||
title: on('showPart') ? plan.title || '' : '',
|
||||
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
|
||||
servicetype: on('showServiceType') ? view.name || '' : '',
|
||||
date: on('showDate') ? plan.dates || '' : '',
|
||||
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
|
||||
time: on('showServiceTime') && plan.serviceStartsAt
|
||||
@@ -239,12 +245,12 @@ function createPlan({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function renderHead() {
|
||||
const plan = state?.plan;
|
||||
const plan = view.plan;
|
||||
const show = on('showTopBar') && plan;
|
||||
headEl.hidden = !show;
|
||||
if (!show) return;
|
||||
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
|
||||
planTitleEl.textContent = line1 || line2 || view.name || 'Plan';
|
||||
planSubEl.textContent = line1 ? line2 || '' : '';
|
||||
planSubEl.hidden = !planSubEl.textContent;
|
||||
renderClock();
|
||||
@@ -258,9 +264,12 @@ function createPlan({ root, moduleApi }) {
|
||||
* time once it has ended.
|
||||
*/
|
||||
function renderClock() {
|
||||
const plan = state?.plan;
|
||||
const live = state?.live || {};
|
||||
const svc = state?.service || {};
|
||||
const plan = view.plan;
|
||||
const live = view.live ? (state?.live || {}) : {};
|
||||
// The service window belongs to the type being followed. Another type's
|
||||
// plan must not borrow its clock — it would read as that service running.
|
||||
// With none, the fall-through below shows that plan's own start time.
|
||||
const svc = view.live ? (state?.service || {}) : {};
|
||||
clockLabelEl.textContent = '';
|
||||
clockValueEl.textContent = '';
|
||||
clockValueEl.className = 'pp-clock-value';
|
||||
@@ -272,7 +281,7 @@ function createPlan({ root, moduleApi }) {
|
||||
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 done = view.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;
|
||||
@@ -321,13 +330,13 @@ function createPlan({ root, moduleApi }) {
|
||||
link: { href: '/admin', text: 'Open Admin' },
|
||||
};
|
||||
}
|
||||
if (!state.reachable && !state.plan) {
|
||||
if (!state.reachable && !view.plan) {
|
||||
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||
}
|
||||
if (!state.plan) {
|
||||
if (!view.plan) {
|
||||
return {
|
||||
title: 'No plan found',
|
||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
||||
hint: `${view.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
||||
};
|
||||
}
|
||||
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
|
||||
@@ -483,7 +492,7 @@ function createPlan({ root, moduleApi }) {
|
||||
|
||||
// Rebuild rows only when the plan content or the display settings
|
||||
// changed; live highlighting and timers are applied in place.
|
||||
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
|
||||
const key = JSON.stringify([view.id, view.plan.id, view.items, cfg(), view.live && state.live?.following]);
|
||||
if (key !== lastListKey) {
|
||||
lastListKey = key;
|
||||
listEl.innerHTML = '';
|
||||
@@ -523,6 +532,7 @@ function createPlan({ root, moduleApi }) {
|
||||
/** The row AbleSet's song belongs to — only ever the current one. */
|
||||
function absRow() {
|
||||
const a = absView;
|
||||
if (!view.live) return null; // another service type is on screen
|
||||
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
|
||||
if (a.itemId !== lastCurrentId) return null;
|
||||
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
|
||||
@@ -627,15 +637,17 @@ function createPlan({ root, moduleApi }) {
|
||||
|
||||
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||
function renderLiveMarks() {
|
||||
const live = state?.live || {};
|
||||
// Only the followed service type has a live item: this dashboard may be
|
||||
// looking at another one, which has no ProPresenter and no clock.
|
||||
const live = view.live ? (state?.live || {}) : {};
|
||||
const now = serverNow();
|
||||
const currentId = live.following ? live.currentItemId || '' : '';
|
||||
const history = live.history || {};
|
||||
const byId = new Map(state.items.map((it) => [it.id, it]));
|
||||
const byId = new Map(view.items.map((it) => [it.id, it]));
|
||||
// Everything before the current item counts as done, matched or not.
|
||||
const doneIds = new Set();
|
||||
if (currentId) {
|
||||
for (const it of state.items) {
|
||||
for (const it of view.items) {
|
||||
if (it.id === currentId) break;
|
||||
if (it.itemType !== 'header') doneIds.add(it.id);
|
||||
}
|
||||
@@ -676,8 +688,8 @@ function createPlan({ root, moduleApi }) {
|
||||
/* ── footer: what ProPresenter is doing ─────────────────────────── */
|
||||
|
||||
function renderFoot() {
|
||||
const live = state?.live;
|
||||
const show = on('showLiveFooter') && live?.following && state?.plan;
|
||||
const live = view.live ? state?.live : null;
|
||||
const show = on('showLiveFooter') && live?.following && view.plan;
|
||||
footEl.hidden = !show;
|
||||
footEl.className = 'pp-foot';
|
||||
if (!show) return;
|
||||
@@ -713,7 +725,10 @@ function createPlan({ root, moduleApi }) {
|
||||
/* ── render + ticking ───────────────────────────────────────────── */
|
||||
|
||||
function render() {
|
||||
view = viewOf(state);
|
||||
switcher.sync();
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
||||
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
|
||||
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
|
||||
const color = (value, fallback) => {
|
||||
@@ -744,9 +759,9 @@ function createPlan({ root, moduleApi }) {
|
||||
|
||||
function tick() {
|
||||
renderAbleSet();
|
||||
if (!state?.plan) return;
|
||||
if (!view.plan) return;
|
||||
renderClock();
|
||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||
if (view.live && state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
@@ -805,11 +820,14 @@ function createPlan({ root, moduleApi }) {
|
||||
.then((body) => { if (body && !state) accept(body); })
|
||||
.catch(() => { /* the stream will cover it */ });
|
||||
connect();
|
||||
offViewed = onViewedType(() => { lastListKey = ''; render(); });
|
||||
listEl.addEventListener('wheel', onUserScroll, { passive: true });
|
||||
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
|
||||
ticker = setInterval(tick, 1000);
|
||||
},
|
||||
stop() {
|
||||
offViewed?.();
|
||||
offViewed = null;
|
||||
clearInterval(ticker);
|
||||
ticker = null;
|
||||
stream?.close();
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
which item is current comes from the ProPresenter module, as ever. */
|
||||
|
||||
import { parseKey, prettyKey } from "./key-notation.js";
|
||||
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
||||
|
||||
export default function createKey({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let stream = null;
|
||||
let feedOffline = false;
|
||||
let view = viewOf(null);
|
||||
let offViewed = null;
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="pk-wrap">
|
||||
@@ -23,17 +26,21 @@ export default function createKey({ root, moduleApi }) {
|
||||
const nameEl = root.querySelector('.pk-name');
|
||||
const nextEl = root.querySelector('.pk-next');
|
||||
|
||||
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
||||
|
||||
const cfg = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||
|
||||
/* ── the item on screen, and the one after it ───────────────────── */
|
||||
|
||||
function items() {
|
||||
return Array.isArray(state?.items) ? state.items : [];
|
||||
return Array.isArray(view.items) ? view.items : [];
|
||||
}
|
||||
|
||||
function currentItem() {
|
||||
const id = state?.live?.following ? state.live.currentItemId : '';
|
||||
// Only the followed service type has a live item; on another one there
|
||||
// is nothing current, so the tile says so rather than guessing.
|
||||
const id = view.live && state?.live?.following ? state.live.currentItemId : '';
|
||||
return id ? items().find((it) => it.id === id) || null : null;
|
||||
}
|
||||
|
||||
@@ -63,6 +70,7 @@ export default function createKey({ root, moduleApi }) {
|
||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||
}
|
||||
const item = currentItem();
|
||||
if (!view.live) return moduleApi.setStatus('ok', `${view.name || 'Another service type'} — not the one live`);
|
||||
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
||||
// The key on screen stays — the band still needs it — but a tile must
|
||||
// never look confidently live while the feed that chose it is gone.
|
||||
@@ -101,7 +109,10 @@ export default function createKey({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function render() {
|
||||
view = viewOf(state);
|
||||
switcher.sync();
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
||||
const live = state?.live;
|
||||
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
||||
const color = String(cfg().keyColor || '').trim();
|
||||
@@ -120,7 +131,9 @@ export default function createKey({ root, moduleApi }) {
|
||||
}
|
||||
if (!item) {
|
||||
drawKey('—');
|
||||
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
|
||||
nameEl.textContent = !view.live
|
||||
? (view.name || 'Another service type')
|
||||
: (state.live?.following ? 'Nothing live' : 'Not following ProPresenter');
|
||||
nextEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
@@ -167,9 +180,12 @@ export default function createKey({ root, moduleApi }) {
|
||||
.then((body) => { if (body && !state) accept(body); })
|
||||
.catch(() => { /* the stream will cover it */ });
|
||||
connect();
|
||||
offViewed = onViewedType(render);
|
||||
render();
|
||||
},
|
||||
stop() {
|
||||
offViewed?.();
|
||||
offViewed = null;
|
||||
stream?.close();
|
||||
stream = null;
|
||||
root.innerHTML = '';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.9.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"version": "1.10.0",
|
||||
"proddash": ">=1.12.0",
|
||||
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
||||
"provides": [
|
||||
"timers"
|
||||
@@ -46,11 +46,13 @@
|
||||
"default": "",
|
||||
"group": "Planning Center credentials"
|
||||
},
|
||||
"serviceTypeId": {
|
||||
"type": "select",
|
||||
"label": "Service type",
|
||||
"default": "",
|
||||
"optionsRoute": "/service-types"
|
||||
"serviceTypes": {
|
||||
"type": "multiselect",
|
||||
"label": "Service types",
|
||||
"orderable": true,
|
||||
"optionsRoute": "/service-types",
|
||||
"help": "Add every service type whose plans this ProdDash should follow. Order is priority: when two of them have a plan at the same date and time, the one higher up wins.",
|
||||
"addLabel": "Add a service type…"
|
||||
},
|
||||
"planId": {
|
||||
"type": "select",
|
||||
@@ -63,7 +65,8 @@
|
||||
}
|
||||
],
|
||||
"optionsRoute": "/plans",
|
||||
"optionsDependsOn": "serviceTypeId"
|
||||
"optionsDependsOn": "serviceTypes",
|
||||
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
|
||||
},
|
||||
"pollSeconds": {
|
||||
"type": "number",
|
||||
|
||||
+235
-54
@@ -73,6 +73,7 @@ const path = require('path');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
||||
const {
|
||||
MINUTE,
|
||||
DEFAULT_SERVICE_LENGTH_MS,
|
||||
serviceTimes,
|
||||
serviceWindows,
|
||||
selectService,
|
||||
@@ -111,6 +112,12 @@ const SEEN_DOCUMENTS_MAX = 200;
|
||||
* plan changes, and otherwise no more often than this.
|
||||
*/
|
||||
const TEAMS_MIN_MS = 60 * 1000;
|
||||
/**
|
||||
* The selected service types nobody is following: their plans only matter to a
|
||||
* dashboard that has switched to one, and they never move during a service, so
|
||||
* they ride a lane slower than the plan itself.
|
||||
*/
|
||||
const OTHERS_MIN_MS = 45 * 1000;
|
||||
/** How long the service type's own team list is believed. */
|
||||
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
|
||||
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
|
||||
@@ -147,6 +154,14 @@ function clearedState() {
|
||||
items: [],
|
||||
teams: [], // who is scheduled, by team (the Teams tile)
|
||||
teamsError: '', // why the last team read failed, if it did
|
||||
serviceTypes: [], // every selected service type id, highest priority first
|
||||
/**
|
||||
* The selected service types this ProdDash is NOT following right now,
|
||||
* each with its own current plan. A dashboard can switch to one of these
|
||||
* for itself; the live machinery (ProPresenter, the service clock, the
|
||||
* timers) stays with the one above, which is the service actually running.
|
||||
*/
|
||||
others: {},
|
||||
live: clearedLive(),
|
||||
service: clearedService(),
|
||||
timing: null, // the admin's service-timing choices (no secrets)
|
||||
@@ -610,7 +625,20 @@ module.exports = {
|
||||
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();
|
||||
/**
|
||||
* Every service type this ProdDash follows, highest priority first. A
|
||||
* config written before 1.10.0 has the single `serviceTypeId` instead;
|
||||
* it is read as a list of one so a booth keeps working untouched until
|
||||
* somebody opens the admin page.
|
||||
*/
|
||||
const serviceTypes = (Array.isArray(config.serviceTypes) ? config.serviceTypes : [])
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (!serviceTypes.length && String(config.serviceTypeId || '').trim()) {
|
||||
serviceTypes.push(String(config.serviceTypeId).trim());
|
||||
}
|
||||
/** The one whose plan is on screen — everything plan-scoped hangs off this. */
|
||||
let activeTypeId = serviceTypes[0] || '';
|
||||
const pinnedPlanId = String(config.planId || '').trim();
|
||||
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
|
||||
const follow = config.followProPresenter !== false;
|
||||
@@ -647,8 +675,9 @@ module.exports = {
|
||||
|
||||
let state = clearedState();
|
||||
state.configured = pco.configured;
|
||||
state.serviceTypeSet = Boolean(serviceTypeId);
|
||||
state.serviceType = { id: serviceTypeId, name: '' };
|
||||
state.serviceTypeSet = Boolean(serviceTypes.length);
|
||||
state.serviceType = { id: activeTypeId, name: '' };
|
||||
state.serviceTypes = serviceTypes.slice();
|
||||
state.live.following = follow;
|
||||
state.timing = { ...timing };
|
||||
|
||||
@@ -704,8 +733,37 @@ module.exports = {
|
||||
|
||||
/* — plan reading — */
|
||||
|
||||
function planPath(planId) {
|
||||
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
||||
function planPath(planId, stId = activeTypeId) {
|
||||
return `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans/${encodeURIComponent(planId)}`;
|
||||
}
|
||||
|
||||
/** id → name, asked once each. A service type that has gone is named, not fatal. */
|
||||
const typeNames = new Map();
|
||||
async function typeName(stId) {
|
||||
if (typeNames.has(stId)) return typeNames.get(stId);
|
||||
let name = `Service type ${stId}`;
|
||||
try {
|
||||
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}`);
|
||||
name = String(st?.data?.attributes?.name || '').trim() || name;
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
name = `Service type ${stId} (no longer in Planning Center)`;
|
||||
}
|
||||
typeNames.set(stId, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned plan, as the admin's Plan select writes it: "<serviceType>/<plan>".
|
||||
* A bare id is a pin saved before 1.10.0, when there was only one service
|
||||
* type to belong to.
|
||||
*/
|
||||
function parsePin(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
if (!text) return null;
|
||||
const slash = text.indexOf('/');
|
||||
if (slash > 0) return { stId: text.slice(0, slash), planId: text.slice(slash + 1) };
|
||||
return { stId: serviceTypes[0] || '', planId: text };
|
||||
}
|
||||
|
||||
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
|
||||
@@ -740,8 +798,61 @@ module.exports = {
|
||||
return pickPlan(await listPlans(stId));
|
||||
}
|
||||
|
||||
async function readPlanTimes(planId) {
|
||||
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
||||
/**
|
||||
* The plan to follow, across every selected service type. Their plans are
|
||||
* ranked together — one happening today beats one still to come, which
|
||||
* beats one already past — and a dead heat on the date is settled by the
|
||||
* order the service types are listed in, which is what that order is for.
|
||||
*
|
||||
* One service type failing (a deleted id, say) must not cost the booth the
|
||||
* others, so it is logged and skipped; a credentials or network failure is
|
||||
* not skippable and is thrown on.
|
||||
*/
|
||||
async function choosePlanAcross() {
|
||||
const now = Date.now();
|
||||
const today = localYmd(new Date(now));
|
||||
const candidates = [];
|
||||
for (let priority = 0; priority < serviceTypes.length; priority += 1) {
|
||||
const stId = serviceTypes[priority];
|
||||
let plans;
|
||||
try {
|
||||
plans = await listPlans(stId);
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 429 || err.network) throw err;
|
||||
log(`service type ${stId} skipped: ${err?.message || err}`);
|
||||
continue;
|
||||
}
|
||||
for (const plan of plans) {
|
||||
const tier = localYmd(new Date(plan.sortDate)) === today ? 0 : (plan.sortDate > now ? 1 : 2);
|
||||
candidates.push({ ...plan, stId, priority, tier });
|
||||
}
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
candidates.sort((a, b) => (
|
||||
a.tier - b.tier
|
||||
// today and upcoming: soonest first. Past: most recent first.
|
||||
|| (a.tier === 2 ? b.sortDate - a.sortDate : a.sortDate - b.sortDate)
|
||||
|| a.priority - b.priority
|
||||
));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/** Point everything plan-scoped at a service type, forgetting the last one's. */
|
||||
async function useServiceType(stId) {
|
||||
if (!stId || stId === activeTypeId) {
|
||||
if (stId && !state.serviceType.name) state.serviceType = { id: stId, name: await typeName(stId) };
|
||||
return;
|
||||
}
|
||||
activeTypeId = stId;
|
||||
// Note categories belong to the service type; the team lists are already
|
||||
// cached per type and look after themselves.
|
||||
noteCategories = { at: 0, options: [] };
|
||||
state.serviceType = { id: stId, name: await typeName(stId) };
|
||||
if (serviceTypes.length > 1) log(`following service type "${state.serviceType.name}"`);
|
||||
}
|
||||
|
||||
async function readPlanTimes(planId, stId = activeTypeId) {
|
||||
const body = await pco.getAll(`${planPath(planId, stId)}/plan_times`);
|
||||
return (body.data || [])
|
||||
.map((t) => ({
|
||||
id: String(t.id),
|
||||
@@ -754,8 +865,8 @@ module.exports = {
|
||||
.sort((x, y) => x.startsAt - y.startsAt);
|
||||
}
|
||||
|
||||
async function readItems(planId) {
|
||||
const target = `${planPath(planId)}/items`;
|
||||
async function readItems(planId, stId = activeTypeId) {
|
||||
const target = `${planPath(planId, stId)}/items`;
|
||||
try {
|
||||
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
|
||||
} catch (err) {
|
||||
@@ -775,18 +886,21 @@ module.exports = {
|
||||
* service type, not during a service. A failed read is not fatal — the
|
||||
* plan's own teams still draw, just without the empty ones.
|
||||
*/
|
||||
let serviceTeams = { at: 0, list: [] };
|
||||
const serviceTeamsByType = new Map(); // stId -> { at, list }
|
||||
|
||||
async function readServiceTeams() {
|
||||
async function readServiceTeams(stId = activeTypeId) {
|
||||
let serviceTeams = serviceTeamsByType.get(stId) || { at: 0, list: [] };
|
||||
serviceTeamsByType.set(stId, serviceTeams);
|
||||
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
|
||||
try {
|
||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
|
||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(stId)}/teams`);
|
||||
const list = (body.data || []).map((t, i) => ({
|
||||
id: String(t.id),
|
||||
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
|
||||
sequence: Number(t.attributes?.sequence) || i + 1,
|
||||
}));
|
||||
serviceTeams = { at: Date.now(), list };
|
||||
serviceTeamsByType.set(stId, serviceTeams);
|
||||
} catch (err) {
|
||||
log(`service type team list read failed: ${err?.message || err}`);
|
||||
serviceTeams.at = Date.now(); // don't retry every poll
|
||||
@@ -795,7 +909,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
/** planPersonId → photo URL, so a browser never learns the CDN address. */
|
||||
let teamPhotos = new Map();
|
||||
const teamPhotos = new Map();
|
||||
let teamsReadAt = 0;
|
||||
|
||||
/**
|
||||
@@ -804,8 +918,8 @@ module.exports = {
|
||||
* them, exactly as the items read does: a tile that can name people
|
||||
* without photos is far better than a tile that shows nothing.
|
||||
*/
|
||||
async function readTeams(planId) {
|
||||
const target = `${planPath(planId)}/team_members`;
|
||||
async function readTeams(planId, stId = activeTypeId) {
|
||||
const target = `${planPath(planId, stId)}/team_members`;
|
||||
let body;
|
||||
try {
|
||||
body = await pco.getAll(target, { include: 'team,person' });
|
||||
@@ -817,16 +931,16 @@ module.exports = {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const teams = shapeTeams(body, await readServiceTeams());
|
||||
const photos = new Map();
|
||||
const teams = shapeTeams(body, await readServiceTeams(stId));
|
||||
const included = new Map();
|
||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||
for (const pp of body.data || []) {
|
||||
const personRef = pp.relationships?.person?.data;
|
||||
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
|
||||
if (url) photos.set(String(pp.id), url);
|
||||
// Photos accumulate: a dashboard looking at another service type still
|
||||
// asks this module for its people's faces.
|
||||
if (url) teamPhotos.set(String(pp.id), url);
|
||||
}
|
||||
teamPhotos = photos;
|
||||
teamsReadAt = Date.now();
|
||||
return teams;
|
||||
}
|
||||
@@ -854,6 +968,57 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The other selected service types, each with its own current plan, for a
|
||||
* dashboard that has switched to one. Read on the slow lane and wrapped
|
||||
* individually: a service type that fails here is reported in its own
|
||||
* entry and costs the booth nothing else.
|
||||
*/
|
||||
let othersReadAt = 0;
|
||||
|
||||
async function readOtherTypes(force) {
|
||||
if (!force && Date.now() - othersReadAt < OTHERS_MIN_MS) return;
|
||||
othersReadAt = Date.now();
|
||||
const others = {};
|
||||
for (const stId of serviceTypes) {
|
||||
if (stId === activeTypeId) continue;
|
||||
const entry = { id: stId, name: '', plan: null, items: [], teams: [], teamsError: '', lastError: '' };
|
||||
others[stId] = entry;
|
||||
try {
|
||||
entry.name = await typeName(stId);
|
||||
const chosen = await choosePlanId(stId);
|
||||
if (!chosen) continue;
|
||||
const [planTimes, items] = await Promise.all([
|
||||
readPlanTimes(chosen.id, stId),
|
||||
readItems(chosen.id, stId),
|
||||
]);
|
||||
const plan = shapePlan(chosen, stId, planTimes);
|
||||
// No service clock runs for a type nobody is following, so take the
|
||||
// service that is current or next purely to lay the items out.
|
||||
const times = serviceTimes(planTimes);
|
||||
const now = Date.now();
|
||||
const service = times.find((t) => (t.endsAt || t.startsAt + DEFAULT_SERVICE_LENGTH_MS) > now) || times[times.length - 1];
|
||||
if (service) {
|
||||
plan.serviceStartsAt = service.startsAt;
|
||||
plan.serviceEndsAt = service.endsAt || 0;
|
||||
plan.serviceName = service.name || '';
|
||||
}
|
||||
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
|
||||
entry.plan = plan;
|
||||
entry.items = items;
|
||||
try {
|
||||
entry.teams = await readTeams(chosen.id, stId);
|
||||
} catch (err) {
|
||||
entry.teamsError = err?.message || String(err);
|
||||
}
|
||||
} catch (err) {
|
||||
entry.lastError = err?.message || String(err);
|
||||
log(`service type ${stId}: ${entry.lastError}`);
|
||||
}
|
||||
}
|
||||
state.others = others;
|
||||
}
|
||||
|
||||
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
|
||||
function teamOptions() {
|
||||
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
|
||||
@@ -861,23 +1026,22 @@ module.exports = {
|
||||
|
||||
|
||||
async function readPlan() {
|
||||
if (!pco.configured || !serviceTypeId) return;
|
||||
if (!pco.configured || !serviceTypes.length) return;
|
||||
let planRec = null;
|
||||
if (pinnedPlanId) {
|
||||
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
|
||||
const pin = parsePin(pinnedPlanId);
|
||||
if (pin && pin.planId && pin.stId) {
|
||||
await useServiceType(pin.stId);
|
||||
planRec = (await pco.get(planPath(pin.planId, pin.stId)))?.data || null;
|
||||
} else {
|
||||
const chosen = await choosePlanId(serviceTypeId);
|
||||
// Re-read the chosen plan by id: the list endpoint's attributes are the
|
||||
// same, but this keeps one code path and picks up edits immediately.
|
||||
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
|
||||
}
|
||||
if (!state.serviceType.name) {
|
||||
try {
|
||||
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
|
||||
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
|
||||
throw err;
|
||||
const chosen = await choosePlanAcross();
|
||||
if (chosen) {
|
||||
await useServiceType(chosen.stId);
|
||||
// Re-read the chosen plan by id: the list endpoint's attributes are
|
||||
// the same, but this keeps one code path and picks up edits at once.
|
||||
planRec = (await pco.get(planPath(chosen.rec.id, chosen.stId)))?.data || chosen.rec;
|
||||
} else {
|
||||
// Nothing anywhere: still name a service type so the tile can say whose.
|
||||
await useServiceType(serviceTypes[0]);
|
||||
}
|
||||
}
|
||||
if (!planRec) {
|
||||
@@ -890,10 +1054,11 @@ module.exports = {
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
syncServiceSafe(Date.now(), null);
|
||||
await readOtherTypes(true);
|
||||
return;
|
||||
}
|
||||
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
||||
const plan = shapePlan(planRec, serviceTypeId, planTimes);
|
||||
const plan = shapePlan(planRec, activeTypeId, planTimes);
|
||||
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`);
|
||||
@@ -916,6 +1081,8 @@ module.exports = {
|
||||
// Service selection, item schedule and the running clock all follow
|
||||
// from the plan's service times — decided in one place.
|
||||
syncServiceSafe(Date.now(), null);
|
||||
// Whatever else is selected, for a dashboard that has switched to it.
|
||||
await readOtherTypes(planChanged);
|
||||
}
|
||||
|
||||
/** Say, once per plan, which item a document named like the plan will land on. */
|
||||
@@ -943,11 +1110,11 @@ module.exports = {
|
||||
* list — the gear window then says there is nothing to choose from yet.
|
||||
*/
|
||||
async function noteCategoryOptions() {
|
||||
if (!pco.configured || !serviceTypeId) return [];
|
||||
if (!pco.configured || !activeTypeId) return [];
|
||||
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
|
||||
if (!noteCategoriesInFlight) {
|
||||
noteCategoriesInFlight = (async () => {
|
||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/item_note_categories`);
|
||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(activeTypeId)}/item_note_categories`);
|
||||
const seen = new Set();
|
||||
const options = (body.data || [])
|
||||
.filter((c) => !c?.attributes?.deleted_at)
|
||||
@@ -1713,11 +1880,11 @@ module.exports = {
|
||||
if (!pco.configured) {
|
||||
state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin';
|
||||
log(state.lastError);
|
||||
} else if (!serviceTypeId) {
|
||||
} else if (!serviceTypes.length) {
|
||||
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
|
||||
log(state.lastError);
|
||||
} else {
|
||||
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||||
log(`reading ${serviceTypes.length} service type${serviceTypes.length === 1 ? '' : 's'} (${serviceTypes.join(', ')}) from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||||
schedulePoll(0);
|
||||
}
|
||||
log(
|
||||
@@ -1777,6 +1944,7 @@ module.exports = {
|
||||
},
|
||||
listPlans,
|
||||
pickPlan,
|
||||
typeName,
|
||||
timersSnapshot,
|
||||
documentOptions,
|
||||
noteCategoryOptions,
|
||||
@@ -1805,11 +1973,11 @@ module.exports = {
|
||||
},
|
||||
health() {
|
||||
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
||||
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' };
|
||||
if (!serviceTypes.length) return { status: 'error', message: 'No service type selected — tick one in the Service types list below' };
|
||||
if (!state.reachable) {
|
||||
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
||||
}
|
||||
const name = state.serviceType.name || serviceTypeId;
|
||||
const name = state.serviceType.name || activeTypeId;
|
||||
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
|
||||
const service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : '';
|
||||
const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
||||
@@ -1986,8 +2154,15 @@ module.exports = {
|
||||
}
|
||||
return names.join(' › ');
|
||||
};
|
||||
const live = current.getState().serviceType?.id || '';
|
||||
const options = serviceTypes
|
||||
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
|
||||
.map((st) => ({
|
||||
value: st.id,
|
||||
// Which one's plan is actually on screen, so the list says so
|
||||
// rather than leaving it to be worked out.
|
||||
label: st.id === live ? `${st.name} — following now` : st.name,
|
||||
group: pathOf(st.parentId),
|
||||
}))
|
||||
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||||
json(res, 200, { options });
|
||||
} catch (err) {
|
||||
@@ -1997,23 +2172,28 @@ module.exports = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Options for the admin page's "Plan" select, for the service type the
|
||||
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
|
||||
* plans, with the one Automatic would choose marked.
|
||||
* Options for the admin page's "Plan" select, across every service type
|
||||
* ticked above (?serviceTypes=a,b,c), grouped by which one each belongs
|
||||
* to. A value is "<serviceType>/<plan>", because a plan id alone does
|
||||
* not say whose it is once there is more than one list.
|
||||
*/
|
||||
'GET /plans': async (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const { pco } = current;
|
||||
const params = new URLSearchParams(req.search || '');
|
||||
const stId = String(params.get('serviceTypeId') || '').trim();
|
||||
const asked = String(params.get('serviceTypes') || '').split(',').map((v) => v.trim()).filter(Boolean);
|
||||
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||||
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||||
if (!pco.configured || !asked.length) return json(res, 200, { options: [auto] });
|
||||
const options = [auto];
|
||||
let failure = '';
|
||||
for (const stId of asked) {
|
||||
try {
|
||||
const plans = await current.listPlans(stId, { future: 8, past: 6 });
|
||||
const [name, plans] = await Promise.all([
|
||||
current.typeName(stId),
|
||||
current.listPlans(stId, { future: 8, past: 6 }),
|
||||
]);
|
||||
const picked = current.pickPlan(plans);
|
||||
const now = Date.now();
|
||||
const options = [auto];
|
||||
// Upcoming first (soonest at the top), then the recent ones.
|
||||
const ordered = [
|
||||
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
||||
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
||||
@@ -2024,13 +2204,14 @@ module.exports = {
|
||||
if (a.series_title) bits.push(String(a.series_title));
|
||||
if (a.title) bits.push(String(a.title));
|
||||
let label = bits.join(' — ');
|
||||
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
|
||||
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
|
||||
if (picked && rec.id === picked.id) label += ' (this one, on Automatic)';
|
||||
options.push({ value: `${stId}/${rec.id}`, label, group: name });
|
||||
}
|
||||
json(res, 200, { options });
|
||||
} catch (err) {
|
||||
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
|
||||
failure = err?.message || String(err);
|
||||
}
|
||||
}
|
||||
json(res, 200, failure && options.length === 1 ? { options, error: failure } : { options });
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -744,3 +744,51 @@
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
||||
|
||||
/* ── the service-type menu, and a tile looking at another one ────────── */
|
||||
|
||||
[data-module="pco-plan"] .pp-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 9px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-menu-item:hover { background: var(--border); }
|
||||
[data-module="pco-plan"] .pp-menu-tick {
|
||||
width: 12px;
|
||||
flex: none;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
visibility: hidden;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-menu-item.is-on .pp-menu-tick { visibility: visible; }
|
||||
[data-module="pco-plan"] .pp-menu-name { flex: 1; min-width: 0; font-weight: 600; }
|
||||
[data-module="pco-plan"] .pp-menu-note {
|
||||
flex: none;
|
||||
font-size: 0.8em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Looking at a service type other than the one running: the top bar carries
|
||||
a quiet stripe so nobody reads this as the live plan. */
|
||||
[data-module="pco-plan"] .pp-wrap.is-other-type .pp-head,
|
||||
[data-module="pco-plan"] .tm-wrap.is-other-type .tm-head {
|
||||
background: color-mix(in srgb, var(--warn) 9%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, var(--warn) 45%, transparent);
|
||||
}
|
||||
[data-module="pco-plan"] .pk-wrap.is-other-type {
|
||||
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--warn) 45%, transparent);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,15 @@ const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
|
||||
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
|
||||
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
|
||||
|
||||
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
||||
|
||||
export default function createTeams({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
let stream = null;
|
||||
let lastKey = '';
|
||||
let view = viewOf(null);
|
||||
let offViewed = null;
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="tm-wrap">
|
||||
@@ -39,6 +43,8 @@ export default function createTeams({ root, moduleApi }) {
|
||||
|
||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||
|
||||
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
||||
|
||||
const refreshBtn = moduleApi.header.addButton({
|
||||
icon: REFRESH_SVG,
|
||||
title: 'Re-read from Planning Center',
|
||||
@@ -92,7 +98,7 @@ export default function createTeams({ root, moduleApi }) {
|
||||
/* ── which teams and people to show ─────────────────────────────── */
|
||||
|
||||
function visibleTeams() {
|
||||
const teams = Array.isArray(state?.teams) ? state.teams : [];
|
||||
const teams = Array.isArray(view.teams) ? view.teams : [];
|
||||
const wantTeams = chosen('teams');
|
||||
const out = [];
|
||||
for (const team of teams) {
|
||||
@@ -118,8 +124,8 @@ export default function createTeams({ root, moduleApi }) {
|
||||
if (!state.reachable) {
|
||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||
}
|
||||
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
|
||||
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
|
||||
if (view.teamsError) return moduleApi.setStatus('error', `Team list: ${view.teamsError}`);
|
||||
const people = (view.teams || []).reduce((n, t) => n + t.members.length, 0);
|
||||
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
|
||||
}
|
||||
|
||||
@@ -127,14 +133,14 @@ export default function createTeams({ root, moduleApi }) {
|
||||
|
||||
/** The counts describe what this tile shows, not what the plan holds. */
|
||||
function templateValues() {
|
||||
const plan = state?.plan || {};
|
||||
const plan = view.plan || {};
|
||||
const teams = visibleTeams();
|
||||
const people = teams.reduce((n, t) => n + t.members.length, 0);
|
||||
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
|
||||
return {
|
||||
series: plan.seriesTitle || '',
|
||||
part: plan.title || '',
|
||||
servicetype: state?.serviceType?.name || '',
|
||||
servicetype: view.name || '',
|
||||
date: plan.dates || '',
|
||||
shortdate: plan.shortDates || plan.dates || '',
|
||||
time: plan.serviceStartsAt
|
||||
@@ -171,11 +177,11 @@ export default function createTeams({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function renderHead() {
|
||||
const show = on('showTopBar') && state?.plan;
|
||||
const show = on('showTopBar') && view.plan;
|
||||
headEl.hidden = !show;
|
||||
if (!show) return;
|
||||
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
|
||||
titleEl.textContent = line1 || line2 || view.name || 'Teams';
|
||||
subEl.textContent = line1 ? line2 || '' : '';
|
||||
subEl.hidden = !subEl.textContent;
|
||||
}
|
||||
@@ -199,19 +205,19 @@ export default function createTeams({ root, moduleApi }) {
|
||||
link: { href: '/admin', text: 'Open Admin' },
|
||||
};
|
||||
}
|
||||
if (!state.reachable && !state.plan) {
|
||||
if (!state.reachable && !view.plan) {
|
||||
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||
}
|
||||
if (!state.plan) {
|
||||
if (!view.plan) {
|
||||
return {
|
||||
title: 'No plan found',
|
||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`,
|
||||
hint: `${view.name || 'This service type'} has no upcoming or recent plans.`,
|
||||
};
|
||||
}
|
||||
if (!(state.teams || []).length) {
|
||||
if (!(view.teams || []).length) {
|
||||
return {
|
||||
title: 'Nobody scheduled yet',
|
||||
hint: state.teamsError || 'This plan has no team members in Planning Center.',
|
||||
hint: view.teamsError || 'This plan has no team members in Planning Center.',
|
||||
};
|
||||
}
|
||||
if (!visibleTeams().length) {
|
||||
@@ -372,7 +378,7 @@ export default function createTeams({ root, moduleApi }) {
|
||||
// Rebuild only when the roster or the settings actually changed: the
|
||||
// avatars are <img>s and a needless rebuild makes them flicker.
|
||||
const teams = visibleTeams();
|
||||
const key = JSON.stringify([teams, cfg()]);
|
||||
const key = JSON.stringify([view.id, teams, cfg()]);
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
bodyEl.innerHTML = '';
|
||||
@@ -380,7 +386,10 @@ export default function createTeams({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function render() {
|
||||
view = viewOf(state);
|
||||
switcher.sync();
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
||||
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
|
||||
wrap.classList.toggle('header-gradient', on('headerGradient'));
|
||||
renderStatus();
|
||||
@@ -421,9 +430,12 @@ export default function createTeams({ root, moduleApi }) {
|
||||
.then((body) => { if (body && !state) accept(body); })
|
||||
.catch(() => { /* the stream will cover it */ });
|
||||
connect();
|
||||
offViewed = onViewedType(() => { lastKey = ''; render(); });
|
||||
render();
|
||||
},
|
||||
stop() {
|
||||
offViewed?.();
|
||||
offViewed = null;
|
||||
stream?.close();
|
||||
stream = null;
|
||||
root.innerHTML = '';
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/* PCO Plan — which service type THIS dashboard is looking at.
|
||||
|
||||
The module's server follows one service type at a time: whichever the
|
||||
admin's priority order lands on. A dashboard may want a different one on
|
||||
screen — the other campus, the youth plan — without changing what the
|
||||
server follows or what any other screen sees.
|
||||
|
||||
That choice belongs to the browser, so it lives here: one value for the
|
||||
whole page, kept in localStorage and shared between every PCO Plan tile on
|
||||
it, so switching from one tile moves them all together. */
|
||||
|
||||
const KEY = 'pco-plan:viewed-service-type';
|
||||
|
||||
/** One bus per page, however many tiles import this. */
|
||||
function bus() {
|
||||
if (!window.__pcoPlanViewed) {
|
||||
let value = '';
|
||||
try {
|
||||
value = String(localStorage.getItem(KEY) || '');
|
||||
} catch {
|
||||
value = ''; // private window, storage off: the choice just won't persist
|
||||
}
|
||||
window.__pcoPlanViewed = { value, listeners: new Set() };
|
||||
}
|
||||
return window.__pcoPlanViewed;
|
||||
}
|
||||
|
||||
/** '' means "whatever the server is following" — the normal case. */
|
||||
export function viewedType() {
|
||||
return bus().value;
|
||||
}
|
||||
|
||||
export function setViewedType(id) {
|
||||
const b = bus();
|
||||
const next = String(id || '');
|
||||
if (next === b.value) return;
|
||||
b.value = next;
|
||||
try {
|
||||
if (next) localStorage.setItem(KEY, next);
|
||||
else localStorage.removeItem(KEY);
|
||||
} catch { /* not persisted, still shared for this page */ }
|
||||
for (const fn of [...b.listeners]) {
|
||||
try {
|
||||
fn(next);
|
||||
} catch (err) {
|
||||
console.error('[pco-plan] viewed-type listener failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns an unsubscribe — call it from the tile's stop(). */
|
||||
export function onViewedType(fn) {
|
||||
const b = bus();
|
||||
b.listeners.add(fn);
|
||||
return () => b.listeners.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan a tile should draw, given the whole state and this page's choice:
|
||||
* the one the server follows, or one of the others it also reads.
|
||||
*
|
||||
* `live` is false for anything but the followed one — the ProPresenter match,
|
||||
* the service clock and the timers belong to the service actually running, so
|
||||
* a tile looking elsewhere must not pretend to have them.
|
||||
*/
|
||||
export function viewOf(state) {
|
||||
const empty = { id: '', name: '', plan: null, items: [], teams: [], teamsError: '', live: true, lastError: '' };
|
||||
if (!state) return empty;
|
||||
const wanted = viewedType();
|
||||
const activeId = state.serviceType?.id || '';
|
||||
if (!wanted || wanted === activeId) {
|
||||
return {
|
||||
id: activeId,
|
||||
name: state.serviceType?.name || '',
|
||||
plan: state.plan,
|
||||
items: state.items || [],
|
||||
teams: state.teams || [],
|
||||
teamsError: state.teamsError || '',
|
||||
live: true,
|
||||
lastError: '',
|
||||
};
|
||||
}
|
||||
const other = (state.others || {})[wanted];
|
||||
if (!other) {
|
||||
// Ticked off in admin since this dashboard chose it, or not read yet.
|
||||
return { ...empty, id: wanted, live: false, lastError: 'That service type is no longer selected' };
|
||||
}
|
||||
return {
|
||||
id: other.id,
|
||||
name: other.name,
|
||||
plan: other.plan,
|
||||
items: other.items || [],
|
||||
teams: other.teams || [],
|
||||
teamsError: other.teamsError || '',
|
||||
live: false,
|
||||
lastError: other.lastError || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Every service type a dashboard may switch to, in the admin's priority order. */
|
||||
export function choices(state) {
|
||||
const out = [];
|
||||
const activeId = state?.serviceType?.id || '';
|
||||
for (const id of state?.serviceTypes || []) {
|
||||
if (id === activeId) out.push({ id, name: state.serviceType?.name || id, following: true });
|
||||
else out.push({ id, name: (state.others || {})[id]?.name || id, following: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SWITCH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h13l-3-3"/><path d="M20 16H7l3 3"/></svg>';
|
||||
|
||||
/**
|
||||
* The title-bar control that switches this dashboard between the selected
|
||||
* service types. Added by every PCO Plan tile, because switching from any of
|
||||
* them moves all of them; it hides itself whenever there is only one service
|
||||
* type to choose from, which is the usual case.
|
||||
*
|
||||
* Returns { sync } — call it when the state arrives so the control appears,
|
||||
* disappears or re-labels itself.
|
||||
*/
|
||||
export function addServiceTypeSwitch({ moduleApi, getState }) {
|
||||
const menu = moduleApi.header.addMenu({
|
||||
icon: SWITCH_SVG,
|
||||
title: 'Service type shown on this dashboard',
|
||||
build(el) {
|
||||
el.classList.add('pp-menu');
|
||||
const state = getState();
|
||||
const wanted = viewedType();
|
||||
const rows = [
|
||||
{ id: '', name: 'Whatever is live', following: false, auto: true },
|
||||
...choices(state),
|
||||
];
|
||||
for (const row of rows) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'pp-menu-item' + (row.id === wanted ? ' is-on' : '');
|
||||
const tick = document.createElement('span');
|
||||
tick.className = 'pp-menu-tick';
|
||||
tick.textContent = '✓';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'pp-menu-name';
|
||||
name.textContent = row.name;
|
||||
item.append(tick, name);
|
||||
if (row.following) {
|
||||
const note = document.createElement('span');
|
||||
note.className = 'pp-menu-note';
|
||||
note.textContent = 'live';
|
||||
item.appendChild(note);
|
||||
}
|
||||
item.addEventListener('click', () => {
|
||||
setViewedType(row.id);
|
||||
menu.close();
|
||||
});
|
||||
el.appendChild(item);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sync() {
|
||||
const state = getState();
|
||||
// One service type is no choice at all — rule 11, nothing in the bar
|
||||
// that has nothing to do.
|
||||
const many = (state?.serviceTypes || []).length > 1;
|
||||
menu.button.hidden = !many;
|
||||
const wanted = viewedType();
|
||||
menu.button.classList.toggle('active', Boolean(wanted) && wanted !== (state?.serviceType?.id || ''));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "resi-cues",
|
||||
"name": "Resi Cues",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||
"provides": ["timers"],
|
||||
|
||||
+89
-14
@@ -39,6 +39,9 @@ function msToPosition(total) {
|
||||
|
||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
||||
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
|
||||
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
|
||||
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
|
||||
|
||||
/**
|
||||
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||
@@ -61,6 +64,8 @@ function createPlayhead(options) {
|
||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
||||
let spreads = []; // how far apart honest readings sit: this link's noise
|
||||
let gaps = []; // how long between the playhead actually moving
|
||||
let changes = 0; // readings that differed from the one before
|
||||
let rejected = false; // the last gap looked like a pause and was skipped
|
||||
let base; // the trusted offset
|
||||
let lastMedia;
|
||||
let lastNewAt;
|
||||
@@ -90,15 +95,25 @@ function createPlayhead(options) {
|
||||
spreads = spreads.concat(-delta).slice(-20);
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) {
|
||||
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
|
||||
// from it to the next one is only a fraction of the real cadence. Learning
|
||||
// from it locked in a 2s cadence that rejected every honest 30s gap.
|
||||
if (lastNewAt !== undefined && changes > 0) {
|
||||
const gap = t - lastNewAt;
|
||||
// The gap either side of a pause is not Resi's cadence — recording it
|
||||
// would inflate the estimate and make the next pause slow to notice.
|
||||
// But a pause is one gap; twice in a row is the cadence having changed.
|
||||
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
||||
if (gap > 0 && gap < 60000) {
|
||||
if (!worst || gap <= worst * 3 || rejected) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
rejected = false;
|
||||
} else {
|
||||
rejected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) changes++;
|
||||
offsets = offsets.concat({ at: t, off });
|
||||
lastMedia = mediaMs;
|
||||
lastNewAt = t;
|
||||
@@ -118,7 +133,9 @@ function createPlayhead(options) {
|
||||
// Only call it paused once the playhead has been still for clearly longer than
|
||||
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
|
||||
// countdown that freezes mid-service is the worse failure of the two.
|
||||
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
|
||||
// Until a full gap has been measured the cadence is unknown; assume the slowest
|
||||
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
|
||||
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
|
||||
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||
if (!playing) {
|
||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||
@@ -161,13 +178,43 @@ function createResiClient(options) {
|
||||
return h;
|
||||
}
|
||||
|
||||
/* A failed login used to be retried on the very next poll -- once a second, for
|
||||
ever. Resi answers that with 429s, and since the retries never let up, the
|
||||
account never got out of them. So a failure now holds off further attempts,
|
||||
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
|
||||
call in between fails at once with the same reason instead of signing in. */
|
||||
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
||||
let loginFailures = 0;
|
||||
let loginBlockedUntil = 0;
|
||||
let loginError = '';
|
||||
|
||||
async function login() {
|
||||
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||
if (now() < loginBlockedUntil) {
|
||||
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
||||
} catch (err) {
|
||||
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
|
||||
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
|
||||
: `Resi login failed (${res.status})`;
|
||||
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||
holdOff(reason, retryAfter * 1000);
|
||||
throw new Error(reason);
|
||||
}
|
||||
loginFailures = 0;
|
||||
loginBlockedUntil = 0;
|
||||
loginError = '';
|
||||
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
const jar = {};
|
||||
@@ -179,24 +226,49 @@ function createResiClient(options) {
|
||||
token = jar.livingas1 || '';
|
||||
if (!token) throw new Error('Resi login returned no session cookie');
|
||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||
loggedInAt = Date.now();
|
||||
loggedInAt = now();
|
||||
const me = await get('/api_v2.svc/users/me');
|
||||
customerId = (me && me.customerId) || '';
|
||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||
return me;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
function holdOff(reason, askedMs) {
|
||||
loginFailures++;
|
||||
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
|
||||
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
|
||||
loginError = reason;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
}
|
||||
|
||||
/* The same hold-off covers every request, not just the login. A 401/403 used to
|
||||
sign in again on the spot, every time: for a login that simply isn't allowed to
|
||||
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
|
||||
after signing in is taken as what it is -- a permission -- and held off like a
|
||||
failed login; only an older session is renewed. A 429 holds off too. */
|
||||
async function get(path) {
|
||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (now() < loginBlockedUntil) {
|
||||
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||
}
|
||||
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
|
||||
await login();
|
||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
||||
return retry.json();
|
||||
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const where = path.includes('/venues') ? 'its venues'
|
||||
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
|
||||
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
@@ -216,7 +288,10 @@ function createResiClient(options) {
|
||||
try {
|
||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||
} catch (err) {
|
||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
|
||||
// Only an objection to the parameter itself; a refusal or a "slow down"
|
||||
// (401/403/429) is about the login, and dropping it then lost fresh readings.
|
||||
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
|
||||
if (!code || ['401', '403', '429'].includes(code)) throw err;
|
||||
cacheBust = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
|
||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
||||
});
|
||||
|
||||
/* ── starting partway through Resi's cycle ─────────────────────────────── */
|
||||
|
||||
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
|
||||
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
|
||||
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
|
||||
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
|
||||
// and the next cue (and its name) stopped moving on.
|
||||
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
|
||||
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
|
||||
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
|
||||
w.lastPublish = w.t - into;
|
||||
const head = createPlayhead({ now: () => w.t });
|
||||
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
|
||||
let paused = 0;
|
||||
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
|
||||
if (burst) {
|
||||
w.media += 15 * 60000; // a seek, published at once...
|
||||
w.lastPublish = w.t - w.nextCadence;
|
||||
second(); second();
|
||||
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
|
||||
}
|
||||
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
|
||||
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||
|
||||
const argv = process.argv.slice(2).map(Number);
|
||||
|
||||
@@ -70,6 +70,7 @@ function emptyTimers(now) {
|
||||
}
|
||||
|
||||
let current = null; // the live handle, shared with routes()
|
||||
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
@@ -146,7 +147,9 @@ module.exports = {
|
||||
elapsedMs: null,
|
||||
remainingMs,
|
||||
targetMs: Math.max(1000, state.next.ms - from),
|
||||
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
|
||||
// The label already is the cue's name, and it is what the card is titled
|
||||
// with; repeating it on the status line only crowded the card.
|
||||
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
|
||||
detail: state.event || '',
|
||||
});
|
||||
}
|
||||
@@ -271,6 +274,7 @@ module.exports = {
|
||||
stateStreams,
|
||||
timerStreams,
|
||||
resi,
|
||||
credentialsKey: `${username}\n${password}`,
|
||||
configured: Boolean(username && password),
|
||||
stop() {
|
||||
stopped = true;
|
||||
@@ -332,9 +336,21 @@ module.exports = {
|
||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||
}
|
||||
try {
|
||||
const client = createResiClient({
|
||||
username: String(config.username), password: String(config.password),
|
||||
});
|
||||
// The running module's client when it has these credentials, else one kept
|
||||
// per login: a fresh client per request signed in on every admin page load,
|
||||
// and its own hold-off never applied -- which kept Resi rate-limiting.
|
||||
const username = String(config.username).trim();
|
||||
const key = `${username}\n${config.password}`;
|
||||
let client;
|
||||
if (current && current.resi && current.credentialsKey === key) client = current.resi;
|
||||
else {
|
||||
if (!pickerClient || pickerClient.key !== key) {
|
||||
pickerClient = { key, client: createResiClient({
|
||||
username, password: String(config.password),
|
||||
}) };
|
||||
}
|
||||
client = pickerClient.client;
|
||||
}
|
||||
const options = [];
|
||||
for (const v of await client.venues()) {
|
||||
for (const p of await client.players(v.uuid)) {
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
// Awaited, so an async test finishes before the next one changes the world under it.
|
||||
async function test(name, fn) {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
@@ -101,7 +102,7 @@ function collect(route, req) {
|
||||
|
||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
||||
|
||||
test('signs in once and finds the configured decoder', () => {
|
||||
await test('signs in once and finds the configured decoder', () => {
|
||||
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.status, 'ok', state.message);
|
||||
@@ -110,20 +111,20 @@ function collect(route, req) {
|
||||
assert.strictEqual(state.cues.length, 3);
|
||||
});
|
||||
|
||||
test('works out the cue either side of the playhead', () => {
|
||||
await test('works out the cue either side of the playhead', () => {
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.next.name, 'Campus Host');
|
||||
assert.strictEqual(state.prev.name, 'Welcome');
|
||||
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
||||
});
|
||||
|
||||
test('/state answers the same snapshot as the stream', () => {
|
||||
await test('/state answers the same snapshot as the stream', () => {
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
||||
});
|
||||
|
||||
test('/stream opens with an event so a new tile paints at once', () => {
|
||||
await test('/stream opens with an event so a new tile paints at once', () => {
|
||||
const res = collect(routes['GET /stream']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
||||
@@ -131,7 +132,7 @@ function collect(route, req) {
|
||||
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
||||
});
|
||||
|
||||
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.source, 'resi-cues');
|
||||
assert.strictEqual(snap.label, 'Resi');
|
||||
@@ -145,7 +146,7 @@ function collect(route, req) {
|
||||
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
|
||||
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||
assert.strictEqual(next.targetMs, 450000);
|
||||
assert.strictEqual(next.status.text, 'Campus Host');
|
||||
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
|
||||
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||
|
||||
assert.strictEqual(playhead.id, 'playhead');
|
||||
@@ -154,7 +155,7 @@ function collect(route, req) {
|
||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
||||
});
|
||||
|
||||
test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||
await test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||
world.position = '00:12:40.000'; // jumped past Campus Host
|
||||
tickFn();
|
||||
await settle();
|
||||
@@ -162,10 +163,10 @@ function collect(route, req) {
|
||||
assert.strictEqual(state.next.name, 'Message');
|
||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.timers[0].label, 'Message');
|
||||
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
|
||||
});
|
||||
|
||||
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
world.eventId = '';
|
||||
tickFn();
|
||||
await settle();
|
||||
@@ -174,13 +175,13 @@ function collect(route, req) {
|
||||
assert.strictEqual(handle.state.event, '');
|
||||
});
|
||||
|
||||
test('health speaks about the upstream, not about itself', () => {
|
||||
await test('health speaks about the upstream, not about itself', () => {
|
||||
const h = handle.health();
|
||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||
assert.ok(h.message.length > 0, 'health should say something useful');
|
||||
});
|
||||
|
||||
test('an unconfigured module idles instead of hammering Resi', () => {
|
||||
await test('an unconfigured module idles instead of hammering Resi', () => {
|
||||
const before = world.logins;
|
||||
const idle = server.init({ config: {}, log: () => {} });
|
||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||
@@ -189,12 +190,90 @@ function collect(route, req) {
|
||||
idle.stop();
|
||||
});
|
||||
|
||||
test('stop() leaves nothing running', () => {
|
||||
await test('stop() leaves nothing running', () => {
|
||||
handle.stop();
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
||||
});
|
||||
|
||||
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
|
||||
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
|
||||
await test('a failed login holds off instead of retrying every poll', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
let t = 1700000000000;
|
||||
let logins = 0;
|
||||
let limited = true;
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c',
|
||||
password: 'secret',
|
||||
now: () => t,
|
||||
fetch: async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
logins++;
|
||||
if (limited) return reply({}, { ok: false, status: 429 });
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
},
|
||||
});
|
||||
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||
|
||||
assert.match(await poll(), /rate-limiting/);
|
||||
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
|
||||
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
|
||||
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
|
||||
|
||||
limited = false;
|
||||
t += 5 * 60000; // past the longest hold-off
|
||||
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
|
||||
const after = logins;
|
||||
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
|
||||
assert.strictEqual(logins, after, 'a good session is not signed in again');
|
||||
});
|
||||
|
||||
await test('a rejected password says so', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c', password: 'wrong', now: () => 0,
|
||||
fetch: async () => reply({}, { ok: false, status: 400 }),
|
||||
});
|
||||
await assert.rejects(client.venues(), /check the password/);
|
||||
});
|
||||
|
||||
// Seen live: media@waters.church signs in fine but may not list venues (403). The
|
||||
// client answered every 403 by signing in again -- a fresh login on every poll.
|
||||
await test('a login that may not see venues is held off, not signed in every poll', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
let t = 1700000000000;
|
||||
let logins = 0;
|
||||
let venueCalls = 0;
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c', password: 'secret', now: () => t,
|
||||
fetch: async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
logins++;
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
},
|
||||
});
|
||||
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||
|
||||
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
|
||||
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
|
||||
// At most one renewal once the session is over a minute old (in case it really
|
||||
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
|
||||
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
|
||||
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
|
||||
assert.match(await poll(), /ask a Resi admin/);
|
||||
});
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
globalThis.clearInterval = realClearInterval;
|
||||
console.log(`\n${passed} tests passed`);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/* Vista Cues — client part.
|
||||
|
||||
One tile: the cue list most recently fired, big, with how long ago and
|
||||
who fired it; under it the other lists still live, the recent notes, and
|
||||
which senders we are in session with. All of it comes from the server's
|
||||
one `state` event; between events only the "how long ago" clocks move. */
|
||||
|
||||
const MINUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9"/></svg>';
|
||||
const PLUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9M8 3.5v9"/></svg>';
|
||||
const CLEAR_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 13.5h7.3"/><path d="M2.8 9.6 8.9 3.5a1.4 1.4 0 0 1 2 0l2.1 2.1a1.4 1.4 0 0 1 0 2l-5.9 5.9H5.6L2.8 10.7a.8.8 0 0 1 0-1.1Z"/><path d="m6 6.4 4.1 4.1"/></svg>';
|
||||
|
||||
const ACTION_LABEL = {
|
||||
go: 'Go',
|
||||
release: 'Release',
|
||||
releaseAll: 'Release all',
|
||||
pause: 'Pause',
|
||||
back: 'Back',
|
||||
flash: 'Flash',
|
||||
cleared: 'Cleared',
|
||||
'': '—',
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/** 75 000 ms → "1:15", 3 725 000 → "1:02:05". */
|
||||
function since(ms) {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||
}
|
||||
|
||||
function clockTime(ms) {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
const prefs = () => moduleApi.instanceSettings || {};
|
||||
const on = (k, dflt = true) => (prefs()[k] === undefined ? dflt : prefs()[k] !== false);
|
||||
const scale = () => Math.min(2.5, Math.max(0.5, Number(prefs().textScale) || 1));
|
||||
|
||||
let state = null;
|
||||
let skew = 0; // server clock − our clock
|
||||
let stream = null;
|
||||
let tick = null;
|
||||
let el = {};
|
||||
let resizeObserver = null;
|
||||
|
||||
const serverNow = () => Date.now() + skew;
|
||||
|
||||
/* ── fitting the big name to its box ─────────────────────────────── */
|
||||
|
||||
function fit(node) {
|
||||
if (!node || !node.isConnected) return;
|
||||
node.style.fontSize = '';
|
||||
const avail = node.parentElement ? node.parentElement.clientWidth : node.clientWidth;
|
||||
let size = parseFloat(getComputedStyle(node).fontSize) || 16;
|
||||
for (let i = 0; i < 3 && node.scrollWidth > avail + 0.5 && size > 10; i += 1) {
|
||||
size = Math.max(10, size * (avail / node.scrollWidth) * 0.98);
|
||||
node.style.fontSize = `${size}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── drawing ─────────────────────────────────────────────────────── */
|
||||
|
||||
function build() {
|
||||
root.innerHTML = `
|
||||
<div class="vc-root">
|
||||
<div class="vc-offline" hidden></div>
|
||||
<div class="vc-body">
|
||||
<div class="vc-now">
|
||||
<div class="vc-kicker"><span class="vc-pill"></span><span class="vc-ago"></span></div>
|
||||
<div class="vc-name-wrap"><div class="vc-name"></div></div>
|
||||
<div class="vc-meta"></div>
|
||||
</div>
|
||||
<div class="vc-active"></div>
|
||||
<div class="vc-recent"></div>
|
||||
<div class="vc-sources"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const q = (s) => root.querySelector(s);
|
||||
el = {
|
||||
root: q('.vc-root'),
|
||||
offline: q('.vc-offline'),
|
||||
pill: q('.vc-pill'),
|
||||
ago: q('.vc-ago'),
|
||||
name: q('.vc-name'),
|
||||
meta: q('.vc-meta'),
|
||||
active: q('.vc-active'),
|
||||
recent: q('.vc-recent'),
|
||||
sources: q('.vc-sources'),
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!el.root) return;
|
||||
el.root.style.setProperty('--vc-scale', String(scale()));
|
||||
const s = state;
|
||||
if (!s) {
|
||||
el.name.textContent = 'Connecting…';
|
||||
el.root.dataset.tone = 'muted';
|
||||
return;
|
||||
}
|
||||
|
||||
const connected = s.sources.filter((x) => x.connected);
|
||||
const offline = !s.listening || !connected.length;
|
||||
el.root.classList.toggle('is-offline', offline);
|
||||
el.offline.hidden = !offline;
|
||||
el.offline.textContent = !s.listening ? 'Not listening' : 'No MIDI session';
|
||||
|
||||
// Now
|
||||
const cur = s.current;
|
||||
if (cur) {
|
||||
el.root.dataset.tone = cur.state === 'paused' ? 'warn' : 'ok';
|
||||
el.pill.textContent = cur.state === 'paused' ? 'Paused' : 'Live';
|
||||
el.pill.hidden = false;
|
||||
if (el.name.textContent !== cur.name) { el.name.textContent = cur.name; fit(el.name); }
|
||||
const bits = [];
|
||||
if (cur.goes > 1) bits.push(`Go ×${cur.goes}`);
|
||||
if (cur.source) bits.push(cur.source);
|
||||
if (cur.lastAction === 'back') bits.push('Back');
|
||||
el.meta.textContent = bits.join(' · ');
|
||||
} else {
|
||||
el.root.dataset.tone = 'muted';
|
||||
el.pill.hidden = true;
|
||||
const msg = s.rules ? 'No cue list live' : 'No cue lists mapped';
|
||||
if (el.name.textContent !== msg) { el.name.textContent = msg; fit(el.name); }
|
||||
el.meta.textContent = s.rules ? (s.log.length ? '' : 'Waiting for the first cue') : 'Add the cue sheet in /admin → Vista Cues';
|
||||
}
|
||||
|
||||
// Other live lists
|
||||
el.active.hidden = !on('showActive') || !s.active.length;
|
||||
if (!el.active.hidden) {
|
||||
el.active.innerHTML = s.active.map((l) => `
|
||||
<div class="vc-chip${l.state === 'paused' ? ' is-paused' : ''}" title="${esc(l.source || '')}">
|
||||
${l.group ? `<span class="vc-chip-group">${esc(l.group)}</span>` : ''}
|
||||
<span class="vc-chip-name">${esc(l.name)}</span>
|
||||
<span class="vc-chip-ago" data-since="${l.lastAt}"></span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// Recent
|
||||
const showUnmapped = on('showUnmapped', false);
|
||||
const rows = s.log.filter((e) => e.mapped || showUnmapped).slice(0, 12);
|
||||
el.recent.hidden = !on('showRecent') || !rows.length;
|
||||
if (!el.recent.hidden) {
|
||||
el.recent.innerHTML = rows.map((e) => `
|
||||
<div class="vc-row${e.mapped ? '' : ' is-unmapped'}" data-action="${esc(e.action)}">
|
||||
<span class="vc-row-time">${esc(clockTime(e.at))}</span>
|
||||
<span class="vc-row-action">${esc(e.mapped ? (ACTION_LABEL[e.action] ?? e.action) : 'Unmapped')}</span>
|
||||
<span class="vc-row-list">${esc(e.mapped ? e.list : e.trigger)}</span>
|
||||
<span class="vc-row-source">${esc(e.source)}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// Senders
|
||||
el.sources.hidden = !on('showSources') || !s.sources.length;
|
||||
if (!el.sources.hidden) {
|
||||
el.sources.innerHTML = s.sources.map((x) => `
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
|
||||
<span class="vc-dot"></span>${esc(x.label)}
|
||||
</span>`).join('');
|
||||
}
|
||||
|
||||
updateClocks();
|
||||
reportStatus();
|
||||
}
|
||||
|
||||
function updateClocks() {
|
||||
if (!state || !el.root) return;
|
||||
const now = serverNow();
|
||||
const cur = state.current;
|
||||
el.ago.textContent = cur ? since(now - cur.lastAt) : '';
|
||||
el.ago.title = cur ? `Fired ${clockTime(cur.firedAt)}` : '';
|
||||
root.querySelectorAll('[data-since]').forEach((n) => {
|
||||
n.textContent = since(now - Number(n.dataset.since));
|
||||
});
|
||||
}
|
||||
|
||||
function reportStatus() {
|
||||
const s = state;
|
||||
if (!s) return;
|
||||
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
||||
const connected = s.sources.filter((x) => x.connected);
|
||||
const waiting = s.sources.filter((x) => !x.connected);
|
||||
const off = s.sources.filter((x) => x.offNetwork);
|
||||
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
|
||||
if (!connected.length) {
|
||||
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
||||
return moduleApi.setStatus('error', why);
|
||||
}
|
||||
const msg = `Hearing ${connected.map((x) => x.label).join(', ')}${waiting.length ? ` · not ${waiting.map((x) => x.label).join(', ')}` : ''}`;
|
||||
return moduleApi.setStatus(waiting.length ? 'connecting' : 'ok', msg);
|
||||
}
|
||||
|
||||
function take(next) {
|
||||
if (!next) return;
|
||||
skew = Number(next.now) ? Number(next.now) - Date.now() : 0;
|
||||
state = next;
|
||||
render();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await moduleApi.fetch('/state');
|
||||
if (r.ok) take((await r.json()).state);
|
||||
} catch { /* the stream will say so */ }
|
||||
}
|
||||
|
||||
/* ── title bar ───────────────────────────────────────────────────── */
|
||||
|
||||
function bump(delta) {
|
||||
const next = Math.round(Math.min(2.5, Math.max(0.5, scale() + delta)) * 10) / 10;
|
||||
moduleApi.saveInstanceSettings({ textScale: next });
|
||||
render();
|
||||
fit(el.name);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
build();
|
||||
render();
|
||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bump(-0.1) });
|
||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bump(0.1) });
|
||||
moduleApi.header.addButton({
|
||||
icon: CLEAR_SVG,
|
||||
title: 'Clear live lists',
|
||||
async onClick() {
|
||||
try { await moduleApi.fetch('/clear', { method: 'POST' }); } catch { /* status says why */ }
|
||||
},
|
||||
});
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() { refresh(); },
|
||||
error() { moduleApi.setStatus('error', 'Lost connection to ProdDash'); },
|
||||
events: {
|
||||
state(e) {
|
||||
try { take(JSON.parse(e.data)); } catch { /* malformed frame */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
tick = setInterval(updateClocks, 1000);
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(() => fit(el.name));
|
||||
resizeObserver.observe(root);
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
clearInterval(tick);
|
||||
tick = null;
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
|
||||
root.innerHTML = '';
|
||||
el = {};
|
||||
},
|
||||
onResize() {
|
||||
fit(el.name);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The admin page's "Cue lists" text → rules, and MIDI events → what Vista
|
||||
* was told to do.
|
||||
*
|
||||
* One rule per line, the way it reads on a cue sheet:
|
||||
*
|
||||
* 60 = Walk-in note 60 fires Go on "Walk-in"
|
||||
* C3 = Worship 1 note names work too (see "Middle C" in admin)
|
||||
* 61 release Walk-in release a list
|
||||
* 62 pause Walk-in also: back, pause, go (the default)
|
||||
* 127 release all every list off
|
||||
* ch2:64 = Message only on MIDI channel 2 (otherwise any channel)
|
||||
* pc5 = Baptism a program change instead of a note
|
||||
* 65 = "Back Lights" quote a name that starts with an action word
|
||||
* # anything after a # is a comment
|
||||
*
|
||||
* Groups: a line "[Colors]" starts a group, and every rule below it belongs
|
||||
* to it until the next "[…]" ("[]" ends grouping). A Go in a group replaces
|
||||
* whichever list of that group was live — firing "Blue" ends "Red" — which is
|
||||
* how a sheet without release notes still says what is on. The first group
|
||||
* in the sheet is the headline the tile shows largest.
|
||||
*
|
||||
* 30 flash Blinder Flash 1 a momentary hit: logged, never left live
|
||||
*
|
||||
* The separator between the trigger and the rest may be "=", "-", ":", "→",
|
||||
* a tab or just a space, so a column pasted from a spreadsheet works.
|
||||
*/
|
||||
|
||||
const NOTE_NAMES = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
||||
const NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
const ACTIONS = [
|
||||
// Longest first, so "release all" wins over "release".
|
||||
[/^(release[\s_-]*all|all[\s_-]*off|releaseall|clear)\b/i, 'releaseAll'],
|
||||
[/^(release|off|stop|kill)\b/i, 'release'],
|
||||
[/^(pause|hold)\b/i, 'pause'],
|
||||
[/^(back|previous|prev)\b/i, 'back'],
|
||||
[/^(flash|hit|momentary)\b/i, 'flash'],
|
||||
[/^(go|fire|play|start)\b/i, 'go'],
|
||||
];
|
||||
|
||||
/** Octave offset: with middle C (note 60) called "C3", C-2 is note 0; with "C4", C-1 is. */
|
||||
function octaveBase(middleC) {
|
||||
return middleC === 'C4' ? 1 : 2;
|
||||
}
|
||||
|
||||
/** "F#2" → note number, or null. */
|
||||
function noteFromName(token, middleC) {
|
||||
const m = /^([a-g])(#|♯|b|♭)?(-?\d+)$/i.exec(token);
|
||||
if (!m) return null;
|
||||
let pc = NOTE_NAMES[m[1].toLowerCase()];
|
||||
if (m[2] === '#' || m[2] === '♯') pc += 1;
|
||||
if (m[2] === 'b' || m[2] === '♭') pc -= 1;
|
||||
const n = (Number(m[3]) + octaveBase(middleC)) * 12 + pc;
|
||||
return n >= 0 && n <= 127 ? n : null;
|
||||
}
|
||||
|
||||
function noteName(n, middleC) {
|
||||
return `${NAMES_SHARP[n % 12]}${Math.floor(n / 12) - octaveBase(middleC)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the whole text. Returns `{ rules, errors, groups }` (groups in sheet order); a line that can't be read
|
||||
* is reported (line number + why) and skipped, never fatal.
|
||||
*/
|
||||
function parseMapping(text, { middleC = 'C3' } = {}) {
|
||||
const rules = [];
|
||||
const errors = [];
|
||||
const groups = [];
|
||||
let group = '';
|
||||
String(text || '').split(/\r?\n/).forEach((raw, i) => {
|
||||
// A comment is a # that starts the line or follows a space ("C#3" and "Song #2" are not).
|
||||
const line = raw.replace(/(^|\s)#(\s.*|$)/, '').trim();
|
||||
if (!line) return;
|
||||
const header = /^\[(.*)\]$/.exec(line);
|
||||
if (header) {
|
||||
group = header[1].trim();
|
||||
if (group && !groups.includes(group)) groups.push(group);
|
||||
return;
|
||||
}
|
||||
const m = /^(?:ch(?:annel)?\s*(\d{1,2})\s*[:/]\s*)?(pc\s*\d{1,3}|note\s*\d{1,3}|\d{1,3}|[a-g](?:#|♯|b|♭)?-?\d)\s*(?:[=:\t→>|,-]+\s*|\s+|$)(.*)$/i.exec(line);
|
||||
if (!m) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'starts with no note' });
|
||||
return;
|
||||
}
|
||||
const channel = m[1] ? Number(m[1]) : null;
|
||||
if (channel != null && (channel < 1 || channel > 16)) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'channel must be 1–16' });
|
||||
return;
|
||||
}
|
||||
const trig = m[2].replace(/\s+/g, '').toLowerCase();
|
||||
let kind = 'note';
|
||||
let number;
|
||||
if (trig.startsWith('pc')) {
|
||||
kind = 'program';
|
||||
number = Number(trig.slice(2));
|
||||
} else if (trig.startsWith('note')) {
|
||||
number = Number(trig.slice(4));
|
||||
} else if (/^\d+$/.test(trig)) {
|
||||
number = Number(trig);
|
||||
} else {
|
||||
number = noteFromName(m[2].replace(/\s+/g, ''), middleC);
|
||||
}
|
||||
if (number == null || !Number.isInteger(number) || number < 0 || number > 127) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'note must be 0–127' });
|
||||
return;
|
||||
}
|
||||
let rest = m[3].trim();
|
||||
let action = 'go';
|
||||
const quoted = (t) => /^"(.*)"$/.exec(t);
|
||||
for (const [re, a] of quoted(rest) ? [] : ACTIONS) {
|
||||
const am = re.exec(rest);
|
||||
if (am) {
|
||||
action = a;
|
||||
rest = rest.slice(am[0].length).replace(/^[\s=:→>|,-]+/, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const q = quoted(rest);
|
||||
if (q) rest = q[1].trim();
|
||||
if (action !== 'releaseAll' && !rest) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'no cue list name' });
|
||||
return;
|
||||
}
|
||||
rules.push({ line: i + 1, channel, kind, number, action, list: action === 'releaseAll' ? '' : rest, group });
|
||||
});
|
||||
return { rules, errors, groups };
|
||||
}
|
||||
|
||||
/** The first rule a MIDI event matches, or null. Only note-ons and program changes fire anything. */
|
||||
function match(rules, event) {
|
||||
let kind;
|
||||
let number;
|
||||
if (event.type === 'noteon') { kind = 'note'; number = event.note; }
|
||||
else if (event.type === 'program') { kind = 'program'; number = event.program; }
|
||||
else return null;
|
||||
return rules.find((r) => r.kind === kind && r.number === number && (r.channel == null || r.channel === event.channel)) || null;
|
||||
}
|
||||
|
||||
/** "Note 60 (C3) · ch 1" — how an event is named on the tile. */
|
||||
function triggerLabel(event, middleC) {
|
||||
if (event.type === 'program') return `PC ${event.program} · ch ${event.channel}`;
|
||||
return `Note ${event.note} (${noteName(event.note, middleC)}) · ch ${event.channel}`;
|
||||
}
|
||||
|
||||
module.exports = { parseMapping, match, noteName, noteFromName, triggerLabel };
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"id": "vista-cues",
|
||||
"name": "Vista Cues",
|
||||
"version": "1.1.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Joining the network MIDI sessions that fire Vista's cue lists"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 5,
|
||||
"h": 4
|
||||
},
|
||||
"configSchema": {
|
||||
"source1Name": {
|
||||
"type": "string",
|
||||
"label": "Name",
|
||||
"default": "ProPresenter",
|
||||
"group": "First sender",
|
||||
"help": "What the tile calls notes from this machine."
|
||||
},
|
||||
"source1": {
|
||||
"type": "endpoint",
|
||||
"label": "Session",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 5004
|
||||
},
|
||||
"group": "First sender",
|
||||
"help": "The Mac whose network MIDI session sends Vista its notes, and that session's port (Audio MIDI Setup → Window → Show MIDI Studio → Network; usually 5004)."
|
||||
},
|
||||
"source2Name": {
|
||||
"type": "string",
|
||||
"label": "Name",
|
||||
"default": "Tracks",
|
||||
"group": "Second sender"
|
||||
},
|
||||
"source2": {
|
||||
"type": "endpoint",
|
||||
"label": "Session",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 5004
|
||||
},
|
||||
"group": "Second sender",
|
||||
"help": "Leave the host empty if this machine joins us from its own side instead."
|
||||
},
|
||||
"source3Name": {
|
||||
"type": "string",
|
||||
"label": "Name",
|
||||
"default": "Companion",
|
||||
"group": "Third sender"
|
||||
},
|
||||
"source3": {
|
||||
"type": "endpoint",
|
||||
"label": "Session",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 5004
|
||||
},
|
||||
"group": "Third sender",
|
||||
"help": "Companion on this machine counts too: its network session's host and port (this Mac's own name works)."
|
||||
},
|
||||
"cueLists": {
|
||||
"type": "text",
|
||||
"label": "Cue lists",
|
||||
"default": "",
|
||||
"group": "Cue lists",
|
||||
"help": "One per line from the cue sheet: 60 = Walk-in · 61 release Walk-in · 127 release all · 30 flash Blinder 1 (momentary). A line like [Colors] starts a group: a Go in a group replaces that group's live list; the first group is the tile's headline. Also pause, back, note names, ch2:64, pc5, and \"quotes\" around a name starting with one of those words."
|
||||
},
|
||||
"middleC": {
|
||||
"type": "select",
|
||||
"label": "Middle C",
|
||||
"default": "C3",
|
||||
"options": [
|
||||
{
|
||||
"value": "C3",
|
||||
"label": "C3 (Ableton, Yamaha)"
|
||||
},
|
||||
{
|
||||
"value": "C4",
|
||||
"label": "C4 (Logic, scientific)"
|
||||
}
|
||||
],
|
||||
"group": "Cue lists",
|
||||
"help": "Only matters for note names in the sheet. Note 60 is middle C either way."
|
||||
},
|
||||
"headTakesOver": {
|
||||
"type": "switch",
|
||||
"label": "Main group takes over",
|
||||
"default": true,
|
||||
"group": "Cue lists",
|
||||
"help": "On: a Go in the sheet's first [group] (the main scene) clears every other live list, as a new scene takes over the look. FX fired after it layer on top again."
|
||||
},
|
||||
"exclusive": {
|
||||
"type": "switch",
|
||||
"label": "Go releases others",
|
||||
"default": false,
|
||||
"group": "Cue lists",
|
||||
"help": "On: a Go replaces every other live list, groups or not. Leave off when lists stack, and use groups instead."
|
||||
},
|
||||
"sessionName": {
|
||||
"type": "string",
|
||||
"label": "Session name",
|
||||
"default": "ProdDash Lighting",
|
||||
"group": "This machine",
|
||||
"help": "How ProdDash appears in other Macs' Audio MIDI Setup Directory."
|
||||
},
|
||||
"sessionPort": {
|
||||
"type": "number",
|
||||
"label": "Session port",
|
||||
"default": 5040,
|
||||
"min": 1024,
|
||||
"max": 65534,
|
||||
"group": "This machine",
|
||||
"help": "UDP; this one and the next are used. Anything not already taken by a MIDI session on this machine."
|
||||
},
|
||||
"advertise": {
|
||||
"type": "switch",
|
||||
"label": "Bonjour",
|
||||
"default": true,
|
||||
"group": "This machine",
|
||||
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
|
||||
},
|
||||
"wiredNetwork": {
|
||||
"type": "string",
|
||||
"label": "Wired network",
|
||||
"default": "",
|
||||
"group": "This machine",
|
||||
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
|
||||
}
|
||||
},
|
||||
"configGroups": {
|
||||
"First sender": {
|
||||
"help": "ProdDash joins the senders' network MIDI sessions, like Vista does, so it hears every note Vista hears. On each sender, in Audio MIDI Setup → MIDI Network Setup, the session must be enabled and 'Who may connect to me' set to Anyone. Or skip the address and connect to \"ProdDash Lighting\" from that Mac's Directory instead."
|
||||
},
|
||||
"This machine": {
|
||||
"collapsed": true
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showActive": {
|
||||
"type": "switch",
|
||||
"label": "Other lists",
|
||||
"default": true
|
||||
},
|
||||
"showRecent": {
|
||||
"type": "switch",
|
||||
"label": "Recent",
|
||||
"default": true
|
||||
},
|
||||
"showSources": {
|
||||
"type": "switch",
|
||||
"label": "Senders",
|
||||
"default": true
|
||||
},
|
||||
"showUnmapped": {
|
||||
"type": "switch",
|
||||
"label": "Unmapped notes",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A listen-only RTP-MIDI (AppleMIDI) session participant, in plain Node.
|
||||
*
|
||||
* This is the protocol behind a macOS "Network" MIDI session (Audio MIDI
|
||||
* Setup → MIDI Network Setup) and rtpMIDI on Windows: two UDP ports, a
|
||||
* control port P and a data port P+1. A session sends every MIDI message to
|
||||
* every participant it is connected to, so joining the same session Vista is
|
||||
* in hears exactly the notes Vista hears.
|
||||
*
|
||||
* Both ways of joining work:
|
||||
* - someone invites us (we show up in Audio MIDI Setup's Directory, or they
|
||||
* add our address by hand) and we accept anyone;
|
||||
* - we invite them (`invite(host, port)`), and keep re-inviting until they
|
||||
* accept, then keep the connection alive with clock syncs.
|
||||
*
|
||||
* We never send MIDI. Packet layouts follow Apple's AppleMIDI session
|
||||
* protocol and RFC 6295 (RTP payload for MIDI).
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
const dns = require('dns');
|
||||
const crypto = require('crypto');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const SIG = 0xffff;
|
||||
const CMD = { IN: 0x494e, OK: 0x4f4b, NO: 0x4e4f, BY: 0x4259, CK: 0x434b, RS: 0x5253 };
|
||||
const PROTOCOL_VERSION = 2;
|
||||
|
||||
const INVITE_RETRY_MS = 1500; // between invitation attempts while setting up
|
||||
const INVITE_ATTEMPTS = 12; // then give up for a while…
|
||||
const REINVITE_MS = 5000; // …and start over after this long
|
||||
const SYNC_FAST_MS = 1500; // first few clock syncs, close together
|
||||
const SYNC_MS = 10000; // then this often, as macOS does
|
||||
const SYNC_TIMEOUT_MS = 25000; // no sync answer for this long → connection is gone
|
||||
const IDLE_MS = 90000; // an inbound participant we have not heard from at all
|
||||
const FEEDBACK_MS = 1000; // receiver feedback (lets the sender trim its journal)
|
||||
|
||||
/** The session clock: 100 µs ticks since this process started the session. */
|
||||
function makeClock() {
|
||||
const t0 = process.hrtime.bigint();
|
||||
return () => (process.hrtime.bigint() - t0) / 100000n;
|
||||
}
|
||||
|
||||
function randomU32() {
|
||||
return crypto.randomBytes(4).readUInt32BE(0);
|
||||
}
|
||||
|
||||
/* ── packets ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function sessionPacket(cmd, token, ssrc, name) {
|
||||
const nameBuf = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`, 'utf8');
|
||||
const b = Buffer.alloc(16 + nameBuf.length);
|
||||
b.writeUInt16BE(SIG, 0);
|
||||
b.writeUInt16BE(cmd, 2);
|
||||
b.writeUInt32BE(PROTOCOL_VERSION, 4);
|
||||
b.writeUInt32BE(token >>> 0, 8);
|
||||
b.writeUInt32BE(ssrc >>> 0, 12);
|
||||
nameBuf.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
function syncPacket(ssrc, count, ts) {
|
||||
const b = Buffer.alloc(36);
|
||||
b.writeUInt16BE(SIG, 0);
|
||||
b.writeUInt16BE(CMD.CK, 2);
|
||||
b.writeUInt32BE(ssrc >>> 0, 4);
|
||||
b.writeUInt8(count, 8);
|
||||
for (let i = 0; i < 3; i++) b.writeBigUInt64BE(BigInt.asUintN(64, ts[i] || 0n), 12 + i * 8);
|
||||
return b;
|
||||
}
|
||||
|
||||
function feedbackPacket(ssrc, seq) {
|
||||
const b = Buffer.alloc(12);
|
||||
b.writeUInt16BE(SIG, 0);
|
||||
b.writeUInt16BE(CMD.RS, 2);
|
||||
b.writeUInt32BE(ssrc >>> 0, 4);
|
||||
b.writeUInt16BE(seq & 0xffff, 8);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** A session command (IN/OK/NO/BY/CK/RS), or null when it isn't one. */
|
||||
function parseSession(buf) {
|
||||
if (buf.length < 4 || buf.readUInt16BE(0) !== SIG) return null;
|
||||
const cmd = buf.readUInt16BE(2);
|
||||
if (cmd === CMD.CK) {
|
||||
if (buf.length < 36) return null;
|
||||
return {
|
||||
cmd,
|
||||
ssrc: buf.readUInt32BE(4),
|
||||
count: buf.readUInt8(8),
|
||||
ts: [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)],
|
||||
};
|
||||
}
|
||||
if (cmd === CMD.RS) {
|
||||
if (buf.length < 12) return null;
|
||||
return { cmd, ssrc: buf.readUInt32BE(4), seq: buf.readUInt16BE(8) };
|
||||
}
|
||||
if (cmd === CMD.IN || cmd === CMD.OK || cmd === CMD.NO || cmd === CMD.BY) {
|
||||
if (buf.length < 16) return null;
|
||||
let name = '';
|
||||
if (buf.length > 16) {
|
||||
const end = buf.indexOf(0, 16);
|
||||
name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
}
|
||||
return { cmd, version: buf.readUInt32BE(4), token: buf.readUInt32BE(8), ssrc: buf.readUInt32BE(12), name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Data bytes that follow a status byte (sysex is handled separately). */
|
||||
function dataLength(status) {
|
||||
const hi = status & 0xf0;
|
||||
if (hi === 0xc0 || hi === 0xd0) return 1;
|
||||
if (hi < 0xf0) return 2;
|
||||
if (status === 0xf1 || status === 0xf3) return 1;
|
||||
if (status === 0xf2) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MIDI commands in one RTP-MIDI packet (RFC 6295 §3), as
|
||||
* `{ status, data: [..] }` in order. Journal data is ignored: we only show
|
||||
* what is happening, and a note lost on the wire is one Vista also missed.
|
||||
*/
|
||||
function parseRtpMidi(buf) {
|
||||
if (buf.length < 13 || (buf[0] >> 6) !== 2) return null;
|
||||
const cc = buf[0] & 0x0f;
|
||||
const ext = Boolean(buf[0] & 0x10);
|
||||
const seq = buf.readUInt16BE(2);
|
||||
const ssrc = buf.readUInt32BE(8);
|
||||
let off = 12 + cc * 4;
|
||||
if (ext) {
|
||||
if (buf.length < off + 4) return null;
|
||||
off += 4 + buf.readUInt16BE(off + 2) * 4;
|
||||
}
|
||||
if (off >= buf.length) return null;
|
||||
const header = buf[off];
|
||||
const big = Boolean(header & 0x80);
|
||||
const zero = Boolean(header & 0x20);
|
||||
let len = header & 0x0f;
|
||||
if (big) {
|
||||
if (off + 1 >= buf.length) return null;
|
||||
len = (len << 8) | buf[off + 1];
|
||||
off += 2;
|
||||
} else {
|
||||
off += 1;
|
||||
}
|
||||
const end = Math.min(buf.length, off + len);
|
||||
const messages = [];
|
||||
let running = 0;
|
||||
let first = true;
|
||||
while (off < end) {
|
||||
if (!first || zero) {
|
||||
// Delta time: 1–4 octets, high bit means "more follow".
|
||||
let n = 0;
|
||||
while (off < end && (buf[off] & 0x80) && n < 3) { off++; n++; }
|
||||
off++;
|
||||
if (off >= end) break;
|
||||
}
|
||||
first = false;
|
||||
let status = buf[off];
|
||||
if (status & 0x80) {
|
||||
off++;
|
||||
} else if (running) {
|
||||
status = running;
|
||||
} else {
|
||||
break; // data byte with nothing to run on: give up on this packet
|
||||
}
|
||||
if (status === 0xf0 || status === 0xf7) {
|
||||
// A sysex, or a segment of one: runs to F7 (end), F0 (more follows) or F4 (cancelled).
|
||||
const start = off;
|
||||
while (off < end && buf[off] !== 0xf7 && buf[off] !== 0xf0 && buf[off] !== 0xf4) off++;
|
||||
const data = Array.from(buf.subarray(start, off));
|
||||
off++;
|
||||
messages.push({ status, data });
|
||||
running = 0;
|
||||
continue;
|
||||
}
|
||||
if (status < 0xf0) running = status;
|
||||
else if (status < 0xf8) running = 0; // system common cancels running status; realtime doesn't
|
||||
const n = dataLength(status);
|
||||
if (off + n > end) break;
|
||||
messages.push({ status, data: Array.from(buf.subarray(off, off + n)) });
|
||||
off += n;
|
||||
}
|
||||
return { seq, ssrc, messages };
|
||||
}
|
||||
|
||||
/** `{ status, data }` → a plain event, or null for what nobody here cares about. */
|
||||
function describe(msg) {
|
||||
const hi = msg.status & 0xf0;
|
||||
const channel = (msg.status & 0x0f) + 1;
|
||||
if (hi === 0x90 && msg.data[1] > 0) return { type: 'noteon', channel, note: msg.data[0], velocity: msg.data[1] };
|
||||
if (hi === 0x80 || (hi === 0x90 && msg.data[1] === 0)) return { type: 'noteoff', channel, note: msg.data[0] };
|
||||
if (hi === 0xc0) return { type: 'program', channel, program: msg.data[0] };
|
||||
if (hi === 0xb0) return { type: 'cc', channel, controller: msg.data[0], value: msg.data[1] };
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── the session ─────────────────────────────────────────────────────── */
|
||||
|
||||
class Session extends EventEmitter {
|
||||
/**
|
||||
* @param {{ name: string, port: number, log?: Function }} opts
|
||||
* Events: 'midi' ({ participant, event }), 'change' (participants or
|
||||
* listening changed), 'listening' (both ports bound).
|
||||
*/
|
||||
constructor({ name, port, log }) {
|
||||
super();
|
||||
this.name = name || 'ProdDash';
|
||||
this.port = port;
|
||||
this.log = log || (() => {});
|
||||
this.ssrc = randomU32();
|
||||
this.now = makeClock();
|
||||
this.control = null;
|
||||
this.data = null;
|
||||
this.participants = new Map(); // key → participant
|
||||
this.invites = new Map(); // key "host:port" → outbound invitation
|
||||
this.strangers = new Map(); // address → when we last told it we're not in its session
|
||||
this.timers = new Set();
|
||||
this.stopped = false;
|
||||
this.error = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Open both ports. Resolves once both are bound — or once the first try
|
||||
* failed, with `error` set. A port that is busy (our own previous session
|
||||
* still closing after a config save, or another program) is retried every
|
||||
* few seconds for as long as the session runs; 'listening' fires when it
|
||||
* finally binds.
|
||||
*/
|
||||
start() {
|
||||
this.every(FEEDBACK_MS, () => this.sendFeedback());
|
||||
this.every(5000, () => this.reap());
|
||||
return new Promise((resolve) => {
|
||||
let first = true;
|
||||
const attempt = (delay) => this.later(delay, async () => {
|
||||
const ok = await this.bind();
|
||||
if (ok) {
|
||||
this.error = '';
|
||||
this.emit('listening');
|
||||
this.emit('change');
|
||||
} else {
|
||||
this.emit('change');
|
||||
attempt(first ? 250 : 3000);
|
||||
}
|
||||
if (first) { first = false; resolve(ok); }
|
||||
});
|
||||
attempt(0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Bind control and data sockets together; on failure close both. */
|
||||
bind() {
|
||||
const open = (port, which) => new Promise((resolve) => {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
const fail = (e) => {
|
||||
try { sock.close(); } catch { /* not open */ }
|
||||
resolve({ sock: null, port, e });
|
||||
};
|
||||
sock.once('error', fail);
|
||||
sock.bind(port, () => {
|
||||
sock.removeListener('error', fail);
|
||||
sock.on('error', (e) => this.log(`socket error on ${port}: ${e.message}`));
|
||||
sock.on('message', (buf, rinfo) => {
|
||||
try { this.onPacket(buf, rinfo, which); } catch (e) { this.log(`bad packet from ${rinfo.address}: ${e.message}`); }
|
||||
});
|
||||
resolve({ sock, port });
|
||||
});
|
||||
});
|
||||
return Promise.all([open(this.port, 'control'), open(this.port + 1, 'data')]).then(([c, d]) => {
|
||||
if (this.stopped || !c.sock || !d.sock) {
|
||||
for (const x of [c, d]) if (x.sock) { try { x.sock.close(); } catch { /* closed */ } }
|
||||
const bad = !c.sock ? c : d;
|
||||
if (bad.e) {
|
||||
const msg = bad.e.code === 'EADDRINUSE'
|
||||
? `UDP port ${bad.port} is in use on this machine — pick another session port in /admin`
|
||||
: `UDP port ${bad.port}: ${bad.e.message}`;
|
||||
if (msg !== this.error) this.log(msg);
|
||||
this.error = msg;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.control = c.sock;
|
||||
this.data = d.sock;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
for (const t of this.timers) { clearTimeout(t); clearInterval(t); }
|
||||
this.timers.clear();
|
||||
const control = this.control;
|
||||
const data = this.data;
|
||||
const close = () => {
|
||||
for (const s of [control, data]) { try { s && s.close(); } catch { /* closed */ } }
|
||||
};
|
||||
// Say goodbye, then close once the goodbyes have left (a config save
|
||||
// re-inits straight away and wants these ports back).
|
||||
const byes = [...this.participants.values()].filter((p) => p.connected);
|
||||
let left = byes.length;
|
||||
if (!control || !left) close();
|
||||
else {
|
||||
for (const p of byes) {
|
||||
try {
|
||||
control.send(sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address, () => { if (--left === 0) close(); });
|
||||
} catch { if (--left === 0) close(); }
|
||||
}
|
||||
}
|
||||
this.control = null;
|
||||
this.data = null;
|
||||
this.participants.clear();
|
||||
this.invites.clear();
|
||||
}
|
||||
|
||||
every(ms, fn) {
|
||||
const t = setInterval(fn, ms);
|
||||
t.unref?.();
|
||||
this.timers.add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
later(ms, fn) {
|
||||
const t = setTimeout(() => { this.timers.delete(t); if (!this.stopped) fn(); }, ms);
|
||||
t.unref?.();
|
||||
this.timers.add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
send(sock, buf, port, address) {
|
||||
if (!sock || this.stopped) return;
|
||||
try { sock.send(buf, port, address); } catch { /* socket closed */ }
|
||||
}
|
||||
|
||||
/** Every participant, for display. */
|
||||
list() {
|
||||
return [...this.participants.values()].map((p) => ({
|
||||
name: p.name,
|
||||
address: p.address,
|
||||
port: p.controlPort,
|
||||
connected: p.connected,
|
||||
outbound: p.outbound,
|
||||
lastHeard: p.lastHeard || null,
|
||||
since: p.since || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/* — outbound invitations — */
|
||||
|
||||
/**
|
||||
* Keep a connection to the session at host:port. Re-invites on its own
|
||||
* whenever the connection drops. Returns the invitation's state object.
|
||||
*/
|
||||
invite(host, port, label) {
|
||||
const key = `${host}:${port}`;
|
||||
if (this.invites.has(key)) return this.invites.get(key);
|
||||
const inv = { key, host, port, label: label || host, address: '', token: 0, attempts: 0, stage: 'idle', error: '', timer: null };
|
||||
this.invites.set(key, inv);
|
||||
this.beginInvite(inv);
|
||||
return inv;
|
||||
}
|
||||
|
||||
beginInvite(inv) {
|
||||
if (this.stopped || !this.invites.has(inv.key)) return;
|
||||
dns.lookup(inv.host, { family: 4 }, (err, address) => {
|
||||
if (this.stopped) return;
|
||||
if (err) {
|
||||
inv.error = `can't find ${inv.host}`;
|
||||
inv.stage = 'idle';
|
||||
this.emit('change');
|
||||
this.later(REINVITE_MS, () => this.beginInvite(inv));
|
||||
return;
|
||||
}
|
||||
inv.address = address;
|
||||
// Already in a session with that machine (it invited us)? Nothing to do.
|
||||
const existing = this.findByAddress(address, inv.port);
|
||||
if (existing && existing.connected) {
|
||||
existing.label = inv.label;
|
||||
inv.stage = 'connected';
|
||||
inv.error = '';
|
||||
this.emit('change');
|
||||
return;
|
||||
}
|
||||
inv.token = randomU32();
|
||||
inv.attempts = 0;
|
||||
inv.stage = 'control';
|
||||
this.sendInvite(inv);
|
||||
});
|
||||
}
|
||||
|
||||
sendInvite(inv) {
|
||||
if (this.stopped || !this.invites.has(inv.key)) return;
|
||||
if (inv.stage !== 'control' && inv.stage !== 'data') return;
|
||||
if (inv.attempts >= INVITE_ATTEMPTS) {
|
||||
inv.error = inv.error || 'no answer';
|
||||
inv.stage = 'idle';
|
||||
this.emit('change');
|
||||
this.later(REINVITE_MS, () => this.beginInvite(inv));
|
||||
return;
|
||||
}
|
||||
inv.attempts++;
|
||||
const sock = inv.stage === 'control' ? this.control : this.data;
|
||||
const port = inv.stage === 'control' ? inv.port : inv.port + 1;
|
||||
this.send(sock, sessionPacket(CMD.IN, inv.token, this.ssrc, this.name), port, inv.address);
|
||||
clearTimeout(inv.timer);
|
||||
inv.timer = this.later(INVITE_RETRY_MS, () => this.sendInvite(inv));
|
||||
}
|
||||
|
||||
inviteFor(rinfo, token) {
|
||||
for (const inv of this.invites.values()) {
|
||||
if (inv.token === token && inv.address === rinfo.address) return inv;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findByAddress(address, controlPort) {
|
||||
for (const p of this.participants.values()) {
|
||||
if (p.address === address && (controlPort == null || p.controlPort === controlPort)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* — packets in — */
|
||||
|
||||
onPacket(buf, rinfo, which) {
|
||||
const s = parseSession(buf);
|
||||
if (!s) {
|
||||
if (which === 'data') this.onRtp(buf, rinfo);
|
||||
return;
|
||||
}
|
||||
const sock = which === 'control' ? this.control : this.data;
|
||||
switch (s.cmd) {
|
||||
case CMD.IN: return this.onInvitation(s, rinfo, which, sock);
|
||||
case CMD.OK: return this.onAccepted(s, rinfo, which);
|
||||
case CMD.NO: return this.onRejected(s, rinfo);
|
||||
case CMD.BY: return this.onBye(s, rinfo);
|
||||
case CMD.CK: return this.onSync(s, rinfo);
|
||||
default: return undefined; // RS from a peer: nothing for a listener to do
|
||||
}
|
||||
}
|
||||
|
||||
onInvitation(s, rinfo, which, sock) {
|
||||
// We accept anyone: being in more sessions only means hearing more cues.
|
||||
this.send(sock, sessionPacket(CMD.OK, s.token, this.ssrc, this.name), rinfo.port, rinfo.address);
|
||||
const key = `in:${s.ssrc}`;
|
||||
let p = this.participants.get(key);
|
||||
if (which === 'control') {
|
||||
if (!p) {
|
||||
p = {
|
||||
key, ssrc: s.ssrc, token: s.token, name: s.name || rinfo.address, label: '',
|
||||
address: rinfo.address, controlPort: rinfo.port, dataPort: rinfo.port + 1,
|
||||
outbound: false, connected: false, lastSeq: -1, fedBack: -1, lastHeard: Date.now(), since: 0,
|
||||
};
|
||||
this.participants.set(key, p);
|
||||
this.log(`invited by "${p.name}" at ${rinfo.address}:${rinfo.port}`);
|
||||
} else {
|
||||
p.token = s.token;
|
||||
p.controlPort = rinfo.port;
|
||||
p.name = s.name || p.name;
|
||||
}
|
||||
} else if (p) {
|
||||
p.dataPort = rinfo.port;
|
||||
p.connected = true;
|
||||
p.since = Date.now();
|
||||
p.lastHeard = Date.now();
|
||||
this.log(`in session with "${p.name}" (${p.address})`);
|
||||
for (const inv of this.invites.values()) {
|
||||
if (inv.address === p.address && inv.port === p.controlPort) {
|
||||
p.label = inv.label;
|
||||
inv.stage = 'connected';
|
||||
inv.error = '';
|
||||
clearTimeout(inv.timer);
|
||||
}
|
||||
}
|
||||
this.emit('change');
|
||||
}
|
||||
}
|
||||
|
||||
onAccepted(s, rinfo, which) {
|
||||
const inv = this.inviteFor(rinfo, s.token);
|
||||
if (!inv) return;
|
||||
clearTimeout(inv.timer);
|
||||
if (which === 'control' && inv.stage === 'control') {
|
||||
inv.stage = 'data';
|
||||
inv.attempts = 0;
|
||||
inv.remoteName = s.name;
|
||||
this.sendInvite(inv);
|
||||
return;
|
||||
}
|
||||
if (which === 'data' && inv.stage === 'data') {
|
||||
const key = `out:${inv.key}`;
|
||||
// Drop any stale record of the same remote before taking the new one.
|
||||
for (const [k, q] of this.participants) if (q.ssrc === s.ssrc && k !== key) this.participants.delete(k);
|
||||
const p = {
|
||||
key, ssrc: s.ssrc, token: inv.token, name: inv.remoteName || s.name || inv.host, label: inv.label,
|
||||
address: rinfo.address, controlPort: inv.port, dataPort: rinfo.port,
|
||||
outbound: true, connected: true, lastSeq: -1, fedBack: -1,
|
||||
lastHeard: Date.now(), lastSyncAnswer: Date.now(), since: Date.now(), syncs: 0,
|
||||
};
|
||||
this.participants.set(key, p);
|
||||
inv.stage = 'connected';
|
||||
inv.error = '';
|
||||
this.log(`joined "${p.name}" at ${inv.host} (${rinfo.address}:${inv.port})`);
|
||||
this.emit('change');
|
||||
this.startSync(p, inv);
|
||||
}
|
||||
}
|
||||
|
||||
onRejected(s, rinfo) {
|
||||
const inv = this.inviteFor(rinfo, s.token);
|
||||
if (!inv) return;
|
||||
clearTimeout(inv.timer);
|
||||
inv.stage = 'idle';
|
||||
inv.error = 'the session refused us — set "Who may connect" to Anyone in Audio MIDI Setup';
|
||||
this.log(`${inv.host}: invitation refused`);
|
||||
this.emit('change');
|
||||
this.later(REINVITE_MS * 2, () => this.beginInvite(inv));
|
||||
}
|
||||
|
||||
onBye(s, rinfo) {
|
||||
for (const [k, p] of this.participants) {
|
||||
if (p.ssrc === s.ssrc && p.address === rinfo.address) this.drop(k, 'left the session');
|
||||
}
|
||||
}
|
||||
|
||||
drop(key, why) {
|
||||
const p = this.participants.get(key);
|
||||
if (!p) return;
|
||||
this.participants.delete(key);
|
||||
clearInterval(p.syncTimer);
|
||||
this.timers.delete(p.syncTimer);
|
||||
this.log(`"${p.name}" ${why}`);
|
||||
// If we were asked to keep this one, go and get it back.
|
||||
for (const inv of this.invites.values()) {
|
||||
if (inv.address === p.address && inv.port === p.controlPort && inv.stage === 'connected') {
|
||||
inv.stage = 'idle';
|
||||
inv.error = why;
|
||||
this.later(p.outbound ? 1000 : REINVITE_MS, () => this.beginInvite(inv));
|
||||
}
|
||||
}
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
/* — clock sync — */
|
||||
|
||||
startSync(p) {
|
||||
const tick = () => {
|
||||
if (!this.participants.has(p.key)) return;
|
||||
if (Date.now() - p.lastSyncAnswer > SYNC_TIMEOUT_MS) {
|
||||
this.send(this.control, sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address);
|
||||
this.drop(p.key, 'stopped answering');
|
||||
return;
|
||||
}
|
||||
this.send(this.data, syncPacket(this.ssrc, 0, [this.now(), 0n, 0n]), p.dataPort, p.address);
|
||||
p.syncs++;
|
||||
if (p.syncs === 6) {
|
||||
clearInterval(p.syncTimer);
|
||||
this.timers.delete(p.syncTimer);
|
||||
p.syncTimer = this.every(SYNC_MS, tick);
|
||||
}
|
||||
};
|
||||
p.syncTimer = this.every(SYNC_FAST_MS, tick);
|
||||
tick();
|
||||
}
|
||||
|
||||
onSync(s, rinfo) {
|
||||
const p = [...this.participants.values()].find((q) => q.ssrc === s.ssrc);
|
||||
if (p) p.lastHeard = Date.now();
|
||||
if (s.count === 0) {
|
||||
this.send(this.data, syncPacket(this.ssrc, 1, [s.ts[0], this.now(), 0n]), rinfo.port, rinfo.address);
|
||||
} else if (s.count === 1) {
|
||||
if (p) p.lastSyncAnswer = Date.now();
|
||||
this.send(this.data, syncPacket(this.ssrc, 2, [s.ts[0], s.ts[1], this.now()]), rinfo.port, rinfo.address);
|
||||
}
|
||||
// count 2 completes a sync someone else started; nothing to answer.
|
||||
}
|
||||
|
||||
/** Drop inbound participants that have gone quiet (their machine slept or left without a BY). */
|
||||
reap() {
|
||||
const now = Date.now();
|
||||
for (const [k, p] of this.participants) {
|
||||
if (!p.outbound && now - (p.lastHeard || 0) > IDLE_MS) this.drop(k, 'went quiet');
|
||||
}
|
||||
}
|
||||
|
||||
sendFeedback() {
|
||||
for (const p of this.participants.values()) {
|
||||
if (!p.connected || p.lastSeq < 0 || p.lastSeq === p.fedBack) continue;
|
||||
p.fedBack = p.lastSeq;
|
||||
this.send(this.control, feedbackPacket(this.ssrc, p.lastSeq), p.controlPort, p.address);
|
||||
}
|
||||
}
|
||||
|
||||
/* — MIDI — */
|
||||
|
||||
onRtp(buf, rinfo) {
|
||||
const pkt = parseRtpMidi(buf);
|
||||
if (!pkt) return;
|
||||
let p = [...this.participants.values()].find((q) => q.ssrc === pkt.ssrc && q.connected);
|
||||
if (!p) {
|
||||
// MIDI from someone not in a session with us (a stale session after our restart).
|
||||
// Ask them to start over so the session comes back properly — once in a while, not per note.
|
||||
const last = this.strangers.get(rinfo.address) || 0;
|
||||
if (Date.now() - last > 5000) {
|
||||
this.strangers.set(rinfo.address, Date.now());
|
||||
this.send(this.control, sessionPacket(CMD.BY, 0, this.ssrc), rinfo.port - 1, rinfo.address);
|
||||
}
|
||||
return;
|
||||
}
|
||||
p.lastHeard = Date.now();
|
||||
p.lastSeq = pkt.seq;
|
||||
for (const m of pkt.messages) {
|
||||
const event = describe(m);
|
||||
if (event) this.emit('midi', { participant: p, event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Session,
|
||||
parseRtpMidi,
|
||||
parseSession,
|
||||
describe,
|
||||
sessionPacket,
|
||||
syncPacket,
|
||||
CMD,
|
||||
};
|
||||
@@ -0,0 +1,428 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Vista Cues — server part.
|
||||
*
|
||||
* Vista 3 cannot say what it is playing: its OSC is input-only and it sends
|
||||
* no feedback. What it *does* have is a network MIDI session that
|
||||
* ProPresenter and the tracks rig fire cue lists through. A session sends
|
||||
* every message to every participant, so this module joins those sessions
|
||||
* as one more participant (rtpmidi.js) and hears exactly what Vista hears.
|
||||
* The admin's "Cue lists" sheet (mapping.js) turns each note into
|
||||
* "Go on Walk-in", "Release Worship 1"…, and from those the module keeps
|
||||
* which lists are live.
|
||||
*
|
||||
* GET /state → { state } (instant paint)
|
||||
* GET /stream → SSE: `state` on every change
|
||||
* POST /clear → forget which lists are live (when Vista was reset by hand)
|
||||
*
|
||||
* What the tile shows is what Vista was told, not what Vista confirmed: if
|
||||
* Vista is closed or someone works the desk by hand, the two can differ.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const { Session } = require('./rtpmidi');
|
||||
const { parseMapping, match, triggerLabel } = require('./mapping');
|
||||
|
||||
const HEARTBEAT_MS = 15000;
|
||||
const LOG_MAX = 40;
|
||||
const DNS_SD = '/usr/bin/dns-sd';
|
||||
|
||||
let current = null;
|
||||
|
||||
function emptyState(extra = {}) {
|
||||
return {
|
||||
listening: false,
|
||||
error: '',
|
||||
sessionName: '',
|
||||
port: null,
|
||||
advertised: false,
|
||||
sources: [],
|
||||
current: null,
|
||||
active: [],
|
||||
log: [],
|
||||
mappingErrors: [],
|
||||
rules: 0,
|
||||
now: Date.now(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
|
||||
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
|
||||
* session once per network, so a sender typed or picked from the wrong copy
|
||||
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
|
||||
* throws on a typo so the admin page says so at once.
|
||||
*/
|
||||
function parseNets(text) {
|
||||
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
|
||||
const parts = m ? m.slice(1, 5).map(Number) : [];
|
||||
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
|
||||
if (!m || parts.some((x) => x > 255) || bits > 32) {
|
||||
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
|
||||
}
|
||||
return { base: ipNumber(parts.join('.')), bits, text: n };
|
||||
});
|
||||
}
|
||||
|
||||
function ipNumber(a) {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
|
||||
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an address is not on the wired network: '' when it is (or there is no
|
||||
* rule), 'by name' for a host name (it resolves to whichever network answers
|
||||
* first), 'not wired' for an address outside every wired network.
|
||||
*/
|
||||
function offNetwork(nets, address) {
|
||||
if (!nets.length || !address) return '';
|
||||
const v = ipNumber(address);
|
||||
if (v === null) return 'by name';
|
||||
if (address.startsWith('127.')) return '';
|
||||
const span = (bits) => 2 ** (32 - bits);
|
||||
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
|
||||
}
|
||||
|
||||
function endpointOf(v) {
|
||||
const ep = v && typeof v === 'object' ? v : {};
|
||||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const sessionName = String(config.sessionName || '').trim() || 'ProdDash Lighting';
|
||||
const port = Math.round(Number(config.sessionPort)) || 5040;
|
||||
const middleC = config.middleC === 'C4' ? 'C4' : 'C3';
|
||||
const exclusive = config.exclusive === true;
|
||||
const headTakesOver = config.headTakesOver !== false;
|
||||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||||
const nets = parseNets(config.wiredNetwork);
|
||||
const netText = nets.map((n) => n.text).join(', ');
|
||||
if (mappingErrors.length) {
|
||||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||||
}
|
||||
|
||||
const senders = [
|
||||
{ label: String(config.source1Name || '').trim() || 'ProPresenter', ...endpointOf(config.source1) },
|
||||
{ label: String(config.source2Name || '').trim() || 'Tracks', ...endpointOf(config.source2) },
|
||||
{ label: String(config.source3Name || '').trim() || 'Companion', ...endpointOf(config.source3) },
|
||||
].filter((s) => s.host);
|
||||
|
||||
const streams = new Set();
|
||||
const lists = new Map(); // lower-cased name → { name, group, state, firedAt, lastAt, lastAction, source, trigger, goes }
|
||||
const history = []; // newest first
|
||||
let currentKey = '';
|
||||
let lastFrame = '';
|
||||
let advertiser = null;
|
||||
let advertised = false;
|
||||
let stopped = false;
|
||||
|
||||
if (port < 1024 || port > 65534) throw new Error('Session port must be between 1024 and 65534');
|
||||
|
||||
const session = new Session({ name: sessionName, port, log });
|
||||
const invites = senders.map((s) => ({ ...s, inv: null }));
|
||||
|
||||
/* ── what Vista was told ─────────────────────────────────────── */
|
||||
|
||||
function sourceLabel(p) {
|
||||
return p.label || p.name || p.address;
|
||||
}
|
||||
|
||||
function record(entry) {
|
||||
history.unshift({ at: Date.now(), ...entry });
|
||||
if (history.length > LOG_MAX) history.length = LOG_MAX;
|
||||
}
|
||||
|
||||
function apply(rule, source, trigger) {
|
||||
const now = Date.now();
|
||||
const key = rule.list.toLowerCase();
|
||||
record({ action: rule.action, list: rule.list, group: rule.group, source, trigger, mapped: true });
|
||||
if (rule.action === 'flash') return; // a momentary hit: in the log, never live
|
||||
if (rule.action === 'releaseAll') {
|
||||
lists.clear();
|
||||
currentKey = '';
|
||||
return;
|
||||
}
|
||||
if (rule.action === 'release') {
|
||||
lists.delete(key);
|
||||
if (currentKey === key) currentKey = newestKey();
|
||||
return;
|
||||
}
|
||||
let l = lists.get(key);
|
||||
if (rule.action === 'go') {
|
||||
// A Go replaces the other lists of its group. A Go in the first group (the
|
||||
// main scene) takes over everything, as Vista's last-takes-precedence does;
|
||||
// and with "Go releases others" on, every Go does.
|
||||
const takeover = exclusive || (headTakesOver && rule.group && rule.group === groups[0]);
|
||||
for (const [k, other] of [...lists]) {
|
||||
if (k !== key && (takeover || (rule.group && other.group === rule.group))) lists.delete(k);
|
||||
}
|
||||
if (!l) {
|
||||
l = { name: rule.list, group: rule.group, state: 'active', firedAt: now, goes: 0 };
|
||||
lists.set(key, l);
|
||||
}
|
||||
l.state = 'active';
|
||||
l.goes++;
|
||||
l.lastAt = now;
|
||||
l.lastAction = 'go';
|
||||
l.source = source;
|
||||
l.trigger = trigger;
|
||||
currentKey = key;
|
||||
return;
|
||||
}
|
||||
// pause / back act on a list that is live; on one that isn't, they only go in the log.
|
||||
if (!l) return;
|
||||
l.lastAt = now;
|
||||
l.lastAction = rule.action;
|
||||
l.source = source;
|
||||
l.trigger = trigger;
|
||||
if (rule.action === 'pause') l.state = 'paused'; // the next Go resumes it
|
||||
if (rule.action === 'back') { l.state = 'active'; l.goes = Math.max(1, l.goes - 1); }
|
||||
}
|
||||
|
||||
function newestKey() {
|
||||
let best = '';
|
||||
let at = -1;
|
||||
for (const [k, l] of lists) if (l.lastAt > at) { best = k; at = l.lastAt; }
|
||||
return best;
|
||||
}
|
||||
|
||||
session.on('midi', ({ participant, event }) => {
|
||||
if (event.type !== 'noteon' && event.type !== 'program') return;
|
||||
const source = sourceLabel(participant);
|
||||
const trigger = triggerLabel(event, middleC);
|
||||
const rule = match(rules, event);
|
||||
if (rule) {
|
||||
apply(rule, source, trigger);
|
||||
log(`${source}: ${trigger} → ${rule.action}${rule.list ? ` ${rule.list}` : ''}`);
|
||||
} else {
|
||||
record({ action: '', list: '', source, trigger, mapped: false });
|
||||
}
|
||||
broadcast();
|
||||
});
|
||||
session.on('change', () => broadcast());
|
||||
// Senders are joined once our ports are ours — straight away normally,
|
||||
// or a moment later when a busy port frees up.
|
||||
session.on('listening', () => {
|
||||
if (stopped) return;
|
||||
log(`session "${sessionName}" listening on UDP ${port}/${port + 1}`);
|
||||
for (const s of invites) s.inv = session.invite(s.host, s.port, s.label);
|
||||
if (!advertiser) advertise();
|
||||
});
|
||||
|
||||
/* ── the view-model ──────────────────────────────────────────── */
|
||||
|
||||
function sources() {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const s of invites) {
|
||||
const inv = s.inv;
|
||||
const p = inv && [...session.participants.values()].find((q) => q.connected && q.address === inv.address && q.controlPort === inv.port);
|
||||
if (p) seen.add(p.key);
|
||||
out.push({
|
||||
label: s.label,
|
||||
name: p ? p.name : '',
|
||||
address: inv?.address || s.host,
|
||||
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
|
||||
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
|
||||
host: s.host,
|
||||
configured: true,
|
||||
connected: Boolean(p),
|
||||
error: p ? '' : (inv?.error || ''),
|
||||
lastHeard: p ? p.lastHeard : null,
|
||||
});
|
||||
}
|
||||
for (const p of session.participants.values()) {
|
||||
if (seen.has(p.key) || !p.connected) continue;
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function groupRank(l) {
|
||||
const i = groups.indexOf(l.group);
|
||||
return i < 0 ? groups.length : i;
|
||||
}
|
||||
|
||||
function buildState() {
|
||||
const live = [...lists.values()].sort((a, b) => b.lastAt - a.lastAt);
|
||||
// The headline: the live list of the sheet's first group (the scene, say,
|
||||
// rather than whichever colour changed last); without groups, the last Go.
|
||||
const head = groups[0];
|
||||
const cur = (head && live.find((l) => l.group === head))
|
||||
|| (currentKey ? lists.get(currentKey) : null)
|
||||
|| null;
|
||||
return {
|
||||
listening: Boolean(session.control),
|
||||
error: session.error,
|
||||
sessionName,
|
||||
port,
|
||||
advertised,
|
||||
wiredNetwork: netText,
|
||||
sources: sources(),
|
||||
current: cur || null,
|
||||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||||
active: live.filter((l) => l !== cur).sort((a, b) => groupRank(a) - groupRank(b) || b.lastAt - a.lastAt),
|
||||
headline: head || '',
|
||||
log: history,
|
||||
mappingErrors,
|
||||
rules: rules.length,
|
||||
now: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function send(payload) {
|
||||
for (const res of streams) {
|
||||
try { res.write(payload); } catch { streams.delete(res); }
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
if (stopped) return;
|
||||
const state = buildState();
|
||||
const { now, sources: src, ...rest } = state;
|
||||
// lastHeard moves with every packet; leave it out of change detection.
|
||||
const frame = JSON.stringify({ ...rest, src: src.map(({ lastHeard, ...s }) => s) });
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
send(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||||
}
|
||||
|
||||
const keepalive = setInterval(() => send(': ping\n\n'), HEARTBEAT_MS);
|
||||
keepalive.unref?.();
|
||||
|
||||
/* ── being found: Bonjour on macOS ───────────────────────────── */
|
||||
|
||||
// Advertising _apple-midi._udp puts us in Audio MIDI Setup's Directory on
|
||||
// every Mac here, so a sender can add us with a click. macOS ships the
|
||||
// dns-sd tool; we run it as a child and stop it with the module. Elsewhere
|
||||
// nothing is advertised and senders are joined by address alone.
|
||||
function advertise() {
|
||||
if (stopped || config.advertise === false || process.platform !== 'darwin' || !fs.existsSync(DNS_SD)) return;
|
||||
advertiser = spawn(DNS_SD, ['-R', sessionName, '_apple-midi._udp', 'local', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
advertiser.stdout.on('data', (d) => {
|
||||
if (/Name now registered and active/i.test(String(d)) && !advertised) {
|
||||
advertised = true;
|
||||
log(`advertised as "${sessionName}" on port ${port}`);
|
||||
broadcast();
|
||||
}
|
||||
});
|
||||
advertiser.stderr.on('data', () => {});
|
||||
advertiser.on('error', (e) => { log(`dns-sd: ${e.message}`); advertiser = null; });
|
||||
advertiser.on('exit', () => {
|
||||
advertiser = null;
|
||||
advertised = false;
|
||||
if (!stopped) setTimeout(advertise, 5000).unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── start ───────────────────────────────────────────────────── */
|
||||
|
||||
session.start().then(() => broadcast());
|
||||
|
||||
current = {
|
||||
streams,
|
||||
getState: buildState,
|
||||
clear() {
|
||||
lists.clear();
|
||||
currentKey = '';
|
||||
record({ action: 'cleared', list: '', source: 'Dashboard', trigger: '', mapped: true });
|
||||
broadcast();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(keepalive);
|
||||
session.stop();
|
||||
if (advertiser) { try { advertiser.kill(); } catch { /* gone */ } advertiser = null; }
|
||||
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (session.error) return { status: 'error', message: session.error };
|
||||
const src = sources();
|
||||
// Off the wired network: still joined (the cues keep working), but said first and in red.
|
||||
const off = src.filter((s) => s.offNetwork);
|
||||
if (off.length) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${off.map((s) => (s.offNetwork === 'by name'
|
||||
? `${s.label} is set by name (${s.host})`
|
||||
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
|
||||
};
|
||||
}
|
||||
const on = src.filter((s) => s.connected);
|
||||
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||||
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||||
if (on.length) {
|
||||
const off = src.filter((s) => !s.connected).map((s) => `${s.label} (${s.error || 'joining…'})`);
|
||||
return {
|
||||
status: off.length ? 'connecting' : 'ok',
|
||||
message: `In session with ${on.map((s) => s.label).join(', ')}${off.length ? ` · waiting for ${off.join(', ')}` : ''}${map}${lines}`,
|
||||
};
|
||||
}
|
||||
const where = advertised ? `advertised as "${sessionName}"` : `port ${port}`;
|
||||
if (src.length) {
|
||||
return { status: 'connecting', message: `Joining ${src.map((s) => `${s.label}${s.error ? ` (${s.error})` : ''}`).join(', ')} · ${where}${map}${lines}` };
|
||||
}
|
||||
return { status: 'connecting', message: `Waiting for a MIDI session to connect · ${where}${map}${lines}` };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// For the tests.
|
||||
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
|
||||
|
||||
/**
|
||||
* One picker entry, so the + menu gives Vista Cues a heading of its own —
|
||||
* a module with no list sits headless under whichever module precedes it
|
||||
* (Slack, alphabetically). Tiles added before this keep working: they
|
||||
* have no variant and use the module's schema.
|
||||
*/
|
||||
tiles() {
|
||||
return [{
|
||||
id: 'live',
|
||||
name: 'Live cue lists',
|
||||
description: 'The main scene Vista is running, the FX on top, and what fired them',
|
||||
defaultSize: { w: 5, h: 4 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
}];
|
||||
},
|
||||
|
||||
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.getState() : emptyState() }),
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getState() : emptyState())}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
const set = current.streams;
|
||||
set.add(res);
|
||||
req.on('close', () => set.delete(res));
|
||||
},
|
||||
'POST /clear': (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Vista Cues is not running' });
|
||||
current.clear();
|
||||
return sendJson(res, 200, { ok: true });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
/* Vista Cues. Sizes hang off the tile (container units) times the tile's
|
||||
own text scale (--vc-scale, the title bar's − / +), so one tile reads the
|
||||
same at 3×2 on a laptop as at 8×5 on a wall. Colours are the shell's. */
|
||||
|
||||
[data-module="vista-cues"] .vc-root {
|
||||
--tone: var(--text);
|
||||
--vc-scale: 1;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
container-type: size;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root[data-tone="ok"] { --tone: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-root[data-tone="warn"] { --tone: var(--warn); }
|
||||
[data-module="vista-cues"] .vc-root[data-tone="muted"] { --tone: var(--muted); }
|
||||
[data-module="vista-cues"] [hidden] { display: none !important; }
|
||||
|
||||
[data-module="vista-cues"] .vc-body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(clamp(3px, 2.5cqh, 14px) * var(--vc-scale));
|
||||
padding: clamp(6px, 3.5cqmin, 22px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-module="vista-cues"] .vc-offline {
|
||||
position: absolute;
|
||||
top: clamp(4px, 3cqmin, 14px);
|
||||
right: clamp(6px, 3.5cqmin, 22px);
|
||||
z-index: 2;
|
||||
font-size: calc(clamp(9px, 4.5cqmin, 14px) * var(--vc-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root.is-offline .vc-now { opacity: 0.45; filter: grayscale(0.6); }
|
||||
|
||||
/* ── now ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-now {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
padding-left: clamp(6px, 2cqmin, 14px);
|
||||
border-left: clamp(3px, 1.1cqmin, 7px) solid var(--tone);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
font-size: calc(clamp(10px, 5cqmin, 18px) * var(--vc-scale));
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-pill {
|
||||
padding: 0.05em 0.55em;
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--tone);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root[data-tone="warn"] .vc-pill { color: var(--bg); }
|
||||
[data-module="vista-cues"] .vc-ago { font-weight: 600; }
|
||||
[data-module="vista-cues"] .vc-name-wrap { min-width: 0; }
|
||||
[data-module="vista-cues"] .vc-name {
|
||||
font-size: calc(clamp(16px, 14cqmin, 96px) * var(--vc-scale));
|
||||
line-height: 1.12;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root[data-tone="muted"] .vc-name { color: var(--muted); font-weight: 600; }
|
||||
[data-module="vista-cues"] .vc-meta {
|
||||
font-size: calc(clamp(10px, 4.6cqmin, 18px) * var(--vc-scale));
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-meta:empty { display: none; }
|
||||
|
||||
/* ── other live lists ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-active {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
flex: 0 0 auto;
|
||||
font-size: calc(clamp(10px, 4.4cqmin, 17px) * var(--vc-scale));
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
max-width: 100%;
|
||||
padding: 0.2em 0.65em;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip.is-paused {
|
||||
border-color: color-mix(in srgb, var(--warn) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--warn) 14%, transparent);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip-name {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip-ago,
|
||||
[data-module="vista-cues"] .vc-chip-group { color: var(--muted); }
|
||||
[data-module="vista-cues"] .vc-chip-group { font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* ── recent ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-recent {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
font-size: calc(clamp(10px, 3.8cqmin, 15px) * var(--vc-scale));
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.35em;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 6.2em minmax(0, 1fr) auto;
|
||||
gap: 0.8em;
|
||||
align-items: baseline;
|
||||
padding: 0.12em 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-row > span { overflow: hidden; text-overflow: ellipsis; }
|
||||
[data-module="vista-cues"] .vc-row-time,
|
||||
[data-module="vista-cues"] .vc-row-source { color: var(--muted); }
|
||||
[data-module="vista-cues"] .vc-row-action { font-weight: 700; color: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-row[data-action="release"] .vc-row-action,
|
||||
[data-module="vista-cues"] .vc-row[data-action="releaseAll"] .vc-row-action,
|
||||
[data-module="vista-cues"] .vc-row[data-action="cleared"] .vc-row-action { color: var(--muted); }
|
||||
[data-module="vista-cues"] .vc-row[data-action="pause"] .vc-row-action { color: var(--warn); }
|
||||
[data-module="vista-cues"] .vc-row[data-action="flash"] .vc-row-action { color: var(--text); }
|
||||
[data-module="vista-cues"] .vc-row.is-unmapped { opacity: 0.6; }
|
||||
[data-module="vista-cues"] .vc-row.is-unmapped .vc-row-action { color: var(--muted); font-weight: 500; }
|
||||
|
||||
@container (max-width: 300px) {
|
||||
[data-module="vista-cues"] .vc-row { grid-template-columns: 5.2em minmax(0, 1fr); }
|
||||
[data-module="vista-cues"] .vc-row-time,
|
||||
[data-module="vista-cues"] .vc-row-source { display: none; }
|
||||
}
|
||||
@container (max-height: 140px) {
|
||||
[data-module="vista-cues"] .vc-recent,
|
||||
[data-module="vista-cues"] .vc-active { display: none; }
|
||||
}
|
||||
|
||||
/* ── senders ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9em;
|
||||
margin-top: auto;
|
||||
font-size: calc(clamp(9px, 3.4cqmin, 13px) * var(--vc-scale));
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-src { display: inline-flex; align-items: center; gap: 0.4em; }
|
||||
[data-module="vista-cues"] .vc-dot {
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
|
||||
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
|
||||
@container (max-height: 140px) {
|
||||
[data-module="vista-cues"] .vc-sources { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for Vista Cues.
|
||||
*
|
||||
* node modules/vista-cues/vista.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. Covers
|
||||
* the cue-sheet parser, RTP-MIDI packet decoding, and a real session over
|
||||
* loopback UDP: a fake sender invites the module's session, plays notes, and
|
||||
* the server entry's state follows.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const dgram = require('dgram');
|
||||
const http = require('http');
|
||||
const { parseMapping, match, noteFromName } = require('./mapping');
|
||||
const { parseRtpMidi, describe, Session } = require('./rtpmidi');
|
||||
|
||||
let passed = 0;
|
||||
async function test(name, fn) {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function until(fn, ms = 3000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (fn()) return;
|
||||
await wait(20);
|
||||
}
|
||||
throw new Error('timed out waiting');
|
||||
}
|
||||
|
||||
/* ── a minimal independent sender ─────────────────────────────────────── */
|
||||
|
||||
function sessionPkt(cmd, token, ssrc, name) {
|
||||
const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`);
|
||||
const b = Buffer.alloc(16 + n.length);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write(cmd, 2, 'ascii');
|
||||
b.writeUInt32BE(2, 4);
|
||||
b.writeUInt32BE(token, 8);
|
||||
b.writeUInt32BE(ssrc, 12);
|
||||
n.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
function rtp(ssrc, seq, midiBytes, flags = 0) {
|
||||
const midi = Buffer.from(midiBytes);
|
||||
const b = Buffer.alloc(13 + midi.length);
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x61;
|
||||
b.writeUInt16BE(seq, 2);
|
||||
b.writeUInt32BE(1234, 4);
|
||||
b.writeUInt32BE(ssrc, 8);
|
||||
b[12] = flags | midi.length;
|
||||
midi.copy(b, 13);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** A sender that accepts invitations (like a Mac session) and can play notes. */
|
||||
async function fakeSender(port, name) {
|
||||
const ssrc = 0x0badf00d;
|
||||
const control = dgram.createSocket('udp4');
|
||||
const data = dgram.createSocket('udp4');
|
||||
const peers = [];
|
||||
const syncs = [];
|
||||
let seq = 100;
|
||||
const handle = (sock, which) => (buf, r) => {
|
||||
const cmd = buf.toString('ascii', 2, 4);
|
||||
if (cmd === 'IN') {
|
||||
sock.send(sessionPkt('OK', buf.readUInt32BE(8), ssrc, name), r.port, r.address);
|
||||
if (which === 'data') peers.push({ address: r.address, port: r.port });
|
||||
} else if (cmd === 'CK') {
|
||||
syncs.push(buf[8]);
|
||||
if (buf[8] === 0) {
|
||||
const out = Buffer.from(buf);
|
||||
out.writeUInt32BE(ssrc, 4);
|
||||
out[8] = 1;
|
||||
data.send(out, r.port, r.address);
|
||||
}
|
||||
}
|
||||
};
|
||||
control.on('message', handle(control, 'control'));
|
||||
data.on('message', handle(data, 'data'));
|
||||
await new Promise((r) => control.bind(port, '127.0.0.1', r));
|
||||
await new Promise((r) => data.bind(port + 1, '127.0.0.1', r));
|
||||
return {
|
||||
peers,
|
||||
syncs,
|
||||
note(n, ch = 1) {
|
||||
for (const p of peers) data.send(rtp(ssrc, seq++, [0x90 | (ch - 1), n, 100, 0x00, n, 0]), p.port, p.address);
|
||||
},
|
||||
close() { control.close(); data.close(); },
|
||||
};
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve) => {
|
||||
const s = dgram.createSocket('udp4');
|
||||
s.bind(0, () => { const p = s.address().port; s.close(() => resolve(p)); });
|
||||
});
|
||||
}
|
||||
|
||||
/** Two adjacent free UDP ports, as a session needs. */
|
||||
async function freePair() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const p = await freePort();
|
||||
if (p > 65000) continue;
|
||||
const ok = await new Promise((resolve) => {
|
||||
const s = dgram.createSocket('udp4');
|
||||
s.on('error', () => resolve(false));
|
||||
s.bind(p + 1, () => s.close(() => resolve(true)));
|
||||
});
|
||||
if (ok) return p;
|
||||
}
|
||||
throw new Error('no free port pair');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('mapping');
|
||||
|
||||
await test('reads the cue sheet forms', () => {
|
||||
const { rules, errors } = parseMapping([
|
||||
'# comment',
|
||||
'60 = Walk-in',
|
||||
'C3 - Worship 1',
|
||||
'61 release Walk-in',
|
||||
'62\tPause\tWorship 1',
|
||||
'127 release all',
|
||||
'ch2:64 = Message # only on channel 2',
|
||||
'pc5 = Baptism',
|
||||
'note 66: Go: Song #2',
|
||||
'67 = "Back Lights"',
|
||||
'F#2 = Hazer',
|
||||
].join('\n'));
|
||||
assert.deepStrictEqual(errors, []);
|
||||
const brief = rules.map((r) => [r.channel, r.kind, r.number, r.action, r.list]);
|
||||
assert.deepStrictEqual(brief, [
|
||||
[null, 'note', 60, 'go', 'Walk-in'],
|
||||
[null, 'note', 60, 'go', 'Worship 1'],
|
||||
[null, 'note', 61, 'release', 'Walk-in'],
|
||||
[null, 'note', 62, 'pause', 'Worship 1'],
|
||||
[null, 'note', 127, 'releaseAll', ''],
|
||||
[2, 'note', 64, 'go', 'Message'],
|
||||
[null, 'program', 5, 'go', 'Baptism'],
|
||||
[null, 'note', 66, 'go', 'Song #2'],
|
||||
[null, 'note', 67, 'go', 'Back Lights'],
|
||||
[null, 'note', 54, 'go', 'Hazer'],
|
||||
]);
|
||||
});
|
||||
|
||||
await test('middle C naming', () => {
|
||||
assert.strictEqual(noteFromName('C3', 'C3'), 60);
|
||||
assert.strictEqual(noteFromName('C4', 'C4'), 60);
|
||||
assert.strictEqual(noteFromName('C-2', 'C3'), 0);
|
||||
assert.strictEqual(noteFromName('Bb3', 'C3'), 70);
|
||||
});
|
||||
|
||||
await test('bad lines are reported, not fatal', () => {
|
||||
const { rules, errors } = parseMapping('hello\n200 = Big\n61 release\nch17:3 = X\n60 = OK');
|
||||
assert.strictEqual(rules.length, 1);
|
||||
assert.deepStrictEqual(errors.map((e) => e.line), [1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
await test('groups and flashes', () => {
|
||||
const { rules, groups, errors } = parseMapping('5 = Loose\n[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n23 = Blue\n30 flash Blinder Flash 1\n[]\n9 = After');
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.deepStrictEqual(groups, ['Scene', 'Color']);
|
||||
assert.deepStrictEqual(rules.map((r) => [r.number, r.group, r.action]), [
|
||||
[5, '', 'go'], [0, 'Scene', 'go'], [8, 'Scene', 'go'], [23, 'Color', 'go'], [30, 'Color', 'flash'], [9, '', 'go'],
|
||||
]);
|
||||
});
|
||||
|
||||
await test('the NA cue sheet parses cleanly', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const file = path.join(__dirname, '..', '..', '..', 'NA cue sheet.txt');
|
||||
if (!fs.existsSync(file)) return;
|
||||
const { rules, errors, groups } = parseMapping(fs.readFileSync(file, 'utf8'));
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.strictEqual(rules.length, 53); // every note marked TRUE for NA in the sheet
|
||||
assert.deepStrictEqual(groups, ['Main', 'Color', 'Blinders', 'Build', 'House', 'Lekos']);
|
||||
assert.strictEqual(new Set(rules.map((r) => r.number)).size, rules.length);
|
||||
});
|
||||
|
||||
await test('matching respects channel and kind', () => {
|
||||
const { rules } = parseMapping('ch2:64 = Two\n64 = Any\npc64 = Program');
|
||||
assert.strictEqual(match(rules, { type: 'noteon', channel: 2, note: 64 }).list, 'Two');
|
||||
assert.strictEqual(match(rules, { type: 'noteon', channel: 1, note: 64 }).list, 'Any');
|
||||
assert.strictEqual(match(rules, { type: 'program', channel: 1, program: 64 }).list, 'Program');
|
||||
assert.strictEqual(match(rules, { type: 'noteoff', channel: 1, note: 64 }), null);
|
||||
});
|
||||
|
||||
console.log('rtp-midi');
|
||||
|
||||
await test('note on + running-status note off', () => {
|
||||
const p = parseRtpMidi(rtp(7, 9, [0x90, 60, 100, 0x00, 60, 0]));
|
||||
assert.strictEqual(p.ssrc, 7);
|
||||
assert.strictEqual(p.seq, 9);
|
||||
assert.deepStrictEqual(p.messages.map(describe), [
|
||||
{ type: 'noteon', channel: 1, note: 60, velocity: 100 },
|
||||
{ type: 'noteoff', channel: 1, note: 60 },
|
||||
]);
|
||||
});
|
||||
|
||||
await test('Z flag, multi-byte deltas, program change, sysex in between', () => {
|
||||
const p = parseRtpMidi(rtp(7, 1, [0x05, 0xc2, 4, 0x81, 0x00, 0xf0, 0x7f, 0x01, 0xf7, 0x00, 0x93, 61, 9], 0x20));
|
||||
assert.deepStrictEqual(p.messages.map(describe).filter(Boolean), [
|
||||
{ type: 'program', channel: 3, program: 4 },
|
||||
{ type: 'noteon', channel: 4, note: 61, velocity: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
await test('journal bytes after the command section are ignored', () => {
|
||||
const b = Buffer.concat([rtp(7, 1, [0x90, 62, 1], 0x40), Buffer.from([0x80, 0x00, 0x05, 0x01, 0x02])]);
|
||||
assert.deepStrictEqual(parseRtpMidi(b).messages.map(describe), [{ type: 'noteon', channel: 1, note: 62, velocity: 1 }]);
|
||||
});
|
||||
|
||||
console.log('session');
|
||||
|
||||
await test('invites a sender, keeps sync, and hears its notes', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake PP');
|
||||
const port = await freePair();
|
||||
const s = new Session({ name: 'Test', port });
|
||||
assert.ok(await s.start());
|
||||
const heard = [];
|
||||
s.on('midi', ({ participant, event }) => heard.push([participant.label, event.type, event.note]));
|
||||
s.invite('127.0.0.1', senderPort, 'ProPresenter');
|
||||
await until(() => s.list().some((p) => p.connected));
|
||||
await until(() => sender.syncs.includes(2)); // we answered its count-1 with a count-2
|
||||
sender.note(60);
|
||||
await until(() => heard.length >= 2);
|
||||
assert.deepStrictEqual(heard, [['ProPresenter', 'noteon', 60], ['ProPresenter', 'noteoff', 60]]);
|
||||
s.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('a config save re-inits on the same port straight away', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake PP');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
const config = { sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort } };
|
||||
let handle = mod.init({ config, log: () => {} });
|
||||
await until(() => handle.health().status === 'ok');
|
||||
handle.stop();
|
||||
handle = mod.init({ config, log: () => {} }); // what the shell does on save: stop(), then init()
|
||||
await until(() => handle.health().status === 'ok');
|
||||
handle.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('a port taken by someone else is reported, then taken once free', async () => {
|
||||
const port = await freePair();
|
||||
const squatter = dgram.createSocket('udp4');
|
||||
await new Promise((r) => squatter.bind(port + 1, r));
|
||||
const s = new Session({ name: 'Test', port });
|
||||
let listening = false;
|
||||
s.on('listening', () => { listening = true; });
|
||||
assert.strictEqual(await s.start(), false);
|
||||
assert.match(s.error, /in use/);
|
||||
squatter.close();
|
||||
await until(() => listening, 5000);
|
||||
assert.strictEqual(s.error, '');
|
||||
s.stop();
|
||||
});
|
||||
|
||||
await test('server entry: notes become live cue lists, over HTTP', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake PP');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
const handle = mod.init({
|
||||
config: {
|
||||
sessionPort: port,
|
||||
advertise: false,
|
||||
source1Name: 'ProPresenter',
|
||||
source1: { host: '127.0.0.1', port: senderPort },
|
||||
cueLists: '60 = Walk-in\n61 = Worship 1\n70 release Walk-in\n127 release all',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
const routes = mod.routes({});
|
||||
const server = http.createServer((req, res) => {
|
||||
const r = routes[`${req.method} ${req.url}`];
|
||||
if (r) r(req, res); else { res.writeHead(404); res.end(); }
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const get = async (path, method = 'GET') => {
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { method });
|
||||
return res.json();
|
||||
};
|
||||
const state = async () => (await get('/state')).state;
|
||||
|
||||
await until(() => sender.peers.length > 0);
|
||||
await until(() => handle.health().status === 'ok');
|
||||
sender.note(60);
|
||||
await wait(100);
|
||||
sender.note(61);
|
||||
await wait(100);
|
||||
let st = await state();
|
||||
assert.strictEqual(st.current.name, 'Worship 1');
|
||||
assert.deepStrictEqual(st.active.map((l) => l.name), ['Walk-in']);
|
||||
assert.strictEqual(st.current.source, 'ProPresenter');
|
||||
assert.strictEqual(st.sources[0].connected, true);
|
||||
|
||||
sender.note(70);
|
||||
sender.note(99); // unmapped
|
||||
await wait(150);
|
||||
st = await state();
|
||||
assert.deepStrictEqual(st.active, []);
|
||||
assert.strictEqual(st.log[0].mapped, false);
|
||||
assert.match(st.log[0].trigger, /Note 99/);
|
||||
|
||||
sender.note(127);
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.strictEqual(st.current, null);
|
||||
|
||||
sender.note(60);
|
||||
await wait(100);
|
||||
await get('/clear', 'POST');
|
||||
st = await state();
|
||||
assert.strictEqual(st.current, null);
|
||||
assert.strictEqual(st.log[0].action, 'cleared');
|
||||
handle.stop();
|
||||
|
||||
// Groups: a Go replaces its group's live list; the first group is the headline; flashes stay out.
|
||||
const h2 = mod.init({
|
||||
config: {
|
||||
sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort },
|
||||
cueLists: '[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n22 = Red\n23 = Blue\n30 flash Blinder Flash 1',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
await until(() => h2.health().status === 'ok');
|
||||
for (const n of [0, 22, 23, 30]) { sender.note(n); await wait(60); }
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.strictEqual(st.current.name, 'Pre Service');
|
||||
assert.deepStrictEqual(st.active.map((l) => [l.group, l.name]), [['Color', 'Blue']]); // Blue replaced Red
|
||||
assert.strictEqual(st.log[0].action, 'flash');
|
||||
sender.note(8); // a new main scene takes over the look…
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.strictEqual(st.current.name, 'Message');
|
||||
assert.deepStrictEqual(st.active, []);
|
||||
sender.note(22); // …and FX fired after it layer on top again
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.deepStrictEqual(st.active.map((l) => l.name), ['Red']);
|
||||
h2.stop();
|
||||
|
||||
server.close();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('wired network: addresses outside it, and names, are flagged', () => {
|
||||
const mod = require('./server');
|
||||
const off = mod._offNetwork;
|
||||
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
|
||||
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
|
||||
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
|
||||
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
|
||||
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
|
||||
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
|
||||
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
|
||||
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
|
||||
});
|
||||
|
||||
await test('server entry: a sender off the wired network still joins, and says so', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake Tracks');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
|
||||
const handle = mod.init({
|
||||
config: {
|
||||
sessionPort: port,
|
||||
advertise: false,
|
||||
wiredNetwork: '10.1.0.0/16',
|
||||
source2Name: 'Tracks',
|
||||
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
|
||||
cueLists: '60 = Walk-in',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
await until(() => sender.peers.length > 0); // joined all the same
|
||||
const h = handle.health();
|
||||
assert.strictEqual(h.status, 'error');
|
||||
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
|
||||
const routes = mod.routes({});
|
||||
let body = '';
|
||||
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
|
||||
const st = JSON.parse(body).state;
|
||||
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
|
||||
assert.strictEqual(st.sources[0].offNetwork, 'by name');
|
||||
handle.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.11.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -648,3 +648,55 @@ details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* an orderable multiselect (manifest `"orderable": true`): not a ticklist but
|
||||
a list built on purpose — the picks, numbered, each with a nudge either way
|
||||
and a way out, over a dropdown holding whatever is left to add. Their
|
||||
position is the setting, so it has to be visible and movable. */
|
||||
.multiselect-row.is-picked {
|
||||
gap: 6px;
|
||||
cursor: default;
|
||||
padding: 4px 7px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.multiselect-add {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
padding: 7px 9px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.multiselect-add:focus { outline: none; border-color: var(--accent); }
|
||||
.multiselect-add:disabled { color: var(--muted); cursor: default; }
|
||||
.multiselect-rank {
|
||||
flex: none;
|
||||
min-width: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
color: var(--accent);
|
||||
}
|
||||
.multiselect-row .switch-label { flex: 1; min-width: 0; }
|
||||
.multiselect-move {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.multiselect-move:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
|
||||
.multiselect-move:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
+133
-13
@@ -333,15 +333,31 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||
span.textContent = label;
|
||||
const list = document.createElement('div');
|
||||
list.className = 'multiselect';
|
||||
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
|
||||
// Kept as an ORDER, not a set: with `orderable` the position is the
|
||||
// value (a priority), and even without it the saved order is what the
|
||||
// shell stores, so losing it here would quietly reshuffle a module's list.
|
||||
let chosen = (Array.isArray(value) ? value : []).map(String).filter(Boolean);
|
||||
const orderable = Boolean(spec.orderable);
|
||||
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||
const fill = (options) => {
|
||||
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||
const syncChosen = () => {
|
||||
for (const input of list.querySelectorAll('input')) {
|
||||
if (input.checked) chosen.add(input.value);
|
||||
else chosen.delete(input.value);
|
||||
const v = input.value;
|
||||
const at = chosen.indexOf(v);
|
||||
if (input.checked && at < 0) chosen.push(v); // newly on: lowest priority
|
||||
else if (!input.checked && at >= 0) chosen.splice(at, 1);
|
||||
}
|
||||
};
|
||||
const move = (v, by) => {
|
||||
const at = chosen.indexOf(v);
|
||||
const to = at + by;
|
||||
if (at < 0 || to < 0 || to >= chosen.length) return;
|
||||
chosen.splice(to, 0, chosen.splice(at, 1)[0]);
|
||||
};
|
||||
const fill = (options) => {
|
||||
syncChosen();
|
||||
list.innerHTML = '';
|
||||
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
|
||||
const missing = chosen.filter((v) => !options.some((opt) => optValue(opt) === v));
|
||||
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
||||
for (const opt of all) {
|
||||
const row = document.createElement('div');
|
||||
@@ -351,13 +367,13 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.value = optValue(opt);
|
||||
input.checked = chosen.has(input.value);
|
||||
input.checked = chosen.includes(input.value);
|
||||
const track = document.createElement('span');
|
||||
track.className = 'track';
|
||||
toggle.append(input, track);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'switch-label';
|
||||
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||
text.textContent = optLabel(opt);
|
||||
row.append(toggle, text);
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target === input) return; // the box toggles itself
|
||||
@@ -373,21 +389,114 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||
list.appendChild(none);
|
||||
}
|
||||
};
|
||||
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||
// A list built on purpose, in an order that means something: the picks,
|
||||
// each numbered and movable, and a dropdown holding everything not picked
|
||||
// yet. Switches cannot carry an order — and an account with forty service
|
||||
// types is forty switches to read past before finding the two that matter.
|
||||
const fillPicked = (options) => {
|
||||
list.innerHTML = '';
|
||||
const byValue = new Map(options.map((opt) => [optValue(opt), opt]));
|
||||
chosen.forEach((v, i) => {
|
||||
const opt = byValue.get(v);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'multiselect-row is-picked';
|
||||
const rank = document.createElement('span');
|
||||
rank.className = 'multiselect-rank';
|
||||
rank.textContent = String(i + 1);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'switch-label';
|
||||
text.textContent = opt ? optLabel(opt) : `${v} (saved — not listed now)`;
|
||||
const btn = (glyph, title, on, off) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'multiselect-move';
|
||||
b.textContent = glyph;
|
||||
b.title = title;
|
||||
b.disabled = Boolean(off);
|
||||
b.addEventListener('click', (e) => { e.preventDefault(); on(); });
|
||||
return b;
|
||||
};
|
||||
row.append(
|
||||
rank,
|
||||
text,
|
||||
btn('▲', 'Higher priority', () => { move(v, -1); changed(); }, i === 0),
|
||||
btn('▼', 'Lower priority', () => { move(v, 1); changed(); }, i === chosen.length - 1),
|
||||
btn('✕', 'Remove', () => { chosen.splice(chosen.indexOf(v), 1); changed(); }),
|
||||
);
|
||||
list.appendChild(row);
|
||||
});
|
||||
if (!chosen.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'multiselect-empty';
|
||||
none.textContent = 'Nothing chosen yet.';
|
||||
list.appendChild(none);
|
||||
}
|
||||
// The adder. Its own change bubbles to the field, which is what any
|
||||
// dependent list listens for, so nothing extra is dispatched here.
|
||||
const add = document.createElement('select');
|
||||
add.className = 'multiselect-add';
|
||||
const left = options.filter((opt) => !chosen.includes(optValue(opt)));
|
||||
const head = document.createElement('option');
|
||||
head.value = '';
|
||||
head.textContent = left.length
|
||||
? (spec.addLabel || 'Add one…')
|
||||
: (options.length ? 'Everything is added' : 'Nothing to choose from yet');
|
||||
add.appendChild(head);
|
||||
add.disabled = !left.length;
|
||||
const groups = new Map();
|
||||
for (const opt of left) {
|
||||
const o = document.createElement('option');
|
||||
o.value = optValue(opt);
|
||||
o.textContent = optLabel(opt);
|
||||
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
|
||||
if (!g) { add.appendChild(o); continue; }
|
||||
if (!groups.has(g)) {
|
||||
const og = document.createElement('optgroup');
|
||||
og.label = g;
|
||||
groups.set(g, og);
|
||||
add.appendChild(og);
|
||||
}
|
||||
groups.get(g).appendChild(o);
|
||||
}
|
||||
add.addEventListener('change', () => {
|
||||
if (!add.value) return;
|
||||
chosen.push(add.value); // added last: lowest priority
|
||||
redraw();
|
||||
});
|
||||
list.appendChild(add);
|
||||
};
|
||||
// The options last drawn, so a reorder or a pick can redraw without asking
|
||||
// the module for its list again.
|
||||
let lastOptions = Array.isArray(spec.options) ? spec.options : [];
|
||||
const redraw = () => (orderable ? fillPicked(lastOptions) : fill(lastOptions));
|
||||
// Moving or removing changes the value without any control firing, so say so.
|
||||
const changed = () => {
|
||||
redraw();
|
||||
list.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
redraw();
|
||||
if (spec.optionsRoute && moduleId) {
|
||||
refetch = (query) => {
|
||||
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
||||
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => {
|
||||
if (Array.isArray(body?.options)) fill(body.options);
|
||||
if (Array.isArray(body?.options)) {
|
||||
lastOptions = body.options;
|
||||
redraw();
|
||||
}
|
||||
})
|
||||
.catch(() => { /* keep the static/saved options */ });
|
||||
};
|
||||
if (!spec.optionsDependsOn) refetch();
|
||||
}
|
||||
field.append(span, list);
|
||||
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
|
||||
read = () => {
|
||||
// With `orderable` the list IS the value, in the order it is shown.
|
||||
if (orderable) return [...chosen];
|
||||
syncChosen();
|
||||
return [...list.querySelectorAll('input:checked')].map((input) => input.value);
|
||||
};
|
||||
} else if (type === 'color') {
|
||||
// A swatch picker; the value is always a #rrggbb string.
|
||||
field.classList.add('color-field');
|
||||
@@ -660,6 +769,7 @@ function buildModuleCard(mod) {
|
||||
const readers = new Map();
|
||||
const controls = new Map(); // key → boolean/switch input (for showWhen)
|
||||
const selects = new Map(); // key → select element (optionsDependsOn sources)
|
||||
const sources = new Map(); // key → { el, value() } for optionsDependsOn, whatever the type
|
||||
const dependents = []; // { field, when } to show/hide by a control's state
|
||||
const optionDeps = []; // { refetch, on } selects whose options follow another field
|
||||
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
|
||||
@@ -717,6 +827,15 @@ function buildModuleCard(mod) {
|
||||
readers.set(key, read);
|
||||
if (control) controls.set(key, control);
|
||||
if (selectEl) selects.set(key, selectEl);
|
||||
// Anything can be the source of an optionsDependsOn — a multiselect
|
||||
// hands over its values joined, which is what a module's route reads.
|
||||
sources.set(key, {
|
||||
el: selectEl || control || field,
|
||||
value: () => {
|
||||
const v = read();
|
||||
return Array.isArray(v) ? v.join(',') : (typeof v === 'boolean' ? String(v) : String(v ?? ''));
|
||||
},
|
||||
});
|
||||
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
|
||||
else if (refetch) reloaders.push(() => refetch());
|
||||
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||
@@ -741,9 +860,10 @@ function buildModuleCard(mod) {
|
||||
// value as a query parameter, now and whenever K changes (a plan list
|
||||
// that follows the chosen service type).
|
||||
for (const dep of optionDeps) {
|
||||
const src = selects.get(dep.on) || controls.get(dep.on);
|
||||
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
|
||||
if (src) src.addEventListener('change', run);
|
||||
const src = sources.get(dep.on);
|
||||
const run = () => dep.refetch({ [dep.on]: src ? src.value() : '' });
|
||||
// A multiselect's switches are inside the field, so listen where they live.
|
||||
if (src) src.el.addEventListener('change', run);
|
||||
reloaders.push(run);
|
||||
run();
|
||||
}
|
||||
|
||||
+93
-4
@@ -188,6 +188,10 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
|
||||
/* ── module registry ────────────────────────────────────────────────── */
|
||||
|
||||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||||
/* The installed modules that are not running — disabled, or needing a newer
|
||||
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
|
||||
to say why a tile one of them left in the layout is not drawn. */
|
||||
let inactiveModules = new Map();
|
||||
|
||||
/** fetch that gives up after `ms`. A request the browser has queued behind
|
||||
held connections would otherwise pend forever, and so would anything
|
||||
@@ -208,6 +212,7 @@ async function loadRegistry() {
|
||||
if (!res.ok) throw new Error('modules ' + res.status);
|
||||
const body = await res.json();
|
||||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||||
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -487,7 +492,9 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
|
||||
registry offers them again: a Service timer waits for PCO Plan, a
|
||||
transcript for its channel. Never the module's default in their place —
|
||||
a wall of "All timers" where five different timers were is worse than
|
||||
five gaps that fill themselves in. */
|
||||
five gaps that fill themselves in. For the one that is not coming back
|
||||
(a module stopped offering that kind of tile) the Layouts menu lists them
|
||||
under Not shown, each with a ✕ — the only way to let its cells go. */
|
||||
|
||||
function tileIsOffered(tile) {
|
||||
const man = registry.get(tile.module);
|
||||
@@ -521,6 +528,7 @@ function reconcileOfferedTiles() {
|
||||
if (changed) rebuildDividers();
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
|
||||
}
|
||||
|
||||
/* A module's entry list can change without anyone saving admin config: PCO
|
||||
@@ -540,10 +548,90 @@ function scheduleOfferedRecheck() {
|
||||
}, OFFERED_RECHECK_MS);
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, each with what
|
||||
to call it and, in a few words, why it is not drawn. */
|
||||
function unshownTiles() {
|
||||
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
|
||||
const man = registry.get(tile.module);
|
||||
if (man) {
|
||||
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
|
||||
return { tile, title, reason: 'not offered right now' };
|
||||
}
|
||||
const off = inactiveModules.get(tile.module);
|
||||
if (off) {
|
||||
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
|
||||
return { tile, title: tile.title || off.name || tile.module, reason };
|
||||
}
|
||||
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
|
||||
});
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, by name. */
|
||||
function unshownTileNames() {
|
||||
return tiles.filter((t) => !tileEls.has(t.id))
|
||||
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||
return unshownTiles().map((u) => u.title);
|
||||
}
|
||||
|
||||
/* Repaints the Layouts menu's Not shown section while the menu is open —
|
||||
set by unshownSection(), called whenever tiles become offered or stop
|
||||
being, so the list never lags the board behind it. */
|
||||
let repaintUnshown = () => {};
|
||||
|
||||
/** The Layouts menu's Not shown section: one row per tile the layout holds
|
||||
that the board is not drawing — its name, why in a word or two, and a ✕
|
||||
that removes it — present only while there is one. A tile that waits for
|
||||
its module (a Service timer before PCO Plan is up) is listed but can stay;
|
||||
this exists for the one that will never be back, which would otherwise
|
||||
hold its cells with no control anywhere to let them go. */
|
||||
function unshownSection() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'menu-unshown';
|
||||
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
|
||||
let painted = '';
|
||||
const paint = () => {
|
||||
wrap.innerHTML = '';
|
||||
const list = unshownTiles();
|
||||
painted = signature(list);
|
||||
wrap.hidden = !list.length;
|
||||
if (!list.length) return;
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'menu-divider';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'menu-title';
|
||||
title.textContent = 'Not shown';
|
||||
wrap.append(divider, title);
|
||||
for (const { tile, title: name, reason } of list) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'menu-item menu-unshown-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = reason;
|
||||
label.appendChild(sub);
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'menu-x';
|
||||
remove.setAttribute('data-tip', 'Remove');
|
||||
remove.setAttribute('aria-label', `Remove ${name}`);
|
||||
remove.innerHTML = CLOSE_SVG;
|
||||
remove.addEventListener('click', (e) => {
|
||||
// The row goes with the tile, so the click must not reach the
|
||||
// document as one from outside the menu — the menu stays open
|
||||
// to show the next row (or none), as the stepper and Alerts do.
|
||||
e.stopPropagation();
|
||||
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
|
||||
paint();
|
||||
});
|
||||
row.append(label, remove);
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
};
|
||||
paint();
|
||||
// Only when something changed: the registry is re-read every few seconds
|
||||
// while a tile is folded away, and rows rebuilt under the pointer for
|
||||
// nothing would lose the tooltip (and the click) the operator was on.
|
||||
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildTile(tile) {
|
||||
@@ -2253,6 +2341,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
// how one is arranged.
|
||||
menu.appendChild(gridStepper());
|
||||
menu.appendChild(alertsFold());
|
||||
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
|
||||
const stepperDivider = document.createElement('div');
|
||||
stepperDivider.className = 'menu-divider';
|
||||
menu.appendChild(stepperDivider);
|
||||
@@ -2344,7 +2433,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||
const note = unshown.length
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
|
||||
: '';
|
||||
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||
ms: unshown.length ? 10000 : 7000,
|
||||
|
||||
@@ -368,6 +368,33 @@ body[data-alert="critical"] .tile.is-tinted {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
|
||||
is not drawing, why in a word or two, and the ✕ that removes it */
|
||||
.menu-unshown[hidden] { display: none; }
|
||||
.menu-unshown-row { cursor: default; }
|
||||
.menu-unshown-row:hover { background: none; }
|
||||
.menu-unshown-row > span:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.menu-x {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-x:hover { background: var(--border); color: var(--danger); }
|
||||
.menu-x svg { width: 12px; height: 12px; display: block; }
|
||||
|
||||
/* ── grid ──────────────────────────────────────────────────────────── */
|
||||
|
||||
#gridwrap {
|
||||
|
||||
@@ -2336,8 +2336,17 @@ function handleRequest(req, res) {
|
||||
|
||||
/* — shell API — */
|
||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||
// Beside what dashboards may load: the installed modules that are not
|
||||
// running, by id and name only, so a tile one of them left in a layout
|
||||
// can say why it is not drawn ("module disabled") instead of guessing at
|
||||
// "not installed". Nothing of their config travels with it.
|
||||
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
|
||||
id,
|
||||
name: String(manifests.get(id).name || id),
|
||||
reason: isEnabled(id) ? 'incompatible' : 'disabled',
|
||||
}));
|
||||
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||
.then((list) => sendJson(res, 200, { modules: list }))
|
||||
.then((list) => sendJson(res, 200, { modules: list, inactive }))
|
||||
.catch((err) => {
|
||||
console.error('[proddash] /api/modules failed:', err);
|
||||
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
|
||||
* GET /services/v2/folders nested folders (parent rel)
|
||||
* GET /services/v2/folders/{id}
|
||||
* Run: node tools/pco-mock.js [port] [--types N] (--types pads the service
|
||||
* type list out to N, for trying a picker against an account that has dozens.)
|
||||
*
|
||||
* GET /services/v2/service_types every service type, with its
|
||||
* parent folder relationship
|
||||
* GET /services/v2/service_types/{id}
|
||||
@@ -49,6 +52,10 @@
|
||||
const http = require('http');
|
||||
|
||||
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
|
||||
// --types N pads the list out to N with planless filler, for accounts that
|
||||
// carry dozens of them: enough to see whether a picker still works at that size.
|
||||
const TYPE_COUNT = Number.parseInt((process.argv.find((a) => a.startsWith('--types')) || '').split('=')[1]
|
||||
|| (process.argv[process.argv.indexOf('--types') + 1] || ''), 10);
|
||||
const bootedAt = Date.now();
|
||||
|
||||
/* ── data ───────────────────────────────────────────────────────────── */
|
||||
@@ -68,6 +75,18 @@ const SERVICE_TYPES = [
|
||||
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
|
||||
];
|
||||
|
||||
if (Number.isFinite(TYPE_COUNT) && TYPE_COUNT > SERVICE_TYPES.length) {
|
||||
FOLDERS.push({ id: '300', name: 'Other Campuses', parentId: '' });
|
||||
for (let i = SERVICE_TYPES.length; i < TYPE_COUNT; i += 1) {
|
||||
SERVICE_TYPES.push({
|
||||
id: String(2000 + i),
|
||||
name: `Campus ${String.fromCharCode(65 + (i % 26))}${i >= 26 + 5 ? Math.floor(i / 26) : ''} Service`,
|
||||
frequency: 'Every week',
|
||||
parentId: i % 3 === 0 ? '300' : (i % 3 === 1 ? '110' : ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function iso(ms) {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A stand-in for the ProPresenter Mac's network MIDI session, for the Vista
|
||||
* Cues module: an RTP-MIDI (AppleMIDI) session that accepts invitations and
|
||||
* plays a cue sheet's notes to everyone in it, the way ProPresenter's MIDI
|
||||
* actions reach Vista.
|
||||
*
|
||||
* node tools/vista-midi-mock.js 5104 # wait to be invited on UDP 5104/5105
|
||||
* node tools/vista-midi-mock.js 5104 --invite 127.0.0.1:5040
|
||||
* # …or invite ProdDash yourself
|
||||
* --name "Tracks Laptop" session name (default "ProPresenter Mock")
|
||||
* --every 4 seconds between cues (default 4)
|
||||
* --channel 1 MIDI channel (default 1)
|
||||
* --notes 2,23,8,30 play these notes in a loop instead of the built-in sheet's
|
||||
*
|
||||
* Point Vista Cues' "First sender" at 127.0.0.1 port 5104 and paste the
|
||||
* sheet this prints into "Cue lists". Kill it to watch the tile go red;
|
||||
* start it again to watch it come back.
|
||||
*
|
||||
* Written separately from the module's own rtpmidi.js on purpose, so a bug
|
||||
* there is not mirrored here.
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (name, dflt) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 5104;
|
||||
const NAME = opt('name', 'ProPresenter Mock');
|
||||
const EVERY = Number(opt('every', 4)) * 1000;
|
||||
const CHANNEL = Math.min(16, Math.max(1, Number(opt('channel', 1)))) - 1;
|
||||
const INVITE = opt('invite', '');
|
||||
|
||||
const SHEET = [
|
||||
'60 = Walk-in',
|
||||
'61 = Worship 1',
|
||||
'62 = Worship 2',
|
||||
'63 = Message',
|
||||
'64 pause Worship 2',
|
||||
'70 release Walk-in',
|
||||
'71 release Worship 1',
|
||||
'127 release all',
|
||||
];
|
||||
const NOTES = opt('notes', '');
|
||||
const SCRIPT = NOTES ? NOTES.split(',').map(Number).filter((n) => n >= 0 && n <= 127) : [60, 61, 70, 62, 64, 64, 71, 90, 63, 127];
|
||||
|
||||
const ssrc = (Math.random() * 0xffffffff) >>> 0;
|
||||
const t0 = process.hrtime.bigint();
|
||||
const now = () => (process.hrtime.bigint() - t0) / 100000n;
|
||||
let seq = Math.floor(Math.random() * 0xffff);
|
||||
|
||||
const peers = new Map(); // ssrc → { address, controlPort, dataPort, token, name, ready }
|
||||
|
||||
function session(cmd, token, name) {
|
||||
const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`);
|
||||
const b = Buffer.alloc(16 + n.length);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write(cmd, 2, 'ascii');
|
||||
b.writeUInt32BE(2, 4);
|
||||
b.writeUInt32BE(token >>> 0, 8);
|
||||
b.writeUInt32BE(ssrc, 12);
|
||||
n.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
function ck(count, ts) {
|
||||
const b = Buffer.alloc(36);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write('CK', 2, 'ascii');
|
||||
b.writeUInt32BE(ssrc, 4);
|
||||
b[8] = count;
|
||||
ts.forEach((t, i) => b.writeBigUInt64BE(BigInt.asUintN(64, t), 12 + i * 8));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** One RTP-MIDI packet: note on, then (delta 0, running status) the note off. */
|
||||
function notePacket(note) {
|
||||
const midi = Buffer.from([0x90 | CHANNEL, note, 100, 0x00, note, 0x00]);
|
||||
const b = Buffer.alloc(12 + 1 + midi.length);
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x61;
|
||||
seq = (seq + 1) & 0xffff;
|
||||
b.writeUInt16BE(seq, 2);
|
||||
b.writeUInt32BE(Number(now() & 0xffffffffn), 4);
|
||||
b.writeUInt32BE(ssrc, 8);
|
||||
b[12] = midi.length; // B=0 J=0 Z=0 P=0, LEN
|
||||
midi.copy(b, 13);
|
||||
return b;
|
||||
}
|
||||
|
||||
const control = dgram.createSocket('udp4');
|
||||
const data = dgram.createSocket('udp4');
|
||||
|
||||
function onPacket(sock, which) {
|
||||
return (buf, r) => {
|
||||
if (buf.length < 4 || buf.readUInt16BE(0) !== 0xffff) return;
|
||||
const cmd = buf.toString('ascii', 2, 4);
|
||||
if (cmd === 'IN') {
|
||||
const token = buf.readUInt32BE(8);
|
||||
const them = buf.readUInt32BE(12);
|
||||
const end = buf.indexOf(0, 16);
|
||||
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
sock.send(session('OK', token, NAME), r.port, r.address);
|
||||
const p = peers.get(them) || { address: r.address, token, name, ready: false };
|
||||
if (which === 'control') p.controlPort = r.port;
|
||||
else { p.dataPort = r.port; p.ready = true; console.log(`[mock] "${name}" joined from ${r.address}`); }
|
||||
peers.set(them, p);
|
||||
} else if (cmd === 'OK') {
|
||||
// Our own invitation (--invite) was accepted.
|
||||
const them = buf.readUInt32BE(12);
|
||||
const end = buf.indexOf(0, 16);
|
||||
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
if (which === 'control') {
|
||||
pending.stage = 'data';
|
||||
data.send(session('IN', pending.token, NAME), pending.port + 1, pending.host);
|
||||
} else {
|
||||
pending.stage = 'done';
|
||||
peers.set(them, { address: r.address, controlPort: pending.port, dataPort: r.port, token: pending.token, name, ready: true });
|
||||
console.log(`[mock] joined "${name}" at ${r.address}:${pending.port}`);
|
||||
}
|
||||
} else if (cmd === 'BY') {
|
||||
const them = buf.readUInt32BE(12);
|
||||
if (peers.delete(them)) console.log('[mock] a participant left');
|
||||
} else if (cmd === 'CK') {
|
||||
const count = buf[8];
|
||||
const ts = [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)];
|
||||
if (count === 0) data.send(ck(1, [ts[0], now(), 0n]), r.port, r.address);
|
||||
else if (count === 1) data.send(ck(2, [ts[0], ts[1], now()]), r.port, r.address);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pending = { stage: '', host: '', port: 0, token: 0 };
|
||||
|
||||
control.on('message', onPacket(control, 'control'));
|
||||
data.on('message', onPacket(data, 'data'));
|
||||
control.bind(PORT, () => data.bind(PORT + 1, () => {
|
||||
console.log(`[mock] session "${NAME}" on UDP ${PORT}/${PORT + 1}, channel ${CHANNEL + 1}`);
|
||||
console.log('[mock] cue sheet for Vista Cues → Cue lists:\n ' + SHEET.join('\n '));
|
||||
if (INVITE) {
|
||||
const [host, port] = INVITE.split(':');
|
||||
Object.assign(pending, { stage: 'control', host, port: Number(port) || 5040, token: (Math.random() * 0xffffffff) >>> 0 });
|
||||
const tryInvite = () => {
|
||||
if (pending.stage === 'done') return;
|
||||
pending.stage = 'control';
|
||||
control.send(session('IN', pending.token, NAME), pending.port, pending.host);
|
||||
setTimeout(tryInvite, 2000);
|
||||
};
|
||||
tryInvite();
|
||||
}
|
||||
}));
|
||||
|
||||
// Clock sync, as the session that started it would do (harmless otherwise).
|
||||
setInterval(() => {
|
||||
for (const p of peers.values()) if (p.ready) data.send(ck(0, [now(), 0n, 0n]), p.dataPort, p.address);
|
||||
}, 10000);
|
||||
|
||||
let step = 0;
|
||||
setInterval(() => {
|
||||
const ready = [...peers.values()].filter((p) => p.ready);
|
||||
if (!ready.length) return;
|
||||
const note = SCRIPT[step % SCRIPT.length];
|
||||
step += 1;
|
||||
const pkt = notePacket(note);
|
||||
for (const p of ready) data.send(pkt, p.dataPort, p.address);
|
||||
console.log(`[mock] note ${note} → ${ready.length} participant${ready.length === 1 ? '' : 's'}`);
|
||||
}, EVERY);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
for (const p of peers.values()) control.send(session('BY', p.token), p.controlPort, p.address);
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
Reference in New Issue
Block a user