Compare commits

...
Author SHA1 Message Date
christianandClaude Opus 5 a8d1b8e159 Resi Cues becomes a real module, and Companion keeps the stage timer
Yesterday's version of this was a Python script under `modules/` with a
LaunchAgent to keep it alive — which the guide rules out in as many words:
everything a module does runs inside its server entry, no companion programs
or daemons an operator starts by hand. It also could not put anything on a
dashboard, which is half of what it was wanted for. Both halves are now in
the right place.

`modules/resi-cues/` is an ordinary module. Its tile is one number as large as
the tile allows — the countdown to the next Resi cue, the cue's name above it,
the playhead and the cue just passed underneath, and the whole cue list behind
a tile switch. It also declares `"provides": ["timers"]` and serves `/timers`
and `/timers/stream`, so the countdown and the playhead turn up among the
ProPresenter timers in the Timers module without either side knowing about
the other.

The countdown ticks in the browser. The server sends the playhead with the
instant it was true and whether playback is running, and the tile adds the
elapsed time since; the Timers module extrapolates from `startedAt` the same
way. So a snapshot only has to be sent when something structural changes — a
new cue, a pause, a seek — rather than at 1 Hz to every open dashboard.

`resi.js` holds the part worth reading twice. Resi republishes a decoder's
position only every few seconds, on an irregular cadence, and each reading is
already stale when it appears; a countdown built straight from it sits seconds
behind and lurches. So every reading is kept as media time minus wall time and
the largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip cannot drag backwards. A reading outside the
link's measured noise floor counts as a seek: drop the lock, snap, and re-read
the cue list at once instead of on its timer. Playback is only called paused
once the playhead has been still for longer than Resi's own cadence, because a
fixed timeout mistakes a slow update for a stop and freezes the countdown
mid-service.

Driving the ProPresenter timer on the stage screen is Companion's job now. It
is already a service on that machine, so nothing new has to be kept alive, and
this module no longer has to reach ProPresenter at all — it only has to be
right about Resi, which is the hard part.

`resi.test.js` drives the estimator on a fake clock against nine simulated
Resi profiles — cadences of one to twelve seconds, staleness to three,
irregular timing, variable round trips — covering lock-on, a fired cue, a seek
backwards, a pause, and a pause longer than the offset window. A punctual
fixture proves nothing here: against one, three separate breakages of the
estimator go unnoticed. `server.test.js` runs the real server entry against a
canned Resi account and checks what the two consumers actually read, including
the `/timers` shape field by field. Every mutation tried against either turns
them red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:08:55 -04:00
christianandClaude Opus 5 82c552955d Resi cue countdowns reach the stage screen without Companion
The ask was a Stream Deck button showing the time until the next Resi cue.
What it turned into is simpler: ProPresenter already renders countdowns, and
it already has an HTTP API, so nothing has to sit in between except something
that knows when the next cue is.

`resi_to_propresenter.py` watches a decoder's playhead and keeps a
ProPresenter countdown timer armed with the seconds until its next cue, with
the cue's name as a stage message beside it. Both halves of that come from
Resi's cloud rather than the decoder — the decoder reports its position
upstream and `central.resi.io` hands it back — so there is nothing to install
at the decoder end and nothing polling it every second.

The playhead is estimated rather than repeated. Resi republishes `position`
only every few seconds, irregularly, and each reading is already stale when
it appears; sent straight through, the countdown sat three seconds behind and
lurched. So each reading is kept as media time minus wall time and the
largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip can no longer drag backwards. A reading
outside the link's measured noise floor counts as a seek: drop the lock, snap,
and re-read the cue list immediately instead of on its timer. Playback is
only called paused once the playhead has been still for longer than Resi's own
cadence, because a fixed timeout mistakes a slow update for a stop and freezes
the countdown mid-service.

This is not a tile. There is no `module.json`, so `readManifests` skips the
folder and it never reaches the + picker; it lives under `modules/` because
that is where the production tooling lives. That is also why it carries a
README when no real module does — there is no *Included modules* entry it
could have instead — and the repo README says so where the module list ends.

`install-agent.command` sets up a LaunchAgent so it runs from login and
restarts if it stops. It deliberately runs a copy in Application Support: a
login agent cannot read `~/Documents` without Full Disk Access, and macOS
answers "Operation not permitted" rather than asking. Settings live there too,
outside the checkout, the same way module config does. The Resi password is
read from the Keychain and never written to disk; with none set the agent
says so once and waits rather than relaunching in a loop.

`test_playhead.py` drives the estimator on a fake clock against nine
simulated Resi profiles — update cadences of one to twelve seconds, staleness
to three, irregular timing, variable round trips — and six of seven deliberate
breakages of the estimator turn it red. `test_end_to_end.py` runs the real
loop against a real ProPresenter with a simulated decoder, and has: it armed
for the first cue, counted down in step, rolled over to the second and stopped
when the cues ran out. Neither covers the live Resi calls, which need real
credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 11:35:40 -04:00
christianandClaude Opus 5 098562c088 Band Lineup's docs move to where every other module's live
Two things were out of place. `modules/band-lineup/README.md` was the only
README inside any of the eight modules — the guide's folder shape is manifest,
client, server, assets, and every other module is described in this README's
*Included modules* instead. It was also describing a module that no longer
exists: photos and birthdays "come from Planning Center", guests "pinned per
slot", and a refresh interval the manifest has never had.

