@xiboplayer/renderer 0.6.12 → 0.7.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.6.12",
3
+ "version": "0.7.0",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -13,9 +13,9 @@
13
13
  "dependencies": {
14
14
  "nanoevents": "^9.1.0",
15
15
  "pdfjs-dist": "^4.10.38",
16
- "@xiboplayer/schedule": "0.6.12",
17
- "@xiboplayer/utils": "0.6.12",
18
- "@xiboplayer/cache": "0.6.12"
16
+ "@xiboplayer/cache": "0.7.0",
17
+ "@xiboplayer/utils": "0.7.0",
18
+ "@xiboplayer/schedule": "0.7.0"
19
19
  },
20
20
  "devDependencies": {
21
21
  "vitest": "^2.0.0",
package/src/index.d.ts CHANGED
@@ -63,6 +63,7 @@ export class RendererLite {
63
63
  resume(): void;
64
64
  isPaused(): boolean;
65
65
  resumeRegionMedia?(regionId: string): void;
66
+ showLayout(layoutId?: number): void;
66
67
 
67
68
  parseXlf(xlfXml: string): any;
68
69
  parseWidget(mediaEl: Element): any;
@@ -279,6 +279,18 @@ export class LayoutPool {
279
279
  return count;
280
280
  }
281
281
 
282
+ /**
283
+ * Get the most recently added layout ID.
284
+ * @returns {number|undefined}
285
+ */
286
+ getLatest() {
287
+ let latest;
288
+ for (const id of this.layouts.keys()) {
289
+ latest = id;
290
+ }
291
+ return latest;
292
+ }
293
+
282
294
  /**
283
295
  * Clear all entries (both hot and warm).
284
296
  */
@@ -215,6 +215,7 @@ export class RendererLite {
215
215
  this.layoutTimer = null;
216
216
  this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
217
217
  this._deferredTimerLayoutId = null; // Set when timer is deferred for dynamic layouts
218
+ this._deferredTimerFallback = null; // Safety timeout: starts layout timer if metadata never arrives
218
219
  this._paused = false;
219
220
  this._layoutTimerStartedAt = null; // Date.now() when layout timer started
220
221
  this._layoutTimerDurationMs = null; // Total layout duration in ms
@@ -720,6 +721,11 @@ export class RendererLite {
720
721
  if (this._hasUnprobedVideos()) {
721
722
  this.log.info(`Layout duration updated to ${maxRegionDuration}s but still has unprobed videos — keeping timer deferred`);
722
723
  } else {
724
+ // Cancel safety fallback — metadata arrived in time
725
+ if (this._deferredTimerFallback) {
726
+ clearTimeout(this._deferredTimerFallback);
727
+ this._deferredTimerFallback = null;
728
+ }
723
729
  const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
724
730
  const remainingMs = Math.max(1000, maxRegionDuration * 1000 - elapsed);
725
731
  this._deferredTimerLayoutId = null;
@@ -1205,8 +1211,13 @@ export class RendererLite {
1205
1211
  clearTimeout(this.layoutTimer);
1206
1212
  this.layoutTimer = null;
1207
1213
  }
1208
- this._deferredTimerLayoutId = null;
1214
+
1209
1215
  this.layoutEndEmitted = false;
1216
+ this._deferredTimerLayoutId = null;
1217
+ if (this._deferredTimerFallback) {
1218
+ clearTimeout(this._deferredTimerFallback);
1219
+ this._deferredTimerFallback = null;
1220
+ }
1210
1221
 
1211
1222
  // DON'T call stopCurrentLayout() - keep elements alive!
1212
1223
  // DON'T recreate regions/elements - already exist!
@@ -1599,12 +1610,24 @@ export class RendererLite {
1599
1610
  }
1600
1611
 
1601
1612
  // Dynamic layouts (useDuration=0 videos): defer timer until video metadata
1602
- // provides real durations. Without this, the 60s fallback fires prematurely
1603
- // causing rapid layout cycling ("layout storm") on startup.
1613
+ // provides real durations. Safety timeout ensures corrupt/missing videos
1614
+ // don't freeze the display forever.
1604
1615
  if (layout.isDynamic && this._hasUnprobedVideos()) {
1605
1616
  this._deferredTimerLayoutId = layoutId;
1606
1617
  this._layoutTimerStartedAt = Date.now();
1607
1618
  this.log.info(`Layout ${layoutId} has unprobed videos — deferring timer until metadata loads`);
1619
+
1620
+ // Safety: if metadata never arrives (corrupt file, codec error), start
1621
+ // the timer with the estimated duration after 30s so the display keeps cycling.
1622
+ this._deferredTimerFallback = setTimeout(() => {
1623
+ this._deferredTimerFallback = null;
1624
+ if (this._deferredTimerLayoutId === layoutId && !this.layoutTimer) {
1625
+ this.log.warn(`Layout ${layoutId}: metadata timeout after 30s — starting timer with ${layout.duration}s estimate`);
1626
+ this._deferredTimerLayoutId = null;
1627
+ this._startLayoutTimer(layoutId, layout);
1628
+ }
1629
+ }, 30000);
1630
+
1608
1631
  return;
1609
1632
  }
1610
1633
 
@@ -1628,6 +1651,10 @@ export class RendererLite {
1628
1651
  */
1629
1652
  _startLayoutTimer(layoutId, layout) {
1630
1653
  this._deferredTimerLayoutId = null;
1654
+ if (this._deferredTimerFallback) {
1655
+ clearTimeout(this._deferredTimerFallback);
1656
+ this._deferredTimerFallback = null;
1657
+ }
1631
1658
  const layoutDurationMs = layout.duration * 1000;
1632
1659
  this.log.info(`Layout ${layoutId} will end after ${layout.duration}s`);
1633
1660
 
@@ -2274,6 +2301,18 @@ export class RendererLite {
2274
2301
  const errorCode = error?.code;
2275
2302
  const errorMessage = error?.message || 'Unknown error';
2276
2303
  this.log.warn(`Video error: ${storedAs}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
2304
+
2305
+ // Set fallback duration so the deferred timer can proceed.
2306
+ // Without this, a corrupt video leaves widget.duration=0 forever,
2307
+ // _hasUnprobedVideos() stays true, and the deferred timer never unblocks.
2308
+ if (widget.useDuration === 0 && widget.duration === 0) {
2309
+ widget.duration = 60;
2310
+ this.log.info(`Set fallback duration 60s for errored widget ${widget.id}`);
2311
+ if (this.currentLayoutId === createdForLayoutId) {
2312
+ this.updateLayoutDuration();
2313
+ }
2314
+ }
2315
+
2277
2316
  this.emit('videoError', { storedAs, fileId, errorCode, errorMessage, currentTime: video.currentTime });
2278
2317
  };
2279
2318
  video.addEventListener('error', onError);
@@ -2471,24 +2510,6 @@ export class RendererLite {
2471
2510
  // Use cache URL — SW serves HTML and intercepts sub-resources
2472
2511
  iframe.src = result.url;
2473
2512
 
2474
- // On hard reload (Ctrl+Shift+R), iframe navigation bypasses SW → server 404
2475
- // Detect and fall back to blob URL with original CMS signed URLs
2476
- if (result.fallback) {
2477
- const self = this;
2478
- iframe.addEventListener('load', function() {
2479
- try {
2480
- // Our cached widget HTML has a <base> tag; server 404 page doesn't
2481
- if (!iframe.contentDocument?.querySelector('base')) {
2482
- self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
2483
- const blob = new Blob([result.fallback], { type: 'text/html' });
2484
- const blobUrl = URL.createObjectURL(blob);
2485
- self.trackBlobUrl(blobUrl);
2486
- iframe.src = blobUrl;
2487
- }
2488
- } catch (e) { /* cross-origin — should not happen */ }
2489
- }, { once: true });
2490
- }
2491
-
2492
2513
  // Parse NUMITEMS/DURATION from fallback HTML (cache path)
2493
2514
  if (result.fallback) {
2494
2515
  this._parseDurationComments(result.fallback, widget);
@@ -2710,24 +2731,6 @@ export class RendererLite {
2710
2731
  // Use cache URL — SW serves HTML and intercepts sub-resources
2711
2732
  iframe.src = result.url;
2712
2733
 
2713
- // On hard reload (Ctrl+Shift+R), iframe navigation bypasses SW → server 404
2714
- // Detect and fall back to blob URL with original CMS signed URLs
2715
- if (result.fallback) {
2716
- const self = this;
2717
- iframe.addEventListener('load', function() {
2718
- try {
2719
- // Our cached widget HTML has a <base> tag; server 404 page doesn't
2720
- if (!iframe.contentDocument?.querySelector('base')) {
2721
- self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
2722
- const blob = new Blob([result.fallback], { type: 'text/html' });
2723
- const blobUrl = URL.createObjectURL(blob);
2724
- self.trackBlobUrl(blobUrl);
2725
- iframe.src = blobUrl;
2726
- }
2727
- } catch (e) { /* cross-origin — should not happen */ }
2728
- }, { once: true });
2729
- }
2730
-
2731
2734
  // Parse NUMITEMS/DURATION from fallback HTML (cache path)
2732
2735
  if (result.fallback) {
2733
2736
  this._parseDurationComments(result.fallback, widget);
@@ -3109,6 +3112,33 @@ export class RendererLite {
3109
3112
  this.log.info(`Swapped to preloaded layout ${layoutId} (instant transition)`);
3110
3113
  }
3111
3114
 
3115
+ /**
3116
+ * Show a preloaded layout (swap from pool to visible).
3117
+ * If no layoutId, shows the most recently preloaded layout.
3118
+ * No-ops if the layout is not in the pool.
3119
+ * @param {number} [layoutId]
3120
+ */
3121
+ showLayout(layoutId) {
3122
+ if (layoutId === undefined) {
3123
+ layoutId = this.layoutPool.getLatest();
3124
+ if (layoutId === undefined) {
3125
+ this.log.warn('showLayout: no preloaded layout to show');
3126
+ return;
3127
+ }
3128
+ }
3129
+ // Same layout already showing — skip swap (self-swap would evict then fail).
3130
+ // Same-layout replay is handled by renderLayout's replay path instead.
3131
+ if (this.currentLayoutId === layoutId) {
3132
+ this.log.info(`showLayout: layout ${layoutId} already showing`);
3133
+ return;
3134
+ }
3135
+ if (!this.layoutPool.has(layoutId)) {
3136
+ this.log.warn(`showLayout: layout ${layoutId} not in preload pool`);
3137
+ return;
3138
+ }
3139
+ this._swapToPreloadedLayout(layoutId);
3140
+ }
3141
+
3112
3142
  /**
3113
3143
  * Check if all regions have completed one full cycle
3114
3144
  * This is informational only - layout timer is authoritative
@@ -3144,6 +3174,10 @@ export class RendererLite {
3144
3174
 
3145
3175
  this.layoutEndEmitted = false;
3146
3176
  this._deferredTimerLayoutId = null;
3177
+ if (this._deferredTimerFallback) {
3178
+ clearTimeout(this._deferredTimerFallback);
3179
+ this._deferredTimerFallback = null;
3180
+ }
3147
3181
  this.currentLayout = null;
3148
3182
  this.currentLayoutId = null;
3149
3183
 
@@ -1230,6 +1230,38 @@ describe('RendererLite', () => {
1230
1230
 
1231
1231
  vi.useRealTimers();
1232
1232
  });
1233
+
1234
+ it('should show a preloaded layout via showLayout()', async () => {
1235
+ const xlf = `<layout><region id="r1"></region></layout>`;
1236
+ const layoutStartHandler = vi.fn();
1237
+ renderer.on('layoutStart', layoutStartHandler);
1238
+
1239
+ // Preload layout hidden
1240
+ await renderer.preloadLayout(xlf, 42);
1241
+ expect(renderer.currentLayoutId).not.toBe(42);
1242
+ expect(layoutStartHandler).not.toHaveBeenCalled();
1243
+
1244
+ // Show it
1245
+ renderer.showLayout(42);
1246
+ expect(renderer.currentLayoutId).toBe(42);
1247
+ expect(layoutStartHandler).toHaveBeenCalledWith(42, expect.any(Object));
1248
+ });
1249
+
1250
+ it('should show the latest preloaded layout when no id given', async () => {
1251
+ const xlf1 = `<layout bgcolor="#ff0000"><region id="r1"></region></layout>`;
1252
+ const xlf2 = `<layout bgcolor="#00ff00"><region id="r2"></region></layout>`;
1253
+
1254
+ await renderer.preloadLayout(xlf1, 10);
1255
+ await renderer.preloadLayout(xlf2, 20);
1256
+
1257
+ renderer.showLayout();
1258
+ expect(renderer.currentLayoutId).toBe(20);
1259
+ });
1260
+
1261
+ it('should no-op showLayout when pool is empty', () => {
1262
+ renderer.showLayout(999);
1263
+ expect(renderer.currentLayoutId).toBeNull();
1264
+ });
1233
1265
  });
1234
1266
 
1235
1267
  describe('Layout Replay Optimization', () => {