@xiboplayer/renderer 0.6.4 → 0.6.6

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.4",
3
+ "version": "0.6.6",
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.4",
17
- "@xiboplayer/utils": "0.6.4",
18
- "@xiboplayer/schedule": "0.6.4"
16
+ "@xiboplayer/cache": "0.6.6",
17
+ "@xiboplayer/schedule": "0.6.6",
18
+ "@xiboplayer/utils": "0.6.6"
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
@@ -262,9 +263,11 @@ export class RendererLite {
262
263
  this.container.style.overflow = 'hidden';
263
264
 
264
265
  // Watch for container resize to rescale layout (debounced to avoid spam)
266
+ this._resizeSuppressed = false;
265
267
  if (typeof ResizeObserver !== 'undefined') {
266
268
  let resizeTimer = null;
267
269
  this.resizeObserver = new ResizeObserver(() => {
270
+ if (this._resizeSuppressed) return;
268
271
  if (resizeTimer) clearTimeout(resizeTimer);
269
272
  resizeTimer = setTimeout(() => this.rescaleRegions(), 150);
270
273
  });
@@ -500,9 +503,10 @@ export class RendererLite {
500
503
  // Calculate layout duration if not specified (duration=0)
501
504
  // Uses shared parseLayoutDuration() — single source of truth for XLF-based duration calc
502
505
  if (layout.duration === 0) {
503
- const { duration } = parseLayoutDuration(xlfXml);
506
+ const { duration, isDynamic } = parseLayoutDuration(xlfXml);
504
507
  layout.duration = duration;
505
- this.log.info(`Calculated layout duration: ${layout.duration}s (not specified in XLF)`);
508
+ layout.isDynamic = isDynamic;
509
+ this.log.info(`Calculated layout duration: ${layout.duration}s (not specified in XLF)${isDynamic ? ' [dynamic — has useDuration=0 video]' : ''}`);
506
510
  }
507
511
 
508
512
  return layout;
@@ -705,10 +709,26 @@ export class RendererLite {
705
709
  this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
706
710
  this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration);
707
711
 
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) {
712
+ // Deferred timer: video metadata arrived, start the timer now
713
+ if (this._deferredTimerLayoutId === this.currentLayoutId && !this.layoutTimer) {
714
+ if (this._hasUnprobedVideos()) {
715
+ this.log.info(`Layout duration updated to ${maxRegionDuration}s but still has unprobed videos — keeping timer deferred`);
716
+ } else {
717
+ const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
718
+ const remainingMs = Math.max(1000, maxRegionDuration * 1000 - elapsed);
719
+ this._deferredTimerLayoutId = null;
720
+ this._layoutTimerDurationMs = remainingMs;
721
+ this.layoutTimer = setTimeout(() => {
722
+ this.log.info(`Layout ${this.currentLayoutId} duration expired (${this.currentLayout.duration}s)`);
723
+ if (this.currentLayoutId) {
724
+ this.layoutEndEmitted = true;
725
+ this.emit('layoutEnd', this.currentLayoutId);
726
+ }
727
+ }, remainingMs);
728
+ this.log.info(`All video durations resolved — deferred timer started: ${(remainingMs / 1000).toFixed(1)}s remaining (waited ${(elapsed / 1000).toFixed(1)}s for metadata)`);
729
+ }
730
+ } else if (this.layoutTimer) {
731
+ // Reset layout timer with REMAINING time — not full duration.
712
732
  clearTimeout(this.layoutTimer);
713
733
 
714
734
  const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
@@ -1177,6 +1197,7 @@ export class RendererLite {
1177
1197
  clearTimeout(this.layoutTimer);
1178
1198
  this.layoutTimer = null;
1179
1199
  }
1200
+ this._deferredTimerLayoutId = null;
1180
1201
  this.layoutEndEmitted = false;
1181
1202
 
1182
1203
  // DON'T call stopCurrentLayout() - keep elements alive!
@@ -1561,6 +1582,36 @@ export class RendererLite {
1561
1582
  return;
1562
1583
  }
1563
1584
 
1585
+ // Dynamic layouts (useDuration=0 videos): defer timer until video metadata
1586
+ // provides real durations. Without this, the 60s fallback fires prematurely
1587
+ // causing rapid layout cycling ("layout storm") on startup.
1588
+ if (layout.isDynamic && this._hasUnprobedVideos()) {
1589
+ this._deferredTimerLayoutId = layoutId;
1590
+ this._layoutTimerStartedAt = Date.now();
1591
+ this.log.info(`Layout ${layoutId} has unprobed videos — deferring timer until metadata loads`);
1592
+ return;
1593
+ }
1594
+
1595
+ this._startLayoutTimer(layoutId, layout);
1596
+ }
1597
+
1598
+ /**
1599
+ * Check if any video widget in current layout still has duration=0 (metadata not loaded).
1600
+ */
1601
+ _hasUnprobedVideos() {
1602
+ for (const [, region] of this.regions) {
1603
+ for (const widget of region.widgets) {
1604
+ if (widget.useDuration === 0 && widget.duration === 0) return true;
1605
+ }
1606
+ }
1607
+ return false;
1608
+ }
1609
+
1610
+ /**
1611
+ * Actually start the layout timer. Called directly or after deferred timer resolves.
1612
+ */
1613
+ _startLayoutTimer(layoutId, layout) {
1614
+ this._deferredTimerLayoutId = null;
1564
1615
  const layoutDurationMs = layout.duration * 1000;
1565
1616
  this.log.info(`Layout ${layoutId} will end after ${layout.duration}s`);
1566
1617
 
@@ -2433,7 +2484,7 @@ export class RendererLite {
2433
2484
  container.className = 'renderer-lite-widget pdf-widget';
2434
2485
  container.style.width = '100%';
2435
2486
  container.style.height = '100%';
2436
- container.style.backgroundColor = '#525659';
2487
+ container.style.backgroundColor = 'transparent';
2437
2488
  container.style.opacity = '0';
2438
2489
  container.style.position = 'relative';
2439
2490
 
@@ -2481,9 +2532,10 @@ export class RendererLite {
2481
2532
  const ctx = canvas.getContext('2d');
2482
2533
  container.appendChild(canvas);
2483
2534
 
2484
- // Page indicator (bottom-right, v1-style pill)
2535
+ // Page indicator (bottom-right, v1-style pill) — debug only
2485
2536
  const indicator = document.createElement('div');
2486
2537
  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;';
2538
+ if (!isDebug()) indicator.style.display = 'none';
2487
2539
  container.appendChild(indicator);
2488
2540
 
2489
2541
  let currentPage = 1;
@@ -2882,6 +2934,10 @@ export class RendererLite {
2882
2934
 
2883
2935
  const oldLayoutId = this.currentLayoutId;
2884
2936
 
2937
+ this.layoutEndEmitted = false;
2938
+ this.currentLayout = null;
2939
+ this.currentLayoutId = null;
2940
+
2885
2941
  if (oldLayoutId && this.layoutPool.has(oldLayoutId)) {
2886
2942
  // Old layout was preloaded — evict from pool (safe: removes its wrapper div)
2887
2943
  this.layoutPool.evict(oldLayoutId);
@@ -2915,11 +2971,6 @@ export class RendererLite {
2915
2971
  }
2916
2972
  }
2917
2973
 
2918
- // Emit layoutEnd for old layout if timer hasn't already
2919
- if (oldLayoutId && !this.layoutEndEmitted) {
2920
- this.emit('layoutEnd', oldLayoutId);
2921
- }
2922
-
2923
2974
  this.regions.clear();
2924
2975
 
2925
2976
  // ── Activate preloaded layout ──
@@ -2931,7 +2982,13 @@ export class RendererLite {
2931
2982
  this.currentLayout = preloaded.layout;
2932
2983
  this.currentLayoutId = layoutId;
2933
2984
  this.regions = preloaded.regions;
2934
- this.layoutEndEmitted = false;
2985
+
2986
+ // Emit layoutEnd for old layout AFTER setting new currentLayoutId —
2987
+ // the listener guard in main.ts sees the new layout already playing
2988
+ // and skips advance, while stats/tracking still run.
2989
+ if (oldLayoutId) {
2990
+ this.emit('layoutEnd', oldLayoutId);
2991
+ }
2935
2992
 
2936
2993
  // Update container background to match preloaded layout
2937
2994
  this.container.style.backgroundColor = preloaded.layout.bgcolor;
@@ -3007,16 +3064,19 @@ export class RendererLite {
3007
3064
 
3008
3065
  this.log.info(`Stopping layout ${this.currentLayoutId}`);
3009
3066
 
3010
- // Remove interactive action listeners before teardown
3011
- this.removeActionListeners();
3067
+ const endedLayoutId = this.currentLayoutId;
3068
+ const shouldEmit = endedLayoutId && !this.layoutEndEmitted;
3012
3069
 
3013
- // Clear layout timer
3070
+ this.layoutEndEmitted = false;
3071
+ this._deferredTimerLayoutId = null;
3072
+ this.currentLayout = null;
3073
+ this.currentLayoutId = null;
3074
+
3075
+ // Clear timers
3014
3076
  if (this.layoutTimer) {
3015
3077
  clearTimeout(this.layoutTimer);
3016
3078
  this.layoutTimer = null;
3017
3079
  }
3018
-
3019
- // Clear preload timers
3020
3080
  if (this.preloadTimer) {
3021
3081
  clearTimeout(this.preloadTimer);
3022
3082
  this.preloadTimer = null;
@@ -3026,16 +3086,19 @@ export class RendererLite {
3026
3086
  this._preloadRetryTimer = null;
3027
3087
  }
3028
3088
 
3089
+ // Remove interactive action listeners before teardown
3090
+ this.removeActionListeners();
3091
+
3029
3092
  // If layout was preloaded (has its own wrapper div in pool), evict safely.
3030
3093
  // 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);
3094
+ if (endedLayoutId && this.layoutPool.has(endedLayoutId)) {
3095
+ this.layoutPool.evict(endedLayoutId);
3033
3096
  } else {
3034
3097
  // Normally-rendered layout - manual cleanup (regions are in this.container)
3035
3098
 
3036
3099
  // Revoke all blob URLs for this layout (tracked lifecycle management)
3037
- if (this.currentLayoutId) {
3038
- this.revokeBlobUrlsForLayout(this.currentLayoutId);
3100
+ if (endedLayoutId) {
3101
+ this.revokeBlobUrlsForLayout(endedLayoutId);
3039
3102
  }
3040
3103
 
3041
3104
  // Stop all regions
@@ -3068,21 +3131,13 @@ export class RendererLite {
3068
3131
 
3069
3132
  }
3070
3133
 
3071
- // Clear state
3072
3134
  this.regions.clear();
3073
3135
 
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);
3136
+ // Emit LAST re-entrant renderLayout() sees currentLayout=null,
3137
+ // so stopCurrentLayout() returns early. No cascade.
3138
+ if (shouldEmit) {
3139
+ this.emit('layoutEnd', endedLayoutId);
3081
3140
  }
3082
-
3083
- this.layoutEndEmitted = false;
3084
- this.currentLayout = null;
3085
- this.currentLayoutId = null;
3086
3141
  }
3087
3142
 
3088
3143
  /**