@xiboplayer/renderer 0.5.18 → 0.5.20

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.
@@ -1,6 +1,6 @@
1
1
  # Renderer Comparison: XLR vs Arexibo vs RendererLite
2
2
 
3
- **Date**: 2026-02-06
3
+ **Date**: 2026-02-28
4
4
  **Purpose**: Comprehensive feature comparison to identify gaps and validate implementation
5
5
 
6
6
  ---
@@ -35,6 +35,7 @@
35
35
  | Text/HTML | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
36
36
  | Ticker | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
37
37
  | PDF | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
38
+ | PDF multi-page cycling | ❌ No | ❌ No | ✅ Yes | ✅ Timed transitions |
38
39
  | Webpage (iframe) | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
39
40
  | Clock | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
40
41
  | Weather | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Complete |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -13,8 +13,8 @@
13
13
  "dependencies": {
14
14
  "nanoevents": "^9.1.0",
15
15
  "pdfjs-dist": "^4.10.38",
16
- "@xiboplayer/utils": "0.5.18",
17
- "@xiboplayer/cache": "0.5.18"
16
+ "@xiboplayer/cache": "0.5.20",
17
+ "@xiboplayer/utils": "0.5.20"
18
18
  },
19
19
  "devDependencies": {
20
20
  "vitest": "^2.0.0",
package/src/layout.js CHANGED
@@ -515,8 +515,8 @@ ${mediaJS}
515
515
  video.play();
516
516
  }
517
517
  };
518
+ video._retryOnCache = retryOnCache;
518
519
  window.addEventListener('media-cached', retryOnCache);
519
- video.dataset.cacheListener = 'attached';
520
520
 
521
521
  region.innerHTML = '';
522
522
  region.appendChild(video);
@@ -536,6 +536,11 @@ ${mediaJS}
536
536
  const region = document.getElementById('region_${regionId}');
537
537
  const video = document.querySelector('#region_${regionId} video');
