Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 9313c9e42a SPL Meter 0.4.2: the device picker can build the tool it needs
NA could not list a single audio interface. The module was installed from
the catalog, which ships source only because the capture binary is
gitignored, and the picker shells out to that binary with --list. So it
returned nothing, forever:

  [spl-meter] device list failed: spawn .../spl-capture ENOENT

The missing-binary build existed, but only inside the listener, and the
listener only starts once a device has been picked. No tool meant no device
list, and no device meant no listener to build the tool. Nothing was going
to break that on its own.

The build now lives at module scope and both callers use it: init() asks for
it on mount whether or not a device is configured, and /devices asks again
each time it is called, so opening the admin page is enough to start it. It
is still a plain spawn that never touches the event loop, and health() now
names the situation instead of leaving an empty dropdown unexplained.

Also removed two per-listener build flags left shadowing the module-scope
ones, which would have made the "is a build already running" test always
false and let a second build start under the first.

Tested the way it actually failed: a data directory holding only the six
source files, no binary. The build starts on mount and /devices answers with
the real device list moments later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:25:43 -04:00
9 changed files with 104 additions and 657 deletions
-28
View File
@@ -57,37 +57,9 @@
<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>
+1 -167
View File
@@ -238,47 +238,6 @@ 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 = [];
@@ -316,15 +275,6 @@ 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',
};
}
@@ -358,26 +308,9 @@ 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();
}
@@ -694,20 +627,6 @@ 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')
@@ -733,7 +652,6 @@ async function syncUpcomingPcoPlan(payload) {
source: 'pco',
pcoPersonId: member.personId,
pcoPlanPersonId: member.planPersonId,
birthday: birthdayByPerson.get(member.personId) || '',
});
}
@@ -813,7 +731,6 @@ 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) {
@@ -1305,7 +1222,6 @@ 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 {
@@ -1516,10 +1432,7 @@ 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);
const birthdayMode = payload?.birthdayMode
? normalizeBirthdayMode(payload.birthdayMode)
: normalizeBirthdayMode(existing.birthdayMode);
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
return true;
});
@@ -1598,85 +1511,6 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
return syncUpcomingPcoPlan(payload);
});
/**
* Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the
* People API and read birthdates, and how many synced band members have one on file. Read-only.
*/
ipcMain.handle('pco-test-birthdays', async () => {
const credentials = await loadPcoCredentials();
if (!credentials) return { connected: false };
// 1. Can the token read the People API at all?
let probe;
try {
probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' });
} catch (err) {
return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) };
}
if (!probe.ok) {
const detail =
probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') ||
`${probe.status}`;
return { connected: true, canRead: false, status: probe.status, detail };
}
const sampleAttrs = probe.payload?.data?.[0]?.attributes || null;
const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true;
// 2. Coverage across the people we've actually synced (unique by person id).
const state = await loadStateRaw();
const seen = new Set();
const targets = [];
for (const p of Array.isArray(state.library) ? state.library : []) {
if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue;
seen.add(p.pcoPersonId);
targets.push(p);
}
let withBirthday = 0;
let notFound = 0;
let errors = 0;
const found = [];
for (const p of targets) {
let r;
try {
r = await pcoFetchBirthday(p.pcoPersonId, credentials);
} catch {
errors += 1;
continue;
}
if (r.status === 404) { notFound += 1; continue; }
if (r.status && r.status >= 400) { errors += 1; continue; }
if (r.birthday) {
withBirthday += 1;
found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday });
}
}
// Soonest upcoming birthday, for a concrete "it works" line.
const today = new Date();
const daysUntil = (mmdd) => {
const [mm, dd] = mmdd.split('-').map(Number);
let next = new Date(today.getFullYear(), mm - 1, dd);
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd);
return Math.round((next - midnight) / 86400000);
};
const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd));
const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null;
return {
connected: true,
canRead: true,
fieldExposed,
total: targets.length,
withBirthday,
notFound,
errors,
found: sortedFound.slice(0, 8),
nextUp,
};
});
ipcMain.handle('pp-status', async () => {
return {
enabled: ppConfig.enabled,
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "band-lineup-display",
"productName": "Band Lineup",
"version": "1.3.10",
"buildNumber": "96",
"version": "1.3.6",
"buildNumber": "92",
"description": "Stage lineup photos with mic/pack labels",
"main": "main.js",
"scripts": {
-1
View File
@@ -14,7 +14,6 @@ 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());
},
+1 -273
View File
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
} else {
window.__bandLineupRendererLoaded = true;
/**
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string }} LibraryPerson
* @typedef {{ personId: string, label: string }} StageSlot
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
*/
@@ -27,10 +27,6 @@ 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();
@@ -69,13 +65,7 @@ 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');
@@ -254,7 +244,6 @@ 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;
@@ -438,9 +427,6 @@ 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);
@@ -465,11 +451,6 @@ 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() {
@@ -547,7 +528,6 @@ function scheduleSave() {
slotLabels: slotLabelMemory,
slotNames: slotNameMemory,
lineup: stageSlots,
birthdayMode,
});
}, 250);
}
@@ -970,132 +950,6 @@ 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');
@@ -1148,7 +1002,6 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
photoArea.appendChild(nameBadge);
photoArea.appendChild(removeBtn);
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
tile.appendChild(photoArea);
return tile;
}
@@ -1303,7 +1156,6 @@ async function renderLibrary() {
async function renderAll() {
await renderStage();
await renderLibrary();
renderBirthdayTestOptions();
}
async function importNewPeople(added) {
@@ -1318,7 +1170,6 @@ async function importNewPeople(added) {
source: 'local',
pcoPersonId: '',
pcoPlanPersonId: '',
birthday: '',
});
}
await renderLibrary();
@@ -1381,97 +1232,6 @@ 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. */
@@ -1497,7 +1257,6 @@ 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) ---------------- */
@@ -1974,36 +1733,6 @@ 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);
@@ -2015,7 +1744,6 @@ btnPcoDisconnect.addEventListener('click', async () => {
renderPcoPositionOptions();
scheduleSave();
setPcoStatus('Planning Center disconnected.');
setPcoBirthdayResult('');
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
} finally {
-126
View File
@@ -331,105 +331,6 @@ 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;
@@ -723,33 +624,6 @@ 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 -1
View File
@@ -1,7 +1,7 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.4.1",
"version": "0.4.2",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+23 -1
View File
@@ -20,7 +20,7 @@
*/
const path = require('path');
const { createSplListener } = require('./spl-listener');
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94;
@@ -114,11 +114,19 @@ 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 }) };
@@ -177,6 +185,9 @@ 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();
@@ -300,6 +311,13 @@ 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, {
@@ -438,6 +456,10 @@ 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, {
+75 -57
View File
@@ -17,7 +17,8 @@
* 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: see ensureBinary for what that cost the first time it was wrong.
* 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.
*
* 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
@@ -30,6 +31,74 @@ 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. */
@@ -53,64 +122,13 @@ function createSplListener(opts) {
let stallTimer = null;
let startedAt = 0;
let meta = null;
let building = false;
let buildFailed = false;
let lastFrameWall = 0;
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.
*/
/** '' when the tool is ready, otherwise what to say about it. */
function ensureBinary() {
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;
return ensureCaptureBinary({ moduleDir, log, onReady() {
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) {
@@ -137,7 +155,7 @@ function createSplListener(opts) {
onUpdate({ error: missing });
// 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);
if (missing !== BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
@@ -236,4 +254,4 @@ function createSplListener(opts) {
};
}
module.exports = { createSplListener };
module.exports = { createSplListener, ensureCaptureBinary };