2368 lines
78 KiB
JavaScript
2368 lines
78 KiB
JavaScript
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();
|
||
})();
|
||
}
|