Files
band-lineup-display/renderer.js
T
christianandClaude Opus 5 96ac2dec2d Band Lineup Display, the backstage app itself
The Electron app that runs on the backstage PC: stage lineup photos with
mic/pack labels, packaged for Windows. It also carries the ProPresenter
Now/Next feature in two forms that share one engine — propresenter-core, where
every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone
display it serves.

Until now this existed only on the production machine and as a read-only
snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build
output, and credentials, photos and processed images are written to the
Electron userData directory, never here. tmp/pp-mock.js stays — it is the
ProPresenter mock ProdDash's own CLAUDE.md tells people to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:41:21 -04:00

2640 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
if (window.__bandLineupRendererLoaded) {
console.warn('renderer.js already loaded; skipping duplicate initialization');
} else {
window.__bandLineupRendererLoaded = true;
/**
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
* @typedef {{ personId: string, label: string }} StageSlot
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
*/
const DEFAULT_COLUMNS = 7;
const DEFAULT_ROWS = 2;
const DRAG_THRESHOLD = 14;
const TOUCH_HOLD_MS = 420;
const DOUBLE_TAP_MS = 325;
const DOUBLE_TAP_DISTANCE = 24;
const CHROME_HIDE_MS = 12000;
/** @type {LibraryPerson[]} */
let library = [];
let stageColumns = DEFAULT_COLUMNS;
let stageRows = DEFAULT_ROWS;
let parallaxEnabled = true;
let pcoServiceTypeId = '';
/** @type {PcoDisplayPosition[]} */
let pcoDisplayPositions = [];
let pcoPositionOptions = [];
let slotLabelMemory = [];
let slotNameMemory = [];
/** Birthday Mode display settings (persisted). */
let birthdayMode = { enabled: true, intensity: 'subtle' };
/** Person id forced into Birthday Mode for testing — in-memory only, never saved, clears on restart. */
let birthdayTestPersonId = '';
/** @type {StageSlot[]} */
let stageSlots = createEmptySlots();
const imageUrlCache = new Map();
/** @type {null | { enabled: boolean, reachable: boolean, lastError: string, needsPlaylist: boolean, playlistName: string, items: Array<{uuid:string,name:string,type:string,isHidden:boolean,isPco:boolean,slideCount:number|null}>, activeIndex: number, activePresentationName: string, currentSlide: number, currentSlideTotal: number }} */
let ppData = null;
let saveTimer = null;
let chromeHideTimer = 0;
/** When true, chrome was shown via double-tap edit mode: stay visible until double-tap dismisses (no idle hide). */
let chromeEditLocked = false;
/** @type {null | { type: 'library' | 'stage', personId: string, slotIndex: number, startX: number, startY: number, pointerId: number, pointerType: string, el: HTMLElement, active: boolean, cancelled: boolean, holdTimer: number | null }} */
let pointerDrag = null;
/** @type {{ type: 'name' | 'slotName' | 'slotLabel', personId: string | null, slotIndex: number }} */
let labelEdit = { type: 'slotLabel', personId: null, slotIndex: -1 };
/** @type {HTMLInputElement | null} */
let numericEditTarget = null;
let numericEditCommit = null;
let numericEditMin = 1;
let numericEditMax = Number.POSITIVE_INFINITY;
let numericEditOnInvalid = null;
/** @type {{ time: number, x: number, y: number, pointerType: string, source: string } | null} */
let lastTap = null;
let lastPointerType = 'mouse';
const row = document.getElementById('row');
const empty = document.getElementById('empty');
const dropZone = document.getElementById('drop-zone');
const toolbar = document.getElementById('toolbar');
const libraryList = document.getElementById('library-list');
const libraryPanel = document.getElementById('library-panel');
const btnAdd = document.getElementById('btn-add');
const btnFullscreen = document.getElementById('btn-fullscreen');
const btnApplyLayout = document.getElementById('btn-apply-layout');
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
const btnPcoConnect = document.getElementById('btn-pco-connect');
const btnPcoSync = document.getElementById('btn-pco-sync');
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
const pcoBirthdayResult = document.getElementById('pco-birthday-result');
const bdayEnabled = document.getElementById('bday-enabled');
const bdayIntensity = document.getElementById('bday-intensity');
const bdayControls = document.getElementById('bday-controls');
const bdayTestSelect = document.getElementById('bday-test');
const btnPcoAddPosition = document.getElementById('btn-pco-add-position');
const inputLayoutCols = document.getElementById('layout-cols');
const inputLayoutRows = document.getElementById('layout-rows');
const slotSizeReadout = document.getElementById('slot-size-readout');
const pcoServiceTypeSelect = document.getElementById('pco-service-type');
const pcoPositionOptionSelect = document.getElementById('pco-position-option');
const pcoPositionList = document.getElementById('pco-position-list');
const pcoStatus = document.getElementById('pco-status');
const dragGhost = document.getElementById('drag-ghost');
const labelModal = document.getElementById('label-modal');
const labelTitle = document.getElementById('label-modal-title');
const labelInput = document.getElementById('label-input');
const labelDone = document.getElementById('label-done');
const labelCancel = document.getElementById('label-cancel');
const labelBackdrop = document.getElementById('label-modal-backdrop');
const numberModal = document.getElementById('number-modal');
const numberModalTitle = document.getElementById('number-modal-title');
const numberInput = document.getElementById('number-input');
const numberDone = document.getElementById('number-done');
const numberCancel = document.getElementById('number-cancel');
const numberBackdrop = document.getElementById('number-modal-backdrop');
const numberKeyboard = document.getElementById('number-keyboard');
const pcoModal = document.getElementById('pco-modal');
const pcoModalBackdrop = document.getElementById('pco-modal-backdrop');
const pcoClientIdInput = document.getElementById('pco-client-id');
const pcoSecretInput = document.getElementById('pco-secret');
const pcoSaveButton = document.getElementById('pco-save');
const pcoCancelButton = document.getElementById('pco-cancel');
const ppColumn = document.getElementById('pp-column');
const ppPlaylistName = document.getElementById('pp-playlist-name');
const ppNow = document.getElementById('pp-now');
const ppNextLabel = document.getElementById('pp-next-label');
const ppNextList = document.getElementById('pp-next-list');
const ppStatus = document.getElementById('pp-status');
const ppPlaylistSelect = document.getElementById('pp-playlist-select');
const btnPpConnect = document.getElementById('btn-pp-connect');
const btnPpDisconnect = document.getElementById('btn-pp-disconnect');
const ppModal = document.getElementById('pp-modal');
const ppModalBackdrop = document.getElementById('pp-modal-backdrop');
const ppHostInput = document.getElementById('pp-host');
const ppPortInput = document.getElementById('pp-port');
const ppPasswordInput = document.getElementById('pp-password');
const ppSaveButton = document.getElementById('pp-save');
const ppCancelButton = document.getElementById('pp-cancel');
const libraryTabs = document.getElementById('library-tabs');
const appVersionEl = document.getElementById('app-version');
const btnDisplayToggle = document.getElementById('btn-display-toggle');
const displayStatusEl = document.getElementById('display-status');
const displayUrlsEl = document.getElementById('display-urls');
const displayPortInput = document.getElementById('display-port');
const VK_ROWS = [
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'],
['Z', 'X', 'C', 'V', 'B', 'N', 'M'],
];
const parallaxAssetCache = new Map();
const parallaxViewStates = new Set();
let parallaxAnimationFrame = 0;
function getStageSlotCount() {
return stageColumns * stageRows;
}
function createEmptySlots(count = getStageSlotCount()) {
return Array.from({ length: count }, (_slot, index) => ({
personId: '',
label: slotLabelMemory[index] || '',
}));
}
function ensureSlotLabelMemorySize(count) {
while (slotLabelMemory.length < count) {
slotLabelMemory.push('');
}
}
function ensureSlotNameMemorySize(count) {
while (slotNameMemory.length < count) {
slotNameMemory.push('');
}
}
/**
* @param {unknown} raw
*/
function normalizeLineup(raw, slotCount = getStageSlotCount()) {
ensureSlotLabelMemorySize(slotCount);
ensureSlotNameMemorySize(slotCount);
const normalized = createEmptySlots(slotCount);
if (!Array.isArray(raw)) return normalized;
for (let i = 0; i < Math.min(raw.length, slotCount); i += 1) {
const entry = raw[i];
if (!entry || typeof entry !== 'object') continue;
normalized[i] = {
personId: typeof entry.personId === 'string' ? entry.personId : '',
label: slotLabelMemory[i] || (typeof entry.label === 'string' ? entry.label : ''),
};
}
return normalized;
}
function isModalOpen() {
return (
!labelModal.classList.contains('hidden') ||
!numberModal.classList.contains('hidden') ||
!pcoModal.classList.contains('hidden') ||
!ppModal.classList.contains('hidden')
);
}
function isToolbarActive() {
return toolbar.contains(document.activeElement) || toolbar.matches(':hover');
}
function isLibraryActive() {
return libraryPanel.contains(document.activeElement) || libraryPanel.matches(':hover');
}
function clearChromeHideTimer() {
if (!chromeHideTimer) return;
clearTimeout(chromeHideTimer);
chromeHideTimer = 0;
}
function enterChromeEditMode() {
chromeEditLocked = true;
clearChromeHideTimer();
document.body.classList.remove('chrome-hidden');
}
function exitChromeEditMode() {
chromeEditLocked = false;
clearChromeHideTimer();
document.body.classList.add('chrome-hidden');
}
function toggleChromeEditMode() {
if (chromeEditLocked || !document.body.classList.contains('chrome-hidden')) {
exitChromeEditMode();
return;
}
enterChromeEditMode();
}
function scheduleChromeHide() {
clearChromeHideTimer();
}
function revealChrome() {
enterChromeEditMode();
}
function noteActivity() {
void CHROME_HIDE_MS;
}
function syncLayoutInputs() {
inputLayoutCols.value = String(stageColumns);
inputLayoutRows.value = String(stageRows);
}
function syncParallaxToggle() {
btnToggleParallax.textContent = parallaxEnabled ? 'Parallax On' : 'Parallax Off';
btnToggleParallax.classList.toggle('primary', parallaxEnabled);
}
function setPcoStatus(message, isError = false) {
pcoStatus.textContent = message;
pcoStatus.style.color = isError ? '#ffb1b1' : '#9ea7bc';
}
function setPcoControlsEnabled(enabled) {
btnPcoConnect.disabled = !enabled;
btnPcoSync.disabled = !enabled;
btnPcoTestBirthdays.disabled = !enabled;
btnPcoDisconnect.disabled = !enabled;
btnPcoAddPosition.disabled = !enabled;
pcoServiceTypeSelect.disabled = !enabled;
pcoPositionOptionSelect.disabled = !enabled;
}
function normalizePcoDisplayPosition(entry) {
const count = Number.parseInt(String(entry?.count ?? 1), 10);
return {
teamName: String(entry?.teamName || '').trim(),
positionName: String(entry?.positionName || '').trim(),
count: Number.isFinite(count) && count > 0 ? count : 1,
};
}
function pcoPositionKey(teamName, positionName) {
return `${String(teamName || '').trim().toLowerCase()}::${String(positionName || '').trim().toLowerCase()}`;
}
function renderPcoServiceTypes(serviceTypes) {
const previousValue = pcoServiceTypeId;
pcoServiceTypeSelect.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = 'Choose service type';
pcoServiceTypeSelect.appendChild(placeholder);
for (const serviceType of serviceTypes) {
const option = document.createElement('option');
option.value = serviceType.id;
option.textContent = serviceType.name;
pcoServiceTypeSelect.appendChild(option);
}
pcoServiceTypeSelect.value = previousValue || '';
}
async function refreshPcoServiceTypes() {
const serviceTypes = await window.lineup.pcoServiceTypes();
renderPcoServiceTypes(Array.isArray(serviceTypes) ? serviceTypes : []);
}
function renderPcoPositionOptions() {
const previousValue = pcoPositionOptionSelect.value;
pcoPositionOptionSelect.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = pcoServiceTypeId ? 'Choose position' : 'Choose service type first';
pcoPositionOptionSelect.appendChild(placeholder);
for (const optionData of pcoPositionOptions) {
const option = document.createElement('option');
option.value = optionData.key;
option.textContent = `${optionData.teamName} | ${optionData.positionName}`;
pcoPositionOptionSelect.appendChild(option);
}
pcoPositionOptionSelect.value = previousValue || '';
}
function movePcoDisplayPosition(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= pcoDisplayPositions.length) return;
const next = [...pcoDisplayPositions];
const [entry] = next.splice(index, 1);
next.splice(targetIndex, 0, entry);
pcoDisplayPositions = next;
renderPcoDisplayPositions();
scheduleSave();
}
function updatePcoDisplayPositionCount(index, delta) {
const entry = pcoDisplayPositions[index];
if (!entry) return;
entry.count = Math.max(1, entry.count + delta);
renderPcoDisplayPositions();
scheduleSave();
}
function removePcoDisplayPosition(index) {
pcoDisplayPositions = pcoDisplayPositions.filter((_entry, entryIndex) => entryIndex !== index);
renderPcoDisplayPositions();
scheduleSave();
}
function renderPcoDisplayPositions() {
pcoPositionList.innerHTML = '';
if (!pcoDisplayPositions.length) {
const emptyItem = document.createElement('div');
emptyItem.className = 'pco-position-empty';
emptyItem.textContent = 'Add the positions you want to display. Increase the count if you need multiple slots for a position like Vocalists.';
pcoPositionList.appendChild(emptyItem);
return;
}
for (const [index, entry] of pcoDisplayPositions.entries()) {
const item = document.createElement('div');
item.className = 'pco-position-item';
const copy = document.createElement('div');
copy.className = 'pco-position-copy';
const name = document.createElement('div');
name.className = 'pco-position-name';
name.textContent = entry.positionName;
const team = document.createElement('div');
team.className = 'pco-position-team';
team.textContent = entry.teamName;
copy.appendChild(name);
copy.appendChild(team);
const downCount = document.createElement('button');
downCount.type = 'button';
downCount.className = 'btn btn-small';
downCount.textContent = '-';
downCount.addEventListener('click', () => updatePcoDisplayPositionCount(index, -1));
const countChip = document.createElement('div');
countChip.className = 'pco-count-chip';
countChip.textContent = `x${entry.count}`;
const upCount = document.createElement('button');
upCount.type = 'button';
upCount.className = 'btn btn-small';
upCount.textContent = '+';
upCount.addEventListener('click', () => updatePcoDisplayPositionCount(index, 1));
const up = document.createElement('button');
up.type = 'button';
up.className = 'btn btn-small';
up.textContent = 'Up';
up.addEventListener('click', () => movePcoDisplayPosition(index, -1));
const down = document.createElement('button');
down.type = 'button';
down.className = 'btn btn-small';
down.textContent = 'Down';
down.addEventListener('click', () => movePcoDisplayPosition(index, 1));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn btn-small';
remove.textContent = 'Remove';
remove.addEventListener('click', () => removePcoDisplayPosition(index));
item.appendChild(copy);
item.appendChild(downCount);
item.appendChild(countChip);
item.appendChild(upCount);
item.appendChild(up);
item.appendChild(down);
item.appendChild(remove);
pcoPositionList.appendChild(item);
}
}
async function refreshPcoPositionOptions() {
if (!pcoServiceTypeId) {
pcoPositionOptions = [];
renderPcoPositionOptions();
return;
}
const options = await window.lineup.pcoPositionOptions(pcoServiceTypeId);
pcoPositionOptions = Array.isArray(options) ? options : [];
renderPcoPositionOptions();
}
function openPcoModal() {
pcoModal.classList.remove('hidden');
pcoClientIdInput.focus();
pcoClientIdInput.select();
}
function closePcoModal() {
pcoModal.classList.add('hidden');
pcoSecretInput.value = '';
}
function applyLoadedState(data) {
library = Array.isArray(data.library)
? data.library.map((person) => ({
id: String(person.id),
fileName: String(person.fileName),
originalName: typeof person.originalName === 'string' ? person.originalName : '',
lastLabel: typeof person.lastLabel === 'string' ? person.lastLabel : '',
lastName: typeof person.lastName === 'string' ? person.lastName : '',
source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
// Carried through so a routine save doesn't wipe birthdays a PCO sync stored (main.js
// re-validates the "MM-DD" shape on write); Birthday Mode reads this.
birthday: typeof person?.birthday === 'string' ? person.birthday : '',
}))
: [];
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
const savedRows = Number.parseInt(String(data?.layout?.rows ?? DEFAULT_ROWS), 10);
stageColumns = Number.isFinite(savedColumns) && savedColumns > 0 ? savedColumns : DEFAULT_COLUMNS;
stageRows = Number.isFinite(savedRows) && savedRows > 0 ? savedRows : DEFAULT_ROWS;
parallaxEnabled = typeof data?.parallaxEnabled === 'boolean' ? data.parallaxEnabled : true;
pcoServiceTypeId = typeof data?.pcoServiceTypeId === 'string' ? data.pcoServiceTypeId : '';
pcoDisplayPositions = Array.isArray(data?.pcoDisplayPositions)
? data.pcoDisplayPositions.map((entry) => normalizePcoDisplayPosition(entry)).filter((entry) => entry.teamName && entry.positionName)
: [];
slotLabelMemory = Array.isArray(data.slotLabels)
? data.slotLabels.map((label) => (typeof label === 'string' ? label : ''))
: [];
slotNameMemory = Array.isArray(data.slotNames)
? data.slotNames.map((label) => (typeof label === 'string' ? label : ''))
: [];
ensureSlotLabelMemorySize(getStageSlotCount());
ensureSlotNameMemorySize(getStageSlotCount());
syncLayoutInputs();
syncParallaxToggle();
pcoServiceTypeSelect.value = pcoServiceTypeId;
renderPcoDisplayPositions();
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
birthdayMode = {
enabled: data?.birthdayMode?.enabled === undefined ? true : Boolean(data.birthdayMode.enabled),
intensity: data?.birthdayMode?.intensity === 'full' ? 'full' : 'subtle',
};
syncBirthdayControls();
}
function updateSlotSizeReadout() {
// The readout is no longer in the toolbar. Kept null-safe because several render paths
// still call it; the source-resolution numbers move into the Pictures tab.
if (!slotSizeReadout) return;
const firstSlot = row.querySelector('.stage-slot');
const visual = firstSlot ? firstSlot.querySelector('.slot-visual') : null;
if (!firstSlot || !visual) {
slotSizeReadout.textContent = 'Slot: -- x -- px | Visible image: -- x -- px | Minimum source: -- x -- px | Ideal source: -- x -- px';
return;
}
const slotRect = firstSlot.getBoundingClientRect();
const visualRect = visual.getBoundingClientRect();
const slotWidth = Math.max(1, Math.round(slotRect.width));
const slotHeight = Math.max(1, Math.round(slotRect.height));
const imageWidth = Math.max(1, Math.round(visualRect.width));
const imageHeight = Math.max(1, Math.round(visualRect.height));
const minimumWidth = imageWidth * 2;
const minimumHeight = imageHeight * 2;
const idealWidth = imageWidth * 3;
const idealHeight = imageHeight * 3;
slotSizeReadout.textContent = `Slot: ${slotWidth} x ${slotHeight} px | Visible image: ${imageWidth} x ${imageHeight} px | Minimum source: ${minimumWidth} x ${minimumHeight} px | Ideal source: ${idealWidth} x ${idealHeight} px`;
}
function resizeStageSlots(nextColumns, nextRows) {
const nextCount = nextColumns * nextRows;
for (let i = 0; i < stageSlots.length; i += 1) {
slotLabelMemory[i] = stageSlots[i].label || '';
}
ensureSlotLabelMemorySize(nextCount);
ensureSlotNameMemorySize(nextCount);
const nextSlots = createEmptySlots(nextCount);
for (let i = 0; i < Math.min(stageSlots.length, nextCount); i += 1) {
nextSlots[i] = {
personId: stageSlots[i].personId || '',
label: slotLabelMemory[i] || '',
};
}
stageColumns = nextColumns;
stageRows = nextRows;
stageSlots = nextSlots;
}
async function applyLayoutChange() {
const nextColumns = Number.parseInt(inputLayoutCols.value, 10);
const nextRows = Number.parseInt(inputLayoutRows.value, 10);
const safeColumns = Number.isFinite(nextColumns) && nextColumns > 0 ? nextColumns : stageColumns;
const safeRows = Number.isFinite(nextRows) && nextRows > 0 ? nextRows : stageRows;
if (safeColumns === stageColumns && safeRows === stageRows) {
syncLayoutInputs();
return;
}
resizeStageSlots(safeColumns, safeRows);
syncLayoutInputs();
await renderStage();
await renderLibrary();
scheduleSave();
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
saveTimer = null;
await window.lineup.saveState({
library,
layout: { columns: stageColumns, rows: stageRows },
parallaxEnabled,
pcoServiceTypeId,
pcoDisplayPositions,
slotLabels: slotLabelMemory,
slotNames: slotNameMemory,
lineup: stageSlots,
birthdayMode,
});
}, 250);
}
function pointInRect(x, y, rect) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
function getPerson(personId) {
return library.find((person) => person.id === personId) || null;
}
/**
* @param {LibraryPerson} person
*/
function getLibraryDisplayName(person) {
if (typeof person.originalName === 'string' && person.originalName.trim()) {
return person.originalName.trim();
}
if (typeof person.lastName === 'string' && person.lastName.trim()) {
return person.lastName.trim();
}
return String(person.fileName || '').replace(/\.[^.]+$/, '');
}
function getStageName(slotIndex, person) {
if (person && typeof person.lastName === 'string' && person.lastName.trim()) {
return person.lastName.trim();
}
return slotNameMemory[slotIndex] || '';
}
async function getImageHref(fileName) {
if (!fileName) return '';
if (imageUrlCache.has(fileName)) {
return imageUrlCache.get(fileName) || '';
}
const href = await window.lineup.imageUrl(fileName);
imageUrlCache.set(fileName, href || '');
return href || '';
}
async function getParallaxAssets(fileName) {
if (!fileName) return null;
if (parallaxAssetCache.has(fileName)) {
return parallaxAssetCache.get(fileName);
}
const result = await window.lineup.parallaxAssets(fileName);
parallaxAssetCache.set(fileName, result);
return result;
}
function ensureParallaxLoop() {
if (parallaxAnimationFrame) return;
const step = (time) => {
parallaxAnimationFrame = 0;
if (!parallaxViewStates.size) return;
for (const state of [...parallaxViewStates]) {
if (!state.root.isConnected) {
parallaxViewStates.delete(state);
continue;
}
const camX = Math.sin(time / 3200 + state.phase) * state.xRange;
const camY = Math.cos(time / 4100 + state.phase) * state.yRange;
const camRot = Math.sin(time / 3600 + state.phase) * state.rotRange;
state.root.style.setProperty('--cam-x', `${camX}px`);
state.root.style.setProperty('--cam-y', `${camY}px`);
state.root.style.setProperty('--cam-rot', `${camRot}deg`);
}
parallaxAnimationFrame = window.requestAnimationFrame(step);
};
parallaxAnimationFrame = window.requestAnimationFrame(step);
}
function setupParallaxViewport(root, seed) {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
parallaxViewStates.add({
root,
phase: (hash % 628) / 100,
xRange: 8 + (hash % 5),
yRange: 5 + ((hash >> 5) % 4),
rotRange: 1.2 + ((hash >> 9) % 8) / 10,
});
ensureParallaxLoop();
}
/**
* @param {PointerEvent | MouseEvent} e
*/
function interactionPointerType(e) {
if ('pointerType' in e && typeof e.pointerType === 'string' && e.pointerType) {
return e.pointerType;
}
return lastPointerType || 'mouse';
}
/**
* @param {HTMLElement | null} target
*/
function shouldIgnoreChromeToggleTarget(target) {
if (!target) return false;
return Boolean(
target.closest(
'button, input, select, textarea, a, .btn, .vk-key, .label-input, .caption, .name-badge, .remove, .lib-delete',
),
);
}
function findStageIndexByPersonId(personId) {
return stageSlots.findIndex((slot) => slot.personId === personId);
}
function getStageSlotIndexFromPoint(clientX, clientY) {
const el = document.elementFromPoint(clientX, clientY);
const slot = el ? el.closest('.stage-slot') : null;
if (!slot) return null;
const idx = Number(slot.dataset.slotIndex);
return Number.isInteger(idx) ? idx : null;
}
function removeFromStageIndex(slotIndex) {
if (slotIndex < 0 || slotIndex >= getStageSlotCount()) return;
stageSlots[slotIndex].personId = '';
}
/**
* Put a person into a slot.
* @param sourceIndex the slot being dragged FROM for a stage move, or -1 for a library drag.
*
* A library drag places into the target and leaves any other slots holding that person
* ALONE — the same photo can fill several slots (e.g. one player covering two mix positions).
* A stage drag still swaps, using the exact slot dragged from rather than searching by
* person, so a duplicated person moves the tile you actually grabbed.
*/
function placePersonIntoSlot(personId, targetIndex, sourceIndex = -1) {
if (targetIndex < 0 || targetIndex >= getStageSlotCount()) return [];
if (sourceIndex === targetIndex) return [];
if (sourceIndex >= 0) {
const targetPersonId = stageSlots[targetIndex].personId;
stageSlots[targetIndex].personId = personId;
stageSlots[sourceIndex].personId = targetPersonId || '';
return [sourceIndex, targetIndex];
}
stageSlots[targetIndex].personId = personId;
return [targetIndex];
}
function showGhostFromImg(imgEl) {
const ghostImage = dragGhost.querySelector('img') || document.createElement('img');
if (!ghostImage.parentElement) dragGhost.appendChild(ghostImage);
ghostImage.src = imgEl.currentSrc || imgEl.src;
dragGhost.classList.remove('hidden');
}
function moveGhost(x, y) {
dragGhost.style.left = `${x - 44}px`;
dragGhost.style.top = `${y - 59}px`;
}
function hideGhost() {
dragGhost.classList.add('hidden');
}
function clearHoldTimer() {
if (pointerDrag && pointerDrag.holdTimer) {
window.clearTimeout(pointerDrag.holdTimer);
pointerDrag.holdTimer = null;
}
}
function startActiveDrag() {
if (!pointerDrag || pointerDrag.active || pointerDrag.cancelled) return;
pointerDrag.active = true;
pointerDrag.el.classList.add('dragging-source');
const image = pointerDrag.el.querySelector('img');
if (image) showGhostFromImg(image);
moveGhost(pointerDrag.startX, pointerDrag.startY);
try {
pointerDrag.el.setPointerCapture(pointerDrag.pointerId);
} catch {
/* ignore */
}
}
function startDragSession(el, state) {
pointerDrag = {
...state,
active: false,
cancelled: false,
holdTimer: null,
};
if (state.pointerType === 'touch') {
pointerDrag.holdTimer = window.setTimeout(() => {
startActiveDrag();
}, TOUCH_HOLD_MS);
}
}
/**
* @param {PointerEvent | MouseEvent} e
* @param {string} source
*/
function maybeToggleChromeOnDoubleActivate(e, source) {
const pointerType = interactionPointerType(e);
if (!pointerType) return;
const now = Date.now();
if (
lastTap &&
lastTap.pointerType === pointerType &&
lastTap.source === source &&
now - lastTap.time <= DOUBLE_TAP_MS &&
Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) <= DOUBLE_TAP_DISTANCE
) {
lastTap = null;
toggleChromeEditMode();
return;
}
lastTap = {
time: now,
x: e.clientX,
y: e.clientY,
pointerType,
source,
};
}
/**
* @param {MouseEvent} e
* @param {string} source
*/
function handleChromeToggleSurfaceClick(e, source) {
if (isModalOpen()) return;
const target = e.target instanceof HTMLElement ? e.target : null;
if (shouldIgnoreChromeToggleTarget(target)) return;
noteActivity();
maybeToggleChromeOnDoubleActivate(e, source);
}
async function handlePointerDrop(clientX, clientY, ctx) {
const targetIndex = getStageSlotIndexFromPoint(clientX, clientY);
const libraryRect = libraryPanel.getBoundingClientRect();
// The whole panel counts as "drop here to remove from the stage" — except the tab strip,
// where a drop would both remove the person and switch tabs. Measured against the panel
// rather than the photo grid on purpose: a hidden grid reports an all-zero rect, which
// would silently disable drag-to-remove whenever another tab is open.
const tabsRect = libraryTabs ? libraryTabs.getBoundingClientRect() : null;
const onTabs = tabsRect ? pointInRect(clientX, clientY, tabsRect) : false;
const onLibrary = !onTabs && pointInRect(clientX, clientY, libraryRect);
if (targetIndex !== null) {
// A stage drag carries its own slot index; a library drag is -1, which lets the same
// photo land in more than one slot instead of being moved out of its current one.
const sourceIndex = ctx.type === 'stage' ? ctx.slotIndex : -1;
const changedSlots = placePersonIntoSlot(ctx.personId, targetIndex, sourceIndex);
if (!changedSlots.length) return;
await renderStage(changedSlots);
if (ctx.type === 'library') {
await renderLibrary();
}
scheduleSave();
return;
}
if (ctx.type === 'stage' && onLibrary) {
removeFromStageIndex(ctx.slotIndex);
await renderStage([ctx.slotIndex]);
await renderLibrary();
scheduleSave();
}
}
function onPointerDown(e) {
lastPointerType = e.pointerType || lastPointerType;
if (isModalOpen()) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const target = /** @type {HTMLElement} */ (e.target);
if (
target.closest('.lib-delete') ||
target.closest('.remove') ||
target.closest('.caption') ||
target.closest('.name-badge') ||
target.closest('.btn') ||
target.closest('.lib-tab') ||
target.closest('.vk-key') ||
target.closest('.label-input')
) {
noteActivity();
return;
}
const libItem = target.closest('.lib-item');
const photoArea = target.closest('.photo-area');
if (libItem) {
const personId = libItem.dataset.personId;
if (!personId) return;
startDragSession(libItem, {
type: 'library',
personId,
slotIndex: -1,
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
pointerType: e.pointerType,
el: libItem,
});
return;
}
if (photoArea) {
const tile = photoArea.closest('.tile');
if (!tile) return;
const personId = tile.dataset.personId;
const slotIndex = Number(tile.dataset.slotIndex);
if (!personId || !Number.isInteger(slotIndex)) return;
startDragSession(tile, {
type: 'stage',
personId,
slotIndex,
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
pointerType: e.pointerType,
el: tile,
});
}
}
function onPointerMove(e) {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
noteActivity();
const dx = e.clientX - pointerDrag.startX;
const dy = e.clientY - pointerDrag.startY;
const distance = Math.hypot(dx, dy);
if (!pointerDrag.active) {
if (pointerDrag.pointerType === 'touch') {
if (distance > DRAG_THRESHOLD) {
if (pointerDrag.holdTimer) {
clearHoldTimer();
pointerDrag.cancelled = true;
}
}
return;
}
if (distance < DRAG_THRESHOLD) return;
startActiveDrag();
}
moveGhost(e.clientX, e.clientY);
}
function onPointerUp(e) {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
const currentDrag = pointerDrag;
const wasActive = currentDrag.active;
clearHoldTimer();
try {
currentDrag.el.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
currentDrag.el.classList.remove('dragging-source');
pointerDrag = null;
hideGhost();
noteActivity();
if (wasActive) {
void handlePointerDrop(e.clientX, e.clientY, {
type: currentDrag.type,
personId: currentDrag.personId,
slotIndex: currentDrag.slotIndex,
});
return;
}
if (currentDrag.cancelled) return;
}
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointermove', onPointerMove, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('pointercancel', onPointerUp, true);
function updateEmpty() {
empty.classList.toggle('visible', stageSlots.every((slot) => !slot.personId));
}
function parallaxMotionVars(seedText) {
let hash = 0;
for (let i = 0; i < seedText.length; i += 1) {
hash = (hash * 31 + seedText.charCodeAt(i)) >>> 0;
}
const directionX = hash % 2 === 0 ? 1 : -1;
const directionY = (hash >> 1) % 2 === 0 ? 1 : -1;
const bgX = 28 + (hash % 18);
const bgY = 18 + ((hash >> 3) % 12);
const midX = 0;
const midY = 0;
const fgX = 14 + ((hash >> 11) % 10);
const fgY = 9 + ((hash >> 14) % 7);
return {
'--p-bg-x': `${directionX * bgX}px`,
'--p-bg-y': `${directionY * bgY}px`,
'--p-mid-x': `${-directionX * midX}px`,
'--p-mid-y': `${directionY * midY}px`,
'--p-fg-x': `${directionX * fgX}px`,
'--p-fg-y': `${-directionY * fgY}px`,
};
}
/* ---------------- Birthday Mode ---------------- */
const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */
const birthdayCanvases = new Set();
let birthdayRaf = null;
/**
* The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday
* anywhere in this window celebrates all week (so it's already up for the weekend services).
* Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday.
*/
let birthdayWeekCache = { key: '', set: null };
function birthdayWeekSet() {
const now = new Date();
const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;
if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set;
const set = new Set();
for (let i = 0; i < 7; i += 1) {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
birthdayWeekCache = { key, set };
return set;
}
/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */
function isBirthdayActive(person) {
if (!birthdayMode.enabled || !person) return false;
if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true;
return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday);
}
/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */
function birthdayTick() {
const before = birthdayWeekCache.key;
birthdayWeekSet();
if (before && birthdayWeekCache.key !== before) void renderStage();
}
setInterval(birthdayTick, 15 * 60 * 1000);
function birthdayMakeParticle(w, h, intensity, seedY) {
const ribbon = Math.random() < 0.55;
return {
x: Math.random() * w,
y: seedY != null ? seedY : Math.random() * h,
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5,
h: ribbon ? 9 + Math.random() * 8 : 0,
ribbon,
color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0],
rot: Math.random() * Math.PI * 2,
vr: -0.14 + Math.random() * 0.28,
sway: 0.6 + Math.random() * 1.1,
phase: Math.random() * Math.PI * 2,
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
};
}
function birthdaySeedCanvas(c, w, h) {
const base = c._intensity === 'subtle' ? 26 : 64;
const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500)));
c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity));
}
// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the
// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine).
function birthdayStepCanvas(c) {
const w = c.clientWidth;
const h = c.clientHeight;
if (!w || !h) return; // not laid out yet — try again next frame
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (c._w !== w || c._h !== h) {
c.width = w * dpr;
c.height = h * dpr;
c._ctx = c.getContext('2d');
c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
c._w = w;
c._h = h;
birthdaySeedCanvas(c, w, h);
}
const ctx = c._ctx;
ctx.clearRect(0, 0, w, h);
for (const p of c._parts) {
p.phase += 0.03;
p.y += p.vy;
p.x += Math.sin(p.phase) * p.sway;
p.rot += p.vr;
if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16));
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
ctx.restore();
}
}
function birthdayLoop() {
for (const c of [...birthdayCanvases]) {
if (!c.isConnected) { birthdayCanvases.delete(c); continue; }
birthdayStepCanvas(c);
}
birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null;
}
function birthdayRegisterCanvas(canvas, intensity) {
canvas._intensity = intensity;
birthdayCanvases.add(canvas);
if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop);
}
/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */
function applyBirthdayEffects(tile, photoArea) {
const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle';
tile.classList.add('birthday', `birthday-${intensity}`);
const confetti = document.createElement('canvas');
confetti.className = 'birthday-confetti';
confetti.setAttribute('aria-hidden', 'true');
photoArea.appendChild(confetti);
birthdayRegisterCanvas(confetti, intensity);
}
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
void parallaxAssets;
const tile = document.createElement('article');
tile.className = 'tile';
tile.dataset.personId = person.id;
tile.dataset.slotIndex = String(slotIndex);
const photoArea = document.createElement('div');
photoArea.className = 'photo-area';
if (!parallaxEnabled) {
const staticImage = document.createElement('img');
staticImage.alt = '';
staticImage.src = href;
staticImage.className = 'static-stage-image';
photoArea.appendChild(staticImage);
} else {
const parallaxViewport = document.createElement('div');
parallaxViewport.className = 'parallax-viewport';
const movingImage = document.createElement('img');
movingImage.alt = '';
movingImage.src = href;
movingImage.className = 'moving-stage-image';
parallaxViewport.appendChild(movingImage);
photoArea.appendChild(parallaxViewport);
setupParallaxViewport(parallaxViewport, `${person.id}:${slotIndex}`);
}
const nameBadge = document.createElement('div');
nameBadge.className = 'name-badge';
nameBadge.textContent = getStageName(slotIndex, person);
nameBadge.addEventListener('click', (e) => {
e.stopPropagation();
openLabelModal({ type: 'name', personId: person.id, slotIndex });
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove';
removeBtn.setAttribute('aria-label', 'Remove from stage');
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeFromStageIndex(slotIndex);
void renderStage([slotIndex]);
void renderLibrary();
scheduleSave();
});
photoArea.appendChild(nameBadge);
photoArea.appendChild(removeBtn);
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
tile.appendChild(photoArea);
return tile;
}
function createStageSlot(slotIndex, slot, href, parallaxAssets) {
const slotEl = document.createElement('div');
const hasPerson = Boolean(slot.personId && href);
slotEl.className = 'stage-slot' + (hasPerson ? '' : ' empty-slot');
slotEl.dataset.slotIndex = String(slotIndex);
slotEl.dataset.slotLabel = `Slot ${slotIndex + 1}`;
const visual = document.createElement('div');
visual.className = 'slot-visual';
if (hasPerson) {
const person = getPerson(slot.personId);
if (person) {
visual.appendChild(createPhotoTile(person, href, slotIndex, parallaxAssets));
}
} else {
const nameHotspot = document.createElement('button');
nameHotspot.type = 'button';
nameHotspot.className = 'empty-name-hotspot';
nameHotspot.setAttribute('aria-label', 'Add stage name');
nameHotspot.title = 'Tap top area to add a name';
nameHotspot.textContent = slotNameMemory[slotIndex] || '';
nameHotspot.addEventListener('click', (e) => {
e.stopPropagation();
openLabelModal({ type: 'slotName', personId: null, slotIndex });
});
const placeholder = document.createElement('div');
placeholder.className = 'slot-placeholder';
placeholder.textContent = '';
visual.appendChild(nameHotspot);
visual.appendChild(placeholder);
}
const caption = document.createElement('div');
caption.className = 'caption';
caption.textContent = slot.label || '';
caption.addEventListener('click', (e) => {
e.stopPropagation();
openLabelModal({ type: 'slotLabel', personId: null, slotIndex });
});
slotEl.appendChild(visual);
slotEl.appendChild(caption);
return slotEl;
}
function createLibraryItem(person, href, onStage) {
const item = document.createElement('div');
item.className = 'lib-item' + (onStage ? ' on-lineup' : '');
item.dataset.personId = person.id;
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'lib-delete';
deleteBtn.setAttribute('aria-label', 'Delete photo from library');
deleteBtn.textContent = '×';
deleteBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
void deleteLibraryPerson(person.id);
});
const image = document.createElement('img');
image.alt = getLibraryDisplayName(person);
image.src = href;
const nameOverlay = document.createElement('div');
nameOverlay.className = 'lib-name-overlay';
nameOverlay.textContent = getLibraryDisplayName(person);
item.appendChild(deleteBtn);
item.appendChild(image);
item.appendChild(nameOverlay);
return item;
}
async function deleteLibraryPerson(personId) {
const person = getPerson(personId);
if (!person) return;
const ok = window.confirm('Delete this photo from the library? It will be removed from disk and cannot be undone.');
if (!ok) return;
stageSlots = stageSlots.map((slot) => (slot.personId === personId ? { ...slot, personId: '' } : slot));
library = library.filter((item) => item.id !== personId);
await window.lineup.deleteLibraryImage(person.fileName);
imageUrlCache.delete(person.fileName);
await renderStage();
await renderLibrary();
scheduleSave();
}
async function renderStage(slotIndexes) {
row.style.gridTemplateColumns = `repeat(${stageColumns}, minmax(0, 1fr))`;
row.style.gridTemplateRows = `repeat(${stageRows}, minmax(0, 1fr))`;
const slotCount = getStageSlotCount();
const indexes =
Array.isArray(slotIndexes) && slotIndexes.length
? [...new Set(slotIndexes.filter((idx) => idx >= 0 && idx < slotCount))]
: Array.from({ length: slotCount }, (_v, idx) => idx);
if (row.children.length !== slotCount || !Array.isArray(slotIndexes)) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < slotCount; i += 1) {
const slot = stageSlots[i];
const person = slot.personId ? getPerson(slot.personId) : null;
const href = person ? await getImageHref(person.fileName) : '';
fragment.appendChild(createStageSlot(i, slot, href, null));
}
row.replaceChildren(fragment);
updateEmpty();
window.requestAnimationFrame(updateSlotSizeReadout);
return;
}
for (const i of indexes) {
const slot = stageSlots[i];
const person = slot.personId ? getPerson(slot.personId) : null;
const href = person ? await getImageHref(person.fileName) : '';
const nextSlot = createStageSlot(i, slot, href, null);
const existingSlot = row.children[i];
if (existingSlot) {
existingSlot.replaceWith(nextSlot);
} else {
row.appendChild(nextSlot);
}
}
updateEmpty();
window.requestAnimationFrame(updateSlotSizeReadout);
}
async function renderLibrary() {
// The grid is rebuilt after every drop. Now that it is full-height and genuinely
// scrollable, losing the scroll position would throw the operator back to the top on
// every single drag onto the stage.
const previousScroll = libraryList.scrollTop;
libraryList.innerHTML = '';
const onStage = new Set(stageSlots.filter((slot) => slot.personId).map((slot) => slot.personId));
for (const person of library) {
const href = await getImageHref(person.fileName);
if (!href) continue;
libraryList.appendChild(createLibraryItem(person, href, onStage.has(person.id)));
}
libraryList.scrollTop = previousScroll;
}
async function renderAll() {
await renderStage();
await renderLibrary();
renderBirthdayTestOptions();
}
async function importNewPeople(added) {
if (!added.length) return;
for (const person of added) {
library.push({
id: person.id,
fileName: person.fileName,
originalName: typeof person.originalName === 'string' ? person.originalName : '',
lastLabel: '',
lastName: '',
source: 'local',
pcoPersonId: '',
pcoPlanPersonId: '',
birthday: '',
});
}
await renderLibrary();
scheduleSave();
}
async function connectPlanningCenter() {
const clientId = pcoClientIdInput.value.trim();
const secret = pcoSecretInput.value.trim();
if (!clientId || !secret) {
setPcoStatus('Enter both the Client ID and Secret.', true);
return;
}
setPcoControlsEnabled(false);
setPcoStatus('Connecting to Planning Center...');
try {
const result = await window.lineup.pcoConnect({ clientId, secret });
renderPcoServiceTypes(Array.isArray(result?.serviceTypes) ? result.serviceTypes : []);
pcoServiceTypeSelect.value = pcoServiceTypeId || '';
await refreshPcoPositionOptions();
closePcoModal();
setPcoStatus('Planning Center connected.');
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Could not connect to Planning Center.', true);
} finally {
setPcoControlsEnabled(true);
}
}
async function syncUpcomingPlanFromPco() {
if (!pcoServiceTypeId) {
setPcoStatus('Choose a service type before syncing.', true);
return;
}
setPcoControlsEnabled(false);
setPcoStatus('Syncing the upcoming plan from Planning Center...');
try {
const result = await window.lineup.pcoSyncUpcomingPlan({
serviceTypeId: pcoServiceTypeId,
library,
layout: { columns: stageColumns, rows: stageRows },
parallaxEnabled,
slotLabels: slotLabelMemory,
slotNames: slotNameMemory,
lineup: stageSlots,
pcoDisplayPositions,
});
applyLoadedState(result.state);
await renderAll();
const planLabel = [result?.plan?.title, result?.plan?.shortDates].filter(Boolean).join(' | ');
setPcoStatus(
planLabel
? `Synced ${result?.plan?.displayedCount || 0} labeled boxes from ${planLabel}.`
: `Synced ${result?.plan?.displayedCount || 0} labeled boxes.`,
);
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Planning Center sync failed.', true);
} finally {
setPcoControlsEnabled(true);
}
}
const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function formatBirthdayMmdd(mmdd) {
const parts = String(mmdd || '').split('-');
const mm = Number(parts[0]);
const dd = Number(parts[1]);
if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return '';
return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`;
}
function setPcoBirthdayResult(message, tone) {
if (!pcoBirthdayResult) return;
pcoBirthdayResult.textContent = message || '';
pcoBirthdayResult.style.display = message ? '' : 'none';
pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc';
}
async function testPcoBirthdays() {
setPcoControlsEnabled(false);
setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info');
try {
const r = await window.lineup.pcoTestBirthdays();
if (!r || r.connected === false) {
setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error');
return;
}
if (!r.canRead) {
const why = r.reason === 'network'
? `Couldn't reach Planning Center (${r.detail || 'network error'}).`
: `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`;
setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error');
return;
}
// Access works. Report coverage across synced band members.
if (r.total === 0) {
setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good');
return;
}
const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`];
if (r.nextUp) {
const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`;
bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`);
}
if (r.notFound) bits.push(`${r.notFound} not matched in People.`);
setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info');
} catch (error) {
setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error');
} finally {
setPcoControlsEnabled(true);
}
}
/* ---------------- Birthday Mode controls ---------------- */
/** Reflect current birthdayMode settings onto the controls (called after load). */
function syncBirthdayControls() {
if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled;
if (bdayIntensity) {
for (const b of bdayIntensity.querySelectorAll('button')) {
b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false');
}
}
if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45';
}
/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */
function renderBirthdayTestOptions() {
if (!bdayTestSelect) return;
const previous = birthdayTestPersonId;
bdayTestSelect.innerHTML = '<option value="">Off</option>';
const seen = new Set();
stageSlots.forEach((slot, index) => {
if (!slot.personId || seen.has(slot.personId)) return;
const person = getPerson(slot.personId);
if (!person) return;
seen.add(slot.personId);
const option = document.createElement('option');
option.value = person.id;
const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim();
const label = String(slotLabelMemory[index] || '').trim();
option.textContent = label ? `${name} — ${label}` : name;
bdayTestSelect.appendChild(option);
});
// Keep the current override selected if that person is still on stage; otherwise drop it.
if (previous && seen.has(previous)) {
bdayTestSelect.value = previous;
} else if (previous) {
birthdayTestPersonId = '';
bdayTestSelect.value = '';
}
}
/* ---------------- Setup panel tabs ---------------- */
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
let suppressPortCommit = false;
function activatePanelTab(name) {
if (!libraryTabs) return;
// Leaving the ProPresenter tab must not commit a port the operator was still thinking
// about: hiding the pane blurs the field, and that blur would otherwise restart the LAN
// display server and drop every bookmarked screen in the building.
if (displayPortInput && document.activeElement === displayPortInput) {
suppressPortCommit = true;
displayPortInput.blur();
suppressPortCommit = false;
}
for (const tab of libraryTabs.querySelectorAll('.lib-tab')) {
const isOn = tab.dataset.tab === name;
tab.classList.toggle('is-active', isOn);
tab.setAttribute('aria-selected', isOn ? 'true' : 'false');
}
for (const pane of libraryPanel.querySelectorAll('.library-pane')) {
pane.classList.toggle('is-active', pane.dataset.pane === name);
}
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
if (name === 'pco') renderBirthdayTestOptions();
}
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
let displayState = null;
/** When hosting was switched on, so the firewall warning waits for a grace period. */
let displayOnSince = 0;
let displayRefreshTimer = null;
const DISPLAY_FIREWALL_GRACE_MS = 60000;
function setDisplayStatus(message, isError = false) {
if (!displayStatusEl) return;
displayStatusEl.textContent = message;
displayStatusEl.style.color = isError ? '#ffb1b1' : '#9ea7bc';
}
/** Signature of the rendered rows, so an unchanged list is left alone. */
let displayUrlsSignature = null;
function renderDisplayUrls() {
if (!displayUrlsEl) return;
const addresses = displayState?.listening ? displayState.addresses || [] : [];
// The status poll runs every 4s. Rebuilding identical rows each time would wipe a
// "Copied" confirmation out from under the operator mid-read, and drop the row they are
// reaching for. Only touch the DOM when the list actually changed.
const signature = JSON.stringify(addresses.map((a) => [a.url, a.confirmed, a.detail]));
if (signature === displayUrlsSignature) return;
displayUrlsSignature = signature;
displayUrlsEl.innerHTML = '';
addresses.forEach((entry, index) => {
const row = document.createElement('div');
row.className = index === 0 ? 'display-url-row is-best' : 'display-url-row';
const main = document.createElement('div');
main.className = 'display-url-main';
const text = document.createElement('div');
text.className = 'display-url-text';
text.textContent = entry.url;
main.appendChild(text);
const note = document.createElement('div');
note.className = 'display-url-note';
note.textContent = entry.detail || '';
main.appendChild(note);
row.appendChild(main);
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'btn btn-small';
copy.textContent = 'Copy';
copy.addEventListener('click', async () => {
revealChrome();
try {
await window.lineup.display.copyUrl(entry.url);
copy.textContent = 'Copied';
window.setTimeout(() => { copy.textContent = 'Copy'; }, 1400);
} catch {
copy.textContent = 'Failed';
}
});
row.appendChild(copy);
displayUrlsEl.appendChild(row);
});
}
function renderDisplay() {
if (!btnDisplayToggle) return;
const state = displayState;
btnDisplayToggle.textContent = state?.enabled ? 'Turn Off' : 'Turn On';
if (displayPortInput && document.activeElement !== displayPortInput) {
displayPortInput.value = state?.port ? String(state.port) : '';
}
if (!state?.enabled) {
setDisplayStatus('Off \u2014 screens around the building can\u2019t see this yet.');
} else if (state.lastError) {
setDisplayStatus(state.lastError, true);
} else if (!state.listening) {
setDisplayStatus('Starting\u2026');
} else if (state.mdnsConflict) {
setDisplayStatus(
`Another computer is already publishing that name (${state.mdnsConflict}). Use one of the numeric addresses below.`,
true,
);
} else if (state.viewers > 0) {
// Report devices rather than connections: one machine can briefly hold two streams
// while a reloaded tab's old connection is still being reaped, and "4 screens watching"
// when two machines are open is just wrong.
const screens = state.devices || state.viewers;
const extra = state.viewers > screens ? ` (${state.viewers} connections)` : '';
setDisplayStatus(`On \u2014 ${screens} screen${screens === 1 ? '' : 's'} watching${extra}.`);
} else if (state.everHadViewer) {
setDisplayStatus('On \u2014 no screens open right now.');
} else if (displayOnSince && Date.now() - displayOnSince > DISPLAY_FIREWALL_GRACE_MS) {
// On Windows the firewall prompt fires when the server starts listening, not when a
// studio first connects — so localhost looks fine here while every other screen times
// out. A viewer count stuck at zero is the only hint we get; say it plainly.
setDisplayStatus(
`On, but no screen has connected yet. If a studio can\u2019t load the page, Windows Firewall on this computer is probably blocking Band Lineup \u2014 it needs to allow TCP port ${state.port} for Private networks.`,
true,
);
} else {
setDisplayStatus('On \u2014 open one of these on any screen in the building.');
}
renderDisplayUrls();
}
async function refreshDisplayStatus() {
try {
displayState = await window.lineup.display.status();
} catch {
return;
}
renderDisplay();
}
function scheduleDisplayRefresh() {
if (displayRefreshTimer) return;
displayRefreshTimer = window.setInterval(() => {
// Only while the edit chrome is up: show mode must stay completely idle.
if (document.body.classList.contains('chrome-hidden')) return;
void refreshDisplayStatus();
}, 4000);
}
async function toggleDisplayHosting() {
const next = !displayState?.enabled;
btnDisplayToggle.disabled = true;
setDisplayStatus(next ? 'Starting\u2026' : 'Stopping\u2026');
try {
displayState = await window.lineup.display.setEnabled(next);
displayOnSince = next ? Date.now() : 0;
} catch (error) {
setDisplayStatus(error instanceof Error ? error.message : 'Could not change the display server.', true);
} finally {
btnDisplayToggle.disabled = false;
}
renderDisplay();
}
async function applyDisplayPort() {
if (!displayPortInput) return;
const value = displayPortInput.value.trim();
if (!value || (displayState && String(displayState.port) === value)) return;
try {
displayState = await window.lineup.display.setPort(value);
renderDisplay();
} catch (error) {
setDisplayStatus(error instanceof Error ? error.message : 'Could not change the port.', true);
}
}
/* ---------------- ProPresenter Now / Next column ---------------- */
function setPpStatus(message, isError = false) {
if (!ppStatus) return;
ppStatus.textContent = message;
ppStatus.style.color = isError ? '#ffb1b1' : '#9ea7bc';
}
function ppItemTypeTag(type) {
switch (type) {
case 'media':
return 'Media';
case 'audio':
return 'Audio';
case 'livevideo':
return 'Live';
case 'placeholder':
return 'PCO';
default:
return '';
}
}
/** Remove any NEXT rows that fall below the visible bottom of the list, so it fills with as many whole items as fit. */
function ppTrimOverflow() {
if (!ppNextList) return;
const listRect = ppNextList.getBoundingClientRect();
if (listRect.height <= 0) return;
for (const child of Array.from(ppNextList.children)) {
if (child.getBoundingClientRect().bottom > listRect.bottom + 1) {
child.remove();
}
}
}
function updatePpActiveClass() {
// Show the column the whole time ProPresenter is connected (enabled), even when idle —
// the NOW area then shows a "Waiting…"/"Reconnecting…" card. Hidden only after Disconnect.
document.body.classList.toggle('pp-active', Boolean(ppData?.enabled));
}
/**
* Scale the NOW title to fill its box: short names stay large, long names shrink to fit.
* Picks the biggest font size (binary search) at which the text fits within `maxLines`,
* so titles like "I Know A Name - [ Full ]" and "This Is The Day - [ Full ]" each look right.
* Falls back to the smallest size and just wraps if a name is too long even at the minimum.
*/
function fitNowTitle(el, { min = 17, max = 40, maxLines = 2 } = {}) {
const lineHeight = 1.1; // keep in sync with .pp-now-name line-height in styles.css
// Wrap only at spaces; never split a word (the "Promis / ed Land" bug). An over-wide word
// then overflows horizontally, which the width check below rejects, forcing a smaller size.
el.style.whiteSpace = 'normal';
el.style.overflowWrap = 'normal';
el.style.wordBreak = 'normal';
el.style.hyphens = 'none';
const fits = (size) => {
el.style.fontSize = `${size}px`;
const allowedHeight = Math.ceil(size * lineHeight * maxLines) + 1;
return el.scrollHeight <= allowedHeight && el.scrollWidth <= el.clientWidth + 1;
};
if (fits(max)) { el.style.fontSize = `${max}px`; return; }
let lo = min;
let hi = max;
let best = min;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
}
el.style.fontSize = `${best}px`;
// Last resort for a single word too wide even at min: allow it to break rather than spill.
if (best === min && el.scrollWidth > el.clientWidth + 1) {
el.style.overflowWrap = 'anywhere';
el.style.wordBreak = 'break-word';
}
}
/**
* Tidy an item name for display only (the raw name is kept for ProPresenter matching).
* Strips arrangement/section tags in brackets ([ Full ], ( TAG ), { ... }), a leading date
* code (e.g. "260607 "), separator dashes, and any leftover edge punctuation/whitespace so the
* card reads as cleanly as possible — "260607 Taking Thoughts Captive - [ Full ]" → "Taking Thoughts Captive".
*/
function cleanItemName(raw) {
const original = String(raw || '').trim();
let s = original;
s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code
s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space
s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace
return s || original; // never blank out a title (e.g. a name that was only a tag)
}
function renderPpNow() {
ppNow.innerHTML = '';
const data = ppData;
if (data?.needsPlaylist) {
const prompt = document.createElement('div');
prompt.className = 'pp-waiting';
prompt.textContent = 'Pick your playlist in settings →';
ppNow.appendChild(prompt);
return;
}
const activeItem = data && data.activeIndex >= 0 ? data.items[data.activeIndex] : null;
const name = activeItem?.name || data?.activePresentationName || '';
if (!name) {
const waiting = document.createElement('div');
waiting.className = 'pp-waiting';
waiting.textContent = data?.reachable ? 'Nothing on screen' : 'Reconnecting…';
ppNow.appendChild(waiting);
if (data?.lastError) {
const errEl = document.createElement('div');
errEl.className = 'pp-error';
errEl.textContent = data.lastError;
ppNow.appendChild(errEl);
}
return;
}
const label = document.createElement('div');
label.className = 'pp-now-label';
label.textContent = 'Now';
ppNow.appendChild(label);
const nameEl = document.createElement('div');
nameEl.className = 'pp-now-name';
nameEl.textContent = cleanItemName(name);
ppNow.appendChild(nameEl);
fitNowTitle(nameEl);
const total = data.currentSlideTotal || activeItem?.slideCount || 0;
const current = data.currentSlide || 0;
if (total > 0 || current > 0) {
const meta = document.createElement('div');
meta.className = 'pp-now-meta';
if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current);
meta.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) {
meta.textContent = `${total} slides`;
} else {
meta.textContent = `Slide ${current}`;
}
ppNow.appendChild(meta);
if (total > 0) {
const progress = document.createElement('div');
progress.className = 'pp-progress';
const fill = document.createElement('div');
fill.className = 'pp-progress-fill';
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
fill.style.width = `${pct}%`;
progress.appendChild(fill);
ppNow.appendChild(progress);
}
}
}
function renderPpNext() {
ppNextList.innerHTML = '';
const data = ppData;
const items = Array.isArray(data?.items) ? data.items : [];
// When presenting from the playlist we list everything after the active item; when
// presenting from the Library (activeIndex < 0) we show the whole upcoming playlist.
const from = data && data.activeIndex >= 0 ? data.activeIndex + 1 : 0;
const upcoming = [];
for (let i = from; i < items.length; i += 1) {
if (!items[i].isHidden) upcoming.push(items[i]);
}
for (const item of upcoming) {
if (item.type === 'header') {
const headerEl = document.createElement('div');
headerEl.className = 'pp-next-item is-header';
const headerName = document.createElement('div');
headerName.className = 'pp-next-name';
headerName.textContent = cleanItemName(item.name || '');
headerEl.appendChild(headerName);
ppNextList.appendChild(headerEl);
continue;
}
const itemEl = document.createElement('div');
itemEl.className = 'pp-next-item';
const nameEl = document.createElement('div');
nameEl.className = 'pp-next-name';
nameEl.textContent = cleanItemName(item.name) || '(untitled)';
itemEl.appendChild(nameEl);
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div');
chip.className = 'pp-count-chip';
chip.textContent = String(item.slideCount);
itemEl.appendChild(chip);
} else {
const tag = ppItemTypeTag(item.type);
if (tag) {
const tagEl = document.createElement('div');
tagEl.className = 'pp-type-tag';
tagEl.textContent = tag;
itemEl.appendChild(tagEl);
}
}
ppNextList.appendChild(itemEl);
}
ppNextLabel.style.display = upcoming.length ? '' : 'none';
window.requestAnimationFrame(ppTrimOverflow);
}
function renderProPresenterColumn() {
if (!ppColumn || !ppNow || !ppNextList) return;
ppPlaylistName.textContent = ppData?.playlistName || '';
renderPpNow();
renderPpNext();
}
function openPpModal() {
ppModal.classList.remove('hidden');
ppHostInput.focus();
ppHostInput.select();
}
function closePpModal() {
ppModal.classList.add('hidden');
ppPasswordInput.value = '';
}
async function connectProPresenter() {
const host = ppHostInput.value.trim();
const port = ppPortInput.value.trim();
const password = ppPasswordInput.value;
if (!host || !port) {
setPpStatus('Enter the ProPresenter host and port.', true);
return;
}
ppSaveButton.disabled = true;
setPpStatus('Connecting to ProPresenter…');
try {
await window.lineup.proPresenter.connect({ host, port, password });
closePpModal();
if (ppPlaylistSelect) ppPlaylistSelect.style.display = 'none';
setPpStatus(`Connected to ProPresenter at ${host}:${port}. Auto-following the live playlist.`);
} catch (error) {
setPpStatus(error instanceof Error ? error.message : 'Could not connect to ProPresenter.', true);
} finally {
ppSaveButton.disabled = false;
}
}
async function disconnectProPresenter() {
try {
await window.lineup.proPresenter.disconnect();
if (ppPlaylistSelect) ppPlaylistSelect.value = '';
setPpStatus('ProPresenter disconnected.');
} catch (error) {
setPpStatus(error instanceof Error ? error.message : 'Could not disconnect ProPresenter.', true);
}
}
async function refreshPpPlaylists() {
if (!ppPlaylistSelect) return;
let playlists = [];
let selected = '';
try {
playlists = await window.lineup.proPresenter.listPlaylists();
const info = await window.lineup.proPresenter.status();
selected = info?.playlistUuid || '';
} catch {
playlists = [];
}
ppPlaylistSelect.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = 'Choose playlist';
ppPlaylistSelect.appendChild(placeholder);
for (const playlist of Array.isArray(playlists) ? playlists : []) {
const option = document.createElement('option');
option.value = playlist.uuid;
option.textContent = playlist.group ? `${playlist.group} / ${playlist.name}` : playlist.name;
option.dataset.name = playlist.name || '';
ppPlaylistSelect.appendChild(option);
}
ppPlaylistSelect.value = selected;
}
btnAdd.addEventListener('click', async () => {
revealChrome();
const added = await window.lineup.addImagesDialog();
await importNewPeople(added);
});
btnPcoConnect.addEventListener('click', () => {
revealChrome();
openPcoModal();
});
btnPcoAddPosition.addEventListener('click', () => {
revealChrome();
const selectedKey = pcoPositionOptionSelect.value;
if (!selectedKey) {
setPcoStatus('Choose a position to add.', true);
return;
}
const option = pcoPositionOptions.find((entry) => entry.key === selectedKey);
if (!option) return;
pcoDisplayPositions.push({
teamName: option.teamName,
positionName: option.positionName,
count: 1,
});
renderPcoDisplayPositions();
scheduleSave();
setPcoStatus(`Added ${option.positionName} from ${option.teamName}.`);
});
btnPcoSync.addEventListener('click', () => {
revealChrome();
void syncUpcomingPlanFromPco();
});
btnPcoTestBirthdays.addEventListener('click', () => {
revealChrome();
void testPcoBirthdays();
});
if (bdayEnabled) {
bdayEnabled.addEventListener('change', () => {
birthdayMode.enabled = bdayEnabled.checked;
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayIntensity) {
bdayIntensity.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-int]');
if (!btn) return;
birthdayMode.intensity = btn.dataset.int === 'full' ? 'full' : 'subtle';
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayTestSelect) {
bdayTestSelect.addEventListener('change', () => {
birthdayTestPersonId = bdayTestSelect.value || '';
void renderStage();
});
}
btnPcoDisconnect.addEventListener('click', async () => {
revealChrome();
setPcoControlsEnabled(false);
try {
await window.lineup.pcoDisconnect();
pcoServiceTypeId = '';
pcoPositionOptions = [];
pcoServiceTypeSelect.innerHTML = '<option value="">Choose service type</option>';
renderPcoPositionOptions();
scheduleSave();
setPcoStatus('Planning Center disconnected.');
setPcoBirthdayResult('');
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
} finally {
setPcoControlsEnabled(true);
}
});
pcoServiceTypeSelect.addEventListener('change', () => {
pcoServiceTypeId = pcoServiceTypeSelect.value;
void refreshPcoPositionOptions();
scheduleSave();
});
btnApplyLayout.addEventListener('click', () => {
revealChrome();
void applyLayoutChange();
});
btnToggleParallax.addEventListener('click', () => {
revealChrome();
parallaxEnabled = !parallaxEnabled;
syncParallaxToggle();
void renderStage();
scheduleSave();
});
for (const input of [inputLayoutCols, inputLayoutRows]) {
input.addEventListener('click', (e) => {
e.stopPropagation();
if (lastPointerType === 'touch') {
e.preventDefault();
openNumberModal(input);
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
void applyLayoutChange();
}
});
}
toolbar.addEventListener('pointerenter', () => {
revealChrome();
});
toolbar.addEventListener('pointerleave', () => {
noteActivity();
});
toolbar.addEventListener('focusin', () => {
revealChrome();
});
toolbar.addEventListener('focusout', () => {
window.setTimeout(() => {
noteActivity();
}, 0);
});
libraryPanel.addEventListener('pointerenter', () => {
revealChrome();
});
libraryPanel.addEventListener('pointerleave', () => {
noteActivity();
});
libraryPanel.addEventListener('focusin', () => {
revealChrome();
});
libraryPanel.addEventListener('focusout', () => {
window.setTimeout(() => {
noteActivity();
}, 0);
});
window.lineup.onMenuAddPhotos(() => {
btnAdd.click();
});
btnFullscreen.addEventListener('click', () => {
revealChrome();
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
});
document.addEventListener('fullscreenchange', () => {
noteActivity();
window.requestAnimationFrame(updateSlotSizeReadout);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
noteActivity();
e.dataTransfer.dropEffect = e.dataTransfer.types.includes('Files') ? 'copy' : 'move';
if (e.dataTransfer.types.includes('Files')) dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', (e) => {
if (!dropZone.contains(/** @type {Node} */ (e.relatedTarget))) {
dropZone.classList.remove('drag-over');
}
});
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
noteActivity();
dropZone.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files || []).filter((file) => file.type.startsWith('image/'));
if (!files.length) return;
const paths = files.map((file) => file.path).filter(Boolean);
if (!paths.length) return;
const added = await window.lineup.addImagesPaths(paths);
await importNewPeople(added);
});
function buildVirtualKeyboard() {
const root = document.getElementById('vkeyboard');
if (!root) return;
root.innerHTML = '';
for (const keys of VK_ROWS) {
const rowEl = document.createElement('div');
rowEl.className = 'vk-row';
for (const key of keys) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'vk-key';
button.textContent = key;
button.dataset.ch = key;
rowEl.appendChild(button);
}
root.appendChild(rowEl);
}
const actionsRow = document.createElement('div');
actionsRow.className = 'vk-row';
const space = document.createElement('button');
space.type = 'button';
space.className = 'vk-key space';
space.textContent = 'Space';
space.dataset.action = 'space';
const erase = document.createElement('button');
erase.type = 'button';
erase.className = 'vk-key wide';
erase.textContent = 'Erase';
erase.dataset.action = 'erase';
actionsRow.appendChild(space);
actionsRow.appendChild(erase);
root.appendChild(actionsRow);
root.addEventListener('click', (e) => {
const button = e.target.closest('.vk-key');
if (!button) return;
noteActivity();
e.preventDefault();
const action = button.dataset.action;
if (action === 'space') {
labelInput.value += ' ';
return;
}
if (action === 'erase') {
labelInput.value = labelInput.value.slice(0, -1);
return;
}
const ch = button.dataset.ch;
if (ch) labelInput.value += ch;
});
}
function buildNumberKeyboard() {
if (!numberKeyboard) return;
numberKeyboard.innerHTML = '';
const rows = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']];
for (const keys of rows) {
const rowEl = document.createElement('div');
rowEl.className = 'vk-row';
for (const key of keys) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'vk-key wide';
button.textContent = key;
button.dataset.ch = key;
rowEl.appendChild(button);
}
numberKeyboard.appendChild(rowEl);
}
const lastRow = document.createElement('div');
lastRow.className = 'vk-row';
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'vk-key wide';
clear.textContent = 'Clear';
clear.dataset.action = 'clear';
const zero = document.createElement('button');
zero.type = 'button';
zero.className = 'vk-key wide';
zero.textContent = '0';
zero.dataset.ch = '0';
const erase = document.createElement('button');
erase.type = 'button';
erase.className = 'vk-key wide';
erase.textContent = 'Erase';
erase.dataset.action = 'erase';
lastRow.appendChild(clear);
lastRow.appendChild(zero);
lastRow.appendChild(erase);
numberKeyboard.appendChild(lastRow);
numberKeyboard.addEventListener('click', (e) => {
const button = e.target.closest('.vk-key');
if (!button) return;
noteActivity();
e.preventDefault();
const action = button.dataset.action;
if (action === 'clear') {
numberInput.value = '';
return;
}
if (action === 'erase') {
numberInput.value = numberInput.value.slice(0, -1);
return;
}
const ch = button.dataset.ch;
if (ch) {
const nextValue = `${numberInput.value}${ch}`.replace(/^0+(?=\d)/, '');
numberInput.value = nextValue;
}
});
}
function openNumberModal(input, options = {}) {
numericEditTarget = input;
numericEditCommit = typeof options.onCommit === 'function' ? options.onCommit : null;
numericEditMin = Number.isFinite(options.min) ? options.min : 1;
numericEditMax = Number.isFinite(options.max) ? options.max : Number.POSITIVE_INFINITY;
numericEditOnInvalid = typeof options.onInvalid === 'function' ? options.onInvalid : null;
numberModalTitle.textContent = options.title || (input === inputLayoutCols ? 'Columns' : 'Rows');
numberInput.value = input.value || '';
numberModal.classList.remove('hidden');
revealChrome();
}
function closeNumberModal() {
numberModal.classList.add('hidden');
numericEditTarget = null;
numericEditCommit = null;
numericEditMin = 1;
numericEditMax = Number.POSITIVE_INFINITY;
numericEditOnInvalid = null;
scheduleChromeHide();
}
async function commitNumberModal() {
if (!numericEditTarget) {
closeNumberModal();
return;
}
const parsed = Number.parseInt(numberInput.value, 10);
const commit = numericEditCommit;
if (commit) {
// A bounded field REFUSES a bad entry rather than coercing it. The grid's historic
// floor-of-1 is harmless for Cols/Rows, but silently turning a cleared keypad into
// port 1 would restart the live display server on a port nobody typed and kill every
// bookmark in the building.
const min = numericEditMin;
const max = numericEditMax;
const onInvalid = numericEditOnInvalid;
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
closeNumberModal();
if (onInvalid) onInvalid(`Enter a number between ${min} and ${max}.`);
return;
}
numericEditTarget.value = String(parsed);
closeNumberModal();
await commit(parsed);
return;
}
const safeValue = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
numericEditTarget.value = String(safeValue);
closeNumberModal();
await applyLayoutChange();
}
function openLabelModal(edit) {
labelEdit = edit;
if (edit.type === 'name') {
const person = edit.personId ? getPerson(edit.personId) : null;
if (!person) return;
labelTitle.textContent = 'Band member name';
labelInput.placeholder = 'Enter band member name';
labelInput.value = person.lastName || '';
} else if (edit.type === 'slotName') {
labelTitle.textContent = 'Stage position name';
labelInput.placeholder = 'Enter stage name';
labelInput.value = slotNameMemory[edit.slotIndex] || '';
} else {
const slot = stageSlots[edit.slotIndex];
if (!slot) return;
labelTitle.textContent = 'Mic / pack label';
labelInput.placeholder = 'Enter mic / pack';
labelInput.value = slot.label || '';
}
labelModal.classList.remove('hidden');
labelInput.focus();
labelInput.select();
}
function closeLabelModal() {
labelModal.classList.add('hidden');
labelEdit = { type: 'slotLabel', personId: null, slotIndex: -1 };
labelInput.blur();
scheduleChromeHide();
}
async function commitLabelModal() {
const currentEdit = { ...labelEdit };
const value = labelInput.value.trim();
if (currentEdit.type === 'name') {
const person = currentEdit.personId ? getPerson(currentEdit.personId) : null;
if (person) {
person.lastName = value;
}
} else if (currentEdit.type === 'slotName') {
if (currentEdit.slotIndex >= 0 && currentEdit.slotIndex < getStageSlotCount()) {
slotNameMemory[currentEdit.slotIndex] = value;
}
} else if (currentEdit.slotIndex >= 0 && currentEdit.slotIndex < getStageSlotCount()) {
stageSlots[currentEdit.slotIndex].label = value;
slotLabelMemory[currentEdit.slotIndex] = value;
}
closeLabelModal();
if (currentEdit.type === 'name') {
const slotIndex = currentEdit.personId ? findStageIndexByPersonId(currentEdit.personId) : -1;
if (slotIndex >= 0) {
await renderStage([slotIndex]);
}
} else if (currentEdit.type === 'slotName' && currentEdit.slotIndex >= 0) {
await renderStage([currentEdit.slotIndex]);
} else if (currentEdit.slotIndex >= 0) {
await renderStage([currentEdit.slotIndex]);
} else {
await renderStage();
}
scheduleSave();
}
labelDone.addEventListener('click', () => void commitLabelModal());
labelCancel.addEventListener('click', () => closeLabelModal());
labelBackdrop.addEventListener('click', () => closeLabelModal());
numberDone.addEventListener('click', () => void commitNumberModal());
numberCancel.addEventListener('click', () => closeNumberModal());
numberBackdrop.addEventListener('click', () => closeNumberModal());
pcoSaveButton.addEventListener('click', () => void connectPlanningCenter());
pcoCancelButton.addEventListener('click', () => closePcoModal());
pcoModalBackdrop.addEventListener('click', () => closePcoModal());
btnPpConnect.addEventListener('click', () => {
revealChrome();
openPpModal();
});
btnPpDisconnect.addEventListener('click', () => {
revealChrome();
void disconnectProPresenter();
});
if (btnDisplayToggle) {
btnDisplayToggle.addEventListener('click', () => {
revealChrome();
void toggleDisplayHosting();
});
}
if (displayPortInput) {
displayPortInput.addEventListener('click', (e) => {
e.stopPropagation();
if (lastPointerType === 'touch') {
e.preventDefault();
openNumberModal(displayPortInput, {
title: 'Display port',
min: 1024,
max: 65535,
onCommit: () => applyDisplayPort(),
onInvalid: (message) => setDisplayStatus(message, true),
});
}
});
displayPortInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
void applyDisplayPort();
}
});
displayPortInput.addEventListener('blur', () => {
if (suppressPortCommit) {
// A tab switch took the focus, not the operator. Put the running value back.
displayPortInput.value = displayState?.port ? String(displayState.port) : '';
return;
}
// Committing whatever is in the box on blur would restart the server on a half-typed
// port ("76" on the way to "7654"). Apply only a value that is actually usable; put
// anything else back so the field never disagrees with the running server.
const parsed = Number.parseInt(displayPortInput.value.trim(), 10);
if (Number.isFinite(parsed) && parsed >= 1024 && parsed <= 65535) {
void applyDisplayPort();
return;
}
displayPortInput.value = displayState?.port ? String(displayState.port) : '';
});
}
ppSaveButton.addEventListener('click', () => void connectProPresenter());
ppCancelButton.addEventListener('click', () => closePpModal());
ppModalBackdrop.addEventListener('click', () => closePpModal());
if (ppPlaylistSelect) {
ppPlaylistSelect.addEventListener('change', () => {
revealChrome();
const uuid = ppPlaylistSelect.value;
const option = ppPlaylistSelect.selectedOptions[0];
const name = option ? option.dataset.name || option.textContent || '' : '';
void window.lineup.proPresenter.selectPlaylist({ uuid, name });
setPpStatus(uuid ? `Showing playlist: ${name}` : 'Choose a playlist to display.');
});
}
for (const input of [ppHostInput, ppPortInput, ppPasswordInput]) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
void connectProPresenter();
}
if (e.key === 'Escape') {
e.preventDefault();
closePpModal();
}
});
}
// Re-fit the Now/Next list whenever the column's size changes: window resize,
// fullscreen toggle, or the column becoming visible when entering show mode.
if (ppColumn && typeof ResizeObserver !== 'undefined') {
// Re-render only when the column actually changes size (window/show-mode), not in reaction
// to our own content writes — otherwise the fitted title can oscillate.
let lastW = 0;
let lastH = 0;
let pending = false;
const ppResizeObserver = new ResizeObserver(() => {
const w = Math.round(ppColumn.clientWidth);
const h = Math.round(ppColumn.clientHeight);
if (w === lastW && h === lastH) return;
lastW = w; lastH = h;
if (pending) return;
pending = true;
window.requestAnimationFrame(() => {
pending = false;
renderProPresenterColumn();
});
});
ppResizeObserver.observe(ppColumn);
}
labelInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
void commitLabelModal();
}
if (e.key === 'Escape') {
e.preventDefault();
closeLabelModal();
}
});
numberInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
void commitNumberModal();
}
if (e.key === 'Escape') {
e.preventDefault();
closeNumberModal();
}
});
for (const input of [pcoClientIdInput, pcoSecretInput]) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
void connectPlanningCenter();
}
if (e.key === 'Escape') {
e.preventDefault();
closePcoModal();
}
});
}
for (const eventName of ['keydown', 'wheel']) {
window.addEventListener(
eventName,
() => {
noteActivity();
},
{ passive: true },
);
}
window.addEventListener(
'resize',
() => {
window.requestAnimationFrame(updateSlotSizeReadout);
},
{ passive: true },
);
dropZone.addEventListener('click', (e) => {
handleChromeToggleSurfaceClick(e, 'stage-surface');
});
if (libraryTabs) {
libraryTabs.addEventListener('click', (e) => {
const tab = e.target instanceof HTMLElement ? e.target.closest('.lib-tab') : null;
if (!tab) return;
// Keep this away from the panel's double-tap show-mode toggle below.
e.stopPropagation();
revealChrome();
activatePanelTab(tab.dataset.tab);
});
}
libraryPanel.addEventListener('click', (e) => {
handleChromeToggleSurfaceClick(e, 'library-surface');
});
buildVirtualKeyboard();
buildNumberKeyboard();
(async function init() {
const data = await window.lineup.loadState();
applyLoadedState(data);
await renderAll();
try {
const status = await window.lineup.pcoStatus();
if (status?.connected) {
if (!pcoServiceTypeId && typeof status.pcoServiceTypeId === 'string') {
pcoServiceTypeId = status.pcoServiceTypeId;
}
if (!pcoDisplayPositions.length && Array.isArray(status.pcoDisplayPositions)) {
pcoDisplayPositions = status.pcoDisplayPositions
.map((entry) => normalizePcoDisplayPosition(entry))
.filter((entry) => entry.teamName && entry.positionName);
renderPcoDisplayPositions();
}
await refreshPcoServiceTypes();
pcoServiceTypeSelect.value = pcoServiceTypeId || '';
await refreshPcoPositionOptions();
setPcoStatus(
pcoServiceTypeId
? 'Planning Center connected.'
: 'Planning Center connected. Choose a service type.',
);
} else {
setPcoStatus('Planning Center is not connected.');
}
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Planning Center is unavailable.', true);
}
window.lineup.proPresenter.onData((incoming) => {
ppData = incoming;
updatePpActiveClass();
renderProPresenterColumn();
});
try {
const ppInfo = await window.lineup.proPresenter.status();
if (ppInfo?.host) ppHostInput.value = ppInfo.host;
if (ppInfo?.port) ppPortInput.value = String(ppInfo.port);
if (ppPlaylistSelect) ppPlaylistSelect.style.display = 'none'; // auto-follow: no manual picker
if (ppInfo?.enabled) {
setPpStatus(`Connected to ProPresenter at ${ppInfo.host}:${ppInfo.port}. Auto-following the live playlist.`);
await window.lineup.proPresenter.start();
} else {
setPpStatus('ProPresenter is not connected.');
}
} catch (error) {
setPpStatus(error instanceof Error ? error.message : 'ProPresenter is unavailable.', true);
}
try {
const info = await window.lineup.appVersion();
if (appVersionEl && info?.version) {
appVersionEl.textContent = info.build ? `v${info.version} · build ${info.build}` : `v${info.version}`;
}
} catch {
/* label just stays empty */
}
await refreshDisplayStatus();
if (displayState?.enabled) displayOnSince = Date.now();
scheduleDisplayRefresh();
exitChromeEditMode();
})();
}