@xiboplayer/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * PlayerCore - Platform-independent orchestration module
3
+ *
4
+ * Pure orchestration logic without platform-specific concerns (UI, DOM, storage).
5
+ * Can be reused across PWA, Electron, mobile platforms.
6
+ *
7
+ * Architecture:
8
+ * ┌─────────────────────────────────────────────────────┐
9
+ * │ PlayerCore (Pure Orchestration) │
10
+ * │ - Collection cycle coordination │
11
+ * │ - Schedule checking │
12
+ * │ - Layout transition logic │
13
+ * │ - Event emission (not DOM manipulation) │
14
+ * │ - XMDS communication │
15
+ * │ - XMR integration │
16
+ * └─────────────────────────────────────────────────────┘
17
+ * ↓
18
+ * ┌─────────────────────────────────────────────────────┐
19
+ * │ Platform Layer (PWA/Electron/Mobile) │
20
+ * │ - UI updates (status display, progress bars) │
21
+ * │ - DOM manipulation │
22
+ * │ - Platform-specific storage │
23
+ * │ - Blob URL management │
24
+ * │ - Event listeners for PlayerCore events │
25
+ * └─────────────────────────────────────────────────────┘
26
+ *
27
+ * Usage:
28
+ * const core = new PlayerCore({
29
+ * config,
30
+ * xmds,
31
+ * cache,
32
+ * schedule,
33
+ * renderer,
34
+ * xmrWrapper
35
+ * });
36
+ *
37
+ * // Listen to events
38
+ * core.on('collection-start', () => { ... });
39
+ * core.on('layout-ready', (layoutId) => { ... });
40
+ *
41
+ * // Start collection
42
+ * await core.collect();
43
+ */
44
+
45
+ import { EventEmitter, createLogger, applyCmsLogLevel } from '@xiboplayer/utils';
46
+ import { DataConnectorManager } from './data-connectors.js';
47
+
48
+ const log = createLogger('PlayerCore');
49
+
50
+ // IndexedDB database/store for offline cache
51
+ const OFFLINE_DB_NAME = 'xibo-offline-cache';
52
+ const OFFLINE_DB_VERSION = 1;
53
+ const OFFLINE_STORE = 'cache';
54
+
55
+ /** Open the offline cache IndexedDB (creates store on first use) */
56
+ function openOfflineDb() {
57
+ return new Promise((resolve, reject) => {
58
+ const req = indexedDB.open(OFFLINE_DB_NAME, OFFLINE_DB_VERSION);
59
+ req.onupgradeneeded = () => {
60
+ const db = req.result;
61
+ if (!db.objectStoreNames.contains(OFFLINE_STORE)) {
62
+ db.createObjectStore(OFFLINE_STORE);
63
+ }
64
+ };
65
+ req.onsuccess = () => resolve(req.result);
66
+ req.onerror = () => reject(req.error);
67
+ });
68
+ }
69
+
70
+ export class PlayerCore extends EventEmitter {
71
+ constructor(options) {
72
+ super();
73
+
74
+ // Required dependencies (injected)
75
+ this.config = options.config;
76
+ this.xmds = options.xmds;
77
+ this.cache = options.cache;
78
+ this.schedule = options.schedule;
79
+ this.renderer = options.renderer;
80
+ this.XmrWrapper = options.xmrWrapper;
81
+ this.statsCollector = options.statsCollector; // Optional: proof of play tracking
82
+ this.displaySettings = options.displaySettings; // Optional: CMS display settings manager
83
+
84
+ // Data connectors manager (real-time data for widgets)
85
+ this.dataConnectorManager = new DataConnectorManager();
86
+
87
+ // State
88
+ this.xmr = null;
89
+ this.currentLayoutId = null;
90
+ this.collecting = false;
91
+ this.collectionInterval = null;
92
+ this.pendingLayouts = new Map(); // layoutId -> required media IDs
93
+ this.offlineMode = false; // Track whether we're currently in offline mode
94
+
95
+ // CRC32 checksums for skip optimization (avoid redundant XMDS calls)
96
+ this._lastCheckRf = null;
97
+ this._lastCheckSchedule = null;
98
+
99
+ // Layout override state (for changeLayout/overlayLayout via XMR → revertToSchedule)
100
+ this._layoutOverride = null; // { layoutId, type: 'change'|'overlay' }
101
+ this._lastRequiredFiles = []; // Track files for MediaInventory
102
+
103
+ // Schedule cycle state (round-robin through multiple layouts)
104
+ this._currentLayoutIndex = 0;
105
+
106
+ // Multi-display sync configuration (from RegisterDisplay syncGroup settings)
107
+ this.syncConfig = null;
108
+ this.syncManager = null; // Optional: set via setSyncManager() after RegisterDisplay
109
+
110
+ // In-memory offline cache (populated from IndexedDB on first load)
111
+ this._offlineCache = { schedule: null, settings: null, requiredFiles: null };
112
+ this._offlineDbReady = this._initOfflineCache();
113
+ }
114
+
115
+ // ── Offline Cache (IndexedDB) ──────────────────────────────────────
116
+
117
+ /** Load offline cache from IndexedDB into memory on startup */
118
+ async _initOfflineCache() {
119
+ try {
120
+ const db = await openOfflineDb();
121
+ const tx = db.transaction(OFFLINE_STORE, 'readonly');
122
+ const store = tx.objectStore(OFFLINE_STORE);
123
+
124
+ const [schedule, settings, requiredFiles] = await Promise.all([
125
+ new Promise(r => { const req = store.get('schedule'); req.onsuccess = () => r(req.result ?? null); req.onerror = () => r(null); }),
126
+ new Promise(r => { const req = store.get('settings'); req.onsuccess = () => r(req.result ?? null); req.onerror = () => r(null); }),
127
+ new Promise(r => { const req = store.get('requiredFiles'); req.onsuccess = () => r(req.result ?? null); req.onerror = () => r(null); }),
128
+ ]);
129
+
130
+ this._offlineCache = { schedule, settings, requiredFiles };
131
+ db.close();
132
+ console.log('[PlayerCore] Offline cache loaded from IndexedDB',
133
+ schedule ? '(has schedule)' : '(empty)');
134
+ } catch (e) {
135
+ console.warn('[PlayerCore] Failed to load offline cache from IndexedDB:', e);
136
+ }
137
+ }
138
+
139
+ /** Save a key to both in-memory cache and IndexedDB (fire-and-forget) */
140
+ async _offlineSave(key, data) {
141
+ this._offlineCache[key] = data;
142
+ try {
143
+ const db = await openOfflineDb();
144
+ const tx = db.transaction(OFFLINE_STORE, 'readwrite');
145
+ tx.objectStore(OFFLINE_STORE).put(data, key);
146
+ await new Promise((resolve, reject) => {
147
+ tx.oncomplete = resolve;
148
+ tx.onerror = () => reject(tx.error);
149
+ });
150
+ db.close();
151
+ } catch (e) {
152
+ console.warn('[PlayerCore] Failed to save offline cache:', key, e);
153
+ }
154
+ }
155
+
156
+ /** Check if we have any cached data to fall back on */
157
+ hasCachedData() {
158
+ return this._offlineCache.schedule !== null;
159
+ }
160
+
161
+ /** Check if the browser reports being offline */
162
+ isOffline() {
163
+ return typeof navigator !== 'undefined' && navigator.onLine === false;
164
+ }
165
+
166
+ /** Check if currently in offline mode */
167
+ isInOfflineMode() {
168
+ return this.offlineMode;
169
+ }
170
+
171
+ /**
172
+ * Run an offline collection cycle using cached data.
173
+ * Evaluates the cached schedule and continues playback.
174
+ */
175
+ collectOffline() {
176
+ console.warn('[PlayerCore] Offline mode — using cached schedule');
177
+
178
+ if (!this.offlineMode) {
179
+ this.offlineMode = true;
180
+ this.emit('offline-mode', true);
181
+ }
182
+
183
+ // Load cached settings for collection interval (first run only)
184
+ if (!this.collectionInterval) {
185
+ const cachedReg = this._offlineCache.settings;
186
+ if (cachedReg?.settings) {
187
+ this.setupCollectionInterval(cachedReg.settings);
188
+ }
189
+ }
190
+
191
+ // Load cached schedule and apply it
192
+ const cachedSchedule = this._offlineCache.schedule;
193
+ if (cachedSchedule) {
194
+ this.schedule.setSchedule(cachedSchedule);
195
+ this.emit('schedule-received', cachedSchedule);
196
+ }
197
+
198
+ // Evaluate current schedule (same logic as online path)
199
+ const layoutFiles = this.schedule.getCurrentLayouts();
200
+ log.info('Offline layouts:', layoutFiles);
201
+ this.emit('layouts-scheduled', layoutFiles);
202
+
203
+ if (layoutFiles.length > 0) {
204
+ // If a layout is currently playing and still in the schedule, don't interrupt
205
+ if (this.currentLayoutId) {
206
+ const currentStillScheduled = layoutFiles.some(f =>
207
+ parseInt(String(f).replace('.xlf', ''), 10) === this.currentLayoutId
208
+ );
209
+ if (currentStillScheduled) {
210
+ const idx = layoutFiles.findIndex(f =>
211
+ parseInt(String(f).replace('.xlf', ''), 10) === this.currentLayoutId
212
+ );
213
+ if (idx >= 0) this._currentLayoutIndex = idx;
214
+ log.debug(`Layout ${this.currentLayoutId} still in schedule (offline), continuing playback`);
215
+ this.emit('layout-already-playing', this.currentLayoutId);
216
+ } else {
217
+ // Current layout not in schedule — switch
218
+ this._currentLayoutIndex = 0;
219
+ const next = this.getNextLayout();
220
+ if (next) {
221
+ log.info(`Offline: switching to layout ${next.layoutId}`);
222
+ this.emit('layout-prepare-request', next.layoutId);
223
+ }
224
+ }
225
+ } else {
226
+ // No current layout — start the first one
227
+ this._currentLayoutIndex = 0;
228
+ const next = this.getNextLayout();
229
+ if (next) {
230
+ log.info(`Offline: switching to layout ${next.layoutId}`);
231
+ this.emit('layout-prepare-request', next.layoutId);
232
+ }
233
+ }
234
+ } else {
235
+ log.info('Offline: no layouts in cached schedule');
236
+ this.emit('no-layouts-scheduled');
237
+ }
238
+
239
+ this.emit('collection-complete');
240
+ }
241
+
242
+ /**
243
+ * Force an immediate collection (used by platform layer on 'online' event)
244
+ */
245
+ async collectNow() {
246
+ this._lastCheckRf = null;
247
+ this._lastCheckSchedule = null;
248
+ return this.collect();
249
+ }
250
+
251
+ /**
252
+ * Start collection cycle
253
+ * Pure orchestration - emits events instead of updating UI
254
+ */
255
+ async collect() {
256
+ // Prevent concurrent collections
257
+ if (this.collecting) {
258
+ log.debug('Collection already in progress, skipping');
259
+ return;
260
+ }
261
+
262
+ this.collecting = true;
263
+
264
+ try {
265
+ // Ensure offline cache is loaded from IndexedDB before checking
266
+ await this._offlineDbReady;
267
+
268
+ log.info('Starting collection cycle...');
269
+ this.emit('collection-start');
270
+
271
+ // Check if browser reports offline
272
+ if (this.isOffline()) {
273
+ if (this.hasCachedData()) {
274
+ return this.collectOffline();
275
+ }
276
+ throw new Error('Offline with no cached data — cannot start playback');
277
+ }
278
+
279
+ // Register display
280
+ log.debug('Collection step: registerDisplay');
281
+ const regResult = await this.xmds.registerDisplay();
282
+ log.info('Display registered:', regResult);
283
+
284
+ // Cache settings for offline use
285
+ this._offlineSave('settings', regResult);
286
+
287
+ // Exit offline mode if we were in it
288
+ if (this.offlineMode) {
289
+ this.offlineMode = false;
290
+ console.log('[PlayerCore] Back online — resuming normal collection');
291
+ this.emit('offline-mode', false);
292
+ }
293
+
294
+ // Apply display settings if DisplaySettings manager is available
295
+ if (this.displaySettings && regResult.settings) {
296
+ const result = this.displaySettings.applySettings(regResult.settings);
297
+ if (result.changed.includes('collectInterval')) {
298
+ // Collection interval changed - update interval
299
+ this.updateCollectionInterval(result.settings.collectInterval);
300
+ }
301
+
302
+ // Apply CMS logLevel (respects local overrides)
303
+ if (regResult.settings.logLevel) {
304
+ const applied = applyCmsLogLevel(regResult.settings.logLevel);
305
+ if (applied) {
306
+ log.info('Log level updated from CMS:', regResult.settings.logLevel);
307
+ this.emit('log-level-changed', regResult.settings.logLevel);
308
+ }
309
+ }
310
+ }
311
+
312
+ // Store sync config if display is in a sync group
313
+ if (regResult.syncConfig) {
314
+ this.syncConfig = regResult.syncConfig;
315
+ log.info('Sync group:', regResult.syncConfig.isLead ? 'LEAD' : `follower → ${regResult.syncConfig.syncGroup}`,
316
+ `(switchDelay: ${regResult.syncConfig.syncSwitchDelay}ms, videoPauseDelay: ${regResult.syncConfig.syncVideoPauseDelay}ms)`);
317
+ this.emit('sync-config', regResult.syncConfig);
318
+ }
319
+
320
+ this.emit('register-complete', regResult);
321
+
322
+ // Initialize XMR if available
323
+ log.debug('Collection step: initializeXmr');
324
+ await this.initializeXmr(regResult);
325
+
326
+ // CRC32 skip optimization: only fetch RequiredFiles/Schedule when CMS data changed
327
+ const checkRf = regResult.checkRf || '';
328
+ const checkSchedule = regResult.checkSchedule || '';
329
+
330
+ // Get required files (skip if CRC unchanged)
331
+ if (!this._lastCheckRf || this._lastCheckRf !== checkRf) {
332
+ log.debug('Collection step: requiredFiles');
333
+ const allFiles = await this.xmds.requiredFiles();
334
+ // Separate purge entries from download entries
335
+ const purgeFiles = allFiles.filter(f => f.type === 'purge');
336
+ const files = allFiles.filter(f => f.type !== 'purge');
337
+ log.info('Required files:', files.length, purgeFiles.length > 0 ? `(+ ${purgeFiles.length} purge)` : '');
338
+ this._lastCheckRf = checkRf;
339
+ this.emit('files-received', files);
340
+
341
+ // Cache required files for offline use
342
+ this._offlineSave('requiredFiles', allFiles);
343
+
344
+ if (purgeFiles.length > 0) {
345
+ this.emit('purge-request', purgeFiles);
346
+ }
347
+
348
+ // Get schedule (skip if CRC unchanged)
349
+ if (!this._lastCheckSchedule || this._lastCheckSchedule !== checkSchedule) {
350
+ log.debug('Collection step: schedule');
351
+ const schedule = await this.xmds.schedule();
352
+ log.info('Schedule received');
353
+ this._lastCheckSchedule = checkSchedule;
354
+ log.debug('Collection step: processing schedule');
355
+ this.emit('schedule-received', schedule);
356
+ this.schedule.setSchedule(schedule);
357
+ this.updateDataConnectors();
358
+ this._offlineSave('schedule', schedule);
359
+ }
360
+
361
+ log.debug('Collection step: download-request + mediaInventory');
362
+ // Prioritize downloads by layout priority (highest first)
363
+ const currentLayouts = this.schedule.getCurrentLayouts();
364
+ const prioritizedFiles = this.prioritizeFilesByLayout(files, currentLayouts);
365
+ this._lastRequiredFiles = files;
366
+ this.emit('download-request', prioritizedFiles);
367
+
368
+ // Submit media inventory to CMS (reports cached files)
369
+ this.submitMediaInventory(files);
370
+ } else {
371
+ if (checkRf) {
372
+ log.info('RequiredFiles CRC unchanged, skipping download check');
373
+ }
374
+ if (this._lastCheckSchedule !== checkSchedule) {
375
+ const schedule = await this.xmds.schedule();
376
+ log.info('Schedule received (RF unchanged but schedule changed)');
377
+ this._lastCheckSchedule = checkSchedule;
378
+ this.emit('schedule-received', schedule);
379
+ this.schedule.setSchedule(schedule);
380
+ this.updateDataConnectors();
381
+ this._offlineSave('schedule', schedule);
382
+ } else if (checkSchedule) {
383
+ log.info('Schedule CRC unchanged, skipping');
384
+ }
385
+ }
386
+
387
+ log.debug('Collection step: evaluateSchedule');
388
+ // Evaluate current schedule
389
+ const layoutFiles = this.schedule.getCurrentLayouts();
390
+ log.info('Current layouts:', layoutFiles);
391
+ this.emit('layouts-scheduled', layoutFiles);
392
+
393
+ if (layoutFiles.length > 0) {
394
+ // If a layout is currently playing and it's still in the schedule, don't interrupt it.
395
+ // Let it finish its natural duration — advanceToNextLayout() handles the transition.
396
+ if (this.currentLayoutId) {
397
+ const currentStillScheduled = layoutFiles.some(f =>
398
+ parseInt(String(f).replace('.xlf', ''), 10) === this.currentLayoutId
399
+ );
400
+ if (currentStillScheduled) {
401
+ // Update round-robin index to match current layout's position
402
+ const idx = layoutFiles.findIndex(f =>
403
+ parseInt(String(f).replace('.xlf', ''), 10) === this.currentLayoutId
404
+ );
405
+ if (idx >= 0) this._currentLayoutIndex = idx;
406
+ log.debug(`Layout ${this.currentLayoutId} still in schedule, continuing playback`);
407
+ this.emit('layout-already-playing', this.currentLayoutId);
408
+ } else {
409
+ // Current layout is not in the schedule (unscheduled or filtered) — switch
410
+ this._currentLayoutIndex = 0;
411
+ const next = this.getNextLayout();
412
+ if (next) {
413
+ log.info(`Switching to layout ${next.layoutId} (from ${this.currentLayoutId})`);
414
+ this.emit('layout-prepare-request', next.layoutId);
415
+ }
416
+ }
417
+ } else {
418
+ // No current layout — start the first one
419
+ this._currentLayoutIndex = 0;
420
+ const next = this.getNextLayout();
421
+ if (next) {
422
+ log.info(`Switching to layout ${next.layoutId}`);
423
+ this.emit('layout-prepare-request', next.layoutId);
424
+ }
425
+ }
426
+ } else {
427
+ log.info('No layouts scheduled, falling back to default');
428
+ this.emit('no-layouts-scheduled');
429
+
430
+ // If we're currently playing a layout but schedule says no layouts (e.g., maxPlaysPerHour filtered it),
431
+ // force switch to default layout if available
432
+ if (this.currentLayoutId && this.schedule.schedule?.default) {
433
+ const defaultLayoutId = parseInt(this.schedule.schedule.default.replace('.xlf', ''), 10);
434
+ log.info(`Current layout filtered by schedule, switching to default layout ${defaultLayoutId}`);
435
+ this.currentLayoutId = null; // Clear to force switch
436
+ this.emit('layout-prepare-request', defaultLayoutId);
437
+ }
438
+ }
439
+
440
+ // Submit stats if enabled and collector is available
441
+ if (regResult.settings?.statsEnabled === 'On' || regResult.settings?.statsEnabled === '1') {
442
+ if (this.statsCollector) {
443
+ log.info('Stats enabled, submitting proof of play');
444
+ this.emit('submit-stats-request');
445
+ } else {
446
+ log.warn('Stats enabled but no StatsCollector provided');
447
+ }
448
+ }
449
+
450
+ // Submit logs to CMS (always, regardless of stats setting)
451
+ this.emit('submit-logs-request');
452
+
453
+ // Setup collection interval on first run
454
+ if (!this.collectionInterval && regResult.settings) {
455
+ this.setupCollectionInterval(regResult.settings);
456
+ }
457
+
458
+ this.emit('collection-complete');
459
+
460
+ } catch (error) {
461
+ // Offline fallback: if network failed but we have cached data, use it
462
+ if (this.hasCachedData()) {
463
+ console.warn('[PlayerCore] Collection failed, falling back to cached data:', error?.message || error);
464
+ this.emit('collection-error', error);
465
+ return this.collectOffline();
466
+ }
467
+
468
+ log.error('Collection error:', error);
469
+ this.emit('collection-error', error);
470
+ throw error;
471
+ } finally {
472
+ this.collecting = false;
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Initialize XMR WebSocket connection
478
+ */
479
+ async initializeXmr(regResult) {
480
+ const xmrUrl = regResult.settings?.xmrWebSocketAddress || regResult.settings?.xmrNetworkAddress;
481
+ if (!xmrUrl) {
482
+ log.warn('XMR not configured: no xmrWebSocketAddress or xmrNetworkAddress in CMS settings');
483
+ this.emit('xmr-misconfigured', {
484
+ reason: 'missing',
485
+ message: 'XMR address not configured in CMS. Go to CMS Admin → Settings → Configuration → XMR and set the WebSocket address.',
486
+ });
487
+ return;
488
+ }
489
+
490
+ // Validate URL protocol — PWA players need ws:// or wss://, not tcp://
491
+ if (xmrUrl.startsWith('tcp://')) {
492
+ log.warn(`XMR address uses tcp:// protocol which is not supported by PWA players: ${xmrUrl}`);
493
+ log.warn('Configure XMR_WS_ADDRESS in CMS Admin → Settings → Configuration → XMR (e.g. wss://your-domain/xmr)');
494
+ this.emit('xmr-misconfigured', {
495
+ reason: 'wrong-protocol',
496
+ url: xmrUrl,
497
+ message: `XMR uses tcp:// protocol (not supported by PWA). Set XMR WebSocket Address to wss://your-domain/xmr in CMS Settings.`,
498
+ });
499
+ return;
500
+ }
501
+
502
+ // Detect placeholder/example URLs
503
+ if (/example\.(org|com|net)/i.test(xmrUrl)) {
504
+ log.warn(`XMR address contains placeholder domain: ${xmrUrl}`);
505
+ log.warn('Configure the real XMR address in CMS Admin → Settings → Configuration → XMR');
506
+ this.emit('xmr-misconfigured', {
507
+ reason: 'placeholder',
508
+ url: xmrUrl,
509
+ message: `XMR address is still the default placeholder (${xmrUrl}). Update it in CMS Settings.`,
510
+ });
511
+ return;
512
+ }
513
+
514
+ const xmrCmsKey = regResult.settings?.xmrCmsKey || regResult.settings?.serverKey || this.config.serverKey;
515
+ log.debug('XMR CMS Key:', xmrCmsKey ? 'present' : 'missing');
516
+
517
+ if (!this.xmr) {
518
+ log.info('Initializing XMR WebSocket:', xmrUrl);
519
+ this.xmr = new this.XmrWrapper(this.config, this);
520
+ await this.xmr.start(xmrUrl, xmrCmsKey);
521
+ this.emit('xmr-connected', xmrUrl);
522
+ } else if (!this.xmr.isConnected()) {
523
+ log.info('XMR disconnected, attempting to reconnect...');
524
+ this.xmr.reconnectAttempts = 0;
525
+ await this.xmr.start(xmrUrl, xmrCmsKey);
526
+ this.emit('xmr-reconnected', xmrUrl);
527
+ } else {
528
+ log.debug('XMR already connected');
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Setup collection interval
534
+ */
535
+ setupCollectionInterval(settings) {
536
+ // Use DisplaySettings if available, otherwise fallback to raw settings
537
+ const collectIntervalSeconds = this.displaySettings
538
+ ? this.displaySettings.getCollectInterval()
539
+ : parseInt(settings.collectInterval || '300', 10);
540
+
541
+ const collectIntervalMs = collectIntervalSeconds * 1000;
542
+
543
+ log.info(`Setting up collection interval: ${collectIntervalSeconds}s`);
544
+
545
+ this.collectionInterval = setInterval(() => {
546
+ log.debug('Running scheduled collection cycle...');
547
+ this.collect().catch(error => {
548
+ log.error('Collection error:', error);
549
+ this.emit('collection-error', error);
550
+ });
551
+ }, collectIntervalMs);
552
+
553
+ this.emit('collection-interval-set', collectIntervalSeconds);
554
+ }
555
+
556
+ /**
557
+ * Update collection interval dynamically
558
+ * Called when CMS changes the collection interval
559
+ */
560
+ updateCollectionInterval(newIntervalSeconds) {
561
+ if (this.collectionInterval) {
562
+ clearInterval(this.collectionInterval);
563
+ log.info(`Updating collection interval: ${newIntervalSeconds}s`);
564
+
565
+ const collectIntervalMs = newIntervalSeconds * 1000;
566
+
567
+ this.collectionInterval = setInterval(() => {
568
+ log.debug('Running scheduled collection cycle...');
569
+ this.collect().catch(error => {
570
+ log.error('Collection error:', error);
571
+ this.emit('collection-error', error);
572
+ });
573
+ }, collectIntervalMs);
574
+
575
+ this.emit('collection-interval-updated', newIntervalSeconds);
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Request layout change (called by XMR or schedule)
581
+ * Pure orchestration - emits events for platform to handle
582
+ */
583
+ async requestLayoutChange(layoutId) {
584
+ log.info(`Layout change requested: ${layoutId}`);
585
+
586
+ // Clear current layout tracking so it will switch
587
+ this.currentLayoutId = null;
588
+
589
+ this.emit('layout-change-requested', layoutId);
590
+ }
591
+
592
+ /**
593
+ * Mark layout as ready and current
594
+ * Called by platform after it successfully renders the layout
595
+ */
596
+ setCurrentLayout(layoutId) {
597
+ this.currentLayoutId = layoutId;
598
+ this.pendingLayouts.delete(layoutId);
599
+ this.emit('layout-current', layoutId);
600
+ }
601
+
602
+ /**
603
+ * Mark layout as pending (waiting for media)
604
+ * Called by platform when layout needs media downloads
605
+ */
606
+ setPendingLayout(layoutId, requiredMediaIds) {
607
+ this.pendingLayouts.set(layoutId, requiredMediaIds);
608
+ this.emit('layout-pending', layoutId, requiredMediaIds);
609
+ }
610
+
611
+ /**
612
+ * Clear current layout (for replay)
613
+ * Called by platform when layout ends
614
+ */
615
+ clearCurrentLayout() {
616
+ this.currentLayoutId = null;
617
+ this.emit('layout-cleared');
618
+ }
619
+
620
+ /**
621
+ * Get the next layout from the schedule using round-robin cycling.
622
+ * Returns { layoutId, layoutFile } or null if no layouts are scheduled.
623
+ */
624
+ getNextLayout() {
625
+ const layoutFiles = this.schedule.getCurrentLayouts();
626
+ if (layoutFiles.length === 0) {
627
+ return null;
628
+ }
629
+
630
+ // Wrap index in case schedule shrank
631
+ if (this._currentLayoutIndex >= layoutFiles.length) {
632
+ this._currentLayoutIndex = 0;
633
+ }
634
+
635
+ const layoutFile = layoutFiles[this._currentLayoutIndex];
636
+ const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
637
+ return { layoutId, layoutFile };
638
+ }
639
+
640
+ /**
641
+ * Peek at the next layout in the schedule without advancing the index.
642
+ * Used by the preload system to know which layout to pre-build.
643
+ * Returns { layoutId, layoutFile } or null if no next layout or same as current.
644
+ */
645
+ peekNextLayout() {
646
+ const layoutFiles = this.schedule.getCurrentLayouts();
647
+ if (layoutFiles.length <= 1) {
648
+ // Single layout or empty schedule - no different layout to preload
649
+ return null;
650
+ }
651
+
652
+ const nextIndex = (this._currentLayoutIndex + 1) % layoutFiles.length;
653
+ const layoutFile = layoutFiles[nextIndex];
654
+ const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
655
+
656
+ // Don't return if it's the same as current (no point preloading)
657
+ if (layoutId === this.currentLayoutId) {
658
+ return null;
659
+ }
660
+
661
+ return { layoutId, layoutFile };
662
+ }
663
+
664
+ /**
665
+ * Advance to the next layout in the schedule (round-robin).
666
+ * Called by platform layer when a layout finishes (layoutEnd event).
667
+ * Increments the index and emits layout-prepare-request for the next layout,
668
+ * or triggers replay if only one layout is scheduled.
669
+ */
670
+ advanceToNextLayout() {
671
+ // Don't cycle if we're in a layout override (XMR changeLayout/overlayLayout)
672
+ if (this._layoutOverride) {
673
+ log.info('Layout override active, not advancing schedule');
674
+ return;
675
+ }
676
+
677
+ const layoutFiles = this.schedule.getCurrentLayouts();
678
+ log.info(`Advancing schedule: ${layoutFiles.length} layout(s) available, current index ${this._currentLayoutIndex}`);
679
+
680
+ // ── Never-stop guarantee ────────────────────────────────────────
681
+ // If no layouts are available at all (every layout is rate-limited
682
+ // or filtered), replay the current layout as a last resort.
683
+ // maxPlaysPerHour is respected in all other cases — this only fires
684
+ // when the alternative would be a blank screen.
685
+ if (layoutFiles.length === 0) {
686
+ if (this.currentLayoutId) {
687
+ log.info(`No layouts available (all rate-limited), replaying ${this.currentLayoutId} to avoid blank screen`);
688
+ const replayId = this.currentLayoutId;
689
+ this.currentLayoutId = null;
690
+ this.emit('layout-prepare-request', replayId);
691
+ } else {
692
+ log.info('No layouts scheduled during advance');
693
+ this.emit('no-layouts-scheduled');
694
+ }
695
+ return;
696
+ }
697
+
698
+ // Advance index (wraps around)
699
+ this._currentLayoutIndex = (this._currentLayoutIndex + 1) % layoutFiles.length;
700
+
701
+ const layoutFile = layoutFiles[this._currentLayoutIndex];
702
+ const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
703
+
704
+ // Multi-display sync: if this is a sync event and we have a SyncManager,
705
+ // delegate layout transitions to the sync protocol
706
+ if (this.syncManager && this.schedule.isSyncEvent(layoutFile)) {
707
+ if (this.isSyncLead()) {
708
+ // Lead: coordinate with followers before showing
709
+ log.info(`[Sync] Lead requesting coordinated layout change: ${layoutId}`);
710
+ this.syncManager.requestLayoutChange(layoutId).catch(err => {
711
+ log.error('[Sync] Layout change failed:', err);
712
+ // Fallback: show layout anyway
713
+ this.emit('layout-prepare-request', layoutId);
714
+ });
715
+ return;
716
+ } else {
717
+ // Follower: don't advance independently — wait for lead's layout-change signal
718
+ log.info(`[Sync] Follower waiting for lead signal (not advancing independently)`);
719
+ return;
720
+ }
721
+ }
722
+
723
+ if (layoutId === this.currentLayoutId) {
724
+ // Same layout (single layout schedule or wrapped back) — trigger replay
725
+ log.info(`Next layout ${layoutId} is same as current, triggering replay`);
726
+ this.currentLayoutId = null; // Clear to allow re-render
727
+ }
728
+
729
+ log.info(`Advancing to layout ${layoutId} (index ${this._currentLayoutIndex}/${layoutFiles.length})`);
730
+ this.emit('layout-prepare-request', layoutId);
731
+ }
732
+
733
+ /**
734
+ * Notify that a file is ready (called by platform for both layout and media files)
735
+ * Checks if any pending layouts can now be rendered
736
+ */
737
+ notifyMediaReady(fileId, fileType = 'media') {
738
+ log.debug(`File ${fileId} ready (${fileType})`);
739
+
740
+ // Check if any pending layouts are now complete
741
+ for (const [layoutId, requiredFiles] of this.pendingLayouts.entries()) {
742
+ // Check if this file is needed by this layout
743
+ // For layout files: match layout ID with file ID (layout 78 needs layout/78)
744
+ // For media files: check if fileId is in requiredFiles array
745
+ const isLayoutFile = fileType === 'layout' && layoutId === parseInt(fileId);
746
+ const isRequiredMedia = fileType === 'media' && requiredFiles.includes(parseInt(fileId));
747
+
748
+ if (isLayoutFile || isRequiredMedia) {
749
+ log.debug(`${fileType} ${fileId} was needed by pending layout ${layoutId}, checking if ready...`);
750
+ this.emit('check-pending-layout', layoutId, requiredFiles);
751
+ }
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Notify layout status to CMS
757
+ */
758
+ async notifyLayoutStatus(layoutId) {
759
+ try {
760
+ await this.xmds.notifyStatus({ currentLayoutId: layoutId });
761
+ this.emit('status-notified', layoutId);
762
+ } catch (error) {
763
+ log.warn('Failed to notify status:', error);
764
+ this.emit('status-notify-failed', layoutId, error);
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Capture screenshot (called by XMR wrapper)
770
+ * Emits event for platform layer to handle
771
+ */
772
+ async captureScreenshot() {
773
+ log.info('Screenshot requested');
774
+ this.emit('screenshot-request');
775
+ }
776
+
777
+ /**
778
+ * Change to a specific layout (called by XMR wrapper)
779
+ * Tracks override state so revertToSchedule() can undo it.
780
+ */
781
+ async changeLayout(layoutId) {
782
+ log.info('Layout change requested via XMR:', layoutId);
783
+ this._layoutOverride = { layoutId: parseInt(layoutId, 10), type: 'change' };
784
+ this.currentLayoutId = null; // Force re-render
785
+ this.emit('layout-prepare-request', parseInt(layoutId, 10));
786
+ }
787
+
788
+ /**
789
+ * Push an overlay layout on top of current content (called by XMR wrapper)
790
+ * @param {number|string} layoutId - Layout to overlay
791
+ */
792
+ async overlayLayout(layoutId) {
793
+ log.info('Overlay layout requested via XMR:', layoutId);
794
+ this._layoutOverride = { layoutId: parseInt(layoutId, 10), type: 'overlay' };
795
+ this.emit('overlay-layout-request', parseInt(layoutId, 10));
796
+ }
797
+
798
+ /**
799
+ * Revert to scheduled content after changeLayout/overlayLayout override
800
+ */
801
+ async revertToSchedule() {
802
+ log.info('Reverting to scheduled content');
803
+ this._layoutOverride = null;
804
+ this.currentLayoutId = null;
805
+ this.emit('revert-to-schedule');
806
+
807
+ // Re-evaluate schedule to get the right layout
808
+ const layoutFiles = this.schedule.getCurrentLayouts();
809
+ if (layoutFiles.length > 0) {
810
+ const layoutFile = layoutFiles[0];
811
+ const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
812
+ this.emit('layout-prepare-request', layoutId);
813
+ } else {
814
+ this.emit('no-layouts-scheduled');
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Purge all cached content and re-download (called by XMR wrapper)
820
+ */
821
+ async purgeAll() {
822
+ log.info('Purge all cache requested via XMR');
823
+ this._lastCheckRf = null;
824
+ this._lastCheckSchedule = null;
825
+ this.emit('purge-all-request');
826
+ // Trigger immediate re-collection after purge
827
+ return this.collectNow();
828
+ }
829
+
830
+ /**
831
+ * Execute a command (HTTP only in browser context)
832
+ * @param {string} commandCode - The command code from CMS
833
+ * @param {Object} commands - Commands map from display settings
834
+ */
835
+ async executeCommand(commandCode, commands) {
836
+ log.info('Execute command requested:', commandCode);
837
+
838
+ if (!commands || !commands[commandCode]) {
839
+ log.warn('Unknown command code:', commandCode);
840
+ this.emit('command-result', { code: commandCode, success: false, reason: 'Unknown command' });
841
+ return;
842
+ }
843
+
844
+ const command = commands[commandCode];
845
+ const commandString = command.commandString || command.value || '';
846
+
847
+ // Only HTTP commands are possible in a browser
848
+ if (commandString.startsWith('http|')) {
849
+ const parts = commandString.split('|');
850
+ const url = parts[1];
851
+ const contentType = parts[2] || 'application/json';
852
+
853
+ try {
854
+ const response = await fetch(url, {
855
+ method: 'POST',
856
+ headers: { 'Content-Type': contentType }
857
+ });
858
+ const success = response.ok;
859
+ log.info(`HTTP command ${commandCode} result: ${response.status}`);
860
+ this.emit('command-result', { code: commandCode, success, status: response.status });
861
+ } catch (error) {
862
+ log.error(`HTTP command ${commandCode} failed:`, error);
863
+ this.emit('command-result', { code: commandCode, success: false, reason: error.message });
864
+ }
865
+ } else {
866
+ log.warn('Non-HTTP commands not supported in browser:', commandCode);
867
+ this.emit('command-result', { code: commandCode, success: false, reason: 'Only HTTP commands supported in browser' });
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Trigger a webhook action (called by XMR wrapper)
873
+ * @param {string} triggerCode - The trigger code to fire
874
+ */
875
+ triggerWebhook(triggerCode) {
876
+ log.info('Webhook trigger from XMR:', triggerCode);
877
+ this.handleTrigger(triggerCode);
878
+ }
879
+
880
+ /**
881
+ * Force refresh of data connectors (called by XMR wrapper)
882
+ */
883
+ refreshDataConnectors() {
884
+ log.info('Data connector refresh requested via XMR');
885
+ this.dataConnectorManager.refreshAll();
886
+ this.emit('data-connectors-refreshed');
887
+ }
888
+
889
+ /**
890
+ * Submit media inventory to CMS
891
+ * Reports which files are cached and complete.
892
+ * @param {Array} files - List of files from RequiredFiles
893
+ */
894
+ async submitMediaInventory(files) {
895
+ if (!files || files.length === 0) return;
896
+
897
+ try {
898
+ // Build inventory XML: <files><file type="media" id="1" complete="1" md5="abc" lastChecked="123"/></files>
899
+ const now = Math.floor(Date.now() / 1000);
900
+ const fileEntries = files
901
+ .filter(f => f.type === 'media' || f.type === 'layout')
902
+ .map(f => `<file type="${f.type}" id="${f.id}" complete="1" md5="${f.md5 || ''}" lastChecked="${now}"/>`)
903
+ .join('');
904
+ const inventoryXml = `<files>${fileEntries}</files>`;
905
+
906
+ await this.xmds.mediaInventory(inventoryXml);
907
+ log.info(`Media inventory submitted: ${files.length} files`);
908
+ this.emit('media-inventory-submitted', files.length);
909
+ } catch (error) {
910
+ log.warn('MediaInventory submission failed:', error);
911
+ }
912
+ }
913
+
914
+ /**
915
+ * BlackList a media file (report broken media to CMS)
916
+ * @param {string|number} mediaId - The media ID
917
+ * @param {string} type - File type ('media' or 'layout')
918
+ * @param {string} reason - Reason for blacklisting
919
+ */
920
+ async blackList(mediaId, type, reason) {
921
+ try {
922
+ await this.xmds.blackList(mediaId, type, reason);
923
+ this.emit('media-blacklisted', { mediaId, type, reason });
924
+ } catch (error) {
925
+ log.warn('BlackList failed:', error);
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Check if currently in a layout override (from XMR changeLayout/overlayLayout)
931
+ */
932
+ isLayoutOverridden() {
933
+ return this._layoutOverride !== null;
934
+ }
935
+
936
+ /**
937
+ * Handle interactive trigger (from IC or touch events)
938
+ * Looks up matching action in schedule and executes it
939
+ * @param {string} triggerCode - The trigger code from the IC request
940
+ */
941
+ handleTrigger(triggerCode) {
942
+ const action = this.schedule.findActionByTrigger(triggerCode);
943
+ if (!action) {
944
+ log.debug('No scheduled action matches trigger:', triggerCode);
945
+ return;
946
+ }
947
+
948
+ log.info(`Action triggered: ${action.actionType} (trigger: ${triggerCode})`);
949
+
950
+ switch (action.actionType) {
951
+ case 'navLayout':
952
+ case 'navigateToLayout':
953
+ if (action.layoutCode) {
954
+ this.changeLayout(action.layoutCode);
955
+ }
956
+ break;
957
+ case 'navWidget':
958
+ case 'navigateToWidget':
959
+ this.emit('navigate-to-widget', action);
960
+ break;
961
+ case 'command':
962
+ this.emit('execute-command', action.commandCode);
963
+ break;
964
+ default:
965
+ log.warn('Unknown action type:', action.actionType);
966
+ }
967
+ }
968
+
969
+ /**
970
+ * Update data connectors from current schedule
971
+ * Reconfigures and restarts polling when schedule changes.
972
+ */
973
+ updateDataConnectors() {
974
+ const connectors = this.schedule.getDataConnectors();
975
+
976
+ if (connectors.length > 0) {
977
+ log.info(`Configuring ${connectors.length} data connector(s)`);
978
+ }
979
+
980
+ this.dataConnectorManager.setConnectors(connectors);
981
+
982
+ if (connectors.length > 0) {
983
+ this.dataConnectorManager.startPolling();
984
+ this.emit('data-connectors-started', connectors.length);
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Get the DataConnectorManager instance
990
+ * Used by platform layer to serve data to widgets via IC /realtime
991
+ * @returns {DataConnectorManager}
992
+ */
993
+ getDataConnectorManager() {
994
+ return this.dataConnectorManager;
995
+ }
996
+
997
+ /**
998
+ * Set the SyncManager instance for multi-display coordination.
999
+ * Called by platform layer after RegisterDisplay returns syncConfig.
1000
+ *
1001
+ * @param {SyncManager} syncManager - SyncManager instance
1002
+ */
1003
+ setSyncManager(syncManager) {
1004
+ this.syncManager = syncManager;
1005
+ log.info('SyncManager attached:', syncManager.isLead ? 'LEAD' : 'FOLLOWER');
1006
+ }
1007
+
1008
+ /**
1009
+ * Check if this display is part of a sync group
1010
+ * @returns {boolean}
1011
+ */
1012
+ isInSyncGroup() {
1013
+ return this.syncConfig !== null;
1014
+ }
1015
+
1016
+ /**
1017
+ * Check if this display is the sync group leader
1018
+ * @returns {boolean}
1019
+ */
1020
+ isSyncLead() {
1021
+ return this.syncConfig?.isLead === true;
1022
+ }
1023
+
1024
+ /**
1025
+ * Get sync configuration
1026
+ * @returns {Object|null} { syncGroup, syncPublisherPort, syncSwitchDelay, syncVideoPauseDelay, isLead }
1027
+ */
1028
+ getSyncConfig() {
1029
+ return this.syncConfig;
1030
+ }
1031
+
1032
+ /**
1033
+ * Cleanup
1034
+ */
1035
+ cleanup() {
1036
+ if (this.collectionInterval) {
1037
+ clearInterval(this.collectionInterval);
1038
+ this.collectionInterval = null;
1039
+ }
1040
+
1041
+ if (this.xmr) {
1042
+ this.xmr.stop();
1043
+ this.xmr = null;
1044
+ }
1045
+
1046
+ // Stop multi-display sync
1047
+ if (this.syncManager) {
1048
+ this.syncManager.stop();
1049
+ this.syncManager = null;
1050
+ }
1051
+
1052
+ // Stop data connector polling
1053
+ this.dataConnectorManager.cleanup();
1054
+
1055
+ // Emit cleanup-complete before removing listeners
1056
+ this.emit('cleanup-complete');
1057
+ this.removeAllListeners();
1058
+ }
1059
+
1060
+ /**
1061
+ * Get current layout ID
1062
+ */
1063
+ getCurrentLayoutId() {
1064
+ return this.currentLayoutId;
1065
+ }
1066
+
1067
+ /**
1068
+ * Check if collecting
1069
+ */
1070
+ isCollecting() {
1071
+ return this.collecting;
1072
+ }
1073
+
1074
+ /**
1075
+ * Get pending layouts
1076
+ */
1077
+ getPendingLayouts() {
1078
+ return Array.from(this.pendingLayouts.keys());
1079
+ }
1080
+
1081
+ /**
1082
+ * Prioritize file downloads for fastest playback start:
1083
+ * 1. Layout XLFs for currently scheduled layouts (tiny, needed for parsing)
1084
+ * 2. Other layout XLFs (also tiny)
1085
+ * 3. Resource files (fonts, bundle.min.js — small, needed by widgets)
1086
+ * 4. Media files sorted by ascending size (small files complete faster)
1087
+ *
1088
+ * This ensures layouts are parseable ASAP so prepareAndRenderLayout() can
1089
+ * call prioritizeDownload() for the specific media the current layout needs.
1090
+ */
1091
+ prioritizeFilesByLayout(files, currentLayouts) {
1092
+ const currentLayoutIds = new Set();
1093
+ currentLayouts.forEach((layoutFile) => {
1094
+ currentLayoutIds.add(parseInt(String(layoutFile).replace('.xlf', ''), 10));
1095
+ });
1096
+
1097
+ // Assign priority tiers
1098
+ const tiered = files.map(f => {
1099
+ let tier;
1100
+ if (f.type === 'layout') {
1101
+ const layoutId = parseInt(f.id);
1102
+ tier = currentLayoutIds.has(layoutId) ? 0 : 1; // Current layouts first
1103
+ } else if (f.type === 'resource' || f.code === 'fonts.css' ||
1104
+ (f.path && (f.path.includes('bundle.min') || f.path.includes('fonts')))) {
1105
+ tier = 2; // Resources (fonts, bundle.min.js)
1106
+ } else {
1107
+ tier = 3; // Media
1108
+ }
1109
+ return { file: f, tier };
1110
+ });
1111
+
1112
+ // Sort by tier, then by ascending size within each tier
1113
+ tiered.sort((a, b) => {
1114
+ if (a.tier !== b.tier) return a.tier - b.tier;
1115
+ return (a.file.size || 0) - (b.file.size || 0);
1116
+ });
1117
+
1118
+ return tiered.map(t => t.file);
1119
+ }
1120
+ }