@xiboplayer/renderer 0.4.0 → 0.4.3

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 CHANGED
@@ -12,6 +12,8 @@ RendererLite parses Xibo Layout Format (XLF) files and builds a live DOM with:
12
12
  - **Layout preloading** — 2-layout pool pre-builds upcoming layouts at 75% of current duration for zero-gap transitions
13
13
  - **Proportional scaling** — ResizeObserver-based scaling to fit any screen resolution
14
14
  - **Overlay support** — multiple simultaneous overlay layouts with independent z-index (1000+)
15
+ - **Absolute widget positioning** — widget elements use `position: absolute` within regions to layer correctly in multi-widget regions
16
+ - **Animation cleanup** — `fill: forwards` animations cancelled between widgets to prevent stale visual state (e.g. video hidden after PDF)
15
17
 
16
18
  ## Installation
17
19
 
@@ -37,7 +39,7 @@ await renderer.renderLayout(xlf, { mediaBaseUrl: '/cache/' });
37
39
  | Widget | Implementation |
38
40
  |--------|---------------|
39
41
  | Video | `<video>` with native HLS (Safari) + hls.js fallback, pause-on-last-frame |
40
- | Image | `<img>` with objectFit contain, blob URL from cache |
42
+ | Image | `<img>` with CMS scaleType mapping (center->contain, stretch->fill, fit->cover), blob URL from cache |
41
43
  | PDF | PDF.js canvas rendering (dynamically imported) |
42
44
  | Text / Ticker | iframe with CMS-rendered HTML via GetResource |
43
45
  | Web page | bare `<iframe src="...">` |
@@ -26,6 +26,8 @@
26
26
  | Visibility toggle | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
27
27
  | Avoid DOM recreation | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
28
28
  | Layout reuse detection | ⚠️ Partial | ✅ Yes | ✅ Yes | ✅ Better than XLR! |
29
+ | Widget absolute positioning | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
30
+ | Image scaleType mapping | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete (center->contain, stretch->fill, fit->cover) |
29
31
  | **Widget Types** | | | | |
30
32
  | Image | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
31
33
  | Video | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
@@ -206,20 +208,15 @@ RendererLite events match XLR/Arexibo with additions:
206
208
 
207
209
  ### 7. Memory Management
208
210
 
209
- **Status**: ⚠️ **Good with Minor Gap**
211
+ **Status**: **Complete**
210
212
 
211
213
  **What's correct**:
212
214
  - ✅ Elements reused (not recreated)
213
- - ✅ Blob URLs revoked on layout change
215
+ - ✅ Blob URLs revoked on layout change (layout-scoped tracking)
214
216
  - ✅ Cache cleared appropriately
215
217
  - ✅ Timers cleared before new layout
216
218
  - ✅ Event listeners managed properly
217
-
218
- **Gap identified**:
219
- - ⚠️ Layout-scoped blob URL tracking missing
220
- - ⚠️ Could accumulate blob URLs across many layout cycles
221
-
222
- **Impact**: Low (only affects 24/7 deployments with frequent layout changes)
219
+ - ✅ `fill: forwards` animations cancelled between widgets to prevent stale visual state
223
220
 
224
221
  ---
225
222
 
@@ -231,12 +228,7 @@ RendererLite events match XLR/Arexibo with additions:
231
228
 
232
229
  ### Important Features (Should Have)
233
230
 
234
- 1. **Blob URL lifecycle tracking**
235
- - **Priority**: Medium
236
- - **Impact**: Memory leak in long-running deployments
237
- - **Effort**: Low (add Map tracking)
238
-
239
- 2. **Widget action events**
231
+ 1. **Widget action events**
240
232
  - **Priority**: Low
241
233
  - **Impact**: Interactive widgets might need action callbacks
242
234
  - **Effort**: Medium (event propagation from widget iframes)
@@ -417,9 +409,8 @@ XLF → Parse → Pre-create Elements → Toggle Visibility → Transitions
417
409
 
418
410
  ### ⚠️ Features Needing Work
419
411
 
