@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 +4 -4
- package/src/index.d.ts +1 -0
- package/src/layout.js +2 -1
- package/src/renderer-lite.js +133 -54
- package/src/renderer-lite.test.js +219 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
17
|
-
"@xiboplayer/schedule": "0.6.
|
|
18
|
-
"@xiboplayer/utils": "0.6.
|
|
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
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
|
|
package/src/renderer-lite.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
713
|
+
const final_ = !this._hasUnprobedVideos();
|
|
714
|
+
this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration, final_);
|
|
707
715
|
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
3011
|
-
this.
|
|
3096
|
+
const endedLayoutId = this.currentLayoutId;
|
|
3097
|
+
const shouldEmit = endedLayoutId && !this.layoutEndEmitted;
|
|
3012
3098
|
|
|
3013
|
-
|
|
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 (
|
|
3032
|
-
this.layoutPool.evict(
|
|
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 (
|
|
3038
|
-
this.revokeBlobUrlsForLayout(
|
|
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
|
-
|
|
3044
|
-
|
|
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
|
|
3075
|
-
//
|
|
3076
|
-
|
|
3077
|
-
|
|
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 [
|
|
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
|
});
|