Compare commits

...
Author SHA1 Message Date
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
christian 6da6f4c31e Merge pull request 'Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red' (#52) from claude/vista-cues-wired-network into main 2026-10-20 13:27:42 -04:00
christian 8e4a0e849e Merge pull request 'Band Lineup 1.12.0: Sync uses Planning Center notes for mics, keeps your moves, and dragging works on touch' (#51) from claude/band-lineup-drag into main 2026-10-20 13:17:33 -04:00
christianandClaude Opus 5.5 df2f0494f8 Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red
The Macs at NA are also on Wi-Fi and the Dante networks, and Bonjour lists each MIDI session once
per network. On 2026-10-20 the Tracks and Companion senders were set to Wi-Fi addresses, so the
lighting MIDI ProdDash hears rode Wi-Fi. With "Wired network" set (This machine; e.g. 10.1.0.0/16,
several with commas), a sender typed as a name or as an address outside it, and a session that
joins us from outside it, turn the admin status line and the tile's sender chip red, naming the
sender. It still joins, so the cues keep showing. Empty (the default) changes nothing; a typo in
the setting fails the module in /admin with a plain message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:14:44 -04:00
christianandClaude Opus 5.5 0548cb54fa Band Lineup 1.12.0: Sync puts the leader and vocalists on the mic their Planning Center note names
The booth writes each vocalist's and the leader's mic into their Planning
Center note every week ("Mix 8, VOX 5", "Mix 1, Vox 1"), because those
change week to week and the position name ("Vocalist") carries no number.
Sync ignored the notes and dropped the singers onto Vox 2-5 in whatever
order Planning Center listed them.

Sync now reads each team member's note and places anyone whose note names
a spot there first: the mix number must agree with the slot's MIX, and the
role ("VOX 5", "Drums") is scored like a position. "NA" or a blank note
says nothing. Everyone else is placed by position as before. Tests use the
real Oct 25 and Nov 1 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:14:39 -04:00
christianandClaude Opus 5.5 c1152b4ab3 Band Lineup 1.12.0: Sync keeps your moves and puts each person in one spot
Two Sync problems found by checking the real October 25 plan against the
board:

- Someone on the plan twice (Mike Powell is Leader and Guitar [Acoustic])
  could fill two spots, pushing a real player off the board. Which spot
  the duplicate took depended on the order Planning Center returned
  people, so it only happened some weeks. Assignment now uses each person
  once.
- Moving a synced person to another spot was undone by the next Sync,
  which re-placed everyone it had filled. A move is now a hand placement
  for the current plan and Sync leaves it alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:11:56 -04:00
christianandClaude Opus 5.5 a692b6a9e2 Band Lineup 1.12.0: dragging photos into spots works with a finger and stops dropping out
The editor used the browser's built-in drag-and-drop, which never fires for
a touchscreen, flickered its drop highlight over a card's inner parts, and
lost the drag whenever a board update arrived mid-drag (the editor was
rebuilt under the pointer).

Dragging is now driven by pointer events, one path for mouse and touch: a
mouse drags as soon as it moves, a finger holds still for a moment first so
a swipe still scrolls. A floating copy of the photo follows the pointer, the
spot under it lights up, the editor auto-scrolls near its edges, and board
updates wait until the drop. Tap a photo then tap a spot still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:06:57 -04:00
christian e670e1ee72 Merge pull request 'Band Lineup 1.11.1: Sync replaces last week's hand-placed people' (#50) from claude/band-lineup-sync-replaces into main 2026-10-13 11:05:08 -04:00
christianandClaude Opus 5.5 4b0f161c93 Band Lineup 1.11.1: Sync replaces last week's hand-placed people
Sync only filled empty spots and spots it had filled itself, so anyone
dragged in by hand stayed forever — pressing Sync for a new week swapped
some photos and left others, depending on how each spot got filled.

Hand placements are now stamped with the plan they were made for. Sync
keeps the ones made for the plan it is loading (a sub dragged in this
week survives a re-sync) and replaces or clears the rest. The last
synced plan is saved so placements made after a restart stamp right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:03:31 -04:00
christian dafb48d9ed Merge pull request 'Vista Cues 1.0.1: its own heading in the + picker instead of sitting under Slack' (#49) from claude/vista-cues-picker into main 2026-10-11 11:40:50 -04:00
12 changed files with 599 additions and 95 deletions
+22 -5
View File
@@ -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 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, drag between slots title bar opens that library — the spots pinned on top, the photos (with a
to swap, rename or clear. Planning Center is optional auto-fill on top, not name search) scrolling below — drag a photo onto a slot (mouse, or press and
the source of truth: **Sync** reads the upcoming plan's team members and hold on a touchscreen), drag between slots to swap, rename or clear.
drops each into the slot matching their position, filling only slots nobody Dropping a photo onto the person already in a spot makes it *their* photo
placed by hand, so a guest pinned on Thursday survives Sunday's sync. 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 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 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 is one. Admin config: Planning Center credentials, service type and plan, the
+144 -56
View File
@@ -1,5 +1,5 @@
// Band Lineup — client. Renders the shared stage board from /state + /stream. // 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 // 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 // come back over SSE, so every Band Lineup screen stays in sync. Birthday week
// (Wed→Tue) rains confetti. // (Wed→Tue) rains confetti.
@@ -14,26 +14,108 @@ export default function create({ root, moduleApi }) {
let selectedLibId = ''; // click-to-place fallback for touch let selectedLibId = ''; // click-to-place fallback for touch
const canvases = new Set(); const canvases = new Set();
let raf = null; let raf = null;
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably). /* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
let dragScrolling = false; * Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
let dragY = 0; * flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
let dragBody = null; * drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
let dragRaf = null; * a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
function startDragAutoScroll() { * follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
if (dragRaf) return; * edges. Board updates that arrive during a drag wait until it ends. */
const tick = () => { const DRAG_MOVE_PX = 6;
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; } const TOUCH_HOLD_MS = 220;
const r = dragBody.getBoundingClientRect(); let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
const zone = 90; // generous edge band so you don't have to be pixel-accurate let pending = null; // a pointerdown that may become a drag
let dy = 0; let renderHeld = false; // a state update arrived mid-drag; render when it ends
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; function slotUnder(x, y) {
if (dy) dragBody.scrollTop += dy; const hit = document.elementFromPoint(x, y);
dragRaf = requestAnimationFrame(tick); const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
}; return cell && root.contains(cell) ? cell : null;
dragRaf = requestAnimationFrame(tick);
} }
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) ---------- */ /* ---------- confetti (always animates) ---------- */
function makeParticle(w, h, intensity, seedY) { 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 stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
function render() { function render() {
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; }
@@ -158,13 +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');
// Feed the drag auto-scroller the pointer position anywhere over the editor.
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
// Slots (drop targets) // Slots (drop targets)
const boardEl = el('div', 'bl-edit-board'); 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', '+')); photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
cell.appendChild(photo); cell.appendChild(photo);
// An occupied card is a drag SOURCE too — move/swap it to another slot. // An occupied card is a drag SOURCE too — move/swap it to another slot.
if (slot.person) { if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
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);
}
const nameRow = el('div', 'bl-edit-namerow'); const nameRow = el('div', 'bl-edit-namerow');
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…'); const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.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); } 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(nameRow);
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`)); cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
// drop 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('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); });
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.'));
for (const person of list) { 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'); 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 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…'); 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'; 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' }); }); 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.appendChild(del);
item.addEventListener('dragstart', (e) => { makeDraggable(item, 'lib', person.id, body, person.name);
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy'; item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
dragBody = body; dragScrolling = true; startDragAutoScroll();
});
item.addEventListener('dragend', stopDragAutoScroll);
item.addEventListener('click', () => { 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) {
@@ -260,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>';
@@ -293,6 +377,10 @@ export default function create({ root, moduleApi }) {
stopConfetti(); stopConfetti();
stopParallax(); stopParallax();
stopDragAutoScroll(); stopDragAutoScroll();
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
document.removeEventListener('touchmove', onTouchMove);
root.innerHTML = ''; root.innerHTML = '';
}, },
}; };
+133 -11
View File
@@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) {
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot. * member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
*/ */
const MIN_SCORE = 60; 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) { function assignMembersToSlots(slots, members) {
const pool = members.slice(); 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); const role = slotRoleText(slotLabel);
let bestIndex = -1; let bestIndex = -1;
let bestScore = MIN_SCORE - 1; let bestScore = MIN_SCORE - 1;
@@ -128,18 +180,66 @@ function assignMembersToSlots(slots, members) {
if (score > bestScore) { bestScore = score; bestIndex = i; } if (score > bestScore) { bestScore = score; bestIndex = i; }
} }
if (bestIndex < 0) return null; 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 * 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);
@@ -171,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,
@@ -194,5 +314,7 @@ module.exports = {
numbersIn, numbersIn,
scoreMatch, scoreMatch,
assignMembersToSlots, assignMembersToSlots,
noteScore,
mixNumbers,
MIN_SCORE, MIN_SCORE,
}; };
+78 -2
View File
@@ -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'); 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 ✓');
} }
// --- 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 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "band-lineup", "id": "band-lineup",
"name": "Band Lineup", "name": "Band Lineup",
"version": "1.11.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",
+72 -17
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';
@@ -247,7 +268,7 @@ module.exports = {
return { return {
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId), configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
reachable: false, lastError: '', lastFetched: 0, columns, 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 })), slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
library: [], choir: choirOn, library: [], choir: choirOn,
}; };
@@ -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
@@ -493,7 +516,8 @@ module.exports = {
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
if (pco.configured && serviceTypeId) { if (pco.configured && serviceTypeId) {
const plan = pickPlan(await listPlans(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.'; } if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
else { else {
@@ -511,6 +535,7 @@ module.exports = {
personId, personId,
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown', name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
label: String(m.attributes?.team_position_name || '').trim(), 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(), photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
}); });
if (personId) seen.add(personId); 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 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 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 eff = effectiveSlots();
const planId = String(plan.id);
const openIdx = []; const manualNames = new Set(); const openIdx = []; const manualNames = new Set();
for (let i = 0; i < eff.length; i += 1) { for (let i = 0; i < eff.length; i += 1) {
const e = board[String(i)]; const e = board[String(i)];
if (e && (e.personId || e.name)) { if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name; const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
if (nm) manualNames.add(normName(nm)); if (nm) manualNames.add(normName(nm));
} else openIdx.push(i); } else openIdx.push(i);
} }
@@ -537,11 +576,12 @@ 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);
} else if (board[String(i)] && board[String(i)].pco) { board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
delete board[String(i)]; // was synced, no longer on the plan } else if (board[String(i)]) {
delete board[String(i)]; // synced or stale, and no longer on the plan
} }
} }
saveBoard(); saveBoard();
@@ -580,6 +620,9 @@ module.exports = {
current = { current = {
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) || '',
rememberPhoto,
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
photoUrlFor, libraryList, photoUrlFor, libraryList,
setChoir, choirOn: () => choirOn, setChoir, choirOn: () => choirOn,
}; };
@@ -670,6 +713,10 @@ module.exports = {
const from = String(p.get('from') || ''); const to = String(p.get('to') || ''); 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.' }); 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]; 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 (a === undefined) delete current.board[to]; else current.board[to] = a;
if (b === undefined) delete current.board[from]; else current.board[from] = b; if (b === undefined) delete current.board[from]; else current.board[from] = b;
current.saveBoard(); current.rebuildLocal(); current.saveBoard(); current.rebuildLocal();
@@ -684,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: '' }; 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 });
@@ -702,6 +756,7 @@ module.exports = {
const name = String(q.get('name') || '').trim(); const name = String(q.get('name') || '').trim();
const existing = current.board[index] || { personId: '', name: '' }; const existing = current.board[index] || { personId: '', name: '' };
existing.name = name; existing.name = name;
existing.plan = current.planId();
if (!existing.personId && !name) delete current.board[index]; if (!existing.personId && !name) delete current.board[index];
else current.board[index] = existing; else current.board[index] = existing;
current.saveBoard(); current.rebuildLocal(); current.saveBoard(); current.rebuildLocal();
+31
View File
@@ -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; 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; } [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; }
+3 -1
View File
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
el.sources.hidden = !on('showSources') || !s.sources.length; el.sources.hidden = !on('showSources') || !s.sources.length;
if (!el.sources.hidden) { if (!el.sources.hidden) {
el.sources.innerHTML = s.sources.map((x) => ` 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 class="vc-dot"></span>${esc(x.label)}
</span>`).join(''); </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'); if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
const connected = s.sources.filter((x) => x.connected); const connected = s.sources.filter((x) => x.connected);
const waiting = 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) { if (!connected.length) {
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected'; const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
return moduleApi.setStatus('error', why); return moduleApi.setStatus('error', why);
+8 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "vista-cues", "id": "vista-cues",
"name": "Vista Cues", "name": "Vista Cues",
"version": "1.0.1", "version": "1.1.0",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista", "description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
"permissions": [ "permissions": [
@@ -131,6 +131,13 @@
"default": true, "default": true,
"group": "This machine", "group": "This machine",
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)." "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": { "configGroups": {
+58 -1
View File
@@ -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) { function endpointOf(v) {
const ep = v && typeof v === 'object' ? v : {}; const ep = v && typeof v === 'object' ? v : {};
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 }; return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
@@ -62,6 +100,8 @@ module.exports = {
const exclusive = config.exclusive === true; const exclusive = config.exclusive === true;
const headTakesOver = config.headTakesOver !== false; const headTakesOver = config.headTakesOver !== false;
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC }); 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) { if (mappingErrors.length) {
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`); 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, label: s.label,
name: p ? p.name : '', name: p ? p.name : '',
address: inv?.address || s.host, 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, configured: true,
connected: Boolean(p), connected: Boolean(p),
error: p ? '' : (inv?.error || ''), error: p ? '' : (inv?.error || ''),
@@ -195,7 +238,7 @@ module.exports = {
} }
for (const p of session.participants.values()) { for (const p of session.participants.values()) {
if (seen.has(p.key) || !p.connected) continue; 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; return out;
} }
@@ -219,6 +262,7 @@ module.exports = {
sessionName, sessionName,
port, port,
advertised, advertised,
wiredNetwork: netText,
sources: sources(), sources: sources(),
current: cur || null, current: cur || null,
// The rest in sheet order of their group, so a colour chip stays put as it changes. // The rest in sheet order of their group, so a colour chip stays put as it changes.
@@ -304,6 +348,16 @@ module.exports = {
health() { health() {
if (session.error) return { status: 'error', message: session.error }; if (session.error) return { status: 'error', message: session.error };
const src = sources(); 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 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 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'; 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 — * 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 * a module with no list sits headless under whichever module precedes it
+2
View File
@@ -180,6 +180,8 @@
} }
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); } [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-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) { @container (max-height: 140px) {
[data-module="vista-cues"] .vc-sources { display: none; } [data-module="vista-cues"] .vc-sources { display: none; }
} }
+47
View File
@@ -363,6 +363,53 @@ async function freePair() {
await wait(80); 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`); console.log(`\n${passed} passed`);
process.exit(0); process.exit(0);
})().catch((e) => { })().catch((e) => {