538
538
  if (video) {
539
+ // Remove global media-cached listener to prevent leak
540
+ if (video._retryOnCache) {
541
+ window.removeEventListener('media-cached', video._retryOnCache);
542
+ video._retryOnCache = null;
543
+ }
539
544
  const transOut = ${transOut};
540
545
  if (transOut && window.Transitions) {
541
546
  const regionRect = region.getBoundingClientRect();
@@ -871,7 +876,7 @@ ${mediaJS}
871
876
  break;
872
877
 
873
878
  case 'webpage':
874
- const url = media.options.uri;
879
+ const url = decodeURIComponent(media.options.uri || '');
875
880
  startFn = `() => {
876
881
  const region = document.getElementById('region_${regionId}');
877
882
  const iframe = document.createElement('iframe');
@@ -642,13 +642,17 @@ export class RendererLite {
642
642
  * @param {string} blobUrl - Blob URL to track
643
643
  */
644
644
  trackBlobUrl(blobUrl) {
645
- if (!this.currentLayoutId) return;
645
+ const layoutId = this.currentLayoutId || 0;
646
646
 
647
- if (!this.layoutBlobUrls.has(this.currentLayoutId)) {
648
- this.layoutBlobUrls.set(this.currentLayoutId, new Set());
647
+ if (!layoutId) {
648
+ this.log.warn('trackBlobUrl called without currentLayoutId, tracking under key 0');
649
649
  }
650
650
 
651
- this.layoutBlobUrls.get(this.currentLayoutId).add(blobUrl);
651
+ if (!this.layoutBlobUrls.has(layoutId)) {
652
+ this.layoutBlobUrls.set(layoutId, new Set());
653
+ }
654
+
655
+ this.layoutBlobUrls.get(layoutId).add(blobUrl);
652
656
  }
653
657
 
654
658
  /**
@@ -1369,15 +1373,16 @@ export class RendererLite {
1369
1373
  return Promise.resolve();
1370
1374
  }
1371
1375
  return new Promise((resolve) => {
1372
- const timer = setTimeout(() => {
1373
- this.log.warn(`Image ready timeout for widget ${widget.id}`);
1374
- resolve();
1375
- }, READY_TIMEOUT);
1376
1376
  const onLoad = () => {
1377
1377
  imgEl.removeEventListener('load', onLoad);
1378
1378
  clearTimeout(timer);
1379
1379
  resolve();
1380
1380
  };
1381
+ const timer = setTimeout(() => {
1382
+ imgEl.removeEventListener('load', onLoad);
1383
+ this.log.warn(`Image ready timeout for widget ${widget.id}`);
1384
+ resolve();
1385
+ }, READY_TIMEOUT);
1381
1386
  imgEl.addEventListener('load', onLoad);
1382
1387
  });
1383
1388
  }
@@ -1592,12 +1597,39 @@ export class RendererLite {
1592
1597
  videoEl.srcObject = null;
1593
1598
  }
1594
1599
 
1600
+ // Destroy HLS.js instance to free worker + buffers
1601
+ if (videoEl?._hlsInstance) {
1602
+ videoEl._hlsInstance.destroy();
1603
+ videoEl._hlsInstance = null;
1604
+ }
1605
+
1606
+ // Remove event listeners to prevent accumulation across widget cycles
1607
+ if (videoEl?._eventCleanup) {
1608
+ for (const [event, handler] of videoEl._eventCleanup) {
1609
+ videoEl.removeEventListener(event, handler);
1610
+ }
1611
+ videoEl._eventCleanup = null;
1612
+ }
1613
+
1595
1614
  const audioEl = widgetElement.querySelector('audio');
1596
1615
  if (audioEl && widget.options.loop !== '1') audioEl.pause();
1597
1616
 
1617
+ // Remove audio event listeners
1618
+ if (audioEl?._eventCleanup) {
1619
+ for (const [event, handler] of audioEl._eventCleanup) {
1620
+ audioEl.removeEventListener(event, handler);
1621
+ }
1622
+ audioEl._eventCleanup = null;
1623
+ }
1624
+
1598
1625
  // Stop audio overlays attached to this widget
1599
1626
  this._stopAudioOverlays(widget.id);
1600
1627
 
1628
+ // Stop PDF page cycling timers
1629
+ if (widgetElement._pdfCleanup) {
1630
+ widgetElement._pdfCleanup();
1631
+ }
1632
+
1601
1633
  return { widget, animPromise };
1602
1634
  }
1603
1635
 
@@ -1887,22 +1919,20 @@ export class RendererLite {
1887
1919
  video.controls = false; // Hidden by default — toggle with V key in PWA
1888
1920
  video.playsInline = true; // Prevent fullscreen on mobile
1889
1921
 
1922
+ // Get media URL from cache (already pre-fetched!) or fetch on-demand
1923
+ const fileId = parseInt(widget.fileId || widget.id);
1924
+
1890
1925
  // Handle video end - pause on last frame instead of showing black
1891
1926
  // Widget cycling will restart the video via updateMediaElement()
1892
- video.addEventListener('ended', () => {
1927
+ const onEnded = () => {
1893
1928
  if (widget.options.loop === '1') {
1894
- // For looping videos: seek back to start but stay paused on first frame
1895
- // This avoids black frames - shows first frame until widget cycles
1896
1929
  video.currentTime = 0;
1897
1930
  this.log.info(`Video ${fileId} ended - reset to start, waiting for widget cycle to replay`);
1898
1931
  } else {
1899
- // For non-looping videos: stay paused on last frame
1900
1932
  this.log.info(`Video ${fileId} ended - paused on last frame`);
1901
1933
  }
1902
- });
1903
-
1904
- // Get media URL from cache (already pre-fetched!) or fetch on-demand
1905
- const fileId = parseInt(widget.fileId || widget.id);
1934
+ };
1935
+ video.addEventListener('ended', onEnded);
1906
1936
  let videoSrc = this.mediaUrlCache.get(fileId);
1907
1937
 
1908
1938
  if (!videoSrc && this.options.getMediaUrl) {
@@ -1953,49 +1983,49 @@ export class RendererLite {
1953
1983
  // loadedmetadata fires (e.g. video was preloaded for next layout), we must
1954
1984
  // NOT update the current layout's duration with a different layout's video.
1955
1985
  const createdForLayoutId = this.currentLayoutId;
1956
- video.addEventListener('loadedmetadata', () => {
1986
+ const onLoadedMetadata = () => {
1957
1987
  const videoDuration = Math.floor(video.duration);
1958
1988
  this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
1959
1989
 
1960
- // Always update widget duration — it's the widget's own data, safe
1961
- // even if this video was preloaded for a different layout.
1962
1990
  if (widget.duration === 0 || widget.useDuration === 0) {
1963
1991
  widget.duration = videoDuration;
1964
1992
  this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
1965
1993
 
1966
- // Only recalculate current layout's timer if this video belongs to it.
1967
- // Preloaded layouts will pick up the corrected widget.duration when
1968
- // they start playing (via updateLayoutDuration() in swapToPreloadedLayout).
1969
1994
  if (this.currentLayoutId === createdForLayoutId) {
1970
1995
  this.updateLayoutDuration();
1971
1996
  } else {
1972
1997
  this.log.info(`Video ${fileId} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
1973
1998
  }
1974
1999
  }
1975
- });
2000
+ };
2001
+ video.addEventListener('loadedmetadata', onLoadedMetadata);
1976
2002
 
