Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d1b8e159 | ||
|
|
82c552955d | ||
|
|
098562c088 | ||
|
|
408dea7923 | ||
|
|
882a418a0f |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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`);
|
||||
@@ -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) });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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`);
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
||||
const { createSplListener } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
@@ -114,19 +114,11 @@ module.exports = {
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
silentSince: null,
|
||||
toolMissing: '',
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
};
|
||||
|
||||
// Before anything else: the device picker shells out to this tool, so a
|
||||
// machine that installed the module from the catalog (source only — the
|
||||
// binary is gitignored) needs the build kicked off here. Waiting for a
|
||||
// listener would never happen: no tool means no device list, and no
|
||||
// device means no listener.
|
||||
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
||||
|
||||
if (!config.enabled) {
|
||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
@@ -185,9 +177,6 @@ module.exports = {
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
||||
return { status: 'error', message: state.toolMissing };
|
||||
}
|
||||
if (state.error) return { status: 'error', message: state.error };
|
||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||
const quiet = silentMs();
|
||||
@@ -311,13 +300,6 @@ module.exports = {
|
||||
/** Device list for the admin page. Runs the capture tool's --list,
|
||||
which is the only thing that knows what CoreAudio can see. */
|
||||
'GET /devices': async (req, res) => {
|
||||
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||
if (missing) {
|
||||
if (state) state.toolMissing = missing;
|
||||
log(`device list unavailable — ${missing}`);
|
||||
json(res, 200, { options: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
@@ -456,10 +438,6 @@ function listDevices() {
|
||||
});
|
||||
}
|
||||
|
||||
function fsExists(p) {
|
||||
try { return require('fs').existsSync(p); } catch { return false; }
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||
* the app, or copied from any Mac of the same architecture. When it is
|
||||
* missing this will build it, but in the background and never on the event
|
||||
* loop, and from module scope so the device picker can trigger it too — see
|
||||
* ensureCaptureBinary for what both of those cost the first time round.
|
||||
* loop: see ensureBinary for what that cost the first time it was wrong.
|
||||
*
|
||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||
@@ -31,74 +30,6 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
||||
* everything that needs it.
|
||||
*
|
||||
* This lives at module scope rather than inside a listener because of a trap
|
||||
* the first version walked into: the admin page's device picker runs the tool
|
||||
* with --list, but the build only ran when a listener started, and a listener
|
||||
* only starts once a device has been picked. On a machine that got the module
|
||||
* from the catalog — source only, the binary being gitignored — the picker
|
||||
* was empty, and nothing would ever fill it.
|
||||
*
|
||||
* It also must never block. It was a spawnSync once, copied from the LTC
|
||||
* listener where the source is a third the size and builds in seconds. Here
|
||||
* it took nearly two minutes on the production Mac and held the Node event
|
||||
* loop for all of it, freezing every other module, then did it again on the
|
||||
* next retry.
|
||||
*
|
||||
* The binary should normally already be there: built beside the app, or
|
||||
* copied from any Mac of the same architecture. This is the fallback.
|
||||
*
|
||||
* @returns {string} '' when the tool is ready, otherwise what to say.
|
||||
*/
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
|
||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||
const bin = path.join(moduleDir, 'spl-capture');
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return BUILDING;
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (typeof onReady === 'function') onReady();
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})`
|
||||
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return BUILDING;
|
||||
}
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||
retrying every 5 s would only fill the log with the same sentence. */
|
||||
@@ -122,13 +53,64 @@ function createSplListener(opts) {
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
/** '' when the tool is ready, otherwise what to say about it. */
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||
*
|
||||
* This used to be a spawnSync, copied from the LTC listener where the
|
||||
* source is a third the size and builds in a few seconds. Here it took
|
||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||
* server for every second of it — one module's optional helper took the
|
||||
* whole dashboard down, then did it again on every retry. Nothing that
|
||||
* runs while a service might be on is allowed to hold the event loop.
|
||||
*
|
||||
* The build is a fallback for a module installed from the catalog on a
|
||||
* machine with a Swift toolchain. The normal path is a binary built
|
||||
* alongside the app, or copied from any same-architecture Mac.
|
||||
*
|
||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||
*/
|
||||
function ensureBinary() {
|
||||
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
} });
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return 'Building the capture tool — this takes a minute or two';
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return 'Building the capture tool — this takes a minute or two';
|
||||
}
|
||||
|
||||
function scheduleRestart(ms) {
|
||||
@@ -153,13 +135,9 @@ function createSplListener(opts) {
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
onUpdate({ error: missing });
|
||||
// onReady is only the FAST path (a successful build restarts us at
|
||||
// once). The slow retry must stay armed regardless: a failed build
|
||||
// never calls back, and a binary can also arrive from outside — scp'd
|
||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||
// Waiting on onReady alone deadlocked exactly that way in the Timers
|
||||
// module: "building…" forever, with a working binary on disk.
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
// A build in flight calls start() itself when it lands; anything else
|
||||
// is config-shaped and should back off hard rather than spin.
|
||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,4 +236,4 @@ function createSplListener(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener, ensureCaptureBinary };
|
||||
module.exports = { createSplListener };
|
||||
|
||||
Reference in New Issue
Block a user