@xiboplayer/core 0.1.3 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/core",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Xibo Player core orchestration and lifecycle management",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -11,13 +11,13 @@
11
11
  "dependencies": {
12
12
  "@xibosignage/xibo-communication-framework": "^0.0.6",
13
13
  "nanoevents": "^9.1.0",
14
- "@xiboplayer/utils": "0.1.3"
14
+ "@xiboplayer/utils": "0.2.0"
15
15
  },
16
16
  "peerDependencies": {
17
- "@xiboplayer/cache": "0.1.3",
18
- "@xiboplayer/schedule": "0.1.3",
19
- "@xiboplayer/renderer": "0.1.3",
20
- "@xiboplayer/xmds": "0.1.3"
17
+ "@xiboplayer/cache": "0.2.0",
18
+ "@xiboplayer/renderer": "0.2.0",
19
+ "@xiboplayer/schedule": "0.2.0",
20
+ "@xiboplayer/xmds": "0.2.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vite": "^7.3.1",
@@ -43,6 +43,7 @@
43
43
  */
44
44
 
45
45
  import { EventEmitter, createLogger, applyCmsLogLevel } from '@xiboplayer/utils';
46
+ import { calculateTimeline, parseLayoutDuration } from '@xiboplayer/schedule';
46
47
  import { DataConnectorManager } from './data-connectors.js';
47
48
 
48
49
  const log = createLogger('PlayerCore');
@@ -52,6 +53,11 @@ const OFFLINE_DB_NAME = 'xibo-offline-cache';
52
53
  const OFFLINE_DB_VERSION = 1;
53
54
  const OFFLINE_STORE = 'cache';
54
55
 
56
+ /** Extract layout ID from a schedule filename like "123.xlf" */
57
+ function parseLayoutFile(f) {
58
+ return parseInt(String(f).replace('.xlf', ''), 10);
59
+ }
60
+
55
61
  /** Open the offline cache IndexedDB (creates store on first use) */
56
62
  function openOfflineDb() {
57
63
  return new Promise((resolve, reject) => {
@@ -107,6 +113,9 @@ export class PlayerCore extends EventEmitter {
107
113
  this.syncConfig = null;
108
114
  this.syncManager = null; // Optional: set via setSyncManager() after RegisterDisplay
109
115
 
116
+ // Layout durations for timeline calculation (layoutFile/layoutId → seconds)
117
+ this._layoutDurations = new Map();
118
+
110
119
  // In-memory offline cache (populated from IndexedDB on first load)
111
120
  this._offlineCache = { schedule: null, settings: null, requiredFiles: null };
112
121
  this._offlineDbReady = this._initOfflineCache();
@@ -195,48 +204,61 @@ export class PlayerCore extends EventEmitter {
195
204
  this.emit('schedule-received', cachedSchedule);
196
205
  }
197
206
 
198
- // Evaluate current schedule (same logic as online path)
207
+ // Evaluate current schedule
199
208
  const layoutFiles = this.schedule.getCurrentLayouts();
200
209
  log.info('Offline layouts:', layoutFiles);
201
210
  this.emit('layouts-scheduled', layoutFiles);
202
211
 
212
+ this._evaluateAndSwitchLayout(layoutFiles, 'Offline');
213
+
214
+ this.emit('collection-complete');
215
+ }
216
+
217
+ /**
218
+ * Evaluate the current schedule and switch layouts if needed.
219
+ * Shared by both collect() and collectOffline() after emitting 'layouts-scheduled'.
220
+ * @param {string[]} layoutFiles - Currently scheduled layout filenames
221
+ * @param {string} context - Log context label (e.g. 'Offline' or '')
222
+ */
223
+ async _evaluateAndSwitchLayout(layoutFiles, context) {
224
+ const prefix = context ? `${context}: ` : '';
225
+
203
226
  if (layoutFiles.length > 0) {
204
- // If a layout is currently playing and still in the schedule, don't interrupt
205
227
  if (this.currentLayoutId) {
206
228
  const currentStillScheduled = layoutFiles.some(f =>
207
- parseInt(String(f).replace('.xlf', ''), 10) === this.currentLayoutId
229
+ parseLayoutFile(f) === this.currentLayoutId
208
230
  );
209
231
  if (currentStillScheduled) {
210
232
  const idx = layoutFiles.findIndex(f =>
211
- parseInt(String(f).replace('.xlf', ''), 10) === this.currentLayoutId
233
+ parseLayoutFile(f) === this.currentLayoutId
212
234
  );
213
235
  if (idx >= 0) this._currentLayoutIndex = idx;
214
- log.debug(`Layout ${this.currentLayoutId} still in schedule (offline), continuing playback`);
236
+ log.debug(`Layout ${this.currentLayoutId} still in schedule${context ? ` (${context.toLowerCase()})` : ''}, continuing playback`);
215
237
  this.emit('layout-already-playing', this.currentLayoutId);
216
238
  } else {
217
- // Current layout not in schedule — switch
218
239
  this._currentLayoutIndex = 0;
219
240
  const next = this.getNextLayout();
220
241
  if (next) {
221
- log.info(`Offline: switching to layout ${next.layoutId}`);
242
+ log.info(`${prefix}switching to layout ${next.layoutId}${!context ? ` (from ${this.currentLayoutId})` : ''}`);
222
243
  this.emit('layout-prepare-request', next.layoutId);
223
244
  }
224
245
  }
225
246
  } else {
226
- // No current layout — start the first one
227
247
  this._currentLayoutIndex = 0;
228
248
  const next = this.getNextLayout();
229
249
  if (next) {
230
- log.info(`Offline: switching to layout ${next.layoutId}`);
250
+ log.info(`${prefix}switching to layout ${next.layoutId}`);
231
251
  this.emit('layout-prepare-request', next.layoutId);
232
252
  }
233
253
  }
234
254
  } else {
235
- log.info('Offline: no layouts in cached schedule');
255
+ log.info(`${context ? `${context}: n` : 'N'}o layouts${context ? ' in cached schedule' : ' scheduled, falling back to default'}`);
236
256
  this.emit('no-layouts-scheduled');
237
257
  }
238
258
 
239
- this.emit('collection-complete');
259
+ // Build layout durations and log upcoming timeline
260
+ await this._buildLayoutDurations();
261
+ this.logUpcomingTimeline();
240
262
  }
241
263
 
242
264
  /**
@@ -356,6 +378,7 @@ export class PlayerCore extends EventEmitter {
356
378
  this.schedule.setSchedule(schedule);
357
379
  this.updateDataConnectors();
358
380
  this._offlineSave('schedule', schedule);
381
+ this.logUpcomingTimeline();
359
382
  }
360
383
 
361
384
  log.debug('Collection step: download-request + mediaInventory');
@@ -379,6 +402,7 @@ export class PlayerCore extends EventEmitter {
379
402
  this.schedule.setSchedule(schedule);
380
403
  this.updateDataConnectors();
381
404
  this._offlineSave('schedule', schedule);
405
+ this.logUpcomingTimeline();
382
406
  } else if (checkSchedule) {
383
407
  log.info('Schedule CRC unchanged, skipping');
384
408
  }
@@ -390,51 +414,15 @@ export class PlayerCore extends EventEmitter {
390
414
  log.info('Current layouts:', layoutFiles);
391
415
  this.emit('layouts-scheduled', layoutFiles);
392
416
 
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');
417
+ this._evaluateAndSwitchLayout(layoutFiles, '');
429
418
 
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
- }
419
+ // If no layouts scheduled and we're playing one that was filtered (e.g., maxPlaysPerHour),
420
+ // force switch to default layout if available
421
+ if (layoutFiles.length === 0 && this.currentLayoutId && this.schedule.schedule?.default) {
422
+ const defaultLayoutId = parseLayoutFile(this.schedule.schedule.default);
423
+ log.info(`Current layout filtered by schedule, switching to default layout ${defaultLayoutId}`);
424
+ this.currentLayoutId = null; // Clear to force switch
425
+ this.emit('layout-prepare-request', defaultLayoutId);
438
426
  }
439
427
 
440
428
  // Submit stats if enabled and collector is available
@@ -538,18 +526,7 @@ export class PlayerCore extends EventEmitter {
538
526
  ? this.displaySettings.getCollectInterval()
539
527
  : parseInt(settings.collectInterval || '300', 10);
540
528
 
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
-
529
+ this._setCollectionTimer(collectIntervalSeconds);
553
530
  this.emit('collection-interval-set', collectIntervalSeconds);
554
531
  }
555
532
 
@@ -559,23 +536,24 @@ export class PlayerCore extends EventEmitter {
559
536
  */
560
537
  updateCollectionInterval(newIntervalSeconds) {
561
538
  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
-
539
+ this._setCollectionTimer(newIntervalSeconds);
575
540
  this.emit('collection-interval-updated', newIntervalSeconds);
576
541
  }
577
542
  }
578
543
 
544
+ /** Internal: (re)create the collection setInterval timer */
545
+ _setCollectionTimer(seconds) {
546
+ if (this.collectionInterval) clearInterval(this.collectionInterval);
547
+ log.info(`Collection interval: ${seconds}s`);
548
+ this.collectionInterval = setInterval(() => {
549
+ log.debug('Running scheduled collection cycle...');
550
+ this.collect().catch(error => {
551
+ log.error('Collection error:', error);
552
+ this.emit('collection-error', error);
553
+ });
554
+ }, seconds * 1000);
555
+ }
556
+
579
557
  /**
580
558
  * Request layout change (called by XMR or schedule)
581
559
  * Pure orchestration - emits events for platform to handle
@@ -597,6 +575,8 @@ export class PlayerCore extends EventEmitter {
597
575
  this.currentLayoutId = layoutId;
598
576
  this.pendingLayouts.delete(layoutId);
599
577
  this.emit('layout-current', layoutId);
578
+ // Re-log timeline from current time on each layout change
579
+ this.logUpcomingTimeline();
600
580
  }
601
581
 
602
582
  /**
@@ -633,7 +613,7 @@ export class PlayerCore extends EventEmitter {
633
613
  }
634
614
 
635
615
  const layoutFile = layoutFiles[this._currentLayoutIndex];
636
- const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
616
+ const layoutId = parseLayoutFile(layoutFile);
637
617
  return { layoutId, layoutFile };
638
618
  }
639
619
 
@@ -651,7 +631,7 @@ export class PlayerCore extends EventEmitter {
651
631
 
652
632
  const nextIndex = (this._currentLayoutIndex + 1) % layoutFiles.length;
653
633
  const layoutFile = layoutFiles[nextIndex];
654
- const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
634
+ const layoutId = parseLayoutFile(layoutFile);
655
635
 
656
636
  // Don't return if it's the same as current (no point preloading)
657
637
  if (layoutId === this.currentLayoutId) {
@@ -699,7 +679,7 @@ export class PlayerCore extends EventEmitter {
699
679
  this._currentLayoutIndex = (this._currentLayoutIndex + 1) % layoutFiles.length;
700
680
 
701
681
  const layoutFile = layoutFiles[this._currentLayoutIndex];
702
- const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
682
+ const layoutId = parseLayoutFile(layoutFile);
703
683
 
704
684
  // Multi-display sync: if this is a sync event and we have a SyncManager,
705
685
  // delegate layout transitions to the sync protocol
@@ -730,6 +710,37 @@ export class PlayerCore extends EventEmitter {
730
710
  this.emit('layout-prepare-request', layoutId);
731
711
  }
732
712
 
713
+ /**
714
+ * Go back to the previous layout in the schedule (round-robin, wraps around).
715
+ * Called by platform layer in response to manual navigation (keyboard/remote).
716
+ * Skips sync-manager logic — manual navigation is local only.
717
+ */
718
+ advanceToPreviousLayout() {
719
+ if (this._layoutOverride) {
720
+ log.info('Layout override active, not going back');
721
+ return;
722
+ }
723
+
724
+ const layoutFiles = this.schedule.getCurrentLayouts();
725
+ if (layoutFiles.length === 0) return;
726
+
727
+ // Decrement index (wrap around)
728
+ const prevIndex = (this._currentLayoutIndex - 1 + layoutFiles.length) % layoutFiles.length;
729
+
730
+ const layoutFile = layoutFiles[prevIndex];
731
+ const layoutId = parseLayoutFile(layoutFile);
732
+
733
+ // No-op if it's the same layout (single-layout schedule) — don't restart
734
+ if (layoutId === this.currentLayoutId) {
735
+ log.info('Only one layout in schedule, nothing to go back to');
736
+ return;
737
+ }
738
+
739
+ this._currentLayoutIndex = prevIndex;
740
+ log.info(`Going back to layout ${layoutId} (index ${this._currentLayoutIndex}/${layoutFiles.length})`);
741
+ this.emit('layout-prepare-request', layoutId);
742
+ }
743
+
733
744
  /**
734
745
  * Notify that a file is ready (called by platform for both layout and media files)
735
746
  * Checks if any pending layouts can now be rendered
@@ -808,7 +819,7 @@ export class PlayerCore extends EventEmitter {
808
819
  const layoutFiles = this.schedule.getCurrentLayouts();
809
820
  if (layoutFiles.length > 0) {
810
821
  const layoutFile = layoutFiles[0];
811
- const layoutId = parseInt(layoutFile.replace('.xlf', ''), 10);
822
+ const layoutId = parseLayoutFile(layoutFile);
812
823
  this.emit('layout-prepare-request', layoutId);
813
824
  } else {
814
825
  this.emit('no-layouts-scheduled');
@@ -1029,6 +1040,74 @@ export class PlayerCore extends EventEmitter {
1029
1040
  return this.syncConfig;
1030
1041
  }
1031
1042
 
1043
+ // ── Timeline (offline schedule prediction) ─────────────────────────
1044
+
1045
+ /**
1046
+ * Parse all cached layout XLFs to extract durations for timeline calculation.
1047
+ * Called after collection completes and layouts are known.
1048
+ */
1049
+ async _buildLayoutDurations() {
1050
+ if (!this.cache?.getFile) return; // Cache doesn't support direct file access
1051
+
1052
+ const layoutFiles = this.schedule.getCurrentLayouts();
1053
+ const defaultFile = this.schedule.schedule?.default;
1054
+ const allFiles = [...new Set([...layoutFiles, ...(defaultFile ? [defaultFile] : [])])];
1055
+
1056
+ let parsed = 0;
1057
+ for (const file of allFiles) {
1058
+ const layoutId = parseLayoutFile(file);
1059
+ try {
1060
+ const xlfXml = await this.cache.getFile('layout', layoutId);
1061
+ if (xlfXml) {
1062
+ const duration = parseLayoutDuration(xlfXml);
1063
+ this._layoutDurations.set(file, duration);
1064
+ this._layoutDurations.set(String(layoutId), duration);
1065
+ parsed++;
1066
+ }
1067
+ } catch (e) {
1068
+ log.debug(`Could not parse duration for layout ${layoutId}:`, e.message);
1069
+ }
1070
+ }
1071
+ if (parsed > 0) {
1072
+ log.info(`[Timeline] Parsed durations for ${parsed} layouts`);
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * Calculate and log the upcoming playback timeline (next 2 hours).
1078
+ * Emits 'timeline-updated' with the full timeline array.
1079
+ */
1080
+ logUpcomingTimeline() {
1081
+ if (this._layoutDurations.size === 0) return;
1082
+ if (!this.schedule.getLayoutsAtTime) return; // Schedule doesn't support time queries
1083
+
1084
+ const timeline = calculateTimeline(this.schedule, this._layoutDurations);
1085
+ if (timeline.length === 0) return;
1086
+
1087
+ const lines = timeline.slice(0, 20).map(e => {
1088
+ const s = e.startTime.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
1089
+ const end = e.endTime.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
1090
+ return ` ${s}-${end} Layout ${e.layoutFile} (${e.duration}s)${e.isDefault ? ' [default]' : ''}`;
1091
+ });
1092
+ log.info(`[Timeline] Next ${timeline.length} plays:\n${lines.join('\n')}`);
1093
+ this.emit('timeline-updated', timeline);
1094
+ }
1095
+
1096
+ /**
1097
+ * Record/correct a layout's actual duration (e.g., from video loadedmetadata).
1098
+ * Updates the durations map and re-logs the timeline if it changed.
1099
+ * @param {string} file - Layout file or layout ID string
1100
+ * @param {number} duration - Actual duration in seconds
1101
+ */
1102
+ recordLayoutDuration(file, duration) {
1103
+ const prev = this._layoutDurations.get(file);
1104
+ if (prev === duration) return; // No change
1105
+
1106
+ this._layoutDurations.set(file, duration);
1107
+ log.debug(`[Timeline] Duration corrected: layout ${file} ${prev || '?'}s → ${duration}s`);
1108
+ this.logUpcomingTimeline();
1109
+ }
1110
+
1032
1111
  /**
1033
1112
  * Cleanup
1034
1113
  */
@@ -1091,7 +1170,7 @@ export class PlayerCore extends EventEmitter {
1091
1170
  prioritizeFilesByLayout(files, currentLayouts) {
1092
1171
  const currentLayoutIds = new Set();
1093
1172
  currentLayouts.forEach((layoutFile) => {
1094
- currentLayoutIds.add(parseInt(String(layoutFile).replace('.xlf', ''), 10));
1173
+ currentLayoutIds.add(parseLayoutFile(layoutFile));
1095
1174
  });
1096
1175
 
1097
1176
  // Assign priority tiers
package/src/main.js DELETED
@@ -1,580 +0,0 @@
1
- /**
2
- * Main player orchestrator
3
- */
4
-
5
- import { config } from '@xiboplayer/utils';
6
- import { XmdsClient } from '@xiboplayer/xmds';
7
- import { cacheManager } from '@xiboplayer/cache';
8
- import { scheduleManager } from '@xiboplayer/schedule';
9
- import { LayoutTranslator } from '@xiboplayer/renderer';
10
- import { XmrWrapper } from './xmr-wrapper.js';
11
-
12
- class Player {
13
- constructor() {
14
- this.xmds = new XmdsClient(config);
15
- this.layoutTranslator = new LayoutTranslator(this.xmds);
16
- this.xmr = null; // XMR real-time messaging
17
- this.settings = null;
18
- this.collectInterval = 900000; // 15 minutes default
19
- this.scheduleCheckInterval = 60000; // 1 minute
20
- this.lastScheduleCheck = 0;
21
- this.currentLayouts = [];
22
- this.currentLayoutIndex = 0; // Track position in campaign
23
- this.layoutChangeTimeout = null; // Timer for layout cycling
24
- this.layoutScripts = []; // Track scripts from current layout
25
- }
26
-
27
- /**
28
- * Initialize player
29
- */
30
- async init() {
31
- console.log('[Player] Initializing...');
32
-
33
- // Check configuration
34
- if (!config.isConfigured()) {
35
- console.log('[Player] Not configured, redirecting to setup');
36
- window.location.href = '/player/setup.html';
37
- return;
38
- }
39
-
40
- // Initialize cache
41
- await cacheManager.init();
42
- console.log('[Player] Cache initialized');
43
-
44
- // Start collection cycle
45
- await this.collect();
46
- setInterval(() => this.collect(), this.collectInterval);
47
-
48
- // Start schedule check cycle
49
- setInterval(() => this.checkSchedule(), this.scheduleCheckInterval);
50
- }
51
-
52
- /**
53
- * Collection cycle - sync with CMS
54
- */
55
- async collect() {
56
- try {
57
- console.log('[Player] Starting collection cycle');
58
-
59
- // 1. Register display
60
- const regResult = await this.xmds.registerDisplay();
61
- console.log('[Player] RegisterDisplay:', regResult.code, regResult.message);
62
-
63
- if (regResult.code !== 'READY') {
64
- this.showMessage(`Display not authorized: ${regResult.message}`);
65
- return;
66
- }
67
-
68
- // Save settings
69
- if (regResult.settings) {
70
- this.settings = regResult.settings;
71
- if (this.settings.collectInterval) {
72
- this.collectInterval = parseInt(this.settings.collectInterval) * 1000;
73
- }
74
- console.log('[Player] Settings updated:', this.settings);
75
-
76
- // Initialize XMR if available and not already connected
77
- if (!this.xmr && this.settings.xmrNetAddress) {
78
- await this.initializeXmr();
79
- }
80
- }
81
-
82
- // 2. Get required files
83
- const files = await this.xmds.requiredFiles();
84
- console.log('[Player] Required files:', files.length);
85
-
86
- // 3. Download missing files
87
- for (const file of files) {
88
- try {
89
- if (file.download === 'http' && file.path) {
90
- await cacheManager.downloadFile(file);
91
- } else if (file.download === 'xmds') {
92
- // TODO: Implement XMDS GetFile for chunked downloads
93
- console.warn('[Player] XMDS download not yet implemented for', file.id);
94
- }
95
-
96
- // Translate layouts to HTML
97
- // Always re-translate to pick up code changes (layout files are small)
98
- if (file.type === 'layout') {
99
- await this.translateLayout(file);
100
- }
101
- } catch (error) {
102
- console.error(`[Player] Failed to download ${file.type}/${file.id}:`, error);
103
- }
104
- }
105
-
106
- // 4. Get schedule
107
- const schedule = await this.xmds.schedule();
108
- console.log('[Player] Schedule:', schedule);
109
- scheduleManager.setSchedule(schedule);
110
-
111
- // 5. Apply schedule
112
- await this.checkSchedule();
113
-
114
- // 6. Notify status
115
- await this.xmds.notifyStatus({
116
- currentLayoutId: this.currentLayouts[0] || null,
117
- deviceName: config.displayName,
118
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
119
- });
120
-
121
- console.log('[Player] Collection cycle complete');
122
- } catch (error) {
123
- console.error('[Player] Collection failed:', error);
124
- this.showMessage(`Collection failed: ${error.message}`);
125
- }
126
- }
127
-
128
- /**
129
- * Translate layout XLF to HTML
130
- */
131
- async translateLayout(fileInfo) {
132
- const xlfText = await cacheManager.getCachedFileText('layout', fileInfo.id);
133
- if (!xlfText) {
134
- console.warn('[Player] Layout XLF not found in cache:', fileInfo.id);
135
- return;
136
- }
137
-
138
- try {
139
- const html = await this.layoutTranslator.translateXLF(fileInfo.id, xlfText, cacheManager);
140
-
141
- // Cache the translated HTML
142
- const htmlBlob = new Blob([html], { type: 'text/html' });
143
- const cacheKey = `/cache/layout-html/${fileInfo.id}`;
144
- const cache = await caches.open('xibo-media-v1');
145
- await cache.put(cacheKey, new Response(htmlBlob));
146
-
147
- console.log('[Player] Translated layout:', fileInfo.id);
148
- } catch (error) {
149
- console.error('[Player] Failed to translate layout:', fileInfo.id, error);
150
- }
151
- }
152
-
153
- /**
154
- * Check schedule and update display
155
- */
156
- async checkSchedule() {
157
- if (!scheduleManager.shouldCheckSchedule(this.lastScheduleCheck)) {
158
- return;
159
- }
160
-
161
- this.lastScheduleCheck = Date.now();
162
- const layouts = scheduleManager.getCurrentLayouts();
163
-
164
- if (JSON.stringify(layouts) !== JSON.stringify(this.currentLayouts)) {
165
- console.log('[Player] Schedule changed:', layouts);
166
- this.currentLayouts = layouts;
167
- this.currentLayoutIndex = 0;
168
-
169
- // Clear any existing layout change timer
170
- if (this.layoutChangeTimeout) {
171
- clearTimeout(this.layoutChangeTimeout);
172
- }
173
-
174
- // Show first layout and start cycling
175
- await this.showCurrentLayout();
176
- }
177
- }
178
-
179
- /**
180
- * Show the current layout in the campaign and schedule next layout
181
- */
182
- async showCurrentLayout() {
183
- if (this.currentLayouts.length === 0) {
184
- this.showMessage('No layout scheduled');
185
- return;
186
- }
187
-
188
- const layoutFile = this.currentLayouts[this.currentLayoutIndex];
189
- await this.showLayout(layoutFile);
190
-
191
- // If there are multiple layouts, schedule the next one
192
- if (this.currentLayouts.length > 1) {
193
- // Get layout duration (default to 60 seconds if not specified)
194
- const layoutDuration = await this.getLayoutDuration(layoutFile);
195
- const duration = layoutDuration || 60000; // milliseconds
196
-
197
- console.log(`[Player] Layout will change in ${duration}ms`);
198
-
199
- // Schedule next layout
200
- this.layoutChangeTimeout = setTimeout(() => {
201
- this.advanceToNextLayout();
202
- }, duration);
203
- }
204
- }
205
-
206
- /**
207
- * Advance to the next layout in the campaign
208
- */
209
- async advanceToNextLayout() {
210
- if (this.currentLayouts.length <= 1) {
211
- return; // Nothing to cycle to
212
- }
213
-
214
- // Advance index (loop back to 0 at end)
215
- this.currentLayoutIndex = (this.currentLayoutIndex + 1) % this.currentLayouts.length;
216
- console.log(`[Player] Advancing to layout ${this.currentLayoutIndex + 1}/${this.currentLayouts.length}`);
217
-
218
- // Show the next layout
219
- await this.showCurrentLayout();
220
- }
221
-
222
- /**
223
- * Get layout duration from cache or default to 60 seconds
224
- */
225
- async getLayoutDuration(layoutFile) {
226
- try {
227
- const layoutId = layoutFile.replace('.xlf', '').replace(/^.*\//, '');
228
- const xlfText = await cacheManager.getCachedFileText('layout', layoutId);
229
-
230
- if (!xlfText) {
231
- return 60000; // Default 60 seconds
232
- }
233
-
234
- // Parse XLF to get duration
235
- const parser = new DOMParser();
236
- const xlf = parser.parseFromString(xlfText, 'text/xml');
237
- const layoutNode = xlf.querySelector('layout');
238
-
239
- if (layoutNode) {
240
- const duration = parseInt(layoutNode.getAttribute('duration')) || 60;
241
- return duration * 1000; // Convert to milliseconds
242
- }
243
-
244
- return 60000; // Default
245
- } catch (error) {
246
- console.warn('[Player] Could not get layout duration:', error);
247
- return 60000; // Default
248
- }
249
- }
250
-
251
- /**
252
- * Show a layout by loading HTML directly into page (not iframe)
253
- */
254
- async showLayout(layoutFile) {
255
- if (!layoutFile) {
256
- this.showMessage('No layout scheduled');
257
- return;
258
- }
259
-
260
- // Extract layout ID from filename (e.g., "123.xlf" -> "123" or just "1")
261
- const layoutId = layoutFile.replace('.xlf', '').replace(/^.*\//, '');
262
-
263
- // Get the translated HTML from cache
264
- const html = await cacheManager.cache.match(`/cache/layout-html/${layoutId}`);
265
- if (!html) {
266
- console.warn('[Player] Layout HTML not in cache:', layoutId);
267
- this.showMessage(`Layout ${layoutId} not available`);
268
- return;
269
- }
270
-
271
- const htmlText = await html.text();
272
-
273
- console.log('[Player] Showing layout:', layoutId);
274
-
275
- // Parse HTML
276
- const parser = new DOMParser();
277
- const doc = parser.parseFromString(htmlText, 'text/html');
278
-
279
- // Get the container
280
- const container = document.getElementById('layout-container');
281
- if (!container) return;
282
-
283
- // Extract all content except scripts
284
- const bodyWithoutScripts = doc.body.cloneNode(true);
285
- const scriptsInBody = bodyWithoutScripts.querySelectorAll('script');
286
- scriptsInBody.forEach(s => s.remove());
287
-
288
- // Remove previous layout's scripts to avoid variable redeclaration errors
289
- this.layoutScripts.forEach(script => {
290
- if (script.parentNode) {
291
- script.parentNode.removeChild(script);
292
- }
293
- });
294
- this.layoutScripts = [];
295
-
296
- // Set HTML (styles + body content without scripts)
297
- container.innerHTML = '';
298
-
299
- // Add head styles
300
- doc.querySelectorAll('head > style').forEach(style => {
301
- container.appendChild(style.cloneNode(true));
302
- });
303
-
304
- // Add body content
305
- while (bodyWithoutScripts.firstChild) {
306
- container.appendChild(bodyWithoutScripts.firstChild);
307
- }
308
-
309
- // Execute scripts manually (innerHTML doesn't execute them)
310
- // Wrap inline scripts in IIFE to prevent const redeclaration errors
311
- const allScripts = [...doc.querySelectorAll('head > script'), ...doc.querySelectorAll('body > script')];
312
- allScripts.forEach(oldScript => {
313
- const newScript = document.createElement('script');
314
- if (oldScript.src) {
315
- newScript.src = oldScript.src;
316
- } else {
317
- // Wrap inline script in IIFE to create isolated scope
318
- // This prevents const/let redeclaration errors when switching layouts
319
- newScript.textContent = `(function() {\n${oldScript.textContent}\n})();`;
320
- }
321
- // Mark script with data attribute for tracking
322
- newScript.setAttribute('data-layout-script', layoutId);
323
- document.body.appendChild(newScript);
324
- this.layoutScripts.push(newScript); // Track for cleanup
325
- });
326
- }
327
-
328
- /**
329
- * Initialize XMR real-time messaging
330
- */
331
- async initializeXmr() {
332
- try {
333
- // Construct XMR WebSocket URL
334
- let xmrUrl = this.settings.xmrNetAddress;
335
-
336
- // If xmrNetAddress is not a full WebSocket URL, construct it from CMS address
337
- if (!xmrUrl || (!xmrUrl.startsWith('ws://') && !xmrUrl.startsWith('wss://'))) {
338
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
339
- const cmsBase = config.cmsAddress || window.location.origin;
340
- const cmsHost = cmsBase.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
341
- xmrUrl = `${protocol}//${cmsHost}/xmr`;
342
- }
343
-
344
- console.log('[Player] Initializing XMR with URL:', xmrUrl);
345
-
346
- // Create and start XMR wrapper
347
- this.xmr = new XmrWrapper(config, this);
348
- const success = await this.xmr.start(xmrUrl, config.cmsKey);
349
-
350
- if (success) {
351
- console.log('[Player] XMR real-time messaging enabled');
352
- } else {
353
- console.log('[Player] Continuing without XMR (polling mode only)');
354
- }
355
- } catch (error) {
356
- console.warn('[Player] XMR initialization failed:', error);
357
- console.log('[Player] Continuing in polling mode (XMDS only)');
358
- }
359
- }
360
-
361
- /**
362
- * Update player status (called by XMR wrapper)
363
- */
364
- updateStatus(status) {
365
- console.log('[Player] Status:', status);
366
- // Could update UI status indicator here
367
- const statusEl = document.getElementById('xmr-status');
368
- if (statusEl) {
369
- statusEl.textContent = status;
370
- statusEl.className = status.includes('connected') ? 'status-connected' : 'status-disconnected';
371
- }
372
- }
373
-
374
- /**
375
- * Capture screenshot (called by XMR when CMS requests it)
376
- */
377
- async captureScreenshot() {
378
- try {
379
- console.log('[Player] Capturing screenshot...');
380
-
381
- // Use html2canvas or native screenshot API if available
382
- // For now, just log that we received the command
383
- console.log('[Player] Screenshot capture not yet implemented');
384
-
385
- // TODO: Implement screenshot capture
386
- // 1. Use html2canvas to capture current layout
387
- // 2. Convert to blob
388
- // 3. Upload to CMS via SubmitScreenShot XMDS call
389
-
390
- return true;
391
- } catch (error) {
392
- console.error('[Player] Screenshot capture failed:', error);
393
- return false;
394
- }
395
- }
396
-
397
- /**
398
- * Change layout immediately (called by XMR)
399
- */
400
- async changeLayout(layoutId) {
401
- console.log('[Player] Changing to layout:', layoutId);
402
- try {
403
- // Find layout file by ID
404
- const layoutFile = `${layoutId}.xlf`;
405
- await this.showLayout(layoutFile);
406
- return true;
407
- } catch (error) {
408
- console.error('[Player] Change layout failed:', error);
409
- return false;
410
- }
411
- }
412
-
413
- /**
414
- * Show a message to the user
415
- */
416
- showMessage(message) {
417
- console.log('[Player]', message);
418
- const messageEl = document.getElementById('message');
419
- if (messageEl) {
420
- messageEl.textContent = message;
421
- messageEl.style.display = 'block';
422
- setTimeout(() => {
423
- messageEl.style.display = 'none';
424
- }, 5000);
425
- }
426
- }
427
- }
428
-
429
- /**
430
- * Network Activity Tracker
431
- */
432
- class NetworkActivityTracker {
433
- constructor() {
434
- this.activities = [];
435
- this.maxActivities = 100;
436
- }
437
-
438
- addActivity(filename, status, size = null) {
439
- const activity = {
440
- timestamp: new Date(),
441
- filename,
442
- status,
443
- size
444
- };
445
- this.activities.unshift(activity);
446
- if (this.activities.length > this.maxActivities) {
447
- this.activities.pop();
448
- }
449
- this.updateUI();
450
- }
451
-
452
- updateUI() {
453
- const list = document.getElementById('activity-list');
454
- if (!list) return;
455
-
456
- if (this.activities.length === 0) {
457
- list.innerHTML = '<li class="empty">No network activity yet</li>';
458
- return;
459
- }
460
-
461
- list.innerHTML = this.activities.map(activity => {
462
- const time = activity.timestamp.toLocaleTimeString();
463
- const statusClass = activity.status === 'success' ? 'success' :
464
- activity.status === 'error' ? 'error' : 'loading';
465
- const sizeText = activity.size ? this.formatSize(activity.size) : '-';
466
-
467
- return `
468
- <li>
469
- <span class="time">${time}</span>
470
- <span class="file" title="${activity.filename}">${activity.filename}</span>
471
- <span class="size">${sizeText}</span>
472
- <span class="status ${statusClass}">${activity.status}</span>
473
- </li>
474
- `;
475
- }).join('');
476
- }
477
-
478
- formatSize(bytes) {
479
- if (bytes < 1024) return bytes + ' B';
480
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
481
- if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
482
- return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
483
- }
484
- }
485
-
486
- const networkTracker = new NetworkActivityTracker();
487
-
488
- /**
489
- * Setup download progress UI
490
- */
491
- function setupProgressUI() {
492
- const progressEl = document.getElementById('download-progress');
493
- const filenameEl = document.getElementById('progress-filename');
494
- const fillEl = document.getElementById('progress-fill');
495
- const percentEl = document.getElementById('progress-percent');
496
- const sizeEl = document.getElementById('progress-size');
497
-
498
- window.addEventListener('download-progress', (event) => {
499
- const { filename, loaded, total, percent, complete, error } = event.detail;
500
-
501
- if (error) {
502
- // Show error
503
- networkTracker.addActivity(filename, 'error', total);
504
- fillEl.style.background = 'linear-gradient(90deg, #c62828, #e53935)';
505
- setTimeout(() => {
506
- progressEl.style.display = 'none';
507
- fillEl.style.background = 'linear-gradient(90deg, #4CAF50, #66BB6A)';
508
- }, 3000);
509
- } else if (complete) {
510
- // Show 100% briefly
511
- fillEl.style.width = '100%';
512
- percentEl.textContent = '100%';
513
-
514
- // Hide progress after 2 seconds
515
- setTimeout(() => {
516
- progressEl.style.display = 'none';
517
- }, 2000);
518
- networkTracker.addActivity(filename, 'success', total);
519
- } else {
520
- // Show progress
521
- progressEl.style.display = 'block';
522
- filenameEl.textContent = filename;
523
- fillEl.style.width = percent.toFixed(1) + '%';
524
- percentEl.textContent = percent.toFixed(1) + '%';
525
-
526
- const loadedMB = (loaded / 1024 / 1024).toFixed(1);
527
- const totalMB = (total / 1024 / 1024).toFixed(1);
528
- sizeEl.textContent = `${loadedMB} / ${totalMB} MB`;
529
-
530
- if (percent === 0) {
531
- networkTracker.addActivity(filename, 'loading', total);
532
- }
533
- }
534
- });
535
- }
536
-
537
- /**
538
- * Setup network activity panel (Ctrl+N)
539
- */
540
- function setupNetworkPanel() {
541
- const panel = document.getElementById('network-panel');
542
- const closeBtn = document.getElementById('close-network');
543
-
544
- // Keyboard shortcut: Ctrl+N
545
- document.addEventListener('keydown', (event) => {
546
- if (event.ctrlKey && event.key === 'n') {
547
- event.preventDefault();
548
- const isVisible = panel.style.display === 'block';
549
- panel.style.display = isVisible ? 'none' : 'block';
550
- if (!isVisible) {
551
- networkTracker.updateUI(); // Refresh on open
552
- }
553
- }
554
-
555
- // Also allow ESC to close
556
- if (event.key === 'Escape' && panel.style.display === 'block') {
557
- panel.style.display = 'none';
558
- }
559
- });
560
-
561
- // Close button
562
- closeBtn.addEventListener('click', () => {
563
- panel.style.display = 'none';
564
- });
565
- }
566
-
567
- // Auto-start player when DOM is ready
568
- if (document.readyState === 'loading') {
569
- document.addEventListener('DOMContentLoaded', () => {
570
- setupProgressUI();
571
- setupNetworkPanel();
572
- const player = new Player();
573
- player.init();
574
- });
575
- } else {
576
- setupProgressUI();
577
- setupNetworkPanel();
578
- const player = new Player();
579
- player.init();
580
- }