@xiboplayer/renderer 0.5.19 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.5.19",
3
+ "version": "0.6.0",
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.6.0",
17
+ "@xiboplayer/utils": "0.6.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "vitest": "^2.0.0",
package/src/layout.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { cacheWidgetHtml } from '@xiboplayer/cache';
7
- import { createLogger } from '@xiboplayer/utils';
7
+ import { createLogger, PLAYER_API } from '@xiboplayer/utils';
8
8
 
9
9
  const log = createLogger('Layout');
10
10
 
@@ -157,10 +157,10 @@ export class LayoutTranslator {
157
157
 
158
158
  // Try to get cached widget HTML from ContentStore via proxy
159
159
  try {
160
- const resp = await fetch(`/store/widget/${layoutId}/${regionId}/${id}`);
160
+ const resp = await fetch(`/store${PLAYER_API}/widgets/${layoutId}/${regionId}/${id}`);
161
161
  if (resp.ok) {
162
162
  raw = await resp.text();
163
- options.widgetCacheKey = `/cache/widget/${layoutId}/${regionId}/${id}`;
163
+ options.widgetCacheKey = `${PLAYER_API}/widgets/${layoutId}/${regionId}/${id}`;
164
164
  log.info(`Using stored widget HTML (${raw.length} chars) - CMS update pending`);
165
165
  } else {
166
166
  log.error(`No stored version available for widget ${id}`);
@@ -466,7 +466,7 @@ ${mediaJS}
466
466
  switch (media.type) {
467
467
  case 'image':
468
468
  // Use absolute URL within service worker scope
469
- const imageSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
469
+ const imageSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
470
470
  startFn = `() => {
471
471
  const region = document.getElementById('region_${regionId}');
472
472
  const img = document.createElement('img');
@@ -490,7 +490,7 @@ ${mediaJS}
490
490
  case 'video':
491
491
  // All videos use cache URL pattern
492
492
  // Background-downloaded videos will auto-reload when cache completes
493
- const videoSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
493
+ const videoSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
494
494
  const videoFilename = media.options.uri;
495
495
 
496
496
  startFn = `() => {
@@ -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();
@@ -559,7 +564,7 @@ ${mediaJS}
559
564
  // Text/ticker widgets use the same iframe pattern as default widgets.
560
565
  // If no widgetCacheKey, fall through to the default case which handles unsupported types.
561
566
  if (media.options.widgetCacheKey) {
562
- const textUrl = `${window.location.origin}/player${media.options.widgetCacheKey}`;
567
+ const textUrl = `${window.location.origin}${media.options.widgetCacheKey}`;
563
568
  const iframe = this._generateIframeWidgetJS(regionId, media.id, textUrl, transIn, transOut);
564
569
  startFn = iframe.startFn;
565
570
  stopFn = iframe.stopFn;
@@ -568,7 +573,7 @@ ${mediaJS}
568
573
  // Fall through to default (handles missing widgetCacheKey as unsupported)
569
574
 
570
575
  case 'audio':
571
- const audioSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
576
+ const audioSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
572
577
  const audioId = `audio_${regionId}_${media.id}`;
573
578
  const audioLoop = media.options.loop === '1';
574
579
  const audioVolume = (parseInt(media.options.volume || '100') / 100).toFixed(2);
@@ -684,7 +689,7 @@ ${mediaJS}
684
689
  break;
685
690
 
686
691
  case 'pdf':
687
- const pdfSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
692
+ const pdfSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
688
693
  const pdfContainerId = `pdf_${regionId}_${media.id}`;
689
694
  const pdfDuration = duration; // Total duration for entire PDF
690
695
 
@@ -897,7 +902,7 @@ ${mediaJS}
897
902
  // Widgets (clock, calendar, weather, etc.) - use cache URL pattern in /player/ scope for SW
898
903
  // Keep widget iframes alive across duration cycles (arexibo behavior)
899
904
  if (media.options.widgetCacheKey) {
900
- const widgetUrl = `${window.location.origin}/player${media.options.widgetCacheKey}`;
905
+ const widgetUrl = `${window.location.origin}${media.options.widgetCacheKey}`;
901
906
  const iframe = this._generateIframeWidgetJS(regionId, media.id, widgetUrl, transIn, transOut);
902
907
  startFn = iframe.startFn;
903
908
  stopFn = iframe.stopFn;
@@ -40,7 +40,7 @@
40
40
  */
41
41
 
42
42
  import { createNanoEvents } from 'nanoevents';
43
- import { createLogger, isDebug } from '@xiboplayer/utils';
43
+ import { createLogger, isDebug, PLAYER_API } from '@xiboplayer/utils';
44
44
  import { LayoutPool } from './layout-pool.js';
45
45
 
46
46
  /**
@@ -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
  }
@@ -1516,10 +1521,10 @@ export class RendererLite {
1516
1521
  this.options.getMediaUrl(mediaId).then(url => {
1517
1522
  audio.src = url;
1518
1523
  }).catch(() => {
1519
- audio.src = `${window.location.origin}/player/cache/media/${audioNode.uri}`;
1524
+ audio.src = `${window.location.origin}${PLAYER_API}/media/${audioNode.uri}`;
1520
1525
  });
1521
1526
  } else if (!audioSrc) {
1522
- audio.src = `${window.location.origin}/player/cache/media/${audioNode.uri}`;
1527
+ audio.src = `${window.location.origin}${PLAYER_API}/media/${audioNode.uri}`;
1523
1528
  } else {
1524
1529
  audio.src = audioSrc;
1525
1530
  }
@@ -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
 
@@ -1866,7 +1893,7 @@ export class RendererLite {
1866
1893
  if (!imageSrc && this.options.getMediaUrl) {
1867
1894
  imageSrc = await this.options.getMediaUrl(fileId);
1868
1895
  } else if (!imageSrc) {
1869
- imageSrc = `${window.location.origin}/player/cache/media/${widget.options.uri}`;
1896
+ imageSrc = `${window.location.origin}${PLAYER_API}/media/${widget.options.uri}`;
1870
1897
  }
1871
1898
 
1872
1899
  img.src = imageSrc;
@@ -1892,28 +1919,26 @@ 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) {
1914
1939
  videoSrc = await this.options.getMediaUrl(fileId);
1915
1940
  } else if (!videoSrc) {
1916
- videoSrc = `${window.location.origin}/player/cache/media/${fileId}`;
1941
+ videoSrc = `${window.location.origin}${PLAYER_API}/media/${fileId}`;
1917
1942
  }
1918
1943
 
1919
1944
  // HLS/DASH streaming support
@@ -1958,49 +1983,50 @@ 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';
2012
+ this.log.warn(`Video error: ${fileId}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
2013
+ this.emit('videoError', { fileId, errorCode, errorMessage, currentTime: video.currentTime });
2014
+ };
2015
+ video.addEventListener('error', onError);
1992
2016
 
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
- this.log.warn(`Video error (non-fatal, logged only): ${fileId}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
1996
-
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', () => {
2017
+ const onPlaying = () => {
2002
2018
  this.log.info('Video playing:', fileId);
2003
- });
2019
+ };
2020
+ video.addEventListener('playing', onPlaying);
2021
+
2022
+ // Store listener references for cleanup in _hideWidget()
2023
+ video._eventCleanup = [
2024
+ ['ended', onEnded],
2025
+ ['loadedmetadata', onLoadedMetadata],
2026
+ ['loadeddata', onLoadedData],
2027
+ ['error', onError],
2028
+ ['playing', onPlaying],
2029
+ ];
2004
2030
 
2005
2031
  this.log.info('Video element created:', fileId, video.src);
2006
2032
 
@@ -2094,24 +2120,25 @@ export class RendererLite {
2094
2120
  if (!audioSrc && this.options.getMediaUrl) {
2095
2121
  audioSrc = await this.options.getMediaUrl(fileId);
2096
2122
  } else if (!audioSrc) {
2097
- audioSrc = `${window.location.origin}/player/cache/media/${fileId}`;
2123
+ audioSrc = `${window.location.origin}${PLAYER_API}/media/${fileId}`;
2098
2124
  }
2099
2125
 
2100
2126
  audio.src = audioSrc;
2101
2127
 
2102
2128
  // Handle audio end - similar to video ended handling
2103
- audio.addEventListener('ended', () => {
2129
+ const onAudioEnded = () => {
2104
2130
  if (widget.options.loop === '1') {
2105
2131
  audio.currentTime = 0;
2106
2132
  this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
2107
2133
  } else {
2108
2134
  this.log.info(`Audio ${fileId} ended - playback complete`);
2109
2135
  }
2110
- });
2136
+ };
2137
+ audio.addEventListener('ended', onAudioEnded);
2111
2138
 
2112
2139
  // Detect audio duration for dynamic layout timing (when useDuration=0)
2113
2140
  const audioCreatedForLayoutId = this.currentLayoutId;
2114
- audio.addEventListener('loadedmetadata', () => {
2141
+ const onAudioLoadedMetadata = () => {
2115
2142
  const audioDuration = Math.floor(audio.duration);
2116
2143
  this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
2117
2144
 
@@ -2125,13 +2152,22 @@ export class RendererLite {
2125
2152
  this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2126
2153
  }
2127
2154
  }
2128
- });
2155
+ };
2156
+ audio.addEventListener('loadedmetadata', onAudioLoadedMetadata);
2129
2157
 
2130
2158
  // Handle audio errors
2131
- audio.addEventListener('error', () => {
2159
+ const onAudioError = () => {
2132
2160
  const error = audio.error;
2133
2161
  this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
2134
- });
2162
+ };
2163
+ audio.addEventListener('error', onAudioError);
2164
+
2165
+ // Store listener references for cleanup in _hideWidget()
2166
+ audio._eventCleanup = [
2167
+ ['ended', onAudioEnded],
2168
+ ['loadedmetadata', onAudioLoadedMetadata],
2169
+ ['error', onAudioError],
2170
+ ];
2135
2171
 
2136
2172
  // Visual feedback
2137
2173
  const icon = document.createElement('div');
@@ -2223,7 +2259,14 @@ export class RendererLite {
2223
2259
  }
2224
2260
 
2225
2261
  /**
2226
- * Render PDF widget
2262
+ * Render PDF widget — single reusable canvas, page-by-page cycling.
2263
+ *
2264
+ * Memory strategy:
2265
+ * - One canvas is created and reused for all pages (no DOM churn)
2266
+ * - Each page is rendered sequentially (avoids concurrent render errors)
2267
+ * - page.cleanup() releases PDF.js internal page buffers after each render
2268
+ * - pdf.destroy() releases the entire document on widget teardown
2269
+ * - Active renderTask is cancelled on cleanup to prevent stale renders
2227
2270
  */
2228
2271
  async renderPdf(widget, region) {
2229
2272
  const container = document.createElement('div');
@@ -2257,7 +2300,7 @@ export class RendererLite {
2257
2300
  if (!pdfUrl && this.options.getMediaUrl) {
2258
2301
  pdfUrl = await this.options.getMediaUrl(fileId);
2259
2302
  } else if (!pdfUrl) {
2260
- pdfUrl = `${window.location.origin}/player/cache/media/${widget.options.uri}`;
2303
+ pdfUrl = `${window.location.origin}${PLAYER_API}/media/${widget.options.uri}`;
2261
2304
  }
2262
2305
 
2263
2306
  // Render PDF with multi-page cycling
@@ -2269,68 +2312,77 @@ export class RendererLite {
2269
2312
  const timePerPage = (duration * 1000) / totalPages;
2270
2313
  this.log.info(`[pdf] PDF loaded: ${totalPages} pages, ${duration}s duration, ${(timePerPage / 1000).toFixed(1)}s/page`);
2271
2314
 
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
- };
2289
-
2290
- // Page indicator (bottom-right)
2315
+ // Measure page size from first page to set up the single reusable canvas
2316
+ const page1 = await pdf.getPage(1);
2317
+ const viewport0 = page1.getViewport({ scale: 1 });
2318
+ const scale = Math.min(region.width / viewport0.width, region.height / viewport0.height);
2319
+ page1.cleanup();
2320
+
2321
+ const canvas = document.createElement('canvas');
2322
+ canvas.className = 'pdf-page';
2323
+ canvas.width = Math.floor(viewport0.width * scale);
2324
+ canvas.height = Math.floor(viewport0.height * scale);
2325
+ canvas.style.cssText = 'display:block;margin:auto;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);';
2326
+ const ctx = canvas.getContext('2d');
2327
+ container.appendChild(canvas);
2328
+
2329
+ // Page indicator (bottom-right, v1-style pill)
2291
2330
  const indicator = document.createElement('div');
2292
- indicator.style.cssText = 'position:absolute;bottom:8px;right:12px;color:rgba(255,255,255,0.6);font:12px system-ui;z-index:1;';
2331
+ indicator.style.cssText = 'position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,0.7);color:white;padding:8px 12px;border-radius:4px;font:14px system-ui;z-index:1;';
2293
2332
  container.appendChild(indicator);
2294
2333
 
2295
2334
  let currentPage = 1;
2296
- const pageTimers = [];
2335
+ let cycleTimer = null;
2336
+ let activeRenderTask = null;
2337
+ let stopped = false;
2297
2338
 
2339
+ // Render one page at a time on the single canvas. Sequential scheduling
2340
+ // (setTimeout after render completes) avoids the "Cannot use the same
2341
+ // canvas during multiple render() operations" error from PDF.js.
2298
2342
  const cyclePage = async () => {
2299
- indicator.textContent = `${currentPage} / ${totalPages}`;
2300
-
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);
2343
+ if (stopped) return;
2344
+ indicator.textContent = `Page ${currentPage} / ${totalPages}`;
2345
+
2346
+ const page = await pdf.getPage(currentPage);
2347
+ const scaledViewport = page.getViewport({ scale });
2348
+
2349
+ // Clear and render on the reusable canvas
2350
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2351
+ activeRenderTask = page.render({ canvasContext: ctx, viewport: scaledViewport });
2352
+ try {
2353
+ await activeRenderTask.promise;
2354
+ } catch (e) {
2355
+ // RenderingCancelledException is expected when stopped during render
2356
+ if (stopped) return;
2357
+ throw e;
2307
2358
  }
2359
+ activeRenderTask = null;
2360
+ page.cleanup(); // Release PDF.js internal page buffers
2308
2361
 
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(() => {
2362
+ // Schedule next page (only after current render completes)
2363
+ if (totalPages > 1 && !stopped) {
2364
+ cycleTimer = setTimeout(() => {
2321
2365
  currentPage = currentPage >= totalPages ? 1 : currentPage + 1;
2322
2366
  cyclePage();
2323
2367
  }, timePerPage);
2324
- pageTimers.push(timer);
2325
2368
  }
2326
2369
  };
2327
2370
 
2328
2371
  await cyclePage();
2329
2372
 
2330
- // Store cleanup function on container for when widget is removed
2373
+ // Cleanup: cancel active render, clear timer, release PDF document
2331
2374
  container._pdfCleanup = () => {
2332
- pageTimers.forEach(t => clearTimeout(t));
2333
- pageTimers.length = 0;
2375
+ stopped = true;
2376
+ if (cycleTimer) clearTimeout(cycleTimer);
2377
+ cycleTimer = null;
2378
+ if (activeRenderTask) {
2379
+ activeRenderTask.cancel();
2380
+ activeRenderTask = null;
2381
+ }
2382
+ // Zero canvas dimensions to release GPU backing store
2383
+ canvas.width = 0;
2384
+ canvas.height = 0;
2385
+ pdf.destroy();
2334
2386
  };
2335
2387
 
2336
2388
  } 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
  });