@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/src/main.js ADDED
@@ -0,0 +1,580 @@
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
+ }