@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.
@@ -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
+ }