Files
ProdDash/band-lineup-display/main.js
T
christianandClaude Opus 5 882a418a0f Band Lineup Display snapshot catches up: 1.3.6 → 1.3.10
The reference fork had drifted four builds behind the app actually running
backstage. What it was missing is Birthday Mode, the feature the ProdDash
module was ported with and this snapshot never had:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:37:54 -04:00

1834 lines
65 KiB
JavaScript

const { app, BrowserWindow, ipcMain, dialog, Menu, safeStorage, clipboard } = require('electron');
const path = require('path');
const fs = require('fs/promises');
const fsSync = require('fs');
const { pathToFileURL } = require('url');
const crypto = require('crypto');
const sharp = require('sharp');
const {
createProPresenterClient,
clearedViewModel: ppClearedViewModel,
} = require('./propresenter-core');
// Require the exact file, never the directory: propresenter-app/package.json declares the
// standalone kiosk wrapper as its main, so a bare directory require would start a second
// Electron app instead of the server.
const {
createDisplayServer,
DEFAULT_PORT: DEFAULT_DISPLAY_PORT,
} = require('./propresenter-app/server');
let mainWindow;
const DEFAULT_COLUMNS = 7;
const DEFAULT_ROWS = 2;
const PCO_API_BASE = 'https://api.planningcenteronline.com';
const ALLOWED_PCO_TEAM_KEYWORDS = ['communication', 'band', 'production'];
/** Default ProPresenter network API port (user-configurable in ProPresenter ▸ Preferences ▸ Network). */
const DEFAULT_PP_PORT = 1025;
/** How often to poll ProPresenter for playlist/slide changes while connected. */
const PP_POLL_MS = 600;
/** Slower retry cadence when ProPresenter is unreachable. */
const PP_RETRY_MS = 2500;
/** Long-edge cap when baking parallax layers (depth/segmentation). Higher = sharper on 4K; bump PARALLAX_VERSION when changing. */
const PARALLAX_MAX_DIMENSION = 3072;
const PARALLAX_VERSION = 10;
let depthEstimatorPromise = null;
let backgroundRemoverPromise = null;
const parallaxTaskCache = new Map();
function normalizeLayout(layout) {
const columns = Number.parseInt(String(layout?.columns ?? DEFAULT_COLUMNS), 10);
const rows = Number.parseInt(String(layout?.rows ?? DEFAULT_ROWS), 10);
return {
columns: Number.isFinite(columns) && columns > 0 ? columns : DEFAULT_COLUMNS,
rows: Number.isFinite(rows) && rows > 0 ? rows : DEFAULT_ROWS,
};
}
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
function imagesDir() {
return path.join(app.getPath('userData'), 'images');
}
function processedDir() {
return path.join(app.getPath('userData'), 'processed');
}
function modelCacheDir() {
return path.join(app.getPath('userData'), 'model-cache');
}
function statePath() {
return path.join(app.getPath('userData'), 'state.json');
}
function pcoCredentialsPath() {
return path.join(app.getPath('userData'), 'pco-credentials.json');
}
async function ensureDirs() {
await fs.mkdir(imagesDir(), { recursive: true });
await fs.mkdir(processedDir(), { recursive: true });
await fs.mkdir(modelCacheDir(), { recursive: true });
}
function encodeStoredSecret(value) {
const text = String(value ?? '');
if (!text) return '';
if (safeStorage.isEncryptionAvailable()) {
return `enc:${safeStorage.encryptString(text).toString('base64')}`;
}
return `plain:${Buffer.from(text, 'utf8').toString('base64')}`;
}
function decodeStoredSecret(value) {
const raw = String(value ?? '');
if (!raw) return '';
if (raw.startsWith('enc:')) {
return safeStorage.decryptString(Buffer.from(raw.slice(4), 'base64'));
}
if (raw.startsWith('plain:')) {
return Buffer.from(raw.slice(6), 'base64').toString('utf8');
}
return raw;
}
async function loadPcoCredentials() {
try {
const raw = JSON.parse(await fs.readFile(pcoCredentialsPath(), 'utf8'));
const clientId = decodeStoredSecret(raw.clientId);
const secret = decodeStoredSecret(raw.secret);
if (!clientId || !secret) return null;
return { clientId, secret };
} catch {
return null;
}
}
async function savePcoCredentials(credentials) {
await ensureDirs();
await fs.writeFile(
pcoCredentialsPath(),
JSON.stringify(
{
clientId: encodeStoredSecret(credentials.clientId),
secret: encodeStoredSecret(credentials.secret),
},
null,
2,
),
'utf8',
);
}
async function clearPcoCredentials() {
try {
await fs.unlink(pcoCredentialsPath());
} catch {
/* already gone */
}
}
function ppCredentialsPath() {
return path.join(app.getPath('userData'), 'propresenter-credentials.json');
}
async function loadPpPassword() {
try {
const raw = JSON.parse(await fs.readFile(ppCredentialsPath(), 'utf8'));
return decodeStoredSecret(raw.password) || '';
} catch {
return '';
}
}
async function savePpPassword(password) {
if (!password) {
await clearPpPassword();
return;
}
await ensureDirs();
await fs.writeFile(
ppCredentialsPath(),
JSON.stringify({ password: encodeStoredSecret(password) }, null, 2),
'utf8',
);
}
async function clearPpPassword() {
try {
await fs.unlink(ppCredentialsPath());
} catch {
/* already gone */
}
}
function randomName(originalPath) {
const ext = path.extname(originalPath) || '.jpg';
return `${crypto.randomBytes(16).toString('hex')}${ext}`;
}
function originalDisplayName(sourcePath) {
return path.parse(sourcePath).name;
}
async function copyImageToStore(sourcePath) {
await ensureDirs();
const destName = randomName(sourcePath);
const dest = path.join(imagesDir(), destName);
await fs.copyFile(sourcePath, dest);
return destName;
}
function processedBaseName(fileName) {
return path.join(processedDir(), path.parse(fileName).name);
}
function pcoUserAgent() {
return `Band Lineup/${app.getVersion()} (desktop sync)`;
}
function pcoAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.clientId}:${credentials.secret}`, 'utf8').toString('base64')}`;
}
function buildPcoUrl(target, searchParams) {
const url = new URL(target.startsWith('http') ? target : `${PCO_API_BASE}${target}`);
for (const [key, value] of Object.entries(searchParams || {})) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, String(value));
}
return url;
}
async function pcoRequestJson(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;
}
if (!response.ok) {
const detail =
payload?.errors?.map((entry) => entry.detail || entry.title).filter(Boolean).join(' | ') ||
payload?.error ||
`${response.status} ${response.statusText}`;
throw new Error(detail);
}
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 = [];
const included = [];
const seenIncluded = new Set();
while (nextUrl) {
const payload = await pcoRequestJson(nextUrl, credentials);
if (Array.isArray(payload.data)) {
data.push(...payload.data);
} else if (payload.data) {
data.push(payload.data);
}
if (Array.isArray(payload.included)) {
for (const item of payload.included) {
const key = `${item?.type || ''}:${item?.id || ''}`;
if (!key || seenIncluded.has(key)) continue;
seenIncluded.add(key);
included.push(item);
}
}
nextUrl = typeof payload?.links?.next === 'string' && payload.links.next ? payload.links.next : '';
}
return { data, included };
}
function emptyState() {
return {
library: [],
layout: { columns: DEFAULT_COLUMNS, rows: DEFAULT_ROWS },
parallaxEnabled: true,
slotLabels: [],
slotNames: [],
lineup: [],
pcoServiceTypeId: '',
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',
};
}
/** Whether we host the Now/Next display for other screens, and on which port. */
function normalizeDisplayServerConfig(entry) {
const portNum = Number.parseInt(String(entry?.port ?? ''), 10);
const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : DEFAULT_DISPLAY_PORT;
return { enabled: Boolean(entry?.enabled), port };
}
function normalizeProPresenterConfig(entry) {
const host = String(entry?.host || '').trim();
const portNum = Number.parseInt(String(entry?.port ?? ''), 10);
const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0;
return {
enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port),
host,
port,
playlistUuid: String(entry?.playlistUuid || ''),
playlistName: String(entry?.playlistName || ''),
};
}
function normalizeSavedLibraryPerson(person) {
return {
id: String(person.id),
fileName: String(person.fileName),
originalName: typeof person.originalName === 'string' && person.originalName.trim() ? person.originalName.trim() : '',
lastLabel: typeof person.lastLabel === 'string' ? person.lastLabel : '',
lastName: typeof person.lastName === 'string' ? person.lastName : '',
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();
}
function isAllowedPcoTeamName(value) {
const normalized = normalizeTeamName(value);
return ALLOWED_PCO_TEAM_KEYWORDS.some((keyword) => normalized === keyword || normalized.includes(keyword));
}
function normalizePcoLabel(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function normalizePcoToken(token) {
const normalized = String(token || '').trim().toLowerCase();
if (!normalized) return '';
if (/^\d+$/.test(normalized)) return '';
if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input'].includes(normalized)) return '';
if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals';
if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys';
if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums';
if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion';
if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar';
if (['electric', 'electricguitar'].includes(normalized)) return 'electric';
if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic';
if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader';
if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized;
if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1);
return normalized;
}
function normalizedPcoLabelTokens(value) {
const normalized = normalizePcoLabel(value);
if (!normalized) return [];
const tokens = normalized
.split(' ')
.map((token) => normalizePcoToken(token))
.filter(Boolean);
if (!tokens.length && normalized) return [normalized];
return [...new Set(tokens)];
}
function scorePcoLabelMatch(slotLabel, positionLabel) {
const slotNormalized = normalizePcoLabel(slotLabel);
const positionNormalized = normalizePcoLabel(positionLabel);
if (!slotNormalized || !positionNormalized) return 0;
if (slotNormalized === positionNormalized) return 120;
if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95;
const slotTokens = normalizedPcoLabelTokens(slotLabel);
const positionTokens = normalizedPcoLabelTokens(positionLabel);
if (!slotTokens.length || !positionTokens.length) return 0;
const positionSet = new Set(positionTokens);
const slotSet = new Set(slotTokens);
const overlap = slotTokens.filter((token) => positionSet.has(token));
if (!overlap.length) return 0;
const overlapCount = overlap.length;
const slotCoverage = overlapCount / slotSet.size;
const positionCoverage = overlapCount / positionSet.size;
const sharedPhrase = overlap.join(' ');
let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4);
if (slotCoverage === 1) score += 18;
if (positionCoverage === 1) score += 10;
if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8;
return score;
}
function findBestPcoMemberForSlot(slotLabel, availableMembers) {
let bestIndex = -1;
let bestScore = 0;
for (let i = 0; i < availableMembers.length; i += 1) {
const member = availableMembers[i];
const score = scorePcoLabelMatch(slotLabel, member.label);
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
if (bestIndex < 0 || bestScore < 60) return null;
return {
member: availableMembers.splice(bestIndex, 1)[0],
score: bestScore,
};
}
function pcoPositionKey(teamName, positionName) {
return `${normalizeTeamName(teamName)}::${String(positionName || '').trim().toLowerCase()}`;
}
function normalizePcoDisplayPosition(entry) {
const count = Number.parseInt(String(entry?.count ?? 1), 10);
return {
teamName: String(entry?.teamName || '').trim(),
positionName: String(entry?.positionName || '').trim(),
count: Number.isFinite(count) && count > 0 ? count : 1,
};
}
function normalizePcoDisplayPositions(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map((entry) => normalizePcoDisplayPosition(entry))
.filter((entry) => entry.teamName && entry.positionName);
}
function initialsForName(name) {
const parts = String(name || '')
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) return '?';
return parts.map((part) => part[0]).join('').toUpperCase();
}
function pcoImageExtension(contentType, imageUrl) {
const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
if (normalized === 'image/jpeg') return '.jpg';
if (normalized === 'image/png') return '.png';
if (normalized === 'image/webp') return '.webp';
if (normalized === 'image/gif') return '.gif';
const ext = path.extname(new URL(imageUrl).pathname || '').toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp'].includes(ext)) return ext;
return '.jpg';
}
async function writePcoPlaceholderImage(displayName, key) {
await ensureDirs();
const fileName = `pco-${crypto.createHash('sha1').update(`${key}:placeholder`).digest('hex')}.svg`;
const filePath = path.join(imagesDir(), fileName);
const initials = initialsForName(displayName);
const safeInitials = initials.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1200" viewBox="0 0 900 1200"><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop offset="0%" stop-color="#2f455c"/><stop offset="100%" stop-color="#1b2432"/></linearGradient></defs><rect width="100%" height="100%" fill="url(#g)"/><text x="50%" y="52%" font-family="Arial, Helvetica, sans-serif" font-size="260" font-weight="700" text-anchor="middle" fill="#f4f6fb">${safeInitials}</text></svg>`;
await fs.writeFile(filePath, svg, 'utf8');
return fileName;
}
async function downloadPcoImage(imageUrl, key, displayName) {
if (!imageUrl) {
return writePcoPlaceholderImage(displayName, key);
}
try {
const response = await fetch(imageUrl, {
headers: {
'User-Agent': pcoUserAgent(),
},
});
if (!response.ok) {
throw new Error(`image download failed with ${response.status}`);
}
const ext = pcoImageExtension(response.headers.get('content-type'), imageUrl);
const fileName = `pco-${crypto.createHash('sha1').update(key).digest('hex')}${ext}`;
const filePath = path.join(imagesDir(), fileName);
const bytes = Buffer.from(await response.arrayBuffer());
await ensureDirs();
await fs.writeFile(filePath, bytes);
return fileName;
} catch {
return writePcoPlaceholderImage(displayName, key);
}
}
function makePcoLocalId(serviceTypeId, planId, planPersonId) {
return `pco-${serviceTypeId}-${planId}-${planPersonId}`;
}
async function listPcoServiceTypes(credentials) {
const payload = await pcoFetchAll('/services/v2/service_types', credentials, {
per_page: 100,
order: 'sequence',
});
return payload.data
.filter((entry) => !entry?.attributes?.archived_at && !entry?.attributes?.deleted_at)
.map((entry) => ({
id: String(entry.id),
name: String(entry?.attributes?.name || `Service Type ${entry.id}`),
}));
}
async function listPcoPositionOptions(serviceTypeId, credentials) {
const normalizedServiceTypeId = String(serviceTypeId || '').trim();
if (!normalizedServiceTypeId) return [];
const payload = await pcoFetchAll(`/services/v2/service_types/${normalizedServiceTypeId}/team_positions`, credentials, {
include: 'team',
per_page: 100,
order: 'name',
});
const includedByKey = new Map(payload.included.map((entry) => [`${entry?.type || ''}:${entry?.id || ''}`, entry]));
const options = [];
for (const entry of payload.data) {
if (!entry || typeof entry !== 'object') continue;
const teamId = String(entry?.relationships?.team?.data?.id || '');
const team = teamId ? includedByKey.get(`Team:${teamId}`) : null;
const teamName = String(team?.attributes?.name || '').trim();
if (!isAllowedPcoTeamName(teamName)) continue;
const positionName = String(entry?.attributes?.name || '').trim();
if (!positionName) continue;
options.push({
teamName,
positionName,
key: pcoPositionKey(teamName, positionName),
});
}
options.sort((a, b) => {
const teamCompare = a.teamName.localeCompare(b.teamName, undefined, { sensitivity: 'base' });
if (teamCompare) return teamCompare;
return a.positionName.localeCompare(b.positionName, undefined, { sensitivity: 'base' });
});
return options;
}
async function syncUpcomingPcoPlan(payload) {
const credentials = await loadPcoCredentials();
if (!credentials) {
throw new Error('Planning Center is not connected yet.');
}
const normalizedServiceTypeId = String(payload?.serviceTypeId || '').trim();
if (!normalizedServiceTypeId) {
throw new Error('Choose a service type first.');
}
const currentState = await loadStateRaw();
const baseState = {
library: Array.isArray(payload?.library) ? payload.library.map((entry) => normalizeSavedLibraryPerson(entry)) : currentState.library,
layout: payload?.layout ? normalizeLayout(payload.layout) : currentState.layout,
parallaxEnabled: typeof payload?.parallaxEnabled === 'boolean' ? payload.parallaxEnabled : currentState.parallaxEnabled,
slotLabels: Array.isArray(payload?.slotLabels)
? payload.slotLabels.map((entry) => (typeof entry === 'string' ? entry : ''))
: currentState.slotLabels,
slotNames: Array.isArray(payload?.slotNames)
? payload.slotNames.map((entry) => (typeof entry === 'string' ? entry : ''))
: currentState.slotNames,
lineup: Array.isArray(payload?.lineup)
? payload.lineup.map((entry) => ({
personId: typeof entry?.personId === 'string' ? entry.personId : '',
label: typeof entry?.label === 'string' ? entry.label : '',
}))
: currentState.lineup,
pcoDisplayPositions: normalizePcoDisplayPositions(payload?.pcoDisplayPositions ?? currentState.pcoDisplayPositions),
// Carried through deliberately: this state is rebuilt field-by-field and written whole,
// so anything omitted here is ERASED from disk by a sync.
proPresenter: normalizeProPresenterConfig(currentState.proPresenter),
displayServer: normalizeDisplayServerConfig(currentState.displayServer),
};
const planPayload = await pcoRequestJson(`/services/v2/service_types/${normalizedServiceTypeId}/plans`, credentials, {
filter: 'future',
per_page: 1,
order: 'sort_date',
});
const plan = Array.isArray(planPayload.data) ? planPayload.data[0] : null;
if (!plan) {
throw new Error('No upcoming plan was found for that service type.');
}
const teamMembersPayload = await pcoFetchAll(
`/services/v2/service_types/${normalizedServiceTypeId}/plans/${plan.id}/team_members`,
credentials,
{
include: 'person,team',
per_page: 100,
},
);
const includedByKey = new Map(
teamMembersPayload.included.map((entry) => [`${entry?.type || ''}:${entry?.id || ''}`, entry]),
);
const scheduledMembers = [];
for (const member of teamMembersPayload.data) {
if (!member || typeof member !== 'object') continue;
const status = String(member?.attributes?.status || '').trim();
if (status === 'D' || /^declined$/i.test(status)) continue;
const personId = String(member?.relationships?.person?.data?.id || '');
const teamId = String(member?.relationships?.team?.data?.id || '');
const person = personId ? includedByKey.get(`Person:${personId}`) : null;
const team = teamId ? includedByKey.get(`Team:${teamId}`) : null;
const displayName =
String(
person?.attributes?.full_name ||
person?.attributes?.profile_name ||
member?.attributes?.name ||
'Unknown Person',
).trim() || 'Unknown Person';
const label = String(member?.attributes?.team_position_name || '').trim();
const teamName = String(team?.attributes?.name || '').trim();
if (!isAllowedPcoTeamName(teamName)) continue;
const photoUrl =
String(
person?.attributes?.photo_url ||
person?.attributes?.photo_thumbnail_url ||
member?.attributes?.photo_thumbnail ||
'',
).trim();
scheduledMembers.push({
planPersonId: String(member.id),
personId,
displayName,
label,
teamName,
photoUrl,
});
}
scheduledMembers.sort((a, b) => {
const teamCompare = a.teamName.localeCompare(b.teamName, undefined, { sensitivity: 'base' });
if (teamCompare) return teamCompare;
const labelCompare = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
if (labelCompare) return labelCompare;
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')
.map((entry) => entry.fileName),
);
const syncedLibrary = [];
const nextPcoFiles = new Set();
for (const member of scheduledMembers) {
const localId = makePcoLocalId(normalizedServiceTypeId, String(plan.id), member.planPersonId);
const fileName = await downloadPcoImage(
member.photoUrl,
`${normalizedServiceTypeId}:${plan.id}:${member.planPersonId}:${member.photoUrl}`,
member.displayName,
);
nextPcoFiles.add(fileName);
syncedLibrary.push({
id: localId,
fileName,
originalName: member.displayName,
lastLabel: member.label,
lastName: member.displayName,
source: 'pco',
pcoPersonId: member.personId,
pcoPlanPersonId: member.planPersonId,
birthday: birthdayByPerson.get(member.personId) || '',
});
}
const layout = normalizeLayout(baseState.layout);
const slotCount = layout.columns * layout.rows;
const slotLabels = Array.from({ length: slotCount }, (_slot, index) =>
typeof baseState.slotLabels[index] === 'string'
? baseState.slotLabels[index]
: typeof baseState.lineup[index]?.label === 'string'
? baseState.lineup[index].label
: '',
);
const slotNames = Array.from({ length: slotCount }, (_slot, index) =>
typeof baseState.slotNames[index] === 'string' ? baseState.slotNames[index] : '',
);
const lineup = Array.from({ length: slotCount }, (_slot, index) => ({
personId: typeof baseState.lineup[index]?.personId === 'string' ? baseState.lineup[index].personId : '',
label: slotLabels[index] || '',
}));
const libraryById = new Map(baseState.library.map((entry) => [entry.id, entry]));
let displayedCount = 0;
const availableMembers = [...scheduledMembers].sort((a, b) => {
const labelCompare = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
if (labelCompare) return labelCompare;
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
});
for (let i = 0; i < slotCount; i += 1) {
const desiredLabel = String(slotLabels[i] || '').trim();
if (!desiredLabel) continue;
const match = findBestPcoMemberForSlot(desiredLabel, availableMembers);
if (match?.member) {
const member = match.member;
lineup[i] = {
personId: makePcoLocalId(normalizedServiceTypeId, String(plan.id), member.planPersonId),
label: desiredLabel,
};
slotNames[i] = member.displayName;
displayedCount += 1;
continue;
}
const currentPerson = libraryById.get(lineup[i].personId);
if (currentPerson?.source === 'local') {
lineup[i] = {
personId: currentPerson.id,
label: desiredLabel,
};
continue;
}
lineup[i] = {
personId: '',
label: desiredLabel,
};
slotNames[i] = '';
}
const localLibrary = (Array.isArray(baseState.library) ? baseState.library : []).filter(
(entry) => entry?.source !== 'pco',
);
const nextState = {
library: [...localLibrary, ...syncedLibrary],
layout,
parallaxEnabled: typeof baseState.parallaxEnabled === 'boolean' ? baseState.parallaxEnabled : true,
slotLabels,
slotNames,
lineup,
pcoServiceTypeId: normalizedServiceTypeId,
pcoDisplayPositions: baseState.pcoDisplayPositions,
};
// Re-read the keys this function does not own, immediately before writing. The snapshot
// above was taken before a plan fetch, paginated team-member fetches and one photo
// download per person — tens of seconds during which the operator can still connect
// ProPresenter or switch hosting on, and this whole-file write would stamp those back to
// their old values. (That is the bug the carry-forward was added to fix; reading the
// values early only moved the window instead of closing it.)
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) {
if (nextPcoFiles.has(fileName)) continue;
try {
await fs.unlink(path.join(imagesDir(), path.basename(fileName)));
} catch {
/* ignore stale file cleanup */
}
}
return {
state: nextState,
plan: {
id: String(plan.id),
title: String(plan?.attributes?.title || ''),
shortDates: String(plan?.attributes?.short_dates || plan?.attributes?.dates || ''),
serviceTypeId: normalizedServiceTypeId,
peopleCount: scheduledMembers.length,
displayedCount,
},
};
}
/* ------------------------------------------------------------------ *
* ProPresenter 7 integration *
* *
* The polling + HTTP logic lives in ./propresenter-core, shared with *
* the standalone display program in propresenter-app/ so both stay in *
* lockstep. It runs in the main process (Node fetch): no CORS and no *
* renderer CSP change. This file owns only the Electron side — *
* persistence in the app state file, and pushing the core's *
* view-model to the renderer over the 'pp-data' channel. *
* ------------------------------------------------------------------ */
/** Connection config as persisted in the app state file (password comes from safeStorage). */
let ppConfig = { enabled: false, host: '', port: 0, password: '', playlistUuid: '', playlistName: '' };
const ppClient = createProPresenterClient({
pollMs: PP_POLL_MS,
retryMs: PP_RETRY_MS,
onData: (view) => {
// Publish to the LAN FIRST, in its own guard. Two reasons: the window check below
// returns early when there is no window (so a fan-out after it would never run for
// the studios), and a throw from here escapes emit() into the poll loop — which is
// precisely how polling could stop for good.
try {
displayServer?.publish(view);
} catch (err) {
displayError = err instanceof Error ? err.message : String(err);
}
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
mainWindow.webContents.send('pp-data', view);
} catch {
/* window went away between the check and the send */
}
},
});
/** Push ppConfig (our persisted source of truth) into the polling client. */
function ppSyncClientConfig() {
ppClient.configure(ppConfig);
}
function ppSendCleared() {
const cleared = ppClearedViewModel();
try {
displayServer?.publish(cleared);
} catch (err) {
displayError = err instanceof Error ? err.message : String(err);
}
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('pp-data', cleared);
}
function ppStop() {
ppClient.stop();
}
function ppStart() {
ppSyncClientConfig();
ppClient.start();
}
async function ppLoadConfigFromDisk() {
const state = await loadStateRaw();
const config = normalizeProPresenterConfig(state.proPresenter);
const password = await loadPpPassword();
ppConfig = { ...config, password };
ppSyncClientConfig();
}
/* ------------------------------------------------------------------ *
* Hosting the Now / Next display for the rest of the building *
* *
* Serves the same display program that lives in propresenter-app/, so *
* studios and hallway screens need only a bookmark — nothing installed. *
* It is handed the ppClient above rather than making its own, so there *
* is exactly ONE poll loop behind this app's own column and every *
* screen on the network, and the ProPresenter connection is still *
* configured in one place: here. *
* ------------------------------------------------------------------ */
/** Persisted in state.json under `displayServer`. */
let displayConfig = { enabled: false, port: DEFAULT_DISPLAY_PORT };
/** The running server, or null. Also the flag the poll-loop fan-out keys off. */
let displayServer = null;
let displayError = '';
/**
* On Windows the firewall prompt fires at bind time, not on first connection, so this
* machine can look perfectly healthy on localhost while every studio times out. A viewer
* count that never leaves zero is the only signal we get, so the UI says so out loud — the
* latch itself lives in the display server, which is what actually sees connections.
*/
/**
* Serializes start/stop/port changes. Without this, two quick toggles interleave: the second
* call's stop runs while the first call is still awaiting listen(), finds displayServer still
* null, and does nothing — then the first call assigns a LISTENING server even though the
* saved state now says off. The port would stay open with the UI reporting "Off".
*/
let displayTransition = Promise.resolve();
function queueDisplayTransition(task) {
const run = () => task();
const next = displayTransition.then(run, run);
displayTransition = next.then(() => {}, () => {});
return next;
}
function displayStatus() {
const listening = Boolean(displayServer && displayServer.isListening());
const status = listening ? displayServer.status() : null;
const viewers = listening ? displayServer.viewers() : 0;
return {
enabled: displayConfig.enabled,
port: displayConfig.port,
listening,
viewers,
devices: status ? status.viewerDevices : 0,
// Read from the server, not sampled here: this UI's poll is suppressed for the whole of
// show mode, so sampling would report "nothing ever connected" after a service where
// three studios watched the entire time.
everHadViewer: Boolean(status && status.everHadViewer),
peakViewers: status ? status.peakViewers : 0,
addresses: status ? status.addresses : [],
mdnsName: status ? status.mdnsName : '',
mdnsConflict: status ? status.mdnsConflict : '',
lastError: displayError,
};
}
async function startDisplayServer() {
if (displayServer) return displayStatus();
displayError = '';
const candidate = createDisplayServer({
client: ppClient,
port: displayConfig.port,
hostKind: 'band-lineup',
hostLabel: 'the Band Lineup computer backstage',
mdnsName: 'nownext',
appVersion: app.getVersion(),
});
try {
await candidate.listen();
// Re-check: hosting may have been switched off while listen() was in flight.
if (!displayConfig.enabled) {
await candidate.close().catch(() => {});
return displayStatus();
}
displayServer = candidate;
// Seed the screens with whatever is on stage right now.
candidate.publish(ppClient.getViewModel());
} catch (error) {
await candidate.close().catch(() => {});
// Deliberately NOT walking to the next free port: a moving port silently breaks every
// bookmark this feature exists to create. Say what happened and let the operator choose.
displayError = error?.code === 'EADDRINUSE'
? `Port ${displayConfig.port} is already in use on this computer — another copy of Band Lineup, or another program, may already have it. Pick a different port.`
: `Could not start the display server: ${error instanceof Error ? error.message : String(error)}`;
}
return displayStatus();
}
async function stopDisplayServer() {
const current = displayServer;
displayServer = null;
// close() never stops the injected ppClient — this app's own column keeps running.
if (current) await current.close().catch(() => {});
return displayStatus();
}
/** Tell every connected screen the ProPresenter connection changed. Never throws. */
function displayNotifyStatus() {
try {
displayServer?.publishStatus();
} catch (err) {
displayError = err instanceof Error ? err.message : String(err);
}
}
async function persistDisplayConfig() {
const state = await loadStateRaw();
state.displayServer = { enabled: displayConfig.enabled, port: displayConfig.port };
await saveStateRaw(state);
}
async function loadDisplayConfigFromDisk() {
const state = await loadStateRaw();
displayConfig = normalizeDisplayServerConfig(state.displayServer);
}
function parallaxMetaPath(fileName) {
return `${processedBaseName(fileName)}.json`;
}
function parallaxLayerPath(fileName, layerName) {
return `${processedBaseName(fileName)}-${layerName}.png`;
}
async function getDepthEstimator() {
if (!depthEstimatorPromise) {
depthEstimatorPromise = (async () => {
const { pipeline, env } = await import('@huggingface/transformers');
env.cacheDir = modelCacheDir();
env.allowLocalModels = true;
env.useFSCache = true;
return pipeline('depth-estimation', 'Xenova/dpt-hybrid-midas', { dtype: 'q8' });
})();
}
return depthEstimatorPromise;
}
async function getBackgroundRemover() {
if (!backgroundRemoverPromise) {
backgroundRemoverPromise = (async () => {
const { pipeline, env } = await import('@huggingface/transformers');
env.cacheDir = modelCacheDir();
env.allowLocalModels = true;
env.useFSCache = true;
return pipeline('background-removal', 'Xenova/modnet', { dtype: 'q8' });
})();
}
return backgroundRemoverPromise;
}
function smoothstep(edge0, edge1, x) {
const t = Math.max(0, Math.min(1, (x - edge0) / Math.max(1, edge1 - edge0)));
return t * t * (3 - 2 * t);
}
function percentileFromHistogram(histogram, total, percentile) {
const target = total * percentile;
let running = 0;
for (let i = 0; i < histogram.length; i += 1) {
running += histogram[i];
if (running >= target) return i;
}
return histogram.length - 1;
}
function applyMaskToRgba(sourceData, width, height, alphaForPixel) {
const out = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < width * height; i += 1) {
const srcOffset = i * 4;
out[srcOffset] = sourceData[srcOffset];
out[srcOffset + 1] = sourceData[srcOffset + 1];
out[srcOffset + 2] = sourceData[srcOffset + 2];
out[srcOffset + 3] = alphaForPixel(i);
}
return out;
}
async function writeRawRgbaPng(filePath, width, height, data) {
await sharp(Buffer.from(data), {
raw: {
width,
height,
channels: 4,
},
})
.png()
.toFile(filePath);
}
async function buildExpandedSubjectMask(width, height, foregroundData) {
const alpha = new Uint8ClampedArray(width * height);
for (let i = 0; i < width * height; i += 1) {
alpha[i] = foregroundData[i * 4 + 3];
}
const edgePx = Math.max(8, Math.min(40, Math.round(10 * (Math.max(width, height) / 768))));
return sharp(Buffer.from(alpha), {
raw: {
width,
height,
channels: 1,
},
})
.dilate(edgePx)
.blur(edgePx)
.raw()
.toBuffer();
}
function averageBackgroundColor(imageData, maskData) {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < maskData.length; i += 1) {
const subject = maskData[i] / 255;
if (subject > 0.12) continue;
const offset = i * 4;
r += imageData[offset];
g += imageData[offset + 1];
b += imageData[offset + 2];
count += 1;
}
if (!count) return { r: 24, g: 24, b: 24 };
return {
r: Math.round(r / count),
g: Math.round(g / count),
b: Math.round(b / count),
};
}
async function existingParallaxAssets(fileName) {
const metaPath = parallaxMetaPath(fileName);
const bgPath = parallaxLayerPath(fileName, 'bg');
const midPath = parallaxLayerPath(fileName, 'mid');
const fgPath = parallaxLayerPath(fileName, 'fg');
const depthPath = parallaxLayerPath(fileName, 'depth');
if (!fsSync.existsSync(metaPath) || !fsSync.existsSync(bgPath) || !fsSync.existsSync(midPath) || !fsSync.existsSync(fgPath) || !fsSync.existsSync(depthPath)) {
return null;
}
try {
const meta = JSON.parse(await fs.readFile(metaPath, 'utf8'));
if (meta.version !== PARALLAX_VERSION) return null;
return {
bgUrl: pathToFileURL(bgPath).href,
midUrl: pathToFileURL(midPath).href,
fgUrl: pathToFileURL(fgPath).href,
depthUrl: pathToFileURL(depthPath).href,
width: meta.width,
height: meta.height,
};
} catch {
return null;
}
}
async function generateParallaxAssets(fileName) {
const existing = await existingParallaxAssets(fileName);
if (existing) return existing;
const safeName = path.basename(fileName);
const sourcePath = path.join(imagesDir(), safeName);
if (!fsSync.existsSync(sourcePath)) return null;
const image = sharp(sourcePath).rotate();
const metadata = await image.metadata();
const sourceWidth = metadata.width || 0;
const sourceHeight = metadata.height || 0;
if (!sourceWidth || !sourceHeight) return null;
const scale = Math.min(1, PARALLAX_MAX_DIMENSION / Math.max(sourceWidth, sourceHeight));
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
const encoded = await image
.resize(width, height, { fit: 'contain', kernel: sharp.kernel.lanczos3 })
.png()
.toBuffer();
const baseRaw = await sharp(encoded).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const bgRaw = await sharp(encoded).modulate({ brightness: 0.94, saturation: 1.0 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const inputBlob = new Blob([encoded], { type: 'image/png' });
const [depthEstimator, backgroundRemover] = await Promise.all([getDepthEstimator(), getBackgroundRemover()]);
const [depthOutput, foregroundOutput] = await Promise.all([
depthEstimator(inputBlob),
backgroundRemover(inputBlob),
]);
const depthImage = depthOutput.depth;
const depthData = depthImage.data;
const foregroundImage = Array.isArray(foregroundOutput) ? foregroundOutput[0] : foregroundOutput;
const foregroundRgba = foregroundImage.clone().rgba();
const foregroundData = foregroundRgba.data;
const histogram = new Array(256).fill(0);
for (let i = 0; i < depthData.length; i += 1) {
histogram[depthData[i]] += 1;
}
const total = depthData.length;
const midThreshold = percentileFromHistogram(histogram, total, 0.56);
const frontThreshold = percentileFromHistogram(histogram, total, 0.82);
const subjectMaskData = await buildExpandedSubjectMask(width, height, foregroundData);
const avgBg = averageBackgroundColor(bgRaw.data, subjectMaskData);
const bgData = applyMaskToRgba(bgRaw.data, width, height, () => 255);
for (let i = 0; i < width * height; i += 1) {
const expandedAlpha = subjectMaskData[i] / 255;
if (expandedAlpha <= 0.02) continue;
const offset = i * 4;
const keep = 1 - expandedAlpha;
bgData[offset] = Math.round(bgData[offset] * keep + avgBg.r * expandedAlpha);
bgData[offset + 1] = Math.round(bgData[offset + 1] * keep + avgBg.g * expandedAlpha);
bgData[offset + 2] = Math.round(bgData[offset + 2] * keep + avgBg.b * expandedAlpha);
bgData[offset + 3] = 255;
}
const midData = applyMaskToRgba(baseRaw.data, width, height, (index) => {
const depth = depthData[index];
const fgAlpha = foregroundData[index * 4 + 3] / 255;
const frontMask = smoothstep(frontThreshold - 16, frontThreshold + 8, depth);
const midMask = smoothstep(midThreshold - 18, midThreshold + 10, depth) * (1 - frontMask);
return Math.round(255 * fgAlpha * midMask);
});
const fgData = applyMaskToRgba(baseRaw.data, width, height, (index) => {
const depth = depthData[index];
const fgAlpha = foregroundData[index * 4 + 3] / 255;
const frontMask = smoothstep(frontThreshold - 16, frontThreshold + 8, depth);
return Math.round(255 * fgAlpha * frontMask);
});
const bgPath = parallaxLayerPath(fileName, 'bg');
const midPath = parallaxLayerPath(fileName, 'mid');
const fgPath = parallaxLayerPath(fileName, 'fg');
const depthPath = parallaxLayerPath(fileName, 'depth');
await Promise.all([
writeRawRgbaPng(bgPath, width, height, bgData),
writeRawRgbaPng(midPath, width, height, midData),
writeRawRgbaPng(fgPath, width, height, fgData),
depthImage.save(depthPath),
fs.writeFile(
parallaxMetaPath(fileName),
JSON.stringify({ version: PARALLAX_VERSION, width, height }, null, 2),
'utf8',
),
]);
return {
bgUrl: pathToFileURL(bgPath).href,
midUrl: pathToFileURL(midPath).href,
fgUrl: pathToFileURL(fgPath).href,
depthUrl: pathToFileURL(depthPath).href,
width,
height,
};
}
async function getOrCreateParallaxAssets(fileName) {
const safeName = path.basename(fileName);
if (!parallaxTaskCache.has(safeName)) {
parallaxTaskCache.set(
safeName,
generateParallaxAssets(safeName).finally(() => {
parallaxTaskCache.delete(safeName);
}),
);
}
return parallaxTaskCache.get(safeName);
}
function migrateState(data) {
if (data && Array.isArray(data.library) && Array.isArray(data.lineup)) {
const library = data.library.map((p) => normalizeSavedLibraryPerson(p));
for (const entry of data.lineup) {
if (!entry || typeof entry !== 'object') continue;
if (!entry.personId || typeof entry.nameLabel !== 'string' || !entry.nameLabel) continue;
const person = library.find((item) => item.id === String(entry.personId));
if (person && !person.lastName) {
person.lastName = entry.nameLabel;
}
}
const slotLabels = Array.isArray(data.slotLabels)
? data.slotLabels.map((label) => (typeof label === 'string' ? label : ''))
: data.lineup.map((entry) => (entry && typeof entry.label === 'string' ? entry.label : ''));
const slotNames = Array.isArray(data.slotNames)
? data.slotNames.map((label) => (typeof label === 'string' ? label : ''))
: data.lineup.map((entry) => (entry && typeof entry.nameLabel === 'string' ? entry.nameLabel : ''));
return {
library,
layout: normalizeLayout(data.layout),
parallaxEnabled: typeof data.parallaxEnabled === 'boolean' ? data.parallaxEnabled : true,
slotLabels,
slotNames,
pcoServiceTypeId: typeof data.pcoServiceTypeId === 'string' ? data.pcoServiceTypeId : '',
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 {
personId: typeof e.personId === 'string' ? e.personId : '',
label: typeof e.label === 'string' ? e.label : '',
};
}),
};
}
if (data && Array.isArray(data.tiles)) {
const library = data.tiles.map((t) => ({
id: String(t.id),
fileName: String(t.fileName),
originalName: '',
lastLabel: typeof t.label === 'string' ? t.label : '',
lastName: '',
source: 'local',
pcoPersonId: '',
pcoPlanPersonId: '',
}));
const lineup = data.tiles.map((t) => ({
personId: String(t.id),
label: typeof t.label === 'string' ? t.label : '',
}));
return {
library,
layout: { columns: DEFAULT_COLUMNS, rows: DEFAULT_ROWS },
parallaxEnabled: true,
slotLabels: lineup.map((slot) => slot.label || ''),
slotNames: [],
lineup,
pcoServiceTypeId: '',
pcoDisplayPositions: [],
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
};
}
return emptyState();
}
async function loadStateRaw() {
try {
const raw = await fs.readFile(statePath(), 'utf8');
return migrateState(JSON.parse(raw));
} catch {
return emptyState();
}
}
async function saveStateRaw(data) {
await ensureDirs();
await fs.writeFile(statePath(), JSON.stringify(data, null, 2), 'utf8');
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 720,
minWidth: 800,
minHeight: 480,
backgroundColor: '#0f0f12',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.setAutoHideMenuBar(true);
mainWindow.loadFile('index.html');
mainWindow.on('closed', () => {
ppStop();
mainWindow = null;
});
const template = [
{
label: 'File',
submenu: [
{
label: 'Add photos…',
accelerator: 'CmdOrCtrl+O',
click: () => mainWindow.webContents.send('menu-add-photos'),
},
{ type: 'separator' },
{ role: 'quit' },
],
},
{
label: 'View',
submenu: [
{ role: 'togglefullscreen' },
{ role: 'reload' },
{ type: 'separator' },
{ role: 'toggleDevTools' },
],
},
{
label: 'Help',
submenu: [
{
label: 'About',
click: async () => {
const { readFileSync } = require('fs');
let buildNumber = '1';
try {
const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
buildNumber = String(pkg.buildNumber ?? pkg.version);
} catch {
/* ignore */
}
await dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'About Band Lineup',
message: 'Band Lineup',
detail: `Version ${app.getVersion()}\nBuild ${buildNumber}\n\nPhotos and labels are saved automatically on this computer.`,
});
},
},
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
if (gotTheLock) {
app.whenReady().then(async () => {
await ppLoadConfigFromDisk().catch(() => {});
await loadDisplayConfigFromDisk().catch(() => {});
createWindow();
if (displayConfig.enabled) {
await queueDisplayTransition(() => startDisplayServer());
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => {
// Best effort only: Electron does not wait for async work here. This gets the mDNS
// goodbye packet out and closes the listener politely; if we lose the race, the OS
// reclaims the port and viewers see "display offline" on their next poll anyway.
const current = displayServer;
displayServer = null;
if (current) void current.close().catch(() => {});
});
ipcMain.handle('add-images-dialog', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Add photos',
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] },
{ name: 'All', extensions: ['*'] },
],
});
if (canceled || !filePaths.length) return [];
const added = [];
for (const p of filePaths) {
const fileName = await copyImageToStore(p);
const id = crypto.randomBytes(12).toString('hex');
added.push({ id, fileName, originalName: originalDisplayName(p) });
}
return added;
});
ipcMain.handle('add-images-paths', async (_e, filePaths) => {
if (!Array.isArray(filePaths) || !filePaths.length) return [];
const added = [];
for (const p of filePaths) {
try {
if (!fsSync.existsSync(p)) continue;
const fileName = await copyImageToStore(p);
const id = crypto.randomBytes(12).toString('hex');
added.push({ id, fileName, originalName: originalDisplayName(p) });
} catch {
/* skip bad file */
}
}
return added;
});
ipcMain.handle('load-state', async () => {
await ensureDirs();
return loadStateRaw();
});
ipcMain.handle('save-state', async (_e, payload) => {
const library = Array.isArray(payload?.library) ? payload.library : [];
const lineup = Array.isArray(payload?.lineup) ? payload.lineup : [];
const slotLabels = Array.isArray(payload?.slotLabels) ? payload.slotLabels : [];
const slotNames = Array.isArray(payload?.slotNames) ? payload.slotNames : [];
const layout = normalizeLayout(payload?.layout);
const parallaxEnabled = typeof payload?.parallaxEnabled === 'boolean' ? payload.parallaxEnabled : true;
const pcoServiceTypeId = typeof payload?.pcoServiceTypeId === 'string' ? payload.pcoServiceTypeId : '';
const pcoDisplayPositions = normalizePcoDisplayPositions(payload?.pcoDisplayPositions);
const existing = await loadStateRaw();
const proPresenter = payload?.proPresenter
? normalizeProPresenterConfig(payload.proPresenter)
: normalizeProPresenterConfig(existing.proPresenter);
// 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 });
return true;
});
ipcMain.handle('delete-library-image', async (_e, fileName) => {
if (!fileName || typeof fileName !== 'string' || fileName.includes('..')) return false;
const full = path.join(imagesDir(), path.basename(fileName));
try {
await fs.unlink(full);
} catch {
/* already gone */
}
return true;
});
ipcMain.handle('image-url', async (_e, fileName) => {
if (!fileName || typeof fileName !== 'string') return '';
const safe = path.basename(fileName);
const full = path.join(imagesDir(), safe);
if (!fsSync.existsSync(full)) return '';
return pathToFileURL(full).href;
});
ipcMain.handle('parallax-assets', async (_e, fileName) => {
if (!fileName || typeof fileName !== 'string') return null;
try {
return await getOrCreateParallaxAssets(fileName);
} catch {
return null;
}
});
ipcMain.handle('pco-status', async () => {
const credentials = await loadPcoCredentials();
const data = await loadStateRaw();
return {
connected: Boolean(credentials?.clientId && credentials?.secret),
pcoServiceTypeId: typeof data?.pcoServiceTypeId === 'string' ? data.pcoServiceTypeId : '',
pcoDisplayPositions: normalizePcoDisplayPositions(data?.pcoDisplayPositions),
};
});
ipcMain.handle('pco-connect', async (_e, payload) => {
const clientId = String(payload?.clientId || '').trim();
const secret = String(payload?.secret || '').trim();
if (!clientId || !secret) {
throw new Error('Enter both the Planning Center client ID and secret.');
}
const credentials = { clientId, secret };
const serviceTypes = await listPcoServiceTypes(credentials);
await savePcoCredentials(credentials);
return { connected: true, serviceTypes };
});
ipcMain.handle('pco-disconnect', async () => {
await clearPcoCredentials();
return true;
});
ipcMain.handle('pco-service-types', async () => {
const credentials = await loadPcoCredentials();
if (!credentials) {
throw new Error('Planning Center is not connected yet.');
}
return listPcoServiceTypes(credentials);
});
ipcMain.handle('pco-position-options', async (_e, serviceTypeId) => {
const credentials = await loadPcoCredentials();
if (!credentials) {
throw new Error('Planning Center is not connected yet.');
}
return listPcoPositionOptions(serviceTypeId, credentials);
});
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,
host: ppConfig.host,
port: ppConfig.port || '',
reachable: ppClient.getViewModel().reachable,
hasPassword: Boolean(ppConfig.password),
playlistUuid: ppConfig.playlistUuid || '',
playlistName: ppConfig.playlistName || '',
};
});
ipcMain.handle('pp-playlists', async () => {
ppSyncClientConfig();
return ppClient.listPlaylists();
});
ipcMain.handle('pp-select-playlist', async (_e, payload) => {
const uuid = String(payload?.uuid || '');
const name = String(payload?.name || '');
ppConfig.playlistUuid = uuid;
ppConfig.playlistName = name;
// Resets the core's cached playlist so the next poll refetches the newly chosen one,
// and restarts the poll loop if it was already running.
ppClient.selectPlaylist({ uuid, name });
const state = await loadStateRaw();
state.proPresenter = {
enabled: ppConfig.enabled,
host: ppConfig.host,
port: ppConfig.port,
playlistUuid: uuid,
playlistName: name,
};
await saveStateRaw(state);
displayNotifyStatus();
return true;
});
ipcMain.handle('pp-connect', async (_e, payload) => {
const host = String(payload?.host || '').trim();
const portNum = Number.parseInt(String(payload?.port ?? ''), 10);
const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0;
const password = String(payload?.password || '');
if (!host || !port) {
throw new Error('Enter the ProPresenter host (IP) and port.');
}
const prevPlaylistUuid = ppConfig.playlistUuid || '';
const prevPlaylistName = ppConfig.playlistName || '';
ppConfig = { enabled: true, host, port, password, playlistUuid: prevPlaylistUuid, playlistName: prevPlaylistName };
ppSyncClientConfig();
try {
await ppClient.testConnection();
} catch (error) {
ppConfig.enabled = false;
ppSyncClientConfig();
throw error;
}
const state = await loadStateRaw();
state.proPresenter = { enabled: true, host, port, playlistUuid: prevPlaylistUuid, playlistName: prevPlaylistName };
await saveStateRaw(state);
await savePpPassword(password);
ppClient.reset();
ppStop();
ppStart();
displayNotifyStatus();
return { connected: true, host, port };
});
ipcMain.handle('pp-disconnect', async () => {
ppStop();
ppConfig = { ...ppConfig, enabled: false, password: '' }; // keep host/port for easy reconnect
ppClient.reset();
ppSyncClientConfig();
const state = await loadStateRaw();
state.proPresenter = {
enabled: false,
host: ppConfig.host,
port: ppConfig.port,
playlistUuid: ppConfig.playlistUuid,
playlistName: ppConfig.playlistName,
};
await saveStateRaw(state);
await clearPpPassword();
ppSendCleared();
displayNotifyStatus();
return true;
});
/** Version + build, for the toolbar label and for studio screens to confirm what they see. */
ipcMain.handle('app-version', async () => {
let build = '';
try {
// Present in the packaged asar as well as in a dev run.
build = String(require('./package.json').buildNumber || '');
} catch {
build = '';
}
return { version: app.getVersion(), build };
});
ipcMain.handle('display-status', async () => displayStatus());
ipcMain.handle('display-set-enabled', async (_e, enabled) => queueDisplayTransition(async () => {
displayConfig.enabled = Boolean(enabled);
await persistDisplayConfig();
if (displayConfig.enabled) return startDisplayServer();
return stopDisplayServer();
}));
ipcMain.handle('display-set-port', async (_e, value) => {
const portNum = Number.parseInt(String(value ?? ''), 10);
// Floor at 1024: below that needs elevation on macOS/Linux, and it is where a mistyped or
// coerced value (an empty keypad entry used to become "1") would silently land and take
// every bookmark down with it.
const port = Number.isFinite(portNum) && portNum >= 1024 && portNum <= 65535 ? portNum : 0;
if (!port) {
throw new Error('Enter a port number between 1024 and 65535.');
}
return queueDisplayTransition(async () => {
if (port === displayConfig.port) return displayStatus();
displayConfig.port = port;
await persistDisplayConfig();
if (displayServer) await stopDisplayServer();
// Key the restart off the SETTING, not off whether a server object exists. The usual
// reason to change the port is that the previous one was taken — in which case there is
// no server to stop, and the old code fell straight through without trying again,
// leaving the stale "port N is in use" error on screen forever.
if (displayConfig.enabled) return startDisplayServer();
return displayStatus();
});
});
// Goes through the main process on purpose: Electron's clipboard always works, while the
// renderer's navigator.clipboard depends on secure-context rules under a file:// origin.
ipcMain.handle('display-copy-url', async (_e, url) => {
clipboard.writeText(String(url || ''));
return true;
});
// Renderer calls this after attaching its 'pp-data' listener, so the first
// emit isn't missed when auto-reconnecting a saved connection on launch.
ipcMain.handle('pp-start', async () => {
if (!ppConfig.enabled || !ppConfig.host || !ppConfig.port) return false;
ppStop();
ppStart();
displayNotifyStatus();
return true;
});