@xiboplayer/renderer 0.6.5 → 0.6.7

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.5",
3
+ "version": "0.6.7",
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.5",
17
- "@xiboplayer/schedule": "0.6.5",
18
- "@xiboplayer/utils": "0.6.5"
16
+ "@xiboplayer/cache": "0.6.7",
17
+ "@xiboplayer/schedule": "0.6.7",
18
+ "@xiboplayer/utils": "0.6.7"
19
19
  },
20
20
  "devDependencies": {
21
21
  "vitest": "^2.0.0",
package/src/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export class RendererLite {
38
38
  scaleFactor: number;
39
39
  offsetX: number;
40
40
  offsetY: number;
41
+ _resizeSuppressed: boolean;
41
42
 
42
43
  on(event: string, callback: (...args: any[]) => void): () => void;
43
44
  emit(event: string, ...args: any[]): void;
package/src/layout.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { cacheWidgetHtml } from '@xiboplayer/cache';
7
- import { createLogger, PLAYER_API } from '@xiboplayer/utils';
7
+ import { createLogger, isDebug, PLAYER_API } from '@xiboplayer/utils';
8
8
 
9
9
  const log = createLogger('Layout');
10
10
 
@@ -754,6 +754,7 @@ ${mediaJS}
754
754
  border-radius: 4px;
755
755
  font-size: 14px;
756
756
  z-index: 10;
757
+ display: ${isDebug() ? 'block' : 'none'};
757
758
  \`;
758
759
  container.appendChild(pageIndicator);
759
760
 
@@ -212,6 +212,7 @@ export class RendererLite {
212
212
  this.regions = new Map(); // regionId => { element, widgets, currentIndex, timer }
213
213
  this.layoutTimer = null;
214
214
  this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
215
+ this._deferredTimerLayoutId = null; // Set when timer is deferred for dynamic layouts
215
216
  this._paused = false;
216
217
  this._layoutTimerStartedAt = null; // Date.now() when layout timer started
217
218
  this._layoutTimerDurationMs = null; // Total layout duration in ms
@@ -235,6 +236,9 @@ export class RendererLite {
235
236
  // Sub-playlist cycle state (round-robin per parentWidgetId group)
236
237
  this._subPlaylistCycleIndex = new Map();
237
238
 
239
+ // Widget lifecycle tracking — ensures symmetric start/stop
240
+ this._startedWidgets = new Set(); // "regionId:widgetIndex" keys
241
+
238
242
  // Layout preload pool (2-layout pool for instant transitions)
239
243
  this.layoutPool = new LayoutPool(2);
240
244
  this.preloadTimer = null;
@@ -262,9 +266,11 @@ export class RendererLite {
262
266
  this.container.style.overflow = 'hidden';
263
267
 
264
268
  // Watch for container resize to rescale layout (debounced to avoid spam)
269
+ this._resizeSuppressed = false;
265
270
  if (typeof ResizeObserver !== 'undefined') {
266
271
  let resizeTimer = null;
267
272
  this.resizeObserver = new ResizeObserver(() => {
273
+ if (this._resizeSuppressed) return;
268
274
  if (resizeTimer) clearTimeout(resizeTimer);
269
275
  resizeTimer = setTimeout(() => this.rescaleRegions(), 150);
270
276
  });
@@ -500,9 +506,10 @@ export class RendererLite {
500
506
  // Calculate layout duration if not specified (duration=0)
501
507
  // Uses shared parseLayoutDuration() — single source of truth for XLF-based duration calc
502
508
  if (layout.duration === 0) {
503
- const { duration } = parseLayoutDuration(xlfXml);
509
+ const { duration, isDynamic } = parseLayoutDuration(xlfXml);
504
510
  layout.duration = duration;
505
- this.log.info(`Calculated layout duration: ${layout.duration}s (not specified in XLF)`);
511
+ layout.isDynamic = isDynamic;
512
+ this.log.info(`Calculated layout duration: ${layout.duration}s (not specified in XLF)${isDynamic ? ' [dynamic — has useDuration=0 video]' : ''}`);
506
513
  }
507
514
 
508
515
  return layout;
@@ -703,12 +710,29 @@ export class RendererLite {
703
710
  this.currentLayout.duration = maxRegionDuration;
704
711
 
705
712
  this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
706
- this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration);
713
+ const final_ = !this._hasUnprobedVideos();
714
+ this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration, final_);
707
715
 
708
- // Reset layout timer with REMAINING time not full duration.
709
- // If startLayoutTimerWhenReady() hasn't fired yet (still waiting for widgets),
710
- // it will pick up the updated duration when it starts the timer.
711
- if (this.layoutTimer) {
716
+ // Deferred timer: video metadata arrived, start the timer now
717
+ if (this._deferredTimerLayoutId === this.currentLayoutId && !this.layoutTimer) {
718
+ if (this._hasUnprobedVideos()) {
719
+ this.log.info(`Layout duration updated to ${maxRegionDuration}s but still has unprobed videos — keeping timer deferred`);
720
+ } else {
721
+ const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
722
+ const remainingMs = Math.max(1000, maxRegionDuration * 1000 - elapsed);
723
+ this._deferredTimerLayoutId = null;
724
+ this._layoutTimerDurationMs = remainingMs;
725
+ this.layoutTimer = setTimeout(() => {
726
+ this.log.info(`Layout ${this.currentLayoutId} duration expired (${this.currentLayout.duration}s)`);
727
+ if (this.currentLayoutId) {
728
+ this.layoutEndEmitted = true;
729
+ this.emit('layoutEnd', this.currentLayoutId);
730
+ }
731
+ }, remainingMs);
732
+ this.log.info(`All video durations resolved — deferred timer started: ${(remainingMs / 1000).toFixed(1)}s remaining (waited ${(elapsed / 1000).toFixed(1)}s for metadata)`);
733
+ }
734
+ } else if (this.layoutTimer) {
735
+ // Reset layout timer with REMAINING time — not full duration.
712
736
  clearTimeout(this.layoutTimer);
713
737
 
714
738
  const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
@@ -1166,10 +1190,12 @@ export class RendererLite {
1166
1190
  // OPTIMIZATION: Reuse existing elements for same layout (Arexibo pattern)
1167
1191
  this.log.info(`Replaying layout ${layoutId} - reusing elements (no recreation!)`);
1168
1192
 
1169
- // Stop all region timers and reset to first widget
1193
+ // Stop all region timers and widgets, then reset to first widget
1170
1194
  this._clearRegionTimers(this.regions);
1195
+ this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
1171
1196
  for (const [, region] of this.regions) {
1172
1197
  region.currentIndex = 0;
1198
+ region.complete = false;
1173
1199
  }
1174
1200
 
1175
1201
  // Clear layout timer
@@ -1177,6 +1203,7 @@ export class RendererLite {
1177
1203
  clearTimeout(this.layoutTimer);
1178
1204
  this.layoutTimer = null;
1179
1205
  }
1206
+ this._deferredTimerLayoutId = null;
1180
1207
  this.layoutEndEmitted = false;
1181
1208
 
1182
1209
  // DON'T call stopCurrentLayout() - keep elements alive!
@@ -1561,6 +1588,36 @@ export class RendererLite {
1561
1588
  return;
1562
1589
  }
1563
1590
 
1591
+ // Dynamic layouts (useDuration=0 videos): defer timer until video metadata
1592
+ // provides real durations. Without this, the 60s fallback fires prematurely
1593
+ // causing rapid layout cycling ("layout storm") on startup.
1594
+ if (layout.isDynamic && this._hasUnprobedVideos()) {
1595
+ this._deferredTimerLayoutId = layoutId;
1596
+ this._layoutTimerStartedAt = Date.now();
1597
+ this.log.info(`Layout ${layoutId} has unprobed videos — deferring timer until metadata loads`);
1598
+ return;
1599
+ }
1600
+
1601
+ this._startLayoutTimer(layoutId, layout);
1602
+ }
1603
+
1604
+ /**
1605
+ * Check if any video widget in current layout still has duration=0 (metadata not loaded).
1606
+ */
1607
+ _hasUnprobedVideos() {
1608
+ for (const [, region] of this.regions) {
1609
+ for (const widget of region.widgets) {
1610
+ if (widget.useDuration === 0 && widget.duration === 0) return true;
1611
+ }
1612
+ }
1613
+ return false;
1614
+ }
1615
+
1616
+ /**
1617
+ * Actually start the layout timer. Called directly or after deferred timer resolves.
1618
+ */
1619
+ _startLayoutTimer(layoutId, layout) {
1620
+ this._deferredTimerLayoutId = null;
1564
1621
  const layoutDurationMs = layout.duration * 1000;
1565
1622
  this.log.info(`Layout ${layoutId} will end after ${layout.duration}s`);
1566
1623
 
@@ -1990,6 +2047,7 @@ export class RendererLite {
1990
2047
  const widget = await this._showWidget(region, widgetIndex);
1991
2048
  if (widget) {
1992
2049
  this.log.info(`Showing widget ${widget.type} (${widget.id}) in region ${regionId}`);
2050
+ this._startedWidgets.add(`${regionId}:${widgetIndex}`);
1993
2051
  this.emit('widgetStart', {
1994
2052
  widgetId: widget.id, regionId, layoutId: this.currentLayoutId,
1995
2053
  mediaId: parseInt(widget.fileId || widget.id) || null,
@@ -2022,6 +2080,9 @@ export class RendererLite {
2022
2080
  * @param {number} widgetIndex - Widget index
2023
2081
  */
2024
2082
  async stopWidget(regionId, widgetIndex) {
2083
+ const key = `${regionId}:${widgetIndex}`;
2084
+ if (!this._startedWidgets.delete(key)) return; // idempotent: already stopped
2085
+
2025
2086
  const region = this.regions.get(regionId);
2026
2087
  if (!region) return;
2027
2088
 
@@ -2037,6 +2098,24 @@ export class RendererLite {
2037
2098
  }
2038
2099
  }
2039
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
+
2040
2119
  /**
2041
2120
  * Render image widget
2042
2121
  */
@@ -2433,7 +2512,7 @@ export class RendererLite {
2433
2512
  container.className = 'renderer-lite-widget pdf-widget';
2434
2513
  container.style.width = '100%';
2435
2514
  container.style.height = '100%';
2436
- container.style.backgroundColor = '#525659';
2515
+ container.style.backgroundColor = 'transparent';
2437
2516
  container.style.opacity = '0';
2438
2517
  container.style.position = 'relative';
2439
2518
 
@@ -2481,9 +2560,10 @@ export class RendererLite {
2481
2560
  const ctx = canvas.getContext('2d');
2482
2561
  container.appendChild(canvas);
2483
2562
 
2484
- // Page indicator (bottom-right, v1-style pill)
2563
+ // Page indicator (bottom-right, v1-style pill) — debug only
2485
2564
  const indicator = document.createElement('div');
2486
2565
  indicator.style.cssText = 'position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,0.7);color:white;padding:8px 12px;border-radius:4px;font:14px system-ui;z-index:1;';
2566
+ if (!isDebug()) indicator.style.display = 'none';
2487
2567
  container.appendChild(indicator);
2488
2568
 
2489
2569
  let currentPage = 1;
@@ -2882,6 +2962,10 @@ export class RendererLite {
2882
2962
 
2883
2963
  const oldLayoutId = this.currentLayoutId;
2884
2964
 
2965
+ this.layoutEndEmitted = false;
2966
+ this.currentLayout = null;
2967
+ this.currentLayoutId = null;
2968
+
2885
2969
  if (oldLayoutId && this.layoutPool.has(oldLayoutId)) {
2886
2970
  // Old layout was preloaded — evict from pool (safe: removes its wrapper div)
2887
2971
  this.layoutPool.evict(oldLayoutId);
@@ -2890,6 +2974,7 @@ export class RendererLite {
2890
2974
  // Region elements live directly in this.container (not a wrapper),
2891
2975
  // so we must remove them individually.
2892
2976
  this._clearRegionTimers(this.regions);
2977
+ this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
2893
2978
  for (const [, region] of this.regions) {
2894
2979
  // Release video/audio resources before removing from DOM
2895
2980
  LayoutPool.releaseMediaElements(region.element);
@@ -2915,11 +3000,6 @@ export class RendererLite {
2915
3000
  }
2916
3001
  }
2917
3002
 
2918
- // Emit layoutEnd for old layout if timer hasn't already
2919
- if (oldLayoutId && !this.layoutEndEmitted) {
2920
- this.emit('layoutEnd', oldLayoutId);
2921
- }
2922
-
2923
3003
  this.regions.clear();
2924
3004
 
2925
3005
  // ── Activate preloaded layout ──
@@ -2931,7 +3011,13 @@ export class RendererLite {
2931
3011
  this.currentLayout = preloaded.layout;
2932
3012
  this.currentLayoutId = layoutId;
2933
3013
  this.regions = preloaded.regions;
2934
- this.layoutEndEmitted = false;
3014
+
3015
+ // Emit layoutEnd for old layout AFTER setting new currentLayoutId —
3016
+ // the listener guard in main.ts sees the new layout already playing
3017
+ // and skips advance, while stats/tracking still run.
3018
+ if (oldLayoutId) {
3019
+ this.emit('layoutEnd', oldLayoutId);
3020
+ }
2935
3021
 
2936
3022
  // Update container background to match preloaded layout
2937
3023
  this.container.style.backgroundColor = preloaded.layout.bgcolor;
@@ -3007,16 +3093,19 @@ export class RendererLite {
3007
3093
 
3008
3094
  this.log.info(`Stopping layout ${this.currentLayoutId}`);
3009
3095
 
3010
- // Remove interactive action listeners before teardown
3011
- this.removeActionListeners();
3096
+ const endedLayoutId = this.currentLayoutId;
3097
+ const shouldEmit = endedLayoutId && !this.layoutEndEmitted;
3012
3098
 
3013
- // Clear layout timer
3099
+ this.layoutEndEmitted = false;
3100
+ this._deferredTimerLayoutId = null;
3101
+ this.currentLayout = null;
3102
+ this.currentLayoutId = null;
3103
+
3104
+ // Clear timers
3014
3105
  if (this.layoutTimer) {
3015
3106
  clearTimeout(this.layoutTimer);
3016
3107
  this.layoutTimer = null;
3017
3108
  }
3018
-
3019
- // Clear preload timers
3020
3109
  if (this.preloadTimer) {
3021
3110
  clearTimeout(this.preloadTimer);
3022
3111
  this.preloadTimer = null;
@@ -3026,26 +3115,25 @@ export class RendererLite {
3026
3115
  this._preloadRetryTimer = null;
3027
3116
  }
3028
3117
 
3118
+ // Remove interactive action listeners before teardown
3119
+ this.removeActionListeners();
3120
+
3029
3121
  // If layout was preloaded (has its own wrapper div in pool), evict safely.
3030
3122
  // Normally-rendered layouts are NOT in the pool, so we do manual cleanup.
3031
- if (this.currentLayoutId && this.layoutPool.has(this.currentLayoutId)) {
3032
- this.layoutPool.evict(this.currentLayoutId);
3123
+ if (endedLayoutId && this.layoutPool.has(endedLayoutId)) {
3124
+ this.layoutPool.evict(endedLayoutId);
3033
3125
  } else {
3034
3126
  // Normally-rendered layout - manual cleanup (regions are in this.container)
3035
3127
 
3036
3128
  // Revoke all blob URLs for this layout (tracked lifecycle management)
3037
- if (this.currentLayoutId) {
3038
- this.revokeBlobUrlsForLayout(this.currentLayoutId);
3129
+ if (endedLayoutId) {
3130
+ this.revokeBlobUrlsForLayout(endedLayoutId);
3039
3131
  }
3040
3132
 
3041
- // Stop all regions
3133
+ // Stop all regions — use helper to stop ALL started widgets (canvas fix)
3042
3134
  this._clearRegionTimers(this.regions);
3043
- for (const [regionId, region] of this.regions) {
3044
- // Stop current widget
3045
- if (region.widgets.length > 0) {
3046
- this.stopWidget(regionId, region.currentIndex);
3047
- }
3048
-
3135
+ this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
3136
+ for (const [, region] of this.regions) {
3049
3137
  // Release video/audio resources before removing from DOM
3050
3138
  LayoutPool.releaseMediaElements(region.element);
3051
3139
 
@@ -3068,21 +3156,13 @@ export class RendererLite {
3068
3156
 
3069
3157
  }
3070
3158
 
3071
- // Clear state
3072
3159
  this.regions.clear();
3073
3160
 
3074
- // Emit layout end event only if timer hasn't already emitted it.
3075
- // Timer-based layoutEnd (natural expiry) is authoritative stopCurrentLayout
3076
- // is called afterwards during the switch to the next layout, so we skip the
3077
- // duplicate. But if the layout is forcibly stopped mid-playback (e.g., XMR
3078
- // schedule change), the timer hasn't fired yet, so we DO emit here.
3079
- if (this.currentLayoutId && !this.layoutEndEmitted) {
3080
- this.emit('layoutEnd', this.currentLayoutId);
3161
+ // Emit LAST re-entrant renderLayout() sees currentLayout=null,
3162
+ // so stopCurrentLayout() returns early. No cascade.
3163
+ if (shouldEmit) {
3164
+ this.emit('layoutEnd', endedLayoutId);
3081
3165
  }
3082
-
3083
- this.layoutEndEmitted = false;
3084
- this.currentLayout = null;
3085
- this.currentLayoutId = null;
3086
3166
  }
3087
3167
 
3088
3168
  /**
@@ -3245,6 +3325,7 @@ export class RendererLite {
3245
3325
  const widget = await this._showWidget(region, widgetIndex);
3246
3326
  if (widget) {
3247
3327
  this.log.info(`Showing overlay widget ${widget.type} (${widget.id}) in overlay ${overlayId} region ${regionId}`);
3328
+ this._startedWidgets.add(`overlay:${overlayId}:${regionId}:${widgetIndex}`);
3248
3329
  this.emit('overlayWidgetStart', {
3249
3330
  overlayId, widgetId: widget.id, regionId,
3250
3331
  type: widget.type, duration: widget.duration
@@ -3263,6 +3344,9 @@ export class RendererLite {
3263
3344
  * @param {number} widgetIndex - Widget index
3264
3345
  */
3265
3346
  async stopOverlayWidget(overlayId, regionId, widgetIndex) {
3347
+ const key = `overlay:${overlayId}:${regionId}:${widgetIndex}`;
3348
+ if (!this._startedWidgets.delete(key)) return; // idempotent
3349
+
3266
3350
  const overlayState = this.activeOverlays.get(overlayId);
3267
3351
  if (!overlayState) return;
3268
3352
 
@@ -3298,17 +3382,11 @@ export class RendererLite {
3298
3382
  }
3299
3383
 
3300
3384
  // Stop all overlay regions
3301
- for (const [regionId, region] of overlayState.regions) {
3302
- if (region.timer) {
3303
- clearTimeout(region.timer);
3304
- region.timer = null;
3305
- }
3306
-
3307
- // Stop current widget
3308
- if (region.widgets.length > 0) {
3309
- this.stopOverlayWidget(layoutId, regionId, region.currentIndex);
3310
- }
3385
+ for (const [, region] of overlayState.regions) {
3386
+ if (region.timer) { clearTimeout(region.timer); region.timer = null; }
3311
3387
  }
3388
+ this._stopAllRegionWidgets(overlayState.regions,
3389
+ (rid, idx) => this.stopOverlayWidget(layoutId, rid, idx));
3312
3390
 
3313
3391
  // Remove overlay container from DOM
3314
3392
  if (overlayState.container) {
@@ -3411,6 +3489,7 @@ export class RendererLite {
3411
3489
  cleanup() {
3412
3490
  this.stopAllOverlays();
3413
3491
  this.stopCurrentLayout();
3492
+ this._startedWidgets.clear();
3414
3493
 
3415
3494
  // Clean up any remaining audio overlays
3416
3495
  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
  });