Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882a418a0f | ||
|
|
0e618ea5d3 | ||
|
|
c8b51c6c2f | ||
|
|
c464e5b4ca | ||
|
|
f4d50f5e95 | ||
|
|
c73d0a3aac | ||
|
|
441cfe3882 |
@@ -328,7 +328,14 @@ below. Nothing is installed without someone clicking.
|
|||||||
description and item notes. When the ProPresenter module is running it
|
description and item notes. When the ProPresenter module is running it
|
||||||
follows that module's live item, highlights the matching plan item, dims
|
follows that module's live item, highlights the matching plan item, dims
|
||||||
what's done and shows the elapsed vs planned runtime and how far
|
what's done and shows the elapsed vs planned runtime and how far
|
||||||
ahead/behind the service is. Admin config is the connection: Planning
|
ahead/behind the service is. Items are matched by name — a document named
|
||||||
|
like the Planning Center item or its song, tags and all. The message is
|
||||||
|
the exception: a sermon's document is named after the sermon, so a live
|
||||||
|
document that reads like the plan itself — its date code, series title
|
||||||
|
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
|
||||||
|
message and lands on the plan's message item (an item titled Message,
|
||||||
|
Sermon or Teaching, else the first item under such a header). Admin
|
||||||
|
config is the connection: Planning
|
||||||
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
||||||
token), the service type (a list grouped by your Planning Center folders)
|
token), the service type (a list grouped by your Planning Center folders)
|
||||||
and an optional pinned plan. Everything about the display is per tile
|
and an optional pinned plan. Everything about the display is per tile
|
||||||
|
|||||||
@@ -57,9 +57,37 @@
|
|||||||
<option value="">Choose service type</option>
|
<option value="">Choose service type</option>
|
||||||
</select>
|
</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-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>
|
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pco-status" id="pco-status">Planning Center is not connected.</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">
|
<div class="pco-position-builder">
|
||||||
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
||||||
<option value="">Choose position</option>
|
<option value="">Choose position</option>
|
||||||
|
|||||||
+167
-1
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
|
|||||||
return payload || { data: [] };
|
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) {
|
async function pcoFetchAll(target, credentials, searchParams) {
|
||||||
let nextUrl = buildPcoUrl(target, searchParams).toString();
|
let nextUrl = buildPcoUrl(target, searchParams).toString();
|
||||||
const data = [];
|
const data = [];
|
||||||
@@ -275,6 +316,15 @@ function emptyState() {
|
|||||||
pcoDisplayPositions: [],
|
pcoDisplayPositions: [],
|
||||||
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
|
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
|
||||||
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
|
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
|
||||||
|
birthdayMode: { enabled: true, intensity: 'subtle' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */
|
||||||
|
function normalizeBirthdayMode(entry) {
|
||||||
|
return {
|
||||||
|
enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled),
|
||||||
|
intensity: entry?.intensity === 'full' ? 'full' : 'subtle',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,9 +358,26 @@ function normalizeSavedLibraryPerson(person) {
|
|||||||
source: person?.source === 'pco' ? 'pco' : 'local',
|
source: person?.source === 'pco' ? 'pco' : 'local',
|
||||||
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
||||||
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
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) {
|
function normalizeTeamName(value) {
|
||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
|
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(
|
const oldPcoFiles = new Set(
|
||||||
(Array.isArray(baseState.library) ? baseState.library : [])
|
(Array.isArray(baseState.library) ? baseState.library : [])
|
||||||
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
|
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
|
||||||
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
source: 'pco',
|
source: 'pco',
|
||||||
pcoPersonId: member.personId,
|
pcoPersonId: member.personId,
|
||||||
pcoPlanPersonId: member.planPersonId,
|
pcoPlanPersonId: member.planPersonId,
|
||||||
|
birthday: birthdayByPerson.get(member.personId) || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
const atWriteTime = await loadStateRaw();
|
const atWriteTime = await loadStateRaw();
|
||||||
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
|
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
|
||||||
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
|
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
|
||||||
|
nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
|
||||||
await saveStateRaw(nextState);
|
await saveStateRaw(nextState);
|
||||||
|
|
||||||
for (const fileName of oldPcoFiles) {
|
for (const fileName of oldPcoFiles) {
|
||||||
@@ -1222,6 +1305,7 @@ function migrateState(data) {
|
|||||||
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
|
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
|
||||||
proPresenter: normalizeProPresenterConfig(data.proPresenter),
|
proPresenter: normalizeProPresenterConfig(data.proPresenter),
|
||||||
displayServer: normalizeDisplayServerConfig(data.displayServer),
|
displayServer: normalizeDisplayServerConfig(data.displayServer),
|
||||||
|
birthdayMode: normalizeBirthdayMode(data.birthdayMode),
|
||||||
lineup: data.lineup.map((e) => {
|
lineup: data.lineup.map((e) => {
|
||||||
if (!e || typeof e !== 'object') return { personId: '', label: '' };
|
if (!e || typeof e !== 'object') return { personId: '', label: '' };
|
||||||
return {
|
return {
|
||||||
@@ -1432,7 +1516,10 @@ ipcMain.handle('save-state', async (_e, payload) => {
|
|||||||
// The renderer never edits this (it is changed through the display-* IPC handlers), so
|
// 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.
|
// always carry the stored value forward rather than letting a save blank it out.
|
||||||
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
|
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
|
||||||
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
|
const birthdayMode = payload?.birthdayMode
|
||||||
|
? normalizeBirthdayMode(payload.birthdayMode)
|
||||||
|
: normalizeBirthdayMode(existing.birthdayMode);
|
||||||
|
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
|
|||||||
return syncUpcomingPcoPlan(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 () => {
|
ipcMain.handle('pp-status', async () => {
|
||||||
return {
|
return {
|
||||||
enabled: ppConfig.enabled,
|
enabled: ppConfig.enabled,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "band-lineup-display",
|
"name": "band-lineup-display",
|
||||||
"productName": "Band Lineup",
|
"productName": "Band Lineup",
|
||||||
"version": "1.3.6",
|
"version": "1.3.10",
|
||||||
"buildNumber": "92",
|
"buildNumber": "96",
|
||||||
"description": "Stage lineup photos with mic/pack labels",
|
"description": "Stage lineup photos with mic/pack labels",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('lineup', {
|
|||||||
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
|
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
|
||||||
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
|
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
|
||||||
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
|
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
|
||||||
|
pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
|
||||||
onMenuAddPhotos: (cb) => {
|
onMenuAddPhotos: (cb) => {
|
||||||
ipcRenderer.on('menu-add-photos', () => cb());
|
ipcRenderer.on('menu-add-photos', () => cb());
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
|
|||||||
} else {
|
} else {
|
||||||
window.__bandLineupRendererLoaded = true;
|
window.__bandLineupRendererLoaded = true;
|
||||||
/**
|
/**
|
||||||
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string }} LibraryPerson
|
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
|
||||||
* @typedef {{ personId: string, label: string }} StageSlot
|
* @typedef {{ personId: string, label: string }} StageSlot
|
||||||
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
|
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +27,10 @@ let pcoDisplayPositions = [];
|
|||||||
let pcoPositionOptions = [];
|
let pcoPositionOptions = [];
|
||||||
let slotLabelMemory = [];
|
let slotLabelMemory = [];
|
||||||
let slotNameMemory = [];
|
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[]} */
|
/** @type {StageSlot[]} */
|
||||||
let stageSlots = createEmptySlots();
|
let stageSlots = createEmptySlots();
|
||||||
const imageUrlCache = new Map();
|
const imageUrlCache = new Map();
|
||||||
@@ -65,7 +69,13 @@ const btnApplyLayout = document.getElementById('btn-apply-layout');
|
|||||||
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
|
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
|
||||||
const btnPcoConnect = document.getElementById('btn-pco-connect');
|
const btnPcoConnect = document.getElementById('btn-pco-connect');
|
||||||
const btnPcoSync = document.getElementById('btn-pco-sync');
|
const btnPcoSync = document.getElementById('btn-pco-sync');
|
||||||
|
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
|
||||||
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
|
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 btnPcoAddPosition = document.getElementById('btn-pco-add-position');
|
||||||
const inputLayoutCols = document.getElementById('layout-cols');
|
const inputLayoutCols = document.getElementById('layout-cols');
|
||||||
const inputLayoutRows = document.getElementById('layout-rows');
|
const inputLayoutRows = document.getElementById('layout-rows');
|
||||||
@@ -244,6 +254,7 @@ function setPcoStatus(message, isError = false) {
|
|||||||
function setPcoControlsEnabled(enabled) {
|
function setPcoControlsEnabled(enabled) {
|
||||||
btnPcoConnect.disabled = !enabled;
|
btnPcoConnect.disabled = !enabled;
|
||||||
btnPcoSync.disabled = !enabled;
|
btnPcoSync.disabled = !enabled;
|
||||||
|
btnPcoTestBirthdays.disabled = !enabled;
|
||||||
btnPcoDisconnect.disabled = !enabled;
|
btnPcoDisconnect.disabled = !enabled;
|
||||||
btnPcoAddPosition.disabled = !enabled;
|
btnPcoAddPosition.disabled = !enabled;
|
||||||
pcoServiceTypeSelect.disabled = !enabled;
|
pcoServiceTypeSelect.disabled = !enabled;
|
||||||
@@ -427,6 +438,9 @@ function applyLoadedState(data) {
|
|||||||
source: person?.source === 'pco' ? 'pco' : 'local',
|
source: person?.source === 'pco' ? 'pco' : 'local',
|
||||||
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
||||||
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
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);
|
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
|
||||||
@@ -451,6 +465,11 @@ function applyLoadedState(data) {
|
|||||||
pcoServiceTypeSelect.value = pcoServiceTypeId;
|
pcoServiceTypeSelect.value = pcoServiceTypeId;
|
||||||
renderPcoDisplayPositions();
|
renderPcoDisplayPositions();
|
||||||
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
|
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() {
|
function updateSlotSizeReadout() {
|
||||||
@@ -528,6 +547,7 @@ function scheduleSave() {
|
|||||||
slotLabels: slotLabelMemory,
|
slotLabels: slotLabelMemory,
|
||||||
slotNames: slotNameMemory,
|
slotNames: slotNameMemory,
|
||||||
lineup: stageSlots,
|
lineup: stageSlots,
|
||||||
|
birthdayMode,
|
||||||
});
|
});
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
@@ -950,6 +970,132 @@ function parallaxMotionVars(seedText) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Birthday Mode ---------------- */
|
||||||
|
|
||||||
|
const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
|
||||||
|
/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */
|
||||||
|
const birthdayCanvases = new Set();
|
||||||
|
let birthdayRaf = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday
|
||||||
|
* anywhere in this window celebrates all week (so it's already up for the weekend services).
|
||||||
|
* Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday.
|
||||||
|
*/
|
||||||
|
let birthdayWeekCache = { key: '', set: null };
|
||||||
|
function birthdayWeekSet() {
|
||||||
|
const now = new Date();
|
||||||
|
const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
|
||||||
|
const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;
|
||||||
|
if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set;
|
||||||
|
const set = new Set();
|
||||||
|
for (let i = 0; i < 7; i += 1) {
|
||||||
|
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||||
|
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
birthdayWeekCache = { key, set };
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */
|
||||||
|
function isBirthdayActive(person) {
|
||||||
|
if (!birthdayMode.enabled || !person) return false;
|
||||||
|
if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true;
|
||||||
|
return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */
|
||||||
|
function birthdayTick() {
|
||||||
|
const before = birthdayWeekCache.key;
|
||||||
|
birthdayWeekSet();
|
||||||
|
if (before && birthdayWeekCache.key !== before) void renderStage();
|
||||||
|
}
|
||||||
|
setInterval(birthdayTick, 15 * 60 * 1000);
|
||||||
|
|
||||||
|
function birthdayMakeParticle(w, h, intensity, seedY) {
|
||||||
|
const ribbon = Math.random() < 0.55;
|
||||||
|
return {
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: seedY != null ? seedY : Math.random() * h,
|
||||||
|
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5,
|
||||||
|
h: ribbon ? 9 + Math.random() * 8 : 0,
|
||||||
|
ribbon,
|
||||||
|
color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0],
|
||||||
|
rot: Math.random() * Math.PI * 2,
|
||||||
|
vr: -0.14 + Math.random() * 0.28,
|
||||||
|
sway: 0.6 + Math.random() * 1.1,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdaySeedCanvas(c, w, h) {
|
||||||
|
const base = c._intensity === 'subtle' ? 26 : 64;
|
||||||
|
const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500)));
|
||||||
|
c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the
|
||||||
|
// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine).
|
||||||
|
function birthdayStepCanvas(c) {
|
||||||
|
const w = c.clientWidth;
|
||||||
|
const h = c.clientHeight;
|
||||||
|
if (!w || !h) return; // not laid out yet — try again next frame
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
if (c._w !== w || c._h !== h) {
|
||||||
|
c.width = w * dpr;
|
||||||
|
c.height = h * dpr;
|
||||||
|
c._ctx = c.getContext('2d');
|
||||||
|
c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
c._w = w;
|
||||||
|
c._h = h;
|
||||||
|
birthdaySeedCanvas(c, w, h);
|
||||||
|
}
|
||||||
|
const ctx = c._ctx;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
for (const p of c._parts) {
|
||||||
|
p.phase += 0.03;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.x += Math.sin(p.phase) * p.sway;
|
||||||
|
p.rot += p.vr;
|
||||||
|
if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16));
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate(p.rot);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||||
|
else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdayLoop() {
|
||||||
|
for (const c of [...birthdayCanvases]) {
|
||||||
|
if (!c.isConnected) { birthdayCanvases.delete(c); continue; }
|
||||||
|
birthdayStepCanvas(c);
|
||||||
|
}
|
||||||
|
birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdayRegisterCanvas(canvas, intensity) {
|
||||||
|
canvas._intensity = intensity;
|
||||||
|
birthdayCanvases.add(canvas);
|
||||||
|
if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */
|
||||||
|
function applyBirthdayEffects(tile, photoArea) {
|
||||||
|
const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle';
|
||||||
|
tile.classList.add('birthday', `birthday-${intensity}`);
|
||||||
|
|
||||||
|
const confetti = document.createElement('canvas');
|
||||||
|
confetti.className = 'birthday-confetti';
|
||||||
|
confetti.setAttribute('aria-hidden', 'true');
|
||||||
|
photoArea.appendChild(confetti);
|
||||||
|
birthdayRegisterCanvas(confetti, intensity);
|
||||||
|
}
|
||||||
|
|
||||||
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
||||||
void parallaxAssets;
|
void parallaxAssets;
|
||||||
const tile = document.createElement('article');
|
const tile = document.createElement('article');
|
||||||
@@ -1002,6 +1148,7 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
|||||||
|
|
||||||
photoArea.appendChild(nameBadge);
|
photoArea.appendChild(nameBadge);
|
||||||
photoArea.appendChild(removeBtn);
|
photoArea.appendChild(removeBtn);
|
||||||
|
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
|
||||||
tile.appendChild(photoArea);
|
tile.appendChild(photoArea);
|
||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
@@ -1156,6 +1303,7 @@ async function renderLibrary() {
|
|||||||
async function renderAll() {
|
async function renderAll() {
|
||||||
await renderStage();
|
await renderStage();
|
||||||
await renderLibrary();
|
await renderLibrary();
|
||||||
|
renderBirthdayTestOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importNewPeople(added) {
|
async function importNewPeople(added) {
|
||||||
@@ -1170,6 +1318,7 @@ async function importNewPeople(added) {
|
|||||||
source: 'local',
|
source: 'local',
|
||||||
pcoPersonId: '',
|
pcoPersonId: '',
|
||||||
pcoPlanPersonId: '',
|
pcoPlanPersonId: '',
|
||||||
|
birthday: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await renderLibrary();
|
await renderLibrary();
|
||||||
@@ -1232,6 +1381,97 @@ async function syncUpcomingPlanFromPco() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
function formatBirthdayMmdd(mmdd) {
|
||||||
|
const parts = String(mmdd || '').split('-');
|
||||||
|
const mm = Number(parts[0]);
|
||||||
|
const dd = Number(parts[1]);
|
||||||
|
if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return '';
|
||||||
|
return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPcoBirthdayResult(message, tone) {
|
||||||
|
if (!pcoBirthdayResult) return;
|
||||||
|
pcoBirthdayResult.textContent = message || '';
|
||||||
|
pcoBirthdayResult.style.display = message ? '' : 'none';
|
||||||
|
pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPcoBirthdays() {
|
||||||
|
setPcoControlsEnabled(false);
|
||||||
|
setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info');
|
||||||
|
try {
|
||||||
|
const r = await window.lineup.pcoTestBirthdays();
|
||||||
|
if (!r || r.connected === false) {
|
||||||
|
setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.canRead) {
|
||||||
|
const why = r.reason === 'network'
|
||||||
|
? `Couldn't reach Planning Center (${r.detail || 'network error'}).`
|
||||||
|
: `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`;
|
||||||
|
setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Access works. Report coverage across synced band members.
|
||||||
|
if (r.total === 0) {
|
||||||
|
setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`];
|
||||||
|
if (r.nextUp) {
|
||||||
|
const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`;
|
||||||
|
bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`);
|
||||||
|
}
|
||||||
|
if (r.notFound) bits.push(`${r.notFound} not matched in People.`);
|
||||||
|
setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info');
|
||||||
|
} catch (error) {
|
||||||
|
setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
setPcoControlsEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Birthday Mode controls ---------------- */
|
||||||
|
|
||||||
|
/** Reflect current birthdayMode settings onto the controls (called after load). */
|
||||||
|
function syncBirthdayControls() {
|
||||||
|
if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled;
|
||||||
|
if (bdayIntensity) {
|
||||||
|
for (const b of bdayIntensity.querySelectorAll('button')) {
|
||||||
|
b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */
|
||||||
|
function renderBirthdayTestOptions() {
|
||||||
|
if (!bdayTestSelect) return;
|
||||||
|
const previous = birthdayTestPersonId;
|
||||||
|
bdayTestSelect.innerHTML = '<option value="">Off</option>';
|
||||||
|
const seen = new Set();
|
||||||
|
stageSlots.forEach((slot, index) => {
|
||||||
|
if (!slot.personId || seen.has(slot.personId)) return;
|
||||||
|
const person = getPerson(slot.personId);
|
||||||
|
if (!person) return;
|
||||||
|
seen.add(slot.personId);
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = person.id;
|
||||||
|
const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim();
|
||||||
|
const label = String(slotLabelMemory[index] || '').trim();
|
||||||
|
option.textContent = label ? `${name} — ${label}` : name;
|
||||||
|
bdayTestSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
// Keep the current override selected if that person is still on stage; otherwise drop it.
|
||||||
|
if (previous && seen.has(previous)) {
|
||||||
|
bdayTestSelect.value = previous;
|
||||||
|
} else if (previous) {
|
||||||
|
birthdayTestPersonId = '';
|
||||||
|
bdayTestSelect.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Setup panel tabs ---------------- */
|
/* ---------------- Setup panel tabs ---------------- */
|
||||||
|
|
||||||
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
|
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
|
||||||
@@ -1257,6 +1497,7 @@ function activatePanelTab(name) {
|
|||||||
}
|
}
|
||||||
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
|
// 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 === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
|
||||||
|
if (name === 'pco') renderBirthdayTestOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
|
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
|
||||||
@@ -1733,6 +1974,36 @@ btnPcoSync.addEventListener('click', () => {
|
|||||||
void syncUpcomingPlanFromPco();
|
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 () => {
|
btnPcoDisconnect.addEventListener('click', async () => {
|
||||||
revealChrome();
|
revealChrome();
|
||||||
setPcoControlsEnabled(false);
|
setPcoControlsEnabled(false);
|
||||||
@@ -1744,6 +2015,7 @@ btnPcoDisconnect.addEventListener('click', async () => {
|
|||||||
renderPcoPositionOptions();
|
renderPcoPositionOptions();
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
setPcoStatus('Planning Center disconnected.');
|
setPcoStatus('Planning Center disconnected.');
|
||||||
|
setPcoBirthdayResult('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
|
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -331,6 +331,105 @@ body {
|
|||||||
color: #9ea7bc;
|
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 {
|
.pco-position-builder {
|
||||||
display: none;
|
display: none;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -624,6 +723,33 @@ body {
|
|||||||
font-weight: 500;
|
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 {
|
.tile .remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Title matching for the PCO Plan module — the one answer to "is this
|
* Title matching for the PCO Plan module — the one answer to "is this
|
||||||
* ProPresenter document that Planning Center thing?", used twice:
|
* ProPresenter document that Planning Center thing?", used three ways:
|
||||||
*
|
*
|
||||||
* - mapping the document ProPresenter has live to a plan item (the
|
* - mapping the document ProPresenter has live to a plan item (the
|
||||||
* highlight and the item runtimes), and
|
* highlight and the item runtimes) — placeDocument(), below,
|
||||||
|
* - recognising the message document, which is named after the sermon
|
||||||
|
* and never after its plan item — the message rule, below, and
|
||||||
* - recognising the documents an admin picked as the service start / end
|
* - recognising the documents an admin picked as the service start / end
|
||||||
* cues (see service-timing.js).
|
* cues (see service-timing.js).
|
||||||
*
|
*
|
||||||
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
|
|||||||
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
|
/* ── the message rule ──────────────────────────────────────────────────
|
||||||
|
A sermon's ProPresenter document is named after the sermon, never after
|
||||||
|
the plan item that stands for it ("Message", "Resi Message"), so no name
|
||||||
|
comparison can place it — and the previous song stayed "current" through
|
||||||
|
a whole message. What the document's name does carry is the plan's own
|
||||||
|
identity. Waters names them "YYMMDD Series Title - Part #": the plan's
|
||||||
|
date, its series title and its title. A live document that reads like
|
||||||
|
the plan is the plan's message, and lands on the plan's message item,
|
||||||
|
however that item happens to be titled. */
|
||||||
|
|
||||||
|
const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i;
|
||||||
|
const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i;
|
||||||
|
/** Undated, the name must read like "Series Title Part #" at least this closely… */
|
||||||
|
const MESSAGE_THRESHOLD = 0.75;
|
||||||
|
/** …dated with this plan's date, it need only resemble the series or the title. */
|
||||||
|
const DATED_MESSAGE_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */
|
||||||
|
function leadingDateCode(raw) {
|
||||||
|
const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || ''));
|
||||||
|
if (!m) return null;
|
||||||
|
const code = m[1];
|
||||||
|
const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2));
|
||||||
|
const mo = Number(code.slice(-4, -2));
|
||||||
|
const d = Number(code.slice(-2));
|
||||||
|
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||||
|
return { y, m: mo, d };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The local calendar day of an epoch-ms instant, in the same shape. */
|
||||||
|
function dayOf(ms) {
|
||||||
|
const t = new Date(Number(ms));
|
||||||
|
if (!Number.isFinite(t.getTime())) return null;
|
||||||
|
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDay(a, b) {
|
||||||
|
return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this live document read like the plan itself — "260927 Where is Your
|
||||||
|
* Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated
|
||||||
|
* 27 Sept 2026? Returns { score, dated } or null.
|
||||||
|
*
|
||||||
|
* A date code naming another day is a firm no: last week's sermon is not
|
||||||
|
* this plan's message. A date code naming the plan's day is strong evidence,
|
||||||
|
* so the rest need only resemble the series or the title. Without a date
|
||||||
|
* the name must read like series + title together — and carry the title
|
||||||
|
* when the plan has both, so a series bumper ("Where is Your Faith?") is
|
||||||
|
* not taken for the sermon.
|
||||||
|
*/
|
||||||
|
function messageDocumentScore(name, plan) {
|
||||||
|
const series = String(plan?.seriesTitle || '').trim();
|
||||||
|
const title = String(plan?.title || '').trim();
|
||||||
|
if (!series && !title) return null;
|
||||||
|
const code = leadingDateCode(name);
|
||||||
|
const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null;
|
||||||
|
if (code && planDay && !sameDay(code, planDay)) return null;
|
||||||
|
const dated = Boolean(code && planDay);
|
||||||
|
const full = [series, title].filter(Boolean).join(' ');
|
||||||
|
const fullScore = similarity(name, full);
|
||||||
|
if (dated) {
|
||||||
|
const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0);
|
||||||
|
return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null;
|
||||||
|
}
|
||||||
|
if (fullScore < MESSAGE_THRESHOLD) return null;
|
||||||
|
if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null;
|
||||||
|
return { score: fullScore, dated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan item that stands for the message: an item titled with the word
|
||||||
|
* ("Message", "Resi Message" — a bare "Message" first), else the first item
|
||||||
|
* under a header so titled (a "Sermon" section whose one item is the sermon's
|
||||||
|
* own title). Null when the plan has no such slot.
|
||||||
|
*/
|
||||||
|
function messageItemOf(items) {
|
||||||
|
let best = null;
|
||||||
|
let inSection = false;
|
||||||
|
for (const it of Array.isArray(items) ? items : []) {
|
||||||
|
if (!it) continue;
|
||||||
|
const title = String(it.title || '');
|
||||||
|
if (it.itemType === 'header') {
|
||||||
|
inSection = MESSAGE_WORDS.test(title);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0;
|
||||||
|
if (rank && (!best || rank > best.rank)) best = { item: it, rank };
|
||||||
|
if (rank === 3) break;
|
||||||
|
}
|
||||||
|
return best ? best.item : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does ProPresenter's playlist mirror the plan item for item? ProPresenter
|
||||||
|
* builds a Planning Center playlist that way — every plan item, headers
|
||||||
|
* included, each flagged as PCO — until an operator adds something of their
|
||||||
|
* own (a song tag, say), which shifts every position after it. Same length,
|
||||||
|
* headers in the same places and every item PCO-flagged is the test.
|
||||||
|
*/
|
||||||
|
function playlistMirrorsPlan(ppItems, items) {
|
||||||
|
if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false;
|
||||||
|
return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best plan item by title, the ones ahead of the current item winning ties. */
|
||||||
|
function bestByName(name, items, all, ctx) {
|
||||||
|
const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId);
|
||||||
|
const hint = ctx?.hint;
|
||||||
|
const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null;
|
||||||
|
let best = null;
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const item = items[i];
|
||||||
|
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
||||||
|
if (score < MATCH_THRESHOLD) continue;
|
||||||
|
// Distance ahead of the current item (wrapping so items behind rank last).
|
||||||
|
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
||||||
|
const atPosition = Boolean(wanted && item.id === wanted.id);
|
||||||
|
const candidate = { item, score, dist, atPosition };
|
||||||
|
if (!best || score > best.score + 0.001) {
|
||||||
|
best = candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Math.abs(score - best.score) > 0.001) continue;
|
||||||
|
if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate;
|
||||||
|
}
|
||||||
|
return best ? { item: best.item, score: best.score, via: 'name' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a live ProPresenter document sits in the plan: { item, score, via }
|
||||||
|
* or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional)
|
||||||
|
* is { index, mirror } — the document's position in the ProPresenter
|
||||||
|
* playlist and whether that playlist mirrors the plan (playlistMirrorsPlan).
|
||||||
|
*
|
||||||
|
* By name first: the item (or song) title most like the document's name,
|
||||||
|
* the ones ahead of the current item winning ties — a near miss lands on the
|
||||||
|
* right row. Then the message rule: a document that reads like the plan
|
||||||
|
* itself lands on the plan's message item. It overrides a name hit that is
|
||||||
|
* anything short of identical — a song called "Faith" must not catch
|
||||||
|
* "260927 Where is Your Faith? - Part 6" — outright when the document is
|
||||||
|
* dated with the plan's day, and when it reads better otherwise. Position
|
||||||
|
* is never a match on its own: it only breaks a tie between equally good
|
||||||
|
* names, and only while the playlist mirrors the plan.
|
||||||
|
*/
|
||||||
|
function placeDocument(name, ctx) {
|
||||||
|
const all = Array.isArray(ctx?.items) ? ctx.items : [];
|
||||||
|
const items = all.filter((it) => it && it.itemType !== 'header');
|
||||||
|
if (!items.length) return null;
|
||||||
|
const byName = bestByName(name, items, all, ctx);
|
||||||
|
const asMessage = messageDocumentScore(name, ctx?.plan);
|
||||||
|
const messageItem = asMessage ? messageItemOf(all) : null;
|
||||||
|
if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) {
|
||||||
|
return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' };
|
||||||
|
}
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MATCH_THRESHOLD,
|
||||||
|
TRIGGER_THRESHOLD,
|
||||||
|
MESSAGE_THRESHOLD,
|
||||||
|
cleanTitle,
|
||||||
|
normalize,
|
||||||
|
similarity,
|
||||||
|
isCue,
|
||||||
|
leadingDateCode,
|
||||||
|
messageDocumentScore,
|
||||||
|
messageItemOf,
|
||||||
|
playlistMirrorsPlan,
|
||||||
|
placeDocument,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the PCO Plan module's document matching — placing what
|
||||||
|
* ProPresenter has live onto a plan item, and the message rule.
|
||||||
|
*
|
||||||
|
* node modules/pco-plan/matching.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies; exits non-zero on the first failure. The two
|
||||||
|
* plans are the campuses' real 27 Sept 2026 plans and ProPresenter playlists
|
||||||
|
* as they stood on 22 Sept, so the expectations read like a Sunday would.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const {
|
||||||
|
MESSAGE_THRESHOLD,
|
||||||
|
leadingDateCode,
|
||||||
|
messageDocumentScore,
|
||||||
|
messageItemOf,
|
||||||
|
playlistMirrorsPlan,
|
||||||
|
placeDocument,
|
||||||
|
} = require('./matching');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed += 1;
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` FAIL ${name}\n ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUNDAY = new Date(2026, 8, 27, 9, 30).getTime(); // 27 Sept 2026, 9:30 local — the plan's sort date
|
||||||
|
const PLAN = { id: '91274523', title: 'Part 6', seriesTitle: 'Where is Your Faith?', sortDate: SUNDAY };
|
||||||
|
|
||||||
|
const header = (id, title) => ({ id, itemType: 'header', title, song: null });
|
||||||
|
const item = (id, title, itemType = 'item', song = null) => ({ id, itemType, title, song: song ? { title: song } : null });
|
||||||
|
const pp = (name, type = 'presentation', isPco = true) => ({ name, type, isPco, isHidden: false });
|
||||||
|
|
||||||
|
// North Attleboro — the message is an item called "Message", linked in
|
||||||
|
// ProPresenter to the bumper video; the sermon slides are their own document.
|
||||||
|
const NA = [
|
||||||
|
header('h0', 'Doors Open'),
|
||||||
|
item('1', 'Preservice'),
|
||||||
|
item('2', '5 Minute Countdown', 'media'),
|
||||||
|
item('3', 'MC Announcements', 'media'),
|
||||||
|
header('h4', 'Worship'),
|
||||||
|
item('5', 'Call To Worship', 'media'),
|
||||||
|
item('6', 'Alleluia', 'song', 'Alleluia'),
|
||||||
|
item('7', 'What a God', 'song', 'What a God'),
|
||||||
|
item('8', 'Baptisms', 'media'),
|
||||||
|
item('9', 'Promised Land', 'song', 'Promised Land'),
|
||||||
|
item('10', 'Be Praised', 'song', 'Be Praised'),
|
||||||
|
header('h11', 'Message'),
|
||||||
|
item('12', 'Message'),
|
||||||
|
item('13', 'Standing Salvation Moment', 'media'),
|
||||||
|
header('h14', 'Closing'),
|
||||||
|
item('15', 'Postservice', 'song', '-Postservice'),
|
||||||
|
];
|
||||||
|
const NA_PP = [
|
||||||
|
pp('Doors Open', 'header'), pp('Preservice'), pp('5 Minute Countdown'), pp('MC Announcements'),
|
||||||
|
pp('Worship', 'header'), pp('Call To Worship'), pp('Alleluia - [ Full ]'), pp('What a God - [ Full ]'),
|
||||||
|
pp('Baptisms'), pp('Promised Land - [ Full ]'), pp('Be Praised - [ Full ]'),
|
||||||
|
pp('Message', 'header'), pp('Message Bumper'), pp('Standing Salvation Moment'),
|
||||||
|
pp('Closing', 'header'), pp('Postservice'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apollo Beach — the message is the media item "Resi Message".
|
||||||
|
const AB = [
|
||||||
|
item('0', 'Pre Service Meeting', 'media'),
|
||||||
|
header('h1', 'Doors Open'),
|
||||||
|
item('2', 'Pre Service', 'media'),
|
||||||
|
item('3', '5 Minute Countdown', 'media'),
|
||||||
|
item('4', 'MC Announcements', 'media'),
|
||||||
|
header('h5', 'Worship'),
|
||||||
|
item('6', 'Call To Worship', 'media'),
|
||||||
|
item('7', 'Alleluia', 'song', 'Alleluia'),
|
||||||
|
item('8', 'What a God', 'song', 'What a God'),
|
||||||
|
item('9', 'The Blood', 'song', 'The Blood'),
|
||||||
|
item('10', 'Be Praised (feat. Kymberli Joye)', 'song', 'Be Praised'),
|
||||||
|
header('h11', 'Message'),
|
||||||
|
item('12', 'Resi Message', 'media'),
|
||||||
|
item('13', 'Standing Salvation Moment', 'media'),
|
||||||
|
header('h14', 'Closing'),
|
||||||
|
item('15', 'Post Service', 'media'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERMON = '260927 Where is Your Faith? - Part 6';
|
||||||
|
const place = (name, items, extra = {}) => placeDocument(name, { items, plan: PLAN, currentItemId: '', ...extra });
|
||||||
|
const titleOf = (hit) => (hit ? hit.item.title : null);
|
||||||
|
|
||||||
|
console.log('date codes');
|
||||||
|
|
||||||
|
test('YYMMDD and YYYYMMDD lead a document name; anything else is not a date', () => {
|
||||||
|
assert.deepStrictEqual(leadingDateCode(SERMON), { y: 2026, m: 9, d: 27 });
|
||||||
|
assert.deepStrictEqual(leadingDateCode('20260927 Where is Your Faith? - Part 6'), { y: 2026, m: 9, d: 27 });
|
||||||
|
assert.strictEqual(leadingDateCode('9-27-26 Where is Your Faith?'), null);
|
||||||
|
assert.strictEqual(leadingDateCode('1234567 seven digits'), null);
|
||||||
|
assert.strictEqual(leadingDateCode('261340 no such month'), null);
|
||||||
|
assert.strictEqual(leadingDateCode('Alleluia'), null);
|
||||||
|
assert.strictEqual(leadingDateCode(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('the message rule');
|
||||||
|
|
||||||
|
test('a document named like the plan, dated with its day, is the message', () => {
|
||||||
|
const hit = messageDocumentScore(SERMON, PLAN);
|
||||||
|
assert.ok(hit, 'recognised');
|
||||||
|
assert.strictEqual(hit.dated, true);
|
||||||
|
assert.strictEqual(hit.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("last week's document is not this plan's message", () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 5', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 6', PLAN), null, 'even with the right part: the date names another day');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undated, series and part together still read as the message', () => {
|
||||||
|
const plain = messageDocumentScore('Where is Your Faith? - Part 6', PLAN);
|
||||||
|
assert.ok(plain && plain.dated === false && plain.score === 1);
|
||||||
|
const tagged = messageDocumentScore('Where Is Your Faith - Part 6 - [ Notes ]', PLAN);
|
||||||
|
assert.ok(tagged && tagged.score >= MESSAGE_THRESHOLD, 'tags and punctuation do not matter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a series bumper is not the sermon', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('Where is Your Faith?', PLAN), null, 'the series alone, undated');
|
||||||
|
assert.strictEqual(messageDocumentScore('Where is Your Faith? Bumper', PLAN), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dated with the plan's day, the part alone is enough", () => {
|
||||||
|
const hit = messageDocumentScore('260927 Part 6', PLAN);
|
||||||
|
assert.ok(hit && hit.dated);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dated document about something else is not the message', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('260927 Announcements', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('260927 Baptism Video', PLAN), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs are not the message', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('What a God - [ Full ]', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('Alleluia', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('Message Bumper', PLAN), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan with neither series nor title has no message document', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore(SERMON, { sortDate: SUNDAY }), null);
|
||||||
|
assert.strictEqual(messageDocumentScore(SERMON, null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan without a date still recognises its own name', () => {
|
||||||
|
const hit = messageDocumentScore(SERMON, { title: 'Part 6', seriesTitle: 'Where is Your Faith?' });
|
||||||
|
assert.ok(hit && hit.dated === false && hit.score === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('the message item');
|
||||||
|
|
||||||
|
test('an item called Message is the message item; so is Resi Message', () => {
|
||||||
|
assert.strictEqual(messageItemOf(NA).id, '12');
|
||||||
|
assert.strictEqual(messageItemOf(AB).id, '12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bare "Message" beats an item that only contains the word', () => {
|
||||||
|
const withBumper = [...NA.slice(0, 12), item('12a', 'Message Bumper', 'media'), ...NA.slice(12)];
|
||||||
|
assert.strictEqual(messageItemOf(withBumper).id, '12');
|
||||||
|
assert.strictEqual(messageItemOf([item('a', 'Message Bumper', 'media')]).id, 'a', 'alone, it is the slot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('under a Sermon header, the first item is the message whatever it is called', () => {
|
||||||
|
const plan = [header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia'), header('s', 'Sermon'), item('2', 'Living Hope'), item('3', 'Response')];
|
||||||
|
assert.strictEqual(messageItemOf(plan).id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan without a message slot has none', () => {
|
||||||
|
assert.strictEqual(messageItemOf([header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia')]), null);
|
||||||
|
assert.strictEqual(messageItemOf([header('m', 'Message')]), null, 'a header with nothing under it');
|
||||||
|
assert.strictEqual(messageItemOf([]), null);
|
||||||
|
assert.strictEqual(messageItemOf(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('the playlist mirror');
|
||||||
|
|
||||||
|
test("ProPresenter's Planning Center playlist mirrors the plan, headers and all", () => {
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(NA_PP, NA), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item the operator added, a header out of place or a non-PCO item breaks the mirror', () => {
|
||||||
|
const extra = [...NA_PP.slice(0, 10), pp('The Blood TAG'), ...NA_PP.slice(10)];
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(extra, NA), false, 'a song tag shifts everything after it');
|
||||||
|
const shifted = [...NA_PP];
|
||||||
|
[shifted[3], shifted[4]] = [shifted[4], shifted[3]];
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(shifted, NA), false);
|
||||||
|
const local = NA_PP.map((x, i) => (i === 8 ? pp('Baptisms', 'presentation', false) : x));
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(local, NA), false);
|
||||||
|
assert.strictEqual(playlistMirrorsPlan([], []), false);
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(null, NA), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('placing what is live');
|
||||||
|
|
||||||
|
test('songs and media land by name, tags and all', () => {
|
||||||
|
const god = place('What a God - [ Full ]', NA);
|
||||||
|
assert.strictEqual(titleOf(god), 'What a God');
|
||||||
|
assert.strictEqual(god.via, 'name');
|
||||||
|
assert.strictEqual(titleOf(place('Resi Message - [ MEDIA ]', AB)), 'Resi Message');
|
||||||
|
assert.strictEqual(titleOf(place('Greatly To Be Praised - [ Full ]', AB)), 'Be Praised (feat. Kymberli Joye)', 'by the song title');
|
||||||
|
assert.strictEqual(titleOf(place('Pre Service - [ Sunday ]', AB)), 'Pre Service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the sermon document lands on the message item at both campuses', () => {
|
||||||
|
const na = place(SERMON, NA);
|
||||||
|
assert.strictEqual(titleOf(na), 'Message');
|
||||||
|
assert.strictEqual(na.via, 'message document, dated');
|
||||||
|
assert.strictEqual(titleOf(place(SERMON, AB)), 'Resi Message');
|
||||||
|
assert.strictEqual(place('Where is Your Faith? - Part 6', NA).via, 'message document', 'undated still lands, and says so');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the bumper lands on the message item too, by name', () => {
|
||||||
|
const hit = place('Message Bumper', NA);
|
||||||
|
assert.strictEqual(titleOf(hit), 'Message');
|
||||||
|
assert.strictEqual(hit.via, 'name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a song called Faith does not catch the sermon', () => {
|
||||||
|
const trap = [...NA.slice(0, 11), item('f', 'Faith', 'song', 'Faith'), ...NA.slice(11)];
|
||||||
|
assert.strictEqual(titleOf(place('Faith - [ Full ]', trap)), 'Faith', 'the song itself still lands');
|
||||||
|
assert.strictEqual(titleOf(place(SERMON, trap)), 'Message', 'dated: the message wins outright');
|
||||||
|
assert.strictEqual(titleOf(place('Where is Your Faith? - Part 6', trap)), 'Message', 'undated: it reads better than the song');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item titled exactly like the document wins over the message slot', () => {
|
||||||
|
const titled = [...NA.slice(0, 12), item('s', 'Where is Your Faith? - Part 6'), ...NA.slice(12)];
|
||||||
|
assert.strictEqual(place(SERMON, titled).item.id, 's');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan without a message slot leaves the sermon document unplaced', () => {
|
||||||
|
const noSlot = NA.filter((it) => it.id !== '12' && it.id !== 'h11');
|
||||||
|
assert.strictEqual(place(SERMON, noSlot), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unplanned document, or last week's sermon, lands nowhere", () => {
|
||||||
|
assert.strictEqual(place('Offering Slide', NA), null);
|
||||||
|
assert.strictEqual(place('260920 Where is Your Faith? - Part 5', NA), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing to place on an empty plan', () => {
|
||||||
|
assert.strictEqual(place(SERMON, []), null);
|
||||||
|
assert.strictEqual(place(SERMON, [header('h', 'Message')]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same song twice: a reprise after "What a God".
|
||||||
|
const REPRISE = [...NA.slice(0, 8), item('6b', 'Alleluia', 'song', 'Alleluia'), ...NA.slice(8)];
|
||||||
|
const REPRISE_PP = [...NA_PP.slice(0, 8), pp('Alleluia - [ Reprise ]'), ...NA_PP.slice(8)];
|
||||||
|
|
||||||
|
test('a tie between equal names goes to the one ahead of the current item', () => {
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE).item.id, '6', 'nothing current yet: the first');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7' }).item.id, '6b', 'after What a God: the reprise');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '6b' }).item.id, '6b', 'the current one keeps a tie');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position breaks a tie only while the playlist mirrors the plan', () => {
|
||||||
|
const mirror = playlistMirrorsPlan(REPRISE_PP, REPRISE);
|
||||||
|
assert.strictEqual(mirror, true);
|
||||||
|
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror } }).item.id, '6b');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { hint: { index: 6, mirror } }).item.id, '6');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7', hint: { index: 6, mirror } }).item.id, '6', 'position wins the tie over "ahead"');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror: false } }).item.id, '6', 'no mirror: back to "ahead"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position never places a document on its own', () => {
|
||||||
|
const mirror = playlistMirrorsPlan(NA_PP, NA);
|
||||||
|
assert.strictEqual(place('Offering Slide', NA, { hint: { index: 8, mirror } }), null);
|
||||||
|
assert.strictEqual(titleOf(place('What a God - [ Full ]', NA, { hint: { index: 12, mirror } })), 'What a God', 'a wrong position cannot move a clear name');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} tests passed`);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service from Planning Center Services",
|
"description": "Order of service from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
+65
-30
@@ -33,8 +33,12 @@
|
|||||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||||
* this module reads the propresenter-now-next module's own SSE feed over
|
* this module reads the propresenter-now-next module's own SSE feed over
|
||||||
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
||||||
* matches the live presentation name to a plan item. Item start/end
|
* places the live document on a plan item (matching.js placeDocument): by
|
||||||
* timestamps are tracked here, once, so every tile agrees on the runtime.
|
* name, or — for the sermon, whose document is named after the sermon and
|
||||||
|
* never after its plan item — by reading like the plan itself ("YYMMDD
|
||||||
|
* Series Title - Part #"), which lands it on the plan's message item. Item
|
||||||
|
* start/end timestamps are tracked here, once, so every tile agrees on the
|
||||||
|
* runtime.
|
||||||
*
|
*
|
||||||
* Service timing: a plan's services (9:00, 11:00…) take turns owning the
|
* Service timing: a plan's services (9:00, 11:00…) take turns owning the
|
||||||
* service clock — the rules live in service-timing.js. A service starts at
|
* service clock — the rules live in service-timing.js. A service starts at
|
||||||
@@ -52,7 +56,7 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { MATCH_THRESHOLD, similarity } = require('./matching');
|
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||||||
const {
|
const {
|
||||||
MINUTE,
|
MINUTE,
|
||||||
serviceTimes,
|
serviceTimes,
|
||||||
@@ -434,6 +438,8 @@ module.exports = {
|
|||||||
let inFlight = null;
|
let inFlight = null;
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
let lastTimersSig = '';
|
let lastTimersSig = '';
|
||||||
|
let lastMessageItemId = null;
|
||||||
|
let lastMatchError = '';
|
||||||
|
|
||||||
/* — broadcasting — */
|
/* — broadcasting — */
|
||||||
|
|
||||||
@@ -582,6 +588,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
state.plan = plan;
|
state.plan = plan;
|
||||||
state.items = items;
|
state.items = items;
|
||||||
|
noteMessageItem(items);
|
||||||
state.reachable = true;
|
state.reachable = true;
|
||||||
state.lastError = '';
|
state.lastError = '';
|
||||||
state.lastFetched = Date.now();
|
state.lastFetched = Date.now();
|
||||||
@@ -596,6 +603,17 @@ module.exports = {
|
|||||||
syncServiceSafe(Date.now(), null);
|
syncServiceSafe(Date.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Say, once per plan, which item a document named like the plan will land on. */
|
||||||
|
function noteMessageItem(items) {
|
||||||
|
const msg = messageItemOf(items);
|
||||||
|
const id = msg ? msg.id : '';
|
||||||
|
if (id === lastMessageItemId) return;
|
||||||
|
lastMessageItemId = id;
|
||||||
|
log(msg
|
||||||
|
? `message item: "${msg.title}" — a live document named like this plan (its date, series and part) lands here`
|
||||||
|
: 'no message item in this plan — a document named like the plan has nowhere to land');
|
||||||
|
}
|
||||||
|
|
||||||
/* — item note categories (a tile's "Item notes" → Categories switches) — */
|
/* — item note categories (a tile's "Item notes" → Categories switches) — */
|
||||||
|
|
||||||
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
|
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
|
||||||
@@ -996,36 +1014,29 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
lastLiveName = keep.activeName || '';
|
lastLiveName = keep.activeName || '';
|
||||||
if (follow && keep.activeName) {
|
if (follow && keep.activeName) {
|
||||||
const hit = matchItem(keep.activeName);
|
const hit = matchItem(keep.activeName, hintFrom(lastView));
|
||||||
state.live.matched = Boolean(hit);
|
state.live.matched = Boolean(hit);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
state.live.currentScore = hit.score;
|
state.live.currentScore = hit.score;
|
||||||
startItem(hit.item.id);
|
startItem(hit.item.id, keep.activeName, hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best plan item for a live presentation name, preferring the ones ahead of us. */
|
/**
|
||||||
function matchItem(name) {
|
* The plan item a live document stands for — { item, score, via } or
|
||||||
const items = state.items.filter((it) => it.itemType !== 'header');
|
* null. The rules live in matching.js (placeDocument): by name, then the
|
||||||
if (!items.length) return null;
|
* message rule for a document named like the plan itself; the playlist
|
||||||
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
|
* position only breaks ties, and only while the playlist mirrors the plan.
|
||||||
let best = null;
|
*/
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
function matchItem(name, hint) {
|
||||||
const item = items[i];
|
return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
|
||||||
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
|
||||||
if (score < MATCH_THRESHOLD) continue;
|
|
||||||
// Distance ahead of the current item (wrapping so items behind rank last).
|
|
||||||
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
|
||||||
if (
|
|
||||||
!best
|
|
||||||
|| score > best.score + 0.001
|
|
||||||
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
|
|
||||||
) {
|
|
||||||
best = { item, score, dist };
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return best;
|
/** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
|
||||||
|
function hintFrom(view) {
|
||||||
|
const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
|
||||||
|
return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */
|
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */
|
||||||
@@ -1077,6 +1088,7 @@ module.exports = {
|
|||||||
function applyLiveView(view) {
|
function applyLiveView(view) {
|
||||||
lastView = view && typeof view === 'object' ? view : null;
|
lastView = view && typeof view === 'object' ? view : null;
|
||||||
const live = state.live;
|
const live = state.live;
|
||||||
|
if (!live.available) log('following the ProPresenter module feed');
|
||||||
live.available = true;
|
live.available = true;
|
||||||
live.reason = '';
|
live.reason = '';
|
||||||
live.ppEnabled = Boolean(view?.enabled);
|
live.ppEnabled = Boolean(view?.enabled);
|
||||||
@@ -1095,23 +1107,40 @@ module.exports = {
|
|||||||
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
|
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastLiveName = name;
|
|
||||||
rememberDocument(name);
|
rememberDocument(name);
|
||||||
if (follow) {
|
if (follow) {
|
||||||
const hit = matchItem(name);
|
try {
|
||||||
|
const hit = matchItem(name, hintFrom(view));
|
||||||
live.matched = Boolean(hit);
|
live.matched = Boolean(hit);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
live.currentScore = hit.score;
|
live.currentScore = hit.score;
|
||||||
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
|
if (hit.item.id !== live.currentItemId) startItem(hit.item.id, name, hit);
|
||||||
|
} else {
|
||||||
|
// Said in the log, because on the wall it looks like the plan
|
||||||
|
// stopped following: the highlight stays where it was.
|
||||||
|
const staying = state.items.find((it) => it.id === live.currentItemId);
|
||||||
|
log(`live document "${name}" is not in the plan — ${staying ? `"${staying.title}" stays current` : 'no current item'}`);
|
||||||
|
}
|
||||||
|
lastMatchError = '';
|
||||||
|
} catch (err) {
|
||||||
|
// Never silent: a matcher that throws would otherwise leave the
|
||||||
|
// highlight stuck with nothing in the log to say why.
|
||||||
|
const msg = err?.message || String(err);
|
||||||
|
if (msg !== lastMatchError) {
|
||||||
|
lastMatchError = msg;
|
||||||
|
log(`matching "${name}" failed: ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
lastLiveName = name;
|
||||||
// The same document is also a possible service start / end cue.
|
// The same document is also a possible service start / end cue.
|
||||||
syncServiceSafe(Date.now(), name);
|
syncServiceSafe(Date.now(), name);
|
||||||
broadcast();
|
broadcast();
|
||||||
broadcastTimers();
|
broadcastTimers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startItem(itemId) {
|
/** Make an item current; `liveName` and `hit` say what placed it, for the log. */
|
||||||
|
function startItem(itemId, liveName, hit) {
|
||||||
const live = state.live;
|
const live = state.live;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
||||||
@@ -1120,12 +1149,18 @@ module.exports = {
|
|||||||
live.currentItemId = itemId;
|
live.currentItemId = itemId;
|
||||||
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||||||
const item = state.items.find((it) => it.id === itemId);
|
const item = state.items.find((it) => it.id === itemId);
|
||||||
log(`live item → ${item?.title || itemId}`);
|
const how = hit
|
||||||
|
? ` (${hit.via}${hit.via === 'name' ? ` ${Number(hit.score).toFixed(2)}` : ''}${liveName ? `: "${liveName}"` : ''})`
|
||||||
|
: '';
|
||||||
|
log(`live item → ${item?.title || itemId}${how}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowNextUnavailable(reason, retryMs) {
|
function nowNextUnavailable(reason, retryMs) {
|
||||||
const live = state.live;
|
const live = state.live;
|
||||||
if (live.available || live.reason !== reason) {
|
if (live.available || live.reason !== reason) {
|
||||||
|
// Every drop is in the log: a highlight that stopped moving is
|
||||||
|
// otherwise indistinguishable from a feed that went away.
|
||||||
|
log(`ProPresenter module feed unavailable — ${reason}`);
|
||||||
live.available = false;
|
live.available = false;
|
||||||
live.reason = reason;
|
live.reason = reason;
|
||||||
live.ppReachable = false;
|
live.ppReachable = false;
|
||||||
|
|||||||
+117
-38
@@ -17,6 +17,21 @@
|
|||||||
|
|
||||||
const FLOOR = -200;
|
const FLOOR = -200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dead input reads as a number, and a number on a wall is believed. Every
|
||||||
|
* tile therefore refuses to draw a level while the server says the input is
|
||||||
|
* digitally silent, and says why instead.
|
||||||
|
*/
|
||||||
|
function silentFor(payload) {
|
||||||
|
return payload && payload.silentSec ? payload.silentSec : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function silentText(sec) {
|
||||||
|
if (sec < 90) return `No audio — ${sec} s`;
|
||||||
|
const m = Math.round(sec / 60);
|
||||||
|
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Retention per frame for the RTA's smoothing setting. */
|
/** Retention per frame for the RTA's smoothing setting. */
|
||||||
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
||||||
|
|
||||||
@@ -37,6 +52,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.setStatus('error', payload.error);
|
moduleApi.setStatus('error', payload.error);
|
||||||
} else if (!payload.frame) {
|
} else if (!payload.frame) {
|
||||||
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
||||||
|
} else if (silentFor(payload)) {
|
||||||
|
moduleApi.setStatus('error',
|
||||||
|
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
|
||||||
} else if (!payload.calibrated) {
|
} else if (!payload.calibrated) {
|
||||||
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
||||||
} else {
|
} else {
|
||||||
@@ -233,6 +251,16 @@ function readoutView(root, moduleApi) {
|
|||||||
render(payload) {
|
render(payload) {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const ch = pickChannel(payload, s);
|
const ch = pickChannel(payload, s);
|
||||||
|
const quiet = silentFor(payload);
|
||||||
|
if (quiet && ch) {
|
||||||
|
num.textContent = '––.–';
|
||||||
|
unit.textContent = '';
|
||||||
|
sub.hidden = false;
|
||||||
|
sub.textContent = silentText(quiet);
|
||||||
|
box.dataset.level = 'dead';
|
||||||
|
fit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!payload || payload.error || !ch) {
|
if (!payload || payload.error || !ch) {
|
||||||
num.textContent = '––.–';
|
num.textContent = '––.–';
|
||||||
sub.textContent = payload && payload.error ? payload.error : '';
|
sub.textContent = payload && payload.error ? payload.error : '';
|
||||||
@@ -286,6 +314,7 @@ function barsView(root, moduleApi) {
|
|||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
|
const quiet = silentFor(payload);
|
||||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||||
const showPeak = s.peakHold !== false;
|
const showPeak = s.peakHold !== false;
|
||||||
const lo = payload.calibrated ? 40 : -80;
|
const lo = payload.calibrated ? 40 : -80;
|
||||||
@@ -295,12 +324,12 @@ function barsView(root, moduleApi) {
|
|||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
const value = readValue(c, { averaging: s.averaging });
|
const value = readValue(c, { averaging: s.averaging });
|
||||||
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||||
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
|
el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${pct(value)}%`;
|
||||||
const peak = el.querySelector('.spl-bar-peak');
|
const peak = el.querySelector('.spl-bar-peak');
|
||||||
peak.style.display = showPeak ? '' : 'none';
|
peak.style.display = showPeak ? '' : 'none';
|
||||||
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
||||||
el.querySelector('.spl-bar-value').textContent = fmt(value);
|
el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
|
||||||
el.dataset.level = !payload.calibrated ? 'idle'
|
el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
|
||||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||||
el.classList.toggle('clip', !!c.clip);
|
el.classList.toggle('clip', !!c.clip);
|
||||||
}
|
}
|
||||||
@@ -321,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) {
|
|||||||
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||||
const canvas = root.querySelector('.spl-canvas');
|
const canvas = root.querySelector('.spl-canvas');
|
||||||
const ctx = canvas.getContext('2d', { alpha: false });
|
const ctx = canvas.getContext('2d', { alpha: false });
|
||||||
let painted = false; // spectrogram: has the background been laid down
|
/**
|
||||||
|
* The spectrogram's scrolling history, on its own surface.
|
||||||
|
*
|
||||||
|
* It used to scroll by copying the visible canvas onto itself, which also
|
||||||
|
* copied whatever had been drawn on top of it — so every frame dragged the
|
||||||
|
* frequency labels and gridlines one step further along and left a smear.
|
||||||
|
* Only measured pixels belong on a surface that scrolls; annotations are
|
||||||
|
* drawn fresh over the top of it, every frame, and never into it.
|
||||||
|
*/
|
||||||
|
let history = null;
|
||||||
|
let historyCtx = null;
|
||||||
|
let painted = false; // spectrogram: has the history been cleared yet
|
||||||
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
||||||
let smoothedFor = '';
|
let smoothedFor = '';
|
||||||
let axisBtn = null;
|
let axisBtn = null;
|
||||||
@@ -389,10 +429,12 @@ function spectrumView(root, moduleApi, scrolling) {
|
|||||||
const muted = themeColor(root, '--muted', '#888');
|
const muted = themeColor(root, '--muted', '#888');
|
||||||
const border = themeColor(root, '--border', '#333');
|
const border = themeColor(root, '--border', '#333');
|
||||||
|
|
||||||
if (!payload || !payload.frame || !payload.frame.bands) {
|
const quiet = silentFor(payload);
|
||||||
|
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
|
||||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
||||||
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
|
ctx.fillText(quiet ? silentText(quiet)
|
||||||
|
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
|
||||||
painted = false;
|
painted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -466,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) {
|
|||||||
// ── spectrogram ──
|
// ── spectrogram ──
|
||||||
const timeX = axis() === 'x';
|
const timeX = axis() === 'x';
|
||||||
const colW = 2;
|
const colW = 2;
|
||||||
const gutter = showLabels ? axisPad : 0;
|
// A label along the bottom needs only its line height; one down the
|
||||||
// Frequency occupies whichever axis time does not.
|
// side has to fit "12.5k" beside the plot rather than on top of it.
|
||||||
const freqExtent = timeX ? h - gutter : w - gutter;
|
const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
|
||||||
const timeExtent = timeX ? w : h;
|
// Labels belong beside the frequency axis: down the left when frequency
|
||||||
|
// runs vertically, along the bottom when it runs horizontally.
|
||||||
|
const plotX = timeX ? gutter : 0;
|
||||||
|
const plotW = timeX ? w - gutter : w;
|
||||||
|
const plotH = timeX ? h : h - gutter;
|
||||||
|
const freqExtent = timeX ? plotH : plotW;
|
||||||
|
const timeExtent = timeX ? plotW : plotH;
|
||||||
|
if (plotW < 4 || plotH < 4) return;
|
||||||
|
|
||||||
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
|
// The history surface is sized to the plot, in device pixels, and is
|
||||||
|
// thrown away whenever that geometry changes — a scroll buffer means
|
||||||
// Source rect in device pixels, destination in CSS pixels: the context
|
// nothing once it is being stretched into a different shape.
|
||||||
// carries a devicePixelRatio transform but the canvas as an image
|
const dpr = window.devicePixelRatio || 1;
|
||||||
// source does not, and letting drawImage infer them scales the picture
|
const needW = Math.max(1, Math.round(plotW * dpr));
|
||||||
// up and pushes every column off the canvas.
|
const needH = Math.max(1, Math.round(plotH * dpr));
|
||||||
if (timeX) {
|
if (!history || history.width !== needW || history.height !== needH) {
|
||||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
history = document.createElement('canvas');
|
||||||
} else {
|
history.width = needW;
|
||||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
|
history.height = needH;
|
||||||
|
historyCtx = history.getContext('2d', { alpha: false });
|
||||||
|
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
painted = false;
|
||||||
}
|
}
|
||||||
|
if (!painted) {
|
||||||
|
historyCtx.fillStyle = bg;
|
||||||
|
historyCtx.fillRect(0, 0, plotW, plotH);
|
||||||
|
painted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the history, then add the newest column. The source rectangle
|
||||||
|
// is in device pixels and the destination in CSS pixels, because the
|
||||||
|
// context carries the dpr transform but the canvas as an image source
|
||||||
|
// does not.
|
||||||
|
historyCtx.drawImage(history, 0, 0, history.width, history.height,
|
||||||
|
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
|
||||||
|
|
||||||
const bandExtent = freqExtent / values.length;
|
const bandExtent = freqExtent / values.length;
|
||||||
for (let i = 0; i < values.length; i++) {
|
for (let i = 0; i < values.length; i++) {
|
||||||
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
|
historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||||
if (timeX) {
|
if (timeX) {
|
||||||
// Low frequencies at the bottom, as every spectrogram has them.
|
// Low frequencies at the bottom, as every spectrogram has them.
|
||||||
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||||
colW, Math.ceil(bandExtent) + 1);
|
colW, Math.ceil(bandExtent) + 1);
|
||||||
} else {
|
} else {
|
||||||
// Low frequencies at the left.
|
// Low frequencies at the left.
|
||||||
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
|
historyCtx.fillRect(i * bandExtent, timeExtent - colW,
|
||||||
Math.ceil(bandExtent) + 1, colW);
|
Math.ceil(bandExtent) + 1, colW);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (grid || showLabels) {
|
// Repaint the visible canvas from scratch every frame: history first,
|
||||||
|
// annotations over it. Nothing drawn here can ever reach the scroll.
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.drawImage(history, 0, 0, history.width, history.height,
|
||||||
|
plotX, 0, plotW, plotH);
|
||||||
|
|
||||||
|
if (!grid && !showLabels) return;
|
||||||
|
|
||||||
|
const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||||
const posOf = (f) => {
|
const posOf = (f) => {
|
||||||
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
const t = Math.log2(f / freqs[0]) / span;
|
||||||
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
|
return timeX ? plotH - t * plotH : plotX + t * plotW;
|
||||||
};
|
};
|
||||||
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
|
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
|
||||||
ctx.fillStyle = bg;
|
|
||||||
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
|
if (grid) {
|
||||||
else ctx.fillRect(0, 0, gutter, h);
|
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
|
||||||
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
|
|
||||||
for (const l of labels) {
|
for (const l of labels) {
|
||||||
if (timeX) {
|
if (timeX) {
|
||||||
const y = Math.round(l.pos) + 0.5;
|
const y = Math.round(l.pos) + 0.5;
|
||||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
ctx.moveTo(plotX, y); ctx.lineTo(w, y);
|
||||||
} else {
|
} else {
|
||||||
const x = Math.round(l.pos) + 0.5;
|
const x = Math.round(l.pos) + 0.5;
|
||||||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.stroke(); ctx.globalAlpha = 1;
|
ctx.stroke(); ctx.globalAlpha = 1;
|
||||||
ctx.textAlign = timeX ? 'left' : 'center';
|
}
|
||||||
for (const l of showLabels ? labels : []) {
|
|
||||||
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
|
if (showLabels) {
|
||||||
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||||
|
ctx.textAlign = timeX ? 'right' : 'center';
|
||||||
|
for (const l of labels) {
|
||||||
|
if (timeX) {
|
||||||
|
ctx.fillText(hzLabel(l.f), gutter - 2,
|
||||||
|
Math.max(7, Math.min(h - 2, l.pos + 3)));
|
||||||
|
} else {
|
||||||
|
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.3.1",
|
"version": "0.4.1",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ const MEASURE_MS = 3000;
|
|||||||
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
const PING_MS = 15000;
|
const PING_MS = 15000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below this, unweighted, the input is not quiet — it is not there.
|
||||||
|
*
|
||||||
|
* A real input always carries something: a microphone in a silent room still
|
||||||
|
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
|
||||||
|
* a stream of actual zeros reaches the capture tool's -200 floor, and that
|
||||||
|
* means the device handed us nothing at all.
|
||||||
|
*/
|
||||||
|
const SILENCE_DBFS = -150;
|
||||||
|
/** How long that has to hold before saying so, so a gap between buffers or a
|
||||||
|
device changing sample rate mid-stream doesn't cry wolf. */
|
||||||
|
const SILENCE_GRACE_MS = 5000;
|
||||||
|
|
||||||
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
||||||
let state = null;
|
let state = null;
|
||||||
|
|
||||||
@@ -100,6 +113,7 @@ module.exports = {
|
|||||||
meta: null,
|
meta: null,
|
||||||
error: '',
|
error: '',
|
||||||
streams: new Set(),
|
streams: new Set(),
|
||||||
|
silentSince: null,
|
||||||
listener: null,
|
listener: null,
|
||||||
lastSent: '',
|
lastSent: '',
|
||||||
pinger: null,
|
pinger: null,
|
||||||
@@ -143,6 +157,12 @@ module.exports = {
|
|||||||
state.error = '';
|
state.error = '';
|
||||||
state.frame = update.frame;
|
state.frame = update.frame;
|
||||||
state.meta = update.meta;
|
state.meta = update.meta;
|
||||||
|
// Digital silence on every channel is a dead input, not a reading.
|
||||||
|
// Saying "ok" through this is what made a stopped Dante device look
|
||||||
|
// like a frozen module for a day and a half.
|
||||||
|
const dead = update.frame.ch.every(
|
||||||
|
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
|
||||||
|
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
|
||||||
broadcast();
|
broadcast();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -159,6 +179,11 @@ module.exports = {
|
|||||||
health() {
|
health() {
|
||||||
if (state.error) return { status: 'error', message: state.error };
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
|
const quiet = silentMs();
|
||||||
|
if (quiet > SILENCE_GRACE_MS) {
|
||||||
|
return { status: 'error',
|
||||||
|
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
|
||||||
|
}
|
||||||
const n = state.channels.length;
|
const n = state.channels.length;
|
||||||
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
||||||
return state.calibrated
|
return state.calibrated
|
||||||
@@ -351,10 +376,26 @@ module.exports = {
|
|||||||
|
|
||||||
/* ── helpers ────────────────────────────────────────────────────────── */
|
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** How long every channel has been at digital silence, in ms. */
|
||||||
|
function silentMs() {
|
||||||
|
return state && state.silentSince ? Date.now() - state.silentSince : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(ms) {
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
if (s < 90) return `${s} s`;
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
|
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
|
||||||
|
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
|
||||||
|
// changed every frame would make the fan-out's change check useless.
|
||||||
|
const quiet = silentMs();
|
||||||
return {
|
return {
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
|
||||||
calibrated: state.calibrated,
|
calibrated: state.calibrated,
|
||||||
channels: state.channels,
|
channels: state.channels,
|
||||||
weighting: state.meta ? state.meta.weighting : '',
|
weighting: state.meta ? state.meta.weighting : '',
|
||||||
|
|||||||
@@ -57,6 +57,11 @@
|
|||||||
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
||||||
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
||||||
|
|
||||||
|
/* A dead input reads danger, not muted: silence that is a fault should not
|
||||||
|
look like silence that is a quiet room. */
|
||||||
|
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
|
||||||
|
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
|
||||||
|
|
||||||
/* ── level bars ───────────────────────────────────────────────────── */
|
/* ── level bars ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
[data-module="spl-meter"] .spl-bars {
|
[data-module="spl-meter"] .spl-bars {
|
||||||
@@ -100,6 +105,7 @@
|
|||||||
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
||||||
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
||||||
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
||||||
|
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
|
||||||
|
|
||||||
/* Peak hold sits on the track rather than inside the fill, so it stays
|
/* Peak hold sits on the track rather than inside the fill, so it stays
|
||||||
visible when the bar falls away underneath it. */
|
visible when the bar falls away underneath it. */
|
||||||
|
|||||||
Reference in New Issue
Block a user