@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 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.1.3",
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.1.3"
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 { parseLayout, translateXLF } from './layout.js';
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
- // Use cache URL pattern for text/ticker widgets - must be in /player/ scope for SW
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 iframeId = `widget_${regionId}_${media.id}`;
623
- startFn = `() => {
624
- const region = document.getElementById('region_${regionId}');
625
- let iframe = document.getElementById('${iframeId}');
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
- break;
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 iframeId = `widget_${regionId}_${media.id}`;
1011
- startFn = `() => {
1012
- const region = document.getElementById('region_${regionId}');
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();
@@ -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
- 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();
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
- // 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;
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
- // 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;
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
- async renderWidget(regionId, widgetIndex) {
1246
- const region = this.regions.get(regionId);
1247
- if (!region) return;
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
- // 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
- }
1210
+ let element = region.widgetElements.get(widget.id);
1275
1211
 
1276
- // Update media element if needed (restart videos)
1277
- this.updateMediaElement(element, widget);
1278
-
1279
- // Show this widget
1280
- element.style.visibility = 'visible';
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
- // 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';
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
- // 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
- });
1227
+ this.updateMediaElement(element, widget);
1228
+ element.style.visibility = 'visible';
1298
1229
 
1299
- } catch (error) {
1300
- this.log.error(`Error rendering widget:`, error);
1301
- this.emit('error', { type: 'widgetError', error, widgetId: widget.id, regionId });
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
- * Stop a widget (with element reuse - don't revoke blob URLs!)
1307
- * @param {string} regionId - Region ID
1308
- * @param {number} widgetIndex - Widget index
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
- async stopWidget(regionId, widgetIndex) {
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
- // Apply out transition
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
- await new Promise(resolve => {
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
- 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
- });
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
- 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();
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
- 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';
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: widget.id, regionId, overlayId });
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.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();
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