The entry here had drifted the same way, calling the Drive folder somewhere
to put "manual photos" and the board something Planning Center fills. It is
the other way round now, and has been since 1.10: the board is shared server
state and the source of truth, the photo library fills it, and Sync is
auto-fill that only touches slots nobody placed by hand. The entry says that,
and picks up the Choir spot and the title-bar columns while it is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 11:30:17 -04:00
christian 408dea7923 Merge pull request 'Band Lineup Display snapshot catches up: 1.3.6 → 1.3.10' (#19) from claude/band-lineup-display-1-3-10 into main 2026-09-24 11:24:56 -04:00
christianandClaude Opus 5 882a418a0f Band Lineup Display snapshot catches up: 1.3.6 → 1.3.10
The reference fork had drifted four builds behind the app actually running
backstage. What it was missing is Birthday Mode, the feature the ProdDash
module was ported with and this snapshot never had:

- main.js grows the Planning Center birthday fetch (`pcoFetchBirthday`),
  mm-dd normalising, and a `pco-test-birthdays` IPC handler behind the
  settings pane's Test Birthdays button.
- renderer.js gains the whole celebration: the Wednesday→Tuesday birthday
  week, the canvas confetti loop, the gold glow, and the controls that switch
  it on and set its intensity.
- index.html and styles.css carry the Birthday Mode block in settings.

Purely additive — 597 lines in, four out, and those four are the version and
build number. Source only, as the fork has always been: `.claude/` settings
stay as they are (the local copy's are one machine's accumulated permissions,
and it has no launch.json to give), and the stray test file beside them is not
part of the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:37:54 -04:00
15 changed files with 2003 additions and 65 deletions
+34 -9
View File
@@ -365,15 +365,40 @@ below. Nothing is installed without someone clicking.
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **Band Lineup** — the backstage stage board: everyone scheduled on the
band, vocal and production teams for the upcoming Planning Center plan,
placed into their mic slot with photo, name and mic/pack label; birthdays
in the service week get confetti. Guests and MCs not on the plan are pinned
per slot from the title bar's Edit button. Admin config: Planning Center
credentials, service type and plan, the teams to include, the stage slots
(one mic/pack label per line, in stage order), default columns, and a
Google Drive folder for manual photos. Tile settings: names, mic labels,
parallax, and the birthday confetti with its intensity.
- **Resi Cues** — how long until the next Resi cue point, for a campus
running video from another site. The tile is one number, as large as it
will go: the countdown to the next cue, its name above it, and the playhead
and the cue just passed underneath. It also offers that countdown and the
playhead to the **Timers** module, so they can sit among the ProPresenter
timers instead of in a tile of their own.
Both the cue list and the decoder's position come from Resi's cloud rather
than the decoder — the decoder reports upstream and central.resi.io hands it
back — so nothing is installed at the decoder end. Resi republishes a
position only every few seconds and each reading is already stale, so the
playhead is estimated between readings rather than repeated; a countdown
built straight from Resi's number sits seconds behind and moves in lurches.
Admin config: Resi login and password, which decoder to follow (the list is
read from the account), and a folded **Timing** group for the poll
intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list.
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
slot with photo, name and mic/pack label, on one shared board that every
screen sees the same way — whoever edits it changes it everywhere, over SSE.
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.
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
teams to include, the stage slots (one mic/pack label per line, in stage
order), default columns, and the photo folder. Tile settings: parallax,
names, mic labels, and birthday confetti with its intensity and a test
picker — columns are − / + in the title bar.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
+28
View File
@@ -57,9 +57,37 @@
<option value="">Choose service type</option>
</select>
<button type="button" class="btn btn-small" id="btn-pco-sync">Sync Upcoming</button>
<button type="button" class="btn btn-small" id="btn-pco-test-birthdays">Test Birthdays</button>
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
</div>
<div class="pco-status" id="pco-status">Planning Center is not connected.</div>
<div class="pco-status pco-birthday-result" id="pco-birthday-result"></div>
<div class="bday-block">
<div class="bday-head">
<span class="bday-title">Birthday Mode 🎂</span>
<label class="bday-switch">
<input type="checkbox" id="bday-enabled" />
<span class="bday-switch-label">On</span>
</label>
</div>
<div class="bday-controls" id="bday-controls">
<div class="bday-line">
<span class="bday-line-label">Intensity</span>
<div class="seg-inline" id="bday-intensity" role="group" aria-label="Birthday intensity">
<button type="button" data-int="subtle">Subtle</button>
<button type="button" data-int="full">Full</button>
</div>
</div>
<div class="bday-line">
<span class="bday-line-label">Force on <em>(test)</em></span>
<select id="bday-test" class="pco-select bday-test-select" aria-label="Force birthday on a person for testing">
<option value="">Off</option>
</select>
</div>
<div class="bday-hint">Birthdays celebrate for the whole service week (Wednesday→Tuesday). Force is for testing only — it clears when the app restarts.</div>
</div>
</div>
<div class="pco-position-builder">
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
<option value="">Choose position</option>
+167 -1
View File
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
return payload || { data: [] };
}
/**
* Like pcoRequestJson but never throws on an HTTP error — returns the status so callers can tell
* "no access" (401/403) apart from "no such record" (404) apart from success. Used by the People
* birthdate probe/fetch, where the whole point is to report what the token is actually allowed to do.
*/
async function pcoFetchStatus(target, credentials, searchParams) {
const url = buildPcoUrl(target, searchParams);
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: pcoAuthHeader(credentials),
'User-Agent': pcoUserAgent(),
},
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}
return { status: response.status, ok: response.ok, payload };
}
/**
* Fetch one person's birthday from the People API as "MM-DD" (year discarded). Services and People
* share the same person id, so the Services person id we already store doubles as the People id.
* Returns { status, birthday, fieldPresent } — birthday '' when the field is null/absent.
*/
async function pcoFetchBirthday(personId, credentials) {
if (!personId) return { status: 0, birthday: '', fieldPresent: false };
const { status, ok, payload } = await pcoFetchStatus(
`/people/v2/people/${encodeURIComponent(personId)}`,
credentials,
{ 'fields[Person]': 'birthdate' },
);
if (!ok) return { status, birthday: '', fieldPresent: false };
const attrs = payload?.data?.attributes || {};
return { status, birthday: normalizeBirthdayMmdd(attrs.birthdate), fieldPresent: 'birthdate' in attrs };
}
async function pcoFetchAll(target, credentials, searchParams) {
let nextUrl = buildPcoUrl(target, searchParams).toString();
const data = [];
@@ -275,6 +316,15 @@ function emptyState() {
pcoDisplayPositions: [],
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
birthdayMode: { enabled: true, intensity: 'subtle' },
};
}
/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */
function normalizeBirthdayMode(entry) {
return {
enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled),
intensity: entry?.intensity === 'full' ? 'full' : 'subtle',
};
}
@@ -308,9 +358,26 @@ function normalizeSavedLibraryPerson(person) {
source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
// "MM-DD" only — we deliberately never persist the birth year (month + day is all
// Birthday Mode needs, and it keeps a volunteer's age off this machine). '' = none on file.
birthday: normalizeBirthdayMmdd(person?.birthday),
};
}
/** Coerce a Planning Center birthdate ("YYYY-MM-DD", or an already-trimmed "MM-DD") to "MM-DD", or '' if absent/invalid. */
function normalizeBirthdayMmdd(raw) {
const s = String(raw || '').trim();
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/); // PCO People birthdate shape
if (full) return `${full[1]}-${full[2]}`;
const md = s.match(/^(\d{2})-(\d{2})$/);
if (md) {
const mm = Number(md[1]);
const dd = Number(md[2]);
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) return `${md[1]}-${md[2]}`;
}
return '';
}
function normalizeTeamName(value) {
return String(value || '').trim().toLowerCase();
}
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
});
// Pull each scheduled person's birthday from the People API (best-effort: a blocked token or a
// person with no date on file just yields '' and never fails the sync). Fetched once per unique
// person so a member in two positions isn't queried twice.
const birthdayByPerson = new Map();
for (const member of scheduledMembers) {
if (!member.personId || birthdayByPerson.has(member.personId)) continue;
try {
const { birthday } = await pcoFetchBirthday(member.personId, credentials);
birthdayByPerson.set(member.personId, birthday || '');
} catch {
birthdayByPerson.set(member.personId, '');
}
}
const oldPcoFiles = new Set(
(Array.isArray(baseState.library) ? baseState.library : [])
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
source: 'pco',
pcoPersonId: member.personId,
pcoPlanPersonId: member.planPersonId,
birthday: birthdayByPerson.get(member.personId) || '',
});
}
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
const atWriteTime = await loadStateRaw();
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
await saveStateRaw(nextState);
for (const fileName of oldPcoFiles) {
@@ -1222,6 +1305,7 @@ function migrateState(data) {
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
proPresenter: normalizeProPresenterConfig(data.proPresenter),
displayServer: normalizeDisplayServerConfig(data.displayServer),
birthdayMode: normalizeBirthdayMode(data.birthdayMode),
lineup: data.lineup.map((e) => {
if (!e || typeof e !== 'object') return { personId: '', label: '' };
return {
@@ -1432,7 +1516,10 @@ ipcMain.handle('save-state', async (_e, payload) => {
// The renderer never edits this (it is changed through the display-* IPC handlers), so
// always carry the stored value forward rather than letting a save blank it out.
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
const birthdayMode = payload?.birthdayMode
? normalizeBirthdayMode(payload.birthdayMode)
: normalizeBirthdayMode(existing.birthdayMode);
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
return true;
});
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
return syncUpcomingPcoPlan(payload);
});
/**
* Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the
* People API and read birthdates, and how many synced band members have one on file. Read-only.
*/
ipcMain.handle('pco-test-birthdays', async () => {
const credentials = await loadPcoCredentials();
if (!credentials) return { connected: false };
// 1. Can the token read the People API at all?
let probe;
try {
probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' });
} catch (err) {
return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) };
}
if (!probe.ok) {
const detail =
probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') ||
`${probe.status}`;
return { connected: true, canRead: false, status: probe.status, detail };
}
const sampleAttrs = probe.payload?.data?.[0]?.attributes || null;
const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true;
// 2. Coverage across the people we've actually synced (unique by person id).
const state = await loadStateRaw();
const seen = new Set();
const targets = [];
for (const p of Array.isArray(state.library) ? state.library : []) {
if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue;
seen.add(p.pcoPersonId);
targets.push(p);
}
let withBirthday = 0;
let notFound = 0;
let errors = 0;
const found = [];
for (const p of targets) {
let r;
try {
r = await pcoFetchBirthday(p.pcoPersonId, credentials);
} catch {
errors += 1;
continue;
}
if (r.status === 404) { notFound += 1; continue; }
if (r.status && r.status >= 400) { errors += 1; continue; }
if (r.birthday) {
withBirthday += 1;
found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday });
}
}
// Soonest upcoming birthday, for a concrete "it works" line.
const today = new Date();
const daysUntil = (mmdd) => {
const [mm, dd] = mmdd.split('-').map(Number);
let next = new Date(today.getFullYear(), mm - 1, dd);
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd);
return Math.round((next - midnight) / 86400000);
};
const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd));
const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null;
return {
connected: true,
canRead: true,
fieldExposed,
total: targets.length,
withBirthday,
notFound,
errors,
found: sortedFound.slice(0, 8),
nextUp,
};
});
ipcMain.handle('pp-status', async () => {
return {
enabled: ppConfig.enabled,
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "band-lineup-display",
"productName": "Band Lineup",
"version": "1.3.6",
"buildNumber": "92",
"version": "1.3.10",
"buildNumber": "96",
"description": "Stage lineup photos with mic/pack labels",
"main": "main.js",
"scripts": {
+1
View File
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('lineup', {
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
onMenuAddPhotos: (cb) => {
ipcRenderer.on('menu-add-photos', () => cb());
},
+273 -1
View File
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
} else {
window.__bandLineupRendererLoaded = true;
/**
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string }} LibraryPerson
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
* @typedef {{ personId: string, label: string }} StageSlot
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
*/
@@ -27,6 +27,10 @@ let pcoDisplayPositions = [];
let pcoPositionOptions = [];
let slotLabelMemory = [];
let slotNameMemory = [];
/** Birthday Mode display settings (persisted). */
let birthdayMode = { enabled: true, intensity: 'subtle' };
/** Person id forced into Birthday Mode for testing — in-memory only, never saved, clears on restart. */
let birthdayTestPersonId = '';
/** @type {StageSlot[]} */
let stageSlots = createEmptySlots();
const imageUrlCache = new Map();
@@ -65,7 +69,13 @@ const btnApplyLayout = document.getElementById('btn-apply-layout');
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
const btnPcoConnect = document.getElementById('btn-pco-connect');
const btnPcoSync = document.getElementById('btn-pco-sync');
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
const pcoBirthdayResult = document.getElementById('pco-birthday-result');
const bdayEnabled = document.getElementById('bday-enabled');
const bdayIntensity = document.getElementById('bday-intensity');
const bdayControls = document.getElementById('bday-controls');
const bdayTestSelect = document.getElementById('bday-test');
const btnPcoAddPosition = document.getElementById('btn-pco-add-position');
const inputLayoutCols = document.getElementById('layout-cols');
const inputLayoutRows = document.getElementById('layout-rows');
@@ -244,6 +254,7 @@ function setPcoStatus(message, isError = false) {
function setPcoControlsEnabled(enabled) {
btnPcoConnect.disabled = !enabled;
btnPcoSync.disabled = !enabled;
btnPcoTestBirthdays.disabled = !enabled;
btnPcoDisconnect.disabled = !enabled;
btnPcoAddPosition.disabled = !enabled;
pcoServiceTypeSelect.disabled = !enabled;
@@ -427,6 +438,9 @@ function applyLoadedState(data) {
source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
// Carried through so a routine save doesn't wipe birthdays a PCO sync stored (main.js
// re-validates the "MM-DD" shape on write); Birthday Mode reads this.
birthday: typeof person?.birthday === 'string' ? person.birthday : '',
}))
: [];
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
@@ -451,6 +465,11 @@ function applyLoadedState(data) {
pcoServiceTypeSelect.value = pcoServiceTypeId;
renderPcoDisplayPositions();
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
birthdayMode = {
enabled: data?.birthdayMode?.enabled === undefined ? true : Boolean(data.birthdayMode.enabled),
intensity: data?.birthdayMode?.intensity === 'full' ? 'full' : 'subtle',
};
syncBirthdayControls();
}
function updateSlotSizeReadout() {
@@ -528,6 +547,7 @@ function scheduleSave() {
slotLabels: slotLabelMemory,
slotNames: slotNameMemory,
lineup: stageSlots,
birthdayMode,
});
}, 250);
}
@@ -950,6 +970,132 @@ function parallaxMotionVars(seedText) {
};
}
/* ---------------- Birthday Mode ---------------- */
const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */
const birthdayCanvases = new Set();
let birthdayRaf = null;
/**
* The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday
* anywhere in this window celebrates all week (so it's already up for the weekend services).
* Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday.
*/
let birthdayWeekCache = { key: '', set: null };
function birthdayWeekSet() {
const now = new Date();
const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;
if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set;
const set = new Set();
for (let i = 0; i < 7; i += 1) {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
birthdayWeekCache = { key, set };
return set;
}
/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */
function isBirthdayActive(person) {
if (!birthdayMode.enabled || !person) return false;
if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true;
return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday);
}
/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */
function birthdayTick() {
const before = birthdayWeekCache.key;
birthdayWeekSet();
if (before && birthdayWeekCache.key !== before) void renderStage();
}
setInterval(birthdayTick, 15 * 60 * 1000);
function birthdayMakeParticle(w, h, intensity, seedY) {
const ribbon = Math.random() < 0.55;
return {
x: Math.random() * w,
y: seedY != null ? seedY : Math.random() * h,
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5,
h: ribbon ? 9 + Math.random() * 8 : 0,
ribbon,
color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0],
rot: Math.random() * Math.PI * 2,
vr: -0.14 + Math.random() * 0.28,
sway: 0.6 + Math.random() * 1.1,
phase: Math.random() * Math.PI * 2,
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
};
}
function birthdaySeedCanvas(c, w, h) {
const base = c._intensity === 'subtle' ? 26 : 64;
const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500)));
c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity));
}
// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the
// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine).
function birthdayStepCanvas(c) {
const w = c.clientWidth;
const h = c.clientHeight;
if (!w || !h) return; // not laid out yet — try again next frame
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (c._w !== w || c._h !== h) {
c.width = w * dpr;
c.height = h * dpr;
c._ctx = c.getContext('2d');
c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
c._w = w;
c._h = h;
birthdaySeedCanvas(c, w, h);
}
const ctx = c._ctx;
ctx.clearRect(0, 0, w, h);
for (const p of c._parts) {
p.phase += 0.03;
p.y += p.vy;
p.x += Math.sin(p.phase) * p.sway;
p.rot += p.vr;
if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16));
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
ctx.restore();
}
}
function birthdayLoop() {
for (const c of [...birthdayCanvases]) {
if (!c.isConnected) { birthdayCanvases.delete(c); continue; }
birthdayStepCanvas(c);
}
birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null;
}
function birthdayRegisterCanvas(canvas, intensity) {
canvas._intensity = intensity;
birthdayCanvases.add(canvas);
if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop);
}
/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */
function applyBirthdayEffects(tile, photoArea) {
const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle';
tile.classList.add('birthday', `birthday-${intensity}`);
const confetti = document.createElement('canvas');
confetti.className = 'birthday-confetti';
confetti.setAttribute('aria-hidden', 'true');
photoArea.appendChild(confetti);
birthdayRegisterCanvas(confetti, intensity);
}
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
void parallaxAssets;
const tile = document.createElement('article');
@@ -1002,6 +1148,7 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
photoArea.appendChild(nameBadge);
photoArea.appendChild(removeBtn);
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
tile.appendChild(photoArea);
return tile;
}
@@ -1156,6 +1303,7 @@ async function renderLibrary() {
async function renderAll() {
await renderStage();
await renderLibrary();
renderBirthdayTestOptions();
}
async function importNewPeople(added) {
@@ -1170,6 +1318,7 @@ async function importNewPeople(added) {
source: 'local',
pcoPersonId: '',
pcoPlanPersonId: '',
birthday: '',
});
}
await renderLibrary();
@@ -1232,6 +1381,97 @@ async function syncUpcomingPlanFromPco() {
}
}
const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function formatBirthdayMmdd(mmdd) {
const parts = String(mmdd || '').split('-');
const mm = Number(parts[0]);
const dd = Number(parts[1]);
if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return '';
return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`;
}
function setPcoBirthdayResult(message, tone) {
if (!pcoBirthdayResult) return;
pcoBirthdayResult.textContent = message || '';
pcoBirthdayResult.style.display = message ? '' : 'none';
pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc';
}
async function testPcoBirthdays() {
setPcoControlsEnabled(false);
setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info');
try {
const r = await window.lineup.pcoTestBirthdays();
if (!r || r.connected === false) {
setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error');
return;
}
if (!r.canRead) {
const why = r.reason === 'network'
? `Couldn't reach Planning Center (${r.detail || 'network error'}).`
: `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`;
setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error');
return;
}
// Access works. Report coverage across synced band members.
if (r.total === 0) {
setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good');
return;
}
const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`];
if (r.nextUp) {
const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`;
bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`);
}
if (r.notFound) bits.push(`${r.notFound} not matched in People.`);
setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info');
} catch (error) {
setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error');
} finally {
setPcoControlsEnabled(true);
}
}
/* ---------------- Birthday Mode controls ---------------- */
/** Reflect current birthdayMode settings onto the controls (called after load). */
function syncBirthdayControls() {
if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled;
if (bdayIntensity) {
for (const b of bdayIntensity.querySelectorAll('button')) {
b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false');
}
}
if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45';
}
/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */
function renderBirthdayTestOptions() {
if (!bdayTestSelect) return;
const previous = birthdayTestPersonId;
bdayTestSelect.innerHTML = '<option value="">Off</option>';
const seen = new Set();
stageSlots.forEach((slot, index) => {
if (!slot.personId || seen.has(slot.personId)) return;
const person = getPerson(slot.personId);
if (!person) return;
seen.add(slot.personId);
const option = document.createElement('option');
option.value = person.id;
const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim();
const label = String(slotLabelMemory[index] || '').trim();
option.textContent = label ? `${name} — ${label}` : name;
bdayTestSelect.appendChild(option);
});
// Keep the current override selected if that person is still on stage; otherwise drop it.
if (previous && seen.has(previous)) {
bdayTestSelect.value = previous;
} else if (previous) {
birthdayTestPersonId = '';
bdayTestSelect.value = '';
}
}
/* ---------------- Setup panel tabs ---------------- */
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
@@ -1257,6 +1497,7 @@ function activatePanelTab(name) {
}
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
if (name === 'pco') renderBirthdayTestOptions();
}
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
@@ -1733,6 +1974,36 @@ btnPcoSync.addEventListener('click', () => {
void syncUpcomingPlanFromPco();
});
btnPcoTestBirthdays.addEventListener('click', () => {
revealChrome();
void testPcoBirthdays();
});
if (bdayEnabled) {
bdayEnabled.addEventListener('change', () => {
birthdayMode.enabled = bdayEnabled.checked;
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayIntensity) {
bdayIntensity.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-int]');
if (!btn) return;
birthdayMode.intensity = btn.dataset.int === 'full' ? 'full' : 'subtle';
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayTestSelect) {
bdayTestSelect.addEventListener('change', () => {
birthdayTestPersonId = bdayTestSelect.value || '';
void renderStage();
});
}
btnPcoDisconnect.addEventListener('click', async () => {
revealChrome();
setPcoControlsEnabled(false);
@@ -1744,6 +2015,7 @@ btnPcoDisconnect.addEventListener('click', async () => {
renderPcoPositionOptions();
scheduleSave();
setPcoStatus('Planning Center disconnected.');
setPcoBirthdayResult('');
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
} finally {
+126
View File
@@ -331,6 +331,105 @@ body {
color: #9ea7bc;
}
.pco-birthday-result {
min-height: 0;
line-height: 1.45;
}
.pco-birthday-result:empty {
display: none;
}
/* ---- Birthday Mode settings ---- */
.bday-block {
margin-top: 14px;
border-top: 1px solid #23232c;
padding-top: 12px;
}
.bday-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.bday-title {
font-size: 13px;
font-weight: 700;
color: #e7e7ef;
}
.bday-switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: #9ea7bc;
}
.bday-controls {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.bday-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.bday-line-label {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8a8a99;
min-width: 74px;
}
.bday-line-label em {
font-style: normal;
text-transform: none;
letter-spacing: 0;
color: #6d6d7a;
}
.seg-inline {
display: inline-flex;
gap: 4px;
background: #0e0e14;
border: 1px solid #23232c;
border-radius: 9px;
padding: 3px;
}
.seg-inline button {
appearance: none;
border: 0;
background: transparent;
color: #9ea7bc;
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
}
.seg-inline button[aria-pressed="true"] {
background: #f5c451;
color: #241a02;
}
.bday-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #c6cada;
cursor: pointer;
}
.bday-test-select {
flex: 1;
min-width: 150px;
}
.bday-hint {
font-size: 11px;
color: #6d6d7a;
}
.pco-position-builder {
display: none;
grid-template-columns: minmax(0, 1fr) auto;
@@ -624,6 +723,33 @@ body {
font-weight: 500;
}
/* ---- Birthday Mode tile effects ---- */
.tile.birthday-subtle {
animation: bdayGlowSubtle 3.4s ease-in-out infinite;
}
.tile.birthday-full {
animation: bdayGlowFull 2.2s ease-in-out infinite;
}
@keyframes bdayGlowSubtle {
0%, 100% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.55), 0 0 14px rgba(245, 196, 81, 0.18); }
50% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.85), 0 0 20px rgba(245, 196, 81, 0.32); }
}
@keyframes bdayGlowFull {
0%, 100% { box-shadow: inset 0 0 0 3px rgba(245, 196, 81, 0.8), 0 0 20px rgba(245, 196, 81, 0.42); }
50% { box-shadow: inset 0 0 0 3px #ffd873, 0 0 34px rgba(245, 196, 81, 0.66); }
}
/* Birthday Mode intentionally keeps animating even under OS "reduced motion" — it's a celebration
the operator opts into, and freezing it leaves confetti stuck mid-fall. */
.birthday-confetti {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.tile .remove {
position: absolute;
top: 6px;
-52
View File
@@ -1,52 +0,0 @@
# Band Lineup module
The backstage stage board as a ProdDash tile: everyone scheduled on the band /
vocal / production teams for the upcoming Planning Center plan, placed into their
mic slot with photo, name and mic/pack label. People whose birthday falls in the
current service week (Wednesday→Tuesday) get raining confetti and a gold glow.
This is the display half of the standalone **Band Lineup** Electron app, ported
to ProdDash. (The ProPresenter Now/Next half of that app is the separate
`propresenter-now-next` module.)
## How it fills the board
1. **Slots** are the mic/pack labels you write in admin, one per line, in the
order they sit on stage — e.g. `[VOX 2] MIX 2`, `[DRUMS]`, `[MC] MC1`. A
blank line is an empty spot.
2. Each refresh the server reads the upcoming plan's **team members** (any team
whose name matches a line in *Teams to include*), and matches each person to
a slot by their Planning Center **position** — the same loose matching the
Electron app uses (`matching.js`; `[VOX 2] MIX 2` takes whoever is on *Vox
2*). A numbered role only matches the same number, and the `MIX n` part is
ignored, so Vox 2 and Vox 3 never swap.
3. **Photos and birthdays** come from Planning Center (birthdays via the People
API; only month + day is kept). Photos are cached on disk and served by the
module; birthdays drive the confetti.
## Manual photos (people not on the plan)
Guest speakers, MCs, stage managers — anyone not scheduled in Planning Center —
are pinned per slot from the tile's **Edit** button (title bar): set a name,
optionally upload a photo, optionally a `MM-DD` birthday. A manual pin overrides
the PCO match for that slot and is kept until you Clear it. Uploaded photos live
in the ProdDash data directory, never in the repo.
## Settings
- **Admin** (server-wide): Planning Center credentials, service type, plan, the
teams to include, the stage slots, columns, refresh interval.
- **Per tile** (gear): birthday confetti on/off + intensity, a force-birthday
test picker, parallax, and show/hide for names and mic labels.
## Testing without Planning Center
```bash
node tools/pco-band-mock.js 24710 # mock PCO
BAND_LINEUP_API_BASE=http://127.0.0.1:24710 node server.js # point the module at it
node modules/band-lineup/matching.test.js # slot-matching unit tests
```
Then in `/admin` under Band Lineup enter any Client ID + Secret, pick "Sunday
Service", and the board fills — one person (on Drums) has a birthday this week,
so their tile rains confetti.
+138
View File
@@ -0,0 +1,138 @@
/* Resi Cues — the tile.
*
* One number matters: how long until the next cue. It is drawn as large as the
* tile allows, with the cue's name above it, because on a wall the operator reads
* it from across the booth.
*
* The countdown ticks locally. The server sends the playhead with the instant it
* was true (`estMs` at `estAt`) and whether playback is running; this adds the
* elapsed time since. That is what keeps the digits moving every second while
* Resi itself only reports every few seconds, and it means a slow poll never
* shows as a stalled clock.
*/
const TICK_MS = 250; // redraw faster than a second so the digit turns on time
export default function create({ root, moduleApi }) {
let stream = null;
let ticker = null;
let state = null;
const settings = () => moduleApi.instanceSettings;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function hms(ms) {
const total = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** Where the playhead is right now, extrapolated from the last server word. */
function playheadMs() {
if (!state || state.estMs === null || state.estMs === undefined) return null;
return state.estMs + (state.playing ? Date.now() - state.estAt : 0);
}
function render() {
root.textContent = '';
const set = settings();
if (!state || state.status === 'error') {
const box = el('div', 'resi-empty');
box.append(el('div', 'resi-empty-line', (state && state.message) || 'Not connected'));
root.append(box);
return;
}
const here = playheadMs();
const wrap = el('div', 'resi-wrap');
if (state.next) {
if (set.showCueName !== false) wrap.append(el('div', 'resi-label', 'NEXT'));
if (set.showCueName !== false) wrap.append(el('div', 'resi-cue', state.next.name || 'Next cue'));
const remaining = here === null ? null : state.next.ms - here;
const count = el('div', 'resi-count', remaining === null ? '—' : hms(remaining));
if (remaining !== null && remaining <= 30000) count.classList.add('is-soon');
if (!state.playing) count.classList.add('is-held');
wrap.append(count);
} else {
wrap.append(el('div', 'resi-label', state.event ? 'NO CUES LEFT' : 'NOTHING PLAYING'));
wrap.append(el('div', 'resi-cue', state.event || state.decoder || ''));
}
const foot = el('div', 'resi-foot');
if (set.showPrevious !== false && state.prev) {
foot.append(el('span', 'resi-foot-item',
`after ${state.prev.name}${here === null ? '' : ` · ${hms(here - state.prev.ms)} ago`}`));
}
if (set.showPosition !== false && here !== null) {
foot.append(el('span', 'resi-foot-item', `playhead ${hms(here)}`));
}
if (!state.playing && state.estMs !== null) foot.append(el('span', 'resi-foot-item is-held', 'paused'));
if (foot.childElementCount) wrap.append(foot);
if (set.showCueList && state.cues && state.cues.length) {
const list = el('ul', 'resi-list');
for (const cue of state.cues) {
const row = el('li', 'resi-list-row');
if (here !== null && cue.ms <= here) row.classList.add('is-past');
if (state.next && cue.ms === state.next.ms) row.classList.add('is-next');
row.append(el('span', 'resi-list-name', cue.name));
row.append(el('span', 'resi-list-at', hms(cue.ms)));
list.append(row);
}
wrap.append(list);
}
root.append(wrap);
}
function applyStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Waiting for the server');
if (state.status !== 'ok') return moduleApi.setStatus('error', state.message || 'Not connected');
if (!state.playing) return moduleApi.setStatus('connecting', `Playback stopped — ${state.decoder}`);
const cadence = state.cadenceMs ? ` · Resi updates every ${Math.round(state.cadenceMs / 1000)}s` : '';
return moduleApi.setStatus('ok', `${state.decoder}${cadence}`);
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting');
stream = moduleApi.sse('/stream', {
state: (data) => {
try {
state = JSON.parse(data);
} catch {
return;
}
applyStatus();
render();
},
error: () => moduleApi.setStatus('error', 'Lost the connection to ProdDash'),
});
ticker = setInterval(render, TICK_MS);
render();
},
stop() {
if (stream) stream.close();
if (ticker) clearInterval(ticker);
stream = null;
ticker = null;
root.textContent = '';
},
onConfigChange() {
render();
},
};
}
+91
View File
@@ -0,0 +1,91 @@
{
"id": "resi-cues",
"name": "Resi Cues",
"version": "1.0.0",
"proddash": ">=1.4.0",
"description": "Time until the next Resi cue point, on the board and as a timer",
"provides": ["timers"],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 3 },
"configGroups": {
"Resi account": {
"help": "The Resi Studio login the board reads with. Cue points and the decoder's playhead both come from Resi's cloud, so nothing is installed at the decoder. Worth using a dedicated low-privilege Resi user rather than a personal one."
},
"Decoder": {
"help": "Which decoder to follow. The list is read from the account once the login above is saved."
},
"Timing": {
"collapsed": true,
"help": "Resi refreshes a decoder's position only every few seconds, on an irregular cadence, and each reading is already slightly stale. The board estimates between readings rather than repeating them, so these only change how often it asks — not how accurate it is."
}
},
"configSchema": {
"username": {
"type": "string",
"label": "Resi login",
"group": "Resi account",
"default": "",
"help": "The email address used to sign in to studio.resi.io."
},
"password": {
"type": "password",
"label": "Password",
"group": "Resi account",
"default": ""
},
"player": {
"type": "select",
"label": "Decoder",
"group": "Decoder",
"default": "",
"optionsRoute": "/decoders",
"help": "Leave unset to follow the only decoder on the account."
},
"pollSeconds": {
"type": "number",
"label": "Position poll (s)",
"group": "Timing",
"default": 1,
"help": "How often Resi is asked where the playhead is. A faster poll notices a fired cue sooner; it does not make the countdown smoother, which is handled by estimating between readings."
},
"cueRefreshSeconds": {
"type": "number",
"label": "Cue list refresh (s)",
"group": "Timing",
"default": 5,
"help": "How often the cue list is re-read. It is also re-read immediately whenever the playhead jumps, so a cue fired at the host shows up without waiting for this."
},
"positionTrimMs": {
"type": "number",
"label": "Position trim (ms)",
"group": "Timing",
"default": 0,
"help": "Leave at zero. Only needed if the whole chain reads consistently behind; a positive value pushes the estimate forward."
}
},
"instanceSchema": {
"showCueName": {
"type": "switch",
"label": "Cue name",
"default": true
},
"showPrevious": {
"type": "switch",
"label": "Previous cue",
"default": true
},
"showPosition": {
"type": "switch",
"label": "Playhead",
"default": true
},
"showCueList": {
"type": "switch",
"label": "Cue list",
"default": false
}
}
}
+263
View File
@@ -0,0 +1,263 @@
/* Resi Cues — talking to Resi, and working out where the decoder actually is.
*
* Two things live here, both free of the shell so they can be tested on their own
* (`resi.test.js`): a thin client for the Resi Central API, and the playhead
* estimator that turns its coarse, late readings into a usable position.
*
* The API is the undocumented one behind studio.resi.io. It is not supported by
* Resi and can change without notice; that is the price of there being no public
* way to read cue points at all.
*/
'use strict';
const RESI = 'https://central.resi.io';
/* ── time ──────────────────────────────────────────────────────────────── */
/** '01:02:03.500' → milliseconds, or null when it is not a position at all. */
function positionToMs(position) {
if (typeof position !== 'string') return null;
const parts = position.split(':');
if (parts.length !== 3) return null;
const hh = Number(parts[0]);
const mm = Number(parts[1]);
const ss = Number(parts[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return null;
return Math.round((hh * 3600 + mm * 60 + ss) * 1000);
}
/** milliseconds → 'HH:MM:SS.mmm', the shape Resi's own cue positions use. */
function msToPosition(total) {
const t = Math.max(0, Math.floor(total));
const pad = (n, w) => String(n).padStart(w, '0');
return `${pad(Math.floor(t / 3600000), 2)}:${pad(Math.floor((t % 3600000) / 60000), 2)}`
+ `:${pad(Math.floor((t % 60000) / 1000), 2)}.${pad(t % 1000, 3)}`;
}
/* ── the playhead estimator ────────────────────────────────────────────── */
const WINDOW_MS = 120000; // how long a reading stays useful
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
/**
* Resi republishes a decoder's `position` only every few seconds, on an irregular
* cadence, and each reading is already a little stale when it appears. Shown raw,
* a countdown built on it sits seconds behind and moves in lurches.
*
* So every reading is kept as *media time minus wall time*. While playback runs at
* 1x that difference is constant, and the LARGEST recent value is the reading that
* reached us with the least delay — the one to trust. The estimate is then simply
* "now plus that offset", which advances smoothly by itself and cannot be dragged
* backwards by a slow round trip.
*
* `now` is injected so the tests can drive a fake clock.
*/
function createPlayhead(options) {
const opts = options || {};
const trimMs = Number(opts.trimMs) || 0;
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
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 base; // the trusted offset
let lastMedia;
let lastNewAt;
let lastEst;
let seeked = false;
function note(mediaMs) {
if (!Number.isFinite(mediaMs)) return;
const t = now();
if (mediaMs !== lastMedia) {
const off = mediaMs - t;
// Anything inside the measured noise floor is noise; only a move clearly
// beyond it is a real seek. Measured rather than assumed, and remembered
// across a reset so the first reading after one is not judged blind.
const noise = spreads.length ? Math.max.apply(null, spreads) : 0;
const threshold = Math.max(JUMP_FLOOR_MS, noise * 1.5 + 4000);
if (base !== undefined) {
const delta = off - base;
if (Math.abs(delta) > threshold) {
offsets = []; // the window describes where we used to be
seeked = true;
// Only abandon the no-going-backwards guard for a move big enough that
// snapping is obviously right; a marginal call must not make the
// displayed playhead lurch backwards.
if (Math.abs(delta) > 10000) lastEst = undefined;
} else if (delta < 0) {
spreads = spreads.concat(-delta).slice(-20);
}
}
if (lastNewAt !== undefined) {
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.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
gaps = gaps.concat(gap).slice(-8);
}
}
offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs;
lastNewAt = t;
}
const kept = offsets.filter((o) => t - o.at <= WINDOW_MS);
// Always keep the newest reading even once it ages out. If the window empties
// during a long pause there is nothing left to compare against, and the resume
// reads as an ordinary reading instead of the discontinuity it is.
offsets = kept.length ? kept : offsets.slice(-1);
base = offsets.length ? offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) : undefined;
}
function read() {
if (lastNewAt === undefined) return { estMs: null, playing: false, cadenceMs: 0 };
const t = now();
const cadenceMs = gaps.length ? Math.max.apply(null, gaps) : 0;
// 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));
const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
}
let est = t + base + trimMs;
if (lastEst !== undefined && est < lastEst) est = lastEst; // the base is a max
lastEst = est;
return { estMs: est, playing: true, cadenceMs };
}
/** True once per detected discontinuity — the caller re-reads the cue list. */
function takeSeek() {
const was = seeked;
seeked = false;
return was;
}
return { note, read, takeSeek };
}
/* ── the Resi Central API ──────────────────────────────────────────────── */
/**
* Login sets a `livingas1` cookie which doubles as the bearer token, sent as
* `Authorization: X-Bearer <cookie>`. Everything else is a plain GET.
*/
function createResiClient(options) {
const opts = options || {};
const doFetch = opts.fetch || globalThis.fetch;
let cookieHeader = '';
let token = '';
let loggedInAt = 0;
let customerId = '';
let cacheBust = true;
function headers() {
const h = { Accept: 'application/json' };
if (cookieHeader) h.Cookie = cookieHeader;
if (token) h.Authorization = `X-Bearer ${token}`;
return h;
}
async function login() {
const 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})`);
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {};
for (const raw of setCookies) {
const first = String(raw).split(';')[0];
const eq = first.indexOf('=');
if (eq > 0) jar[first.slice(0, eq)] = first.slice(eq + 1);
}
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();
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();
}
async function get(path) {
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (res.status === 401 || res.status === 403) {
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();
}
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
return res.json();
}
async function venues() {
await ensureSession();
return (await get(`/api/v3/customers/${customerId}/venues`)) || [];
}
/** A stale cached reading looks exactly like a laggy playhead, so ask for a fresh
* one — and if the service objects to the parameter, stop sending it. */
async function players(venueUuid) {
await ensureSession();
const path = `/api_v2.svc/users/${venueUuid}/players`;
if (cacheBust) {
try {
return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) {
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
cacheBust = false;
}
}
return (await get(path)) || [];
}
async function event(eventId) {
await ensureSession();
return get(`/api/v3/customers/${customerId}/events/${eventId}`);
}
async function cues(profileId, eventId) {
await ensureSession();
const raw = (await get(
`/api_v2.svc/streamprofiles/${profileId}/events/${eventId}/cues?canISetCues=1`)) || [];
return raw
.map((c) => ({ name: String(c.name || ''), ms: positionToMs(c.position) }))
.filter((c) => c.ms !== null)
.sort((a, b) => a.ms - b.ms);
}
return {
login,
ensureSession,
venues,
players,
event,
cues,
get customerId() { return customerId; },
};
}
/** The cue after `estMs`, and the one before it. */
function surroundingCues(cues, estMs) {
let prev = null;
let next = null;
for (const c of cues || []) {
if (c.ms > estMs) { next = c; break; }
prev = c;
}
return { prev, next };
}
module.exports = { positionToMs, msToPosition, createPlayhead, createResiClient, surroundingCues, RESI };
+210
View File
@@ -0,0 +1,210 @@
'use strict';
/**
* Unit tests for the Resi playhead estimator.
*
* node modules/resi-cues/resi.test.js
* node modules/resi-cues/resi.test.js 7000 1000 4000 2000 # one profile
*
* Plain Node, no dependencies; exits non-zero on the first failure.
*
* The estimator's whole job is to be right about a feed that is coarse, late and
* irregular, so the fixture is a simulated Resi rather than a list of values: a
* decoder playing at 1x, and an API that republishes its position every so often,
* already stale, over a round trip that varies. Nine profiles span what the real
* one has been seen to do. Feeding it a kind, punctual API proves nothing —
* against one, three separate breakages of this code go unnoticed.
*/
const assert = require('assert');
const { createPlayhead, surroundingCues, positionToMs, msToPosition } = require('./resi');
let passed = 0;
function test(name, fn) {
fn();
passed++;
console.log(` ok ${name}`);
}
/* ── the simulated world ───────────────────────────────────────────────── */
function makeWorld({ cadence, lag, jitter, net }) {
let seed = 12345;
const rnd = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const w = {
t: 1700000000000,
media: 8 * 60000 + 48000,
playing: true,
published: 0,
lastPublish: 0,
nextCadence: cadence,
};
w.published = w.media - lag;
w.lastPublish = w.t;
w.advance = (ms) => {
for (let i = 0; i < ms; i += 100) {
w.t += 100;
if (w.playing) w.media += 100;
if (w.t - w.lastPublish >= w.nextCadence) {
w.lastPublish = w.t;
w.published = w.media - lag;
w.nextCadence = Math.max(500, cadence + Math.round((rnd() - 0.5) * 2 * jitter));
}
}
};
// A poll reads what Resi had when the request left, and the round trip costs time.
w.poll = () => {
const body = w.published;
if (net) w.advance(100 + Math.round(rnd() * net));
return body;
};
return w;
}
const CUES = [
{ name: 'Welcome', ms: 5 * 60000 },
{ name: 'Campus Host', ms: 12 * 60000 + 30000 },
{ name: 'Message', ms: 41 * 60000 },
{ name: 'Closing', ms: 45 * 60000 },
];
function runProfile(cadence, lag, jitter, net) {
const w = makeWorld({ cadence, lag, jitter, net });
const head = createPlayhead({ now: () => w.t });
const tol = lag + 1200 + jitter + net * 0.35;
const label = `cadence ${cadence} lag ${lag} jitter ${jitter} net ${net}`;
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
const seconds = (n) => { let r; for (let i = 0; i < n; i++) r = second(); return r; };
test(`${label}: locks on, and beats the raw reading it is built from`, () => {
seconds(40);
let worstEst = 0;
let worstRaw = 0;
for (let i = 0; i < 30; i++) {
const { estMs } = second();
worstEst = Math.max(worstEst, w.media - estMs);
worstRaw = Math.max(worstRaw, w.media - w.published);
}
assert.ok(worstEst <= tol, `estimate drifted ${Math.round(worstEst)}ms (allowed ${Math.round(tol)})`);
if (cadence >= 2000) {
assert.ok(worstRaw >= cadence * 0.4,
`fixture is too kind: the raw value only went ${Math.round(worstRaw)}ms stale`);
}
assert.ok(head.read().playing, 'should read as playing');
});
test(`${label}: ticks every second and never runs backwards`, () => {
let prev = head.read().estMs;
let stalls = 0;
let backwards = 0;
for (let i = 0; i < 200; i++) {
const { estMs } = second();
if (estMs === prev) stalls++;
if (estMs < prev) backwards++;
prev = estMs;
}
assert.strictEqual(stalls, 0, 'the playhead stalled');
assert.strictEqual(backwards, 0, 'the playhead went backwards');
});
test(`${label}: follows a fired cue, and rolls over the one it passes`, () => {
w.media = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
let reported = false;
for (let i = 0; i < 60 && !reported; i++) {
second();
reported = Math.abs((head.read().estMs || 0) - w.media) < 8000;
}
assert.ok(reported, 'the jump was never picked up');
assert.ok(head.takeSeek(), 'the jump was not flagged as a seek');
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2,
`resynced ${Math.round(w.media - estMs)}ms off`);
assert.ok(w.media - estMs >= -400, 'overshot the new playhead');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Message');
const after = seconds(15);
assert.strictEqual(surroundingCues(CUES, after.estMs).next.name, 'Closing');
assert.strictEqual(surroundingCues(CUES, after.estMs).prev.name, 'Message');
});
test(`${label}: follows a seek backwards`, () => {
w.media = 6 * 60000;
for (let i = 0; i < 60 && Math.abs((head.read().estMs || 0) - w.media) > 8000; i++) second();
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, 'did not follow the seek back');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Campus Host');
});
test(`${label}: freezes on a real pause and re-locks on resume`, () => {
w.playing = false;
const stoppedAt = w.media;
const wait = Math.ceil((Math.max(15000, Math.min(45000, (cadence + jitter) * 2.5 + 6000))
+ cadence + jitter + 6000) / 1000);
let r = seconds(wait);
assert.strictEqual(r.playing, false, 'did not notice the pause');
const held = r.estMs;
r = seconds(20);
assert.strictEqual(r.estMs, held, 'kept counting while paused');
w.playing = true;
r = seconds(25);
assert.strictEqual(r.playing, true, 'did not notice the resume');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(held - stoppedAt <= 45000 + cadence + jitter + 2 * net + 3000,
'the frozen position crept instead of stopping');
});
test(`${label}: survives a pause longer than the offset window`, () => {
w.playing = false;
let r = seconds(150);
assert.strictEqual(r.playing, false, 'should still read as paused');
assert.ok(r.estMs !== null && r.estMs !== undefined, 'lost the position entirely');
w.playing = true;
head.takeSeek();
r = seconds(30);
assert.strictEqual(r.playing, true, 'did not recover');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(head.takeSeek(), 'a resume after a long pause must flag as a seek');
});
}
/* ── the plain bits ────────────────────────────────────────────────────── */
test('positionToMs reads Resi positions, and rejects anything else', () => {
assert.strictEqual(positionToMs('00:00:00.000'), 0);
assert.strictEqual(positionToMs('01:02:03.500'), 3723500);
assert.strictEqual(positionToMs('00:00:01'), 1000);
assert.strictEqual(positionToMs(''), null);
assert.strictEqual(positionToMs('garbage'), null);
assert.strictEqual(positionToMs(null), null);
assert.strictEqual(positionToMs(undefined), null);
});
test('msToPosition round-trips', () => {
for (const ms of [0, 1500, 3723500, 36000000]) {
assert.strictEqual(positionToMs(msToPosition(ms)), ms);
}
});
test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).next.name, 'Message');
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).prev.name, 'Campus Host');
assert.strictEqual(surroundingCues(CUES, 0).prev, null);
assert.strictEqual(surroundingCues(CUES, 60 * 60000).next, null);
assert.strictEqual(surroundingCues([], 1000).next, null);
});
/* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number);
const PROFILES = argv.length >= 4 ? [argv.slice(0, 4)] : [
[5000, 500, 0, 0], [5000, 500, 0, 2500], [2000, 0, 0, 800],
[10000, 3000, 0, 2500], [7000, 1000, 4000, 2000], [3000, 500, 2000, 1000],
[12000, 2000, 6000, 3000], [1000, 0, 0, 0], [8000, 1500, 3000, 4000],
];
for (const [cadence, lag, jitter, net] of PROFILES) runProfile(cadence, lag, jitter, net);
console.log(`\n${passed} tests passed`);
+356
View File
@@ -0,0 +1,356 @@
/* Resi Cues — server entry.
*
* Follows one Resi decoder and works out how long until its next cue point, then
* offers that two ways:
*
* GET /state → snapshot for this module's tile
* GET /stream → SSE, `state` events on every change
* GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
* GET /decoders → optionsRoute for the admin page's decoder picker
*
* Both the cue list and the playhead come from Resi's cloud rather than the
* decoder: the decoder reports its position upstream and central.resi.io hands it
* back. Nothing is installed at the decoder end, and nothing here talks to the
* decoder directly.
*
* Why the playhead is estimated rather than repeated is explained in resi.js.
*/
'use strict';
const { createResiClient, createPlayhead, surroundingCues, positionToMs } = require('./resi');
const HEARTBEAT_MS = 15000;
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(text);
}
function sseHead(res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
}
function writeAll(set, chunk) {
for (const res of set) {
try {
res.write(chunk);
} catch {
set.delete(res);
}
}
}
/** What the tile and the Timers module see before anything has been configured. */
function emptyState(message) {
return {
status: 'error',
message: message || 'Not configured',
decoder: '',
event: '',
playing: false,
estMs: null,
estAt: Date.now(),
cadenceMs: 0,
next: null,
prev: null,
cues: [],
};
}
function emptyTimers(now) {
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers: [] };
}
let current = null; // the live handle, shared with routes()
module.exports = {
init({ config, log }) {
const username = String(config.username || '').trim();
const password = String(config.password || '');
const wanted = String(config.player || '').trim();
const pollMs = Math.max(250, Number(config.pollSeconds || 1) * 1000);
const cueRefreshMs = Math.max(1000, Number(config.cueRefreshSeconds || 5) * 1000);
const stateStreams = new Set();
const timerStreams = new Set();
let state = emptyState(username && password ? 'Connecting' : 'Enter the Resi login in admin');
let timer = null;
let heartbeat = null;
let stopped = false;
const resi = createResiClient({ username, password });
const head = createPlayhead({ trimMs: Number(config.positionTrimMs || 0) });
let venueUuid = '';
let hardwareId = '';
let decoderName = '';
let eventId = '';
let profileId = '';
let eventName = '';
let cues = [];
let lastCueLoad = 0;
let inFlight = false;
/* — what the two consumers see — */
function publishState(next) {
const before = JSON.stringify(state);
state = next;
if (JSON.stringify(state) === before) return;
writeAll(stateStreams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
/**
* The Timers module extrapolates from `startedAt` and its own clock, so this
* only has to change when a timer changes *structurally* — a new cue, a pause,
* a seek. Broadcasting every poll would make every dashboard repaint at 1 Hz
* for nothing.
*/
function timersSignature(snap) {
return JSON.stringify(snap.timers.map(
(t) => [t.id, t.label, t.state, t.startedAt, t.targetMs, t.status && t.status.text]));
}
let lastTimerSig = '';
function publishTimers() {
const snap = timersSnapshot(Date.now());
const sig = timersSignature(snap);
if (sig === lastTimerSig) return;
lastTimerSig = sig;
writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot(now) {
const timers = [];
if (state.estMs !== null && state.estMs !== undefined) {
const est = state.estMs + (state.playing ? now - state.estAt : 0);
if (state.next) {
const remainingMs = Math.max(0, state.next.ms - est);
// The countdown "runs" from the cue before it, which is what gives the
// consumer something to colour progress against.
const from = state.prev ? state.prev.ms : 0;
timers.push({
id: 'next-cue',
label: state.next.name || 'Next cue',
kind: 'countdown',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est - from),
elapsedMs: null,
remainingMs,
targetMs: Math.max(1000, state.next.ms - from),
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '',
});
}
timers.push({
id: 'playhead',
label: 'Playhead',
kind: 'elapsed',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est),
elapsedMs: Math.max(0, est),
remainingMs: null,
targetMs: null,
status: { text: state.decoder || '', tone: state.playing ? 'ok' : 'muted' },
detail: state.prev ? `After ${state.prev.name}` : '',
});
}
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers };
}
/* — finding the decoder — */
async function locate() {
const venues = await resi.venues();
for (const v of venues) {
const players = await resi.players(v.uuid);
for (const p of players) {
if (wanted ? p.playerHardwareId === wanted : true) {
venueUuid = v.uuid;
hardwareId = p.playerHardwareId;
decoderName = `${v.userName || v.name || 'Venue'} — ${p.userName || hardwareId}`;
return true;
}
}
}
return false;
}
/* — the loop — */
async function tick() {
if (inFlight || stopped) return;
inFlight = true;
try {
if (!hardwareId && !(await locate())) {
publishState(emptyState(wanted ? 'That decoder is not on this account' : 'No decoders found'));
return;
}
const players = await resi.players(venueUuid);
const me = players.find((p) => p.playerHardwareId === hardwareId);
if (!me) {
publishState({ ...emptyState('Decoder not listed'), decoder: decoderName });
return;
}
head.note(positionToMs(me.position));
if (me.eventId && me.eventId !== eventId) {
eventId = me.eventId;
const ev = (await resi.event(eventId)) || {};
profileId = ev.eventProfileId || '';
eventName = ev.name || '';
cues = profileId ? await resi.cues(profileId, eventId) : [];
lastCueLoad = Date.now();
log(`event ${eventName || eventId} — ${cues.length} cue(s)`);
} else if (!me.eventId) {
eventId = '';
profileId = '';
eventName = '';
cues = [];
}
const seeked = head.takeSeek();
if (profileId && eventId && (seeked || Date.now() - lastCueLoad >= cueRefreshMs)) {
lastCueLoad = Date.now();
const fresh = await resi.cues(profileId, eventId);
if (JSON.stringify(fresh) !== JSON.stringify(cues)) {
log(`cue list changed — ${fresh.length} cue(s)`);
cues = fresh;
}
}
const { estMs, playing, cadenceMs } = head.read();
const { prev, next } = surroundingCues(cues, estMs === null ? -1 : estMs);
publishState({
status: 'ok',
message: '',
decoder: decoderName,
event: eventName,
playing,
estMs,
estAt: Date.now(),
cadenceMs,
next,
prev,
cues,
});
} catch (err) {
const message = (err && err.message) || String(err);
log(`poll failed: ${message}`);
publishState({ ...state, status: 'error', message });
} finally {
inFlight = false;
publishTimers();
}
}
if (username && password) {
timer = setInterval(() => { void tick(); }, pollMs);
void tick();
} else {
log('no Resi login configured — idle');
}
heartbeat = setInterval(() => {
writeAll(stateStreams, ': ping\n\n');
writeAll(timerStreams, ': ping\n\n');
}, HEARTBEAT_MS);
current = {
get state() { return state; },
timersSnapshot,
stateStreams,
timerStreams,
resi,
configured: Boolean(username && password),
stop() {
stopped = true;
if (timer) clearInterval(timer);
if (heartbeat) clearInterval(heartbeat);
for (const res of [...stateStreams, ...timerStreams]) {
try { res.end(); } catch { /* already gone */ }
}
stateStreams.clear();
timerStreams.clear();
if (current && current.stop === this.stop) current = null;
},
health() {
if (!username || !password) return { status: 'error', message: 'Enter the Resi login in admin' };
if (state.status === 'ok') {
return { status: 'ok', message: state.playing ? `Playing — ${state.decoder}` : `Paused — ${state.decoder}` };
}
if (state.status === 'error') return { status: 'error', message: state.message };
return { status: 'connecting', message: state.message };
},
};
return current;
},
routes({ config, log }) {
return {
'GET /state': (req, res) => {
json(res, 200, current ? current.state : emptyState('Module restarting'));
},
'GET /stream': (req, res) => {
sseHead(res);
const snap = current ? current.state : emptyState('Module restarting');
res.write(`event: state\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.stateStreams.add(res);
req.on('close', () => current && current.stateStreams.delete(res));
},
'GET /timers': (req, res) => {
const now = Date.now();
json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now));
},
'GET /timers/stream': (req, res) => {
sseHead(res);
const now = Date.now();
const snap = current ? current.timersSnapshot(now) : emptyTimers(now);
res.write(`event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.timerStreams.add(res);
req.on('close', () => current && current.timerStreams.delete(res));
},
// The admin page's decoder picker. A live call, because it is how the decoder
// gets chosen in the first place — there is nothing cached to answer from.
'GET /decoders': async (req, res) => {
if (!config.username || !config.password) {
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
}
try {
const client = createResiClient({
username: String(config.username), password: String(config.password),
});
const options = [];
for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) {
options.push({
value: p.playerHardwareId,
label: `${p.userName || p.playerHardwareId}`,
group: v.userName || v.name || 'Venue',
});
}
}
json(res, 200, { options });
} catch (err) {
log(`decoder list failed: ${(err && err.message) || err}`);
json(res, 502, { options: [], error: (err && err.message) || String(err) });
}
},
};
},
};
+201
View File
@@ -0,0 +1,201 @@
'use strict';
/**
* Server-entry tests for Resi Cues.
*
* node modules/resi-cues/server.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Resi is
* replaced with a canned account — a venue, a decoder on an event, four cue
* points — so this covers the parts `resi.test.js` cannot: signing in, finding
* the decoder, following the event, and the two snapshots other things read
* (`/state` for the tile, `/timers` for the Timers module).
*
* Nothing here reaches the network, and the poll interval is captured rather
* than run, so the test drives the loop a tick at a time.
*/
const assert = require('assert');
let passed = 0;
function test(name, fn) {
fn();
passed++;
console.log(` ok ${name}`);
}
/* ── a canned Resi account ─────────────────────────────────────────────── */
const CUES = [
{ name: 'Welcome', position: '00:05:00.000' },
{ name: 'Campus Host', position: '00:12:30.000' },
{ name: 'Message', position: '00:41:00.000' },
];
const world = { position: '00:08:48.000', eventId: 'EV1', logins: 0, playerCalls: 0 };
function reply(body, extra) {
return Object.assign({
ok: true,
status: 200,
headers: { get: () => null, getSetCookie: () => [] },
json: async () => body,
}, extra || {});
}
globalThis.fetch = async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
world.logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=TOKEN; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST', userName: 'tester' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1', userName: 'Main' }]);
if (path === '/api_v2.svc/users/V1/players') {
world.playerCalls++;
return reply([{ playerHardwareId: 'DEC1', userName: 'Lobby', position: world.position, eventId: world.eventId }]);
}
if (path === '/api/v3/customers/CUST/events/EV1') {
return reply({ name: 'Sunday 9am', eventProfileId: 'P1' });
}
if (path === '/api_v2.svc/streamprofiles/P1/events/EV1/cues') return reply(CUES);
return reply({}, { ok: false, status: 404 });
};
/* ── the shell, as much of it as the module touches ────────────────────── */
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
let tickFn = null;
globalThis.setInterval = (fn, ms) => {
if (!tickFn) tickFn = fn; // the poll; the heartbeat is the second one
return { fn, ms };
};
globalThis.clearInterval = () => {};
const server = require('./server');
const logged = [];
const handle = server.init({
config: { username: 'a@b.c', password: 'secret', player: 'DEC1', pollSeconds: 1, cueRefreshSeconds: 5 },
log: (m) => logged.push(m),
});
/** Let the poll's promise chain finish; nothing here waits on real time. */
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
function collect(route, req) {
const res = {
code: 0, headers: null, body: '',
writeHead(code, headers) { this.code = code; this.headers = headers; },
write(chunk) { this.body += chunk; },
end(chunk) { if (chunk) this.body += chunk; },
};
route(req || { on() {} }, res);
return res;
}
(async () => {
await settle();
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
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);
assert.strictEqual(state.decoder, 'Main — Lobby');
assert.strictEqual(state.event, 'Sunday 9am');
assert.strictEqual(state.cues.length, 3);
});
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', () => {
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', () => {
const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
assert.ok(res.body.includes('retry: 3000'), 'should set a retry');
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', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi');
assert.ok(snap.updatedAt > 0);
const [next, playhead] = snap.timers;
assert.strictEqual(next.id, 'next-cue');
assert.strictEqual(next.kind, 'countdown');
assert.strictEqual(next.state, 'running');
assert.strictEqual(next.label, 'Campus Host');
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.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead');
assert.strictEqual(playhead.kind, 'elapsed');
assert.strictEqual(playhead.remainingMs, null);
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
});
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();
const state = handle.state;
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');
});
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = '';
tickFn();
await settle();
assert.strictEqual(handle.state.next, null);
assert.deepStrictEqual(handle.state.cues, []);
assert.strictEqual(handle.state.event, '');
});
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', () => {
const before = world.logins;
const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
assert.strictEqual(idle.health().status, 'error');
assert.ok(/login/i.test(idle.health().message), idle.health().message);
idle.stop();
});
test('stop() leaves nothing running', () => {
handle.stop();
const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error');
});
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
console.log(`\n${passed} tests passed`);
})();
+113
View File
@@ -0,0 +1,113 @@
/* Resi Cues — scoped under its tile via [data-module="resi-cues"], styled with
the shell's CSS variables only.
The countdown is sized in cqmin so it fills whatever the tile is given: the
same module is a small strip on a busy dashboard and a wall panel on its own. */
[data-module="resi-cues"] {
container-type: size;
}
[data-module="resi-cues"] .resi-wrap {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35em;
padding: 2%;
text-align: center;
overflow: hidden;
}
[data-module="resi-cues"] .resi-label {
color: var(--muted);
font-size: clamp(0.6rem, 5cqmin, 1rem);
font-weight: 700;
letter-spacing: 0.14em;
line-height: 1;
}
[data-module="resi-cues"] .resi-cue {
color: var(--text);
font-size: clamp(0.85rem, 11cqmin, 2.6rem);
font-weight: 650;
line-height: 1.05;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="resi-cues"] .resi-count {
color: var(--accent);
font-size: clamp(1.4rem, 30cqmin, 9rem);
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
line-height: 1;
}
[data-module="resi-cues"] .resi-count.is-soon { color: var(--warn); }
/* Held rather than counting: the digits are still true, they just are not moving. */
[data-module="resi-cues"] .resi-count.is-held,
[data-module="resi-cues"] .resi-foot-item.is-held {
color: var(--muted);
}
[data-module="resi-cues"] .resi-foot {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2em 0.9em;
color: var(--muted);
font-size: clamp(0.6rem, 5cqmin, 0.95rem);
font-variant-numeric: tabular-nums;
}
[data-module="resi-cues"] .resi-list {
list-style: none;
margin: 0.4em 0 0;
padding: 0.4em 0 0;
width: 100%;
max-width: 28em;
border-top: 1px solid var(--border);
overflow-y: auto;
font-size: clamp(0.6rem, 4.5cqmin, 0.95rem);
}
[data-module="resi-cues"] .resi-list-row {
display: flex;
justify-content: space-between;
gap: 1em;
padding: 0.12em 0.2em;
color: var(--text);
}
[data-module="resi-cues"] .resi-list-row.is-past { color: var(--muted); }
[data-module="resi-cues"] .resi-list-row.is-next {
color: var(--accent);
font-weight: 650;
}
[data-module="resi-cues"] .resi-list-at { font-variant-numeric: tabular-nums; }
[data-module="resi-cues"] .resi-list-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="resi-cues"] .resi-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 6%;
color: var(--muted);
text-align: center;
}