@xiboplayer/renderer 0.7.7 → 0.7.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.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,9 +12,9 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "pdfjs-dist": "^4.10.38",
15
- "@xiboplayer/schedule": "0.7.7",
16
- "@xiboplayer/utils": "0.7.7",
17
- "@xiboplayer/cache": "0.7.7"
15
+ "@xiboplayer/utils": "0.7.9",
16
+ "@xiboplayer/cache": "0.7.9",
17
+ "@xiboplayer/schedule": "0.7.9"
18
18
  },
19
19
  "devDependencies": {
20
20
  "jsdom": "^25.0.1",
package/src/index.d.ts CHANGED
@@ -74,6 +74,7 @@ export class RendererLite {
74
74
 
75
75
  updateLayoutDuration(): void;
76
76
  checkLayoutComplete(): void;
77
+ hasActiveLayoutTimer(): boolean;
77
78
 
78
79
  cleanup(): void;
79
80
  }
@@ -167,6 +167,15 @@ export class LayoutPool {
167
167
  * @param {HTMLElement} container
168
168
  */
169
169
  static releaseMediaElements(container) {
170
+ // Defer the actual release by one animation frame to give the GPU compositor
171
+ // time to stop referencing textures from the old layout. Without this delay,
172
+ // the compositor may still hold stale mailbox references when we destroy the
173
+ // video backing, causing SharedImageManager::ProduceSkia "non-existent mailbox"
174
+ // errors (Chrome bug: race in shared_image_manager.cc acknowledged in a TODO).
175
+ requestAnimationFrame(() => LayoutPool._releaseMediaElementsSync(container));
176
+ }
177
+
178
+ static _releaseMediaElementsSync(container) {
170
179
  let videoCount = 0;
171
180
  let hlsCount = 0;
172
181
 
@@ -195,13 +204,43 @@ export class LayoutPool {
195
204
  a.load();
196
205
  });
197
206
 
207
+ // Release media inside iframes (embedded widgets with HLS streams, webcams, etc.)
208
+ // We can't querySelectorAll('video') across iframe boundaries, but we can:
209
+ // 1. Try to access same-origin iframe contentDocument
210
+ // 2. Force-remove the iframe src to stop all network activity
211
+ let iframeCount = 0;
212
+ container.querySelectorAll('iframe').forEach(iframe => {
213
+ try {
214
+ // Same-origin iframes: reach inside and release videos
215
+ const doc = iframe.contentDocument || iframe.contentWindow?.document;
216
+ if (doc) {
217
+ doc.querySelectorAll('video').forEach(v => {
218
+ v.pause();
219
+ v.removeAttribute('src');
220
+ v.load();
221
+ videoCount++;
222
+ });
223
+ doc.querySelectorAll('audio').forEach(a => {
224
+ a.pause();
225
+ a.removeAttribute('src');
226
+ a.load();
227
+ });
228
+ }
229
+ } catch (_) {
230
+ // Cross-origin: can't access contentDocument
231
+ }
232
+ // Force stop all iframe network activity (HLS segments, SSE, WebSocket, etc.)
233
+ iframe.src = 'about:blank';
234
+ iframeCount++;
235
+ });
236
+
198
237
  // Destroy PDF documents and release GPU canvas backing stores
199
238
  container.querySelectorAll('.pdf-widget').forEach(el => {
200
239
  if (el._pdfDestroy) el._pdfDestroy();
201
240
  });
202
241
 
203
- if (videoCount > 0) {
204
- log.info(`Released ${videoCount} video(s)${hlsCount ? ` (${hlsCount} HLS)` : ''}`);
242
+ if (videoCount > 0 || iframeCount > 0) {
243
+ log.info(`Released ${videoCount} video(s)${hlsCount ? ` (${hlsCount} HLS)` : ''}${iframeCount ? `, ${iframeCount} iframe(s)` : ''}`);
205
244
  }
206
245
  }
207
246
 
@@ -211,6 +211,7 @@ export class RendererLite {
211
211
  this.currentLayout = null;
212
212
  this.currentLayoutId = null;
213
213
  this._preloadingLayoutId = null; // Set during preload for blob URL tracking
214
+ this._preloadingPromise = null; // Promise for in-flight preload (await instead of skip)
214
215
  this.regions = new Map(); // regionId => { element, widgets, currentIndex, timer }
215
216
  this.layoutTimer = null;
216
217
  this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
@@ -1911,6 +1912,21 @@ export class RendererLite {
1911
1912
  widgetElement._pdfCleanup();
1912
1913
  }
1913
1914
 
1915
+ // Stop embedded widget iframes (HLS live streams, webcams, etc.)
1916
+ // Setting src=about:blank kills all network activity (HLS segment fetches,
1917
+ // WebSocket connections, SSE streams) and releases video decode buffers.
1918
+ const iframes = widgetElement.querySelectorAll('iframe');
1919
+ for (const iframe of iframes) {
1920
+ try {
1921
+ const doc = iframe.contentDocument || iframe.contentWindow?.document;
1922
+ if (doc) {
1923
+ doc.querySelectorAll('video').forEach(v => { v.pause(); v.removeAttribute('src'); v.load(); });
1924
+ doc.querySelectorAll('audio').forEach(a => { a.pause(); a.removeAttribute('src'); a.load(); });
1925
+ }
1926
+ } catch (_) {}
1927
+ iframe.src = 'about:blank';
1928
+ }
1929
+
1914
1930
  return { widget, animPromise };
1915
1931
  }
1916
1932
 
@@ -2872,13 +2888,20 @@ export class RendererLite {
2872
2888
  return true;
2873
2889
  }
2874
2890
 
2875
- // Don't preload if already in-flight (prevents triple preload when
2876
- // 75% and 90% timers both fire before the async preload completes)
2877
- if (this._preloadingLayoutId === layoutId) {
2878
- this.log.info(`Layout ${layoutId} preload already in-flight, skipping`);
2879
- return true;
2891
+ // If already in-flight, wait for it instead of skipping (prevents the race
2892
+ // where showLayout is called before the background preload finishes adding
2893
+ // the layout to the pool).
2894
+ if (this._preloadingLayoutId === layoutId && this._preloadingPromise) {
2895
+ this.log.info(`Layout ${layoutId} preload in-flight, waiting for it...`);
2896
+ return this._preloadingPromise;
2880
2897
  }
2881
2898
 
2899
+ // Store the preload promise so concurrent callers can await it
2900
+ this._preloadingPromise = this._doPreloadLayout(xlfXml, layoutId);
2901
+ return this._preloadingPromise;
2902
+ }
2903
+
2904
+ async _doPreloadLayout(xlfXml, layoutId) {
2882
2905
  try {
2883
2906
  this.log.info(`Preloading layout ${layoutId} into pool...`);
2884
2907
 
@@ -2981,6 +3004,11 @@ export class RendererLite {
2981
3004
  } catch (error) {
2982
3005
  this.log.error(`Preload failed for layout ${layoutId}:`, error);
2983
3006
  return false;
3007
+ } finally {
3008
+ if (this._preloadingLayoutId === layoutId) {
3009
+ this._preloadingLayoutId = null;
3010
+ this._preloadingPromise = null;
3011
+ }
2984
3012
  }
2985
3013
  }
2986
3014
 
@@ -3192,6 +3220,15 @@ export class RendererLite {
3192
3220
  this._swapToPreloadedLayout(layoutId);
3193
3221
  }
3194
3222
 
3223
+ /**
3224
+ * Check if the layout timer is active (running or deferred waiting for metadata).
3225
+ * Used to detect stalled layouts that need timer restart.
3226
+ * @returns {boolean}
3227
+ */
3228
+ hasActiveLayoutTimer() {
3229
+ return this.layoutTimer !== null || this._deferredTimerLayoutId !== null;
3230
+ }
3231
+
3195
3232
  /**
3196
3233
  * Check if all regions have completed one full cycle
3197
3234
  * This is informational only - layout timer is authoritative
@@ -817,12 +817,9 @@ describe('RendererLite', () => {
817
817
  const region = { id: 'r1', widgets: [widget1, widget2], isDrawer: false };
818
818
  renderer.regions = new Map([['r1', region]]);
819
819
 
820
- // One still unprobed
821
- expect(renderer._hasUnprobedVideos()).toBe(true);
822
-
823
- // Probe the second
824
- widget2.duration = 30;
825
- widget2._probed = true;
820
+ // Once any video is probed, updateLayoutDuration has already run with
821
+ // real metadata, so _hasUnprobedVideos() returns false — no further
822
+ // deferral needed.
826
823
  expect(renderer._hasUnprobedVideos()).toBe(false);
827
824
  });
828
825
  });