Files

2368 lines
78 KiB
JavaScript
Raw Permalink Normal View History

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 }} 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 = [];
/** @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 btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
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;
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 : '',
}))
: [];
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());
}
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,
});
}, 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`,
};
}
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);
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();
}
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: '',
});
}
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);
}
}
/* ---------------- 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);
}
/* ---------------- 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();
});
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.');
} 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();
})();
}