@xiboplayer/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAMPAIGNS.md +254 -0
- package/README.md +163 -0
- package/TESTING_STATUS.md +281 -0
- package/TEST_STANDARDIZATION_COMPLETE.md +287 -0
- package/docs/ARCHITECTURE.md +714 -0
- package/docs/README.md +92 -0
- package/examples/dayparting-schedule-example.json +190 -0
- package/index.html +262 -0
- package/package.json +53 -0
- package/proxy.js +72 -0
- package/public/manifest.json +22 -0
- package/public/sw.js +218 -0
- package/setup.html +220 -0
- package/src/data-connectors.js +198 -0
- package/src/index.js +4 -0
- package/src/main.js +580 -0
- package/src/player-core.js +1120 -0
- package/src/player-core.test.js +1796 -0
- package/src/state.js +54 -0
- package/src/state.test.js +206 -0
- package/src/test-utils.js +217 -0
- package/src/xmds-test.html +109 -0
- package/src/xmds.test.js +516 -0
- package/vite.config.js +51 -0
- package/vitest.config.js +35 -0
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
|
+
}
|