@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.
- package/CAMPAIGNS.md +254 -0
- package/README.md +163 -0
- package/TESTING_STATUS.md +281 -0
- package/TEST_STANDARDIZATION_COMPLETE.md +287 -0
- package/docs/ARCHITECTURE.md +714 -0
- package/docs/README.md +92 -0
- package/examples/dayparting-schedule-example.json +190 -0
- package/index.html +262 -0
- package/package.json +53 -0
- package/proxy.js +72 -0
- package/public/manifest.json +22 -0
- package/public/sw.js +218 -0
- package/setup.html +220 -0
- package/src/data-connectors.js +198 -0
- package/src/index.js +4 -0
- package/src/main.js +580 -0
- package/src/player-core.js +1120 -0
- package/src/player-core.test.js +1796 -0
- package/src/state.js +54 -0
- package/src/state.test.js +206 -0
- package/src/test-utils.js +217 -0
- package/src/xmds-test.html +109 -0
- package/src/xmds.test.js +516 -0
- package/vite.config.js +51 -0
- package/vitest.config.js +35 -0
|
@@ -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
|
+
}
|