1977
- // Debug video loading
1978
- video.addEventListener('loadeddata', () => {
2003
+ const onLoadedData = () => {
1979
2004
  this.log.info('Video loaded and ready:', fileId);
1980
- });
2005
+ };
2006
+ video.addEventListener('loadeddata', onLoadedData);
1981
2007
 
1982
- // Handle video errors
1983
- video.addEventListener('error', (e) => {
2008
+ const onError = () => {
1984
2009
  const error = video.error;
1985
2010
  const errorCode = error?.code;
1986
2011
  const errorMessage = error?.message || 'Unknown error';
1987
-
1988
- // Log all video errors for debugging, but never show to users
1989
- // These are often transient codec warnings that don't prevent playback
1990
2012
  this.log.warn(`Video error (non-fatal, logged only): ${fileId}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
2013
+ };
2014
+ video.addEventListener('error', onError);
1991
2015
 
1992
- // Do NOT emit error events - video errors are logged but not surfaced to UI
1993
- // Video will either recover (transient decode error) or fail completely (handled elsewhere)
1994
- });
1995
-
1996
- video.addEventListener('playing', () => {
2016
+ const onPlaying = () => {
1997
2017
  this.log.info('Video playing:', fileId);
1998
- });
2018
+ };
2019
+ video.addEventListener('playing', onPlaying);
2020
+
2021
+ // Store listener references for cleanup in _hideWidget()
2022
+ video._eventCleanup = [
2023
+ ['ended', onEnded],
2024
+ ['loadedmetadata', onLoadedMetadata],
2025
+ ['loadeddata', onLoadedData],
2026
+ ['error', onError],
2027
+ ['playing', onPlaying],
2028
+ ];
1999
2029
 
2000
2030
  this.log.info('Video element created:', fileId, video.src);
2001
2031
 
@@ -2095,18 +2125,19 @@ export class RendererLite {
2095
2125
  audio.src = audioSrc;
2096
2126
 
2097
2127
  // Handle audio end - similar to video ended handling
2098
- audio.addEventListener('ended', () => {
2128
+ const onAudioEnded = () => {
2099
2129
  if (widget.options.loop === '1') {
2100
2130
  audio.currentTime = 0;
2101
2131
  this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
2102
2132
  } else {
2103
2133
  this.log.info(`Audio ${fileId} ended - playback complete`);
2104
2134
  }
2105
- });
2135
+ };
2136
+ audio.addEventListener('ended', onAudioEnded);
2106
2137
 
2107
2138
  // Detect audio duration for dynamic layout timing (when useDuration=0)
2108
2139
  const audioCreatedForLayoutId = this.currentLayoutId;
2109
- audio.addEventListener('loadedmetadata', () => {
2140
+ const onAudioLoadedMetadata = () => {
2110
2141
  const audioDuration = Math.floor(audio.duration);
2111
2142
  this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
2112
2143
 
@@ -2120,13 +2151,22 @@ export class RendererLite {
2120
2151
  this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2121
2152
  }
2122
2153
  }
2123
- });
2154
+ };
2155
+ audio.addEventListener('loadedmetadata', onAudioLoadedMetadata);
2124
2156
 
2125
2157
  // Handle audio errors
2126
- audio.addEventListener('error', () => {
2158
+ const onAudioError = () => {
2127
2159
  const error = audio.error;
2128
2160
  this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
2129
- });
2161
+ };
2162
+ audio.addEventListener('error', onAudioError);
2163
+
2164
+ // Store listener references for cleanup in _hideWidget()
2165
+ audio._eventCleanup = [
2166
+ ['ended', onAudioEnded],
2167
+ ['loadedmetadata', onAudioLoadedMetadata],
2168
+ ['error', onAudioError],
2169
+ ];
2130
2170
 
2131
2171
  // Visual feedback
2132
2172
  const icon = document.createElement('div');
@@ -2255,29 +2295,72 @@ export class RendererLite {
2255
2295
  pdfUrl = `${window.location.origin}/player/cache/media/${widget.options.uri}`;
2256
2296
  }
2257
2297
 
2258
- // Render PDF
2298
+ // Render PDF with multi-page cycling
2259
2299
  try {
2260
2300
  const loadingTask = window.pdfjsLib.getDocument(pdfUrl);
2261
2301
  const pdf = await loadingTask.promise;
2262
- const page = await pdf.getPage(1); // Render first page
2263
-
2264
- const viewport = page.getViewport({ scale: 1 });
2265
- const scale = Math.min(
2266
- region.width / viewport.width,
2267
- region.height / viewport.height
2268
- );
2269
- const scaledViewport = page.getViewport({ scale });
2302
+ const totalPages = pdf.numPages;
2303
+ const duration = widget.duration || 60;
2304
+ const timePerPage = (duration * 1000) / totalPages;
2305
+ this.log.info(`[pdf] PDF loaded: ${totalPages} pages, ${duration}s duration, ${(timePerPage / 1000).toFixed(1)}s/page`);
2306
+
2307
+ // Single reused canvas — render each page on-demand, call page.cleanup()
2308
+ // after each render to release PDF.js internal buffers. Sequential rendering
2309
+ // (one page at a time via setTimeout) avoids the "Cannot use the same canvas
2310
+ // during multiple render() operations" error.
2311
+ const page1 = await pdf.getPage(1);
2312
+ const viewport0 = page1.getViewport({ scale: 1 });
2313
+ const scale = Math.min(region.width / viewport0.width, region.height / viewport0.height);
2314
+ page1.cleanup();
2270
2315
 
2271
2316
  const canvas = document.createElement('canvas');
2272
- canvas.width = scaledViewport.width;
2273
- canvas.height = scaledViewport.height;
2274
- canvas.style.display = 'block';
2275
- canvas.style.margin = 'auto';
2317
+ canvas.className = 'pdf-page';
2318
+ canvas.width = Math.floor(viewport0.width * scale);
2319
+ canvas.height = Math.floor(viewport0.height * scale);
2320
+ canvas.style.cssText = 'display:block;margin:auto;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);';
2321
+ const ctx = canvas.getContext('2d');
2322
+ container.appendChild(canvas);
2276
2323
 
2277
- const context = canvas.getContext('2d');
2278
- await page.render({ canvasContext: context, viewport: scaledViewport }).promise;
2324
+ // Page indicator (bottom-right)
2325
+ const indicator = document.createElement('div');
2326
+ indicator.style.cssText = 'position:absolute;bottom:8px;right:12px;color:rgba(255,255,255,0.6);font:12px system-ui;z-index:1;';
2327
+ container.appendChild(indicator);
2328
+
2329
+ let currentPage = 1;
2330
+ let cycleTimer = null;
2331
+ let stopped = false;
2332
+
2333
+ const cyclePage = async () => {
2334
+ if (stopped) return;
2335
+ indicator.textContent = `${currentPage} / ${totalPages}`;
2336
+
2337
+ const page = await pdf.getPage(currentPage);
2338
+ const scaledViewport = page.getViewport({ scale });
2339
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2340
+ await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise;
2341
+ page.cleanup(); // Release PDF.js internal page buffers
2342
+
2343
+ // Schedule next page (only after current render completes)
2344
+ if (totalPages > 1 && !stopped) {
2345
+ cycleTimer = setTimeout(() => {
2346
+ currentPage = currentPage >= totalPages ? 1 : currentPage + 1;
2347
+ cyclePage();
2348
+ }, timePerPage);
2349
+ }
2350
+ };
2279
2351
 
2280
- container.appendChild(canvas);
2352
+ await cyclePage();
2353
+
2354
+ // Store cleanup function on container for when widget is removed
2355
+ container._pdfCleanup = () => {
2356
+ stopped = true;
2357
+ if (cycleTimer) clearTimeout(cycleTimer);
2358
+ cycleTimer = null;
2359
+ canvas.width = 0;
2360
+ canvas.height = 0;
2361
+ pdf.cleanup();
2362
+ pdf.destroy();
2363
+ };
2281
2364
 
2282
2365
  } catch (error) {
2283
2366
  this.log.error('PDF render failed:', error);
@@ -2305,7 +2388,9 @@ export class RendererLite {
2305
2388
  iframe.style.height = '100%';
2306
2389
  iframe.style.border = 'none';
2307
2390
  iframe.style.opacity = '0';
2308
- iframe.src = widget.options.uri;
2391
+ // CMS may percent-encode the URI in XLF (e.g. https%3A%2F%2F → https://)
2392
+ const uri = decodeURIComponent(widget.options.uri || '');
2393
+ iframe.src = uri;
2309
2394
 
2310
2395
  return iframe;
2311
2396
  }
@@ -486,8 +486,9 @@ describe('RendererLite - Overlay Rendering', () => {
486
486
  it('should track blob URLs per overlay layout', async () => {
487
487
  await renderer.renderOverlay(overlayXLF, 200, 10);
488
488
 
489
- // Blob URLs should be tracked
490
- expect(renderer.layoutBlobUrls.has(200) || renderer.layoutBlobUrls.size === 0).toBe(true);
489
+ // Blob URLs should be tracked (under overlay ID 200, or fallback key 0 if
490
+ // currentLayoutId wasn't set, or empty if no blob URLs were created)
491
+ expect(renderer.layoutBlobUrls.has(200) || renderer.layoutBlobUrls.has(0) || renderer.layoutBlobUrls.size === 0).toBe(true);
491
492
  });
492
493
  });
493
494
  });