@xiboplayer/renderer 0.5.7 → 0.5.9

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.7",
3
+ "version": "0.5.9",
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.5.7",
16
- "@xiboplayer/utils": "0.5.7"
15
+ "@xiboplayer/utils": "0.5.9",
16
+ "@xiboplayer/cache": "0.5.9"
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};
@@ -689,30 +689,33 @@ export class RendererLite {
689
689
  maxRegionDuration = Math.max(maxRegionDuration, regionDuration);
690
690
  }
691
691
 
692
- // If we calculated a different duration, update layout
693
- if (maxRegionDuration > 0 && maxRegionDuration !== this.currentLayout.duration) {
692
+ // If we calculated a LONGER duration, update layout.
693
+ // Never downgrade — widgets with useDuration=0 start at duration=0
694
+ // until loadedmetadata fires, so early calculations undercount.
695
+ if (maxRegionDuration > 0 && maxRegionDuration > this.currentLayout.duration) {
694
696
  const oldDuration = this.currentLayout.duration;
695
697
  this.currentLayout.duration = maxRegionDuration;
696
698
 
697
699
  this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
698
700
  this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration);
699
701
 
700
- // Reset layout timer with new durationbut only if a timer is already running.
702
+ // Reset layout timer with REMAINING timenot full duration.
701
703
  // If startLayoutTimerWhenReady() hasn't fired yet (still waiting for widgets),
702
704
  // it will pick up the updated duration when it starts the timer.
703
705
  if (this.layoutTimer) {
704
706
  clearTimeout(this.layoutTimer);
705
707
 
706
- const layoutDurationMs = this.currentLayout.duration * 1000;
708
+ const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
709
+ const remainingMs = Math.max(1000, this.currentLayout.duration * 1000 - elapsed);
707
710
  this.layoutTimer = setTimeout(() => {
708
711
  this.log.info(`Layout ${this.currentLayoutId} duration expired (${this.currentLayout.duration}s)`);
709
712
  if (this.currentLayoutId) {
710
713
  this.layoutEndEmitted = true;
711
714
  this.emit('layoutEnd', this.currentLayoutId);
712
715
  }
713
- }, layoutDurationMs);
716
+ }, remainingMs);
714
717
 
715
- this.log.info(`Layout timer reset to ${this.currentLayout.duration}s`);
718
+ this.log.info(`Layout timer adjusted to ${(remainingMs / 1000).toFixed(1)}s remaining (elapsed ${(elapsed / 1000).toFixed(1)}s of ${this.currentLayout.duration}s)`);
716
719
  } else {
717
720
  this.log.info(`Layout duration updated to ${maxRegionDuration}s (timer not yet started, will use new value)`);
718
721
  }
@@ -1920,10 +1923,12 @@ export class RendererLite {
1920
1923
  const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
1921
1924
  hls.loadSource(videoSrc);
1922
1925
  hls.attachMedia(video);
1926
+ video._hlsInstance = hls; // Store for cleanup on eviction
1923
1927
  hls.on(Hls.Events.ERROR, (_event, data) => {
1924
1928
  if (data.fatal) {
1925
1929
  this.log.error(`HLS fatal error: ${data.type}`, data.details);
1926
1930
  hls.destroy();
1931
+ video._hlsInstance = null;
1927
1932
  }
1928
1933
  });
1929
1934
  this.log.info(`HLS stream (hls.js): ${fileId}`);
@@ -1941,17 +1946,28 @@ export class RendererLite {
1941
1946
  }
1942
1947
 
1943
1948
  // Detect video duration for dynamic layout timing (when useDuration=0)
1949
+ // Capture the layout ID at creation time — if the layout changes before
1950
+ // loadedmetadata fires (e.g. video was preloaded for next layout), we must
1951
+ // NOT update the current layout's duration with a different layout's video.
1952
+ const createdForLayoutId = this.currentLayoutId;
1944
1953
  video.addEventListener('loadedmetadata', () => {
1945
1954
  const videoDuration = Math.floor(video.duration);
1946
1955
  this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
1947
1956
 
1948
- // If widget has useDuration=0, update widget duration with actual video length
1957
+ // Always update widget duration it's the widget's own data, safe
1958
+ // even if this video was preloaded for a different layout.
1949
1959
  if (widget.duration === 0 || widget.useDuration === 0) {
1950
1960
  widget.duration = videoDuration;
1951
1961
  this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
1952
1962
 
1953
- // Recalculate layout duration if needed
1954
- this.updateLayoutDuration();
1963
+ // Only recalculate current layout's timer if this video belongs to it.
1964
+ // Preloaded layouts will pick up the corrected widget.duration when
1965
+ // they start playing (via updateLayoutDuration() in swapToPreloadedLayout).
1966
+ if (this.currentLayoutId === createdForLayoutId) {
1967
+ this.updateLayoutDuration();
1968
+ } else {
1969
+ this.log.info(`Video ${fileId} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
1970
+ }
1955
1971
  }
1956
1972
  });
1957
1973
 
@@ -2086,6 +2102,7 @@ export class RendererLite {
2086
2102
  });
2087
2103
 
2088
2104
  // Detect audio duration for dynamic layout timing (when useDuration=0)
2105
+ const audioCreatedForLayoutId = this.currentLayoutId;
2089
2106
  audio.addEventListener('loadedmetadata', () => {
2090
2107
  const audioDuration = Math.floor(audio.duration);
2091
2108
  this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
@@ -2093,7 +2110,12 @@ export class RendererLite {
2093
2110
  if (widget.duration === 0 || widget.useDuration === 0) {
2094
2111
  widget.duration = audioDuration;
2095
2112
  this.log.info(`Updated widget ${widget.id} duration to ${audioDuration}s (useDuration=0)`);
2096
- this.updateLayoutDuration();
2113
+
2114
+ if (this.currentLayoutId === audioCreatedForLayoutId) {
2115
+ this.updateLayoutDuration();
2116
+ } else {
2117
+ this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2118
+ }
2097
2119
  }
2098
2120
  });
2099
2121
 
@@ -2650,12 +2672,8 @@ export class RendererLite {
2650
2672
  clearTimeout(region.timer);
2651
2673
  region.timer = null;
2652
2674
  }
2653
- // Release video resources
2654
- region.element.querySelectorAll('video').forEach(v => {
2655
- v.pause();
2656
- v.removeAttribute('src');
2657
- v.load();
2658
- });
2675
+ // Release video/audio resources before removing from DOM
2676
+ LayoutPool.releaseMediaElements(region.element);
2659
2677
  // Apply region exit transition if configured, then remove
2660
2678
  if (region.config && region.config.exitTransition) {
2661
2679
  const animation = Transitions.apply(
@@ -2818,6 +2836,9 @@ export class RendererLite {
2818
2836
  this.stopWidget(regionId, region.currentIndex);
2819
2837
  }
2820
2838
 
2839
+ // Release video/audio resources before removing from DOM
2840
+ LayoutPool.releaseMediaElements(region.element);
2841
+
2821
2842
  // Apply region exit transition if configured, then remove
2822
2843
  if (region.config && region.config.exitTransition) {
2823
2844
  const animation = Transitions.apply(