2026-08-30 10:09:41 -04:00
if ( window . __bandLineupRendererLoaded ) {
console . warn ( 'renderer.js already loaded; skipping duplicate initialization' );
} else {
window . __bandLineupRendererLoaded = true ;
/**
2026-09-23 16:37:54 -04:00
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
2026-08-30 10:09:41 -04:00
* @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 = [];
2026-09-23 16:37:54 -04:00
/** 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 = '' ;
2026-08-30 10:09:41 -04:00
/** @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' );
2026-09-23 16:37:54 -04:00
const btnPcoTestBirthdays = document . getElementById ( 'btn-pco-test-birthdays' );
2026-08-30 10:09:41 -04:00
const btnPcoDisconnect = document . getElementById ( 'btn-pco-disconnect' );
2026-09-23 16:37:54 -04:00
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' );
2026-08-30 10:09:41 -04:00
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 ;
2026-09-23 16:37:54 -04:00
btnPcoTestBirthdays . disabled = ! enabled ;
2026-08-30 10:09:41 -04:00
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 : '' ,
2026-09-23 16:37:54 -04:00
// 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 : '' ,
2026-08-30 10:09:41 -04:00
}))
: [];
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 ());
2026-09-23 16:37:54 -04:00
birthdayMode = {
enabled : data ? . birthdayMode ? . enabled === undefined ? true : Boolean ( data . birthdayMode . enabled ),
intensity : data ? . birthdayMode ? . intensity === 'full' ? 'full' : 'subtle' ,
};
syncBirthdayControls ();
2026-08-30 10:09:41 -04:00
}
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 ,
2026-09-23 16:37:54 -04:00
birthdayMode ,
2026-08-30 10:09:41 -04:00
});
}, 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` ,
};
}
2026-09-23 16:37:54 -04:00
/* ---------------- 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 );
}
2026-08-30 10:09:41 -04:00
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 );
2026-09-23 16:37:54 -04:00
if ( isBirthdayActive ( person )) applyBirthdayEffects ( tile , photoArea );
2026-08-30 10:09:41 -04:00
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 ();
2026-09-23 16:37:54 -04:00
renderBirthdayTestOptions ();
2026-08-30 10:09:41 -04:00
}
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 : '' ,
2026-09-23 16:37:54 -04:00
birthday : '' ,
2026-08-30 10:09:41 -04:00
});
}
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 );
}
}
2026-09-23 16:37:54 -04:00
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 = '' ;
}
}
2026-08-30 10:09:41 -04:00
/* ---------------- 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 );
2026-09-23 16:37:54 -04:00
if ( name === 'pco' ) renderBirthdayTestOptions ();
2026-08-30 10:09:41 -04:00
}
/* ---------------- 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 ();
});
2026-09-23 16:37:54 -04:00
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 ();
});
}
2026-08-30 10:09:41 -04:00
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.' );
2026-09-23 16:37:54 -04:00
setPcoBirthdayResult ( '' );
2026-08-30 10:09:41 -04:00
} 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 ();
})();
}