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