Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49f8a82fbd | ||
|
|
70f56c7909 | ||
|
|
cd13ecaf7c | ||
|
|
09f59c1bef | ||
|
|
3bfea6010a | ||
|
|
7c0579cbf9 | ||
|
|
c043834d1e | ||
|
|
1fd7dcdf1e | ||
|
|
e59629694e | ||
|
|
6da6f4c31e | ||
|
|
8e4a0e849e | ||
|
|
df2f0494f8 | ||
|
|
0548cb54fa | ||
|
|
c1152b4ab3 | ||
|
|
a692b6a9e2 | ||
|
|
e670e1ee72 | ||
|
|
4b0f161c93 | ||
|
|
dafb48d9ed |
@@ -521,11 +521,28 @@ below. Nothing is installed without someone clicking.
|
||||
A photo library fills it: point the module at a Google Drive folder shared
|
||||
"anyone with the link" (file name → person's name) or at a folder on the
|
||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||
title bar opens that library — drag a photo onto a slot, drag between slots
|
||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
||||
drops each into the slot matching their position, filling only slots nobody
|
||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
||||
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.
|
||||
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:
|
||||
**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
|
||||
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
|
||||
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
|
||||
replaced.
|
||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||
|
||||
+144
-56
@@ -1,5 +1,5 @@
|
||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
|
||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||
// (Wed→Tue) rains confetti.
|
||||
@@ -14,26 +14,108 @@ export default function create({ root, moduleApi }) {
|
||||
let selectedLibId = ''; // click-to-place fallback for touch
|
||||
const canvases = new Set();
|
||||
let raf = null;
|
||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||
let dragScrolling = false;
|
||||
let dragY = 0;
|
||||
let dragBody = null;
|
||||
let dragRaf = null;
|
||||
function startDragAutoScroll() {
|
||||
if (dragRaf) return;
|
||||
const tick = () => {
|
||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||
const r = dragBody.getBoundingClientRect();
|
||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||
let dy = 0;
|
||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||
if (dy) dragBody.scrollTop += dy;
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
|
||||
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
|
||||
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
|
||||
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
|
||||
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
|
||||
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
|
||||
* edges. Board updates that arrive during a drag wait until it ends. */
|
||||
const DRAG_MOVE_PX = 6;
|
||||
const TOUCH_HOLD_MS = 220;
|
||||
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
|
||||
let pending = null; // a pointerdown that may become a drag
|
||||
let renderHeld = false; // a state update arrived mid-drag; render when it ends
|
||||
|
||||
function slotUnder(x, y) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
|
||||
return cell && root.contains(cell) ? cell : null;
|
||||
}
|
||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||
function beginDrag(p) {
|
||||
const ghost = el('div', 'bl-drag-ghost');
|
||||
ghost.dataset.module = 'band-lineup'; // lives on <body> (above every tile), so it carries its own scope
|
||||
const src = p.source.querySelector('img');
|
||||
if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); }
|
||||
else ghost.textContent = p.label || '';
|
||||
document.body.appendChild(ghost);
|
||||
drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source };
|
||||
p.source.classList.add('bl-dragging');
|
||||
root.classList.add('bl-is-dragging');
|
||||
moveDrag(p.x, p.y);
|
||||
const tick = () => {
|
||||
if (!drag) return;
|
||||
// Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
|
||||
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; });
|
||||
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;
|
||||
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); }
|
||||
}
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
function moveDrag(x, y) {
|
||||
drag.x = x; drag.y = y;
|
||||
drag.ghost.style.transform = `translate(${x}px, ${y}px)`;
|
||||
const cell = slotUnder(x, y);
|
||||
if (cell !== drag.over) {
|
||||
if (drag.over) drag.over.classList.remove('bl-drop-over');
|
||||
drag.over = cell;
|
||||
if (cell) cell.classList.add('bl-drop-over');
|
||||
}
|
||||
}
|
||||
function endDrag(drop) {
|
||||
const d = drag; drag = null;
|
||||
if (!d) return;
|
||||
if (d.raf) cancelAnimationFrame(d.raf);
|
||||
d.ghost.remove();
|
||||
d.source.classList.remove('bl-dragging');
|
||||
root.classList.remove('bl-is-dragging');
|
||||
if (d.over) d.over.classList.remove('bl-drop-over');
|
||||
if (drop && d.over) {
|
||||
const to = d.over.dataset.index;
|
||||
if (d.kind === 'lib') assign(to, d.id);
|
||||
else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' });
|
||||
}
|
||||
if (renderHeld) { renderHeld = false; render(); }
|
||||
}
|
||||
function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; }
|
||||
// Make `node` draggable. `kind`/`id` say what is being dragged.
|
||||
function makeDraggable(node, kind, id, body, label) {
|
||||
node.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || drag) return;
|
||||
if (e.target.closest('button')) return; // name / clear / delete buttons keep working
|
||||
cancelPending();
|
||||
pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null };
|
||||
if (pending.touch) {
|
||||
const p = pending;
|
||||
p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Document-level move/up so the drag survives leaving the card it started on.
|
||||
function onPointerMove(e) {
|
||||
if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; }
|
||||
if (!pending || e.pointerId !== pending.pointerId) return;
|
||||
const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy);
|
||||
if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll
|
||||
if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); }
|
||||
}
|
||||
function onPointerUp(e) {
|
||||
if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); }
|
||||
cancelPending();
|
||||
}
|
||||
let suppressClick = false; // the click that follows a drop must not also select / place
|
||||
// While a finger drag is live, stop the page from scrolling under it.
|
||||
function onTouchMove(e) { if (drag) e.preventDefault(); }
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.addEventListener('pointercancel', onPointerUp);
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
function stopDragAutoScroll() { cancelPending(); endDrag(false); }
|
||||
|
||||
/* ---------- confetti (always animates) ---------- */
|
||||
function makeParticle(w, h, intensity, seedY) {
|
||||
@@ -79,11 +161,12 @@ export default function create({ root, moduleApi }) {
|
||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||
|
||||
function render() {
|
||||
if (drag) { renderHeld = true; return; }
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
root.innerHTML = '';
|
||||
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');
|
||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||
@@ -158,13 +241,16 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
let libFilter = ''; // the photo search box, kept across re-renders
|
||||
function renderEditor() {
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
||||
// board on top and the picture picker (library) below it.
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
|
||||
// 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 body = el('div', 'bl-editor-body');
|
||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||
const body = el('div', 'bl-editor-body bl-editor-split');
|
||||
|
||||
// Slots (drop targets)
|
||||
const boardEl = el('div', 'bl-edit-board');
|
||||
@@ -176,14 +262,7 @@ export default function create({ root, moduleApi }) {
|
||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||
cell.appendChild(photo);
|
||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||
if (slot.person) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||
}
|
||||
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
|
||||
const nameRow = el('div', 'bl-edit-namerow');
|
||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||
@@ -191,26 +270,23 @@ export default function create({ root, moduleApi }) {
|
||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||
cell.appendChild(nameRow);
|
||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||
// drop
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||
const data = e.dataTransfer.getData('text/plain') || '';
|
||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||
});
|
||||
cell.addEventListener('click', (e) => { 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);
|
||||
}
|
||||
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 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.'));
|
||||
for (const person of list) {
|
||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||
const item = el('div', 'bl-lib-item'); item.dataset.id = person.id;
|
||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||
@@ -219,19 +295,27 @@ export default function create({ root, moduleApi }) {
|
||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||
item.appendChild(del);
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
item.addEventListener('dragend', stopDragAutoScroll);
|
||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
makeDraggable(item, 'lib', person.id, body, person.name);
|
||||
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
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);
|
||||
|
||||
root.innerHTML = '';
|
||||
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) {
|
||||
@@ -260,7 +344,7 @@ export default function create({ root, moduleApi }) {
|
||||
title: 'Sync',
|
||||
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).
|
||||
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>';
|
||||
@@ -293,6 +377,10 @@ export default function create({ root, moduleApi }) {
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
stopDragAutoScroll();
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
|
||||
+133
-11
@@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) {
|
||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||
*/
|
||||
const MIN_SCORE = 60;
|
||||
|
||||
/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */
|
||||
function mixNumbers(value) {
|
||||
const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m;
|
||||
while ((m = re.exec(String(value || '')))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a person's Planning Center note names a spot. The booth writes the mic/role into each
|
||||
* vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those
|
||||
* change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a
|
||||
* different mix than the spot is never a match; the role part ("VOX 5") is scored like a position.
|
||||
* 0 means the note says nothing usable about this spot ("NA", blank, or no overlap).
|
||||
*/
|
||||
function noteScore(slotLabel, note) {
|
||||
const text = String(note || '').trim();
|
||||
if (!text || /^n\/?a$/i.test(text)) return 0;
|
||||
const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text);
|
||||
let mixBonus = 0;
|
||||
if (noteMix.size && slotMix.size) {
|
||||
if (![...noteMix].some((n) => slotMix.has(n))) return 0;
|
||||
mixBonus = 100;
|
||||
}
|
||||
const role = text.replace(/\bmix\s*\d+\b/gi, ' ');
|
||||
const roleScore = scoreMatch(slotRoleText(slotLabel), role);
|
||||
if (!roleScore && !mixBonus) return 0;
|
||||
if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) {
|
||||
// The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither.
|
||||
const roleTokens = labelTokens(role);
|
||||
if (roleTokens.length) return 0;
|
||||
}
|
||||
return mixBonus + roleScore;
|
||||
}
|
||||
|
||||
function assignMembersToSlots(slots, members) {
|
||||
const pool = members.slice();
|
||||
return slots.map((slotLabel) => {
|
||||
const result = slots.map(() => null);
|
||||
const take = (chosen) => {
|
||||
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
|
||||
const key = chosen.personId || '';
|
||||
for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1);
|
||||
};
|
||||
// Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first.
|
||||
const pairs = [];
|
||||
slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); }));
|
||||
pairs.sort((a, b) => b.sc - a.sc);
|
||||
for (const { s, m } of pairs) {
|
||||
if (result[s] || !pool.includes(m)) continue;
|
||||
result[s] = m; take(m);
|
||||
}
|
||||
// Pass 2 — everyone else by Planning Center position, into the spots still open.
|
||||
return result.map((placed, s) => {
|
||||
if (placed) return placed;
|
||||
const slotLabel = slots[s];
|
||||
const role = slotRoleText(slotLabel);
|
||||
let bestIndex = -1;
|
||||
let bestScore = MIN_SCORE - 1;
|
||||
@@ -128,18 +180,66 @@ function assignMembersToSlots(slots, members) {
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
return pool.splice(bestIndex, 1)[0];
|
||||
const chosen = pool[bestIndex];
|
||||
take(chosen);
|
||||
return chosen;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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
|
||||
* 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
|
||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||||
* `entries` is [{ ...whatever, name }].
|
||||
* preferring the most specific. Returns the entry, or null when no library photo is a confident
|
||||
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
|
||||
* 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);
|
||||
if (!target) return null;
|
||||
const T = target.split(' ').filter(Boolean);
|
||||
@@ -171,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
|
||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||
return 0;
|
||||
};
|
||||
const wanted = want && want.size ? want : null;
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
let tie = false;
|
||||
for (const e of entries) {
|
||||
const s = scoreOf(e.name);
|
||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||||
else if (s === bestScore && s > 0) tie = true;
|
||||
const parts = photoParts(e.name);
|
||||
let s = scoreOf(parts.roles.size ? parts.person : e.name);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
samePerson,
|
||||
roleFamilies,
|
||||
spotFamilies,
|
||||
photoParts,
|
||||
photoKey,
|
||||
normalizeTeamName,
|
||||
isAllowedTeamName,
|
||||
bestLibraryMatch,
|
||||
@@ -194,5 +314,7 @@ module.exports = {
|
||||
numbersIn,
|
||||
scoreMatch,
|
||||
assignMembersToSlots,
|
||||
noteScore,
|
||||
mixNumbers,
|
||||
MIN_SCORE,
|
||||
};
|
||||
|
||||
@@ -61,7 +61,83 @@ console.log('assignment:', JSON.stringify(out));
|
||||
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||
// 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');
|
||||
// tie → no match (two identical-scoring photos)
|
||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
|
||||
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 ✓');
|
||||
}
|
||||
|
||||
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
|
||||
{
|
||||
const P = (label, name, personId) => ({ label, name, personId });
|
||||
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
|
||||
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
||||
console.log('one spot per person: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
|
||||
{
|
||||
const { noteScore } = require('./matching');
|
||||
const NA_SLOTS = [
|
||||
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
|
||||
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
|
||||
];
|
||||
const P = (label, name, personId, note) => ({ label, name, personId, note });
|
||||
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
|
||||
const oct25 = [
|
||||
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
|
||||
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
|
||||
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
|
||||
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
|
||||
];
|
||||
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
|
||||
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
|
||||
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
|
||||
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
|
||||
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
|
||||
// A note never pulls someone onto a different mix.
|
||||
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
|
||||
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 ✓');
|
||||
}
|
||||
|
||||
// --- 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,7 +1,7 @@
|
||||
{
|
||||
"id": "band-lineup",
|
||||
"name": "Band Lineup",
|
||||
"version": "1.11.0",
|
||||
"version": "1.13.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||
"client": "client.js",
|
||||
|
||||
@@ -32,7 +32,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
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 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 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
|
||||
// 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}`); } }
|
||||
|
||||
const CHOIR_LABEL = 'CHOIR';
|
||||
@@ -247,7 +268,7 @@ module.exports = {
|
||||
return {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||
plan: null,
|
||||
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
|
||||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||
library: [], choir: choirOn,
|
||||
};
|
||||
@@ -313,7 +334,7 @@ module.exports = {
|
||||
* 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 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 scanFolder() {
|
||||
if (!photoFolder) return false;
|
||||
@@ -449,11 +470,13 @@ module.exports = {
|
||||
if (!e) return null;
|
||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||
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 || '';
|
||||
name = String(e.pco.name || '').trim();
|
||||
file = e.pco.file || '';
|
||||
birthday = e.pco.birthday || '';
|
||||
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
||||
file = (lib && lib.file) || e.pco.file || '';
|
||||
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
||||
} else if (e.personId) {
|
||||
const lib = library[e.personId];
|
||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||
@@ -493,7 +516,8 @@ module.exports = {
|
||||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||
if (pco.configured && serviceTypeId) {
|
||||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
|
||||
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
|
||||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||
else {
|
||||
|
||||
@@ -511,6 +535,7 @@ module.exports = {
|
||||
personId,
|
||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||
label: String(m.attributes?.team_position_name || '').trim(),
|
||||
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||
});
|
||||
if (personId) seen.add(personId);
|
||||
@@ -519,15 +544,29 @@ 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 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 is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||
// 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
|
||||
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
|
||||
const eff = effectiveSlots();
|
||||
const planId = String(plan.id);
|
||||
const openIdx = []; const manualNames = new Set();
|
||||
for (let i = 0; i < eff.length; i += 1) {
|
||||
const e = board[String(i)];
|
||||
if (e && (e.personId || e.name)) {
|
||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||||
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (nm) manualNames.add(normName(nm));
|
||||
} else openIdx.push(i);
|
||||
}
|
||||
@@ -537,11 +576,12 @@ module.exports = {
|
||||
for (let k = 0; k < openIdx.length; k += 1) {
|
||||
const i = openIdx[k]; const m = assigned[k];
|
||||
if (m) {
|
||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||
} else if (board[String(i)] && board[String(i)].pco) {
|
||||
delete board[String(i)]; // was synced, no longer on the plan
|
||||
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
|
||||
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
|
||||
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)]) {
|
||||
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||||
}
|
||||
}
|
||||
saveBoard();
|
||||
@@ -580,6 +620,9 @@ module.exports = {
|
||||
current = {
|
||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||
planId: () => (latest.plan && latest.plan.id) || '',
|
||||
rememberPhoto,
|
||||
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
|
||||
photoUrlFor, libraryList,
|
||||
setChoir, choirOn: () => choirOn,
|
||||
};
|
||||
@@ -670,6 +713,10 @@ module.exports = {
|
||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||
const a = current.board[from]; const b = current.board[to];
|
||||
// Moving a spot is a hand placement for this plan: Sync must not move it back.
|
||||
const plan = current.planId();
|
||||
if (a) a.plan = plan;
|
||||
if (b) b.plan = plan;
|
||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
@@ -684,7 +731,14 @@ module.exports = {
|
||||
const personId = String(q.get('personId') || '').trim();
|
||||
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.board[slot] = { personId, name: '' };
|
||||
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];
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
@@ -702,6 +756,7 @@ module.exports = {
|
||||
const name = String(q.get('name') || '').trim();
|
||||
const existing = current.board[index] || { personId: '', name: '' };
|
||||
existing.name = name;
|
||||
existing.plan = current.planId();
|
||||
if (!existing.personId && !name) delete current.board[index];
|
||||
else current.board[index] = existing;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
|
||||
@@ -230,3 +230,34 @@
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
/* Drag: the floating photo that follows the pointer (appended to <body>, so it scopes itself). */
|
||||
[data-module="band-lineup"].bl-drag-ghost {
|
||||
position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none;
|
||||
width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden;
|
||||
border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92;
|
||||
}
|
||||
[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||
[data-module="band-lineup"] .bl-lib-item,
|
||||
[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
[data-module="band-lineup"] .bl-edit-slot img,
|
||||
[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; }
|
||||
[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 .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; }
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
|
||||
el.sources.hidden = !on('showSources') || !s.sources.length;
|
||||
if (!el.sources.hidden) {
|
||||
el.sources.innerHTML = s.sources.map((x) => `
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
|
||||
<span class="vc-dot"></span>${esc(x.label)}
|
||||
</span>`).join('');
|
||||
}
|
||||
@@ -186,6 +186,8 @@ export default function create({ root, moduleApi }) {
|
||||
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
||||
const connected = s.sources.filter((x) => x.connected);
|
||||
const waiting = s.sources.filter((x) => !x.connected);
|
||||
const off = s.sources.filter((x) => x.offNetwork);
|
||||
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
|
||||
if (!connected.length) {
|
||||
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
||||
return moduleApi.setStatus('error', why);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "vista-cues",
|
||||
"name": "Vista Cues",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
||||
"permissions": [
|
||||
@@ -131,6 +131,13 @@
|
||||
"default": true,
|
||||
"group": "This machine",
|
||||
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
|
||||
},
|
||||
"wiredNetwork": {
|
||||
"type": "string",
|
||||
"label": "Wired network",
|
||||
"default": "",
|
||||
"group": "This machine",
|
||||
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
|
||||
}
|
||||
},
|
||||
"configGroups": {
|
||||
|
||||
@@ -49,6 +49,44 @@ function emptyState(extra = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
|
||||
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
|
||||
* session once per network, so a sender typed or picked from the wrong copy
|
||||
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
|
||||
* throws on a typo so the admin page says so at once.
|
||||
*/
|
||||
function parseNets(text) {
|
||||
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
|
||||
const parts = m ? m.slice(1, 5).map(Number) : [];
|
||||
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
|
||||
if (!m || parts.some((x) => x > 255) || bits > 32) {
|
||||
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
|
||||
}
|
||||
return { base: ipNumber(parts.join('.')), bits, text: n };
|
||||
});
|
||||
}
|
||||
|
||||
function ipNumber(a) {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
|
||||
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an address is not on the wired network: '' when it is (or there is no
|
||||
* rule), 'by name' for a host name (it resolves to whichever network answers
|
||||
* first), 'not wired' for an address outside every wired network.
|
||||
*/
|
||||
function offNetwork(nets, address) {
|
||||
if (!nets.length || !address) return '';
|
||||
const v = ipNumber(address);
|
||||
if (v === null) return 'by name';
|
||||
if (address.startsWith('127.')) return '';
|
||||
const span = (bits) => 2 ** (32 - bits);
|
||||
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
|
||||
}
|
||||
|
||||
function endpointOf(v) {
|
||||
const ep = v && typeof v === 'object' ? v : {};
|
||||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||||
@@ -62,6 +100,8 @@ module.exports = {
|
||||
const exclusive = config.exclusive === true;
|
||||
const headTakesOver = config.headTakesOver !== false;
|
||||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||||
const nets = parseNets(config.wiredNetwork);
|
||||
const netText = nets.map((n) => n.text).join(', ');
|
||||
if (mappingErrors.length) {
|
||||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||||
}
|
||||
@@ -187,6 +227,9 @@ module.exports = {
|
||||
label: s.label,
|
||||
name: p ? p.name : '',
|
||||
address: inv?.address || s.host,
|
||||
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
|
||||
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
|
||||
host: s.host,
|
||||
configured: true,
|
||||
connected: Boolean(p),
|
||||
error: p ? '' : (inv?.error || ''),
|
||||
@@ -195,7 +238,7 @@ module.exports = {
|
||||
}
|
||||
for (const p of session.participants.values()) {
|
||||
if (seen.has(p.key) || !p.connected) continue;
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -219,6 +262,7 @@ module.exports = {
|
||||
sessionName,
|
||||
port,
|
||||
advertised,
|
||||
wiredNetwork: netText,
|
||||
sources: sources(),
|
||||
current: cur || null,
|
||||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||||
@@ -304,6 +348,16 @@ module.exports = {
|
||||
health() {
|
||||
if (session.error) return { status: 'error', message: session.error };
|
||||
const src = sources();
|
||||
// Off the wired network: still joined (the cues keep working), but said first and in red.
|
||||
const off = src.filter((s) => s.offNetwork);
|
||||
if (off.length) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${off.map((s) => (s.offNetwork === 'by name'
|
||||
? `${s.label} is set by name (${s.host})`
|
||||
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
|
||||
};
|
||||
}
|
||||
const on = src.filter((s) => s.connected);
|
||||
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||||
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||||
@@ -323,6 +377,9 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
|
||||
// For the tests.
|
||||
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
|
||||
|
||||
/**
|
||||
* One picker entry, so the + menu gives Vista Cues a heading of its own —
|
||||
* a module with no list sits headless under whichever module precedes it
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
}
|
||||
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
|
||||
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
|
||||
@container (max-height: 140px) {
|
||||
[data-module="vista-cues"] .vc-sources { display: none; }
|
||||
}
|
||||
|
||||
@@ -363,6 +363,53 @@ async function freePair() {
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('wired network: addresses outside it, and names, are flagged', () => {
|
||||
const mod = require('./server');
|
||||
const off = mod._offNetwork;
|
||||
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
|
||||
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
|
||||
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
|
||||
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
|
||||
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
|
||||
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
|
||||
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
|
||||
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
|
||||
});
|
||||
|
||||
await test('server entry: a sender off the wired network still joins, and says so', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake Tracks');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
|
||||
const handle = mod.init({
|
||||
config: {
|
||||
sessionPort: port,
|
||||
advertise: false,
|
||||
wiredNetwork: '10.1.0.0/16',
|
||||
source2Name: 'Tracks',
|
||||
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
|
||||
cueLists: '60 = Walk-in',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
await until(() => sender.peers.length > 0); // joined all the same
|
||||
const h = handle.health();
|
||||
assert.strictEqual(h.status, 'error');
|
||||
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
|
||||
const routes = mod.routes({});
|
||||
let body = '';
|
||||
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
|
||||
const st = JSON.parse(body).state;
|
||||
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
|
||||
assert.strictEqual(st.sources[0].offNetwork, 'by name');
|
||||
handle.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
|
||||
Reference in New Issue
Block a user