@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 +6 -6
- package/src/index.d.ts +3 -3
- package/src/renderer-lite.js +102 -147
- 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.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/
|
|
16
|
-
"@xiboplayer/
|
|
17
|
-
"@xiboplayer/
|
|
15
|
+
"@xiboplayer/schedule": "0.7.3",
|
|
16
|
+
"@xiboplayer/utils": "0.7.3",
|
|
17
|
+
"@xiboplayer/cache": "0.7.3"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"
|
|
21
|
-
"
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
package/src/renderer-lite.js
CHANGED
|
@@ -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
|
-
*
|
|
1343
|
-
*
|
|
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
|
-
|
|
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 =
|
|
1348
|
-
regionEl.className =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
-
|
|
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
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
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
|
-
|
|
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
|
});
|