@xiboplayer/renderer 0.5.8 → 0.5.10

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.8",
3
+ "version": "0.5.10",
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/utils": "0.5.8",
16
- "@xiboplayer/cache": "0.5.8"
15
+ "@xiboplayer/cache": "0.5.10",
16
+ "@xiboplayer/utils": "0.5.10"
17
17
  },
18
18
  "devDependencies": {
19
19
  "vitest": "^2.0.0",
@@ -101,7 +101,7 @@ export class LayoutPool {
101
101
 
102
102
  /**
103
103
  * Evict a specific layout from the pool.
104
- * Revokes blob URLs and removes the container from the DOM.
104
+ * Releases video/audio resources, revokes blob URLs, and removes the container from the DOM.
105
105
  * @param {number} layoutId
106
106
  */
107
107
  evict(layoutId) {
@@ -110,6 +110,22 @@ export class LayoutPool {
110
110
 
111
111
  log.info(`Evicting layout ${layoutId} from pool`);
112
112
 
113
+ // Stop any active region timers
114
+ if (entry.regions) {
115
+ for (const [regionId, region] of entry.regions) {
116
+ if (region.timer) {
117
+ clearTimeout(region.timer);
118
+ region.timer = null;
119
+ }
120
+ }
121
+ }
122
+
123
+ // Release all video/audio resources BEFORE removing from DOM.
124
+ // Removing a <video> with an active src leaks decoded frame buffers.
125
+ if (entry.container) {
126
+ LayoutPool.releaseMediaElements(entry.container);
127
+ }
128
+
113
129
  // Revoke blob URLs
114
130
  if (entry.blobUrls && entry.blobUrls.size > 0) {
115
131
  entry.blobUrls.forEach(url => {
@@ -127,16 +143,6 @@ export class LayoutPool {
127
143
  }
128
144
  }
129
145
 
130
- // Stop any active region timers
131
- if (entry.regions) {
132
- for (const [regionId, region] of entry.regions) {
133
- if (region.timer) {
134
- clearTimeout(region.timer);
135
- region.timer = null;
136
- }
137
- }
138
- }
139
-
140
146
  // Remove container from DOM
141
147
  if (entry.container && entry.container.parentNode) {
142
148
  entry.container.remove();
@@ -150,6 +156,48 @@ export class LayoutPool {
150
156
  }
151
157
  }
152
158
 
159
+ /**
160
+ * Release all video and audio elements inside a container.
161
+ * Must be called BEFORE removing the container from the DOM —
162
+ * browsers keep decoded frame buffers alive for detached <video> elements
163
+ * that still have a src.
164
+ *
165
+ * @param {HTMLElement} container
166
+ */
167
+ static releaseMediaElements(container) {
168
+ let videoCount = 0;
169
+ let hlsCount = 0;
170
+
171
+ container.querySelectorAll('video').forEach(v => {
172
+ // Destroy hls.js instance if attached (stored by renderVideo)
173
+ if (v._hlsInstance) {
174
+ v._hlsInstance.destroy();
175
+ v._hlsInstance = null;
176
+ hlsCount++;
177
+ }
178
+ // Stop MediaStream tracks (webcam/mic)
179
+ if (v._mediaStream) {
180
+ v._mediaStream.getTracks().forEach(t => t.stop());
181
+ v._mediaStream = null;
182
+ v.srcObject = null;
183
+ }
184
+ v.pause();
185
+ v.removeAttribute('src');
186
+ v.load(); // Forces browser to release decoded buffers
187
+ videoCount++;
188
+ });
189
+
190
+ container.querySelectorAll('audio').forEach(a => {
191
+ a.pause();
192
+ a.removeAttribute('src');
193
+ a.load();
194
+ });
195
+
196
+ if (videoCount > 0) {
197
+ log.info(`Released ${videoCount} video(s)${hlsCount ? ` (${hlsCount} HLS)` : ''}`);
198
+ }
199
+ }
200
+
153
201
  /**
154
202
  * Evict the least-recently-used warm entry.
155
203
  * Only warm entries are eligible for eviction (never the hot layout).
package/src/layout.js CHANGED
@@ -155,23 +155,19 @@ export class LayoutTranslator {
155
155
  if (!raw && lastError) {
156
156
  log.warn('All retries failed, checking for cached widget HTML...');
157
157
 
158
- // Try to get cached widget HTML directly from Cache API
158
+ // Try to get cached widget HTML from ContentStore via proxy
159
159
  try {
160
- const cachedKey = `/cache/widget/${layoutId}/${regionId}/${id}.html`;
161
- const cache = await caches.open('xibo-media-v1');
162
- const cached = await cache.match(new Request(window.location.origin + '/player' + cachedKey));
163
-
164
- if (cached) {
165
- raw = await cached.text();
166
- options.widgetCacheKey = cachedKey;
167
- log.info(`Using cached widget HTML (${raw.length} chars) - CMS update pending`);
160
+ const resp = await fetch(`/store/widget/${layoutId}/${regionId}/${id}`);
161
+ if (resp.ok) {
162
+ raw = await resp.text();
163
+ options.widgetCacheKey = `/cache/widget/${layoutId}/${regionId}/${id}`;
164
+ log.info(`Using stored widget HTML (${raw.length} chars) - CMS update pending`);
168
165
  } else {
169
- log.error(`No cached version available for widget ${id}`);
170
- // Show minimal placeholder that doesn't look like an error
166
+ log.error(`No stored version available for widget ${id}`);
171
167
  raw = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:18px;">Content updating...</div>`;
172
168
  }
173
- } catch (cacheError) {
174
- log.error('Cache fallback failed:', cacheError);
169
+ } catch (storeError) {
170
+ log.error('Store fallback failed:', storeError);
175
171
  raw = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:18px;">Content updating...</div>`;
176
172
  }
177
173
  }
@@ -330,10 +326,15 @@ Object.keys(regions).forEach(id => {
330
326
  playRegion(id);
331
327
  });
332
328
 
329
+ // Track active timers per region so layout teardown can cancel them
330
+ const regionTimers = {};
331
+
333
332
  function playRegion(id) {
334
333
  const region = regions[id];
335
334
  if (!region || region.media.length === 0) return;
336
335
 
336
+ regionTimers[id] = [];
337
+
337
338
  // If only one media item, just show it and don't cycle (arexibo behavior)
338
339
  if (region.media.length === 1) {
339
340
  const media = region.media[0];
@@ -349,15 +350,21 @@ function playRegion(id) {
349
350
  if (media.start) media.start();
350
351
 
351
352
  const duration = media.duration || 10;
352
- setTimeout(() => {
353
+ const timerId = setTimeout(() => {
353
354
  if (media.stop) media.stop();
354
355
  currentIndex = (currentIndex + 1) % region.media.length;
355
356
  playNext();
356
357
  }, duration * 1000);
358
+ regionTimers[id].push(timerId);
357
359
  }
358
360
 
359
361
  playNext();
360
362
  }
363
+
364
+ // Cleanup function — called before layout teardown
365
+ window._stopAllRegions = function() {
366
+ Object.values(regionTimers).forEach(timers => timers.forEach(t => clearTimeout(t)));
367
+ };
361
368
  </script>
362
369
  </body>
363
370
  </html>`;
@@ -815,6 +822,9 @@ ${mediaJS}
815
822
  }
816
823
  }
817
824
 
825
+ // Store live timer array on element for cleanup (not JSON — stays current)
826
+ container._pageTimers = pageTimers;
827
+
818
828
  // Start cycling
819
829
  await cyclePage();
820
830
 
@@ -827,9 +837,6 @@ ${mediaJS}
827
837
  container.style.opacity = '1';
828
838
  }
829
839
 
830
- // Store timers for cleanup
831
- container.dataset.pageTimers = JSON.stringify(pageTimers.map(t => t));
832
-
833
840
  } catch (error) {
834
841
  console.error('[PDF] Render failed:', error);
835
842
  container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">Failed to load PDF</div>';
@@ -841,12 +848,10 @@ ${mediaJS}
841
848
  const region = document.getElementById('region_${regionId}');
842
849
  const container = document.getElementById('${pdfContainerId}');
843
850
  if (container) {
844
- // Clear page cycling timers
845
- const timers = container.dataset.pageTimers;
846
- if (timers) {
847
- try {
848
- JSON.parse(timers).forEach(t => clearTimeout(t));
849
- } catch (e) {}
851
+ // Clear page cycling timers (live array, always current)
852
+ if (container._pageTimers) {
853
+ container._pageTimers.forEach(t => clearTimeout(t));
854
+ container._pageTimers.length = 0;
850
855
  }
851
856
 
852
857
  const transOut = ${transOut};
@@ -1923,10 +1923,12 @@ export class RendererLite {
1923
1923
  const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
1924
1924
  hls.loadSource(videoSrc);
1925
1925
  hls.attachMedia(video);
1926
+ video._hlsInstance = hls; // Store for cleanup on eviction
1926
1927
  hls.on(Hls.Events.ERROR, (_event, data) => {
1927
1928
  if (data.fatal) {
1928
1929
  this.log.error(`HLS fatal error: ${data.type}`, data.details);
1929
1930
  hls.destroy();
1931
+ video._hlsInstance = null;
1930
1932
  }
1931
1933
  });
1932
1934
  this.log.info(`HLS stream (hls.js): ${fileId}`);
@@ -2670,12 +2672,8 @@ export class RendererLite {
2670
2672
  clearTimeout(region.timer);
2671
2673
  region.timer = null;
2672
2674
  }
2673
- // Release video resources
2674
- region.element.querySelectorAll('video').forEach(v => {
2675
- v.pause();
2676
- v.removeAttribute('src');
2677
- v.load();
2678
- });
2675
+ // Release video/audio resources before removing from DOM
2676
+ LayoutPool.releaseMediaElements(region.element);
2679
2677
  // Apply region exit transition if configured, then remove
2680
2678
  if (region.config && region.config.exitTransition) {
2681
2679
  const animation = Transitions.apply(
@@ -2838,6 +2836,9 @@ export class RendererLite {
2838
2836
  this.stopWidget(regionId, region.currentIndex);
2839
2837
  }
2840
2838
 
2839
+ // Release video/audio resources before removing from DOM
2840
+ LayoutPool.releaseMediaElements(region.element);
2841
+
2841
2842
  // Apply region exit transition if configured, then remove
2842
2843
  if (region.config && region.config.exitTransition) {
2843
2844
  const animation = Transitions.apply(