Compare commits

...
Author SHA1 Message Date
christian 49f8a82fbd ProPresenter Look 1.0.0: mock ProPresenter looks API 2026-10-26 12:58:39 -04:00
christian 70f56c7909 ProPresenter Look 1.0.0: README 2026-10-26 12:58:25 -04:00
christian cd13ecaf7c ProPresenter Look 1.0.0: stylesheet 2026-10-26 12:58:12 -04:00
christian 09f59c1bef ProPresenter Look 1.0.0: client entry 2026-10-26 12:57:53 -04:00
christian 3bfea6010a ProPresenter Look 1.0.0: server entry 2026-10-26 12:57:40 -04:00
christian 7c0579cbf9 ProPresenter Look 1.0.0: manifest 2026-10-26 12:57:36 -04:00
christian c043834d1e Merge pull request 'Band Lineup 1.13.0: a photo per instrument, your own photos win, spots stay in reach while dragging' (#53) from claude/band-lineup-photos into main 2026-10-20 13:41:06 -04:00
christianandClaude Opus 5.5 1fd7dcdf1e Band Lineup 1.13.0: a photo per instrument, picked by the spot each person is in that week
DJ plays drums one week and sings the next; one photo can't fit both. A
person can now have a photo per instrument, named "Name - Instrument"
("DJ - Drums.jpg", "DJ - Vocals.jpg"), plus a plain "DJ.jpg" for
everything else. Sync reads the instrument family of the spot each person
lands in (its bracketed role, else their PCO position) and picks:

1. the photo picked by hand for them on that instrument;
2. a photo named for that instrument;
3. their everyday pick, else their best-named photo. A photo of them on
   another instrument still beats PCO's headshot.

Families keep the shorthands together: Vox/Vocals/Singer, Keys/Piano/Pad,
Drums/Perc, Bass, Guitar/Electric/Acoustic/Rhythm, Leader. A photo
dropped onto someone is remembered per person per instrument
("<id>|drums"), so a drum pick never overrides the vocal photo. A spot
naming several roles files under its most specific: Leader, then Vocals,
Drums, Keys, Bass, Guitar. Photo names from files now collapse the spaces
left by "Name - Instrument".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:40:00 -04:00
christianandClaude Opus 5.5 e59629694e Band Lineup 1.13.0: your own photos win, a photo dropped on someone becomes theirs, and the spots stay in reach while dragging
Photos. Sync fell back to the Planning Center headshot whenever two
library photos matched a person equally ("Mike" and "mike"), and had no
way to know "Rose" is Rosaliz — so the booth dragged those in by hand
every week. Now:
- Several equally good library photos is not a reason to use PCO's; the
  first of them is used. PCO's headshot is only for someone with no photo.
- Dropping a library photo onto the synced person already in that spot
  (same name, same first name, or a nickname sharing its first three
  letters) makes it their photo: they keep their Planning Center name and
  spot, and it is remembered (settings.json photoFor) for every later
  Sync. Photos already dragged in by hand for this plan are learned the
  same way on the next Sync.
- A synced person's library photo is read live, so replacing that photo
  updates the board without a re-sync.

Dragging. The editor was one scrolling column, so a photo found far down
the list had to be carried back up to its spot, and every drop redrew the
editor back to the top. The spots are now pinned in their own pane above
the photos, each pane auto-scrolls when a drag nears its edge, both keep
their scroll position through redraws, and a search box filters the
photos by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:28:49 -04:00
13 changed files with 991 additions and 36 deletions
+14 -2
View File
@@ -521,14 +521,26 @@ below. Nothing is installed without someone clicking.
A photo library fills it: point the module at a Google Drive folder shared A photo library fills it: point the module at a Google Drive folder shared
"anyone with the link" (file name → person's name) or at a folder on the "anyone with the link" (file name → person's name) or at a folder on the
ProdDash machine, and photos added or removed there follow. **Edit** in the ProdDash machine, and photos added or removed there follow. **Edit** in the
title bar opens that library — drag a photo onto a slot (mouse, or press and title bar opens that library — the spots pinned on top, the photos (with a
name search) scrolling below — drag a photo onto a slot (mouse, or press and
hold on a touchscreen), drag between slots to swap, rename or clear. hold on a touchscreen), drag between slots to swap, rename or clear.
Dropping a photo onto the person already in a spot makes it *their* photo
for that instrument (a nickname like "Rose" for Rosaliz counts): they keep
their Planning Center name and spot, and every later Sync uses it whenever
they are on that instrument.
Planning Center is optional auto-fill on top, not the source of truth: Planning Center is optional auto-fill on top, not the source of truth:
**Sync** reads the upcoming plan's team members and places each one. A **Sync** reads the upcoming plan's team members and places each one. A
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
that slot — this is how the leader and vocalists, whose position is just that slot — this is how the leader and vocalists, whose position is just
"Vocalist", land on the right mic — and everyone else goes to the slot "Vocalist", land on the right mic — and everyone else goes to the slot
matching their position, one slot per person. Anything placed or moved by matching their position, one slot per person. Photos come from the library
whenever it has one of them, and Planning Center's headshot only when it
has none. Someone who plays more than one thing can have a photo per
instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`;
Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and
a plain `DJ.jpg` for everything else: Sync uses the one for the spot they
are in that week. A photo dropped onto someone is remembered for that
instrument only. Anything placed or moved by
hand for that plan stays put through later syncs; last week's people are hand for that plan stays put through later syncs; last week's people are
replaced. replaced.
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
+36 -12
View File
@@ -45,9 +45,10 @@ export default function create({ root, moduleApi }) {
moveDrag(p.x, p.y); moveDrag(p.x, p.y);
const tick = () => { const tick = () => {
if (!drag) return; if (!drag) return;
const b = drag.body; // Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
if (b && b.isConnected) { const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
const r = b.getBoundingClientRect(); const zone = 70; let dy = 0; if (b && b.scrollHeight > b.clientHeight) {
const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2; if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2; else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); } if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
@@ -163,9 +164,9 @@ export default function create({ root, moduleApi }) {
if (drag) { renderHeld = true; return; } if (drag) { renderHeld = true; return; }
stopConfetti(); stopConfetti();
stopParallax(); stopParallax();
root.innerHTML = '';
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir)); if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
if (editing) { renderEditor(); return; } if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
root.innerHTML = '';
const board = el('div', 'bl-board'); const board = el('div', 'bl-board');
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; } if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
@@ -240,11 +241,16 @@ export default function create({ root, moduleApi }) {
} }
/* ---------- editor ---------- */ /* ---------- editor ---------- */
let libFilter = ''; // the photo search box, kept across re-renders
function renderEditor() { function renderEditor() {
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the // No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
// board on top and the picture picker (library) below it. // that scroll on their own: the spots pinned on top, the photos below — so a photo found far
// down the list never has to be carried back up to its spot. Both panes keep their scroll
// position (and the search box its focus) when a board update redraws the editor.
const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
const wrap = el('div', 'bl-editor bl-editor-nobar'); const wrap = el('div', 'bl-editor bl-editor-nobar');
const body = el('div', 'bl-editor-body'); const body = el('div', 'bl-editor-body bl-editor-split');
// Slots (drop targets) // Slots (drop targets)
const boardEl = el('div', 'bl-edit-board'); const boardEl = el('div', 'bl-edit-board');
@@ -267,9 +273,15 @@ export default function create({ root, moduleApi }) {
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); }); cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
boardEl.appendChild(cell); boardEl.appendChild(cell);
} }
body.appendChild(boardEl); const boardPane = el('div', 'bl-pane bl-board-pane');
boardPane.appendChild(boardEl);
body.appendChild(boardPane);
// Library // Library, with a name search
const libPane = el('div', 'bl-pane bl-lib-pane');
const search = el('input', 'bl-lib-search');
search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
libPane.appendChild(search);
const lib = el('div', 'bl-library'); const lib = el('div', 'bl-library');
const list = state?.library || []; const list = state?.library || [];
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.')); if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
@@ -287,11 +299,23 @@ export default function create({ root, moduleApi }) {
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
lib.appendChild(item); lib.appendChild(item);
} }
body.appendChild(lib); const applyFilter = () => {
const q = libFilter.trim().toLowerCase();
for (const item of lib.querySelectorAll('.bl-lib-item')) {
const nm = item.querySelector('.bl-lib-name');
item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
}
};
search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
applyFilter();
libPane.appendChild(lib);
body.appendChild(libPane);
wrap.appendChild(body); wrap.appendChild(body);
root.innerHTML = ''; root.innerHTML = '';
root.appendChild(wrap); root.appendChild(wrap);
boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
} }
function assign(slotIndex, personId) { function assign(slotIndex, personId) {
@@ -320,7 +344,7 @@ export default function create({ root, moduleApi }) {
title: 'Sync', title: 'Sync',
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); }, onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
}); });
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } }); const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
// Columns −/+ (touch-friendly; replaces the gear number field). // Columns −/+ (touch-friendly; replaces the gear number field).
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>'; const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>'; const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
+75 -9
View File
@@ -186,14 +186,60 @@ function assignMembersToSlots(slots, members) {
}); });
} }
/* ── photos by instrument ──────────────────────────────────────────────
* A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg",
* "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo
* whose instrument matches the spot the person is in that week. Instruments group into families so
* the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass,
* Guitar/Electric/Acoustic/Rhythm, Leader. */
const ROLE_FAMILY = {
vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys',
drums: 'drums', percussion: 'drums', bass: 'bass',
guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader',
};
/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */
function roleFamilies(text) {
const out = new Set();
for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); }
return out;
}
/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */
function spotFamilies(slotLabel, positionLabel) {
const fromSlot = roleFamilies(slotRoleText(slotLabel));
return fromSlot.size ? fromSlot : roleFamilies(positionLabel || '');
}
/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}.
* The first word is always the person; instrument words after it are read off, other words
* ("action", "2") are just part of the photo's description. */
function photoParts(libName) {
const words = normalizeLabel(libName).split(' ').filter(Boolean);
const roles = new Set(); const person = [];
words.forEach((w, i) => {
const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null;
if (f) roles.add(f); else if (!roles.size) person.push(w);
});
return { person: person.join(' '), roles };
}
/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick
* never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under
* its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */
const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar'];
function photoKey(personId, families) {
const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || '';
return f ? `${personId}|${f}` : String(personId || '');
}
/** /**
* Best library photo for a Planning Center person, matched by name so Sync can use the folder * Best library photo for a Planning Center person, matched by name so Sync can use the folder
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files, * photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS * preferring the most specific. Returns the entry, or null when no library photo is a confident
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used. * match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
* `entries` is [{ ...whatever, name }]. * not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
* `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the
* person is on this week: a photo for that instrument beats their everyday photo, which beats a
* photo of them on something else — but any photo of theirs still beats PCO's headshot.
*/ */
function bestLibraryMatch(pcoName, entries) { function bestLibraryMatch(pcoName, entries, want) {
const target = normalizeLabel(pcoName); const target = normalizeLabel(pcoName);
if (!target) return null; if (!target) return null;
const T = target.split(' ').filter(Boolean); const T = target.split(' ').filter(Boolean);
@@ -225,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
if (last && last.length >= 3 && T.includes(last)) return 78; if (last && last.length >= 3 && T.includes(last)) return 78;
return 0; return 0;
}; };
const wanted = want && want.size ? want : null;
let best = null; let best = null;
let bestScore = 0; let bestScore = 0;
let tie = false;
for (const e of entries) { for (const e of entries) {
const s = scoreOf(e.name); const parts = photoParts(e.name);
if (s > bestScore) { bestScore = s; best = e; tie = false; } let s = scoreOf(parts.roles.size ? parts.person : e.name);
else if (s === bestScore && s > 0) tie = true; if (s < 60) continue; // not them
if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20;
if (s > bestScore) { bestScore = s; best = e; }
} }
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo if (!best) return null; // no confident match → PCO photo
return best; return best;
} }
/**
* Could this library photo be of this Planning Center person? Used when someone drops a photo onto
* a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
* A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
* first three letters (a nickname: "Rose" → "Rosaliz").
*/
function samePerson(pcoName, libName) {
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || '';
if (a && a === b) return true;
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
}
module.exports = { module.exports = {
samePerson,
roleFamilies,
spotFamilies,
photoParts,
photoKey,
normalizeTeamName, normalizeTeamName,
isAllowedTeamName, isAllowedTeamName,
bestLibraryMatch, bestLibraryMatch,
+40 -2
View File
@@ -61,8 +61,9 @@ console.log('assignment:', JSON.stringify(out));
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person'); assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
// exact full name beats a first-name photo // exact full name beats a first-name photo
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins'); assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
// tie → no match (two identical-scoring photos) // two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null'); assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
console.log('bestLibraryMatch: all assertions passed ✓'); console.log('bestLibraryMatch: all assertions passed ✓');
} }
@@ -103,3 +104,40 @@ console.log('assignment:', JSON.stringify(out));
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10'); assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
console.log('notes: all assertions passed ✓'); console.log('notes: all assertions passed ✓');
} }
// --- samePerson: is a dropped photo of the person already in the spot? ---
{
const { samePerson } = require('./matching');
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
console.log('samePerson: all assertions passed ✓');
}
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
{
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
assert.strictEqual(photoKey('dj', new Set()), 'dj');
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
console.log('photos by instrument: all assertions passed ✓');
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "band-lineup", "id": "band-lineup",
"name": "Band Lineup", "name": "Band Lineup",
"version": "1.12.0", "version": "1.13.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill", "description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
"client": "client.js", "client": "client.js",
+54 -10
View File
@@ -32,7 +32,7 @@ const path = require('path');
const os = require('os'); const os = require('os');
const crypto = require('crypto'); const crypto = require('crypto');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching'); const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
@@ -219,6 +219,27 @@ module.exports = {
const settings = readJsonFile(settingsPath, {}) || {}; const settings = readJsonFile(settingsPath, {}) || {};
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path) const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
// The photo the booth picked for a Planning Center person on an instrument ("<PCO person id>|drums"
// → library id; a bare person id is their everyday pick), learned when a library photo is dropped
// onto that person. Sync uses it before any name matching.
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
function rememberPhoto(key, libId) {
if (!key || !libId || settings.photoFor[key] === libId) return;
settings.photoFor[key] = libId; saveSettings();
}
// Library photo for a scheduled person in a spot whose instruments are `fams`:
// 1. the photo picked for them on this instrument;
// 2. a photo named for this instrument ("DJ - Drums");
// 3. their everyday pick, else their best-named photo (an other-instrument photo of them
// still beats PCO's headshot).
function libraryPhotoFor(personId, name, fams) {
const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null);
const forRole = live(settings.photoFor[photoKey(personId, fams)]);
if (forRole) return forRole;
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams);
if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m;
return live(settings.photoFor[personId]) || (m && m.file ? m : null);
}
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } } function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
const CHOIR_LABEL = 'CHOIR'; const CHOIR_LABEL = 'CHOIR';
@@ -313,7 +334,7 @@ module.exports = {
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed. * left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
*/ */
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i; const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim(); const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums"
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; } function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
function scanFolder() { function scanFolder() {
if (!photoFolder) return false; if (!photoFolder) return false;
@@ -449,11 +470,13 @@ module.exports = {
if (!e) return null; if (!e) return null;
let name = '', file = '', birthday = '', manual = true, personId = ''; let name = '', file = '', birthday = '', manual = true, personId = '';
if (e.pco) { if (e.pco) {
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is. // A synced person. Their library photo (if any) is read live, so replacing that photo
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
manual = false; personId = e.pco.personId || ''; manual = false; personId = e.pco.personId || '';
name = String(e.pco.name || '').trim(); name = String(e.pco.name || '').trim();
file = e.pco.file || ''; const lib = e.pco.libId ? library[e.pco.libId] : null;
birthday = e.pco.birthday || ''; file = (lib && lib.file) || e.pco.file || '';
birthday = e.pco.birthday || (lib && lib.birthday) || '';
} else if (e.personId) { } else if (e.personId) {
const lib = library[e.personId]; const lib = library[e.personId];
if (!lib) return null; // its library photo was deleted → slot is empty if (!lib) return null; // its library photo was deleted → slot is empty
@@ -521,7 +544,18 @@ module.exports = {
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } } for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
for (const m of members) m.birthday = bdays.get(m.personId) || ''; for (const m of members) m.birthday = bdays.get(m.personId) || '';
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })); // A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
// Planning Center name, kept in that spot.
for (const [i, e] of Object.entries(board)) {
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
const libName = library[e.personId].name;
const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
const m = who[0];
rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId);
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan };
}
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that // Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one // same person twice. A hand placement made for an earlier plan (last week's lineup, or one
@@ -542,9 +576,10 @@ module.exports = {
for (let k = 0; k < openIdx.length; k += 1) { for (let k = 0; k < openIdx.length; k += 1) {
const i = openIdx[k]; const m = assigned[k]; const i = openIdx[k]; const m = assigned[k];
if (m) { if (m) {
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot // The booth's own photos win over the PCO headshot — the one for the instrument they're on.
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl); const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } }; const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
} else if (board[String(i)]) { } else if (board[String(i)]) {
delete board[String(i)]; // synced or stale, and no longer on the plan delete board[String(i)]; // synced or stale, and no longer on the plan
} }
@@ -586,6 +621,8 @@ module.exports = {
streams, getLatest: () => latest, pco, listPlans, photoDir, streams, getLatest: () => latest, pco, listPlans, photoDir,
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots, library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
planId: () => (latest.plan && latest.plan.id) || '', planId: () => (latest.plan && latest.plan.id) || '',
rememberPhoto,
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
photoUrlFor, libraryList, photoUrlFor, libraryList,
setChoir, choirOn: () => choirOn, setChoir, choirOn: () => choirOn,
}; };
@@ -694,7 +731,14 @@ module.exports = {
const personId = String(q.get('personId') || '').trim(); const personId = String(q.get('personId') || '').trim();
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' }); if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' }); if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
if (personId) current.board[slot] = { personId, name: '', plan: current.planId() }; const here = current.board[slot];
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
// A photo of the person already in this spot: it's their photo now — this week and every
// Sync after. They keep their Planning Center name and their spot.
// Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone.
current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId);
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
else delete current.board[slot]; else delete current.board[slot];
current.saveBoard(); current.rebuildLocal(); current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true }); json(res, 200, { ok: true });
+14
View File
@@ -247,3 +247,17 @@
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; } [data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; } [data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; } [data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
[data-module="band-lineup"] .bl-lib-search {
display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
}
[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }
+55
View File
@@ -0,0 +1,55 @@
# ProPresenter Look — a ProdDash module
Shows the audience **look** live in ProPresenter right now, as big as the
tile allows, so anyone away from the ProPresenter machine can tell at a glance
which look the outputs are on.
- The look's name fills the tile and **flashes** when it changes.
- **Modified** appears when someone changed the look by hand and it no longer
matches any saved look.
- **since 10:42 AM** says when the look last changed.
- If ProPresenter stops answering, the status dot goes red and the tile keeps
the **last known** look on screen, dimmed, until it comes back by itself.
## Install
This folder is a standard ProdDash module (id `propresenter-looks`). Per the
ProdDash module guide, it installs one of two ways:
1. **From the admin page's catalog**, once the folder is in the ProdDash repo
at `modules/propresenter-looks/`: `/admin` → Available modules →
*ProPresenter Look* → Install.
2. **Drop it in**: copy this folder to the ProdDash data directory's
`modules/propresenter-looks/` and restart ProdDash.
- macOS: `~/Library/Application Support/ProdDash/modules/`
- Windows: `%APPDATA%\ProdDash\modules\`
Then add a tile from **+ Add tile → ProPresenter Look**.
## Configure (in `/admin`)
| Setting | What to enter |
| --- | --- |
| ProPresenter | Host / IP and port from ProPresenter's Settings › Network (Network enabled). |
| Password | Only if ProPresenter's network API asks for one. |
Each tile's gear has three switches: **Heading**, **Live status**, **Since**.
## How it works
The server entry polls ProPresenter's network API once a second
(`GET /v1/look/current`, plus `GET /v1/looks` every ten seconds to name the
saved look it matches) and streams the result to tiles over SSE. Browsers
never talk to ProPresenter. No dependencies. Needs ProdDash 1.5.0 or later and
ProPresenter 7.9+ (or 20+).
## Testing without ProPresenter
```bash
node tools/pp-looks-mock.js 1601
```
Point the module at host `127.0.0.1`, port `1601`. The live look changes every
8 seconds (`--every 3` for faster) and every fourth change is a hand-modified
look. `--password <pw>` demands a password; `--no-list` acts like a
ProPresenter without `GET /v1/looks`.
+192
View File
@@ -0,0 +1,192 @@
/* ProPresenter Look — client part.
The server part polls ProPresenter and streams the live audience look
here over SSE; the tile only renders. The look's name fills the tile so
it reads from across the booth, flashes once when it changes, and stays
on screen dimmed — marked "last known" — while ProPresenter is
unreachable. */
export default function create({ root, moduleApi }) {
let view = null;
let feedOffline = false;
let stream = null;
let resizeObserver = null;
let flashTimer = null;
let lastName = null;
root.innerHTML = `
<div class="pl-screen">
<header class="pl-top">
<div class="pl-heading">Look</div>
<div class="pl-live"><span class="pl-live-dot"></span><span class="pl-live-text">Connecting…</span></div>
</header>
<div class="pl-name-box"><div class="pl-name is-waiting">Connecting…</div></div>
<footer class="pl-meta">
<span class="pl-tag" hidden>Modified</span>
<span class="pl-since" hidden></span>
</footer>
</div>`;
const screenEl = root.querySelector('.pl-screen');
const topEl = root.querySelector('.pl-top');
const headingEl = root.querySelector('.pl-heading');
const liveEl = root.querySelector('.pl-live');
const liveTextEl = root.querySelector('.pl-live-text');
const nameBox = root.querySelector('.pl-name-box');
const nameEl = root.querySelector('.pl-name');
const metaEl = root.querySelector('.pl-meta');
const tagEl = root.querySelector('.pl-tag');
const sinceEl = root.querySelector('.pl-since');
const on = (key) => moduleApi.instanceSettings[key] !== false;
function timeText(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/** Biggest font size at which the name fits its box without breaking a word. */
function fitName() {
const MIN = 11;
const boxHeight = nameBox.clientHeight;
const boxWidth = nameBox.clientWidth;
nameEl.style.overflowWrap = 'normal';
if (boxHeight < MIN || !boxWidth) {
nameEl.style.fontSize = `${MIN}px`;
return;
}
const fits = (size) => {
nameEl.style.fontSize = `${size}px`;
return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1;
};
let lo = MIN;
let hi = Math.max(MIN, Math.floor(boxHeight));
let best = MIN;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
}
nameEl.style.fontSize = `${best}px`;
// One word too long even at MIN: let it break rather than overflow.
if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere';
}
function flash() {
screenEl.classList.remove('is-changed');
void screenEl.offsetWidth; // restart the animation
screenEl.classList.add('is-changed');
clearTimeout(flashTimer);
flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600);
}
function setStatus() {
if (feedOffline) {
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
} else if (!view) {
moduleApi.setStatus('connecting', 'Connecting…');
} else if (!view.configured) {
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
} else if (view.reachable) {
moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`);
} else if (!view.updatedAt && !view.lastError) {
moduleApi.setStatus('connecting', 'Reaching ProPresenter…');
} else {
moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`);
}
}
function render() {
setStatus();
const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError));
const look = view && view.look;
screenEl.classList.toggle('is-offline', offline);
headingEl.hidden = !on('showHeading');
liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows
topEl.hidden = headingEl.hidden && liveEl.hidden;
liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable));
liveEl.classList.toggle('is-down', offline);
if (feedOffline) liveTextEl.textContent = 'ProdDash offline';
else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable';
else if (view && view.reachable) liveTextEl.textContent = 'Live';
else liveTextEl.textContent = 'Connecting…';
let name;
let waiting = false;
if (look) {
name = look.name;
} else if (view && !view.configured) {
name = 'Set up in Admin';
waiting = true;
} else if (offline) {
name = 'ProPresenter unreachable';
waiting = true;
} else {
name = 'Connecting…';
waiting = true;
}
nameEl.textContent = name;
nameEl.classList.toggle('is-waiting', waiting);
if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash();
if (look) lastName = `${look.name}|${look.modified}`;
tagEl.hidden = !(look && look.modified);
const since = look && view.changedAt && on('showSince');
sinceEl.hidden = !since;
if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`;
metaEl.hidden = tagEl.hidden && sinceEl.hidden;
fitName();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
feedOffline = false;
render();
},
error() {
feedOffline = true;
render();
},
events: {
state(e) {
try {
view = JSON.parse(e.data);
} catch {
return;
}
feedOffline = false;
render();
},
},
});
}
return {
start() {
render();
moduleApi.fetch('/state')
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body && body.state && !view) {
view = body.state;
render();
}
})
.catch(() => {});
connect();
resizeObserver = new ResizeObserver(() => fitName());
resizeObserver.observe(nameBox);
},
stop() {
if (stream) stream.close();
stream = null;
if (resizeObserver) resizeObserver.disconnect();
resizeObserver = null;
clearTimeout(flashTimer);
root.innerHTML = '';
},
};
}
+58
View File
@@ -0,0 +1,58 @@
{
"id": "propresenter-looks",
"name": "ProPresenter Look",
"version": "1.0.0",
"proddash": ">=1.5.0",
"description": "The audience look live in ProPresenter right now",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 2
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": {
"host": "",
"port": 1025
},
"help": "The host and port shown in ProPresenter's Settings › Network, with Network enabled."
},
"password": {
"type": "password",
"label": "Password",
"default": "",
"help": "Only if ProPresenter's network API asks for one. Leave empty otherwise."
}
},
"instanceSchema": {
"showHeading": {
"type": "switch",
"label": "Heading",
"default": true
},
"showLive": {
"type": "switch",
"label": "Live status",
"default": true
},
"showSince": {
"type": "switch",
"label": "Since",
"default": true
}
}
}
+242
View File
@@ -0,0 +1,242 @@
'use strict';
/**
* ProPresenter Look — server part.
*
* Polls ProPresenter's network API for the live audience look and fans it
* out to every tile over SSE. Browsers never talk to ProPresenter directly.
*
* GET /state → { state: <view> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
*
* ProPresenter's routes (ProPresenter 7.9+ / 20+):
* GET /v1/look/current → { id: { uuid, name, index }, screens: [...] }
* GET /v1/looks → [ { id: { uuid, name, index }, screens: [...] }, … ]
* The current look carries a UUID of its own, never one from /v1/looks. Its
* `index` points at the saved look it matches, or equals the number of saved
* looks when someone has changed it by hand and it matches none.
*/
const POLL_MS = 1000; // the look matters the moment it changes
const RETRY_MS = 3000; // while ProPresenter is unreachable
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
const REQUEST_TIMEOUT_MS = 2500;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
function emptyView(configured = false) {
return {
configured,
reachable: false,
lastError: '',
look: null, // { name, modified, screens }
changedAt: 0, // ms epoch the look last changed while we watched; 0 = unknown
updatedAt: 0, // ms epoch of the last good answer
};
}
function errorText(err) {
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
}
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
function lookName(entry) {
return String(entry?.id?.name ?? entry?.name ?? '').trim();
}
module.exports = {
init({ config, log }) {
const streams = new Set();
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 1025;
const password = String(config.password || '');
const configured = Boolean(host && port);
let view = emptyView(configured);
let lastFrame = '';
let saved = []; // names of the saved looks, in ProPresenter's order
let savedOk = false;
let tick = 0;
let stopped = false;
let pollTimer = null;
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
if (password) built.searchParams.set('password', password);
return built;
}
async function requestJson(target) {
const response = await fetch(url(target), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
err.status = response.status;
throw err;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function broadcast() {
let frame;
try {
frame = JSON.stringify(view);
} catch {
return;
}
// Polls land every second whether anything changed or not; only real
// changes go out, so idle tiles stay idle.
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
async function refreshSaved() {
try {
const list = await requestJson('/v1/looks');
saved = Array.isArray(list) ? list.map(lookName) : [];
savedOk = true;
} catch (err) {
// An older ProPresenter without the list still answers look/current;
// the live look's own name is shown then.
if (err.status !== 404) throw err;
saved = [];
savedOk = false;
}
}
function resolveLook(live) {
const index = Number(live?.id?.index);
const own = lookName(live);
const screens = Array.isArray(live?.screens) ? live.screens.length : 0;
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
return { name: saved[index] || own || `Look ${index + 1}`, modified: false, screens };
}
// Changed by hand, matching no saved look.
const modified = savedOk && Number.isInteger(index) && index >= saved.length;
return { name: own || (modified ? 'Custom look' : 'Unnamed look'), modified, screens };
}
async function pollTick() {
if (stopped) return;
const next = { ...view };
try {
tick += 1;
if (tick % LOOKS_EVERY === 1 || !savedOk) await refreshSaved();
const live = await requestJson('/v1/look/current');
let look = resolveLook(live);
// A look saved since our last read: refresh the list now rather than
// calling it "Custom" for up to ten seconds.
if (look.modified && !view.look?.modified && tick % LOOKS_EVERY !== 1) {
await refreshSaved();
look = resolveLook(live);
}
const before = view.look;
if (before && (before.name !== look.name || before.modified !== look.modified)) {
next.changedAt = Date.now();
log(`look → ${look.name}${look.modified ? ' (modified)' : ''}`);
} else if (!before) {
log(`look is ${look.name}${look.modified ? ' (modified)' : ''}`);
}
next.look = look;
next.reachable = true;
next.lastError = '';
next.updatedAt = Date.now();
} catch (err) {
if (view.reachable) log(`ProPresenter unreachable: ${errorText(err)}`);
next.reachable = false;
next.lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
tick = 0; // re-read the saved looks first when it comes back
}
if (stopped) return;
view = next;
broadcast();
pollTimer = setTimeout(pollTick, view.reachable ? POLL_MS : RETRY_MS);
}
if (configured) {
log(`following the live look on ${host}:${port}`);
pollTick();
} else {
log('no ProPresenter host configured — set one in /admin');
}
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = { streams, getView: () => view };
return {
stop() {
stopped = true;
clearTimeout(pollTimer);
clearInterval(heartbeat);
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health() {
if (!configured) return { status: 'error', message: 'No ProPresenter host configured' };
if (view.reachable) {
return { status: 'ok', message: `Live look on ${host}:${port}: ${view.look ? view.look.name : '—'}` };
}
return {
status: view.updatedAt || view.lastError ? 'error' : 'connecting',
message: view.lastError ? `ProPresenter unreachable: ${view.lastError}` : `Reaching ${host}:${port}…`,
};
},
};
},
routes() {
return {
'GET /state': (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ state: current ? current.getView() : emptyView() }));
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getView() : emptyView())}\n\n`);
if (!current) return void res.end();
const { streams } = current;
// A viewer that stops reading (locked tablet, sleeping laptop) must
// not queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
streams.add(res);
req.on('close', () => streams.delete(res));
},
};
},
};
+141
View File
@@ -0,0 +1,141 @@
/* ProPresenter Look — the live look's name, as big as the tile allows.
Colors come from the shell's variables only. */
[data-module="propresenter-looks"] .pl-screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
user-select: none;
-webkit-user-select: none;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 12%, transparent),
color-mix(in srgb, var(--bg) 60%, transparent)
);
}
/* ── top row: heading and live status ── */
[data-module="propresenter-looks"] .pl-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
min-height: 0;
}
[data-module="propresenter-looks"] .pl-top[hidden],
[data-module="propresenter-looks"] .pl-heading[hidden],
[data-module="propresenter-looks"] .pl-live[hidden],
[data-module="propresenter-looks"] .pl-meta[hidden],
[data-module="propresenter-looks"] .pl-tag[hidden],
[data-module="propresenter-looks"] .pl-since[hidden] {
display: none;
}
[data-module="propresenter-looks"] .pl-heading {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-looks"] .pl-live {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-looks"] .pl-live-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--border);
transition: background 0.3s ease;
}
[data-module="propresenter-looks"] .pl-live.is-live .pl-live-dot {
background: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
}
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-dot {
background: var(--danger);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 16%, transparent);
}
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-text {
color: var(--danger);
}
/* ── the look's name ── */
[data-module="propresenter-looks"] .pl-name-box {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
[data-module="propresenter-looks"] .pl-name {
max-width: 100%;
font-size: 11px;
font-weight: 800;
line-height: 1.05;
text-align: center;
white-space: normal;
word-break: normal;
hyphens: none;
color: var(--text);
}
[data-module="propresenter-looks"] .pl-name.is-waiting {
font-weight: 600;
color: var(--muted);
}
/* Unreachable: the last known look stays, visibly stale. */
[data-module="propresenter-looks"] .pl-screen.is-offline {
background: color-mix(in srgb, var(--danger) 8%, transparent);
}
[data-module="propresenter-looks"] .pl-screen.is-offline .pl-name:not(.is-waiting) {
opacity: 0.45;
}
/* ── footer: modified tag and when it changed ── */
[data-module="propresenter-looks"] .pl-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-looks"] .pl-tag {
padding: 1px 7px;
border-radius: 999px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 10px;
color: var(--warn);
background: color-mix(in srgb, var(--warn) 16%, transparent);
}
/* A new look: one bright pulse so a glance catches the change. */
[data-module="propresenter-looks"] .pl-screen.is-changed {
animation: pl-changed 2.6s ease-out;
}
@keyframes pl-changed {
0%, 20% { box-shadow: inset 0 0 0 3px var(--accent); background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
100% { box-shadow: inset 0 0 0 3px transparent; background-color: transparent; }
}
@@ -0,0 +1,69 @@
#!/usr/bin/env node
'use strict';
/**
* Mock ProPresenter looks API, for developing the ProPresenter Look module
* without the real thing.
*
* node tools/pp-looks-mock.js [port] (default 1601)
* --every <seconds> how often the live look changes (default 8)
* --password <pw> demand ?password=<pw> like a locked ProPresenter
* --no-list answer 404 for /v1/looks (an older ProPresenter)
*
* Point the module at host 127.0.0.1 and this port. Every few changes the
* live look is "modified by hand", so it matches no saved look.
*/
const http = require('http');
const crypto = require('crypto');
const args = process.argv.slice(2);
const flag = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const port = Number(args.find((a) => /^\d+$/.test(a))) || 1601;
const every = Number(flag('--every')) || 8;
const password = flag('--password') || '';
const noList = args.includes('--no-list');
const screen = (on) => ({ video_input: false, media: on, slide: on, announcements: on, props: on, messages: on, presentation: null, mask: null });
const saved = ['Normal', 'Lyrics Only', 'Sermon', 'Countdown', 'Video Only'].map((name, index) => ({
id: { uuid: crypto.randomUUID(), name, index },
screens: [screen(true), screen(index !== 4)],
}));
let live = 0;
let modified = false;
let changes = 0;
const liveUuid = crypto.randomUUID();
setInterval(() => {
changes += 1;
modified = changes % 4 === 0;
if (!modified) live = (live + 1) % saved.length;
console.log(`live look → ${saved[live].id.name}${modified ? ' (modified)' : ''}`);
}, every * 1000);
function currentLook() {
return {
id: { uuid: liveUuid, name: saved[live].id.name, index: modified ? saved.length : live },
screens: saved[live].screens,
};
}
http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const send = (code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(body === undefined ? '' : JSON.stringify(body));
};
if (password && url.searchParams.get('password') !== password) return send(401, { error: 'password' });
if (url.pathname === '/version') return send(200, { name: 'Mock', platform: 'mac', os_version: '15', host_description: 'ProPresenter 21.0 (mock)' });
if (url.pathname === '/v1/looks') return noList ? send(404) : send(200, saved);
if (url.pathname === '/v1/look/current') return send(200, currentLook());
send(404);
}).listen(port, () => {
console.log(`mock ProPresenter looks on http://127.0.0.1:${port} — live look changes every ${every}s`);
console.log(`live look → ${saved[live].id.name}`);
});