@xiboplayer/renderer 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/package.json +2 -2
- package/src/index.js +3 -1
- package/src/layout.js +67 -234
- package/src/renderer-lite.js +252 -292
- package/docs/README.md +0 -98
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @xiboplayer/renderer
|
|
2
|
+
|
|
3
|
+
**XLF layout rendering engine for Xibo digital signage.**
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
RendererLite parses Xibo Layout Format (XLF) files and builds a live DOM with:
|
|
8
|
+
|
|
9
|
+
- **Rich media** — video (MP4/HLS), images, PDF (via PDF.js), text/ticker, web pages, clock, calendar, weather
|
|
10
|
+
- **Transitions** — fade and fly (8-direction compass) via Web Animations API
|
|
11
|
+
- **Interactive actions** — touch/click and keyboard triggers for widget navigation, layout jumps, and commands
|
|
12
|
+
- **Layout preloading** — 2-layout pool pre-builds upcoming layouts at 75% of current duration for zero-gap transitions
|
|
13
|
+
- **Proportional scaling** — ResizeObserver-based scaling to fit any screen resolution
|
|
14
|
+
- **Overlay support** — multiple simultaneous overlay layouts with independent z-index (1000+)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @xiboplayer/renderer
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import { RendererLite } from '@xiboplayer/renderer';
|
|
26
|
+
|
|
27
|
+
const renderer = new RendererLite({
|
|
28
|
+
container: document.getElementById('player'),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Render a layout from parsed XLF
|
|
32
|
+
await renderer.renderLayout(xlf, { mediaBaseUrl: '/cache/' });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Widget Types
|
|
36
|
+
|
|
37
|
+
| Widget | Implementation |
|
|
38
|
+
|--------|---------------|
|
|
39
|
+
| Video | `<video>` with native HLS (Safari) + hls.js fallback, pause-on-last-frame |
|
|
40
|
+
| Image | `<img>` with objectFit contain, blob URL from cache |
|
|
41
|
+
| PDF | PDF.js canvas rendering (dynamically imported) |
|
|
42
|
+
| Text / Ticker | iframe with CMS-rendered HTML via GetResource |
|
|
43
|
+
| Web page | bare `<iframe src="...">` |
|
|
44
|
+
| Clock, Calendar, Weather | iframe via GetResource (server-rendered) |
|
|
45
|
+
| All other CMS widgets | Generic iframe via GetResource |
|
|
46
|
+
|
|
47
|
+
## Dependencies
|
|
48
|
+
|
|
49
|
+
- `@xiboplayer/utils` — logger, events
|
|
50
|
+
- `pdfjs-dist` — PDF rendering
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
**Part of the [XiboPlayer SDK](https://github.com/linuxnow/xiboplayer)**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "RendererLite - Fast, efficient XLF layout rendering engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"nanoevents": "^9.1.0",
|
|
14
14
|
"pdfjs-dist": "^4.10.38",
|
|
15
|
-
"@xiboplayer/utils": "0.
|
|
15
|
+
"@xiboplayer/utils": "0.3.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0",
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// @xiboplayer/renderer - Layout rendering
|
|
2
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
3
|
+
export const VERSION = pkg.version;
|
|
2
4
|
export { RendererLite } from './renderer-lite.js';
|
|
3
5
|
export { LayoutPool } from './layout-pool.js';
|
|
4
|
-
export {
|
|
6
|
+
export { LayoutTranslator } from './layout.js';
|
package/src/layout.js
CHANGED
|
@@ -3,136 +3,6 @@
|
|
|
3
3
|
* Based on arexibo layout.rs
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// Transition utility functions
|
|
7
|
-
const Transitions = {
|
|
8
|
-
/**
|
|
9
|
-
* Apply fade in transition
|
|
10
|
-
*/
|
|
11
|
-
fadeIn(element, duration) {
|
|
12
|
-
const keyframes = [
|
|
13
|
-
{ opacity: 0 },
|
|
14
|
-
{ opacity: 1 }
|
|
15
|
-
];
|
|
16
|
-
const timing = {
|
|
17
|
-
duration: duration,
|
|
18
|
-
easing: 'linear',
|
|
19
|
-
fill: 'forwards'
|
|
20
|
-
};
|
|
21
|
-
return element.animate(keyframes, timing);
|
|
22
|
-
},
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Apply fade out transition
|
|
26
|
-
*/
|
|
27
|
-
fadeOut(element, duration) {
|
|
28
|
-
const keyframes = [
|
|
29
|
-
{ opacity: 1 },
|
|
30
|
-
{ opacity: 0, zIndex: 0 }
|
|
31
|
-
];
|
|
32
|
-
const timing = {
|
|
33
|
-
duration: duration,
|
|
34
|
-
easing: 'linear',
|
|
35
|
-
fill: 'forwards'
|
|
36
|
-
};
|
|
37
|
-
return element.animate(keyframes, timing);
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Get fly keyframes based on compass direction
|
|
42
|
-
*/
|
|
43
|
-
getFlyKeyframes(direction, width, height, isIn) {
|
|
44
|
-
const keyframes = { from: {}, to: {} };
|
|
45
|
-
|
|
46
|
-
// Map compass directions to transform values
|
|
47
|
-
const dirMap = {
|
|
48
|
-
'N': { x: 0, y: isIn ? -height : height },
|
|
49
|
-
'NE': { x: isIn ? width : -width, y: isIn ? -height : height },
|
|
50
|
-
'E': { x: isIn ? width : -width, y: 0 },
|
|
51
|
-
'SE': { x: isIn ? width : -width, y: isIn ? height : -height },
|
|
52
|
-
'S': { x: 0, y: isIn ? height : -height },
|
|
53
|
-
'SW': { x: isIn ? -width : width, y: isIn ? height : -height },
|
|
54
|
-
'W': { x: isIn ? -width : width, y: 0 },
|
|
55
|
-
'NW': { x: isIn ? -width : width, y: isIn ? -height : height }
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const offset = dirMap[direction] || dirMap['N'];
|
|
59
|
-
|
|
60
|
-
if (isIn) {
|
|
61
|
-
keyframes.from = {
|
|
62
|
-
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
63
|
-
opacity: 0
|
|
64
|
-
};
|
|
65
|
-
keyframes.to = {
|
|
66
|
-
transform: 'translate(0, 0)',
|
|
67
|
-
opacity: 1
|
|
68
|
-
};
|
|
69
|
-
} else {
|
|
70
|
-
keyframes.from = {
|
|
71
|
-
transform: 'translate(0, 0)',
|
|
72
|
-
opacity: 1
|
|
73
|
-
};
|
|
74
|
-
keyframes.to = {
|
|
75
|
-
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
76
|
-
opacity: 0
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return keyframes;
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Apply fly in transition
|
|
85
|
-
*/
|
|
86
|
-
flyIn(element, duration, direction, regionWidth, regionHeight) {
|
|
87
|
-
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, true);
|
|
88
|
-
const timing = {
|
|
89
|
-
duration: duration,
|
|
90
|
-
easing: 'ease-out',
|
|
91
|
-
fill: 'forwards'
|
|
92
|
-
};
|
|
93
|
-
return element.animate([keyframes.from, keyframes.to], timing);
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Apply fly out transition
|
|
98
|
-
*/
|
|
99
|
-
flyOut(element, duration, direction, regionWidth, regionHeight) {
|
|
100
|
-
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, false);
|
|
101
|
-
const timing = {
|
|
102
|
-
duration: duration,
|
|
103
|
-
easing: 'ease-in',
|
|
104
|
-
fill: 'forwards'
|
|
105
|
-
};
|
|
106
|
-
return element.animate([keyframes.from, keyframes.to], timing);
|
|
107
|
-
},
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Apply transition based on type
|
|
111
|
-
*/
|
|
112
|
-
apply(element, transitionConfig, isIn, regionWidth, regionHeight) {
|
|
113
|
-
if (!transitionConfig || !transitionConfig.type) {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const type = transitionConfig.type.toLowerCase();
|
|
118
|
-
const duration = transitionConfig.duration || 1000;
|
|
119
|
-
const direction = transitionConfig.direction || 'N';
|
|
120
|
-
|
|
121
|
-
switch (type) {
|
|
122
|
-
case 'fadein':
|
|
123
|
-
return isIn ? this.fadeIn(element, duration) : null;
|
|
124
|
-
case 'fadeout':
|
|
125
|
-
return isIn ? null : this.fadeOut(element, duration);
|
|
126
|
-
case 'flyin':
|
|
127
|
-
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
128
|
-
case 'flyout':
|
|
129
|
-
return isIn ? null : this.flyOut(element, duration, direction, regionWidth, regionHeight);
|
|
130
|
-
default:
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
|
|
136
6
|
export class LayoutTranslator {
|
|
137
7
|
constructor(xmds) {
|
|
138
8
|
this.xmds = xmds;
|
|
@@ -513,6 +383,63 @@ ${mediaJS}
|
|
|
513
383
|
}`;
|
|
514
384
|
}
|
|
515
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Generate iframe widget JS for text/ticker and generic widget types.
|
|
388
|
+
* Returns { startFn, stopFn } strings for the media item.
|
|
389
|
+
*/
|
|
390
|
+
_generateIframeWidgetJS(regionId, mediaId, widgetUrl, transIn, transOut) {
|
|
391
|
+
const iframeId = `widget_${regionId}_${mediaId}`;
|
|
392
|
+
const startFn = `() => {
|
|
393
|
+
const region = document.getElementById('region_${regionId}');
|
|
394
|
+
let iframe = document.getElementById('${iframeId}');
|
|
395
|
+
if (!iframe) {
|
|
396
|
+
iframe = document.createElement('iframe');
|
|
397
|
+
iframe.id = '${iframeId}';
|
|
398
|
+
iframe.src = '${widgetUrl}';
|
|
399
|
+
iframe.style.width = '100%';
|
|
400
|
+
iframe.style.height = '100%';
|
|
401
|
+
iframe.style.border = 'none';
|
|
402
|
+
iframe.scrolling = 'no';
|
|
403
|
+
iframe.style.opacity = '0';
|
|
404
|
+
region.innerHTML = '';
|
|
405
|
+
region.appendChild(iframe);
|
|
406
|
+
|
|
407
|
+
// Apply transition after iframe loads
|
|
408
|
+
iframe.onload = () => {
|
|
409
|
+
const transIn = ${transIn};
|
|
410
|
+
if (transIn && window.Transitions) {
|
|
411
|
+
const regionRect = region.getBoundingClientRect();
|
|
412
|
+
window.Transitions.apply(iframe, transIn, true, regionRect.width, regionRect.height);
|
|
413
|
+
} else {
|
|
414
|
+
iframe.style.opacity = '1';
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
} else {
|
|
418
|
+
iframe.style.display = 'block';
|
|
419
|
+
iframe.style.opacity = '1';
|
|
420
|
+
}
|
|
421
|
+
}`;
|
|
422
|
+
const stopFn = `() => {
|
|
423
|
+
const region = document.getElementById('region_${regionId}');
|
|
424
|
+
const iframe = document.getElementById('${iframeId}');
|
|
425
|
+
if (iframe) {
|
|
426
|
+
const transOut = ${transOut};
|
|
427
|
+
if (transOut && window.Transitions) {
|
|
428
|
+
const regionRect = region.getBoundingClientRect();
|
|
429
|
+
const animation = window.Transitions.apply(iframe, transOut, false, regionRect.width, regionRect.height);
|
|
430
|
+
if (animation) {
|
|
431
|
+
animation.onfinish = () => {
|
|
432
|
+
iframe.style.display = 'none';
|
|
433
|
+
};
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
iframe.style.display = 'none';
|
|
438
|
+
}
|
|
439
|
+
}`;
|
|
440
|
+
return { startFn, stopFn };
|
|
441
|
+
}
|
|
442
|
+
|
|
516
443
|
/**
|
|
517
444
|
* Generate JavaScript for a single media item
|
|
518
445
|
*/
|
|
@@ -616,63 +543,16 @@ ${mediaJS}
|
|
|
616
543
|
|
|
617
544
|
case 'text':
|
|
618
545
|
case 'ticker':
|
|
619
|
-
//
|
|
546
|
+
// Text/ticker widgets use the same iframe pattern as default widgets.
|
|
547
|
+
// If no widgetCacheKey, fall through to the default case which handles unsupported types.
|
|
620
548
|
if (media.options.widgetCacheKey) {
|
|
621
549
|
const textUrl = `${window.location.origin}/player${media.options.widgetCacheKey}`;
|
|
622
|
-
const
|
|
623
|
-
startFn =
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (!iframe) {
|
|
627
|
-
iframe = document.createElement('iframe');
|
|
628
|
-
iframe.id = '${iframeId}';
|
|
629
|
-
iframe.src = '${textUrl}';
|
|
630
|
-
iframe.style.width = '100%';
|
|
631
|
-
iframe.style.height = '100%';
|
|
632
|
-
iframe.style.border = 'none';
|
|
633
|
-
iframe.scrolling = 'no';
|
|
634
|
-
iframe.style.opacity = '0';
|
|
635
|
-
region.innerHTML = '';
|
|
636
|
-
region.appendChild(iframe);
|
|
637
|
-
|
|
638
|
-
// Apply transition after iframe loads
|
|
639
|
-
iframe.onload = () => {
|
|
640
|
-
const transIn = ${transIn};
|
|
641
|
-
if (transIn && window.Transitions) {
|
|
642
|
-
const regionRect = region.getBoundingClientRect();
|
|
643
|
-
window.Transitions.apply(iframe, transIn, true, regionRect.width, regionRect.height);
|
|
644
|
-
} else {
|
|
645
|
-
iframe.style.opacity = '1';
|
|
646
|
-
}
|
|
647
|
-
};
|
|
648
|
-
} else {
|
|
649
|
-
iframe.style.display = 'block';
|
|
650
|
-
iframe.style.opacity = '1';
|
|
651
|
-
}
|
|
652
|
-
}`;
|
|
653
|
-
stopFn = `() => {
|
|
654
|
-
const region = document.getElementById('region_${regionId}');
|
|
655
|
-
const iframe = document.getElementById('${iframeId}');
|
|
656
|
-
if (iframe) {
|
|
657
|
-
const transOut = ${transOut};
|
|
658
|
-
if (transOut && window.Transitions) {
|
|
659
|
-
const regionRect = region.getBoundingClientRect();
|
|
660
|
-
const animation = window.Transitions.apply(iframe, transOut, false, regionRect.width, regionRect.height);
|
|
661
|
-
if (animation) {
|
|
662
|
-
animation.onfinish = () => {
|
|
663
|
-
iframe.style.display = 'none';
|
|
664
|
-
};
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
iframe.style.display = 'none';
|
|
669
|
-
}
|
|
670
|
-
}`;
|
|
671
|
-
} else {
|
|
672
|
-
console.warn(`[Layout] Text media without widgetCacheKey`);
|
|
673
|
-
startFn = `() => console.log('Text media without cache key')`;
|
|
550
|
+
const iframe = this._generateIframeWidgetJS(regionId, media.id, textUrl, transIn, transOut);
|
|
551
|
+
startFn = iframe.startFn;
|
|
552
|
+
stopFn = iframe.stopFn;
|
|
553
|
+
break;
|
|
674
554
|
}
|
|
675
|
-
|
|
555
|
+
// Fall through to default (handles missing widgetCacheKey as unsupported)
|
|
676
556
|
|
|
677
557
|
case 'audio':
|
|
678
558
|
const audioSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
|
|
@@ -1007,55 +887,9 @@ ${mediaJS}
|
|
|
1007
887
|
// Keep widget iframes alive across duration cycles (arexibo behavior)
|
|
1008
888
|
if (media.options.widgetCacheKey) {
|
|
1009
889
|
const widgetUrl = `${window.location.origin}/player${media.options.widgetCacheKey}`;
|
|
1010
|
-
const
|
|
1011
|
-
startFn =
|
|
1012
|
-
|
|
1013
|
-
let iframe = document.getElementById('${iframeId}');
|
|
1014
|
-
if (!iframe) {
|
|
1015
|
-
iframe = document.createElement('iframe');
|
|
1016
|
-
iframe.id = '${iframeId}';
|
|
1017
|
-
iframe.src = '${widgetUrl}';
|
|
1018
|
-
iframe.style.width = '100%';
|
|
1019
|
-
iframe.style.height = '100%';
|
|
1020
|
-
iframe.style.border = 'none';
|
|
1021
|
-
iframe.scrolling = 'no';
|
|
1022
|
-
iframe.style.opacity = '0';
|
|
1023
|
-
region.innerHTML = '';
|
|
1024
|
-
region.appendChild(iframe);
|
|
1025
|
-
|
|
1026
|
-
// Apply transition after iframe loads
|
|
1027
|
-
iframe.onload = () => {
|
|
1028
|
-
const transIn = ${transIn};
|
|
1029
|
-
if (transIn && window.Transitions) {
|
|
1030
|
-
const regionRect = region.getBoundingClientRect();
|
|
1031
|
-
window.Transitions.apply(iframe, transIn, true, regionRect.width, regionRect.height);
|
|
1032
|
-
} else {
|
|
1033
|
-
iframe.style.opacity = '1';
|
|
1034
|
-
}
|
|
1035
|
-
};
|
|
1036
|
-
} else {
|
|
1037
|
-
iframe.style.display = 'block';
|
|
1038
|
-
iframe.style.opacity = '1';
|
|
1039
|
-
}
|
|
1040
|
-
}`;
|
|
1041
|
-
stopFn = `() => {
|
|
1042
|
-
const region = document.getElementById('region_${regionId}');
|
|
1043
|
-
const iframe = document.getElementById('${iframeId}');
|
|
1044
|
-
if (iframe) {
|
|
1045
|
-
const transOut = ${transOut};
|
|
1046
|
-
if (transOut && window.Transitions) {
|
|
1047
|
-
const regionRect = region.getBoundingClientRect();
|
|
1048
|
-
const animation = window.Transitions.apply(iframe, transOut, false, regionRect.width, regionRect.height);
|
|
1049
|
-
if (animation) {
|
|
1050
|
-
animation.onfinish = () => {
|
|
1051
|
-
iframe.style.display = 'none';
|
|
1052
|
-
};
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
iframe.style.display = 'none';
|
|
1057
|
-
}
|
|
1058
|
-
}`;
|
|
890
|
+
const iframe = this._generateIframeWidgetJS(regionId, media.id, widgetUrl, transIn, transOut);
|
|
891
|
+
startFn = iframe.startFn;
|
|
892
|
+
stopFn = iframe.stopFn;
|
|
1059
893
|
} else {
|
|
1060
894
|
console.warn(`[Layout] Unsupported media type: ${media.type}`);
|
|
1061
895
|
startFn = `() => console.log('Unsupported media type: ${media.type}')`;
|
|
@@ -1070,4 +904,3 @@ ${mediaJS}
|
|
|
1070
904
|
}
|
|
1071
905
|
}
|
|
1072
906
|
|
|
1073
|
-
export const layoutTranslator = new LayoutTranslator();
|
package/src/renderer-lite.js
CHANGED
|
@@ -204,6 +204,10 @@ export class RendererLite {
|
|
|
204
204
|
this.regions = new Map(); // regionId => { element, widgets, currentIndex, timer }
|
|
205
205
|
this.layoutTimer = null;
|
|
206
206
|
this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
|
|
207
|
+
this._paused = false;
|
|
208
|
+
this._layoutTimerStartedAt = null; // Date.now() when layout timer started
|
|
209
|
+
this._layoutTimerDurationMs = null; // Total layout duration in ms
|
|
210
|
+
this._layoutTimerRemaining = null; // ms remaining when paused
|
|
207
211
|
this.widgetTimers = new Map(); // widgetId => timer
|
|
208
212
|
this.mediaUrlCache = new Map(); // fileId => blob URL (for parallel pre-fetching)
|
|
209
213
|
this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
|
|
@@ -555,6 +559,7 @@ export class RendererLite {
|
|
|
555
559
|
this.currentLayout.duration = maxRegionDuration;
|
|
556
560
|
|
|
557
561
|
this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
|
|
562
|
+
this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration);
|
|
558
563
|
|
|
559
564
|
// Reset layout timer with new duration — but only if a timer is already running.
|
|
560
565
|
// If startLayoutTimerWhenReady() hasn't fired yet (still waiting for widgets),
|
|
@@ -1002,48 +1007,15 @@ export class RendererLite {
|
|
|
1002
1007
|
*/
|
|
1003
1008
|
startRegion(regionId) {
|
|
1004
1009
|
const region = this.regions.get(regionId);
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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();
|
|
1010
|
+
this._startRegionCycle(
|
|
1011
|
+
region, regionId,
|
|
1012
|
+
(rid, idx) => this.renderWidget(rid, idx),
|
|
1013
|
+
(rid, idx) => this.stopWidget(rid, idx),
|
|
1014
|
+
() => {
|
|
1015
|
+
this.log.info(`Region ${regionId} completed one full cycle`);
|
|
1016
|
+
this.checkLayoutComplete();
|
|
1017
|
+
}
|
|
1018
|
+
);
|
|
1047
1019
|
}
|
|
1048
1020
|
|
|
1049
1021
|
/**
|
|
@@ -1090,48 +1062,31 @@ export class RendererLite {
|
|
|
1090
1062
|
* @param {Object} widget - Widget config
|
|
1091
1063
|
*/
|
|
1092
1064
|
updateMediaElement(element, widget) {
|
|
1093
|
-
//
|
|
1094
|
-
const
|
|
1095
|
-
if (
|
|
1096
|
-
|
|
1097
|
-
|
|
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;
|
|
1065
|
+
// Restart video or audio on widget show (even if looping)
|
|
1066
|
+
const mediaEl = this.findMediaElement(element, 'VIDEO') || this.findMediaElement(element, 'AUDIO');
|
|
1067
|
+
if (mediaEl) {
|
|
1068
|
+
this._restartMediaElement(mediaEl);
|
|
1069
|
+
this.log.info(`${mediaEl.tagName === 'VIDEO' ? 'Video' : 'Audio'} restarted: ${widget.fileId || widget.id}`);
|
|
1111
1070
|
}
|
|
1071
|
+
}
|
|
1112
1072
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1073
|
+
/**
|
|
1074
|
+
* Restart a media element from the beginning.
|
|
1075
|
+
* Waits for seek to complete before playing — avoids DOMException
|
|
1076
|
+
* "The play() request was interrupted" when calling play() mid-seek.
|
|
1077
|
+
*/
|
|
1078
|
+
_restartMediaElement(el) {
|
|
1079
|
+
el.currentTime = 0;
|
|
1080
|
+
const playAfterSeek = () => {
|
|
1081
|
+
el.removeEventListener('seeked', playAfterSeek);
|
|
1082
|
+
el.play().catch(() => {});
|
|
1083
|
+
};
|
|
1084
|
+
el.addEventListener('seeked', playAfterSeek);
|
|
1085
|
+
// Fallback: if seeked doesn't fire (already at 0), try play directly
|
|
1086
|
+
if (el.currentTime === 0 && el.readyState >= 2) {
|
|
1087
|
+
el.removeEventListener('seeked', playAfterSeek);
|
|
1088
|
+
el.play().catch(() => {});
|
|
1128
1089
|
}
|
|
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
1090
|
}
|
|
1136
1091
|
|
|
1137
1092
|
/**
|
|
@@ -1228,6 +1183,8 @@ export class RendererLite {
|
|
|
1228
1183
|
const layoutDurationMs = layout.duration * 1000;
|
|
1229
1184
|
this.log.info(`Layout ${layoutId} will end after ${layout.duration}s`);
|
|
1230
1185
|
|
|
1186
|
+
this._layoutTimerStartedAt = Date.now();
|
|
1187
|
+
this._layoutTimerDurationMs = layoutDurationMs;
|
|
1231
1188
|
this.layoutTimer = setTimeout(() => {
|
|
1232
1189
|
this.log.info(`Layout ${layoutId} duration expired (${layout.duration}s)`);
|
|
1233
1190
|
if (this.currentLayoutId) {
|
|
@@ -1242,120 +1199,152 @@ export class RendererLite {
|
|
|
1242
1199
|
* @param {string} regionId - Region ID
|
|
1243
1200
|
* @param {number} widgetIndex - Widget index in region
|
|
1244
1201
|
*/
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1202
|
+
/**
|
|
1203
|
+
* Core: show a widget in a region (shared by main layout + overlay)
|
|
1204
|
+
* Returns the widget object on success, null on failure.
|
|
1205
|
+
*/
|
|
1206
|
+
async _showWidget(region, widgetIndex) {
|
|
1249
1207
|
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
|
-
}
|
|
1208
|
+
if (!widget) return null;
|
|
1267
1209
|
|
|
1268
|
-
|
|
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
|
-
}
|
|
1210
|
+
let element = region.widgetElements.get(widget.id);
|
|
1275
1211
|
|
|
1276
|
-
|
|
1277
|
-
this.
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
element.
|
|
1212
|
+
if (!element) {
|
|
1213
|
+
this.log.warn(`Widget ${widget.id} not pre-created, creating now`);
|
|
1214
|
+
element = await this.createWidgetElement(widget, region);
|
|
1215
|
+
region.widgetElements.set(widget.id, element);
|
|
1216
|
+
region.element.appendChild(element);
|
|
1217
|
+
}
|
|
1281
1218
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1219
|
+
// Hide all other widgets in region
|
|
1220
|
+
for (const [widgetId, widgetEl] of region.widgetElements) {
|
|
1221
|
+
if (widgetId !== widget.id) {
|
|
1222
|
+
widgetEl.style.visibility = 'hidden';
|
|
1223
|
+
widgetEl.style.opacity = '0';
|
|
1287
1224
|
}
|
|
1225
|
+
}
|
|
1288
1226
|
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
});
|
|
1227
|
+
this.updateMediaElement(element, widget);
|
|
1228
|
+
element.style.visibility = 'visible';
|
|
1298
1229
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1230
|
+
if (widget.transitions.in) {
|
|
1231
|
+
Transitions.apply(element, widget.transitions.in, true, region.width, region.height);
|
|
1232
|
+
} else {
|
|
1233
|
+
element.style.opacity = '1';
|
|
1302
1234
|
}
|
|
1235
|
+
|
|
1236
|
+
return widget;
|
|
1303
1237
|
}
|
|
1304
1238
|
|
|
1305
1239
|
/**
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1240
|
+
* Core: hide a widget in a region (shared by main layout + overlay).
|
|
1241
|
+
* Returns { widget, animPromise } synchronously — callers await animPromise if needed.
|
|
1242
|
+
* NOT async, so callers that don't need the animation stay on the same microtask.
|
|
1309
1243
|
*/
|
|
1310
|
-
|
|
1311
|
-
const region = this.regions.get(regionId);
|
|
1312
|
-
if (!region) return;
|
|
1313
|
-
|
|
1244
|
+
_hideWidget(region, widgetIndex) {
|
|
1314
1245
|
const widget = region.widgets[widgetIndex];
|
|
1315
|
-
if (!widget) return;
|
|
1246
|
+
if (!widget) return { widget: null, animPromise: null };
|
|
1316
1247
|
|
|
1317
|
-
// Get widget element from reuse cache
|
|
1318
1248
|
const widgetElement = region.widgetElements.get(widget.id);
|
|
1319
|
-
if (!widgetElement) return;
|
|
1249
|
+
if (!widgetElement) return { widget: null, animPromise: null };
|
|
1320
1250
|
|
|
1321
|
-
|
|
1251
|
+
let animPromise = null;
|
|
1322
1252
|
if (widget.transitions.out) {
|
|
1323
1253
|
const animation = Transitions.apply(
|
|
1324
|
-
widgetElement,
|
|
1325
|
-
widget.transitions.out,
|
|
1326
|
-
false,
|
|
1327
|
-
region.width,
|
|
1328
|
-
region.height
|
|
1254
|
+
widgetElement, widget.transitions.out, false, region.width, region.height
|
|
1329
1255
|
);
|
|
1330
|
-
|
|
1331
1256
|
if (animation) {
|
|
1332
|
-
|
|
1333
|
-
animation.onfinish = resolve;
|
|
1334
|
-
});
|
|
1257
|
+
animPromise = new Promise(resolve => { animation.onfinish = resolve; });
|
|
1335
1258
|
}
|
|
1336
1259
|
}
|
|
1337
1260
|
|
|
1338
|
-
// Pause media elements (but DON'T revoke URLs - element will be reused!)
|
|
1339
1261
|
const videoEl = widgetElement.querySelector('video');
|
|
1340
|
-
if (videoEl && widget.options.loop !== '1')
|
|
1341
|
-
videoEl.pause();
|
|
1342
|
-
// Keep src intact for next cycle
|
|
1343
|
-
}
|
|
1262
|
+
if (videoEl && widget.options.loop !== '1') videoEl.pause();
|
|
1344
1263
|
|
|
1345
1264
|
const audioEl = widgetElement.querySelector('audio');
|
|
1346
|
-
if (audioEl && widget.options.loop !== '1')
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1265
|
+
if (audioEl && widget.options.loop !== '1') audioEl.pause();
|
|
1266
|
+
|
|
1267
|
+
return { widget, animPromise };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Core: cycle through widgets in a region (shared by main layout + overlay)
|
|
1272
|
+
* @param {Object} region - Region state object
|
|
1273
|
+
* @param {string} regionId - Region ID
|
|
1274
|
+
* @param {Function} showFn - (regionId, widgetIndex) => show widget
|
|
1275
|
+
* @param {Function} hideFn - (regionId, widgetIndex) => hide widget
|
|
1276
|
+
* @param {Function} [onCycleComplete] - Called when region completes one full cycle
|
|
1277
|
+
*/
|
|
1278
|
+
_startRegionCycle(region, regionId, showFn, hideFn, onCycleComplete) {
|
|
1279
|
+
if (!region || region.widgets.length === 0) return;
|
|
1280
|
+
|
|
1281
|
+
if (region.widgets.length === 1) {
|
|
1282
|
+
showFn(regionId, 0);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const playNext = () => {
|
|
1287
|
+
const widgetIndex = region.currentIndex;
|
|
1288
|
+
const widget = region.widgets[widgetIndex];
|
|
1289
|
+
|
|
1290
|
+
showFn(regionId, widgetIndex);
|
|
1291
|
+
|
|
1292
|
+
const duration = widget.duration * 1000;
|
|
1293
|
+
region.timer = setTimeout(() => {
|
|
1294
|
+
hideFn(regionId, widgetIndex);
|
|
1295
|
+
|
|
1296
|
+
const nextIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
1297
|
+
if (nextIndex === 0 && !region.complete) {
|
|
1298
|
+
region.complete = true;
|
|
1299
|
+
onCycleComplete?.();
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
region.currentIndex = nextIndex;
|
|
1303
|
+
playNext();
|
|
1304
|
+
}, duration);
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
playNext();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async renderWidget(regionId, widgetIndex) {
|
|
1311
|
+
const region = this.regions.get(regionId);
|
|
1312
|
+
if (!region) return;
|
|
1313
|
+
|
|
1314
|
+
try {
|
|
1315
|
+
const widget = await this._showWidget(region, widgetIndex);
|
|
1316
|
+
if (widget) {
|
|
1317
|
+
this.log.info(`Showing widget ${widget.type} (${widget.id}) in region ${regionId}`);
|
|
1318
|
+
this.emit('widgetStart', {
|
|
1319
|
+
widgetId: widget.id, regionId, layoutId: this.currentLayoutId,
|
|
1320
|
+
mediaId: parseInt(widget.fileId || widget.id) || null,
|
|
1321
|
+
type: widget.type, duration: widget.duration
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
this.log.error(`Error rendering widget:`, error);
|
|
1326
|
+
this.emit('error', { type: 'widgetError', error, widgetId: region.widgets[widgetIndex]?.id, regionId });
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Stop a widget (with element reuse - don't revoke blob URLs!)
|
|
1332
|
+
* @param {string} regionId - Region ID
|
|
1333
|
+
* @param {number} widgetIndex - Widget index
|
|
1334
|
+
*/
|
|
1335
|
+
async stopWidget(regionId, widgetIndex) {
|
|
1336
|
+
const region = this.regions.get(regionId);
|
|
1337
|
+
if (!region) return;
|
|
1338
|
+
|
|
1339
|
+
const { widget, animPromise } = this._hideWidget(region, widgetIndex);
|
|
1340
|
+
if (animPromise) await animPromise;
|
|
1341
|
+
if (widget) {
|
|
1342
|
+
this.emit('widgetEnd', {
|
|
1343
|
+
widgetId: widget.id, regionId, layoutId: this.currentLayoutId,
|
|
1344
|
+
mediaId: parseInt(widget.fileId || widget.id) || null,
|
|
1345
|
+
type: widget.type
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1359
1348
|
}
|
|
1360
1349
|
|
|
1361
1350
|
/**
|
|
@@ -2378,44 +2367,12 @@ export class RendererLite {
|
|
|
2378
2367
|
if (!overlayState) return;
|
|
2379
2368
|
|
|
2380
2369
|
const region = overlayState.regions.get(regionId);
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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();
|
|
2370
|
+
this._startRegionCycle(
|
|
2371
|
+
region, regionId,
|
|
2372
|
+
(rid, idx) => this.renderOverlayWidget(overlayId, rid, idx),
|
|
2373
|
+
(rid, idx) => this.stopOverlayWidget(overlayId, rid, idx),
|
|
2374
|
+
() => this.log.info(`Overlay ${overlayId} region ${regionId} completed one full cycle`)
|
|
2375
|
+
);
|
|
2419
2376
|
}
|
|
2420
2377
|
|
|
2421
2378
|
/**
|
|
@@ -2431,55 +2388,18 @@ export class RendererLite {
|
|
|
2431
2388
|
const region = overlayState.regions.get(regionId);
|
|
2432
2389
|
if (!region) return;
|
|
2433
2390
|
|
|
2434
|
-
const widget = region.widgets[widgetIndex];
|
|
2435
|
-
if (!widget) return;
|
|
2436
|
-
|
|
2437
2391
|
try {
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
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';
|
|
2392
|
+
const widget = await this._showWidget(region, widgetIndex);
|
|
2393
|
+
if (widget) {
|
|
2394
|
+
this.log.info(`Showing overlay widget ${widget.type} (${widget.id}) in overlay ${overlayId} region ${regionId}`);
|
|
2395
|
+
this.emit('overlayWidgetStart', {
|
|
2396
|
+
overlayId, widgetId: widget.id, regionId,
|
|
2397
|
+
type: widget.type, duration: widget.duration
|
|
2398
|
+
});
|
|
2469
2399
|
}
|
|
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
2400
|
} catch (error) {
|
|
2481
2401
|
this.log.error(`Error rendering overlay widget:`, error);
|
|
2482
|
-
this.emit('error', { type: 'overlayWidgetError', error, widgetId:
|
|
2402
|
+
this.emit('error', { type: 'overlayWidgetError', error, widgetId: region.widgets[widgetIndex]?.id, regionId, overlayId });
|
|
2483
2403
|
}
|
|
2484
2404
|
}
|
|
2485
2405
|
|
|
@@ -2496,47 +2416,13 @@ export class RendererLite {
|
|
|
2496
2416
|
const region = overlayState.regions.get(regionId);
|
|
2497
2417
|
if (!region) return;
|
|
2498
2418
|
|
|
2499
|
-
const widget = region
|
|
2500
|
-
if (
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
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();
|
|
2419
|
+
const { widget, animPromise } = this._hideWidget(region, widgetIndex);
|
|
2420
|
+
if (animPromise) await animPromise;
|
|
2421
|
+
if (widget) {
|
|
2422
|
+
this.emit('overlayWidgetEnd', {
|
|
2423
|
+
overlayId, widgetId: widget.id, regionId, type: widget.type
|
|
2424
|
+
});
|
|
2531
2425
|
}
|
|
2532
|
-
|
|
2533
|
-
// Emit widget end event
|
|
2534
|
-
this.emit('overlayWidgetEnd', {
|
|
2535
|
-
overlayId,
|
|
2536
|
-
widgetId: widget.id,
|
|
2537
|
-
regionId,
|
|
2538
|
-
type: widget.type
|
|
2539
|
-
});
|
|
2540
2426
|
}
|
|
2541
2427
|
|
|
2542
2428
|
/**
|
|
@@ -2607,6 +2493,80 @@ export class RendererLite {
|
|
|
2607
2493
|
return Array.from(this.activeOverlays.keys());
|
|
2608
2494
|
}
|
|
2609
2495
|
|
|
2496
|
+
/**
|
|
2497
|
+
* Pause playback: stop layout timer, pause all media, stop widget cycling.
|
|
2498
|
+
* The layout timer's remaining time is saved so resume() can restart it.
|
|
2499
|
+
*/
|
|
2500
|
+
pause() {
|
|
2501
|
+
if (this._paused) return;
|
|
2502
|
+
this._paused = true;
|
|
2503
|
+
|
|
2504
|
+
// Save remaining layout time
|
|
2505
|
+
if (this.layoutTimer && this._layoutTimerStartedAt) {
|
|
2506
|
+
const elapsed = Date.now() - this._layoutTimerStartedAt;
|
|
2507
|
+
this._layoutTimerRemaining = Math.max(0, this._layoutTimerDurationMs - elapsed);
|
|
2508
|
+
clearTimeout(this.layoutTimer);
|
|
2509
|
+
this.layoutTimer = null;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// Stop all region widget-cycling timers
|
|
2513
|
+
for (const [, region] of this.regions) {
|
|
2514
|
+
if (region.timer) {
|
|
2515
|
+
clearTimeout(region.timer);
|
|
2516
|
+
region.timer = null;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// Pause all video/audio elements
|
|
2521
|
+
this._forEachMedia(el => el.pause());
|
|
2522
|
+
|
|
2523
|
+
this.emit('paused');
|
|
2524
|
+
this.log.info('Playback paused');
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Resume playback: restart layout timer with remaining time, resume media and widget cycling.
|
|
2529
|
+
*/
|
|
2530
|
+
resume() {
|
|
2531
|
+
if (!this._paused) return;
|
|
2532
|
+
this._paused = false;
|
|
2533
|
+
|
|
2534
|
+
// Resume layout timer with remaining time
|
|
2535
|
+
if (this._layoutTimerRemaining != null && this._layoutTimerRemaining > 0) {
|
|
2536
|
+
this._layoutTimerStartedAt = Date.now();
|
|
2537
|
+
this._layoutTimerDurationMs = this._layoutTimerRemaining;
|
|
2538
|
+
const layoutId = this.currentLayoutId;
|
|
2539
|
+
this.layoutTimer = setTimeout(() => {
|
|
2540
|
+
this.log.info(`Layout ${layoutId} duration expired (resumed)`);
|
|
2541
|
+
if (this.currentLayoutId) {
|
|
2542
|
+
this.layoutEndEmitted = true;
|
|
2543
|
+
this.emit('layoutEnd', this.currentLayoutId);
|
|
2544
|
+
}
|
|
2545
|
+
}, this._layoutTimerRemaining);
|
|
2546
|
+
this._layoutTimerRemaining = null;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
// Resume all video/audio
|
|
2550
|
+
this._forEachMedia(el => el.play().catch(() => {}));
|
|
2551
|
+
|
|
2552
|
+
// Restart region widget cycling (re-enters cycle from current widget)
|
|
2553
|
+
for (const [regionId] of this.regions) {
|
|
2554
|
+
this.startRegion(regionId);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
this.emit('resumed');
|
|
2558
|
+
this.log.info('Playback resumed');
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
/**
|
|
2562
|
+
* Apply a function to every video/audio element in all regions.
|
|
2563
|
+
*/
|
|
2564
|
+
_forEachMedia(fn) {
|
|
2565
|
+
for (const [, region] of this.regions) {
|
|
2566
|
+
region.element?.querySelectorAll('video, audio').forEach(fn);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2610
2570
|
/**
|
|
2611
2571
|
* Cleanup renderer
|
|
2612
2572
|
*/
|
package/docs/README.md
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# @xiboplayer/renderer Documentation
|
|
2
|
-
|
|
3
|
-
**RendererLite: Fast, efficient layout rendering engine.**
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
The `@xiboplayer/renderer` package provides:
|
|
8
|
-
|
|
9
|
-
- **RendererLite** - Lightweight XLF layout renderer
|
|
10
|
-
- **Layout parser** - XLF to JSON translation
|
|
11
|
-
- **Widget system** - Extensible widget rendering
|
|
12
|
-
- **Transition engine** - Smooth layout transitions
|
|
13
|
-
- **Element reuse** - Performance optimization (50% memory reduction)
|
|
14
|
-
|
|
15
|
-
## Installation
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npm install @xiboplayer/renderer
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Usage
|
|
22
|
-
|
|
23
|
-
```javascript
|
|
24
|
-
import { RendererLite } from '@xiboplayer/renderer';
|
|
25
|
-
|
|
26
|
-
const renderer = new RendererLite({
|
|
27
|
-
container: document.getElementById('player'),
|
|
28
|
-
cacheManager: cache
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
await renderer.loadLayout(xlf);
|
|
32
|
-
renderer.start();
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Features
|
|
36
|
-
|
|
37
|
-
### Element Reuse Pattern
|
|
38
|
-
|
|
39
|
-
Pre-creates all widget elements at layout load, toggles visibility instead of recreating DOM:
|
|
40
|
-
|
|
41
|
-
- **50% memory reduction** over 10 cycles
|
|
42
|
-
- **10x faster** layout replay (<0.5s vs 2-3s)
|
|
43
|
-
- Zero GC pressure from DOM churn
|
|
44
|
-
|
|
45
|
-
### Parallel Media Pre-fetch
|
|
46
|
-
|
|
47
|
-
Fetches all media URLs upfront in parallel, enabling instant widget rendering.
|
|
48
|
-
|
|
49
|
-
### Dynamic Video Duration
|
|
50
|
-
|
|
51
|
-
Respects `useDuration` flag from XLF, uses video metadata when duration should be dynamic.
|
|
52
|
-
|
|
53
|
-
## API Reference
|
|
54
|
-
|
|
55
|
-
### RendererLite
|
|
56
|
-
|
|
57
|
-
```javascript
|
|
58
|
-
class RendererLite {
|
|
59
|
-
constructor(options)
|
|
60
|
-
async loadLayout(xlf)
|
|
61
|
-
start()
|
|
62
|
-
stop()
|
|
63
|
-
pause()
|
|
64
|
-
resume()
|
|
65
|
-
on(event, callback)
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Events
|
|
70
|
-
|
|
71
|
-
- `layout:loaded` - Layout parsed and ready
|
|
72
|
-
- `layout:start` - Layout playback started
|
|
73
|
-
- `layout:end` - Layout completed
|
|
74
|
-
- `region:start` - Region playback started
|
|
75
|
-
- `widget:start` - Widget started
|
|
76
|
-
|
|
77
|
-
## Performance
|
|
78
|
-
|
|
79
|
-
| Metric | XLR | Arexibo | RendererLite |
|
|
80
|
-
|--------|-----|---------|--------------|
|
|
81
|
-
| Initial load | 17-20s | 12-15s | **3-5s** |
|
|
82
|
-
| Layout replay | 2-3s | <1s | **<0.5s** |
|
|
83
|
-
| Memory (10 cycles) | +500MB | Stable | **Stable** |
|
|
84
|
-
|
|
85
|
-
## Dependencies
|
|
86
|
-
|
|
87
|
-
- `@xiboplayer/utils` - Logger, EventEmitter
|
|
88
|
-
- `pdfjs-dist` - PDF rendering
|
|
89
|
-
|
|
90
|
-
## Related Packages
|
|
91
|
-
|
|
92
|
-
- [@xiboplayer/core](../../core/docs/) - Player orchestration
|
|
93
|
-
- [@xiboplayer/cache](../../cache/docs/) - Media caching
|
|
94
|
-
|
|
95
|
-
---
|
|
96
|
-
|
|
97
|
-
**Package Version**: 1.0.0
|
|
98
|
-
**Last Updated**: 2026-02-10
|