@xiboplayer/renderer 0.6.13 → 0.7.1

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.6.13",
3
+ "version": "0.7.1",
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.6.13",
17
- "@xiboplayer/schedule": "0.6.13",
18
- "@xiboplayer/utils": "0.6.13"
15
+ "@xiboplayer/cache": "0.7.1",
16
+ "@xiboplayer/schedule": "0.7.1",
17
+ "@xiboplayer/utils": "0.7.1"
19
18
  },
20
19
  "devDependencies": {
21
20
  "vitest": "^2.0.0",
package/src/index.d.ts CHANGED
@@ -63,6 +63,8 @@ export class RendererLite {
63
63
  resume(): void;
64
64
  isPaused(): boolean;
65
65
  resumeRegionMedia?(regionId: string): void;
66
+ showLayout(layoutId?: number): void;
67
+ getCurrentLayoutId(): number | null;
66
68
 
67
69
  parseXlf(xlfXml: string): any;
68
70
  parseWidget(mediaEl: Element): any;
@@ -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
  */
@@ -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;
@@ -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
@@ -222,6 +223,10 @@ export class RendererLite {
222
223
  this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
223
224
  this.audioOverlays = new Map(); // widgetId => [HTMLAudioElement] (audio overlays for widgets)
224
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
+
225
230
  // Scale state (for fitting layout to screen)
226
231
  this.scaleFactor = 1;
227
232
  this.offsetX = 0;
@@ -355,7 +360,7 @@ export class RendererLite {
355
360
  * Event emitter interface (like XMR wrapper)
356
361
  */
357
362
  on(event, callback) {
358
- return this.emitter.on(event, callback);
363
+ this.emitter.on(event, callback);
359
364
  }
360
365
 
361
366
  emit(event, ...args) {
@@ -720,6 +725,11 @@ export class RendererLite {
720
725
  if (this._hasUnprobedVideos()) {
721
726
  this.log.info(`Layout duration updated to ${maxRegionDuration}s but still has unprobed videos — keeping timer deferred`);
722
727
  } else {
728
+ // Cancel safety fallback — metadata arrived in time
729
+ if (this._deferredTimerFallback) {
730
+ clearTimeout(this._deferredTimerFallback);
731
+ this._deferredTimerFallback = null;
732
+ }
723
733
  const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
724
734
  const remainingMs = Math.max(1000, maxRegionDuration * 1000 - elapsed);
725
735
  this._deferredTimerLayoutId = null;
@@ -947,8 +957,8 @@ export class RendererLite {
947
957
  const isMain = regionMap === this.regions;
948
958
  this._startRegionCycle(
949
959
  region, regionId,
950
- isMain ? (rid, idx) => this.renderWidget(rid, idx) : (rid, idx) => this.renderWidget(rid, idx),
951
- 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,
952
962
  isMain ? () => this.checkLayoutComplete() : undefined
953
963
  );
954
964
  }
@@ -1194,7 +1204,7 @@ export class RendererLite {
1194
1204
 
1195
1205
  // Stop all region timers and widgets, then reset to first widget
1196
1206
  this._clearRegionTimers(this.regions);
1197
- this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
1207
+ this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
1198
1208
  for (const [, region] of this.regions) {
1199
1209
  region.currentIndex = 0;
1200
1210
  region.complete = false;
@@ -1205,8 +1215,13 @@ export class RendererLite {
1205
1215
  clearTimeout(this.layoutTimer);
1206
1216
  this.layoutTimer = null;
1207
1217
  }
1208
- this._deferredTimerLayoutId = null;
1218
+
1209
1219
  this.layoutEndEmitted = false;
1220
+ this._deferredTimerLayoutId = null;
1221
+ if (this._deferredTimerFallback) {
1222
+ clearTimeout(this._deferredTimerFallback);
1223
+ this._deferredTimerFallback = null;
1224
+ }
1210
1225
 
1211
1226
  // DON'T call stopCurrentLayout() - keep elements alive!
1212
1227
  // DON'T recreate regions/elements - already exist!
@@ -1378,8 +1393,8 @@ export class RendererLite {
1378
1393
  const region = this.regions.get(regionId);
1379
1394
  this._startRegionCycle(
1380
1395
  region, regionId,
1381
- (rid, idx) => this.renderWidget(rid, idx),
1382
- (rid, idx) => this.stopWidget(rid, idx),
1396
+ this._renderWidgetBound,
1397
+ this._stopWidgetBound,
1383
1398
  () => {
1384
1399
  this.log.info(`Region ${regionId} completed one full cycle`);
1385
1400
  this.checkLayoutComplete();
@@ -1599,12 +1614,24 @@ export class RendererLite {
1599
1614
  }
1600
1615
 
1601
1616
  // Dynamic layouts (useDuration=0 videos): defer timer until video metadata
1602
- // provides real durations. Without this, the 60s fallback fires prematurely
1603
- // causing rapid layout cycling ("layout storm") on startup.
1617
+ // provides real durations. Safety timeout ensures corrupt/missing videos
1618
+ // don't freeze the display forever.
1604
1619
  if (layout.isDynamic && this._hasUnprobedVideos()) {
1605
1620
  this._deferredTimerLayoutId = layoutId;
1606
1621
  this._layoutTimerStartedAt = Date.now();
1607
1622
  this.log.info(`Layout ${layoutId} has unprobed videos — deferring timer until metadata loads`);
1623
+
1624
+ // Safety: if metadata never arrives (corrupt file, codec error), start
1625
+ // the timer with the estimated duration after 30s so the display keeps cycling.
1626
+ this._deferredTimerFallback = setTimeout(() => {
1627
+ this._deferredTimerFallback = null;
1628
+ if (this._deferredTimerLayoutId === layoutId && !this.layoutTimer) {
1629
+ this.log.warn(`Layout ${layoutId}: metadata timeout after 30s — starting timer with ${layout.duration}s estimate`);
1630
+ this._deferredTimerLayoutId = null;
1631
+ this._startLayoutTimer(layoutId, layout);
1632
+ }
1633
+ }, 30000);
1634
+
1608
1635
  return;
1609
1636
  }
1610
1637
 
@@ -1628,6 +1655,10 @@ export class RendererLite {
1628
1655
  */
1629
1656
  _startLayoutTimer(layoutId, layout) {
1630
1657
  this._deferredTimerLayoutId = null;
1658
+ if (this._deferredTimerFallback) {
1659
+ clearTimeout(this._deferredTimerFallback);
1660
+ this._deferredTimerFallback = null;
1661
+ }
1631
1662
  const layoutDurationMs = layout.duration * 1000;
1632
1663
  this.log.info(`Layout ${layoutId} will end after ${layout.duration}s`);
1633
1664
 
@@ -2274,6 +2305,18 @@ export class RendererLite {
2274
2305
  const errorCode = error?.code;
2275
2306
  const errorMessage = error?.message || 'Unknown error';
2276
2307
  this.log.warn(`Video error: ${storedAs}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
2308
+
2309
+ // Set fallback duration so the deferred timer can proceed.
2310
+ // Without this, a corrupt video leaves widget.duration=0 forever,
2311
+ // _hasUnprobedVideos() stays true, and the deferred timer never unblocks.
2312
+ if (widget.useDuration === 0 && widget.duration === 0) {
2313
+ widget.duration = 60;
2314
+ this.log.info(`Set fallback duration 60s for errored widget ${widget.id}`);
2315
+ if (this.currentLayoutId === createdForLayoutId) {
2316
+ this.updateLayoutDuration();
2317
+ }
2318
+ }
2319
+
2277
2320
  this.emit('videoError', { storedAs, fileId, errorCode, errorMessage, currentTime: video.currentTime });
2278
2321
  };
2279
2322
  video.addEventListener('error', onError);
@@ -2471,24 +2514,6 @@ export class RendererLite {
2471
2514
  // Use cache URL — SW serves HTML and intercepts sub-resources
2472
2515
  iframe.src = result.url;
2473
2516
 
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
2517
  // Parse NUMITEMS/DURATION from fallback HTML (cache path)
2493
2518
  if (result.fallback) {
2494
2519
  this._parseDurationComments(result.fallback, widget);
@@ -2710,24 +2735,6 @@ export class RendererLite {
2710
2735
  // Use cache URL — SW serves HTML and intercepts sub-resources
2711
2736
  iframe.src = result.url;
2712
2737
 
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
2738
  // Parse NUMITEMS/DURATION from fallback HTML (cache path)
2732
2739
  if (result.fallback) {
2733
2740
  this._parseDurationComments(result.fallback, widget);
@@ -3008,7 +3015,7 @@ export class RendererLite {
3008
3015
  if (oldLayoutId && this.layoutPool.has(oldLayoutId)) {
3009
3016
  // Stop all widgets before evicting (symmetric widgetEnd events)
3010
3017
  this._clearRegionTimers(this.regions);
3011
- this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
3018
+ this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
3012
3019
  // Old layout was preloaded — evict from pool (safe: removes its wrapper div)
3013
3020
  this.layoutPool.evict(oldLayoutId);
3014
3021
  } else {
@@ -3016,7 +3023,7 @@ export class RendererLite {
3016
3023
  // Region elements live directly in this.container (not a wrapper),
3017
3024
  // so we must remove them individually.
3018
3025
  this._clearRegionTimers(this.regions);
3019
- this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
3026
+ this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
3020
3027
  for (const [, region] of this.regions) {
3021
3028
  // Release video/audio resources before removing from DOM
3022
3029
  LayoutPool.releaseMediaElements(region.element);
@@ -3109,6 +3116,41 @@ export class RendererLite {
3109
3116
  this.log.info(`Swapped to preloaded layout ${layoutId} (instant transition)`);
3110
3117
  }
3111
3118
 
3119
+ /**
3120
+ * Get the currently showing layout ID.
3121
+ * @returns {number|null}
3122
+ */
3123
+ getCurrentLayoutId() {
3124
+ return this.currentLayoutId;
3125
+ }
3126
+
3127
+ /**
3128
+ * Show a preloaded layout (swap from pool to visible).
3129
+ * If no layoutId, shows the most recently preloaded layout.
3130
+ * No-ops if the layout is not in the pool.
3131
+ * @param {number} [layoutId]
3132
+ */
3133
+ showLayout(layoutId) {
3134
+ if (layoutId === undefined) {
3135
+ layoutId = this.layoutPool.getLatest();
3136
+ if (layoutId === undefined) {
3137
+ this.log.warn('showLayout: no preloaded layout to show');
3138
+ return;
3139
+ }
3140
+ }
3141
+ // Same layout already showing — skip swap (self-swap would evict then fail).
3142
+ // Same-layout replay is handled by renderLayout's replay path instead.
3143
+ if (this.currentLayoutId === layoutId) {
3144
+ this.log.info(`showLayout: layout ${layoutId} already showing`);
3145
+ return;
3146
+ }
3147
+ if (!this.layoutPool.has(layoutId)) {
3148
+ this.log.warn(`showLayout: layout ${layoutId} not in preload pool`);
3149
+ return;
3150
+ }
3151
+ this._swapToPreloadedLayout(layoutId);
3152
+ }
3153
+
3112
3154
  /**
3113
3155
  * Check if all regions have completed one full cycle
3114
3156
  * This is informational only - layout timer is authoritative
@@ -3144,6 +3186,10 @@ export class RendererLite {
3144
3186
 
3145
3187
  this.layoutEndEmitted = false;
3146
3188
  this._deferredTimerLayoutId = null;
3189
+ if (this._deferredTimerFallback) {
3190
+ clearTimeout(this._deferredTimerFallback);
3191
+ this._deferredTimerFallback = null;
3192
+ }
3147
3193
  this.currentLayout = null;
3148
3194
  this.currentLayoutId = null;
3149
3195
 
@@ -3178,7 +3224,7 @@ export class RendererLite {
3178
3224
 
3179
3225
  // Stop all regions — use helper to stop ALL started widgets (canvas fix)
3180
3226
  this._clearRegionTimers(this.regions);
3181
- this._stopAllRegionWidgets(this.regions, (rid, idx) => this.stopWidget(rid, idx));
3227
+ this._stopAllRegionWidgets(this.regions, this._stopWidgetBound);
3182
3228
  for (const [, region] of this.regions) {
3183
3229
  // Release video/audio resources before removing from DOM
3184
3230
  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;
@@ -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', () => {