@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 +6 -6
- package/src/player-core.js +164 -85
- package/src/main.js +0 -580
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/core",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
14
|
+
"@xiboplayer/utils": "0.2.0"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
|
-
"@xiboplayer/cache": "0.
|
|
18
|
-
"@xiboplayer/
|
|
19
|
-
"@xiboplayer/
|
|
20
|
-
"@xiboplayer/xmds": "0.
|
|
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",
|
package/src/player-core.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
229
|
+
parseLayoutFile(f) === this.currentLayoutId
|
|
208
230
|
);
|
|
209
231
|
if (currentStillScheduled) {
|
|
210
232
|
const idx = layoutFiles.findIndex(f =>
|
|
211
|
-
|
|
233
|
+
parseLayoutFile(f) === this.currentLayoutId
|
|
212
234
|
);
|
|
213
235
|
if (idx >= 0) this._currentLayoutIndex = idx;
|
|
214
|
-
log.debug(`Layout ${this.currentLayoutId} still in schedule (
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
}
|