The Electron app that runs on the backstage PC: stage lineup photos with mic/pack labels, packaged for Windows. It also carries the ProPresenter Now/Next feature in two forms that share one engine — propresenter-core, where every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone display it serves. Until now this existed only on the production machine and as a read-only snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build output, and credentials, photos and processed images are written to the Electron userData directory, never here. tmp/pp-mock.js stays — it is the ProPresenter mock ProdDash's own CLAUDE.md tells people to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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();
|
||
})();
|
||
}
|