@xiboplayer/renderer 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/docs/PERFORMANCE_OPTIMIZATIONS.md +451 -0
- package/docs/README.md +98 -0
- package/docs/RENDERER_COMPARISON.md +483 -0
- package/docs/TRANSITIONS.md +180 -0
- package/package.json +40 -0
- package/src/index.js +4 -0
- package/src/layout-pool.js +245 -0
- package/src/layout-pool.test.js +373 -0
- package/src/layout.js +1073 -0
- package/src/renderer-lite.js +2637 -0
- package/src/renderer-lite.overlays.test.js +493 -0
- package/src/renderer-lite.test.js +901 -0
- package/vitest.config.js +8 -0
|
@@ -0,0 +1,2637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RendererLite - Lightweight XLF Layout Renderer
|
|
3
|
+
*
|
|
4
|
+
* A standalone, reusable JavaScript library for rendering Xibo Layout Format (XLF) files.
|
|
5
|
+
* Provides layout rendering without dependencies on XLR, suitable for any platform.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Parse XLF XML layout files
|
|
9
|
+
* - Create region DOM elements with positioning
|
|
10
|
+
* - Render widgets (text, image, video, audio, PDF, webpage)
|
|
11
|
+
* - Handle widget duration timers
|
|
12
|
+
* - Apply CSS transitions (fade, fly)
|
|
13
|
+
* - Event emitter for lifecycle hooks
|
|
14
|
+
* - Manage layout lifecycle
|
|
15
|
+
*
|
|
16
|
+
* Usage pattern (similar to xmr-wrapper.js):
|
|
17
|
+
*
|
|
18
|
+
* ```javascript
|
|
19
|
+
* import { RendererLite } from './renderer-lite.js';
|
|
20
|
+
*
|
|
21
|
+
* const container = document.getElementById('player-container');
|
|
22
|
+
* const renderer = new RendererLite({ cmsUrl: '...', hardwareKey: '...' }, container);
|
|
23
|
+
*
|
|
24
|
+
* // Listen to events
|
|
25
|
+
* renderer.on('layoutStart', (layoutId) => console.log('Layout started:', layoutId));
|
|
26
|
+
* renderer.on('layoutEnd', (layoutId) => console.log('Layout ended:', layoutId));
|
|
27
|
+
* renderer.on('widgetStart', (widget) => console.log('Widget started:', widget));
|
|
28
|
+
* renderer.on('widgetEnd', (widget) => console.log('Widget ended:', widget));
|
|
29
|
+
* renderer.on('error', (error) => console.error('Error:', error));
|
|
30
|
+
*
|
|
31
|
+
* // Render a layout
|
|
32
|
+
* await renderer.renderLayout(layoutXml, duration);
|
|
33
|
+
*
|
|
34
|
+
* // Stop current layout
|
|
35
|
+
* renderer.stopCurrentLayout();
|
|
36
|
+
*
|
|
37
|
+
* // Cleanup
|
|
38
|
+
* renderer.cleanup();
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createNanoEvents } from 'nanoevents';
|
|
43
|
+
import { createLogger, isDebug } from '@xiboplayer/utils';
|
|
44
|
+
import { LayoutPool } from './layout-pool.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Transition utilities for widget animations
|
|
48
|
+
*/
|
|
49
|
+
const Transitions = {
|
|
50
|
+
/**
|
|
51
|
+
* Apply fade in transition
|
|
52
|
+
*/
|
|
53
|
+
fadeIn(element, duration) {
|
|
54
|
+
const keyframes = [
|
|
55
|
+
{ opacity: 0 },
|
|
56
|
+
{ opacity: 1 }
|
|
57
|
+
];
|
|
58
|
+
const timing = {
|
|
59
|
+
duration: duration,
|
|
60
|
+
easing: 'linear',
|
|
61
|
+
fill: 'forwards'
|
|
62
|
+
};
|
|
63
|
+
return element.animate(keyframes, timing);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Apply fade out transition
|
|
68
|
+
*/
|
|
69
|
+
fadeOut(element, duration) {
|
|
70
|
+
const keyframes = [
|
|
71
|
+
{ opacity: 1 },
|
|
72
|
+
{ opacity: 0 }
|
|
73
|
+
];
|
|
74
|
+
const timing = {
|
|
75
|
+
duration: duration,
|
|
76
|
+
easing: 'linear',
|
|
77
|
+
fill: 'forwards'
|
|
78
|
+
};
|
|
79
|
+
return element.animate(keyframes, timing);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get fly keyframes based on compass direction
|
|
84
|
+
*/
|
|
85
|
+
getFlyKeyframes(direction, width, height, isIn) {
|
|
86
|
+
const dirMap = {
|
|
87
|
+
'N': { x: 0, y: isIn ? -height : height },
|
|
88
|
+
'NE': { x: isIn ? width : -width, y: isIn ? -height : height },
|
|
89
|
+
'E': { x: isIn ? width : -width, y: 0 },
|
|
90
|
+
'SE': { x: isIn ? width : -width, y: isIn ? height : -height },
|
|
91
|
+
'S': { x: 0, y: isIn ? height : -height },
|
|
92
|
+
'SW': { x: isIn ? -width : width, y: isIn ? height : -height },
|
|
93
|
+
'W': { x: isIn ? -width : width, y: 0 },
|
|
94
|
+
'NW': { x: isIn ? -width : width, y: isIn ? -height : height }
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const offset = dirMap[direction] || dirMap['N'];
|
|
98
|
+
|
|
99
|
+
if (isIn) {
|
|
100
|
+
return {
|
|
101
|
+
from: {
|
|
102
|
+
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
103
|
+
opacity: 0
|
|
104
|
+
},
|
|
105
|
+
to: {
|
|
106
|
+
transform: 'translate(0, 0)',
|
|
107
|
+
opacity: 1
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
} else {
|
|
111
|
+
return {
|
|
112
|
+
from: {
|
|
113
|
+
transform: 'translate(0, 0)',
|
|
114
|
+
opacity: 1
|
|
115
|
+
},
|
|
116
|
+
to: {
|
|
117
|
+
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
118
|
+
opacity: 0
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Apply fly in transition
|
|
126
|
+
*/
|
|
127
|
+
flyIn(element, duration, direction, regionWidth, regionHeight) {
|
|
128
|
+
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, true);
|
|
129
|
+
const timing = {
|
|
130
|
+
duration: duration,
|
|
131
|
+
easing: 'ease-out',
|
|
132
|
+
fill: 'forwards'
|
|
133
|
+
};
|
|
134
|
+
return element.animate([keyframes.from, keyframes.to], timing);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Apply fly out transition
|
|
139
|
+
*/
|
|
140
|
+
flyOut(element, duration, direction, regionWidth, regionHeight) {
|
|
141
|
+
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, false);
|
|
142
|
+
const timing = {
|
|
143
|
+
duration: duration,
|
|
144
|
+
easing: 'ease-in',
|
|
145
|
+
fill: 'forwards'
|
|
146
|
+
};
|
|
147
|
+
return element.animate([keyframes.from, keyframes.to], timing);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Apply transition based on type
|
|
152
|
+
*/
|
|
153
|
+
apply(element, transitionConfig, isIn, regionWidth, regionHeight) {
|
|
154
|
+
if (!transitionConfig || !transitionConfig.type) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const type = transitionConfig.type.toLowerCase();
|
|
159
|
+
const duration = transitionConfig.duration || 1000;
|
|
160
|
+
const direction = transitionConfig.direction || 'N';
|
|
161
|
+
|
|
162
|
+
switch (type) {
|
|
163
|
+
case 'fadein':
|
|
164
|
+
return isIn ? this.fadeIn(element, duration) : null;
|
|
165
|
+
case 'fadeout':
|
|
166
|
+
return isIn ? null : this.fadeOut(element, duration);
|
|
167
|
+
case 'flyin':
|
|
168
|
+
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
169
|
+
case 'flyout':
|
|
170
|
+
return isIn ? null : this.flyOut(element, duration, direction, regionWidth, regionHeight);
|
|
171
|
+
default:
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* RendererLite - Lightweight XLF renderer
|
|
179
|
+
*/
|
|
180
|
+
export class RendererLite {
|
|
181
|
+
/**
|
|
182
|
+
* @param {Object} config - Player configuration
|
|
183
|
+
* @param {string} config.cmsUrl - CMS base URL
|
|
184
|
+
* @param {string} config.hardwareKey - Display hardware key
|
|
185
|
+
* @param {HTMLElement} container - DOM container for rendering
|
|
186
|
+
* @param {Object} options - Renderer options
|
|
187
|
+
* @param {Function} options.getMediaUrl - Function to get media file URL (mediaId) => url
|
|
188
|
+
* @param {Function} options.getWidgetHtml - Function to get widget HTML (layoutId, regionId, widgetId) => html
|
|
189
|
+
*/
|
|
190
|
+
constructor(config, container, options = {}) {
|
|
191
|
+
this.config = config;
|
|
192
|
+
this.container = container;
|
|
193
|
+
this.options = options;
|
|
194
|
+
|
|
195
|
+
// Logger with configurable level
|
|
196
|
+
this.log = createLogger('RendererLite', options.logLevel);
|
|
197
|
+
|
|
198
|
+
// Event emitter for lifecycle hooks
|
|
199
|
+
this.emitter = createNanoEvents();
|
|
200
|
+
|
|
201
|
+
// State
|
|
202
|
+
this.currentLayout = null;
|
|
203
|
+
this.currentLayoutId = null;
|
|
204
|
+
this.regions = new Map(); // regionId => { element, widgets, currentIndex, timer }
|
|
205
|
+
this.layoutTimer = null;
|
|
206
|
+
this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
|
|
207
|
+
this.widgetTimers = new Map(); // widgetId => timer
|
|
208
|
+
this.mediaUrlCache = new Map(); // fileId => blob URL (for parallel pre-fetching)
|
|
209
|
+
this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
|
|
210
|
+
|
|
211
|
+
// Scale state (for fitting layout to screen)
|
|
212
|
+
this.scaleFactor = 1;
|
|
213
|
+
this.offsetX = 0;
|
|
214
|
+
this.offsetY = 0;
|
|
215
|
+
|
|
216
|
+
// Overlay state
|
|
217
|
+
this.overlayContainer = null;
|
|
218
|
+
this.activeOverlays = new Map(); // layoutId => { container, layout, timer, regions }
|
|
219
|
+
|
|
220
|
+
// Interactive action state
|
|
221
|
+
this._keydownHandler = null; // Document keydown listener (single, shared)
|
|
222
|
+
this._keyboardActions = []; // Active keyboard actions for current layout
|
|
223
|
+
|
|
224
|
+
// Layout preload pool (2-layout pool for instant transitions)
|
|
225
|
+
this.layoutPool = new LayoutPool(2);
|
|
226
|
+
this.preloadTimer = null;
|
|
227
|
+
this._preloadRetryTimer = null;
|
|
228
|
+
|
|
229
|
+
// Setup container styles
|
|
230
|
+
this.setupContainer();
|
|
231
|
+
|
|
232
|
+
this.log.info('Initialized');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Setup container element
|
|
237
|
+
*/
|
|
238
|
+
setupContainer() {
|
|
239
|
+
this.container.style.position = 'relative';
|
|
240
|
+
this.container.style.width = '100%';
|
|
241
|
+
this.container.style.height = '100vh'; // Use viewport height, not percentage
|
|
242
|
+
this.container.style.overflow = 'hidden';
|
|
243
|
+
|
|
244
|
+
// Watch for container resize to rescale layout
|
|
245
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
246
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
247
|
+
this.rescaleRegions();
|
|
248
|
+
});
|
|
249
|
+
this.resizeObserver.observe(this.container);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Create overlay container for overlay layouts (higher z-index than main content)
|
|
253
|
+
this.overlayContainer = document.createElement('div');
|
|
254
|
+
this.overlayContainer.id = 'overlay-container';
|
|
255
|
+
this.overlayContainer.style.position = 'absolute';
|
|
256
|
+
this.overlayContainer.style.top = '0';
|
|
257
|
+
this.overlayContainer.style.left = '0';
|
|
258
|
+
this.overlayContainer.style.width = '100%';
|
|
259
|
+
this.overlayContainer.style.height = '100%';
|
|
260
|
+
this.overlayContainer.style.zIndex = '1000'; // Above main layout (z-index 0-999)
|
|
261
|
+
this.overlayContainer.style.pointerEvents = 'none'; // Don't block clicks on main layout
|
|
262
|
+
this.container.appendChild(this.overlayContainer);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Calculate scale factor to fit layout into container
|
|
267
|
+
* Centers the layout and scales regions proportionally.
|
|
268
|
+
* @param {Object} layout - Parsed layout with width/height
|
|
269
|
+
*/
|
|
270
|
+
calculateScale(layout) {
|
|
271
|
+
const screenWidth = this.container.clientWidth;
|
|
272
|
+
const screenHeight = this.container.clientHeight;
|
|
273
|
+
|
|
274
|
+
if (!screenWidth || !screenHeight) return;
|
|
275
|
+
|
|
276
|
+
const scaleX = screenWidth / layout.width;
|
|
277
|
+
const scaleY = screenHeight / layout.height;
|
|
278
|
+
this.scaleFactor = Math.min(scaleX, scaleY);
|
|
279
|
+
this.offsetX = (screenWidth - layout.width * this.scaleFactor) / 2;
|
|
280
|
+
this.offsetY = (screenHeight - layout.height * this.scaleFactor) / 2;
|
|
281
|
+
|
|
282
|
+
this.log.info(`Scale: ${this.scaleFactor.toFixed(3)} (${layout.width}x${layout.height} → ${screenWidth}x${screenHeight}, offset ${Math.round(this.offsetX)},${Math.round(this.offsetY)})`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Apply scale to a region element
|
|
287
|
+
* @param {HTMLElement} regionEl - Region DOM element
|
|
288
|
+
* @param {Object} regionConfig - Region config with left, top, width, height
|
|
289
|
+
*/
|
|
290
|
+
applyRegionScale(regionEl, regionConfig) {
|
|
291
|
+
const sf = this.scaleFactor;
|
|
292
|
+
regionEl.style.left = `${regionConfig.left * sf + this.offsetX}px`;
|
|
293
|
+
regionEl.style.top = `${regionConfig.top * sf + this.offsetY}px`;
|
|
294
|
+
regionEl.style.width = `${regionConfig.width * sf}px`;
|
|
295
|
+
regionEl.style.height = `${regionConfig.height * sf}px`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Reapply scale to all current regions (e.g., on window resize)
|
|
300
|
+
*/
|
|
301
|
+
rescaleRegions() {
|
|
302
|
+
if (!this.currentLayout) return;
|
|
303
|
+
|
|
304
|
+
this.calculateScale(this.currentLayout);
|
|
305
|
+
|
|
306
|
+
for (const [regionId, region] of this.regions) {
|
|
307
|
+
this.applyRegionScale(region.element, region.config);
|
|
308
|
+
// Update region dimensions for transition calculations
|
|
309
|
+
region.width = region.config.width * this.scaleFactor;
|
|
310
|
+
region.height = region.config.height * this.scaleFactor;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Rescale active overlays too
|
|
314
|
+
for (const [overlayId, overlay] of this.activeOverlays) {
|
|
315
|
+
this.calculateScale(overlay.layout);
|
|
316
|
+
for (const [regionId, region] of overlay.regions) {
|
|
317
|
+
this.applyRegionScale(region.element, region.config);
|
|
318
|
+
region.width = region.config.width * this.scaleFactor;
|
|
319
|
+
region.height = region.config.height * this.scaleFactor;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Event emitter interface (like XMR wrapper)
|
|
326
|
+
*/
|
|
327
|
+
on(event, callback) {
|
|
328
|
+
return this.emitter.on(event, callback);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
emit(event, ...args) {
|
|
332
|
+
this.emitter.emit(event, ...args);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Parse action elements from an XLF parent element (region or media)
|
|
337
|
+
* @param {Element} parentEl - Parent XML element containing <action> children
|
|
338
|
+
* @returns {Array} Parsed actions
|
|
339
|
+
*/
|
|
340
|
+
parseActions(parentEl) {
|
|
341
|
+
const actions = [];
|
|
342
|
+
for (const actionEl of parentEl.children) {
|
|
343
|
+
if (actionEl.tagName !== 'action') continue;
|
|
344
|
+
actions.push({
|
|
345
|
+
actionType: actionEl.getAttribute('actionType') || '',
|
|
346
|
+
triggerType: actionEl.getAttribute('triggerType') || '',
|
|
347
|
+
triggerCode: actionEl.getAttribute('triggerCode') || '',
|
|
348
|
+
layoutCode: actionEl.getAttribute('layoutCode') || '',
|
|
349
|
+
targetId: actionEl.getAttribute('targetId') || '',
|
|
350
|
+
commandCode: actionEl.getAttribute('commandCode') || ''
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return actions;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Parse XLF XML to layout object
|
|
358
|
+
* @param {string} xlfXml - XLF XML content
|
|
359
|
+
* @returns {Object} Parsed layout
|
|
360
|
+
*/
|
|
361
|
+
parseXlf(xlfXml) {
|
|
362
|
+
const parser = new DOMParser();
|
|
363
|
+
const doc = parser.parseFromString(xlfXml, 'text/xml');
|
|
364
|
+
|
|
365
|
+
const layoutEl = doc.querySelector('layout');
|
|
366
|
+
if (!layoutEl) {
|
|
367
|
+
throw new Error('Invalid XLF: no <layout> element');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const layoutDurationAttr = layoutEl.getAttribute('duration');
|
|
371
|
+
const layout = {
|
|
372
|
+
width: parseInt(layoutEl.getAttribute('width') || '1920'),
|
|
373
|
+
height: parseInt(layoutEl.getAttribute('height') || '1080'),
|
|
374
|
+
duration: layoutDurationAttr ? parseInt(layoutDurationAttr) : 0, // 0 = calculate from widgets
|
|
375
|
+
bgcolor: layoutEl.getAttribute('bgcolor') || '#000000',
|
|
376
|
+
background: layoutEl.getAttribute('background') || null, // Background image fileId
|
|
377
|
+
regions: []
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (layoutDurationAttr) {
|
|
381
|
+
this.log.info(`Layout duration from XLF: ${layout.duration}s`);
|
|
382
|
+
} else {
|
|
383
|
+
this.log.info(`Layout duration NOT in XLF, will calculate from widgets`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Parse regions
|
|
387
|
+
for (const regionEl of doc.querySelectorAll('region')) {
|
|
388
|
+
const region = {
|
|
389
|
+
id: regionEl.getAttribute('id'),
|
|
390
|
+
width: parseInt(regionEl.getAttribute('width')),
|
|
391
|
+
height: parseInt(regionEl.getAttribute('height')),
|
|
392
|
+
top: parseInt(regionEl.getAttribute('top')),
|
|
393
|
+
left: parseInt(regionEl.getAttribute('left')),
|
|
394
|
+
zindex: parseInt(regionEl.getAttribute('zindex') || '0'),
|
|
395
|
+
actions: this.parseActions(regionEl),
|
|
396
|
+
widgets: []
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Parse media/widgets
|
|
400
|
+
for (const mediaEl of regionEl.querySelectorAll('media')) {
|
|
401
|
+
const widget = this.parseWidget(mediaEl);
|
|
402
|
+
region.widgets.push(widget);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
layout.regions.push(region);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Calculate layout duration if not specified (duration=0)
|
|
409
|
+
if (layout.duration === 0) {
|
|
410
|
+
let maxDuration = 0;
|
|
411
|
+
|
|
412
|
+
for (const region of layout.regions) {
|
|
413
|
+
let regionDuration = 0;
|
|
414
|
+
|
|
415
|
+
// Calculate region duration based on widgets
|
|
416
|
+
for (const widget of region.widgets) {
|
|
417
|
+
if (widget.duration > 0) {
|
|
418
|
+
regionDuration += widget.duration;
|
|
419
|
+
} else {
|
|
420
|
+
// Widget with duration=0 means "use media length"
|
|
421
|
+
// Default to 60s here; actual duration is detected dynamically
|
|
422
|
+
// from video.loadedmetadata event and updateLayoutDuration() recalculates
|
|
423
|
+
regionDuration = 60;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
maxDuration = Math.max(maxDuration, regionDuration);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
layout.duration = maxDuration > 0 ? maxDuration : 60;
|
|
432
|
+
this.log.info(`Calculated layout duration: ${layout.duration}s (not specified in XLF)`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return layout;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Parse widget from media element
|
|
440
|
+
* @param {Element} mediaEl - Media XML element
|
|
441
|
+
* @returns {Object} Widget config
|
|
442
|
+
*/
|
|
443
|
+
parseWidget(mediaEl) {
|
|
444
|
+
const type = mediaEl.getAttribute('type');
|
|
445
|
+
const duration = parseInt(mediaEl.getAttribute('duration') || '10');
|
|
446
|
+
const useDuration = parseInt(mediaEl.getAttribute('useDuration') || '1');
|
|
447
|
+
const id = mediaEl.getAttribute('id');
|
|
448
|
+
const fileId = mediaEl.getAttribute('fileId'); // Media library file ID
|
|
449
|
+
|
|
450
|
+
// Parse options
|
|
451
|
+
const options = {};
|
|
452
|
+
const optionsEl = mediaEl.querySelector('options');
|
|
453
|
+
if (optionsEl) {
|
|
454
|
+
for (const child of optionsEl.children) {
|
|
455
|
+
options[child.tagName] = child.textContent;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Parse raw content
|
|
460
|
+
const rawEl = mediaEl.querySelector('raw');
|
|
461
|
+
const raw = rawEl ? rawEl.textContent : '';
|
|
462
|
+
|
|
463
|
+
// Parse transitions
|
|
464
|
+
const transitions = {
|
|
465
|
+
in: null,
|
|
466
|
+
out: null
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
if (options.transIn) {
|
|
470
|
+
transitions.in = {
|
|
471
|
+
type: options.transIn,
|
|
472
|
+
duration: parseInt(options.transInDuration || '1000'),
|
|
473
|
+
direction: options.transInDirection || 'N'
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (options.transOut) {
|
|
478
|
+
transitions.out = {
|
|
479
|
+
type: options.transOut,
|
|
480
|
+
duration: parseInt(options.transOutDuration || '1000'),
|
|
481
|
+
direction: options.transOutDirection || 'N'
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Parse widget-level actions
|
|
486
|
+
const actions = this.parseActions(mediaEl);
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
type,
|
|
490
|
+
duration,
|
|
491
|
+
useDuration, // Whether to use specified duration (1) or media length (0)
|
|
492
|
+
id,
|
|
493
|
+
fileId, // Media library file ID for cache lookup
|
|
494
|
+
options,
|
|
495
|
+
raw,
|
|
496
|
+
transitions,
|
|
497
|
+
actions
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Track blob URL for lifecycle management
|
|
503
|
+
* @param {string} blobUrl - Blob URL to track
|
|
504
|
+
*/
|
|
505
|
+
trackBlobUrl(blobUrl) {
|
|
506
|
+
if (!this.currentLayoutId) return;
|
|
507
|
+
|
|
508
|
+
if (!this.layoutBlobUrls.has(this.currentLayoutId)) {
|
|
509
|
+
this.layoutBlobUrls.set(this.currentLayoutId, new Set());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
this.layoutBlobUrls.get(this.currentLayoutId).add(blobUrl);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Revoke all blob URLs for a specific layout
|
|
517
|
+
* @param {number} layoutId - Layout ID
|
|
518
|
+
*/
|
|
519
|
+
revokeBlobUrlsForLayout(layoutId) {
|
|
520
|
+
const blobUrls = this.layoutBlobUrls.get(layoutId);
|
|
521
|
+
if (blobUrls) {
|
|
522
|
+
blobUrls.forEach(url => {
|
|
523
|
+
URL.revokeObjectURL(url);
|
|
524
|
+
});
|
|
525
|
+
this.layoutBlobUrls.delete(layoutId);
|
|
526
|
+
this.log.info(`Revoked ${blobUrls.size} blob URLs for layout ${layoutId}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Update layout duration based on actual widget durations
|
|
532
|
+
* Called when video metadata loads and we discover actual duration
|
|
533
|
+
*/
|
|
534
|
+
updateLayoutDuration() {
|
|
535
|
+
if (!this.currentLayout) return;
|
|
536
|
+
|
|
537
|
+
// Calculate maximum region duration
|
|
538
|
+
let maxRegionDuration = 0;
|
|
539
|
+
|
|
540
|
+
for (const region of this.currentLayout.regions) {
|
|
541
|
+
let regionDuration = 0;
|
|
542
|
+
|
|
543
|
+
for (const widget of region.widgets) {
|
|
544
|
+
if (widget.duration > 0) {
|
|
545
|
+
regionDuration += widget.duration;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
maxRegionDuration = Math.max(maxRegionDuration, regionDuration);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// If we calculated a different duration, update layout
|
|
553
|
+
if (maxRegionDuration > 0 && maxRegionDuration !== this.currentLayout.duration) {
|
|
554
|
+
const oldDuration = this.currentLayout.duration;
|
|
555
|
+
this.currentLayout.duration = maxRegionDuration;
|
|
556
|
+
|
|
557
|
+
this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
|
|
558
|
+
|
|
559
|
+
// Reset layout timer with new duration — but only if a timer is already running.
|
|
560
|
+
// If startLayoutTimerWhenReady() hasn't fired yet (still waiting for widgets),
|
|
561
|
+
// it will pick up the updated duration when it starts the timer.
|
|
562
|
+
if (this.layoutTimer) {
|
|
563
|
+
clearTimeout(this.layoutTimer);
|
|
564
|
+
|
|
565
|
+
const layoutDurationMs = this.currentLayout.duration * 1000;
|
|
566
|
+
this.layoutTimer = setTimeout(() => {
|
|
567
|
+
this.log.info(`Layout ${this.currentLayoutId} duration expired (${this.currentLayout.duration}s)`);
|
|
568
|
+
if (this.currentLayoutId) {
|
|
569
|
+
this.layoutEndEmitted = true;
|
|
570
|
+
this.emit('layoutEnd', this.currentLayoutId);
|
|
571
|
+
}
|
|
572
|
+
}, layoutDurationMs);
|
|
573
|
+
|
|
574
|
+
this.log.info(`Layout timer reset to ${this.currentLayout.duration}s`);
|
|
575
|
+
} else {
|
|
576
|
+
this.log.info(`Layout duration updated to ${maxRegionDuration}s (timer not yet started, will use new value)`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Reschedule preload timer — the initial preload was based on the old
|
|
580
|
+
// duration estimate (e.g. 45s for 60s default). With the real duration
|
|
581
|
+
// (e.g. 375s), the preload should fire much later so that schedule
|
|
582
|
+
// cooldowns (maxPlaysPerHour) have time to expire.
|
|
583
|
+
this._scheduleNextLayoutPreload(this.currentLayout);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Interactive Actions ──────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Attach interactive action event listeners for a layout.
|
|
591
|
+
* Binds touch/click on region/widget elements and a single document keydown handler.
|
|
592
|
+
*/
|
|
593
|
+
attachActionListeners(layout) {
|
|
594
|
+
const allKeyboardActions = [];
|
|
595
|
+
let touchActionCount = 0;
|
|
596
|
+
|
|
597
|
+
for (const regionConfig of layout.regions) {
|
|
598
|
+
const region = this.regions.get(regionConfig.id);
|
|
599
|
+
if (!region) continue;
|
|
600
|
+
|
|
601
|
+
// Region-level actions
|
|
602
|
+
for (const action of (regionConfig.actions || [])) {
|
|
603
|
+
if (action.triggerType === 'touch') {
|
|
604
|
+
this.attachTouchAction(region.element, action, regionConfig.id, null);
|
|
605
|
+
touchActionCount++;
|
|
606
|
+
} else if (action.triggerType.startsWith('keyboard:')) {
|
|
607
|
+
allKeyboardActions.push(action);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Widget-level actions
|
|
612
|
+
for (const widget of regionConfig.widgets) {
|
|
613
|
+
if (!widget.actions || widget.actions.length === 0) continue;
|
|
614
|
+
const widgetEl = region.widgetElements.get(widget.id);
|
|
615
|
+
if (!widgetEl) continue;
|
|
616
|
+
|
|
617
|
+
for (const action of widget.actions) {
|
|
618
|
+
if (action.triggerType === 'touch') {
|
|
619
|
+
this.attachTouchAction(widgetEl, action, regionConfig.id, widget.id);
|
|
620
|
+
touchActionCount++;
|
|
621
|
+
} else if (action.triggerType.startsWith('keyboard:')) {
|
|
622
|
+
allKeyboardActions.push(action);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
this.setupKeyboardListener(allKeyboardActions);
|
|
629
|
+
|
|
630
|
+
if (touchActionCount > 0 || allKeyboardActions.length > 0) {
|
|
631
|
+
this.log.info(`Actions attached: ${touchActionCount} touch, ${allKeyboardActions.length} keyboard`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Attach a click listener to an element for a touch-triggered action.
|
|
637
|
+
*/
|
|
638
|
+
attachTouchAction(element, action, regionId, widgetId) {
|
|
639
|
+
element.style.cursor = 'pointer';
|
|
640
|
+
|
|
641
|
+
const handler = (event) => {
|
|
642
|
+
event.stopPropagation();
|
|
643
|
+
const source = widgetId ? `widget ${widgetId}` : `region ${regionId}`;
|
|
644
|
+
this.log.info(`Touch action fired on ${source}: ${action.actionType}`);
|
|
645
|
+
|
|
646
|
+
this.emit('action-trigger', {
|
|
647
|
+
actionType: action.actionType,
|
|
648
|
+
triggerType: 'touch',
|
|
649
|
+
triggerCode: action.triggerCode,
|
|
650
|
+
layoutCode: action.layoutCode,
|
|
651
|
+
targetId: action.targetId,
|
|
652
|
+
commandCode: action.commandCode,
|
|
653
|
+
source: { regionId, widgetId }
|
|
654
|
+
});
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
element.addEventListener('click', handler);
|
|
658
|
+
if (!element._actionHandlers) element._actionHandlers = [];
|
|
659
|
+
element._actionHandlers.push(handler);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Setup document-level keyboard listener for keyboard-triggered actions.
|
|
664
|
+
*/
|
|
665
|
+
setupKeyboardListener(keyboardActions) {
|
|
666
|
+
this.removeKeyboardListener();
|
|
667
|
+
this._keyboardActions = keyboardActions;
|
|
668
|
+
if (keyboardActions.length === 0) return;
|
|
669
|
+
|
|
670
|
+
this._keydownHandler = (event) => {
|
|
671
|
+
const pressedKey = event.key;
|
|
672
|
+
for (const action of this._keyboardActions) {
|
|
673
|
+
const keycode = action.triggerType.substring('keyboard:'.length);
|
|
674
|
+
if (pressedKey === keycode) {
|
|
675
|
+
this.log.info(`Keyboard action (key: ${pressedKey}): ${action.actionType}`);
|
|
676
|
+
this.emit('action-trigger', {
|
|
677
|
+
actionType: action.actionType,
|
|
678
|
+
triggerType: action.triggerType,
|
|
679
|
+
triggerCode: action.triggerCode,
|
|
680
|
+
layoutCode: action.layoutCode,
|
|
681
|
+
targetId: action.targetId,
|
|
682
|
+
commandCode: action.commandCode,
|
|
683
|
+
source: { key: pressedKey }
|
|
684
|
+
});
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
document.addEventListener('keydown', this._keydownHandler);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Remove the document-level keyboard listener */
|
|
694
|
+
removeKeyboardListener() {
|
|
695
|
+
if (this._keydownHandler) {
|
|
696
|
+
document.removeEventListener('keydown', this._keydownHandler);
|
|
697
|
+
this._keydownHandler = null;
|
|
698
|
+
}
|
|
699
|
+
this._keyboardActions = [];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Remove all action listeners (touch + keyboard) */
|
|
703
|
+
removeActionListeners() {
|
|
704
|
+
for (const [, region] of this.regions) {
|
|
705
|
+
this._cleanElementActionHandlers(region.element);
|
|
706
|
+
for (const [, widgetEl] of region.widgetElements) {
|
|
707
|
+
this._cleanElementActionHandlers(widgetEl);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
this.removeKeyboardListener();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
_cleanElementActionHandlers(element) {
|
|
714
|
+
if (element._actionHandlers) {
|
|
715
|
+
for (const handler of element._actionHandlers) {
|
|
716
|
+
element.removeEventListener('click', handler);
|
|
717
|
+
}
|
|
718
|
+
delete element._actionHandlers;
|
|
719
|
+
element.style.cursor = '';
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Navigate to a specific widget within a region (for navWidget actions)
|
|
725
|
+
*/
|
|
726
|
+
navigateToWidget(targetWidgetId) {
|
|
727
|
+
for (const [regionId, region] of this.regions) {
|
|
728
|
+
const widgetIndex = region.widgets.findIndex(w => w.id === targetWidgetId);
|
|
729
|
+
if (widgetIndex === -1) continue;
|
|
730
|
+
|
|
731
|
+
this.log.info(`Navigating to widget ${targetWidgetId} in region ${regionId} (index ${widgetIndex})`);
|
|
732
|
+
|
|
733
|
+
if (region.timer) {
|
|
734
|
+
clearTimeout(region.timer);
|
|
735
|
+
region.timer = null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
this.stopWidget(regionId, region.currentIndex);
|
|
739
|
+
region.currentIndex = widgetIndex;
|
|
740
|
+
this.renderWidget(regionId, widgetIndex);
|
|
741
|
+
|
|
742
|
+
if (region.widgets.length > 1) {
|
|
743
|
+
const widget = region.widgets[widgetIndex];
|
|
744
|
+
const duration = widget.duration * 1000;
|
|
745
|
+
region.timer = setTimeout(() => {
|
|
746
|
+
this.stopWidget(regionId, widgetIndex);
|
|
747
|
+
const nextIndex = (widgetIndex + 1) % region.widgets.length;
|
|
748
|
+
region.currentIndex = nextIndex;
|
|
749
|
+
this.startRegion(regionId);
|
|
750
|
+
}, duration);
|
|
751
|
+
}
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
this.log.warn(`Target widget ${targetWidgetId} not found in any region`);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Navigate to the next widget in a region (wraps around)
|
|
759
|
+
* @param {string} [regionId] - Target region. If omitted, uses the first region.
|
|
760
|
+
*/
|
|
761
|
+
nextWidget(regionId) {
|
|
762
|
+
const region = regionId ? this.regions.get(regionId) : this.regions.values().next().value;
|
|
763
|
+
if (!region || region.widgets.length <= 1) return;
|
|
764
|
+
|
|
765
|
+
const nextIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
766
|
+
const targetWidget = region.widgets[nextIndex];
|
|
767
|
+
this.log.info(`nextWidget → index ${nextIndex} (widget ${targetWidget.id})`);
|
|
768
|
+
this.navigateToWidget(targetWidget.id);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Navigate to the previous widget in a region (wraps around)
|
|
773
|
+
* @param {string} [regionId] - Target region. If omitted, uses the first region.
|
|
774
|
+
*/
|
|
775
|
+
previousWidget(regionId) {
|
|
776
|
+
const region = regionId ? this.regions.get(regionId) : this.regions.values().next().value;
|
|
777
|
+
if (!region || region.widgets.length <= 1) return;
|
|
778
|
+
|
|
779
|
+
const prevIndex = (region.currentIndex - 1 + region.widgets.length) % region.widgets.length;
|
|
780
|
+
const targetWidget = region.widgets[prevIndex];
|
|
781
|
+
this.log.info(`previousWidget → index ${prevIndex} (widget ${targetWidget.id})`);
|
|
782
|
+
this.navigateToWidget(targetWidget.id);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── Layout Rendering ──────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Render a layout
|
|
789
|
+
* @param {string} xlfXml - XLF XML content
|
|
790
|
+
* @param {number} layoutId - Layout ID
|
|
791
|
+
* @returns {Promise<void>}
|
|
792
|
+
*/
|
|
793
|
+
async renderLayout(xlfXml, layoutId) {
|
|
794
|
+
try {
|
|
795
|
+
this.log.info(`Rendering layout ${layoutId}`);
|
|
796
|
+
|
|
797
|
+
// Check if we're replaying the same layout
|
|
798
|
+
const isSameLayout = this.currentLayoutId === layoutId;
|
|
799
|
+
|
|
800
|
+
if (isSameLayout) {
|
|
801
|
+
// OPTIMIZATION: Reuse existing elements for same layout (Arexibo pattern)
|
|
802
|
+
this.log.info(`Replaying layout ${layoutId} - reusing elements (no recreation!)`);
|
|
803
|
+
|
|
804
|
+
// Stop all region timers
|
|
805
|
+
for (const [regionId, region] of this.regions) {
|
|
806
|
+
if (region.timer) {
|
|
807
|
+
clearTimeout(region.timer);
|
|
808
|
+
region.timer = null;
|
|
809
|
+
}
|
|
810
|
+
// Reset to first widget
|
|
811
|
+
region.currentIndex = 0;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Clear layout timer
|
|
815
|
+
if (this.layoutTimer) {
|
|
816
|
+
clearTimeout(this.layoutTimer);
|
|
817
|
+
this.layoutTimer = null;
|
|
818
|
+
}
|
|
819
|
+
this.layoutEndEmitted = false;
|
|
820
|
+
|
|
821
|
+
// DON'T call stopCurrentLayout() - keep elements alive!
|
|
822
|
+
// DON'T clear mediaUrlCache - keep blob URLs alive!
|
|
823
|
+
// DON'T recreate regions/elements - already exist!
|
|
824
|
+
|
|
825
|
+
// Emit layout start event
|
|
826
|
+
this.emit('layoutStart', layoutId, this.currentLayout);
|
|
827
|
+
|
|
828
|
+
// Restart all regions from widget 0
|
|
829
|
+
for (const [regionId, region] of this.regions) {
|
|
830
|
+
this.startRegion(regionId);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Wait for all initial widgets to be ready then start layout timer
|
|
834
|
+
this.startLayoutTimerWhenReady(layoutId, this.currentLayout);
|
|
835
|
+
|
|
836
|
+
this.log.info(`Layout ${layoutId} restarted (reused elements)`);
|
|
837
|
+
|
|
838
|
+
// Schedule next layout preload for same-layout replay
|
|
839
|
+
this._scheduleNextLayoutPreload(this.currentLayout);
|
|
840
|
+
|
|
841
|
+
return; // EARLY RETURN - skip recreation below
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Check if this layout was preloaded in the pool
|
|
845
|
+
if (this.layoutPool.has(layoutId)) {
|
|
846
|
+
this.log.info(`Layout ${layoutId} found in preload pool - instant swap!`);
|
|
847
|
+
await this._swapToPreloadedLayout(layoutId);
|
|
848
|
+
return; // EARLY RETURN - preloaded layout swapped in
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Different layout - full teardown and rebuild
|
|
852
|
+
this.log.info(`Switching to new layout ${layoutId}`);
|
|
853
|
+
this.stopCurrentLayout();
|
|
854
|
+
|
|
855
|
+
// Parse XLF
|
|
856
|
+
const layout = this.parseXlf(xlfXml);
|
|
857
|
+
this.currentLayout = layout;
|
|
858
|
+
this.currentLayoutId = layoutId;
|
|
859
|
+
|
|
860
|
+
// Calculate scale factor to fit layout into screen
|
|
861
|
+
this.calculateScale(layout);
|
|
862
|
+
|
|
863
|
+
// Set container background
|
|
864
|
+
this.container.style.backgroundColor = layout.bgcolor;
|
|
865
|
+
this.container.style.backgroundImage = ''; // Reset previous
|
|
866
|
+
|
|
867
|
+
// Apply background image if specified in XLF
|
|
868
|
+
if (layout.background && this.options.getMediaUrl) {
|
|
869
|
+
try {
|
|
870
|
+
const bgUrl = await this.options.getMediaUrl(parseInt(layout.background));
|
|
871
|
+
if (bgUrl) {
|
|
872
|
+
this.container.style.backgroundImage = `url(${bgUrl})`;
|
|
873
|
+
this.container.style.backgroundSize = 'cover';
|
|
874
|
+
this.container.style.backgroundPosition = 'center';
|
|
875
|
+
this.container.style.backgroundRepeat = 'no-repeat';
|
|
876
|
+
this.log.info(`Background image set: ${layout.background}`);
|
|
877
|
+
}
|
|
878
|
+
} catch (err) {
|
|
879
|
+
this.log.warn('Failed to load background image:', err);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// PRE-FETCH: Get all media URLs in parallel (huge speedup!)
|
|
884
|
+
if (this.options.getMediaUrl) {
|
|
885
|
+
const mediaPromises = [];
|
|
886
|
+
this.mediaUrlCache.clear(); // Clear previous layout's cache
|
|
887
|
+
|
|
888
|
+
for (const region of layout.regions) {
|
|
889
|
+
for (const widget of region.widgets) {
|
|
890
|
+
if (widget.fileId) {
|
|
891
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
892
|
+
if (!this.mediaUrlCache.has(fileId)) {
|
|
893
|
+
mediaPromises.push(
|
|
894
|
+
this.options.getMediaUrl(fileId)
|
|
895
|
+
.then(url => {
|
|
896
|
+
this.mediaUrlCache.set(fileId, url);
|
|
897
|
+
})
|
|
898
|
+
.catch(err => {
|
|
899
|
+
this.log.warn(`Failed to fetch media ${fileId}:`, err);
|
|
900
|
+
})
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (mediaPromises.length > 0) {
|
|
908
|
+
this.log.info(`Pre-fetching ${mediaPromises.length} media URLs in parallel...`);
|
|
909
|
+
await Promise.all(mediaPromises);
|
|
910
|
+
this.log.info(`All media URLs pre-fetched`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Create regions
|
|
915
|
+
for (const regionConfig of layout.regions) {
|
|
916
|
+
await this.createRegion(regionConfig);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// PRE-CREATE: Build all widget elements upfront (Arexibo pattern)
|
|
920
|
+
this.log.info('Pre-creating widget elements for instant transitions...');
|
|
921
|
+
for (const [regionId, region] of this.regions) {
|
|
922
|
+
for (let i = 0; i < region.widgets.length; i++) {
|
|
923
|
+
const widget = region.widgets[i];
|
|
924
|
+
widget.layoutId = this.currentLayoutId;
|
|
925
|
+
widget.regionId = regionId;
|
|
926
|
+
|
|
927
|
+
try {
|
|
928
|
+
const element = await this.createWidgetElement(widget, region);
|
|
929
|
+
element.style.visibility = 'hidden'; // Hidden by default
|
|
930
|
+
element.style.opacity = '0';
|
|
931
|
+
region.element.appendChild(element);
|
|
932
|
+
region.widgetElements.set(widget.id, element);
|
|
933
|
+
} catch (error) {
|
|
934
|
+
this.log.error(`Failed to pre-create widget ${widget.id}:`, error);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
this.log.info('All widget elements pre-created');
|
|
939
|
+
|
|
940
|
+
// Attach interactive action listeners (touch/click and keyboard)
|
|
941
|
+
this.attachActionListeners(layout);
|
|
942
|
+
|
|
943
|
+
// Emit layout start event
|
|
944
|
+
this.emit('layoutStart', layoutId, layout);
|
|
945
|
+
|
|
946
|
+
// Start all regions
|
|
947
|
+
for (const [regionId, region] of this.regions) {
|
|
948
|
+
this.startRegion(regionId);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Wait for all initial widgets to be ready (videos playing, images loaded)
|
|
952
|
+
// THEN start the layout timer — ensures videos play to their last frame
|
|
953
|
+
this.startLayoutTimerWhenReady(layoutId, layout);
|
|
954
|
+
|
|
955
|
+
// Schedule preloading of the next layout at 75% of current duration
|
|
956
|
+
this._scheduleNextLayoutPreload(layout);
|
|
957
|
+
|
|
958
|
+
this.log.info(`Layout ${layoutId} started`);
|
|
959
|
+
|
|
960
|
+
} catch (error) {
|
|
961
|
+
this.log.error('Error rendering layout:', error);
|
|
962
|
+
this.emit('error', { type: 'layoutError', error, layoutId });
|
|
963
|
+
throw error;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Create a region element
|
|
969
|
+
* @param {Object} regionConfig - Region configuration
|
|
970
|
+
*/
|
|
971
|
+
async createRegion(regionConfig) {
|
|
972
|
+
const regionEl = document.createElement('div');
|
|
973
|
+
regionEl.id = `region_${regionConfig.id}`;
|
|
974
|
+
regionEl.className = 'renderer-lite-region';
|
|
975
|
+
regionEl.style.position = 'absolute';
|
|
976
|
+
regionEl.style.zIndex = regionConfig.zindex;
|
|
977
|
+
regionEl.style.overflow = 'hidden';
|
|
978
|
+
|
|
979
|
+
// Apply scaled positioning
|
|
980
|
+
this.applyRegionScale(regionEl, regionConfig);
|
|
981
|
+
|
|
982
|
+
this.container.appendChild(regionEl);
|
|
983
|
+
|
|
984
|
+
// Store region state (dimensions use scaled values for transitions)
|
|
985
|
+
const sf = this.scaleFactor;
|
|
986
|
+
this.regions.set(regionConfig.id, {
|
|
987
|
+
element: regionEl,
|
|
988
|
+
config: regionConfig,
|
|
989
|
+
widgets: regionConfig.widgets,
|
|
990
|
+
currentIndex: 0,
|
|
991
|
+
timer: null,
|
|
992
|
+
width: regionConfig.width * sf,
|
|
993
|
+
height: regionConfig.height * sf,
|
|
994
|
+
complete: false, // Track if region has played all widgets once
|
|
995
|
+
widgetElements: new Map() // widgetId -> DOM element (for element reuse)
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Start playing a region's widgets
|
|
1001
|
+
* @param {string} regionId - Region ID
|
|
1002
|
+
*/
|
|
1003
|
+
startRegion(regionId) {
|
|
1004
|
+
const region = this.regions.get(regionId);
|
|
1005
|
+
if (!region || region.widgets.length === 0) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// If only one widget, just render it (no cycling)
|
|
1010
|
+
// Don't set completion timer - layout duration controls ending
|
|
1011
|
+
// Region completion is NOT tracked for single-widget regions
|
|
1012
|
+
// (they display continuously until layout timer expires)
|
|
1013
|
+
if (region.widgets.length === 1) {
|
|
1014
|
+
this.renderWidget(regionId, 0);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Multiple widgets - cycle through them
|
|
1019
|
+
const playNext = () => {
|
|
1020
|
+
const widgetIndex = region.currentIndex;
|
|
1021
|
+
const widget = region.widgets[widgetIndex];
|
|
1022
|
+
|
|
1023
|
+
// Render widget
|
|
1024
|
+
this.renderWidget(regionId, widgetIndex);
|
|
1025
|
+
|
|
1026
|
+
// Schedule next widget
|
|
1027
|
+
const duration = widget.duration * 1000;
|
|
1028
|
+
region.timer = setTimeout(() => {
|
|
1029
|
+
this.stopWidget(regionId, widgetIndex);
|
|
1030
|
+
|
|
1031
|
+
// Move to next widget (wraps to 0 if at end)
|
|
1032
|
+
const nextIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
1033
|
+
|
|
1034
|
+
// Check if completing full cycle (wrapped back to 0)
|
|
1035
|
+
if (nextIndex === 0 && !region.complete) {
|
|
1036
|
+
region.complete = true;
|
|
1037
|
+
this.log.info(`Region ${regionId} completed one full cycle`);
|
|
1038
|
+
this.checkLayoutComplete();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
region.currentIndex = nextIndex;
|
|
1042
|
+
playNext();
|
|
1043
|
+
}, duration);
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
playNext();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Create a widget element (extracted for pre-creation)
|
|
1051
|
+
* @param {Object} widget - Widget config
|
|
1052
|
+
* @param {Object} region - Region state
|
|
1053
|
+
* @returns {Promise<HTMLElement>} Widget DOM element
|
|
1054
|
+
*/
|
|
1055
|
+
async createWidgetElement(widget, region) {
|
|
1056
|
+
switch (widget.type) {
|
|
1057
|
+
case 'image':
|
|
1058
|
+
return await this.renderImage(widget, region);
|
|
1059
|
+
case 'video':
|
|
1060
|
+
return await this.renderVideo(widget, region);
|
|
1061
|
+
case 'audio':
|
|
1062
|
+
return await this.renderAudio(widget, region);
|
|
1063
|
+
case 'text':
|
|
1064
|
+
case 'ticker':
|
|
1065
|
+
return await this.renderTextWidget(widget, region);
|
|
1066
|
+
case 'pdf':
|
|
1067
|
+
return await this.renderPdf(widget, region);
|
|
1068
|
+
case 'webpage':
|
|
1069
|
+
return await this.renderWebpage(widget, region);
|
|
1070
|
+
default:
|
|
1071
|
+
// Generic widget (clock, calendar, weather, etc.)
|
|
1072
|
+
return await this.renderGenericWidget(widget, region);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Helper: Find media element within widget (works for both direct and wrapped elements)
|
|
1078
|
+
* @param {HTMLElement} element - Widget element (might BE the media element or contain it)
|
|
1079
|
+
* @param {string} tagName - Tag name to find ('VIDEO', 'AUDIO', 'IMG', 'IFRAME')
|
|
1080
|
+
* @returns {HTMLElement|null}
|
|
1081
|
+
*/
|
|
1082
|
+
findMediaElement(element, tagName) {
|
|
1083
|
+
// Check if element IS the tag, or contains it as a descendant
|
|
1084
|
+
return element.tagName === tagName ? element : element.querySelector(tagName.toLowerCase());
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Update media element for dynamic content (videos/audio need restart)
|
|
1089
|
+
* @param {HTMLElement} element - Widget element
|
|
1090
|
+
* @param {Object} widget - Widget config
|
|
1091
|
+
*/
|
|
1092
|
+
updateMediaElement(element, widget) {
|
|
1093
|
+
// Videos: ALWAYS restart on widget show (even if looping)
|
|
1094
|
+
const videoEl = this.findMediaElement(element, 'VIDEO');
|
|
1095
|
+
if (videoEl) {
|
|
1096
|
+
videoEl.currentTime = 0;
|
|
1097
|
+
// Wait for seek to complete before playing — avoids DOMException
|
|
1098
|
+
// "The play() request was interrupted" when calling play() mid-seek
|
|
1099
|
+
const playAfterSeek = () => {
|
|
1100
|
+
videoEl.removeEventListener('seeked', playAfterSeek);
|
|
1101
|
+
videoEl.play().catch(() => {}); // Silently ignore — autoplay will retry
|
|
1102
|
+
};
|
|
1103
|
+
videoEl.addEventListener('seeked', playAfterSeek);
|
|
1104
|
+
// Fallback: if seeked doesn't fire (already at 0), try play directly
|
|
1105
|
+
if (videoEl.currentTime === 0 && videoEl.readyState >= 2) {
|
|
1106
|
+
videoEl.removeEventListener('seeked', playAfterSeek);
|
|
1107
|
+
videoEl.play().catch(() => {});
|
|
1108
|
+
}
|
|
1109
|
+
this.log.info(`Video restarted: ${widget.fileId || widget.id}`);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Audio: ALWAYS restart on widget show (even if looping)
|
|
1114
|
+
const audioEl = this.findMediaElement(element, 'AUDIO');
|
|
1115
|
+
if (audioEl) {
|
|
1116
|
+
audioEl.currentTime = 0;
|
|
1117
|
+
const playAfterSeek = () => {
|
|
1118
|
+
audioEl.removeEventListener('seeked', playAfterSeek);
|
|
1119
|
+
audioEl.play().catch(() => {});
|
|
1120
|
+
};
|
|
1121
|
+
audioEl.addEventListener('seeked', playAfterSeek);
|
|
1122
|
+
if (audioEl.currentTime === 0 && audioEl.readyState >= 2) {
|
|
1123
|
+
audioEl.removeEventListener('seeked', playAfterSeek);
|
|
1124
|
+
audioEl.play().catch(() => {});
|
|
1125
|
+
}
|
|
1126
|
+
this.log.info(`Audio restarted: ${widget.fileId || widget.id}`);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Images: Could refresh src if needed (future enhancement)
|
|
1131
|
+
// const imgEl = this.findMediaElement(element, 'IMG');
|
|
1132
|
+
|
|
1133
|
+
// Iframes: Could reload if needed (future enhancement)
|
|
1134
|
+
// const iframeEl = this.findMediaElement(element, 'IFRAME');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Wait for a widget's media to be ready for playback.
|
|
1139
|
+
* - Video: resolves when 'playing' fires (buffered enough to render frames)
|
|
1140
|
+
* - Image: resolves when 'load' fires (decoded and paintable)
|
|
1141
|
+
* - Text/embedded/clock: resolves immediately (inline content, no async load)
|
|
1142
|
+
* @param {HTMLElement} element - Widget DOM element
|
|
1143
|
+
* @param {Object} widget - Widget config
|
|
1144
|
+
* @returns {Promise<void>}
|
|
1145
|
+
*/
|
|
1146
|
+
waitForWidgetReady(element, widget) {
|
|
1147
|
+
const READY_TIMEOUT = 10000; // 10s max wait — don't block forever on broken media
|
|
1148
|
+
|
|
1149
|
+
// Video widgets: wait for actual playback
|
|
1150
|
+
const videoEl = this.findMediaElement(element, 'VIDEO');
|
|
1151
|
+
if (videoEl) {
|
|
1152
|
+
// Already playing (replay case where video was kept alive)
|
|
1153
|
+
if (!videoEl.paused && videoEl.readyState >= 3) {
|
|
1154
|
+
return Promise.resolve();
|
|
1155
|
+
}
|
|
1156
|
+
return new Promise((resolve) => {
|
|
1157
|
+
const timer = setTimeout(() => {
|
|
1158
|
+
this.log.warn(`Video ready timeout (${READY_TIMEOUT}ms) for widget ${widget.id}`);
|
|
1159
|
+
resolve();
|
|
1160
|
+
}, READY_TIMEOUT);
|
|
1161
|
+
const onPlaying = () => {
|
|
1162
|
+
videoEl.removeEventListener('playing', onPlaying);
|
|
1163
|
+
clearTimeout(timer);
|
|
1164
|
+
this.log.info(`Video widget ${widget.id} ready (playing)`);
|
|
1165
|
+
resolve();
|
|
1166
|
+
};
|
|
1167
|
+
videoEl.addEventListener('playing', onPlaying);
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Image widgets: wait for image decode
|
|
1172
|
+
const imgEl = this.findMediaElement(element, 'IMG');
|
|
1173
|
+
if (imgEl) {
|
|
1174
|
+
if (imgEl.complete && imgEl.naturalWidth > 0) {
|
|
1175
|
+
return Promise.resolve();
|
|
1176
|
+
}
|
|
1177
|
+
return new Promise((resolve) => {
|
|
1178
|
+
const timer = setTimeout(() => {
|
|
1179
|
+
this.log.warn(`Image ready timeout for widget ${widget.id}`);
|
|
1180
|
+
resolve();
|
|
1181
|
+
}, READY_TIMEOUT);
|
|
1182
|
+
const onLoad = () => {
|
|
1183
|
+
imgEl.removeEventListener('load', onLoad);
|
|
1184
|
+
clearTimeout(timer);
|
|
1185
|
+
resolve();
|
|
1186
|
+
};
|
|
1187
|
+
imgEl.addEventListener('load', onLoad);
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Text, embedded, clock, etc. — ready immediately
|
|
1192
|
+
return Promise.resolve();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Start the layout timer only after all initial widgets are ready.
|
|
1197
|
+
* This ensures that the layout duration counts from when content is
|
|
1198
|
+
* actually visible, so videos play their full duration to the last frame.
|
|
1199
|
+
* @param {number|string} layoutId - Layout ID
|
|
1200
|
+
* @param {Object} layout - Layout config with .duration
|
|
1201
|
+
*/
|
|
1202
|
+
async startLayoutTimerWhenReady(layoutId, layout) {
|
|
1203
|
+
if (!layout || layout.duration <= 0) return;
|
|
1204
|
+
|
|
1205
|
+
// Collect readiness promises for each region's first (current) widget
|
|
1206
|
+
const readyPromises = [];
|
|
1207
|
+
for (const [regionId, region] of this.regions) {
|
|
1208
|
+
if (region.widgets.length === 0) continue;
|
|
1209
|
+
const widget = region.widgets[region.currentIndex || 0];
|
|
1210
|
+
const element = region.widgetElements.get(widget.id);
|
|
1211
|
+
if (element) {
|
|
1212
|
+
readyPromises.push(this.waitForWidgetReady(element, widget));
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (readyPromises.length > 0) {
|
|
1217
|
+
this.log.info(`Waiting for ${readyPromises.length} widget(s) to be ready before starting layout timer...`);
|
|
1218
|
+
await Promise.all(readyPromises);
|
|
1219
|
+
this.log.info(`All widgets ready — starting layout timer`);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Guard: layout may have changed while we were waiting
|
|
1223
|
+
if (this.currentLayoutId !== layoutId) {
|
|
1224
|
+
this.log.warn(`Layout changed while waiting for widgets — skipping timer for ${layoutId}`);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const layoutDurationMs = layout.duration * 1000;
|
|
1229
|
+
this.log.info(`Layout ${layoutId} will end after ${layout.duration}s`);
|
|
1230
|
+
|
|
1231
|
+
this.layoutTimer = setTimeout(() => {
|
|
1232
|
+
this.log.info(`Layout ${layoutId} duration expired (${layout.duration}s)`);
|
|
1233
|
+
if (this.currentLayoutId) {
|
|
1234
|
+
this.layoutEndEmitted = true;
|
|
1235
|
+
this.emit('layoutEnd', this.currentLayoutId);
|
|
1236
|
+
}
|
|
1237
|
+
}, layoutDurationMs);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Render a widget in a region (using element reuse)
|
|
1242
|
+
* @param {string} regionId - Region ID
|
|
1243
|
+
* @param {number} widgetIndex - Widget index in region
|
|
1244
|
+
*/
|
|
1245
|
+
async renderWidget(regionId, widgetIndex) {
|
|
1246
|
+
const region = this.regions.get(regionId);
|
|
1247
|
+
if (!region) return;
|
|
1248
|
+
|
|
1249
|
+
const widget = region.widgets[widgetIndex];
|
|
1250
|
+
if (!widget) return;
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
this.log.info(`Showing widget ${widget.type} (${widget.id}) in region ${regionId}`);
|
|
1254
|
+
|
|
1255
|
+
// REUSE: Get existing element instead of creating new one
|
|
1256
|
+
let element = region.widgetElements.get(widget.id);
|
|
1257
|
+
|
|
1258
|
+
if (!element) {
|
|
1259
|
+
// Fallback: create if doesn't exist (shouldn't happen with pre-creation)
|
|
1260
|
+
this.log.warn(`Widget ${widget.id} not pre-created, creating now`);
|
|
1261
|
+
widget.layoutId = this.currentLayoutId;
|
|
1262
|
+
widget.regionId = regionId;
|
|
1263
|
+
element = await this.createWidgetElement(widget, region);
|
|
1264
|
+
region.widgetElements.set(widget.id, element);
|
|
1265
|
+
region.element.appendChild(element);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Hide all other widgets in region
|
|
1269
|
+
for (const [widgetId, widgetEl] of region.widgetElements) {
|
|
1270
|
+
if (widgetId !== widget.id) {
|
|
1271
|
+
widgetEl.style.visibility = 'hidden';
|
|
1272
|
+
widgetEl.style.opacity = '0';
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Update media element if needed (restart videos)
|
|
1277
|
+
this.updateMediaElement(element, widget);
|
|
1278
|
+
|
|
1279
|
+
// Show this widget
|
|
1280
|
+
element.style.visibility = 'visible';
|
|
1281
|
+
|
|
1282
|
+
// Apply in transition
|
|
1283
|
+
if (widget.transitions.in) {
|
|
1284
|
+
Transitions.apply(element, widget.transitions.in, true, region.width, region.height);
|
|
1285
|
+
} else {
|
|
1286
|
+
element.style.opacity = '1';
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Emit widget start event
|
|
1290
|
+
this.emit('widgetStart', {
|
|
1291
|
+
widgetId: widget.id,
|
|
1292
|
+
regionId,
|
|
1293
|
+
layoutId: this.currentLayoutId,
|
|
1294
|
+
mediaId: parseInt(widget.fileId || widget.id) || null,
|
|
1295
|
+
type: widget.type,
|
|
1296
|
+
duration: widget.duration
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
this.log.error(`Error rendering widget:`, error);
|
|
1301
|
+
this.emit('error', { type: 'widgetError', error, widgetId: widget.id, regionId });
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Stop a widget (with element reuse - don't revoke blob URLs!)
|
|
1307
|
+
* @param {string} regionId - Region ID
|
|
1308
|
+
* @param {number} widgetIndex - Widget index
|
|
1309
|
+
*/
|
|
1310
|
+
async stopWidget(regionId, widgetIndex) {
|
|
1311
|
+
const region = this.regions.get(regionId);
|
|
1312
|
+
if (!region) return;
|
|
1313
|
+
|
|
1314
|
+
const widget = region.widgets[widgetIndex];
|
|
1315
|
+
if (!widget) return;
|
|
1316
|
+
|
|
1317
|
+
// Get widget element from reuse cache
|
|
1318
|
+
const widgetElement = region.widgetElements.get(widget.id);
|
|
1319
|
+
if (!widgetElement) return;
|
|
1320
|
+
|
|
1321
|
+
// Apply out transition
|
|
1322
|
+
if (widget.transitions.out) {
|
|
1323
|
+
const animation = Transitions.apply(
|
|
1324
|
+
widgetElement,
|
|
1325
|
+
widget.transitions.out,
|
|
1326
|
+
false,
|
|
1327
|
+
region.width,
|
|
1328
|
+
region.height
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
if (animation) {
|
|
1332
|
+
await new Promise(resolve => {
|
|
1333
|
+
animation.onfinish = resolve;
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Pause media elements (but DON'T revoke URLs - element will be reused!)
|
|
1339
|
+
const videoEl = widgetElement.querySelector('video');
|
|
1340
|
+
if (videoEl && widget.options.loop !== '1') {
|
|
1341
|
+
videoEl.pause();
|
|
1342
|
+
// Keep src intact for next cycle
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const audioEl = widgetElement.querySelector('audio');
|
|
1346
|
+
if (audioEl && widget.options.loop !== '1') {
|
|
1347
|
+
audioEl.pause();
|
|
1348
|
+
// Keep src intact for next cycle
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Emit widget end event
|
|
1352
|
+
this.emit('widgetEnd', {
|
|
1353
|
+
widgetId: widget.id,
|
|
1354
|
+
regionId,
|
|
1355
|
+
layoutId: this.currentLayoutId,
|
|
1356
|
+
mediaId: parseInt(widget.fileId || widget.id) || null,
|
|
1357
|
+
type: widget.type
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Render image widget
|
|
1363
|
+
*/
|
|
1364
|
+
async renderImage(widget, region) {
|
|
1365
|
+
const img = document.createElement('img');
|
|
1366
|
+
img.className = 'renderer-lite-widget';
|
|
1367
|
+
img.style.width = '100%';
|
|
1368
|
+
img.style.height = '100%';
|
|
1369
|
+
img.style.objectFit = 'contain';
|
|
1370
|
+
img.style.opacity = '0';
|
|
1371
|
+
|
|
1372
|
+
// Get media URL from cache (already pre-fetched!) or fetch on-demand
|
|
1373
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
1374
|
+
let imageSrc = this.mediaUrlCache.get(fileId);
|
|
1375
|
+
|
|
1376
|
+
if (!imageSrc && this.options.getMediaUrl) {
|
|
1377
|
+
imageSrc = await this.options.getMediaUrl(fileId);
|
|
1378
|
+
} else if (!imageSrc) {
|
|
1379
|
+
imageSrc = `${window.location.origin}/player/cache/media/${widget.options.uri}`;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
img.src = imageSrc;
|
|
1383
|
+
return img;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Render video widget
|
|
1388
|
+
*/
|
|
1389
|
+
async renderVideo(widget, region) {
|
|
1390
|
+
const video = document.createElement('video');
|
|
1391
|
+
video.className = 'renderer-lite-widget';
|
|
1392
|
+
video.style.width = '100%';
|
|
1393
|
+
video.style.height = '100%';
|
|
1394
|
+
video.style.objectFit = 'contain';
|
|
1395
|
+
video.style.opacity = '1'; // Immediately visible
|
|
1396
|
+
video.autoplay = true;
|
|
1397
|
+
video.preload = 'auto'; // Eagerly buffer - chunks are pre-warmed in SW BlobCache
|
|
1398
|
+
video.muted = widget.options.mute === '1';
|
|
1399
|
+
video.loop = false; // Don't use native loop - we handle it manually to avoid black frames
|
|
1400
|
+
video.controls = isDebug(); // Show controls only in debug mode
|
|
1401
|
+
video.playsInline = true; // Prevent fullscreen on mobile
|
|
1402
|
+
|
|
1403
|
+
// Handle video end - pause on last frame instead of showing black
|
|
1404
|
+
// Widget cycling will restart the video via updateMediaElement()
|
|
1405
|
+
video.addEventListener('ended', () => {
|
|
1406
|
+
if (widget.options.loop === '1') {
|
|
1407
|
+
// For looping videos: seek back to start but stay paused on first frame
|
|
1408
|
+
// This avoids black frames - shows first frame until widget cycles
|
|
1409
|
+
video.currentTime = 0;
|
|
1410
|
+
this.log.info(`Video ${fileId} ended - reset to start, waiting for widget cycle to replay`);
|
|
1411
|
+
} else {
|
|
1412
|
+
// For non-looping videos: stay paused on last frame
|
|
1413
|
+
this.log.info(`Video ${fileId} ended - paused on last frame`);
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
// Get media URL from cache (already pre-fetched!) or fetch on-demand
|
|
1418
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
1419
|
+
let videoSrc = this.mediaUrlCache.get(fileId);
|
|
1420
|
+
|
|
1421
|
+
if (!videoSrc && this.options.getMediaUrl) {
|
|
1422
|
+
videoSrc = await this.options.getMediaUrl(fileId);
|
|
1423
|
+
} else if (!videoSrc) {
|
|
1424
|
+
videoSrc = `${window.location.origin}/player/cache/media/${fileId}`;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// HLS/DASH streaming support
|
|
1428
|
+
const isHlsStream = videoSrc.includes('.m3u8');
|
|
1429
|
+
if (isHlsStream) {
|
|
1430
|
+
// Try native HLS first (Safari, iOS, some Android)
|
|
1431
|
+
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
1432
|
+
this.log.info(`HLS stream (native): ${fileId}`);
|
|
1433
|
+
video.src = videoSrc;
|
|
1434
|
+
} else {
|
|
1435
|
+
// Dynamic import hls.js for Chrome/Firefox (code-split, not in main bundle)
|
|
1436
|
+
try {
|
|
1437
|
+
const { default: Hls } = await import('hls.js');
|
|
1438
|
+
if (Hls.isSupported()) {
|
|
1439
|
+
const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
|
|
1440
|
+
hls.loadSource(videoSrc);
|
|
1441
|
+
hls.attachMedia(video);
|
|
1442
|
+
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
1443
|
+
if (data.fatal) {
|
|
1444
|
+
this.log.error(`HLS fatal error: ${data.type}`, data.details);
|
|
1445
|
+
hls.destroy();
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
this.log.info(`HLS stream (hls.js): ${fileId}`);
|
|
1449
|
+
} else {
|
|
1450
|
+
this.log.warn(`HLS not supported on this browser for ${fileId}`);
|
|
1451
|
+
video.src = videoSrc; // Fallback — may not work
|
|
1452
|
+
}
|
|
1453
|
+
} catch (e) {
|
|
1454
|
+
this.log.warn(`hls.js not available, falling back to native: ${e.message}`);
|
|
1455
|
+
video.src = videoSrc;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
} else {
|
|
1459
|
+
video.src = videoSrc;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Detect video duration for dynamic layout timing (when useDuration=0)
|
|
1463
|
+
video.addEventListener('loadedmetadata', () => {
|
|
1464
|
+
const videoDuration = Math.floor(video.duration);
|
|
1465
|
+
this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
|
|
1466
|
+
|
|
1467
|
+
// If widget has useDuration=0, update widget duration with actual video length
|
|
1468
|
+
if (widget.duration === 0 || widget.useDuration === 0) {
|
|
1469
|
+
widget.duration = videoDuration;
|
|
1470
|
+
this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
|
|
1471
|
+
|
|
1472
|
+
// Recalculate layout duration if needed
|
|
1473
|
+
this.updateLayoutDuration();
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
// Debug video loading
|
|
1478
|
+
video.addEventListener('loadeddata', () => {
|
|
1479
|
+
this.log.info('Video loaded and ready:', fileId);
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
// Handle video errors
|
|
1483
|
+
video.addEventListener('error', (e) => {
|
|
1484
|
+
const error = video.error;
|
|
1485
|
+
const errorCode = error?.code;
|
|
1486
|
+
const errorMessage = error?.message || 'Unknown error';
|
|
1487
|
+
|
|
1488
|
+
// Log all video errors for debugging, but never show to users
|
|
1489
|
+
// These are often transient codec warnings that don't prevent playback
|
|
1490
|
+
this.log.warn(`Video error (non-fatal, logged only): ${fileId}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
|
|
1491
|
+
|
|
1492
|
+
// Do NOT emit error events - video errors are logged but not surfaced to UI
|
|
1493
|
+
// Video will either recover (transient decode error) or fail completely (handled elsewhere)
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
video.addEventListener('playing', () => {
|
|
1497
|
+
this.log.info('Video playing:', fileId);
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
this.log.info('Video element created:', fileId, video.src);
|
|
1501
|
+
|
|
1502
|
+
return video;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Render audio widget
|
|
1507
|
+
*/
|
|
1508
|
+
async renderAudio(widget, region) {
|
|
1509
|
+
const container = document.createElement('div');
|
|
1510
|
+
container.className = 'renderer-lite-widget audio-widget';
|
|
1511
|
+
container.style.width = '100%';
|
|
1512
|
+
container.style.height = '100%';
|
|
1513
|
+
container.style.display = 'flex';
|
|
1514
|
+
container.style.flexDirection = 'column';
|
|
1515
|
+
container.style.alignItems = 'center';
|
|
1516
|
+
container.style.justifyContent = 'center';
|
|
1517
|
+
container.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
|
1518
|
+
container.style.opacity = '0';
|
|
1519
|
+
|
|
1520
|
+
// Audio element
|
|
1521
|
+
const audio = document.createElement('audio');
|
|
1522
|
+
audio.autoplay = true;
|
|
1523
|
+
audio.loop = widget.options.loop === '1';
|
|
1524
|
+
audio.volume = parseFloat(widget.options.volume || '100') / 100;
|
|
1525
|
+
|
|
1526
|
+
// Get media URL from cache (already pre-fetched!) or fetch on-demand
|
|
1527
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
1528
|
+
let audioSrc = this.mediaUrlCache.get(fileId);
|
|
1529
|
+
|
|
1530
|
+
if (!audioSrc && this.options.getMediaUrl) {
|
|
1531
|
+
audioSrc = await this.options.getMediaUrl(fileId);
|
|
1532
|
+
} else if (!audioSrc) {
|
|
1533
|
+
audioSrc = `${window.location.origin}/player/cache/media/${fileId}`;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
audio.src = audioSrc;
|
|
1537
|
+
|
|
1538
|
+
// Visual feedback
|
|
1539
|
+
const icon = document.createElement('div');
|
|
1540
|
+
icon.innerHTML = '♪';
|
|
1541
|
+
icon.style.fontSize = '120px';
|
|
1542
|
+
icon.style.color = 'white';
|
|
1543
|
+
icon.style.marginBottom = '20px';
|
|
1544
|
+
|
|
1545
|
+
const info = document.createElement('div');
|
|
1546
|
+
info.style.color = 'white';
|
|
1547
|
+
info.style.fontSize = '24px';
|
|
1548
|
+
info.textContent = 'Playing Audio';
|
|
1549
|
+
|
|
1550
|
+
const filename = document.createElement('div');
|
|
1551
|
+
filename.style.color = 'rgba(255,255,255,0.7)';
|
|
1552
|
+
filename.style.fontSize = '16px';
|
|
1553
|
+
filename.style.marginTop = '10px';
|
|
1554
|
+
filename.textContent = widget.options.uri;
|
|
1555
|
+
|
|
1556
|
+
container.appendChild(audio);
|
|
1557
|
+
container.appendChild(icon);
|
|
1558
|
+
container.appendChild(info);
|
|
1559
|
+
container.appendChild(filename);
|
|
1560
|
+
|
|
1561
|
+
return container;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Render text/ticker widget
|
|
1566
|
+
*/
|
|
1567
|
+
async renderTextWidget(widget, region) {
|
|
1568
|
+
const iframe = document.createElement('iframe');
|
|
1569
|
+
iframe.className = 'renderer-lite-widget';
|
|
1570
|
+
iframe.style.width = '100%';
|
|
1571
|
+
iframe.style.height = '100%';
|
|
1572
|
+
iframe.style.border = 'none';
|
|
1573
|
+
iframe.style.opacity = '0';
|
|
1574
|
+
|
|
1575
|
+
// Get widget HTML (may return { url } for cache-path loading or string for blob)
|
|
1576
|
+
let html = widget.raw;
|
|
1577
|
+
if (this.options.getWidgetHtml) {
|
|
1578
|
+
const result = await this.options.getWidgetHtml(widget);
|
|
1579
|
+
if (result && typeof result === 'object' && result.url) {
|
|
1580
|
+
// Use cache URL — SW serves HTML and intercepts sub-resources
|
|
1581
|
+
iframe.src = result.url;
|
|
1582
|
+
|
|
1583
|
+
// On hard reload (Ctrl+Shift+R), iframe navigation bypasses SW → server 404
|
|
1584
|
+
// Detect and fall back to blob URL with original CMS signed URLs
|
|
1585
|
+
if (result.fallback) {
|
|
1586
|
+
const self = this;
|
|
1587
|
+
iframe.addEventListener('load', function() {
|
|
1588
|
+
try {
|
|
1589
|
+
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
1590
|
+
if (!iframe.contentDocument?.querySelector('base')) {
|
|
1591
|
+
console.warn('[RendererLite] Cache URL failed (hard reload?), using original CMS URLs');
|
|
1592
|
+
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
1593
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1594
|
+
self.trackBlobUrl(blobUrl);
|
|
1595
|
+
iframe.src = blobUrl;
|
|
1596
|
+
}
|
|
1597
|
+
} catch (e) { /* cross-origin — should not happen */ }
|
|
1598
|
+
}, { once: true });
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
return iframe;
|
|
1602
|
+
}
|
|
1603
|
+
html = result;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Fallback: Create blob URL for iframe
|
|
1607
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
1608
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1609
|
+
iframe.src = blobUrl;
|
|
1610
|
+
|
|
1611
|
+
// Track blob URL for lifecycle management
|
|
1612
|
+
this.trackBlobUrl(blobUrl);
|
|
1613
|
+
|
|
1614
|
+
return iframe;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Render PDF widget
|
|
1619
|
+
*/
|
|
1620
|
+
async renderPdf(widget, region) {
|
|
1621
|
+
const container = document.createElement('div');
|
|
1622
|
+
container.className = 'renderer-lite-widget pdf-widget';
|
|
1623
|
+
container.style.width = '100%';
|
|
1624
|
+
container.style.height = '100%';
|
|
1625
|
+
container.style.backgroundColor = '#525659';
|
|
1626
|
+
container.style.opacity = '0';
|
|
1627
|
+
container.style.position = 'relative';
|
|
1628
|
+
|
|
1629
|
+
// Load PDF.js if available
|
|
1630
|
+
if (typeof window.pdfjsLib === 'undefined') {
|
|
1631
|
+
try {
|
|
1632
|
+
const pdfjsModule = await import('pdfjs-dist');
|
|
1633
|
+
window.pdfjsLib = pdfjsModule;
|
|
1634
|
+
window.pdfjsLib.GlobalWorkerOptions.workerSrc = `${window.location.origin}/player/pdf.worker.min.mjs`;
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
this.log.error('PDF.js not available:', error);
|
|
1637
|
+
container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">PDF viewer unavailable</div>';
|
|
1638
|
+
container.style.opacity = '1';
|
|
1639
|
+
return container;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Get PDF URL from cache (already pre-fetched!) or fetch on-demand
|
|
1644
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
1645
|
+
let pdfUrl = this.mediaUrlCache.get(fileId);
|
|
1646
|
+
|
|
1647
|
+
if (!pdfUrl && this.options.getMediaUrl) {
|
|
1648
|
+
pdfUrl = await this.options.getMediaUrl(fileId);
|
|
1649
|
+
} else if (!pdfUrl) {
|
|
1650
|
+
pdfUrl = `${window.location.origin}/player/cache/media/${widget.options.uri}`;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Render PDF
|
|
1654
|
+
try {
|
|
1655
|
+
const loadingTask = window.pdfjsLib.getDocument(pdfUrl);
|
|
1656
|
+
const pdf = await loadingTask.promise;
|
|
1657
|
+
const page = await pdf.getPage(1); // Render first page
|
|
1658
|
+
|
|
1659
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
1660
|
+
const scale = Math.min(
|
|
1661
|
+
region.width / viewport.width,
|
|
1662
|
+
region.height / viewport.height
|
|
1663
|
+
);
|
|
1664
|
+
const scaledViewport = page.getViewport({ scale });
|
|
1665
|
+
|
|
1666
|
+
const canvas = document.createElement('canvas');
|
|
1667
|
+
canvas.width = scaledViewport.width;
|
|
1668
|
+
canvas.height = scaledViewport.height;
|
|
1669
|
+
canvas.style.display = 'block';
|
|
1670
|
+
canvas.style.margin = 'auto';
|
|
1671
|
+
|
|
1672
|
+
const context = canvas.getContext('2d');
|
|
1673
|
+
await page.render({ canvasContext: context, viewport: scaledViewport }).promise;
|
|
1674
|
+
|
|
1675
|
+
container.appendChild(canvas);
|
|
1676
|
+
|
|
1677
|
+
} catch (error) {
|
|
1678
|
+
this.log.error('PDF render failed:', error);
|
|
1679
|
+
container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">Failed to load PDF</div>';
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
container.style.opacity = '1';
|
|
1683
|
+
return container;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Render webpage widget
|
|
1688
|
+
*/
|
|
1689
|
+
async renderWebpage(widget, region) {
|
|
1690
|
+
const iframe = document.createElement('iframe');
|
|
1691
|
+
iframe.className = 'renderer-lite-widget';
|
|
1692
|
+
iframe.style.width = '100%';
|
|
1693
|
+
iframe.style.height = '100%';
|
|
1694
|
+
iframe.style.border = 'none';
|
|
1695
|
+
iframe.style.opacity = '0';
|
|
1696
|
+
iframe.src = widget.options.uri;
|
|
1697
|
+
|
|
1698
|
+
return iframe;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Render generic widget (clock, calendar, weather, etc.)
|
|
1703
|
+
*/
|
|
1704
|
+
async renderGenericWidget(widget, region) {
|
|
1705
|
+
const iframe = document.createElement('iframe');
|
|
1706
|
+
iframe.className = 'renderer-lite-widget';
|
|
1707
|
+
iframe.style.width = '100%';
|
|
1708
|
+
iframe.style.height = '100%';
|
|
1709
|
+
iframe.style.border = 'none';
|
|
1710
|
+
iframe.style.opacity = '0';
|
|
1711
|
+
|
|
1712
|
+
// Get widget HTML (may return { url } for cache-path loading or string for blob)
|
|
1713
|
+
let html = widget.raw;
|
|
1714
|
+
if (this.options.getWidgetHtml) {
|
|
1715
|
+
const result = await this.options.getWidgetHtml(widget);
|
|
1716
|
+
if (result && typeof result === 'object' && result.url) {
|
|
1717
|
+
// Use cache URL — SW serves HTML and intercepts sub-resources
|
|
1718
|
+
iframe.src = result.url;
|
|
1719
|
+
|
|
1720
|
+
// On hard reload (Ctrl+Shift+R), iframe navigation bypasses SW → server 404
|
|
1721
|
+
// Detect and fall back to blob URL with original CMS signed URLs
|
|
1722
|
+
if (result.fallback) {
|
|
1723
|
+
const self = this;
|
|
1724
|
+
iframe.addEventListener('load', function() {
|
|
1725
|
+
try {
|
|
1726
|
+
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
1727
|
+
if (!iframe.contentDocument?.querySelector('base')) {
|
|
1728
|
+
console.warn('[RendererLite] Cache URL failed (hard reload?), using original CMS URLs');
|
|
1729
|
+
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
1730
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1731
|
+
self.trackBlobUrl(blobUrl);
|
|
1732
|
+
iframe.src = blobUrl;
|
|
1733
|
+
}
|
|
1734
|
+
} catch (e) { /* cross-origin — should not happen */ }
|
|
1735
|
+
}, { once: true });
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
return iframe;
|
|
1739
|
+
}
|
|
1740
|
+
html = result;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (html) {
|
|
1744
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
1745
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1746
|
+
iframe.src = blobUrl;
|
|
1747
|
+
|
|
1748
|
+
// Track blob URL for lifecycle management
|
|
1749
|
+
this.trackBlobUrl(blobUrl);
|
|
1750
|
+
} else {
|
|
1751
|
+
this.log.warn(`No HTML for widget ${widget.id}`);
|
|
1752
|
+
iframe.srcdoc = '<div style="padding:20px;">Widget content unavailable</div>';
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
return iframe;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// ── Layout Preload Pool ─────────────────────────────────────────────
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Schedule preloading of the next layout at 75% of current layout duration.
|
|
1762
|
+
* Emits 'request-next-layout-preload' so the platform layer can peek at the
|
|
1763
|
+
* schedule and call preloadLayout() with the next layout's XLF.
|
|
1764
|
+
* @param {Object} layout - Current layout object with .duration
|
|
1765
|
+
*/
|
|
1766
|
+
_scheduleNextLayoutPreload(layout) {
|
|
1767
|
+
if (this.preloadTimer) {
|
|
1768
|
+
clearTimeout(this.preloadTimer);
|
|
1769
|
+
this.preloadTimer = null;
|
|
1770
|
+
}
|
|
1771
|
+
if (this._preloadRetryTimer) {
|
|
1772
|
+
clearTimeout(this._preloadRetryTimer);
|
|
1773
|
+
this._preloadRetryTimer = null;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const duration = layout.duration || 60; // seconds
|
|
1777
|
+
const preloadDelay = duration * 1000 * 0.75; // 75% through
|
|
1778
|
+
const retryDelay = duration * 1000 * 0.90; // 90% retry
|
|
1779
|
+
|
|
1780
|
+
this.log.info(`Scheduling next layout preload in ${(preloadDelay / 1000).toFixed(1)}s (75% of ${duration}s)`);
|
|
1781
|
+
|
|
1782
|
+
this.preloadTimer = setTimeout(() => {
|
|
1783
|
+
this.preloadTimer = null;
|
|
1784
|
+
this.emit('request-next-layout-preload');
|
|
1785
|
+
}, preloadDelay);
|
|
1786
|
+
|
|
1787
|
+
// Retry at 90% if the 75% attempt couldn't find a layout (e.g. cooldowns
|
|
1788
|
+
// hadn't expired yet). The platform handler is idempotent — if a layout
|
|
1789
|
+
// is already in the pool it skips, so this is safe even if 75% succeeded.
|
|
1790
|
+
this._preloadRetryTimer = setTimeout(() => {
|
|
1791
|
+
this._preloadRetryTimer = null;
|
|
1792
|
+
this.emit('request-next-layout-preload');
|
|
1793
|
+
}, retryDelay);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Preload a layout into the pool as a warm (hidden) entry.
|
|
1798
|
+
* Creates the full DOM hierarchy (regions + widgets) in a hidden container,
|
|
1799
|
+
* pre-fetches media, but does NOT start widget cycling or layout timer.
|
|
1800
|
+
*
|
|
1801
|
+
* This is called by the platform layer in response to 'request-next-layout-preload'.
|
|
1802
|
+
*
|
|
1803
|
+
* @param {string} xlfXml - XLF XML content for the layout
|
|
1804
|
+
* @param {number} layoutId - Layout ID
|
|
1805
|
+
* @returns {Promise<boolean>} true if preload succeeded, false on failure
|
|
1806
|
+
*/
|
|
1807
|
+
async preloadLayout(xlfXml, layoutId) {
|
|
1808
|
+
// Don't preload if already in pool
|
|
1809
|
+
if (this.layoutPool.has(layoutId)) {
|
|
1810
|
+
this.log.info(`Layout ${layoutId} already in preload pool, skipping`);
|
|
1811
|
+
return true;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Don't preload the currently playing layout
|
|
1815
|
+
if (this.currentLayoutId === layoutId) {
|
|
1816
|
+
this.log.info(`Layout ${layoutId} is current, skipping preload`);
|
|
1817
|
+
return true;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
try {
|
|
1821
|
+
this.log.info(`Preloading layout ${layoutId} into pool...`);
|
|
1822
|
+
|
|
1823
|
+
// Parse XLF
|
|
1824
|
+
const layout = this.parseXlf(xlfXml);
|
|
1825
|
+
|
|
1826
|
+
// Calculate scale factor
|
|
1827
|
+
this.calculateScale(layout);
|
|
1828
|
+
|
|
1829
|
+
// Create a hidden wrapper container for the preloaded layout
|
|
1830
|
+
const wrapper = document.createElement('div');
|
|
1831
|
+
wrapper.id = `preload_layout_${layoutId}`;
|
|
1832
|
+
wrapper.className = 'renderer-lite-preload-wrapper';
|
|
1833
|
+
wrapper.style.position = 'absolute';
|
|
1834
|
+
wrapper.style.top = '0';
|
|
1835
|
+
wrapper.style.left = '0';
|
|
1836
|
+
wrapper.style.width = '100%';
|
|
1837
|
+
wrapper.style.height = '100%';
|
|
1838
|
+
wrapper.style.visibility = 'hidden';
|
|
1839
|
+
wrapper.style.zIndex = '-1'; // Behind everything
|
|
1840
|
+
|
|
1841
|
+
// Set background
|
|
1842
|
+
wrapper.style.backgroundColor = layout.bgcolor;
|
|
1843
|
+
|
|
1844
|
+
// Apply background image if specified
|
|
1845
|
+
if (layout.background && this.options.getMediaUrl) {
|
|
1846
|
+
try {
|
|
1847
|
+
const bgUrl = await this.options.getMediaUrl(parseInt(layout.background));
|
|
1848
|
+
if (bgUrl) {
|
|
1849
|
+
wrapper.style.backgroundImage = `url(${bgUrl})`;
|
|
1850
|
+
wrapper.style.backgroundSize = 'cover';
|
|
1851
|
+
wrapper.style.backgroundPosition = 'center';
|
|
1852
|
+
wrapper.style.backgroundRepeat = 'no-repeat';
|
|
1853
|
+
}
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
this.log.warn('Preload: Failed to load background image:', err);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Pre-fetch all media URLs in parallel
|
|
1860
|
+
const preloadMediaUrlCache = new Map();
|
|
1861
|
+
if (this.options.getMediaUrl) {
|
|
1862
|
+
const mediaPromises = [];
|
|
1863
|
+
|
|
1864
|
+
for (const region of layout.regions) {
|
|
1865
|
+
for (const widget of region.widgets) {
|
|
1866
|
+
if (widget.fileId) {
|
|
1867
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
1868
|
+
if (!preloadMediaUrlCache.has(fileId)) {
|
|
1869
|
+
mediaPromises.push(
|
|
1870
|
+
this.options.getMediaUrl(fileId)
|
|
1871
|
+
.then(url => {
|
|
1872
|
+
preloadMediaUrlCache.set(fileId, url);
|
|
1873
|
+
})
|
|
1874
|
+
.catch(err => {
|
|
1875
|
+
this.log.warn(`Preload: Failed to fetch media ${fileId}:`, err);
|
|
1876
|
+
})
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
if (mediaPromises.length > 0) {
|
|
1884
|
+
this.log.info(`Preload: fetching ${mediaPromises.length} media URLs...`);
|
|
1885
|
+
await Promise.all(mediaPromises);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Temporarily swap mediaUrlCache so createWidgetElement uses preload cache
|
|
1890
|
+
const savedMediaUrlCache = this.mediaUrlCache;
|
|
1891
|
+
const savedCurrentLayoutId = this.currentLayoutId;
|
|
1892
|
+
this.mediaUrlCache = preloadMediaUrlCache;
|
|
1893
|
+
|
|
1894
|
+
// Create regions in the hidden wrapper
|
|
1895
|
+
const preloadRegions = new Map();
|
|
1896
|
+
const sf = this.scaleFactor;
|
|
1897
|
+
|
|
1898
|
+
for (const regionConfig of layout.regions) {
|
|
1899
|
+
const regionEl = document.createElement('div');
|
|
1900
|
+
regionEl.id = `preload_region_${layoutId}_${regionConfig.id}`;
|
|
1901
|
+
regionEl.className = 'renderer-lite-region';
|
|
1902
|
+
regionEl.style.position = 'absolute';
|
|
1903
|
+
regionEl.style.zIndex = regionConfig.zindex;
|
|
1904
|
+
regionEl.style.overflow = 'hidden';
|
|
1905
|
+
|
|
1906
|
+
// Apply scaled positioning
|
|
1907
|
+
this.applyRegionScale(regionEl, regionConfig);
|
|
1908
|
+
|
|
1909
|
+
wrapper.appendChild(regionEl);
|
|
1910
|
+
|
|
1911
|
+
const region = {
|
|
1912
|
+
element: regionEl,
|
|
1913
|
+
config: regionConfig,
|
|
1914
|
+
widgets: regionConfig.widgets,
|
|
1915
|
+
currentIndex: 0,
|
|
1916
|
+
timer: null,
|
|
1917
|
+
width: regionConfig.width * sf,
|
|
1918
|
+
height: regionConfig.height * sf,
|
|
1919
|
+
complete: false,
|
|
1920
|
+
widgetElements: new Map()
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1923
|
+
preloadRegions.set(regionConfig.id, region);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// Track blob URLs for the preloaded layout separately
|
|
1927
|
+
const preloadBlobUrls = new Set();
|
|
1928
|
+
const savedLayoutBlobUrls = this.layoutBlobUrls;
|
|
1929
|
+
this.layoutBlobUrls = new Map();
|
|
1930
|
+
this.layoutBlobUrls.set(layoutId, preloadBlobUrls);
|
|
1931
|
+
|
|
1932
|
+
// Temporarily set currentLayoutId for trackBlobUrl to work
|
|
1933
|
+
this.currentLayoutId = layoutId;
|
|
1934
|
+
|
|
1935
|
+
// Pre-create all widget elements
|
|
1936
|
+
for (const [regionId, region] of preloadRegions) {
|
|
1937
|
+
for (let i = 0; i < region.widgets.length; i++) {
|
|
1938
|
+
const widget = region.widgets[i];
|
|
1939
|
+
widget.layoutId = layoutId;
|
|
1940
|
+
widget.regionId = regionId;
|
|
1941
|
+
|
|
1942
|
+
try {
|
|
1943
|
+
const element = await this.createWidgetElement(widget, region);
|
|
1944
|
+
element.style.visibility = 'hidden';
|
|
1945
|
+
element.style.opacity = '0';
|
|
1946
|
+
region.element.appendChild(element);
|
|
1947
|
+
region.widgetElements.set(widget.id, element);
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
this.log.error(`Preload: Failed to create widget ${widget.id}:`, error);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// Restore state
|
|
1955
|
+
this.mediaUrlCache = savedMediaUrlCache;
|
|
1956
|
+
this.currentLayoutId = savedCurrentLayoutId;
|
|
1957
|
+
|
|
1958
|
+
// Pause all videos in preloaded layout (autoplay starts them even when hidden)
|
|
1959
|
+
wrapper.querySelectorAll('video').forEach(v => v.pause());
|
|
1960
|
+
|
|
1961
|
+
// Collect any blob URLs tracked during preload
|
|
1962
|
+
const trackedBlobUrls = this.layoutBlobUrls.get(layoutId) || new Set();
|
|
1963
|
+
trackedBlobUrls.forEach(url => preloadBlobUrls.add(url));
|
|
1964
|
+
|
|
1965
|
+
// Restore original layoutBlobUrls
|
|
1966
|
+
this.layoutBlobUrls = savedLayoutBlobUrls;
|
|
1967
|
+
|
|
1968
|
+
// Add wrapper to main container (hidden)
|
|
1969
|
+
this.container.appendChild(wrapper);
|
|
1970
|
+
|
|
1971
|
+
// Add to pool as warm
|
|
1972
|
+
this.layoutPool.add(layoutId, {
|
|
1973
|
+
container: wrapper,
|
|
1974
|
+
layout,
|
|
1975
|
+
regions: preloadRegions,
|
|
1976
|
+
blobUrls: preloadBlobUrls,
|
|
1977
|
+
mediaUrlCache: preloadMediaUrlCache
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
this.log.info(`Layout ${layoutId} preloaded into pool (${preloadRegions.size} regions, ${preloadMediaUrlCache.size} media)`);
|
|
1981
|
+
return true;
|
|
1982
|
+
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
this.log.error(`Preload failed for layout ${layoutId}:`, error);
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* Swap to a preloaded layout from the pool (instant transition).
|
|
1991
|
+
* Hides the current layout container and shows the preloaded one,
|
|
1992
|
+
* then starts widget cycling and layout timer.
|
|
1993
|
+
*
|
|
1994
|
+
* @param {number} layoutId - Layout ID to swap to
|
|
1995
|
+
*/
|
|
1996
|
+
async _swapToPreloadedLayout(layoutId) {
|
|
1997
|
+
const preloaded = this.layoutPool.get(layoutId);
|
|
1998
|
+
if (!preloaded) {
|
|
1999
|
+
this.log.error(`Cannot swap: layout ${layoutId} not in pool`);
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// ── Tear down old layout ──
|
|
2004
|
+
this.removeActionListeners();
|
|
2005
|
+
|
|
2006
|
+
if (this.layoutTimer) {
|
|
2007
|
+
clearTimeout(this.layoutTimer);
|
|
2008
|
+
this.layoutTimer = null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (this.preloadTimer) {
|
|
2012
|
+
clearTimeout(this.preloadTimer);
|
|
2013
|
+
this.preloadTimer = null;
|
|
2014
|
+
}
|
|
2015
|
+
if (this._preloadRetryTimer) {
|
|
2016
|
+
clearTimeout(this._preloadRetryTimer);
|
|
2017
|
+
this._preloadRetryTimer = null;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
const oldLayoutId = this.currentLayoutId;
|
|
2021
|
+
|
|
2022
|
+
if (oldLayoutId && this.layoutPool.has(oldLayoutId)) {
|
|
2023
|
+
// Old layout was preloaded — evict from pool (safe: removes its wrapper div)
|
|
2024
|
+
this.layoutPool.evict(oldLayoutId);
|
|
2025
|
+
} else {
|
|
2026
|
+
// Old layout was rendered normally — manual cleanup.
|
|
2027
|
+
// Region elements live directly in this.container (not a wrapper),
|
|
2028
|
+
// so we must remove them individually.
|
|
2029
|
+
for (const [regionId, region] of this.regions) {
|
|
2030
|
+
if (region.timer) {
|
|
2031
|
+
clearTimeout(region.timer);
|
|
2032
|
+
region.timer = null;
|
|
2033
|
+
}
|
|
2034
|
+
// Release video resources
|
|
2035
|
+
region.element.querySelectorAll('video').forEach(v => {
|
|
2036
|
+
v.pause();
|
|
2037
|
+
v.removeAttribute('src');
|
|
2038
|
+
v.load();
|
|
2039
|
+
});
|
|
2040
|
+
region.element.remove();
|
|
2041
|
+
}
|
|
2042
|
+
// Revoke blob URLs
|
|
2043
|
+
if (oldLayoutId) {
|
|
2044
|
+
this.revokeBlobUrlsForLayout(oldLayoutId);
|
|
2045
|
+
}
|
|
2046
|
+
for (const [fileId, blobUrl] of this.mediaUrlCache) {
|
|
2047
|
+
if (blobUrl && typeof blobUrl === 'string' && blobUrl.startsWith('blob:')) {
|
|
2048
|
+
URL.revokeObjectURL(blobUrl);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Emit layoutEnd for old layout if timer hasn't already
|
|
2054
|
+
if (oldLayoutId && !this.layoutEndEmitted) {
|
|
2055
|
+
this.emit('layoutEnd', oldLayoutId);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
this.regions.clear();
|
|
2059
|
+
this.mediaUrlCache.clear();
|
|
2060
|
+
|
|
2061
|
+
// ── Activate preloaded layout ──
|
|
2062
|
+
preloaded.container.style.visibility = 'visible';
|
|
2063
|
+
preloaded.container.style.zIndex = '0';
|
|
2064
|
+
|
|
2065
|
+
// Update renderer state to the preloaded layout
|
|
2066
|
+
this.layoutPool.setHot(layoutId);
|
|
2067
|
+
this.currentLayout = preloaded.layout;
|
|
2068
|
+
this.currentLayoutId = layoutId;
|
|
2069
|
+
this.regions = preloaded.regions;
|
|
2070
|
+
this.mediaUrlCache = preloaded.mediaUrlCache || new Map();
|
|
2071
|
+
this.layoutEndEmitted = false;
|
|
2072
|
+
|
|
2073
|
+
// Update container background to match preloaded layout
|
|
2074
|
+
this.container.style.backgroundColor = preloaded.layout.bgcolor;
|
|
2075
|
+
if (preloaded.container.style.backgroundImage) {
|
|
2076
|
+
this.container.style.backgroundImage = preloaded.container.style.backgroundImage;
|
|
2077
|
+
this.container.style.backgroundSize = preloaded.container.style.backgroundSize;
|
|
2078
|
+
this.container.style.backgroundPosition = preloaded.container.style.backgroundPosition;
|
|
2079
|
+
this.container.style.backgroundRepeat = preloaded.container.style.backgroundRepeat;
|
|
2080
|
+
} else {
|
|
2081
|
+
this.container.style.backgroundImage = '';
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// Recalculate scale for the preloaded layout
|
|
2085
|
+
this.calculateScale(preloaded.layout);
|
|
2086
|
+
|
|
2087
|
+
// Attach interactive action listeners
|
|
2088
|
+
this.attachActionListeners(preloaded.layout);
|
|
2089
|
+
|
|
2090
|
+
// Emit layout start event
|
|
2091
|
+
this.emit('layoutStart', layoutId, preloaded.layout);
|
|
2092
|
+
|
|
2093
|
+
// Reset all regions and start widget cycling
|
|
2094
|
+
for (const [regionId, region] of this.regions) {
|
|
2095
|
+
region.currentIndex = 0;
|
|
2096
|
+
region.complete = false;
|
|
2097
|
+
this.startRegion(regionId);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// Recalculate layout duration from widget durations.
|
|
2101
|
+
// During preload, video loadedmetadata updated widget.duration but
|
|
2102
|
+
// updateLayoutDuration() updated this.currentLayout (the old layout),
|
|
2103
|
+
// so preloaded.layout.duration may still be the XLF default (e.g. 60s).
|
|
2104
|
+
this.updateLayoutDuration();
|
|
2105
|
+
|
|
2106
|
+
// Wait for widgets to be ready then start layout timer
|
|
2107
|
+
this.startLayoutTimerWhenReady(layoutId, preloaded.layout);
|
|
2108
|
+
|
|
2109
|
+
// Schedule next preload
|
|
2110
|
+
this._scheduleNextLayoutPreload(preloaded.layout);
|
|
2111
|
+
|
|
2112
|
+
this.log.info(`Swapped to preloaded layout ${layoutId} (instant transition)`);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* Check if all regions have completed one full cycle
|
|
2117
|
+
* This is informational only - layout timer is authoritative
|
|
2118
|
+
*/
|
|
2119
|
+
checkLayoutComplete() {
|
|
2120
|
+
// Check if all regions with multiple widgets have completed one cycle
|
|
2121
|
+
let allComplete = true;
|
|
2122
|
+
for (const [regionId, region] of this.regions) {
|
|
2123
|
+
// Only check multi-widget regions
|
|
2124
|
+
if (region.widgets.length > 1 && !region.complete) {
|
|
2125
|
+
allComplete = false;
|
|
2126
|
+
break;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
if (allComplete && this.currentLayoutId) {
|
|
2131
|
+
this.log.info(`All multi-widget regions completed one cycle`);
|
|
2132
|
+
// NOTE: We DON'T emit layoutEnd here - layout timer is authoritative
|
|
2133
|
+
// This is just informational logging for debugging
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Stop current layout
|
|
2139
|
+
*/
|
|
2140
|
+
stopCurrentLayout() {
|
|
2141
|
+
if (!this.currentLayout) return;
|
|
2142
|
+
|
|
2143
|
+
this.log.info(`Stopping layout ${this.currentLayoutId}`);
|
|
2144
|
+
|
|
2145
|
+
// Remove interactive action listeners before teardown
|
|
2146
|
+
this.removeActionListeners();
|
|
2147
|
+
|
|
2148
|
+
// Clear layout timer
|
|
2149
|
+
if (this.layoutTimer) {
|
|
2150
|
+
clearTimeout(this.layoutTimer);
|
|
2151
|
+
this.layoutTimer = null;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Clear preload timers
|
|
2155
|
+
if (this.preloadTimer) {
|
|
2156
|
+
clearTimeout(this.preloadTimer);
|
|
2157
|
+
this.preloadTimer = null;
|
|
2158
|
+
}
|
|
2159
|
+
if (this._preloadRetryTimer) {
|
|
2160
|
+
clearTimeout(this._preloadRetryTimer);
|
|
2161
|
+
this._preloadRetryTimer = null;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// If layout was preloaded (has its own wrapper div in pool), evict safely.
|
|
2165
|
+
// Normally-rendered layouts are NOT in the pool, so we do manual cleanup.
|
|
2166
|
+
if (this.currentLayoutId && this.layoutPool.has(this.currentLayoutId)) {
|
|
2167
|
+
this.layoutPool.evict(this.currentLayoutId);
|
|
2168
|
+
} else {
|
|
2169
|
+
// Normally-rendered layout - manual cleanup (regions are in this.container)
|
|
2170
|
+
|
|
2171
|
+
// Revoke all blob URLs for this layout (tracked lifecycle management)
|
|
2172
|
+
if (this.currentLayoutId) {
|
|
2173
|
+
this.revokeBlobUrlsForLayout(this.currentLayoutId);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// Stop all regions
|
|
2177
|
+
for (const [regionId, region] of this.regions) {
|
|
2178
|
+
if (region.timer) {
|
|
2179
|
+
clearTimeout(region.timer);
|
|
2180
|
+
region.timer = null;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// Stop current widget
|
|
2184
|
+
if (region.widgets.length > 0) {
|
|
2185
|
+
this.stopWidget(regionId, region.currentIndex);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// Remove region element
|
|
2189
|
+
region.element.remove();
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// Revoke media blob URLs from cache
|
|
2193
|
+
for (const [fileId, blobUrl] of this.mediaUrlCache) {
|
|
2194
|
+
if (blobUrl && blobUrl.startsWith('blob:')) {
|
|
2195
|
+
URL.revokeObjectURL(blobUrl);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Clear state
|
|
2201
|
+
this.regions.clear();
|
|
2202
|
+
this.mediaUrlCache.clear();
|
|
2203
|
+
|
|
2204
|
+
// Emit layout end event only if timer hasn't already emitted it.
|
|
2205
|
+
// Timer-based layoutEnd (natural expiry) is authoritative — stopCurrentLayout
|
|
2206
|
+
// is called afterwards during the switch to the next layout, so we skip the
|
|
2207
|
+
// duplicate. But if the layout is forcibly stopped mid-playback (e.g., XMR
|
|
2208
|
+
// schedule change), the timer hasn't fired yet, so we DO emit here.
|
|
2209
|
+
if (this.currentLayoutId && !this.layoutEndEmitted) {
|
|
2210
|
+
this.emit('layoutEnd', this.currentLayoutId);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
this.layoutEndEmitted = false;
|
|
2214
|
+
this.currentLayout = null;
|
|
2215
|
+
this.currentLayoutId = null;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* Render an overlay layout on top of the main layout
|
|
2220
|
+
* @param {string} xlfXml - XLF XML content for overlay
|
|
2221
|
+
* @param {number} layoutId - Overlay layout ID
|
|
2222
|
+
* @param {number} priority - Overlay priority (higher = on top)
|
|
2223
|
+
* @returns {Promise<void>}
|
|
2224
|
+
*/
|
|
2225
|
+
async renderOverlay(xlfXml, layoutId, priority = 0) {
|
|
2226
|
+
try {
|
|
2227
|
+
this.log.info(`Rendering overlay ${layoutId} (priority ${priority})`);
|
|
2228
|
+
|
|
2229
|
+
// Check if this overlay is already active
|
|
2230
|
+
if (this.activeOverlays.has(layoutId)) {
|
|
2231
|
+
this.log.warn(`Overlay ${layoutId} already active, skipping`);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// Parse XLF
|
|
2236
|
+
const layout = this.parseXlf(xlfXml);
|
|
2237
|
+
|
|
2238
|
+
// Create overlay container
|
|
2239
|
+
const overlayDiv = document.createElement('div');
|
|
2240
|
+
overlayDiv.id = `overlay_${layoutId}`;
|
|
2241
|
+
overlayDiv.className = 'renderer-lite-overlay';
|
|
2242
|
+
overlayDiv.style.position = 'absolute';
|
|
2243
|
+
overlayDiv.style.top = '0';
|
|
2244
|
+
overlayDiv.style.left = '0';
|
|
2245
|
+
overlayDiv.style.width = '100%';
|
|
2246
|
+
overlayDiv.style.height = '100%';
|
|
2247
|
+
overlayDiv.style.zIndex = String(1000 + priority); // Higher priority = higher z-index
|
|
2248
|
+
overlayDiv.style.pointerEvents = 'auto'; // Enable clicks on overlay
|
|
2249
|
+
overlayDiv.style.backgroundColor = layout.bgcolor;
|
|
2250
|
+
|
|
2251
|
+
// Pre-fetch all media URLs for overlay
|
|
2252
|
+
if (this.options.getMediaUrl) {
|
|
2253
|
+
const mediaPromises = [];
|
|
2254
|
+
for (const region of layout.regions) {
|
|
2255
|
+
for (const widget of region.widgets) {
|
|
2256
|
+
if (widget.fileId) {
|
|
2257
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
2258
|
+
if (!this.mediaUrlCache.has(fileId)) {
|
|
2259
|
+
mediaPromises.push(
|
|
2260
|
+
this.options.getMediaUrl(fileId)
|
|
2261
|
+
.then(url => {
|
|
2262
|
+
this.mediaUrlCache.set(fileId, url);
|
|
2263
|
+
})
|
|
2264
|
+
.catch(err => {
|
|
2265
|
+
this.log.warn(`Failed to fetch overlay media ${fileId}:`, err);
|
|
2266
|
+
})
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
if (mediaPromises.length > 0) {
|
|
2274
|
+
this.log.info(`Pre-fetching ${mediaPromises.length} overlay media URLs...`);
|
|
2275
|
+
await Promise.all(mediaPromises);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// Calculate scale for overlay layout
|
|
2280
|
+
this.calculateScale(layout);
|
|
2281
|
+
|
|
2282
|
+
// Create regions for overlay
|
|
2283
|
+
const overlayRegions = new Map();
|
|
2284
|
+
const sf = this.scaleFactor;
|
|
2285
|
+
for (const regionConfig of layout.regions) {
|
|
2286
|
+
const regionEl = document.createElement('div');
|
|
2287
|
+
regionEl.id = `overlay_${layoutId}_region_${regionConfig.id}`;
|
|
2288
|
+
regionEl.className = 'renderer-lite-region overlay-region';
|
|
2289
|
+
regionEl.style.position = 'absolute';
|
|
2290
|
+
regionEl.style.zIndex = String(regionConfig.zindex);
|
|
2291
|
+
regionEl.style.overflow = 'hidden';
|
|
2292
|
+
|
|
2293
|
+
// Apply scaled positioning
|
|
2294
|
+
this.applyRegionScale(regionEl, regionConfig);
|
|
2295
|
+
|
|
2296
|
+
overlayDiv.appendChild(regionEl);
|
|
2297
|
+
|
|
2298
|
+
// Store region state (dimensions use scaled values)
|
|
2299
|
+
overlayRegions.set(regionConfig.id, {
|
|
2300
|
+
element: regionEl,
|
|
2301
|
+
config: regionConfig,
|
|
2302
|
+
widgets: regionConfig.widgets,
|
|
2303
|
+
currentIndex: 0,
|
|
2304
|
+
timer: null,
|
|
2305
|
+
width: regionConfig.width * sf,
|
|
2306
|
+
height: regionConfig.height * sf,
|
|
2307
|
+
complete: false,
|
|
2308
|
+
widgetElements: new Map()
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// Pre-create widget elements for overlay
|
|
2313
|
+
for (const [regionId, region] of overlayRegions) {
|
|
2314
|
+
for (const widget of region.widgets) {
|
|
2315
|
+
widget.layoutId = layoutId;
|
|
2316
|
+
widget.regionId = regionId;
|
|
2317
|
+
|
|
2318
|
+
try {
|
|
2319
|
+
const element = await this.createWidgetElement(widget, region);
|
|
2320
|
+
element.style.visibility = 'hidden';
|
|
2321
|
+
element.style.opacity = '0';
|
|
2322
|
+
region.element.appendChild(element);
|
|
2323
|
+
region.widgetElements.set(widget.id, element);
|
|
2324
|
+
} catch (error) {
|
|
2325
|
+
this.log.error(`Failed to pre-create overlay widget ${widget.id}:`, error);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// Add overlay to container
|
|
2331
|
+
this.overlayContainer.appendChild(overlayDiv);
|
|
2332
|
+
|
|
2333
|
+
// Store overlay state
|
|
2334
|
+
this.activeOverlays.set(layoutId, {
|
|
2335
|
+
container: overlayDiv,
|
|
2336
|
+
layout: layout,
|
|
2337
|
+
regions: overlayRegions,
|
|
2338
|
+
timer: null,
|
|
2339
|
+
priority: priority
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2342
|
+
// Emit overlay start event
|
|
2343
|
+
this.emit('overlayStart', layoutId, layout);
|
|
2344
|
+
|
|
2345
|
+
// Start all overlay regions
|
|
2346
|
+
for (const [regionId, region] of overlayRegions) {
|
|
2347
|
+
this.startOverlayRegion(layoutId, regionId);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Set overlay timer based on duration
|
|
2351
|
+
if (layout.duration > 0) {
|
|
2352
|
+
const durationMs = layout.duration * 1000;
|
|
2353
|
+
const overlayState = this.activeOverlays.get(layoutId);
|
|
2354
|
+
if (overlayState) {
|
|
2355
|
+
overlayState.timer = setTimeout(() => {
|
|
2356
|
+
this.log.info(`Overlay ${layoutId} duration expired (${layout.duration}s)`);
|
|
2357
|
+
this.emit('overlayEnd', layoutId);
|
|
2358
|
+
}, durationMs);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
this.log.info(`Overlay ${layoutId} started`);
|
|
2363
|
+
|
|
2364
|
+
} catch (error) {
|
|
2365
|
+
this.log.error('Error rendering overlay:', error);
|
|
2366
|
+
this.emit('error', { type: 'overlayError', error, layoutId });
|
|
2367
|
+
throw error;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* Start playing an overlay region's widgets
|
|
2373
|
+
* @param {number} overlayId - Overlay layout ID
|
|
2374
|
+
* @param {string} regionId - Region ID
|
|
2375
|
+
*/
|
|
2376
|
+
startOverlayRegion(overlayId, regionId) {
|
|
2377
|
+
const overlayState = this.activeOverlays.get(overlayId);
|
|
2378
|
+
if (!overlayState) return;
|
|
2379
|
+
|
|
2380
|
+
const region = overlayState.regions.get(regionId);
|
|
2381
|
+
if (!region || region.widgets.length === 0) {
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// If only one widget, just render it (no cycling)
|
|
2386
|
+
if (region.widgets.length === 1) {
|
|
2387
|
+
this.renderOverlayWidget(overlayId, regionId, 0);
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// Multiple widgets - cycle through them
|
|
2392
|
+
const playNext = () => {
|
|
2393
|
+
const widgetIndex = region.currentIndex;
|
|
2394
|
+
const widget = region.widgets[widgetIndex];
|
|
2395
|
+
|
|
2396
|
+
// Render widget
|
|
2397
|
+
this.renderOverlayWidget(overlayId, regionId, widgetIndex);
|
|
2398
|
+
|
|
2399
|
+
// Schedule next widget
|
|
2400
|
+
const duration = widget.duration * 1000;
|
|
2401
|
+
region.timer = setTimeout(() => {
|
|
2402
|
+
this.stopOverlayWidget(overlayId, regionId, widgetIndex);
|
|
2403
|
+
|
|
2404
|
+
// Move to next widget (wraps to 0 if at end)
|
|
2405
|
+
const nextIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
2406
|
+
|
|
2407
|
+
// Check if completing full cycle (wrapped back to 0)
|
|
2408
|
+
if (nextIndex === 0 && !region.complete) {
|
|
2409
|
+
region.complete = true;
|
|
2410
|
+
this.log.info(`Overlay ${overlayId} region ${regionId} completed one full cycle`);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
region.currentIndex = nextIndex;
|
|
2414
|
+
playNext();
|
|
2415
|
+
}, duration);
|
|
2416
|
+
};
|
|
2417
|
+
|
|
2418
|
+
playNext();
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* Render a widget in an overlay region
|
|
2423
|
+
* @param {number} overlayId - Overlay layout ID
|
|
2424
|
+
* @param {string} regionId - Region ID
|
|
2425
|
+
* @param {number} widgetIndex - Widget index in region
|
|
2426
|
+
*/
|
|
2427
|
+
async renderOverlayWidget(overlayId, regionId, widgetIndex) {
|
|
2428
|
+
const overlayState = this.activeOverlays.get(overlayId);
|
|
2429
|
+
if (!overlayState) return;
|
|
2430
|
+
|
|
2431
|
+
const region = overlayState.regions.get(regionId);
|
|
2432
|
+
if (!region) return;
|
|
2433
|
+
|
|
2434
|
+
const widget = region.widgets[widgetIndex];
|
|
2435
|
+
if (!widget) return;
|
|
2436
|
+
|
|
2437
|
+
try {
|
|
2438
|
+
this.log.info(`Showing overlay widget ${widget.type} (${widget.id}) in overlay ${overlayId} region ${regionId}`);
|
|
2439
|
+
|
|
2440
|
+
// Get existing element (pre-created)
|
|
2441
|
+
let element = region.widgetElements.get(widget.id);
|
|
2442
|
+
|
|
2443
|
+
if (!element) {
|
|
2444
|
+
this.log.warn(`Overlay widget ${widget.id} not pre-created, creating now`);
|
|
2445
|
+
element = await this.createWidgetElement(widget, region);
|
|
2446
|
+
region.widgetElements.set(widget.id, element);
|
|
2447
|
+
region.element.appendChild(element);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// Hide all other widgets in region
|
|
2451
|
+
for (const [widgetId, widgetEl] of region.widgetElements) {
|
|
2452
|
+
if (widgetId !== widget.id) {
|
|
2453
|
+
widgetEl.style.visibility = 'hidden';
|
|
2454
|
+
widgetEl.style.opacity = '0';
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// Update media element if needed (restart videos)
|
|
2459
|
+
this.updateMediaElement(element, widget);
|
|
2460
|
+
|
|
2461
|
+
// Show this widget
|
|
2462
|
+
element.style.visibility = 'visible';
|
|
2463
|
+
|
|
2464
|
+
// Apply in transition
|
|
2465
|
+
if (widget.transitions.in) {
|
|
2466
|
+
Transitions.apply(element, widget.transitions.in, true, region.width, region.height);
|
|
2467
|
+
} else {
|
|
2468
|
+
element.style.opacity = '1';
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// Emit widget start event
|
|
2472
|
+
this.emit('overlayWidgetStart', {
|
|
2473
|
+
overlayId,
|
|
2474
|
+
widgetId: widget.id,
|
|
2475
|
+
regionId,
|
|
2476
|
+
type: widget.type,
|
|
2477
|
+
duration: widget.duration
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2480
|
+
} catch (error) {
|
|
2481
|
+
this.log.error(`Error rendering overlay widget:`, error);
|
|
2482
|
+
this.emit('error', { type: 'overlayWidgetError', error, widgetId: widget.id, regionId, overlayId });
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* Stop an overlay widget
|
|
2488
|
+
* @param {number} overlayId - Overlay layout ID
|
|
2489
|
+
* @param {string} regionId - Region ID
|
|
2490
|
+
* @param {number} widgetIndex - Widget index
|
|
2491
|
+
*/
|
|
2492
|
+
async stopOverlayWidget(overlayId, regionId, widgetIndex) {
|
|
2493
|
+
const overlayState = this.activeOverlays.get(overlayId);
|
|
2494
|
+
if (!overlayState) return;
|
|
2495
|
+
|
|
2496
|
+
const region = overlayState.regions.get(regionId);
|
|
2497
|
+
if (!region) return;
|
|
2498
|
+
|
|
2499
|
+
const widget = region.widgets[widgetIndex];
|
|
2500
|
+
if (!widget) return;
|
|
2501
|
+
|
|
2502
|
+
const widgetElement = region.widgetElements.get(widget.id);
|
|
2503
|
+
if (!widgetElement) return;
|
|
2504
|
+
|
|
2505
|
+
// Apply out transition
|
|
2506
|
+
if (widget.transitions.out) {
|
|
2507
|
+
const animation = Transitions.apply(
|
|
2508
|
+
widgetElement,
|
|
2509
|
+
widget.transitions.out,
|
|
2510
|
+
false,
|
|
2511
|
+
region.width,
|
|
2512
|
+
region.height
|
|
2513
|
+
);
|
|
2514
|
+
|
|
2515
|
+
if (animation) {
|
|
2516
|
+
await new Promise(resolve => {
|
|
2517
|
+
animation.onfinish = resolve;
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// Pause media elements
|
|
2523
|
+
const videoEl = widgetElement.querySelector('video');
|
|
2524
|
+
if (videoEl && widget.options.loop !== '1') {
|
|
2525
|
+
videoEl.pause();
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
const audioEl = widgetElement.querySelector('audio');
|
|
2529
|
+
if (audioEl && widget.options.loop !== '1') {
|
|
2530
|
+
audioEl.pause();
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// Emit widget end event
|
|
2534
|
+
this.emit('overlayWidgetEnd', {
|
|
2535
|
+
overlayId,
|
|
2536
|
+
widgetId: widget.id,
|
|
2537
|
+
regionId,
|
|
2538
|
+
type: widget.type
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
/**
|
|
2543
|
+
* Stop and remove an overlay layout
|
|
2544
|
+
* @param {number} layoutId - Overlay layout ID
|
|
2545
|
+
*/
|
|
2546
|
+
stopOverlay(layoutId) {
|
|
2547
|
+
const overlayState = this.activeOverlays.get(layoutId);
|
|
2548
|
+
if (!overlayState) {
|
|
2549
|
+
this.log.warn(`Overlay ${layoutId} not active`);
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
this.log.info(`Stopping overlay ${layoutId}`);
|
|
2554
|
+
|
|
2555
|
+
// Clear overlay timer
|
|
2556
|
+
if (overlayState.timer) {
|
|
2557
|
+
clearTimeout(overlayState.timer);
|
|
2558
|
+
overlayState.timer = null;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// Stop all overlay regions
|
|
2562
|
+
for (const [regionId, region] of overlayState.regions) {
|
|
2563
|
+
if (region.timer) {
|
|
2564
|
+
clearTimeout(region.timer);
|
|
2565
|
+
region.timer = null;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// Stop current widget
|
|
2569
|
+
if (region.widgets.length > 0) {
|
|
2570
|
+
this.stopOverlayWidget(layoutId, regionId, region.currentIndex);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// Remove overlay container from DOM
|
|
2575
|
+
if (overlayState.container) {
|
|
2576
|
+
overlayState.container.remove();
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
// Revoke blob URLs for this overlay
|
|
2580
|
+
this.revokeBlobUrlsForLayout(layoutId);
|
|
2581
|
+
|
|
2582
|
+
// Remove from active overlays
|
|
2583
|
+
this.activeOverlays.delete(layoutId);
|
|
2584
|
+
|
|
2585
|
+
// Emit overlay end event
|
|
2586
|
+
this.emit('overlayEnd', layoutId);
|
|
2587
|
+
|
|
2588
|
+
this.log.info(`Overlay ${layoutId} stopped`);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Stop all active overlays
|
|
2593
|
+
*/
|
|
2594
|
+
stopAllOverlays() {
|
|
2595
|
+
const overlayIds = Array.from(this.activeOverlays.keys());
|
|
2596
|
+
for (const overlayId of overlayIds) {
|
|
2597
|
+
this.stopOverlay(overlayId);
|
|
2598
|
+
}
|
|
2599
|
+
this.log.info('All overlays stopped');
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* Get active overlay IDs
|
|
2604
|
+
* @returns {Array<number>}
|
|
2605
|
+
*/
|
|
2606
|
+
getActiveOverlays() {
|
|
2607
|
+
return Array.from(this.activeOverlays.keys());
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* Cleanup renderer
|
|
2612
|
+
*/
|
|
2613
|
+
cleanup() {
|
|
2614
|
+
this.stopAllOverlays();
|
|
2615
|
+
this.stopCurrentLayout();
|
|
2616
|
+
|
|
2617
|
+
// Clear the layout preload pool
|
|
2618
|
+
this.layoutPool.clear();
|
|
2619
|
+
|
|
2620
|
+
if (this.preloadTimer) {
|
|
2621
|
+
clearTimeout(this.preloadTimer);
|
|
2622
|
+
this.preloadTimer = null;
|
|
2623
|
+
}
|
|
2624
|
+
if (this._preloadRetryTimer) {
|
|
2625
|
+
clearTimeout(this._preloadRetryTimer);
|
|
2626
|
+
this._preloadRetryTimer = null;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
if (this.resizeObserver) {
|
|
2630
|
+
this.resizeObserver.disconnect();
|
|
2631
|
+
this.resizeObserver = null;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
this.container.innerHTML = '';
|
|
2635
|
+
this.log.info('Cleaned up');
|
|
2636
|
+
}
|
|
2637
|
+
}
|