@xiboplayer/renderer 0.6.6 → 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/renderer-lite.js +43 -19
- 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/renderer-lite.js
CHANGED
|
@@ -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.
|
|
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
|
*/
|
|
@@ -2946,6 +2974,7 @@ export class RendererLite {
|
|
|
2946
2974
|
// Region elements live directly in this.container (not a wrapper),
|
|
2947
2975
|
// so we must remove them individually.
|
|
2948
2976
|
this._clearRegionTimers(this.regions);
|
|
2977
|
+
this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
|
|
2949
2978
|
for (const [, region] of this.regions) {
|
|
2950
2979
|
// Release video/audio resources before removing from DOM
|
|
2951
2980
|
LayoutPool.releaseMediaElements(region.element);
|
|
@@ -3101,14 +3130,10 @@ export class RendererLite {
|
|
|
3101
3130
|
this.revokeBlobUrlsForLayout(endedLayoutId);
|
|
3102
3131
|
}
|
|
3103
3132
|
|
|
3104
|
-
// Stop all regions
|
|
3133
|
+
// Stop all regions — use helper to stop ALL started widgets (canvas fix)
|
|
3105
3134
|
this._clearRegionTimers(this.regions);
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
if (region.widgets.length > 0) {
|
|
3109
|
-
this.stopWidget(regionId, region.currentIndex);
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3135
|
+
this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
|
|
3136
|
+
for (const [, region] of this.regions) {
|
|
3112
3137
|
// Release video/audio resources before removing from DOM
|
|
3113
3138
|
LayoutPool.releaseMediaElements(region.element);
|
|
3114
3139
|
|
|
@@ -3300,6 +3325,7 @@ export class RendererLite {
|
|
|
3300
3325
|
const widget = await this._showWidget(region, widgetIndex);
|
|
3301
3326
|
if (widget) {
|
|
3302
3327
|
this.log.info(`Showing overlay widget ${widget.type} (${widget.id}) in overlay ${overlayId} region ${regionId}`);
|
|
3328
|
+
this._startedWidgets.add(`overlay:${overlayId}:${regionId}:${widgetIndex}`);
|
|
3303
3329
|
this.emit('overlayWidgetStart', {
|
|
3304
3330
|
overlayId, widgetId: widget.id, regionId,
|
|
3305
3331
|
type: widget.type, duration: widget.duration
|
|
@@ -3318,6 +3344,9 @@ export class RendererLite {
|
|
|
3318
3344
|
* @param {number} widgetIndex - Widget index
|
|
3319
3345
|
*/
|
|
3320
3346
|
async stopOverlayWidget(overlayId, regionId, widgetIndex) {
|
|
3347
|
+
const key = `overlay:${overlayId}:${regionId}:${widgetIndex}`;
|
|
3348
|
+
if (!this._startedWidgets.delete(key)) return; // idempotent
|
|
3349
|
+
|
|
3321
3350
|
const overlayState = this.activeOverlays.get(overlayId);
|
|
3322
3351
|
if (!overlayState) return;
|
|
3323
3352
|
|
|
@@ -3353,17 +3382,11 @@ export class RendererLite {
|
|
|
3353
3382
|
}
|
|
3354
3383
|
|
|
3355
3384
|
// Stop all overlay regions
|
|
3356
|
-
for (const [
|
|
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
|
-
}
|
|
3385
|
+
for (const [, region] of overlayState.regions) {
|
|
3386
|
+
if (region.timer) { clearTimeout(region.timer); region.timer = null; }
|
|
3366
3387
|
}
|
|
3388
|
+
this._stopAllRegionWidgets(overlayState.regions,
|
|
3389
|
+
(rid, idx) => this.stopOverlayWidget(layoutId, rid, idx));
|
|
3367
3390
|
|
|
3368
3391
|
// Remove overlay container from DOM
|
|
3369
3392
|
if (overlayState.container) {
|
|
@@ -3466,6 +3489,7 @@ export class RendererLite {
|
|
|
3466
3489
|
cleanup() {
|
|
3467
3490
|
this.stopAllOverlays();
|
|
3468
3491
|
this.stopCurrentLayout();
|
|
3492
|
+
this._startedWidgets.clear();
|
|
3469
3493
|
|
|
3470
3494
|
// Clean up any remaining audio overlays
|
|
3471
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
|
});
|