@xiboplayer/renderer 0.7.2 → 0.7.4

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.2",
3
+ "version": "0.7.4",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,13 +12,13 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "pdfjs-dist": "^4.10.38",
15
- "@xiboplayer/cache": "0.7.2",
16
- "@xiboplayer/schedule": "0.7.2",
17
- "@xiboplayer/utils": "0.7.2"
15
+ "@xiboplayer/cache": "0.7.4",
16
+ "@xiboplayer/schedule": "0.7.4",
17
+ "@xiboplayer/utils": "0.7.4"
18
18
  },
19
19
  "devDependencies": {
20
- "vitest": "^2.0.0",
21
- "jsdom": "^25.0.0"
20
+ "jsdom": "^25.0.1",
21
+ "vitest": "^2.1.9"
22
22
  },
23
23
  "keywords": [
24
24
  "xibo",
package/src/index.d.ts CHANGED
@@ -21,8 +21,8 @@ export class LayoutPool {
21
21
  get(layoutId: number): any | undefined;
22
22
  add(layoutId: number, entry: any): void;
23
23
  clearWarmNotIn(keepIds: Set<number>): number;
24
- makeHot(layoutId: number): void;
25
- remove(layoutId: number): void;
24
+ setHot(layoutId: number): void;
25
+ evict(layoutId: number): void;
26
26
  clear(): void;
27
27
  }
28
28
 
@@ -59,10 +59,10 @@ export class RendererLite {
59
59
  nextWidget(regionId?: string): void;
60
60
  previousWidget(regionId?: string): void;
61
61
 
62
+ resumeRegionMedia(regionId: string): void;
62
63
  pause(): void;
63
64
  resume(): void;
64
65
  isPaused(): boolean;
65
- resumeRegionMedia?(regionId: string): void;
66
66
  showLayout(layoutId?: number): void;
67
67
  getCurrentLayoutId(): number | null;
68
68
 
@@ -219,7 +219,6 @@ export class RendererLite {
219
219
  this._paused = false;
220
220
  this._layoutTimerStartedAt = null; // Date.now() when layout timer started
221
221
  this._layoutTimerDurationMs = null; // Total layout duration in ms
222
- this.widgetTimers = new Map(); // widgetId => timer
223
222
  this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
224
223
  this.audioOverlays = new Map(); // widgetId => [HTMLAudioElement] (audio overlays for widgets)
225
224
 
@@ -1339,26 +1338,64 @@ export class RendererLite {
1339
1338
  }
1340
1339
 
1341
1340
  /**
1342
- * Create a region element
1343
- * @param {Object} regionConfig - Region configuration
1341
+ * Build a region DOM element and state entry.
1342
+ * Shared by createRegion, preloadLayout, and renderOverlay.
1343
+ *
1344
+ * @param {Object} regionConfig - Region configuration from parsed XLF
1345
+ * @param {string} elementId - DOM element ID for the region div
1346
+ * @param {HTMLElement} parentEl - Parent element to append the region to
1347
+ * @param {Object} [extraState] - Additional properties merged into region state
1348
+ * @returns {Object} Region state object { element, config, widgets, ... }
1344
1349
  */
1345
- async createRegion(regionConfig) {
1350
+ _createRegionEntry(regionConfig, elementId, parentEl, extraState = {}) {
1351
+ const { className = 'renderer-lite-region', ...stateProps } = extraState;
1352
+
1346
1353
  const regionEl = document.createElement('div');
1347
- regionEl.id = `region_${regionConfig.id}`;
1348
- regionEl.className = 'renderer-lite-region';
1354
+ regionEl.id = elementId;
1355
+ regionEl.className = className;
1349
1356
  regionEl.style.position = 'absolute';
1350
- regionEl.style.zIndex = regionConfig.zindex;
1357
+ regionEl.style.zIndex = String(regionConfig.zindex);
1351
1358
  regionEl.style.overflow = 'hidden';
1352
1359
 
1353
- // Drawer regions start fully hidden — shown only by navWidget actions
1354
- if (regionConfig.isDrawer) {
1355
- regionEl.style.display = 'none';
1356
- }
1357
-
1358
1360
  // Apply scaled positioning
1359
1361
  this.applyRegionScale(regionEl, regionConfig);
1360
1362
 
1361
- this.container.appendChild(regionEl);
1363
+ parentEl.appendChild(regionEl);
1364
+
1365
+ const sf = this.scaleFactor;
1366
+ return {
1367
+ element: regionEl,
1368
+ config: regionConfig,
1369
+ widgets: regionConfig.widgets,
1370
+ currentIndex: 0,
1371
+ timer: null,
1372
+ width: regionConfig.width * sf,
1373
+ height: regionConfig.height * sf,
1374
+ complete: false,
1375
+ widgetElements: new Map(),
1376
+ ...stateProps,
1377
+ };
1378
+ }
1379
+
1380
+ /**
1381
+ * Create a region element
1382
+ * @param {Object} regionConfig - Region configuration
1383
+ */
1384
+ async createRegion(regionConfig) {
1385
+ const region = this._createRegionEntry(
1386
+ regionConfig,
1387
+ `region_${regionConfig.id}`,
1388
+ this.container,
1389
+ {
1390
+ isDrawer: regionConfig.isDrawer || false,
1391
+ isCanvas: regionConfig.isCanvas || false,
1392
+ }
1393
+ );
1394
+
1395
+ // Drawer regions start fully hidden — shown only by navWidget actions
1396
+ if (regionConfig.isDrawer) {
1397
+ region.element.style.display = 'none';
1398
+ }
1362
1399
 
1363
1400
  // Filter expired widgets (fromDt/toDt time-gating within XLF)
1364
1401
  let widgets = regionConfig.widgets.filter(w => this._isWidgetActive(w));
@@ -1367,22 +1404,9 @@ export class RendererLite {
1367
1404
  if (widgets.some(w => w.cyclePlayback)) {
1368
1405
  widgets = this._applyCyclePlayback(widgets);
1369
1406
  }
1407
+ region.widgets = widgets;
1370
1408
 
1371
- // Store region state (dimensions use scaled values for transitions)
1372
- const sf = this.scaleFactor;
1373
- this.regions.set(regionConfig.id, {
1374
- element: regionEl,
1375
- config: regionConfig,
1376
- widgets,
1377
- currentIndex: 0,
1378
- timer: null,
1379
- width: regionConfig.width * sf,
1380
- height: regionConfig.height * sf,
1381
- complete: false, // Track if region has played all widgets once
1382
- isDrawer: regionConfig.isDrawer || false,
1383
- isCanvas: regionConfig.isCanvas || false, // Canvas regions render all widgets simultaneously
1384
- widgetElements: new Map() // widgetId -> DOM element (for element reuse)
1385
- });
1409
+ this.regions.set(regionConfig.id, region);
1386
1410
  }
1387
1411
 
1388
1412
  /**
@@ -1493,11 +1517,10 @@ export class RendererLite {
1493
1517
  el.play().catch(() => {});
1494
1518
  };
1495
1519
  el.addEventListener('seeked', playAfterSeek);
1496
- // Fallback: if seeked doesn't fire (already at 0), try play directly
1497
- if (el.currentTime === 0 && el.readyState >= 2) {
1498
- el.removeEventListener('seeked', playAfterSeek);
1499
- el.play().catch(() => {});
1500
- }
1520
+ // Always call play() for preloaded-then-paused videos, seeked may not
1521
+ // fire (currentTime already 0) and readyState may be < 2 (not buffered yet).
1522
+ // play() handles both cases: if not ready, it queues; if ready, it plays.
1523
+ el.play().catch(() => {});
1501
1524
  }
1502
1525
 
1503
1526
  /**
@@ -2276,10 +2299,9 @@ export class RendererLite {
2276
2299
  }
2277
2300
 
2278
2301
  // Detect video duration for dynamic layout timing (when useDuration=0)
2279
- // Capture the layout ID at creation time — if the layout changes before
2280
- // loadedmetadata fires (e.g. video was preloaded for next layout), we must
2281
- // NOT update the current layout's duration with a different layout's video.
2282
- const createdForLayoutId = this.currentLayoutId;
2302
+ // Capture the layout ID at creation time — during preload, _preloadingLayoutId
2303
+ // is the target layout (currentLayoutId is still the playing layout).
2304
+ const createdForLayoutId = this._preloadingLayoutId || this.currentLayoutId;
2283
2305
  const onLoadedMetadata = () => {
2284
2306
  const videoDuration = video.duration;
2285
2307
  this.log.info(`Video ${storedAs} duration detected: ${videoDuration}s`);
@@ -2440,7 +2462,7 @@ export class RendererLite {
2440
2462
  audio.addEventListener('ended', onAudioEnded);
2441
2463
 
2442
2464
  // Detect audio duration for dynamic layout timing (when useDuration=0)
2443
- const audioCreatedForLayoutId = this.currentLayoutId;
2465
+ const audioCreatedForLayoutId = this._preloadingLayoutId || this.currentLayoutId;
2444
2466
  const onAudioLoadedMetadata = () => {
2445
2467
  const audioDuration = Math.floor(audio.duration);
2446
2468
  this.log.info(`Audio ${storedAs} duration detected: ${audioDuration}s`);
@@ -2502,45 +2524,7 @@ export class RendererLite {
2502
2524
  * Render text/ticker widget
2503
2525
  */
2504
2526
  async renderTextWidget(widget, region) {
2505
- const iframe = document.createElement('iframe');
2506
- iframe.className = 'renderer-lite-widget';
2507
- iframe.style.width = '100%';
2508
- iframe.style.height = '100%';
2509
- iframe.style.border = 'none';
2510
- iframe.style.opacity = '0';
2511
-
2512
- // Get widget HTML (may return { url } for cache-path loading or string for blob)
2513
- let html = widget.raw;
2514
- if (this.options.getWidgetHtml) {
2515
- const result = await this.options.getWidgetHtml(widget);
2516
- if (result && typeof result === 'object' && result.url) {
2517
- // Use cache URL — SW serves HTML and intercepts sub-resources
2518
- iframe.src = result.url;
2519
-
2520
- // Parse NUMITEMS/DURATION from fallback HTML (cache path)
2521
- if (result.fallback) {
2522
- this._parseDurationComments(result.fallback, widget);
2523
- }
2524
-
2525
- return iframe;
2526
- }
2527
- html = result;
2528
- }
2529
-
2530
- if (html) {
2531
- // Parse NUMITEMS/DURATION HTML comments for dynamic widget duration
2532
- this._parseDurationComments(html, widget);
2533
- }
2534
-
2535
- // Fallback: Create blob URL for iframe
2536
- const blob = new Blob([html], { type: 'text/html' });
2537
- const blobUrl = URL.createObjectURL(blob);
2538
- iframe.src = blobUrl;
2539
-
2540
- // Track blob URL for lifecycle management
2541
- this.trackBlobUrl(blobUrl);
2542
-
2543
- return iframe;
2527
+ return await this._renderIframeWidget(widget, region);
2544
2528
  }
2545
2529
 
2546
2530
  /**
@@ -2723,6 +2707,15 @@ export class RendererLite {
2723
2707
  * Render generic widget (clock, calendar, weather, etc.)
2724
2708
  */
2725
2709
  async renderGenericWidget(widget, region) {
2710
+ return await this._renderIframeWidget(widget, region);
2711
+ }
2712
+
2713
+ /**
2714
+ * Shared iframe rendering for text/ticker and generic widgets.
2715
+ * Creates an iframe, resolves widget HTML via getWidgetHtml (cache URL or blob),
2716
+ * and parses NUMITEMS/DURATION comments for dynamic widget duration.
2717
+ */
2718
+ async _renderIframeWidget(widget, region) {
2726
2719
  const iframe = document.createElement('iframe');
2727
2720
  iframe.className = 'renderer-lite-widget';
2728
2721
  iframe.style.width = '100%';
@@ -2886,33 +2879,12 @@ export class RendererLite {
2886
2879
 
2887
2880
  // Create regions in the hidden wrapper
2888
2881
  const preloadRegions = new Map();
2889
- const sf = this.scaleFactor;
2890
-
2891
2882
  for (const regionConfig of layout.regions) {
2892
- const regionEl = document.createElement('div');
2893
- regionEl.id = `preload_region_${layoutId}_${regionConfig.id}`;
2894
- regionEl.className = 'renderer-lite-region';
2895
- regionEl.style.position = 'absolute';
2896
- regionEl.style.zIndex = regionConfig.zindex;
2897
- regionEl.style.overflow = 'hidden';
2898
-
2899
- // Apply scaled positioning
2900
- this.applyRegionScale(regionEl, regionConfig);
2901
-
2902
- wrapper.appendChild(regionEl);
2903
-
2904
- const region = {
2905
- element: regionEl,
2906
- config: regionConfig,
2907
- widgets: regionConfig.widgets,
2908
- currentIndex: 0,
2909
- timer: null,
2910
- width: regionConfig.width * sf,
2911
- height: regionConfig.height * sf,
2912
- complete: false,
2913
- widgetElements: new Map()
2914
- };
2915
-
2883
+ const region = this._createRegionEntry(
2884
+ regionConfig,
2885
+ `preload_region_${layoutId}_${regionConfig.id}`,
2886
+ wrapper
2887
+ );
2916
2888
  preloadRegions.set(regionConfig.id, region);
2917
2889
  }
2918
2890
 
@@ -2993,20 +2965,7 @@ export class RendererLite {
2993
2965
 
2994
2966
  // ── Tear down old layout ──
2995
2967
  this.removeActionListeners();
2996
-
2997
- if (this.layoutTimer) {
2998
- clearTimeout(this.layoutTimer);
2999
- this.layoutTimer = null;
3000
- }
3001
-
3002
- if (this.preloadTimer) {
3003
- clearTimeout(this.preloadTimer);
3004
- this.preloadTimer = null;
3005
- }
3006
- if (this._preloadRetryTimer) {
3007
- clearTimeout(this._preloadRetryTimer);
3008
- this._preloadRetryTimer = null;
3009
- }
2968
+ this._clearLayoutTimers();
3010
2969
 
3011
2970
  const oldLayoutId = this.currentLayoutId;
3012
2971
  const alreadyEmittedEnd = this.layoutEndEmitted;
@@ -3176,6 +3135,24 @@ export class RendererLite {
3176
3135
  }
3177
3136
  }
3178
3137
 
3138
+ /**
3139
+ * Clear all layout-level timers (layout duration, preload, preload retry).
3140
+ */
3141
+ _clearLayoutTimers() {
3142
+ if (this.layoutTimer) {
3143
+ clearTimeout(this.layoutTimer);
3144
+ this.layoutTimer = null;
3145
+ }
3146
+ if (this.preloadTimer) {
3147
+ clearTimeout(this.preloadTimer);
3148
+ this.preloadTimer = null;
3149
+ }
3150
+ if (this._preloadRetryTimer) {
3151
+ clearTimeout(this._preloadRetryTimer);
3152
+ this._preloadRetryTimer = null;
3153
+ }
3154
+ }
3155
+
3179
3156
  /**
3180
3157
  * Stop current layout
3181
3158
  */
@@ -3197,18 +3174,7 @@ export class RendererLite {
3197
3174
  this.currentLayoutId = null;
3198
3175
 
3199
3176
  // Clear timers
3200
- if (this.layoutTimer) {
3201
- clearTimeout(this.layoutTimer);
3202
- this.layoutTimer = null;
3203
- }
3204
- if (this.preloadTimer) {
3205
- clearTimeout(this.preloadTimer);
3206
- this.preloadTimer = null;
3207
- }
3208
- if (this._preloadRetryTimer) {
3209
- clearTimeout(this._preloadRetryTimer);
3210
- this._preloadRetryTimer = null;
3211
- }
3177
+ this._clearLayoutTimers();
3212
3178
 
3213
3179
  // Remove interactive action listeners before teardown
3214
3180
  this.removeActionListeners();
@@ -3298,33 +3264,17 @@ export class RendererLite {
3298
3264
 
3299
3265
  // Create regions for overlay
3300
3266
  const overlayRegions = new Map();
3301
- const sf = this.scaleFactor;
3302
3267
  for (const regionConfig of layout.regions) {
3303
- const regionEl = document.createElement('div');
3304
- regionEl.id = `overlay_${layoutId}_region_${regionConfig.id}`;
3305
- regionEl.className = 'renderer-lite-region overlay-region';
3306
- regionEl.style.position = 'absolute';
3307
- regionEl.style.zIndex = String(regionConfig.zindex);
3308
- regionEl.style.overflow = 'hidden';
3309
-
3310
- // Apply scaled positioning
3311
- this.applyRegionScale(regionEl, regionConfig);
3312
-
3313
- overlayDiv.appendChild(regionEl);
3314
-
3315
- // Store region state (dimensions use scaled values)
3316
- overlayRegions.set(regionConfig.id, {
3317
- element: regionEl,
3318
- config: regionConfig,
3319
- widgets: regionConfig.widgets,
3320
- currentIndex: 0,
3321
- timer: null,
3322
- width: regionConfig.width * sf,
3323
- height: regionConfig.height * sf,
3324
- complete: false,
3325
- isCanvas: regionConfig.isCanvas || false,
3326
- widgetElements: new Map()
3327
- });
3268
+ const region = this._createRegionEntry(
3269
+ regionConfig,
3270
+ `overlay_${layoutId}_region_${regionConfig.id}`,
3271
+ overlayDiv,
3272
+ {
3273
+ className: 'renderer-lite-region overlay-region',
3274
+ isCanvas: regionConfig.isCanvas || false,
3275
+ }
3276
+ );
3277
+ overlayRegions.set(regionConfig.id, region);
3328
3278
  }
3329
3279
 
3330
3280
  // Pre-create widget elements for overlay
@@ -2917,4 +2917,55 @@ describe('RendererLite', () => {
2917
2917
  vi.useRealTimers();
2918
2918
  });
2919
2919
  });
2920
+
2921
+ // ── Video layout ID tracking during preload ──────────────────────
2922
+ // Regression test: createdForLayoutId must use _preloadingLayoutId
2923
+ // during preload, not currentLayoutId (which is the *playing* layout).
2924
+ // Without this, video duration updates for preloaded layouts are
2925
+ // rejected, causing layouts to play with wrong (10s) duration.
2926
+
2927
+ describe('Video createdForLayoutId during preload', () => {
2928
+ it('should capture _preloadingLayoutId for video elements created during preload', () => {
2929
+ renderer.currentLayoutId = 100; // currently playing
2930
+ renderer._preloadingLayoutId = 200; // preloading next
2931
+
2932
+ // The fix: _preloadingLayoutId || currentLayoutId should give 200
2933
+ const capturedId = renderer._preloadingLayoutId || renderer.currentLayoutId;
2934
+ expect(capturedId).toBe(200);
2935
+ });
2936
+
2937
+ it('should fall back to currentLayoutId when not preloading', () => {
2938
+ renderer.currentLayoutId = 100;
2939
+ renderer._preloadingLayoutId = null;
2940
+
2941
+ const capturedId = renderer._preloadingLayoutId || renderer.currentLayoutId;
2942
+ expect(capturedId).toBe(100);
2943
+ });
2944
+
2945
+ it('should allow duration update when preloaded layout becomes current', () => {
2946
+ // Simulate: video created during preload of layout 200
2947
+ renderer._preloadingLayoutId = 200;
2948
+ const createdForLayoutId = renderer._preloadingLayoutId || renderer.currentLayoutId;
2949
+
2950
+ // Now layout 200 swaps in
2951
+ renderer.currentLayoutId = 200;
2952
+ renderer._preloadingLayoutId = null;
2953
+
2954
+ // Duration update check should pass
2955
+ expect(renderer.currentLayoutId === createdForLayoutId).toBe(true);
2956
+ });
2957
+
2958
+ it('should reject duration update when a different layout is current', () => {
2959
+ // Video created during preload of layout 200
2960
+ renderer._preloadingLayoutId = 200;
2961
+ const createdForLayoutId = renderer._preloadingLayoutId || renderer.currentLayoutId;
2962
+
2963
+ // But layout 300 swaps in instead (e.g., schedule changed)
2964
+ renderer.currentLayoutId = 300;
2965
+ renderer._preloadingLayoutId = null;
2966
+
2967
+ // Duration update should be rejected — wrong layout
2968
+ expect(renderer.currentLayoutId === createdForLayoutId).toBe(false);
2969
+ });
2970
+ });
2920
2971
  });