@xiboplayer/renderer 0.5.19 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.5.19",
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/cache": "0.5.19",
17
- "@xiboplayer/utils": "0.5.19"
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();
@@ -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,9 +1597,31 @@ 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
 
@@ -1892,22 +1919,20 @@ export class RendererLite {
1892
1919
  video.controls = false; // Hidden by default — toggle with V key in PWA
1893
1920
  video.playsInline = true; // Prevent fullscreen on mobile
1894
1921
 
1922
+ // Get media URL from cache (already pre-fetched!) or fetch on-demand
1923
+ const fileId = parseInt(widget.fileId || widget.id);
1924
+
1895
1925
  // Handle video end - pause on last frame instead of showing black
1896
1926
  // Widget cycling will restart the video via updateMediaElement()
1897
- video.addEventListener('ended', () => {
1927
+ const onEnded = () => {
1898
1928
  if (widget.options.loop === '1') {
1899
- // For looping videos: seek back to start but stay paused on first frame
1900
- // This avoids black frames - shows first frame until widget cycles
1901
1929
  video.currentTime = 0;
1902
1930
  this.log.info(`Video ${fileId} ended - reset to start, waiting for widget cycle to replay`);
1903
1931
  } else {
1904
- // For non-looping videos: stay paused on last frame
1905
1932
  this.log.info(`Video ${fileId} ended - paused on last frame`);
1906
1933
  }
1907
- });
1908
-
1909
- // Get media URL from cache (already pre-fetched!) or fetch on-demand
1910
- const fileId = parseInt(widget.fileId || widget.id);
1934
+ };
1935
+ video.addEventListener('ended', onEnded);
1911
1936
  let videoSrc = this.mediaUrlCache.get(fileId);
1912
1937
 
1913
1938
  if (!videoSrc && this.options.getMediaUrl) {
@@ -1958,49 +1983,49 @@ export class RendererLite {
1958
1983
  // loadedmetadata fires (e.g. video was preloaded for next layout), we must
1959
1984
  // NOT update the current layout's duration with a different layout's video.
1960
1985
  const createdForLayoutId = this.currentLayoutId;
1961
- video.addEventListener('loadedmetadata', () => {
1986
+ const onLoadedMetadata = () => {
1962
1987
  const videoDuration = Math.floor(video.duration);
1963
1988
  this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
1964
1989
 
1965
- // Always update widget duration — it's the widget's own data, safe
1966
- // even if this video was preloaded for a different layout.
1967
1990
  if (widget.duration === 0 || widget.useDuration === 0) {
1968
1991
  widget.duration = videoDuration;
1969
1992
  this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
1970
1993
 
1971
- // Only recalculate current layout's timer if this video belongs to it.
1972
- // Preloaded layouts will pick up the corrected widget.duration when
1973
- // they start playing (via updateLayoutDuration() in swapToPreloadedLayout).
1974
1994
  if (this.currentLayoutId === createdForLayoutId) {
1975
1995
  this.updateLayoutDuration();
1976
1996
  } else {
1977
1997
  this.log.info(`Video ${fileId} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
1978
1998
  }
1979
1999
  }
1980
- });
2000
+ };
2001
+ video.addEventListener('loadedmetadata', onLoadedMetadata);
1981
2002
 
1982
- // Debug video loading
1983
- video.addEventListener('loadeddata', () => {
2003
+ const onLoadedData = () => {
1984
2004
  this.log.info('Video loaded and ready:', fileId);
1985
- });
2005
+ };
2006
+ video.addEventListener('loadeddata', onLoadedData);
1986
2007
 
1987
- // Handle video errors
1988
- video.addEventListener('error', (e) => {
2008
+ const onError = () => {
1989
2009
  const error = video.error;
1990
2010
  const errorCode = error?.code;
1991
2011
  const errorMessage = error?.message || 'Unknown error';
1992
-
1993
- // Log all video errors for debugging, but never show to users
1994
- // These are often transient codec warnings that don't prevent playback
1995
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);
1996
2015
 
1997
- // Do NOT emit error events - video errors are logged but not surfaced to UI
1998
- // Video will either recover (transient decode error) or fail completely (handled elsewhere)
1999
- });
2000
-
2001
- video.addEventListener('playing', () => {
2016
+ const onPlaying = () => {
2002
2017
  this.log.info('Video playing:', fileId);
2003
- });
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
+ ];
2004
2029
 
2005
2030
  this.log.info('Video element created:', fileId, video.src);
2006
2031
 
@@ -2100,18 +2125,19 @@ export class RendererLite {
2100
2125
  audio.src = audioSrc;
2101
2126
 
2102
2127
  // Handle audio end - similar to video ended handling
2103
- audio.addEventListener('ended', () => {
2128
+ const onAudioEnded = () => {
2104
2129
  if (widget.options.loop === '1') {
2105
2130
  audio.currentTime = 0;
2106
2131
  this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
2107
2132
  } else {
2108
2133
  this.log.info(`Audio ${fileId} ended - playback complete`);
2109
2134
  }
2110
- });
2135
+ };
2136
+ audio.addEventListener('ended', onAudioEnded);
2111
2137
 
2112
2138
  // Detect audio duration for dynamic layout timing (when useDuration=0)
2113
2139
  const audioCreatedForLayoutId = this.currentLayoutId;
2114
- audio.addEventListener('loadedmetadata', () => {
2140
+ const onAudioLoadedMetadata = () => {
2115
2141
  const audioDuration = Math.floor(audio.duration);
2116
2142
  this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
2117
2143
 
@@ -2125,13 +2151,22 @@ export class RendererLite {
2125
2151
  this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2126
2152
  }
2127
2153
  }
2128
- });
2154
+ };
2155
+ audio.addEventListener('loadedmetadata', onAudioLoadedMetadata);
2129
2156
 
2130
2157
  // Handle audio errors
2131
- audio.addEventListener('error', () => {
2158
+ const onAudioError = () => {
2132
2159
  const error = audio.error;
2133
2160
  this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
2134
- });
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
+ ];
2135
2170
 
2136
2171
  // Visual feedback
2137
2172
  const icon = document.createElement('div');
@@ -2269,23 +2304,22 @@ export class RendererLite {
2269
2304
  const timePerPage = (duration * 1000) / totalPages;
2270
2305
  this.log.info(`[pdf] PDF loaded: ${totalPages} pages, ${duration}s duration, ${(timePerPage / 1000).toFixed(1)}s/page`);
2271
2306
 
2272
- // Render a single page to a canvas, scaled to fit the region
2273
- const renderPage = async (pageNum) => {
2274
- const page = await pdf.getPage(pageNum);
2275
- const viewport = page.getViewport({ scale: 1 });
2276
- const scale = Math.min(region.width / viewport.width, region.height / viewport.height);
2277
- const scaledViewport = page.getViewport({ scale });
2278
-
2279
- const canvas = document.createElement('canvas');
2280
- canvas.className = 'pdf-page';
2281
- canvas.width = scaledViewport.width;
2282
- canvas.height = scaledViewport.height;
2283
- canvas.style.cssText = 'display:block;margin:auto;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);';
2284
-
2285
- const context = canvas.getContext('2d');
2286
- await page.render({ canvasContext: context, viewport: scaledViewport }).promise;
2287
- return canvas;
2288
- };
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();
2315
+
2316
+ const canvas = document.createElement('canvas');
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);
2289
2323
 
2290
2324
  // Page indicator (bottom-right)
2291
2325
  const indicator = document.createElement('div');
@@ -2293,35 +2327,25 @@ export class RendererLite {
2293
2327
  container.appendChild(indicator);
2294
2328
 
2295
2329
  let currentPage = 1;
2296
- const pageTimers = [];
2330
+ let cycleTimer = null;
2331
+ let stopped = false;
2297
2332
 
2298
2333
  const cyclePage = async () => {
2334
+ if (stopped) return;
2299
2335
  indicator.textContent = `${currentPage} / ${totalPages}`;
2300
2336
 
2301
- // Fade out old canvas
2302
- const oldCanvas = container.querySelector('.pdf-page');
2303
- if (oldCanvas) {
2304
- oldCanvas.style.transition = 'opacity 0.3s';
2305
- oldCanvas.style.opacity = '0';
2306
- setTimeout(() => oldCanvas.remove(), 300);
2307
- }
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
2308
2342
 
2309
- // Render and show new page
2310
- const canvas = await renderPage(currentPage);
2311
- canvas.style.opacity = '0';
2312
- container.appendChild(canvas);
2313
- // Trigger reflow then fade in
2314
- canvas.offsetHeight; // eslint-disable-line no-unused-expressions
2315
- canvas.style.transition = 'opacity 0.3s';
2316
- canvas.style.opacity = '1';
2317
-
2318
- // Schedule next page
2319
- if (totalPages > 1) {
2320
- const timer = setTimeout(() => {
2343
+ // Schedule next page (only after current render completes)
2344
+ if (totalPages > 1 && !stopped) {
2345
+ cycleTimer = setTimeout(() => {
2321
2346
  currentPage = currentPage >= totalPages ? 1 : currentPage + 1;
2322
2347
  cyclePage();
2323
2348
  }, timePerPage);
2324
- pageTimers.push(timer);
2325
2349
  }
2326
2350
  };
2327
2351
 
@@ -2329,8 +2353,13 @@ export class RendererLite {
2329
2353
 
2330
2354
  // Store cleanup function on container for when widget is removed
2331
2355
  container._pdfCleanup = () => {
2332
- pageTimers.forEach(t => clearTimeout(t));
2333
- pageTimers.length = 0;
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();
2334
2363
  };
2335
2364
 
2336
2365
  } catch (error) {
@@ -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
  });