@xiboplayer/renderer 0.7.1 → 0.7.3

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.3",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,13 +12,13 @@
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/schedule": "0.7.3",
16
+ "@xiboplayer/utils": "0.7.3",
17
+ "@xiboplayer/cache": "0.7.3"
18
18
  },
19
19
  "devDependencies": {
20
- "vitest": "^2.0.0",
21
- "jsdom": "^25.0.0"
20
+ "jsdom": "^25.0.1",
21
+ "vitest": "^2.1.9"
22
22
  },
23
23
  "keywords": [
24
24
  "xibo",
package/src/index.d.ts CHANGED
@@ -21,8 +21,8 @@ export class LayoutPool {
21
21
  get(layoutId: number): any | undefined;
22
22
  add(layoutId: number, entry: any): void;
23
23
  clearWarmNotIn(keepIds: Set<number>): number;
24
- makeHot(layoutId: number): void;
25
- remove(layoutId: number): void;
24
+ setHot(layoutId: number): void;
25
+ evict(layoutId: number): void;
26
26
  clear(): void;
27
27
  }
28
28
 
@@ -59,10 +59,10 @@ export class RendererLite {
59
59
  nextWidget(regionId?: string): void;
60
60
  previousWidget(regionId?: string): void;
61
61
 
62
+ resumeRegionMedia(regionId: string): void;
62
63
  pause(): void;
63
64
  resume(): void;
64
65
  isPaused(): boolean;
65
- resumeRegionMedia?(regionId: string): void;
66
66
  showLayout(layoutId?: number): void;
67
67
  getCurrentLayoutId(): number | null;
68
68
 
@@ -219,7 +219,6 @@ export class RendererLite {
219
219
  this._paused = false;
220
220
  this._layoutTimerStartedAt = null; // Date.now() when layout timer started
221
221
  this._layoutTimerDurationMs = null; // Total layout duration in ms
222
- this.widgetTimers = new Map(); // widgetId => timer
223
222
  this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
224
223
  this.audioOverlays = new Map(); // widgetId => [HTMLAudioElement] (audio overlays for widgets)
225
224
 
@@ -1339,26 +1338,64 @@ export class RendererLite {
1339
1338
  }
1340
1339
 
1341
1340
  /**
1342
- * Create a region element
1343
- * @param {Object} regionConfig - Region configuration
1341
+ * Build a region DOM element and state entry.
1342
+ * Shared by createRegion, preloadLayout, and renderOverlay.
1343
+ *
1344
+ * @param {Object} regionConfig - Region configuration from parsed XLF
1345
+ * @param {string} elementId - DOM element ID for the region div
1346
+ * @param {HTMLElement} parentEl - Parent element to append the region to
1347
+ * @param {Object} [extraState] - Additional properties merged into region state
1348
+ * @returns {Object} Region state object { element, config, widgets, ... }
1344
1349
  */
1345
- async createRegion(regionConfig) {
1350
+ _createRegionEntry(regionConfig, elementId, parentEl, extraState = {}) {
1351
+ const { className = 'renderer-lite-region', ...stateProps } = extraState;
1352
+
1346
1353
  const regionEl = document.createElement('div');
1347
- regionEl.id = `region_${regionConfig.id}`;
1348
- regionEl.className = 'renderer-lite-region';
1354
+ regionEl.id = elementId;
1355
+ regionEl.className = className;
1349
1356
  regionEl.style.position = 'absolute';
1350
- regionEl.style.zIndex = regionConfig.zindex;
1357
+ regionEl.style.zIndex = String(regionConfig.zindex);
1351
1358
  regionEl.style.overflow = 'hidden';
1352
1359
 
1353
- // Drawer regions start fully hidden — shown only by navWidget actions
1354
- if (regionConfig.isDrawer) {
1355
- regionEl.style.display = 'none';
1356
- }
1357
-
1358
1360
  // Apply scaled positioning
1359
1361
  this.applyRegionScale(regionEl, regionConfig);
1360
1362
 
1361
- this.container.appendChild(regionEl);
1363
+ parentEl.appendChild(regionEl);
1364
+
1365
+ const sf = this.scaleFactor;
1366
+ return {
1367
+ element: regionEl,
1368
+ config: regionConfig,
1369
+ widgets: regionConfig.widgets,
1370
+ currentIndex: 0,
1371
+ timer: null,
1372
+ width: regionConfig.width * sf,
1373
+ height: regionConfig.height * sf,
1374
+ complete: false,
1375
+ widgetElements: new Map(),
1376
+ ...stateProps,
1377
+ };
1378
+ }
1379
+
1380
+ /**
1381
+ * Create a region element
1382
+ * @param {Object} regionConfig - Region configuration
1383
+ */
1384
+ async createRegion(regionConfig) {
1385
+ const region = this._createRegionEntry(
1386
+ regionConfig,
1387
+ `region_${regionConfig.id}`,
1388
+ this.container,
1389
+ {
1390
+ isDrawer: regionConfig.isDrawer || false,
1391
+ isCanvas: regionConfig.isCanvas || false,
1392
+ }
1393
+ );
1394
+
1395
+ // Drawer regions start fully hidden — shown only by navWidget actions
1396
+ if (regionConfig.isDrawer) {
1397
+ region.element.style.display = 'none';
1398
+ }
1362
1399
 
1363
1400
  // Filter expired widgets (fromDt/toDt time-gating within XLF)
1364
1401
  let widgets = regionConfig.widgets.filter(w => this._isWidgetActive(w));
@@ -1367,22 +1404,9 @@ export class RendererLite {
1367
1404
  if (widgets.some(w => w.cyclePlayback)) {
1368
1405
  widgets = this._applyCyclePlayback(widgets);
1369
1406
  }
1407
+ region.widgets = widgets;
1370
1408
 
1371
- // Store region state (dimensions use scaled values for transitions)
1372
- const sf = this.scaleFactor;
1373
- this.regions.set(regionConfig.id, {
1374
- element: regionEl,
1375
- config: regionConfig,
1376
- widgets,
1377
- currentIndex: 0,
1378
- timer: null,
1379
- width: regionConfig.width * sf,
1380
- height: regionConfig.height * sf,
1381
- complete: false, // Track if region has played all widgets once
1382
- isDrawer: regionConfig.isDrawer || false,
1383
- isCanvas: regionConfig.isCanvas || false, // Canvas regions render all widgets simultaneously
1384
- widgetElements: new Map() // widgetId -> DOM element (for element reuse)
1385
- });
1409
+ this.regions.set(regionConfig.id, region);
1386
1410
  }
1387
1411
 
1388
1412
  /**
@@ -1639,12 +1663,14 @@ export class RendererLite {
1639
1663
  }
1640
1664
 
1641
1665
  /**
1642
- * Check if any video widget in current layout still has duration=0 (metadata not loaded).
1666
+ * Check if any video widget has useDuration=0 ("play to end") and hasn't
1667
+ * been corrected by video metadata yet. The XLF always provides a non-zero
1668
+ * duration attribute (typically 60s), so we check the _probed flag instead.
1643
1669
  */
1644
1670
  _hasUnprobedVideos() {
1645
1671
  for (const [, region] of this.regions) {
1646
1672
  for (const widget of region.widgets) {
1647
- if (widget.useDuration === 0 && widget.duration === 0) return true;
1673
+ if (widget.useDuration === 0 && !widget._probed) return true;
1648
1674
  }
1649
1675
  }
1650
1676
  return false;
@@ -2284,6 +2310,7 @@ export class RendererLite {
2284
2310
 
2285
2311
  if (widget.duration === 0 || widget.useDuration === 0) {
2286
2312
  widget.duration = videoDuration;
2313
+ widget._probed = true;
2287
2314
  this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
2288
2315
 
2289
2316
  if (this.currentLayoutId === createdForLayoutId) {
@@ -2499,45 +2526,7 @@ export class RendererLite {
2499
2526
  * Render text/ticker widget
2500
2527
  */
2501
2528
  async renderTextWidget(widget, region) {
2502
- const iframe = document.createElement('iframe');
2503
- iframe.className = 'renderer-lite-widget';
2504
- iframe.style.width = '100%';
2505
- iframe.style.height = '100%';
2506
- iframe.style.border = 'none';
2507
- iframe.style.opacity = '0';
2508
-
2509
- // Get widget HTML (may return { url } for cache-path loading or string for blob)
2510
- let html = widget.raw;
2511
- if (this.options.getWidgetHtml) {
2512
- const result = await this.options.getWidgetHtml(widget);
2513
- if (result && typeof result === 'object' && result.url) {
2514
- // Use cache URL — SW serves HTML and intercepts sub-resources
2515
- iframe.src = result.url;
2516
-
2517
- // Parse NUMITEMS/DURATION from fallback HTML (cache path)
2518
- if (result.fallback) {
2519
- this._parseDurationComments(result.fallback, widget);
2520
- }
2521
-
2522
- return iframe;
2523
- }
2524
- html = result;
2525
- }
2526
-
2527
- if (html) {
2528
- // Parse NUMITEMS/DURATION HTML comments for dynamic widget duration
2529
- this._parseDurationComments(html, widget);
2530
- }
2531
-
2532
- // Fallback: Create blob URL for iframe
2533
- const blob = new Blob([html], { type: 'text/html' });
2534
- const blobUrl = URL.createObjectURL(blob);
2535
- iframe.src = blobUrl;
2536
-
2537
- // Track blob URL for lifecycle management
2538
- this.trackBlobUrl(blobUrl);
2539
-
2540
- return iframe;
2529
+ return await this._renderIframeWidget(widget, region);
2541
2530
  }
2542
2531
 
2543
2532
  /**
@@ -2720,6 +2709,15 @@ export class RendererLite {
2720
2709
  * Render generic widget (clock, calendar, weather, etc.)
2721
2710
  */
2722
2711
  async renderGenericWidget(widget, region) {
2712
+ return await this._renderIframeWidget(widget, region);
2713
+ }
2714
+
2715
+ /**
2716
+ * Shared iframe rendering for text/ticker and generic widgets.
2717
+ * Creates an iframe, resolves widget HTML via getWidgetHtml (cache URL or blob),
2718
+ * and parses NUMITEMS/DURATION comments for dynamic widget duration.
2719
+ */
2720
+ async _renderIframeWidget(widget, region) {
2723
2721
  const iframe = document.createElement('iframe');
2724
2722
  iframe.className = 'renderer-lite-widget';
2725
2723
  iframe.style.width = '100%';
@@ -2883,33 +2881,12 @@ export class RendererLite {
2883
2881
 
2884
2882
  // Create regions in the hidden wrapper
2885
2883
  const preloadRegions = new Map();
2886
- const sf = this.scaleFactor;
2887
-
2888
2884
  for (const regionConfig of layout.regions) {
2889
- const regionEl = document.createElement('div');
2890
- regionEl.id = `preload_region_${layoutId}_${regionConfig.id}`;
2891
- regionEl.className = 'renderer-lite-region';
2892
- regionEl.style.position = 'absolute';
2893
- regionEl.style.zIndex = regionConfig.zindex;
2894
- regionEl.style.overflow = 'hidden';
2895
-
2896
- // Apply scaled positioning
2897
- this.applyRegionScale(regionEl, regionConfig);
2898
-
2899
- wrapper.appendChild(regionEl);
2900
-
2901
- const region = {
2902
- element: regionEl,
2903
- config: regionConfig,
2904
- widgets: regionConfig.widgets,
2905
- currentIndex: 0,
2906
- timer: null,
2907
- width: regionConfig.width * sf,
2908
- height: regionConfig.height * sf,
2909
- complete: false,
2910
- widgetElements: new Map()
2911
- };
2912
-
2885
+ const region = this._createRegionEntry(
2886
+ regionConfig,
2887
+ `preload_region_${layoutId}_${regionConfig.id}`,
2888
+ wrapper
2889
+ );
2913
2890
  preloadRegions.set(regionConfig.id, region);
2914
2891
  }
2915
2892
 
@@ -2990,20 +2967,7 @@ export class RendererLite {
2990
2967
 
2991
2968
  // ── Tear down old layout ──
2992
2969
  this.removeActionListeners();
2993
-
2994
- if (this.layoutTimer) {
2995
- clearTimeout(this.layoutTimer);
2996
- this.layoutTimer = null;
2997
- }
2998
-
2999
- if (this.preloadTimer) {
3000
- clearTimeout(this.preloadTimer);
3001
- this.preloadTimer = null;
3002
- }
3003
- if (this._preloadRetryTimer) {
3004
- clearTimeout(this._preloadRetryTimer);
3005
- this._preloadRetryTimer = null;
3006
- }
2970
+ this._clearLayoutTimers();
3007
2971
 
3008
2972
  const oldLayoutId = this.currentLayoutId;
3009
2973
  const alreadyEmittedEnd = this.layoutEndEmitted;
@@ -3173,6 +3137,24 @@ export class RendererLite {
3173
3137
  }
3174
3138
  }
3175
3139
 
3140
+ /**
3141
+ * Clear all layout-level timers (layout duration, preload, preload retry).
3142
+ */
3143
+ _clearLayoutTimers() {
3144
+ if (this.layoutTimer) {
3145
+ clearTimeout(this.layoutTimer);
3146
+ this.layoutTimer = null;
3147
+ }
3148
+ if (this.preloadTimer) {
3149
+ clearTimeout(this.preloadTimer);
3150
+ this.preloadTimer = null;
3151
+ }
3152
+ if (this._preloadRetryTimer) {
3153
+ clearTimeout(this._preloadRetryTimer);
3154
+ this._preloadRetryTimer = null;
3155
+ }
3156
+ }
3157
+
3176
3158
  /**
3177
3159
  * Stop current layout
3178
3160
  */
@@ -3194,18 +3176,7 @@ export class RendererLite {
3194
3176
  this.currentLayoutId = null;
3195
3177
 
3196
3178
  // Clear timers
3197
- if (this.layoutTimer) {
3198
- clearTimeout(this.layoutTimer);
3199
- this.layoutTimer = null;
3200
- }
3201
- if (this.preloadTimer) {
3202
- clearTimeout(this.preloadTimer);
3203
- this.preloadTimer = null;
3204
- }
3205
- if (this._preloadRetryTimer) {
3206
- clearTimeout(this._preloadRetryTimer);
3207
- this._preloadRetryTimer = null;
3208
- }
3179
+ this._clearLayoutTimers();
3209
3180
 
3210
3181
  // Remove interactive action listeners before teardown
3211
3182
  this.removeActionListeners();
@@ -3295,33 +3266,17 @@ export class RendererLite {
3295
3266
 
3296
3267
  // Create regions for overlay
3297
3268
  const overlayRegions = new Map();
3298
- const sf = this.scaleFactor;
3299
3269
  for (const regionConfig of layout.regions) {
3300
- const regionEl = document.createElement('div');
3301
- regionEl.id = `overlay_${layoutId}_region_${regionConfig.id}`;
3302
- regionEl.className = 'renderer-lite-region overlay-region';
3303
- regionEl.style.position = 'absolute';
3304
- regionEl.style.zIndex = String(regionConfig.zindex);
3305
- regionEl.style.overflow = 'hidden';
3306
-
3307
- // Apply scaled positioning
3308
- this.applyRegionScale(regionEl, regionConfig);
3309
-
3310
- overlayDiv.appendChild(regionEl);
3311
-
3312
- // Store region state (dimensions use scaled values)
3313
- overlayRegions.set(regionConfig.id, {
3314
- element: regionEl,
3315
- config: regionConfig,
3316
- widgets: regionConfig.widgets,
3317
- currentIndex: 0,
3318
- timer: null,
3319
- width: regionConfig.width * sf,
3320
- height: regionConfig.height * sf,
3321
- complete: false,
3322
- isCanvas: regionConfig.isCanvas || false,
3323
- widgetElements: new Map()
3324
- });
3270
+ const region = this._createRegionEntry(
3271
+ regionConfig,
3272
+ `overlay_${layoutId}_region_${regionConfig.id}`,
3273
+ overlayDiv,
3274
+ {
3275
+ className: 'renderer-lite-region overlay-region',
3276
+ isCanvas: regionConfig.isCanvas || false,
3277
+ }
3278
+ );
3279
+ overlayRegions.set(regionConfig.id, region);
3325
3280
  }
3326
3281
 
3327
3282
  // Pre-create widget elements for overlay
@@ -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
  });