@xiboplayer/renderer 0.6.6 → 0.6.8

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.6",
3
+ "version": "0.6.8",
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/cache": "0.6.6",
17
- "@xiboplayer/schedule": "0.6.6",
18
- "@xiboplayer/utils": "0.6.6"
16
+ "@xiboplayer/cache": "0.6.8",
17
+ "@xiboplayer/utils": "0.6.8",
18
+ "@xiboplayer/schedule": "0.6.8"
19
19
  },
20
20
  "devDependencies": {
21
21
  "vitest": "^2.0.0",
@@ -236,6 +236,9 @@ export class RendererLite {
236
236
  // Sub-playlist cycle state (round-robin per parentWidgetId group)
237
237
  this._subPlaylistCycleIndex = new Map();
238
238
 
239
+ // Widget lifecycle tracking — ensures symmetric start/stop
240
+ this._startedWidgets = new Set(); // "regionId:widgetIndex" keys
241
+
239
242
  // Layout preload pool (2-layout pool for instant transitions)
240
243
  this.layoutPool = new LayoutPool(2);
241
244
  this.preloadTimer = null;
@@ -707,7 +710,8 @@ export class RendererLite {
707
710
  this.currentLayout.duration = maxRegionDuration;
708
711
 
709
712
  this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
710
- this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration);
713
+ const final_ = !this._hasUnprobedVideos();
714
+ this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration, final_);
711
715
 
712
716
  // Deferred timer: video metadata arrived, start the timer now
713
717
  if (this._deferredTimerLayoutId === this.currentLayoutId && !this.layoutTimer) {
@@ -1186,10 +1190,12 @@ export class RendererLite {
1186
1190
  // OPTIMIZATION: Reuse existing elements for same layout (Arexibo pattern)
1187
1191
  this.log.info(`Replaying layout ${layoutId} - reusing elements (no recreation!)`);
1188
1192
 
1189
- // Stop all region timers and reset to first widget
1193
+ // Stop all region timers and widgets, then reset to first widget
1190
1194
  this._clearRegionTimers(this.regions);
1195
+ this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
1191
1196
  for (const [, region] of this.regions) {
1192
1197
  region.currentIndex = 0;
1198
+ region.complete = false;
1193
1199
  }
1194
1200
 
1195
1201
  // Clear layout timer
@@ -2041,6 +2047,7 @@ export class RendererLite {
2041
2047
  const widget = await this._showWidget(region, widgetIndex);
2042
2048
  if (widget) {
2043
2049
  this.log.info(`Showing widget ${widget.type} (${widget.id}) in region ${regionId}`);
2050
+ this._startedWidgets.add(`${regionId}:${widgetIndex}`);
2044
2051
  this.emit('widgetStart', {
2045
2052
  widgetId: widget.id, regionId, layoutId: this.currentLayoutId,
2046
2053
  mediaId: parseInt(widget.fileId || widget.id) || null,
@@ -2073,6 +2080,9 @@ export class RendererLite {
2073
2080
  * @param {number} widgetIndex - Widget index
2074
2081
  */
2075
2082
  async stopWidget(regionId, widgetIndex) {
2083
+ const key = `${regionId}:${widgetIndex}`;
2084
+ if (!this._startedWidgets.delete(key)) return; // idempotent: already stopped
2085
+
2076
2086
  const region = this.regions.get(regionId);
2077
2087
  if (!region) return;
2078
2088
 
@@ -2088,6 +2098,24 @@ export class RendererLite {
2088
2098
  }
2089
2099
  }
2090
2100
 
2101
+ /**
2102
+ * Stop all started widgets across regions (symmetric counterpart to startRegion)
2103
+ * Canvas regions start ALL widgets; non-canvas regions have one active widget.
2104
+ * @param {Map} regions - Region map
2105
+ * @param {Function} stopFn - (regionId, widgetIndex) => void
2106
+ */
2107
+ _stopAllRegionWidgets(regions, stopFn) {
2108
+ for (const [regionId, region] of regions) {
2109
+ if (region.isCanvas) {
2110
+ for (let i = 0; i < region.widgets.length; i++) {
2111
+ stopFn(regionId, i);
2112
+ }
2113
+ } else if (region.widgets.length > 0) {
2114
+ stopFn(regionId, region.currentIndex);
2115
+ }
2116
+ }
2117
+ }
2118
+
2091
2119
  /**
2092
2120
  * Render image widget
2093
2121
  */
@@ -2933,6 +2961,7 @@ export class RendererLite {
2933
2961
  }
2934
2962
 
2935
2963
  const oldLayoutId = this.currentLayoutId;
2964
+ const alreadyEmittedEnd = this.layoutEndEmitted;
2936
2965
 
2937
2966
  this.layoutEndEmitted = false;
2938
2967
  this.currentLayout = null;
@@ -2946,6 +2975,7 @@ export class RendererLite {
2946
2975
  // Region elements live directly in this.container (not a wrapper),
2947
2976
  // so we must remove them individually.
2948
2977
  this._clearRegionTimers(this.regions);
2978
+ this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
2949
2979
  for (const [, region] of this.regions) {
2950
2980
  // Release video/audio resources before removing from DOM
2951
2981
  LayoutPool.releaseMediaElements(region.element);
@@ -2986,7 +3016,8 @@ export class RendererLite {
2986
3016
  // Emit layoutEnd for old layout AFTER setting new currentLayoutId —
2987
3017
  // the listener guard in main.ts sees the new layout already playing
2988
3018
  // and skips advance, while stats/tracking still run.
2989
- if (oldLayoutId) {
3019
+ // Skip if the layout timer already emitted layoutEnd (avoids double stats).
3020
+ if (oldLayoutId && !alreadyEmittedEnd) {
2990
3021
  this.emit('layoutEnd', oldLayoutId);
2991
3022
  }
2992
3023
 
@@ -3101,14 +3132,10 @@ export class RendererLite {
3101
3132
  this.revokeBlobUrlsForLayout(endedLayoutId);
3102
3133
  }
3103
3134
 
3104
- // Stop all regions
3135
+ // Stop all regions — use helper to stop ALL started widgets (canvas fix)
3105
3136
  this._clearRegionTimers(this.regions);
3106
- for (const [regionId, region] of this.regions) {
3107
- // Stop current widget
3108
- if (region.widgets.length > 0) {
3109
- this.stopWidget(regionId, region.currentIndex);
3110
- }
3111
-
3137
+ this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
3138
+ for (const [, region] of this.regions) {
3112
3139
  // Release video/audio resources before removing from DOM
3113
3140
  LayoutPool.releaseMediaElements(region.element);
3114
3141
 
@@ -3300,6 +3327,7 @@ export class RendererLite {
3300
3327
  const widget = await this._showWidget(region, widgetIndex);
3301
3328
  if (widget) {
3302
3329
  this.log.info(`Showing overlay widget ${widget.type} (${widget.id}) in overlay ${overlayId} region ${regionId}`);
3330
+ this._startedWidgets.add(`overlay:${overlayId}:${regionId}:${widgetIndex}`);
3303
3331
  this.emit('overlayWidgetStart', {
3304
3332
  overlayId, widgetId: widget.id, regionId,
3305
3333
  type: widget.type, duration: widget.duration
@@ -3318,6 +3346,9 @@ export class RendererLite {
3318
3346
  * @param {number} widgetIndex - Widget index
3319
3347
  */
3320
3348
  async stopOverlayWidget(overlayId, regionId, widgetIndex) {
3349
+ const key = `overlay:${overlayId}:${regionId}:${widgetIndex}`;
3350
+ if (!this._startedWidgets.delete(key)) return; // idempotent
3351
+
3321
3352
  const overlayState = this.activeOverlays.get(overlayId);
3322
3353
  if (!overlayState) return;
3323
3354
 
@@ -3353,17 +3384,11 @@ export class RendererLite {
3353
3384
  }
3354
3385
 
3355
3386
  // Stop all overlay regions
3356
- for (const [regionId, region] of overlayState.regions) {
3357
- if (region.timer) {
3358
- clearTimeout(region.timer);
3359
- region.timer = null;
3360
- }
3361
-
3362
- // Stop current widget
3363
- if (region.widgets.length > 0) {
3364
- this.stopOverlayWidget(layoutId, regionId, region.currentIndex);
3365
- }
3387
+ for (const [, region] of overlayState.regions) {
3388
+ if (region.timer) { clearTimeout(region.timer); region.timer = null; }
3366
3389
  }
3390
+ this._stopAllRegionWidgets(overlayState.regions,
3391
+ (rid, idx) => this.stopOverlayWidget(layoutId, rid, idx));
3367
3392
 
3368
3393
  // Remove overlay container from DOM
3369
3394
  if (overlayState.container) {
@@ -3466,6 +3491,7 @@ export class RendererLite {
3466
3491
  cleanup() {
3467
3492
  this.stopAllOverlays();
3468
3493
  this.stopCurrentLayout();
3494
+ this._startedWidgets.clear();
3469
3495
 
3470
3496
  // Clean up any remaining audio overlays
3471
3497
  for (const widgetId of this.audioOverlays.keys()) {
@@ -2698,4 +2698,223 @@ describe('RendererLite', () => {
2698
2698
  vi.useRealTimers();
2699
2699
  });
2700
2700
  });
2701
+
2702
+ describe('Widget Lifecycle Symmetry', () => {
2703
+ it('should emit symmetric widgetStart/widgetEnd for single-widget layout', async () => {
2704
+ vi.useFakeTimers();
2705
+ const starts = [];
2706
+ const ends = [];
2707
+ renderer.on('widgetStart', (e) => starts.push(e));
2708
+ renderer.on('widgetEnd', (e) => ends.push(e));
2709
+
2710
+ const xlf = `
2711
+ <layout width="1920" height="1080" duration="10">
2712
+ <region id="r1" width="1920" height="1080" top="0" left="0">
2713
+ <media id="m1" type="image" duration="10" fileId="1">
2714
+ <options><uri>test.png</uri></options>
2715
+ </media>
2716
+ </region>
2717
+ </layout>
2718
+ `;
2719
+
2720
+ const renderPromise = renderer.renderLayout(xlf, 1);
2721
+ await vi.advanceTimersByTimeAsync(2000);
2722
+
2723
+ expect(starts).toHaveLength(1);
2724
+ expect(ends).toHaveLength(0);
2725
+
2726
+ renderer.stopCurrentLayout();
2727
+
2728
+ expect(ends).toHaveLength(1);
2729
+ expect(ends[0].widgetId).toBe(starts[0].widgetId);
2730
+ expect(renderer._startedWidgets.size).toBe(0);
2731
+
2732
+ await vi.advanceTimersByTimeAsync(60000);
2733
+ await renderPromise;
2734
+ vi.useRealTimers();
2735
+ });
2736
+
2737
+ it('should stop ALL widgets in canvas region on teardown', async () => {
2738
+ vi.useFakeTimers();
2739
+ const starts = [];
2740
+ const ends = [];
2741
+ renderer.on('widgetStart', (e) => starts.push(e));
2742
+ renderer.on('widgetEnd', (e) => ends.push(e));
2743
+
2744
+ const xlf = `
2745
+ <layout width="1920" height="1080" duration="30">
2746
+ <region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
2747
+ <media id="m1" type="image" duration="10" fileId="1">
2748
+ <options><uri>img1.png</uri></options>
2749
+ </media>
2750
+ <media id="m2" type="image" duration="10" fileId="2">
2751
+ <options><uri>img2.png</uri></options>
2752
+ </media>
2753
+ <media id="m3" type="image" duration="10" fileId="3">
2754
+ <options><uri>img3.png</uri></options>
2755
+ </media>
2756
+ </region>
2757
+ </layout>
2758
+ `;
2759
+
2760
+ const renderPromise = renderer.renderLayout(xlf, 1);
2761
+ await vi.advanceTimersByTimeAsync(2000);
2762
+
2763
+ expect(starts).toHaveLength(3);
2764
+ expect(ends).toHaveLength(0);
2765
+
2766
+ renderer.stopCurrentLayout();
2767
+
2768
+ expect(ends).toHaveLength(3);
2769
+ expect(renderer._startedWidgets.size).toBe(0);
2770
+
2771
+ await vi.advanceTimersByTimeAsync(60000);
2772
+ await renderPromise;
2773
+ vi.useRealTimers();
2774
+ });
2775
+
2776
+ it('should stop widgets before restarting on same-layout replay', async () => {
2777
+ vi.useFakeTimers();
2778
+ const events = [];
2779
+ renderer.on('widgetStart', (e) => events.push({ type: 'start', id: e.widgetId }));
2780
+ renderer.on('widgetEnd', (e) => events.push({ type: 'end', id: e.widgetId }));
2781
+
2782
+ const xlf = `
2783
+ <layout width="1920" height="1080" duration="10">
2784
+ <region id="r1" width="1920" height="1080" top="0" left="0">
2785
+ <media id="m1" type="image" duration="10" fileId="1">
2786
+ <options><uri>test.png</uri></options>
2787
+ </media>
2788
+ </region>
2789
+ </layout>
2790
+ `;
2791
+
2792
+ // First render
2793
+ const p1 = renderer.renderLayout(xlf, 1);
2794
+ await vi.advanceTimersByTimeAsync(2000);
2795
+ expect(events).toHaveLength(1); // 1 start
2796
+
2797
+ // Same-layout replay
2798
+ events.length = 0;
2799
+ const p2 = renderer.renderLayout(xlf, 1);
2800
+ await vi.advanceTimersByTimeAsync(2000);
2801
+
2802
+ // widgetEnd should fire before widgetStart for the replay
2803
+ expect(events.length).toBeGreaterThanOrEqual(2);
2804
+ const endIdx = events.findIndex(e => e.type === 'end');
2805
+ const startIdx = events.findIndex(e => e.type === 'start');
2806
+ expect(endIdx).toBeLessThan(startIdx);
2807
+ expect(renderer._startedWidgets.size).toBe(1);
2808
+
2809
+ await vi.advanceTimersByTimeAsync(60000);
2810
+ await p1;
2811
+ await p2;
2812
+ vi.useRealTimers();
2813
+ });
2814
+
2815
+ it('should be idempotent on double stopWidget calls', async () => {
2816
+ vi.useFakeTimers();
2817
+ const ends = [];
2818
+ renderer.on('widgetEnd', (e) => ends.push(e));
2819
+
2820
+ const xlf = `
2821
+ <layout width="1920" height="1080" duration="10">
2822
+ <region id="r1" width="1920" height="1080" top="0" left="0">
2823
+ <media id="m1" type="image" duration="10" fileId="1">
2824
+ <options><uri>test.png</uri></options>
2825
+ </media>
2826
+ </region>
2827
+ </layout>
2828
+ `;
2829
+
2830
+ const renderPromise = renderer.renderLayout(xlf, 1);
2831
+ await vi.advanceTimersByTimeAsync(2000);
2832
+
2833
+ // Stop twice
2834
+ await renderer.stopWidget('r1', 0);
2835
+ await renderer.stopWidget('r1', 0);
2836
+
2837
+ expect(ends).toHaveLength(1); // only 1 widgetEnd, not 2
2838
+
2839
+ await vi.advanceTimersByTimeAsync(60000);
2840
+ await renderPromise;
2841
+ vi.useRealTimers();
2842
+ });
2843
+
2844
+ it('should not double-emit widgetEnd on layout timer + stopCurrentLayout', async () => {
2845
+ vi.useFakeTimers();
2846
+ const ends = [];
2847
+ renderer.on('widgetEnd', (e) => ends.push(e));
2848
+
2849
+ const xlf = `
2850
+ <layout width="1920" height="1080" duration="5">
2851
+ <region id="r1" width="1920" height="1080" top="0" left="0">
2852
+ <media id="m1" type="image" duration="5" fileId="1">
2853
+ <options><uri>test.png</uri></options>
2854
+ </media>
2855
+ </region>
2856
+ </layout>
2857
+ `;
2858
+
2859
+ const renderPromise = renderer.renderLayout(xlf, 1);
2860
+ await vi.advanceTimersByTimeAsync(2000);
2861
+
2862
+ // Advance past layout duration (layout timer fires, which triggers stopCurrentLayout internally via layoutEnd)
2863
+ await vi.advanceTimersByTimeAsync(5000);
2864
+
2865
+ // Now explicitly stop (as renderLayout(next) would do)
2866
+ renderer.stopCurrentLayout();
2867
+
2868
+ // Should only have 1 widgetEnd total, not 2
2869
+ expect(ends).toHaveLength(1);
2870
+
2871
+ await vi.advanceTimersByTimeAsync(60000);
2872
+ await renderPromise;
2873
+ vi.useRealTimers();
2874
+ });
2875
+
2876
+ it('should balance starts and ends during multi-widget cycling', async () => {
2877
+ vi.useFakeTimers();
2878
+ let startCount = 0;
2879
+ let endCount = 0;
2880
+ renderer.on('widgetStart', () => startCount++);
2881
+ renderer.on('widgetEnd', () => endCount++);
2882
+
2883
+ const xlf = `
2884
+ <layout width="1920" height="1080" duration="30">
2885
+ <region id="r1" width="1920" height="1080" top="0" left="0">
2886
+ <media id="m1" type="image" duration="2" fileId="1">
2887
+ <options><uri>img1.png</uri></options>
2888
+ </media>
2889
+ <media id="m2" type="image" duration="2" fileId="2">
2890
+ <options><uri>img2.png</uri></options>
2891
+ </media>
2892
+ <media id="m3" type="image" duration="2" fileId="3">
2893
+ <options><uri>img3.png</uri></options>
2894
+ </media>
2895
+ </region>
2896
+ </layout>
2897
+ `;
2898
+
2899
+ const renderPromise = renderer.renderLayout(xlf, 1);
2900
+ await vi.advanceTimersByTimeAsync(1000);
2901
+
2902
+ // Cycle through several widgets (3 widgets × 2s each = 6s per cycle)
2903
+ for (let i = 0; i < 12; i++) {
2904
+ await vi.advanceTimersByTimeAsync(2000);
2905
+ }
2906
+
2907
+ // At any point, starts should be >= ends
2908
+ expect(startCount).toBeGreaterThanOrEqual(endCount);
2909
+
2910
+ // After full teardown, they should balance
2911
+ renderer.stopCurrentLayout();
2912
+ expect(startCount).toBe(endCount);
2913
+ expect(renderer._startedWidgets.size).toBe(0);
2914
+
2915
+ await vi.advanceTimersByTimeAsync(60000);
2916
+ await renderPromise;
2917
+ vi.useRealTimers();
2918
+ });
2919
+ });
2701
2920
  });