@xiboplayer/renderer 0.7.0 → 0.7.2
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 -5
- package/src/index.d.ts +1 -0
- package/src/renderer-lite.js +28 -13
- package/src/renderer-lite.overlays.test.js +18 -9
- package/src/renderer-lite.test.js +78 -112
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "RendererLite - Fast, efficient XLF layout rendering engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
"./layout": "./src/layout.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"nanoevents": "^9.1.0",
|
|
15
14
|
"pdfjs-dist": "^4.10.38",
|
|
16
|
-
"@xiboplayer/cache": "0.7.
|
|
17
|
-
"@xiboplayer/
|
|
18
|
-
"@xiboplayer/
|
|
15
|
+
"@xiboplayer/cache": "0.7.2",
|
|
16
|
+
"@xiboplayer/schedule": "0.7.2",
|
|
17
|
+
"@xiboplayer/utils": "0.7.2"
|
|
19
18
|
},
|
|
20
19
|
"devDependencies": {
|
|
21
20
|
"vitest": "^2.0.0",
|
package/src/index.d.ts
CHANGED
package/src/renderer-lite.js
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* ```
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
-
import {
|
|
44
|
+
import { EventEmitter } from '@xiboplayer/utils';
|
|
45
45
|
import { createLogger, isDebug, PLAYER_API } from '@xiboplayer/utils';
|
|
46
46
|
import { parseLayoutDuration } from '@xiboplayer/schedule';
|
|
47
47
|
import { LayoutPool } from './layout-pool.js';
|
|
@@ -205,7 +205,7 @@ export class RendererLite {
|
|
|
205
205
|
this.log = createLogger('RendererLite', options.logLevel);
|
|
206
206
|
|
|
207
207
|
// Event emitter for lifecycle hooks
|
|
208
|
-
this.emitter =
|
|
208
|
+
this.emitter = new EventEmitter();
|
|
209
209
|
|
|
210
210
|
// State
|
|
211
211
|
this.currentLayout = null;
|
|
@@ -223,6 +223,10 @@ export class RendererLite {
|
|
|
223
223
|
this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
|
|
224
224
|
this.audioOverlays = new Map(); // widgetId => [HTMLAudioElement] (audio overlays for widgets)
|
|
225
225
|
|
|
226
|
+
// Bound methods (avoid lambda allocation per call in startRegion/_advanceRegion)
|
|
227
|
+
this._stopWidgetBound = (rid, idx) => this.stopWidget(rid, idx);
|
|
228
|
+
this._renderWidgetBound = (rid, idx) => this.renderWidget(rid, idx);
|
|
229
|
+
|
|
226
230
|
// Scale state (for fitting layout to screen)
|
|
227
231
|
this.scaleFactor = 1;
|
|
228
232
|
this.offsetX = 0;
|
|
@@ -356,7 +360,7 @@ export class RendererLite {
|
|
|
356
360
|
* Event emitter interface (like XMR wrapper)
|
|
357
361
|
*/
|
|
358
362
|
on(event, callback) {
|
|
359
|
-
|
|
363
|
+
this.emitter.on(event, callback);
|
|
360
364
|
}
|
|
361
365
|
|
|
362
366
|
emit(event, ...args) {
|
|
@@ -953,8 +957,8 @@ export class RendererLite {
|
|
|
953
957
|
const isMain = regionMap === this.regions;
|
|
954
958
|
this._startRegionCycle(
|
|
955
959
|
region, regionId,
|
|
956
|
-
isMain ?
|
|
957
|
-
isMain ?
|
|
960
|
+
isMain ? this._renderWidgetBound : this._renderWidgetBound,
|
|
961
|
+
isMain ? this._stopWidgetBound : this._stopWidgetBound,
|
|
958
962
|
isMain ? () => this.checkLayoutComplete() : undefined
|
|
959
963
|
);
|
|
960
964
|
}
|
|
@@ -1200,7 +1204,7 @@ export class RendererLite {
|
|
|
1200
1204
|
|
|
1201
1205
|
// Stop all region timers and widgets, then reset to first widget
|
|
1202
1206
|
this._clearRegionTimers(this.regions);
|
|
1203
|
-
this._stopAllRegionWidgets(this.regions,
|
|
1207
|
+
this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
|
|
1204
1208
|
for (const [, region] of this.regions) {
|
|
1205
1209
|
region.currentIndex = 0;
|
|
1206
1210
|
region.complete = false;
|
|
@@ -1389,8 +1393,8 @@ export class RendererLite {
|
|
|
1389
1393
|
const region = this.regions.get(regionId);
|
|
1390
1394
|
this._startRegionCycle(
|
|
1391
1395
|
region, regionId,
|
|
1392
|
-
|
|
1393
|
-
|
|
1396
|
+
this._renderWidgetBound,
|
|
1397
|
+
this._stopWidgetBound,
|
|
1394
1398
|
() => {
|
|
1395
1399
|
this.log.info(`Region ${regionId} completed one full cycle`);
|
|
1396
1400
|
this.checkLayoutComplete();
|
|
@@ -1635,12 +1639,14 @@ export class RendererLite {
|
|
|
1635
1639
|
}
|
|
1636
1640
|
|
|
1637
1641
|
/**
|
|
1638
|
-
* Check if any video widget
|
|
1642
|
+
* Check if any video widget has useDuration=0 ("play to end") and hasn't
|
|
1643
|
+
* been corrected by video metadata yet. The XLF always provides a non-zero
|
|
1644
|
+
* duration attribute (typically 60s), so we check the _probed flag instead.
|
|
1639
1645
|
*/
|
|
1640
1646
|
_hasUnprobedVideos() {
|
|
1641
1647
|
for (const [, region] of this.regions) {
|
|
1642
1648
|
for (const widget of region.widgets) {
|
|
1643
|
-
if (widget.useDuration === 0 && widget.
|
|
1649
|
+
if (widget.useDuration === 0 && !widget._probed) return true;
|
|
1644
1650
|
}
|
|
1645
1651
|
}
|
|
1646
1652
|
return false;
|
|
@@ -2280,6 +2286,7 @@ export class RendererLite {
|
|
|
2280
2286
|
|
|
2281
2287
|
if (widget.duration === 0 || widget.useDuration === 0) {
|
|
2282
2288
|
widget.duration = videoDuration;
|
|
2289
|
+
widget._probed = true;
|
|
2283
2290
|
this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
|
|
2284
2291
|
|
|
2285
2292
|
if (this.currentLayoutId === createdForLayoutId) {
|
|
@@ -3011,7 +3018,7 @@ export class RendererLite {
|
|
|
3011
3018
|
if (oldLayoutId && this.layoutPool.has(oldLayoutId)) {
|
|
3012
3019
|
// Stop all widgets before evicting (symmetric widgetEnd events)
|
|
3013
3020
|
this._clearRegionTimers(this.regions);
|
|
3014
|
-
this._stopAllRegionWidgets(this.regions,
|
|
3021
|
+
this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
|
|
3015
3022
|
// Old layout was preloaded — evict from pool (safe: removes its wrapper div)
|
|
3016
3023
|
this.layoutPool.evict(oldLayoutId);
|
|
3017
3024
|
} else {
|
|
@@ -3019,7 +3026,7 @@ export class RendererLite {
|
|
|
3019
3026
|
// Region elements live directly in this.container (not a wrapper),
|
|
3020
3027
|
// so we must remove them individually.
|
|
3021
3028
|
this._clearRegionTimers(this.regions);
|
|
3022
|
-
this._stopAllRegionWidgets(this.regions,
|
|
3029
|
+
this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
|
|
3023
3030
|
for (const [, region] of this.regions) {
|
|
3024
3031
|
// Release video/audio resources before removing from DOM
|
|
3025
3032
|
LayoutPool.releaseMediaElements(region.element);
|
|
@@ -3112,6 +3119,14 @@ export class RendererLite {
|
|
|
3112
3119
|
this.log.info(`Swapped to preloaded layout ${layoutId} (instant transition)`);
|
|
3113
3120
|
}
|
|
3114
3121
|
|
|
3122
|
+
/**
|
|
3123
|
+
* Get the currently showing layout ID.
|
|
3124
|
+
* @returns {number|null}
|
|
3125
|
+
*/
|
|
3126
|
+
getCurrentLayoutId() {
|
|
3127
|
+
return this.currentLayoutId;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3115
3130
|
/**
|
|
3116
3131
|
* Show a preloaded layout (swap from pool to visible).
|
|
3117
3132
|
* If no layoutId, shows the most recently preloaded layout.
|
|
@@ -3212,7 +3227,7 @@ export class RendererLite {
|
|
|
3212
3227
|
|
|
3213
3228
|
// Stop all regions — use helper to stop ALL started widgets (canvas fix)
|
|
3214
3229
|
this._clearRegionTimers(this.regions);
|
|
3215
|
-
this._stopAllRegionWidgets(this.regions,
|
|
3230
|
+
this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
|
|
3216
3231
|
for (const [, region] of this.regions) {
|
|
3217
3232
|
// Release video/audio resources before removing from DOM
|
|
3218
3233
|
LayoutPool.releaseMediaElements(region.element);
|
|
@@ -9,15 +9,24 @@
|
|
|
9
9
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
10
10
|
import { RendererLite } from './renderer-lite.js';
|
|
11
11
|
|
|
12
|
-
// Mock logger
|
|
13
|
-
vi.mock('@xiboplayer/utils', () =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
12
|
+
// Mock logger (keep real EventEmitter for RendererLite construction)
|
|
13
|
+
vi.mock('@xiboplayer/utils', () => {
|
|
14
|
+
// Inline minimal EventEmitter — avoids async importActual which breaks vitest mock hoisting
|
|
15
|
+
class EventEmitter {
|
|
16
|
+
constructor() { this._listeners = new Map(); }
|
|
17
|
+
on(event, cb) { if (!this._listeners.has(event)) this._listeners.set(event, []); this._listeners.get(event).push(cb); }
|
|
18
|
+
emit(event, ...args) { for (const cb of this._listeners.get(event) || []) cb(...args); }
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
EventEmitter,
|
|
22
|
+
createLogger: () => ({
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
warn: vi.fn(),
|
|
25
|
+
error: vi.fn(),
|
|
26
|
+
debug: vi.fn()
|
|
27
|
+
})
|
|
28
|
+
};
|
|
29
|
+
});
|
|
21
30
|
|
|
22
31
|
describe('RendererLite - Overlay Rendering', () => {
|
|
23
32
|
let renderer;
|
|
@@ -7,10 +7,20 @@
|
|
|
7
7
|
* and memory management.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
10
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
|
11
11
|
import { RendererLite, Transitions } from './renderer-lite.js';
|
|
12
12
|
|
|
13
13
|
describe('RendererLite', () => {
|
|
14
|
+
// Patch HTMLMediaElement after jsdom initializes — vitest.setup.js runs
|
|
15
|
+
// before the jsdom environment, so stubs set there get overwritten.
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
const proto = window.HTMLMediaElement.prototype;
|
|
18
|
+
proto.play = vi.fn(() => Promise.resolve());
|
|
19
|
+
proto.pause = vi.fn();
|
|
20
|
+
proto.load = vi.fn();
|
|
21
|
+
Object.defineProperty(proto, 'duration', { writable: true, configurable: true, value: NaN });
|
|
22
|
+
Object.defineProperty(proto, 'currentTime', { writable: true, configurable: true, value: 0 });
|
|
23
|
+
});
|
|
14
24
|
let container;
|
|
15
25
|
let renderer;
|
|
16
26
|
let mockGetWidgetHtml;
|
|
@@ -755,142 +765,98 @@ describe('RendererLite', () => {
|
|
|
755
765
|
});
|
|
756
766
|
|
|
757
767
|
describe('Video Duration Detection', () => {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const video = videoElement.querySelector('video');
|
|
776
|
-
|
|
777
|
-
// Simulate loadedmetadata event
|
|
778
|
-
Object.defineProperty(video, 'duration', { value: 45.5, writable: false });
|
|
779
|
-
video.dispatchEvent(new Event('loadedmetadata'));
|
|
780
|
-
|
|
781
|
-
// Wait for async handler
|
|
782
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
783
|
-
|
|
784
|
-
// Widget duration should be updated
|
|
785
|
-
const widget = region.widgets[0];
|
|
786
|
-
expect(widget.duration).toBe(45); // Floor of 45.5
|
|
768
|
+
it('should detect video duration from metadata', () => {
|
|
769
|
+
// Directly test _hasUnprobedVideos + duration update logic
|
|
770
|
+
// (renderer.renderLayout creates iframes in jsdom, not real video elements)
|
|
771
|
+
const widget = { id: 'm1', type: 'video', duration: 60, useDuration: 0, _probed: false };
|
|
772
|
+
const region = { id: 'r1', widgets: [widget], isDrawer: false };
|
|
773
|
+
renderer.regions = new Map([['r1', region]]);
|
|
774
|
+
|
|
775
|
+
// Before metadata: widget is unprobed
|
|
776
|
+
expect(renderer._hasUnprobedVideos()).toBe(true);
|
|
777
|
+
|
|
778
|
+
// Simulate metadata arrival
|
|
779
|
+
widget.duration = 45;
|
|
780
|
+
widget._probed = true;
|
|
781
|
+
|
|
782
|
+
// After metadata: all probed
|
|
783
|
+
expect(renderer._hasUnprobedVideos()).toBe(false);
|
|
784
|
+
expect(widget.duration).toBe(45);
|
|
787
785
|
});
|
|
788
786
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
</region>
|
|
798
|
-
</layout>
|
|
799
|
-
`;
|
|
787
|
+
it('should calculate correct duration from widget durations', () => {
|
|
788
|
+
// Test the duration calculation logic that updateLayoutDuration uses:
|
|
789
|
+
// max region duration across all regions, sum of widgets per region
|
|
790
|
+
const w1 = { id: 'v1', duration: 30, useDuration: 0, _probed: true };
|
|
791
|
+
const w2 = { id: 'v2', duration: 15, useDuration: 0, _probed: true };
|
|
792
|
+
// Region duration = sum of widgets = 30 + 15 = 45
|
|
793
|
+
const region = { id: 'r1', widgets: [w1, w2], isDrawer: false };
|
|
794
|
+
renderer.regions = new Map([['r1', region]]);
|
|
800
795
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const region = renderer.regions.get('r1');
|
|
804
|
-
const videoElement = region.widgetElements.get('m1');
|
|
805
|
-
const video = videoElement.querySelector('video');
|
|
806
|
-
|
|
807
|
-
// Simulate video with 45s duration
|
|
808
|
-
Object.defineProperty(video, 'duration', { value: 45, writable: false });
|
|
809
|
-
video.dispatchEvent(new Event('loadedmetadata'));
|
|
796
|
+
// Verify _hasUnprobedVideos returns false when all probed
|
|
797
|
+
expect(renderer._hasUnprobedVideos()).toBe(false);
|
|
810
798
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
expect(renderer.currentLayout.duration).toBe(45);
|
|
799
|
+
// Verify the duration sum matches what updateLayoutDuration would compute
|
|
800
|
+
const regionDuration = region.widgets.reduce((sum, w) => sum + (w.duration > 0 ? w.duration : 0), 0);
|
|
801
|
+
expect(regionDuration).toBe(45);
|
|
815
802
|
});
|
|
816
803
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
<region id="r1">
|
|
822
|
-
<media id="m1" type="video" duration="30" useDuration="1" fileId="5">
|
|
823
|
-
<options><uri>5.mp4</uri></options>
|
|
824
|
-
</media>
|
|
825
|
-
</region>
|
|
826
|
-
</layout>
|
|
827
|
-
`;
|
|
828
|
-
|
|
829
|
-
await renderer.renderLayout(xlf, 1);
|
|
804
|
+
it('should NOT mark widget as probed when useDuration=1', () => {
|
|
805
|
+
const widget = { id: 'm1', type: 'video', duration: 30, useDuration: 1 };
|
|
806
|
+
const region = { id: 'r1', widgets: [widget], isDrawer: false };
|
|
807
|
+
renderer.regions = new Map([['r1', region]]);
|
|
830
808
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
809
|
+
// useDuration=1 means CMS-set duration — not a "play to end" video
|
|
810
|
+
expect(renderer._hasUnprobedVideos()).toBe(false);
|
|
811
|
+
expect(widget.duration).toBe(30);
|
|
812
|
+
});
|
|
834
813
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
814
|
+
it('should handle mixed probed and unprobed videos', () => {
|
|
815
|
+
const widget1 = { id: 'm1', type: 'video', duration: 45, useDuration: 0, _probed: true };
|
|
816
|
+
const widget2 = { id: 'm2', type: 'video', duration: 60, useDuration: 0, _probed: false };
|
|
817
|
+
const region = { id: 'r1', widgets: [widget1, widget2], isDrawer: false };
|
|
818
|
+
renderer.regions = new Map([['r1', region]]);
|
|
838
819
|
|
|
839
|
-
|
|
820
|
+
// One still unprobed
|
|
821
|
+
expect(renderer._hasUnprobedVideos()).toBe(true);
|
|
840
822
|
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
823
|
+
// Probe the second
|
|
824
|
+
widget2.duration = 30;
|
|
825
|
+
widget2._probed = true;
|
|
826
|
+
expect(renderer._hasUnprobedVideos()).toBe(false);
|
|
844
827
|
});
|
|
845
828
|
});
|
|
846
829
|
|
|
847
830
|
describe('Media Element Restart', () => {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const widget = {
|
|
851
|
-
type: 'video',
|
|
852
|
-
id: 'm1',
|
|
853
|
-
fileId: '5',
|
|
854
|
-
options: { loop: '0', mute: '1' },
|
|
855
|
-
duration: 30
|
|
856
|
-
};
|
|
857
|
-
|
|
858
|
-
const region = { width: 1920, height: 1080 };
|
|
859
|
-
const element = await renderer.renderVideo(widget, region);
|
|
860
|
-
const video = element.querySelector('video');
|
|
861
|
-
|
|
862
|
-
// Mock video methods
|
|
831
|
+
it('should restart video via updateMediaElement', () => {
|
|
832
|
+
const video = document.createElement('video');
|
|
863
833
|
video.currentTime = 25.5;
|
|
834
|
+
// Simulate readyState >= 2 so _restartMediaElement calls play directly
|
|
835
|
+
Object.defineProperty(video, 'readyState', { value: 3, configurable: true });
|
|
864
836
|
video.play = vi.fn(() => Promise.resolve());
|
|
865
837
|
|
|
866
|
-
|
|
867
|
-
|
|
838
|
+
const wrapper = document.createElement('div');
|
|
839
|
+
wrapper.appendChild(video);
|
|
840
|
+
|
|
841
|
+
const widget = { type: 'video', id: 'm1', fileId: '5', options: { loop: '0' }, duration: 30 };
|
|
842
|
+
renderer.updateMediaElement(wrapper, widget);
|
|
868
843
|
|
|
869
|
-
// Should restart from beginning
|
|
870
844
|
expect(video.currentTime).toBe(0);
|
|
871
845
|
expect(video.play).toHaveBeenCalled();
|
|
872
846
|
});
|
|
873
847
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
id: 'm1',
|
|
879
|
-
fileId: '5',
|
|
880
|
-
options: { loop: '1', mute: '1' }, // Looping video
|
|
881
|
-
duration: 30
|
|
882
|
-
};
|
|
883
|
-
|
|
884
|
-
const region = { width: 1920, height: 1080 };
|
|
885
|
-
const element = await renderer.renderVideo(widget, region);
|
|
886
|
-
const video = element.querySelector('video');
|
|
887
|
-
|
|
888
|
-
video.currentTime = 10;
|
|
848
|
+
it('should restart looping videos too', () => {
|
|
849
|
+
const video = document.createElement('video');
|
|
850
|
+
video.currentTime = 25.5;
|
|
851
|
+
Object.defineProperty(video, 'readyState', { value: 3, configurable: true });
|
|
889
852
|
video.play = vi.fn(() => Promise.resolve());
|
|
890
853
|
|
|
891
|
-
|
|
854
|
+
const wrapper = document.createElement('div');
|
|
855
|
+
wrapper.appendChild(video);
|
|
856
|
+
|
|
857
|
+
const widget = { type: 'video', id: 'm1', fileId: '5', options: { loop: '1' }, duration: 30 };
|
|
858
|
+
renderer.updateMediaElement(wrapper, widget);
|
|
892
859
|
|
|
893
|
-
// Should STILL restart (even when looping)
|
|
894
860
|
expect(video.currentTime).toBe(0);
|
|
895
861
|
expect(video.play).toHaveBeenCalled();
|
|
896
862
|
});
|