420
- 1. **Blob URL Lifecycle**: Needs layout-scoped tracking
421
- 2. **Widget Actions**: Event propagation from iframes
422
- 3. **Service Worker**: Currently disabled (HTTP 202 issues)
412
+ 1. **Widget Actions**: Event propagation from iframes
413
+ 2. **Service Worker**: Currently disabled (HTTP 202 issues)
423
414
 
424
415
  ### ❌ Features Not Applicable
425
416
 
@@ -472,7 +463,7 @@ XLF → Parse → Pre-create Elements → Toggle Visibility → Transitions
472
463
 
473
464
  **RendererLite successfully implements the Arexibo pattern** and adds significant performance improvements through parallelization. The implementation is production-ready with minor improvements needed for blob URL lifecycle management.
474
465
 
475
- **Feature Parity**: ~95% (missing only blob URL tracking and widget actions)
466
+ **Feature Parity**: ~98% (missing only widget action event propagation)
476
467
  **Performance**: Exceeds XLR and Arexibo benchmarks
477
468
  **Memory**: Stable with Arexibo pattern correctly implemented
478
469
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,8 +12,8 @@
12
12
  "dependencies": {
13
13
  "nanoevents": "^9.1.0",
14
14
  "pdfjs-dist": "^4.10.38",
15
- "@xiboplayer/cache": "0.4.0",
16
- "@xiboplayer/utils": "0.4.0"
15
+ "@xiboplayer/cache": "0.4.3",
16
+ "@xiboplayer/utils": "0.4.3"
17
17
  },
