@xiboplayer/renderer 0.7.1 → 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.1",
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",
@@ -12,9 +12,9 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "pdfjs-dist": "^4.10.38",
15
- "@xiboplayer/cache": "0.7.1",
16
- "@xiboplayer/schedule": "0.7.1",
17
- "@xiboplayer/utils": "0.7.1"
15
+ "@xiboplayer/cache": "0.7.2",
16
+ "@xiboplayer/schedule": "0.7.2",
17
+ "@xiboplayer/utils": "0.7.2"
18
18
  },
19
19
  "devDependencies": {
20
20
  "vitest": "^2.0.0",
@@ -1639,12 +1639,14 @@ export class RendererLite {
1639
1639
  }
1640
1640
 
1641
1641
  /**
1642
- * 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.
1643
1645
  */
1644
1646
  _hasUnprobedVideos() {
1645
1647
  for (const [, region] of this.regions) {
1646
1648
  for (const widget of region.widgets) {
1647
- if (widget.useDuration === 0 && widget.duration === 0) return true;
1649
+ if (widget.useDuration === 0 && !widget._probed) return true;
1648
1650
  }
1649
1651
  }
1650
1652
  return false;
@@ -2284,6 +2286,7 @@ export class RendererLite {
2284
2286
 
2285
2287
  if (widget.duration === 0 || widget.useDuration === 0) {
2286
2288
  widget.duration = videoDuration;
2289
+ widget._probed = true;
2287
2290
  this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
2288
2291
 
2289
2292
  if (this.currentLayoutId === createdForLayoutId) {
@@ -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
  });