@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.7.0",
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.0",
17
- "@xiboplayer/utils": "0.7.0",
18
- "@xiboplayer/schedule": "0.7.0"
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
@@ -64,6 +64,7 @@ export class RendererLite {
64
64
  isPaused(): boolean;
65
65
  resumeRegionMedia?(regionId: string): void;
66
66
  showLayout(layoutId?: number): void;
67
+ getCurrentLayoutId(): number | null;
67
68
 
68
69
  parseXlf(xlfXml: string): any;
69
70
  parseWidget(mediaEl: Element): any;
@@ -41,7 +41,7 @@
41
41
  * ```
42
42
  */
43
43
 
44
- import { createNanoEvents } from 'nanoevents';
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 = createNanoEvents();
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
- return this.emitter.on(event, callback);
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 ? (rid, idx) => this.renderWidget(rid, idx) : (rid, idx) => this.renderWidget(rid, idx),
957
- isMain ? (rid, idx) => this.stopWidget(rid, idx) : (rid, idx) => this.stopWidget(rid, idx),
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, (rid, idx) => this.stopWidget(rid, idx));
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
- (rid, idx) => this.renderWidget(rid, idx),
1393
- (rid, idx) => this.stopWidget(rid, idx),
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 in current layout still has duration=0 (metadata not loaded).
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.duration === 0) return true;
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, (rid, idx) => this.stopWidget(rid, idx));
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, (rid, idx) => this.stopWidget(rid, idx));
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, (rid, idx) => this.stopWidget(rid, idx));
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
- createLogger: () => ({
15
- info: vi.fn(),
16
- warn: vi.fn(),
17
- error: vi.fn(),
18
- debug: vi.fn()
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
- // Skip: jsdom doesn't support real video element properties
759
- it.skip('should detect video duration from metadata', async () => {
760
- const xlf = `
761
- <layout>
762
- <region id="r1">
763
- <media id="m1" type="video" duration="0" useDuration="0" fileId="5">
764
- <options><uri>5.mp4</uri></options>
765
- </media>
766
- </region>
767
- </layout>
768
- `;
769
-
770
- await renderer.renderLayout(xlf, 1);
771
-
772
- // Mock video element with duration
773
- const region = renderer.regions.get('r1');
774
- const videoElement = region.widgetElements.get('m1');
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
- // Skip: jsdom doesn't support real video element properties
790
- it.skip('should update layout duration when video metadata loads', async () => {
791
- const xlf = `
792
- <layout>
793
- <region id="r1">
794
- <media id="m1" type="video" duration="0" useDuration="0" fileId="5">
795
- <options><uri>5.mp4</uri></options>
796
- </media>
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
- await renderer.renderLayout(xlf, 1);
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
- await new Promise(resolve => setTimeout(resolve, 10));
812
-
813
- // Layout duration should be updated
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
- // Skip: jsdom doesn't support real video element properties
818
- it.skip('should NOT update duration when useDuration=1', async () => {
819
- const xlf = `
820
- <layout>
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
- const region = renderer.regions.get('r1');
832
- const videoElement = region.widgetElements.get('m1');
833
- const video = videoElement.querySelector('video');
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
- // Simulate video with 45s duration
836
- Object.defineProperty(video, 'duration', { value: 45, writable: false });
837
- video.dispatchEvent(new Event('loadedmetadata'));
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
- await new Promise(resolve => setTimeout(resolve, 10));
820
+ // One still unprobed
821
+ expect(renderer._hasUnprobedVideos()).toBe(true);
840
822
 
841
- // Widget duration should stay 30 (useDuration=1 overrides)
842
- const widget = region.widgets[0];
843
- expect(widget.duration).toBe(30);
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
- // Skip: jsdom video elements don't support currentTime properly
849
- it.skip('should restart video on updateMediaElement()', async () => {
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
- // Call updateMediaElement
867
- renderer.updateMediaElement(element, widget);
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
- // Skip: jsdom video elements don't support currentTime properly
875
- it.skip('should restart looping videos too', async () => {
876
- const widget = {
877
- type: 'video',
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
- renderer.updateMediaElement(element, widget);
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
  });