18
18
  "devDependencies": {
19
19
  "vitest": "^2.0.0",
@@ -1098,6 +1098,11 @@ export class RendererLite {
1098
1098
 
1099
1099
  try {
1100
1100
  const element = await this.createWidgetElement(widget, region);
1101
+ element.style.position = 'absolute';
1102
+ element.style.top = '0';
1103
+ element.style.left = '0';
1104
+ element.style.width = '100%';
1105
+ element.style.height = '100%';
1101
1106
  element.style.visibility = 'hidden'; // Hidden by default
1102
1107
  element.style.opacity = '0';
1103
1108
  region.element.appendChild(element);
@@ -1207,8 +1212,9 @@ export class RendererLite {
1207
1212
  * @returns {Promise<HTMLElement>} Widget DOM element
1208
1213
  */
1209
1214
  async createWidgetElement(widget, region) {
1210
- // render="html" forces GetResource iframe regardless of native type
1211
- if (widget.render === 'html') {
1215
+ // render="html" forces GetResource iframe regardless of native type,
1216
+ // EXCEPT for types we handle natively (PDF: CMS bundle can't work cross-origin)
1217
+ if (widget.render === 'html' && widget.type !== 'pdf') {
1212
1218
  return await this.renderGenericWidget(widget, region);
1213
1219
  }
1214
1220
 
@@ -1228,6 +1234,8 @@ export class RendererLite {
1228
1234
  return await this.renderWebpage(widget, region);
1229
1235
  case 'localvideo':
1230
1236
  return await this.renderVideo(widget, region);
1237
+ case 'videoin':
1238
+ return await this.renderVideoIn(widget, region);
1231
1239
  case 'powerpoint':
1232
1240
  case 'flash':
1233
1241
  // Legacy Windows-only types — show placeholder instead of failing silently
@@ -1259,6 +1267,18 @@ export class RendererLite {
1259
1267
  // Restart video or audio on widget show (even if looping)
1260
1268
  const mediaEl = this.findMediaElement(element, 'VIDEO') || this.findMediaElement(element, 'AUDIO');
1261
1269
  if (mediaEl) {
1270
+ // Re-acquire webcam stream if it was stopped during _hideWidget()
1271
+ if (mediaEl.tagName === 'VIDEO' && mediaEl._mediaConstraints && !mediaEl._mediaStream) {
1272
+ navigator.mediaDevices.getUserMedia(mediaEl._mediaConstraints).then(stream => {
1273
+ mediaEl.srcObject = stream;
1274
+ mediaEl._mediaStream = stream;
1275
+ this.log.info(`Webcam stream re-acquired for widget ${widget.id}`);
1276
+ }).catch(e => {
1277
+ this.log.warn('Failed to re-acquire webcam stream:', e.message);
1278
+ });
1279
+ return; // srcObject auto-plays, no need for _restartMediaElement
1280
+ }
1281
+
1262
1282
  this._restartMediaElement(mediaEl);
1263
1283
  this.log.info(`${mediaEl.tagName === 'VIDEO' ? 'Video' : 'Audio'} restarted: ${widget.fileId || widget.id}`);
1264
1284
  }
@@ -1427,19 +1447,27 @@ export class RendererLite {
1427
1447
  if (!element) {
1428
1448
  this.log.warn(`Widget ${widget.id} not pre-created, creating now`);
1429
1449
  element = await this.createWidgetElement(widget, region);
1450
+ element.style.position = 'absolute';
1451
+ element.style.top = '0';
1452
+ element.style.left = '0';
1453
+ element.style.width = '100%';
1454
+ element.style.height = '100%';
1430
1455
  region.widgetElements.set(widget.id, element);
1431
1456
  region.element.appendChild(element);
1432
1457
  }
1433
1458
 
1434
1459
  // Hide all other widgets in region
1460
+ // Cancel fill:forwards animations first — they override inline styles
1435
1461
  for (const [widgetId, widgetEl] of region.widgetElements) {
1436
1462
  if (widgetId !== widget.id) {
1463
+ widgetEl.getAnimations?.().forEach(a => a.cancel());
1437
1464
  widgetEl.style.visibility = 'hidden';
1438
1465
  widgetEl.style.opacity = '0';
1439
1466
  }
1440
1467
  }
1441
1468
 
1442
1469
  this.updateMediaElement(element, widget);
1470
+ element.getAnimations?.().forEach(a => a.cancel());
1443
1471
  element.style.visibility = 'visible';
1444
1472
 
1445
1473
  if (widget.transitions.in) {
@@ -1553,6 +1581,13 @@ export class RendererLite {
1553
1581
  const videoEl = widgetElement.querySelector('video');
1554
1582
  if (videoEl && widget.options.loop !== '1') videoEl.pause();
1555
1583
 
1584
+ // Stop MediaStream tracks (webcam/mic) to release the device
1585
+ if (videoEl?._mediaStream) {
1586
+ videoEl._mediaStream.getTracks().forEach(t => t.stop());
1587
+ videoEl._mediaStream = null;
1588
+ videoEl.srcObject = null;
1589
+ }
1590
+
1556
1591
  const audioEl = widgetElement.querySelector('audio');
1557
1592
  if (audioEl && widget.options.loop !== '1') audioEl.pause();
1558
1593
 
@@ -1794,16 +1829,20 @@ export class RendererLite {
1794
1829
  img.className = 'renderer-lite-widget';
1795
1830
  img.style.width = '100%';
1796
1831
  img.style.height = '100%';
1797
- // Scale type: stretch → fill, center → none (natural size), default → contain
1832
+ // Scale type mapping (CMS image.xml):
1833
+ // center (default) → contain: scale proportionally to fit region, centered
1834
+ // stretch → fill: ignore aspect ratio, fill entire region
1835
+ // fit → cover: scale proportionally to fill region, crop excess
1798
1836
  const scaleType = widget.options.scaleType;
1799
- const fitMap = { stretch: 'fill', center: 'none', fit: 'contain' };
1837
+ const fitMap = { stretch: 'fill', center: 'contain', fit: 'cover' };
1800
1838
  img.style.objectFit = fitMap[scaleType] || 'contain';
1801
1839
 
1802
- // Alignment: map align/valign to CSS object-position
1840
+ // Alignment: map alignId/valignId to CSS object-position
1841
+ // XLF tags are <alignId> and <valignId> (from CMS image.xml property ids)
1803
1842
  const alignMap = { left: 'left', center: 'center', right: 'right' };
1804
1843
  const valignMap = { top: 'top', middle: 'center', bottom: 'bottom' };
1805
- const hPos = alignMap[widget.options.align] || 'center';
1806
- const vPos = valignMap[widget.options.valign] || 'center';
1844
+ const hPos = alignMap[widget.options.alignId] || 'center';
1845
+ const vPos = valignMap[widget.options.valignId] || 'center';
1807
1846
  img.style.objectPosition = `${hPos} ${vPos}`;
1808
1847
 
1809
1848
  img.style.opacity = '0';
@@ -1943,6 +1982,65 @@ export class RendererLite {
1943
1982
  return video;
1944
1983
  }
1945
1984
 
1985
+ /**
1986
+ * Render videoin (webcam/microphone) widget.
1987
+ * Uses getUserMedia() to capture live video from camera hardware.
1988
+ * @param {Object} widget - Widget config with options (sourceId, showFullScreen, mirror, mute, captureAudio)
1989
+ * @param {Object} region - Region dimensions (width, height)
1990
+ * @returns {HTMLVideoElement}
1991
+ */
1992
+ async renderVideoIn(widget, region) {
1993
+ const video = document.createElement('video');
1994
+ video.className = 'renderer-lite-widget';
1995
+ video.style.width = '100%';
1996
+ video.style.height = '100%';
1997
+ video.style.objectFit = widget.options.showFullScreen === '1' ? 'cover' : 'contain';
1998
+ video.autoplay = true;
1999
+ video.playsInline = true;
2000
+ video.controls = false;
2001
+ video.muted = widget.options.mute !== '0'; // Muted by default to prevent audio feedback
2002
+
2003
+ // Mirror mode (front-facing camera)
2004
+ if (widget.options.mirror === '1') {
2005
+ video.style.transform = 'scaleX(-1)';
2006
+ }
2007
+
2008
+ // Build getUserMedia constraints
2009
+ const videoConstraints = {
2010
+ width: { ideal: region.width },
2011
+ height: { ideal: region.height },
2012
+ };
2013
+ const deviceId = widget.options.sourceId || widget.options.deviceId;
2014
+ if (deviceId) {
2015
+ videoConstraints.deviceId = { exact: deviceId };
2016
+ } else {
2017
+ videoConstraints.facingMode = widget.options.facingMode || 'environment';
2018
+ }
2019
+
2020
+ const constraints = {
2021
+ video: videoConstraints,
2022
+ audio: widget.options.captureAudio === '1',
2023
+ };
2024
+
2025
+ // Store constraints for re-acquisition after layout transitions
2026
+ video._mediaConstraints = constraints;
2027
+
2028
+ try {
2029
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
2030
+ video.srcObject = stream;
2031
+ video._mediaStream = stream;
2032
+ this.log.info(`Webcam stream acquired for widget ${widget.id} (tracks: ${stream.getTracks().length})`);
2033
+ } catch (e) {
2034
+ this.log.warn(`getUserMedia failed for widget ${widget.id}: ${e.message}`);
2035
+ return this._renderUnsupportedPlaceholder(
2036
+ { ...widget, type: 'Camera unavailable' },
2037
+ region
2038
+ );
2039
+ }
2040
+
2041
+ return video;
2042
+ }
2043
+
1946
2044
  /**
1947
2045
  * Render audio widget
1948
2046
  */
@@ -2110,7 +2208,9 @@ export class RendererLite {
2110
2208
  try {
2111
2209
  const pdfjsModule = await import('pdfjs-dist');
2112
2210
  window.pdfjsLib = pdfjsModule;
2113
- window.pdfjsLib.GlobalWorkerOptions.workerSrc = `${window.location.origin}/player/pdf.worker.min.mjs`;
2211
+ // Derive worker path from current page location (works for /player/pwa/ and /player/)
2212
+ const basePath = window.location.pathname.replace(/\/[^/]*$/, '/');
2213
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc = `${window.location.origin}${basePath}pdf.worker.min.mjs`;
2114
2214
  } catch (error) {
2115
2215
  this.log.error('PDF.js not available:', error);
2116
2216
  container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">PDF viewer unavailable</div>';
@@ -2454,6 +2554,11 @@ export class RendererLite {
2454
2554
 
2455
2555
  try {
2456
2556
  const element = await this.createWidgetElement(widget, region);
2557
+ element.style.position = 'absolute';
2558
+ element.style.top = '0';
2559
+ element.style.left = '0';
2560
+ element.style.width = '100%';
2561
+ element.style.height = '100%';
2457
2562
  element.style.visibility = 'hidden';
2458
2563
  element.style.opacity = '0';
2459
2564
  region.element.appendChild(element);
@@ -451,7 +451,7 @@ describe('RendererLite', () => {
451
451
  expect(element.style.objectFit).toBe('fill');
452
452
  });
453
453
 
454
- it('should apply objectFit none when scaleType is center (natural size)', async () => {
454
+ it('should apply objectFit contain when scaleType is center (proportional fit)', async () => {
455
455
  const widget = {
456
456
  type: 'image',
457
457
  id: 'm1',
@@ -464,10 +464,10 @@ describe('RendererLite', () => {
464
464
  const region = { width: 1920, height: 1080 };
465
465
  const element = await renderer.renderImage(widget, region);
466
466
 
467
- expect(element.style.objectFit).toBe('none');
467
+ expect(element.style.objectFit).toBe('contain');
468
468
  });
469
469
 
470
- it('should apply objectFit contain when scaleType is fit', async () => {
470
+ it('should apply objectFit cover when scaleType is fit', async () => {
471
471
  const widget = {
472
472
  type: 'image',
473
473
  id: 'm1',
@@ -480,15 +480,15 @@ describe('RendererLite', () => {
480
480
  const region = { width: 1920, height: 1080 };
481
481
  const element = await renderer.renderImage(widget, region);
482
482
 
483
- expect(element.style.objectFit).toBe('contain');
483
+ expect(element.style.objectFit).toBe('cover');
484
484
  });
485
485
 
486
- it('should map align and valign to objectPosition', async () => {
486
+ it('should map alignId and valignId to objectPosition', async () => {
487
487
  const widget = {
488
488
  type: 'image',
489
489
  id: 'm1',
490
490
  fileId: '1',
491
- options: { uri: 'test.png', align: 'left', valign: 'top' },
491
+ options: { uri: 'test.png', alignId: 'left', valignId: 'top' },
492
492
  duration: 10,
493
493
  transitions: { in: null, out: null }
494
494
  };
@@ -499,12 +499,12 @@ describe('RendererLite', () => {
499
499
  expect(element.style.objectPosition).toBe('left top');
500
500
  });
501
501
 
502
- it('should map align right and valign bottom to objectPosition', async () => {
502
+ it('should map alignId right and valignId bottom to objectPosition', async () => {
503
503
  const widget = {
504
504
  type: 'image',
505
505
  id: 'm1',
506
506
  fileId: '1',
507
- options: { uri: 'test.png', align: 'right', valign: 'bottom' },
507
+ options: { uri: 'test.png', alignId: 'right', valignId: 'bottom' },
508
508
  duration: 10,
509
509
  transitions: { in: null, out: null }
510
510
  };
@@ -515,12 +515,12 @@ describe('RendererLite', () => {
515
515
  expect(element.style.objectPosition).toBe('right bottom');
516
516
  });
517
517
 
518
- it('should map valign middle to center in objectPosition', async () => {
518
+ it('should map valignId middle to center in objectPosition', async () => {
519
519
  const widget = {
520
520
  type: 'image',
521
521
  id: 'm1',
522
522
  fileId: '1',
523
- options: { uri: 'test.png', align: 'center', valign: 'middle' },
523
+ options: { uri: 'test.png', alignId: 'center', valignId: 'middle' },
524
524
  duration: 10,
525
525
  transitions: { in: null, out: null }
526
526
  };
@@ -536,7 +536,7 @@ describe('RendererLite', () => {
536
536
  type: 'image',
537
537
  id: 'm1',
538
538
  fileId: '1',
539
- options: { uri: 'test.png', scaleType: 'stretch', align: 'left', valign: 'bottom' },
539
+ options: { uri: 'test.png', scaleType: 'stretch', alignId: 'left', valignId: 'bottom' },
540
540
  duration: 10,
541
541
  transitions: { in: null, out: null }
542
542
  };