@xiboplayer/renderer 0.6.12 → 0.7.0
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 -4
- package/src/index.d.ts +1 -0
- package/src/layout-pool.js +12 -0
- package/src/renderer-lite.js +73 -39
- package/src/renderer-lite.test.js +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "RendererLite - Fast, efficient XLF layout rendering engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"nanoevents": "^9.1.0",
|
|
15
15
|
"pdfjs-dist": "^4.10.38",
|
|
16
|
-
"@xiboplayer/
|
|
17
|
-
"@xiboplayer/utils": "0.
|
|
18
|
-
"@xiboplayer/
|
|
16
|
+
"@xiboplayer/cache": "0.7.0",
|
|
17
|
+
"@xiboplayer/utils": "0.7.0",
|
|
18
|
+
"@xiboplayer/schedule": "0.7.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"vitest": "^2.0.0",
|
package/src/index.d.ts
CHANGED
package/src/layout-pool.js
CHANGED
|
@@ -279,6 +279,18 @@ export class LayoutPool {
|
|
|
279
279
|
return count;
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Get the most recently added layout ID.
|
|
284
|
+
* @returns {number|undefined}
|
|
285
|
+
*/
|
|
286
|
+
getLatest() {
|
|
287
|
+
let latest;
|
|
288
|
+
for (const id of this.layouts.keys()) {
|
|
289
|
+
latest = id;
|
|
290
|
+
}
|
|
291
|
+
return latest;
|
|
292
|
+
}
|
|
293
|
+
|
|
282
294
|
/**
|
|
283
295
|
* Clear all entries (both hot and warm).
|
|
284
296
|
*/
|
package/src/renderer-lite.js
CHANGED
|
@@ -215,6 +215,7 @@ export class RendererLite {
|
|
|
215
215
|
this.layoutTimer = null;
|
|
216
216
|
this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
|
|
217
217
|
this._deferredTimerLayoutId = null; // Set when timer is deferred for dynamic layouts
|
|
218
|
+
this._deferredTimerFallback = null; // Safety timeout: starts layout timer if metadata never arrives
|
|
218
219
|
this._paused = false;
|
|
219
220
|
this._layoutTimerStartedAt = null; // Date.now() when layout timer started
|
|
220
221
|
this._layoutTimerDurationMs = null; // Total layout duration in ms
|
|
@@ -720,6 +721,11 @@ export class RendererLite {
|
|
|
720
721
|
if (this._hasUnprobedVideos()) {
|
|
721
722
|
this.log.info(`Layout duration updated to ${maxRegionDuration}s but still has unprobed videos — keeping timer deferred`);
|
|
722
723
|
} else {
|
|
724
|
+
// Cancel safety fallback — metadata arrived in time
|
|
725
|
+
if (this._deferredTimerFallback) {
|
|
726
|
+
clearTimeout(this._deferredTimerFallback);
|
|
727
|
+
this._deferredTimerFallback = null;
|
|
728
|
+
}
|
|
723
729
|
const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
|
|
724
730
|
const remainingMs = Math.max(1000, maxRegionDuration * 1000 - elapsed);
|
|
725
731
|
this._deferredTimerLayoutId = null;
|
|
@@ -1205,8 +1211,13 @@ export class RendererLite {
|
|
|
1205
1211
|
clearTimeout(this.layoutTimer);
|
|
1206
1212
|
this.layoutTimer = null;
|
|
1207
1213
|
}
|
|
1208
|
-
|
|
1214
|
+
|
|
1209
1215
|
this.layoutEndEmitted = false;
|
|
1216
|
+
this._deferredTimerLayoutId = null;
|
|
1217
|
+
if (this._deferredTimerFallback) {
|
|
1218
|
+
clearTimeout(this._deferredTimerFallback);
|
|
1219
|
+
this._deferredTimerFallback = null;
|
|
1220
|
+
}
|
|
1210
1221
|
|
|
1211
1222
|
// DON'T call stopCurrentLayout() - keep elements alive!
|
|
1212
1223
|
// DON'T recreate regions/elements - already exist!
|
|
@@ -1599,12 +1610,24 @@ export class RendererLite {
|
|
|
1599
1610
|
}
|
|
1600
1611
|
|
|
1601
1612
|
// Dynamic layouts (useDuration=0 videos): defer timer until video metadata
|
|
1602
|
-
// provides real durations.
|
|
1603
|
-
//
|
|
1613
|
+
// provides real durations. Safety timeout ensures corrupt/missing videos
|
|
1614
|
+
// don't freeze the display forever.
|
|
1604
1615
|
if (layout.isDynamic && this._hasUnprobedVideos()) {
|
|
1605
1616
|
this._deferredTimerLayoutId = layoutId;
|
|
1606
1617
|
this._layoutTimerStartedAt = Date.now();
|
|
1607
1618
|
this.log.info(`Layout ${layoutId} has unprobed videos — deferring timer until metadata loads`);
|
|
1619
|
+
|
|
1620
|
+
// Safety: if metadata never arrives (corrupt file, codec error), start
|
|
1621
|
+
// the timer with the estimated duration after 30s so the display keeps cycling.
|
|
1622
|
+
this._deferredTimerFallback = setTimeout(() => {
|
|
1623
|
+
this._deferredTimerFallback = null;
|
|
1624
|
+
if (this._deferredTimerLayoutId === layoutId && !this.layoutTimer) {
|
|
1625
|
+
this.log.warn(`Layout ${layoutId}: metadata timeout after 30s — starting timer with ${layout.duration}s estimate`);
|
|
1626
|
+
this._deferredTimerLayoutId = null;
|
|
1627
|
+
this._startLayoutTimer(layoutId, layout);
|
|
1628
|
+
}
|
|
1629
|
+
}, 30000);
|
|
1630
|
+
|
|
1608
1631
|
return;
|
|
1609
1632
|
}
|
|
1610
1633
|
|
|
@@ -1628,6 +1651,10 @@ export class RendererLite {
|
|
|
1628
1651
|
*/
|
|
1629
1652
|
_startLayoutTimer(layoutId, layout) {
|
|
1630
1653
|
this._deferredTimerLayoutId = null;
|
|
1654
|
+
if (this._deferredTimerFallback) {
|
|
1655
|
+
clearTimeout(this._deferredTimerFallback);
|
|
1656
|
+
this._deferredTimerFallback = null;
|
|
1657
|
+
}
|
|
1631
1658
|
const layoutDurationMs = layout.duration * 1000;
|
|
1632
1659
|
this.log.info(`Layout ${layoutId} will end after ${layout.duration}s`);
|
|
1633
1660
|
|
|
@@ -2274,6 +2301,18 @@ export class RendererLite {
|
|
|
2274
2301
|
const errorCode = error?.code;
|
|
2275
2302
|
const errorMessage = error?.message || 'Unknown error';
|
|
2276
2303
|
this.log.warn(`Video error: ${storedAs}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
|
|
2304
|
+
|
|
2305
|
+
// Set fallback duration so the deferred timer can proceed.
|
|
2306
|
+
// Without this, a corrupt video leaves widget.duration=0 forever,
|
|
2307
|
+
// _hasUnprobedVideos() stays true, and the deferred timer never unblocks.
|
|
2308
|
+
if (widget.useDuration === 0 && widget.duration === 0) {
|
|
2309
|
+
widget.duration = 60;
|
|
2310
|
+
this.log.info(`Set fallback duration 60s for errored widget ${widget.id}`);
|
|
2311
|
+
if (this.currentLayoutId === createdForLayoutId) {
|
|
2312
|
+
this.updateLayoutDuration();
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2277
2316
|
this.emit('videoError', { storedAs, fileId, errorCode, errorMessage, currentTime: video.currentTime });
|
|
2278
2317
|
};
|
|
2279
2318
|
video.addEventListener('error', onError);
|
|
@@ -2471,24 +2510,6 @@ export class RendererLite {
|
|
|
2471
2510
|
// Use cache URL — SW serves HTML and intercepts sub-resources
|
|
2472
2511
|
iframe.src = result.url;
|
|
2473
2512
|
|
|
2474
|
-
// On hard reload (Ctrl+Shift+R), iframe navigation bypasses SW → server 404
|
|
2475
|
-
// Detect and fall back to blob URL with original CMS signed URLs
|
|
2476
|
-
if (result.fallback) {
|
|
2477
|
-
const self = this;
|
|
2478
|
-
iframe.addEventListener('load', function() {
|
|
2479
|
-
try {
|
|
2480
|
-
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
2481
|
-
if (!iframe.contentDocument?.querySelector('base')) {
|
|
2482
|
-
self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
|
|
2483
|
-
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
2484
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
2485
|
-
self.trackBlobUrl(blobUrl);
|
|
2486
|
-
iframe.src = blobUrl;
|
|
2487
|
-
}
|
|
2488
|
-
} catch (e) { /* cross-origin — should not happen */ }
|
|
2489
|
-
}, { once: true });
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
2513
|
// Parse NUMITEMS/DURATION from fallback HTML (cache path)
|
|
2493
2514
|
if (result.fallback) {
|
|
2494
2515
|
this._parseDurationComments(result.fallback, widget);
|
|
@@ -2710,24 +2731,6 @@ export class RendererLite {
|
|
|
2710
2731
|
// Use cache URL — SW serves HTML and intercepts sub-resources
|
|
2711
2732
|
iframe.src = result.url;
|
|
2712
2733
|
|
|
2713
|
-
// On hard reload (Ctrl+Shift+R), iframe navigation bypasses SW → server 404
|
|
2714
|
-
// Detect and fall back to blob URL with original CMS signed URLs
|
|
2715
|
-
if (result.fallback) {
|
|
2716
|
-
const self = this;
|
|
2717
|
-
iframe.addEventListener('load', function() {
|
|
2718
|
-
try {
|
|
2719
|
-
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
2720
|
-
if (!iframe.contentDocument?.querySelector('base')) {
|
|
2721
|
-
self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
|
|
2722
|
-
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
2723
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
2724
|
-
self.trackBlobUrl(blobUrl);
|
|
2725
|
-
iframe.src = blobUrl;
|
|
2726
|
-
}
|
|
2727
|
-
} catch (e) { /* cross-origin — should not happen */ }
|
|
2728
|
-
}, { once: true });
|
|
2729
|
-
}
|
|
2730
|
-
|
|
2731
2734
|
// Parse NUMITEMS/DURATION from fallback HTML (cache path)
|
|
2732
2735
|
if (result.fallback) {
|
|
2733
2736
|
this._parseDurationComments(result.fallback, widget);
|
|
@@ -3109,6 +3112,33 @@ export class RendererLite {
|
|
|
3109
3112
|
this.log.info(`Swapped to preloaded layout ${layoutId} (instant transition)`);
|
|
3110
3113
|
}
|
|
3111
3114
|
|
|
3115
|
+
/**
|
|
3116
|
+
* Show a preloaded layout (swap from pool to visible).
|
|
3117
|
+
* If no layoutId, shows the most recently preloaded layout.
|
|
3118
|
+
* No-ops if the layout is not in the pool.
|
|
3119
|
+
* @param {number} [layoutId]
|
|
3120
|
+
*/
|
|
3121
|
+
showLayout(layoutId) {
|
|
3122
|
+
if (layoutId === undefined) {
|
|
3123
|
+
layoutId = this.layoutPool.getLatest();
|
|
3124
|
+
if (layoutId === undefined) {
|
|
3125
|
+
this.log.warn('showLayout: no preloaded layout to show');
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
// Same layout already showing — skip swap (self-swap would evict then fail).
|
|
3130
|
+
// Same-layout replay is handled by renderLayout's replay path instead.
|
|
3131
|
+
if (this.currentLayoutId === layoutId) {
|
|
3132
|
+
this.log.info(`showLayout: layout ${layoutId} already showing`);
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
if (!this.layoutPool.has(layoutId)) {
|
|
3136
|
+
this.log.warn(`showLayout: layout ${layoutId} not in preload pool`);
|
|
3137
|
+
return;
|
|
3138
|
+
}
|
|
3139
|
+
this._swapToPreloadedLayout(layoutId);
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3112
3142
|
/**
|
|
3113
3143
|
* Check if all regions have completed one full cycle
|
|
3114
3144
|
* This is informational only - layout timer is authoritative
|
|
@@ -3144,6 +3174,10 @@ export class RendererLite {
|
|
|
3144
3174
|
|
|
3145
3175
|
this.layoutEndEmitted = false;
|
|
3146
3176
|
this._deferredTimerLayoutId = null;
|
|
3177
|
+
if (this._deferredTimerFallback) {
|
|
3178
|
+
clearTimeout(this._deferredTimerFallback);
|
|
3179
|
+
this._deferredTimerFallback = null;
|
|
3180
|
+
}
|
|
3147
3181
|
this.currentLayout = null;
|
|
3148
3182
|
this.currentLayoutId = null;
|
|
3149
3183
|
|
|
@@ -1230,6 +1230,38 @@ describe('RendererLite', () => {
|
|
|
1230
1230
|
|
|
1231
1231
|
vi.useRealTimers();
|
|
1232
1232
|
});
|
|
1233
|
+
|
|
1234
|
+
it('should show a preloaded layout via showLayout()', async () => {
|
|
1235
|
+
const xlf = `<layout><region id="r1"></region></layout>`;
|
|
1236
|
+
const layoutStartHandler = vi.fn();
|
|
1237
|
+
renderer.on('layoutStart', layoutStartHandler);
|
|
1238
|
+
|
|
1239
|
+
// Preload layout hidden
|
|
1240
|
+
await renderer.preloadLayout(xlf, 42);
|
|
1241
|
+
expect(renderer.currentLayoutId).not.toBe(42);
|
|
1242
|
+
expect(layoutStartHandler).not.toHaveBeenCalled();
|
|
1243
|
+
|
|
1244
|
+
// Show it
|
|
1245
|
+
renderer.showLayout(42);
|
|
1246
|
+
expect(renderer.currentLayoutId).toBe(42);
|
|
1247
|
+
expect(layoutStartHandler).toHaveBeenCalledWith(42, expect.any(Object));
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
it('should show the latest preloaded layout when no id given', async () => {
|
|
1251
|
+
const xlf1 = `<layout bgcolor="#ff0000"><region id="r1"></region></layout>`;
|
|
1252
|
+
const xlf2 = `<layout bgcolor="#00ff00"><region id="r2"></region></layout>`;
|
|
1253
|
+
|
|
1254
|
+
await renderer.preloadLayout(xlf1, 10);
|
|
1255
|
+
await renderer.preloadLayout(xlf2, 20);
|
|
1256
|
+
|
|
1257
|
+
renderer.showLayout();
|
|
1258
|
+
expect(renderer.currentLayoutId).toBe(20);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it('should no-op showLayout when pool is empty', () => {
|
|
1262
|
+
renderer.showLayout(999);
|
|
1263
|
+
expect(renderer.currentLayoutId).toBeNull();
|
|
1264
|
+
});
|
|
1233
1265
|
});
|
|
1234
1266
|
|
|
1235
1267
|
describe('Layout Replay Optimization', () => {
|