@xiboplayer/renderer 0.6.1 → 0.6.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.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -13,8 +13,9 @@
13
13
  "dependencies": {
14
14
  "nanoevents": "^9.1.0",
15
15
  "pdfjs-dist": "^4.10.38",
16
- "@xiboplayer/cache": "0.6.1",
17
- "@xiboplayer/utils": "0.6.1"
16
+ "@xiboplayer/cache": "0.6.2",
17
+ "@xiboplayer/schedule": "0.6.2",
18
+ "@xiboplayer/utils": "0.6.2"
18
19
  },
19
20
  "devDependencies": {
20
21
  "vitest": "^2.0.0",
package/src/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface RendererConfig {
6
6
  }
7
7
 
8
8
  export interface RendererOptions {
9
- getMediaUrl?: (fileId: number) => Promise<string>;
9
+ fileIdToSaveAs?: Map<string, string>;
10
10
  getWidgetHtml?: (widget: any) => Promise<string | { url: string; fallback?: string }>;
11
11
  logLevel?: string;
12
12
  }
@@ -33,8 +33,7 @@ describe('LayoutPool', () => {
33
33
  container,
34
34
  layout: { width: 1920, height: 1080, duration: 60, bgcolor: '#000', regions: [] },
35
35
  regions: new Map(),
36
- blobUrls: new Set(),
37
- mediaUrlCache: new Map()
36
+ blobUrls: new Set()
38
37
  };
39
38
  }
40
39
 
@@ -170,18 +169,6 @@ describe('LayoutPool', () => {
170
169
  expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-2');
171
170
  });
172
171
 
173
- it('should revoke media blob URLs on eviction', () => {
174
- const entry = createMockEntry(1);
175
- entry.mediaUrlCache.set(10, 'blob:media-10');
176
- entry.mediaUrlCache.set(20, 'blob:media-20');
177
-
178
- pool.add(1, entry);
179
- pool.evict(1);
180
-
181
- expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:media-10');
182
- expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:media-20');
183
- });
184
-
185
172
  it('should remove container from DOM', () => {
186
173
  const entry = createMockEntry(1);
187
174
  pool.add(1, entry);
@@ -41,6 +41,7 @@
41
41
 
42
42
  import { createNanoEvents } from 'nanoevents';
43
43
  import { createLogger, isDebug, PLAYER_API } from '@xiboplayer/utils';
44
+ import { parseLayoutDuration } from '@xiboplayer/schedule';
44
45
  import { LayoutPool } from './layout-pool.js';
45
46
 
46
47
  /**
@@ -186,7 +187,7 @@ export class RendererLite {
186
187
  * @param {string} config.hardwareKey - Display hardware key
187
188
  * @param {HTMLElement} container - DOM container for rendering
188
189
  * @param {Object} options - Renderer options
189
- * @param {Function} options.getMediaUrl - Function to get media file URL (mediaId) => url
190
+ * @param {Map<string,string>} [options.fileIdToSaveAs] - Map from numeric file ID to storedAs filename (for layout backgrounds)
190
191
  * @param {Function} options.getWidgetHtml - Function to get widget HTML (layoutId, regionId, widgetId) => html
191
192
  */
192
193
  constructor(config, container, options = {}) {
@@ -203,6 +204,7 @@ export class RendererLite {
203
204
  // State
204
205
  this.currentLayout = null;
205
206
  this.currentLayoutId = null;
207
+ this._preloadingLayoutId = null; // Set during preload for blob URL tracking
206
208
  this.regions = new Map(); // regionId => { element, widgets, currentIndex, timer }
207
209
  this.layoutTimer = null;
208
210
  this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
@@ -210,7 +212,6 @@ export class RendererLite {
210
212
  this._layoutTimerStartedAt = null; // Date.now() when layout timer started
211
213
  this._layoutTimerDurationMs = null; // Total layout duration in ms
212
214
  this.widgetTimers = new Map(); // widgetId => timer
213
- this.mediaUrlCache = new Map(); // fileId => blob URL (for parallel pre-fetching)
214
215
  this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
215
216
  this.audioOverlays = new Map(); // widgetId => [HTMLAudioElement] (audio overlays for widgets)
216
217
 
@@ -475,31 +476,10 @@ export class RendererLite {
475
476
  }
476
477
 
477
478
  // Calculate layout duration if not specified (duration=0)
478
- // Drawers don't contribute to layout duration (they're action-triggered)
479
+ // Uses shared parseLayoutDuration() single source of truth for XLF-based duration calc
479
480
  if (layout.duration === 0) {
480
- let maxDuration = 0;
481
-
482
- for (const region of layout.regions) {
483
- if (region.isDrawer) continue;
484
- let regionDuration = 0;
485
-
486
- // Calculate region duration based on widgets
487
- for (const widget of region.widgets) {
488
- if (widget.duration > 0) {
489
- regionDuration += widget.duration;
490
- } else {
491
- // Widget with duration=0 means "use media length"
492
- // Default to 60s here; actual duration is detected dynamically
493
- // from video.loadedmetadata event and updateLayoutDuration() recalculates
494
- regionDuration = 60;
495
- break;
496
- }
497
- }
498
-
499
- maxDuration = Math.max(maxDuration, regionDuration);
500
- }
501
-
502
- layout.duration = maxDuration > 0 ? maxDuration : 60;
481
+ const { duration } = parseLayoutDuration(xlfXml);
482
+ layout.duration = duration;
503
483
  this.log.info(`Calculated layout duration: ${layout.duration}s (not specified in XLF)`);
504
484
  }
505
485
 
@@ -642,7 +622,7 @@ export class RendererLite {
642
622
  * @param {string} blobUrl - Blob URL to track
643
623
  */
644
624
  trackBlobUrl(blobUrl) {
645
- const layoutId = this.currentLayoutId || 0;
625
+ const layoutId = this._preloadingLayoutId || this.currentLayoutId || 0;
646
626
 
647
627
  if (!layoutId) {
648
628
  this.log.warn('trackBlobUrl called without currentLayoutId, tracking under key 0');
@@ -693,10 +673,10 @@ export class RendererLite {
693
673
  maxRegionDuration = Math.max(maxRegionDuration, regionDuration);
694
674
  }
695
675
 
696
- // If we calculated a LONGER duration, update layout.
697
- // Never downgrade widgets with useDuration=0 start at duration=0
698
- // until loadedmetadata fires, so early calculations undercount.
699
- if (maxRegionDuration > 0 && maxRegionDuration > this.currentLayout.duration) {
676
+ // Update layout duration if recalculated value differs.
677
+ // Both upgrades (video metadata revealing longer duration) and downgrades
678
+ // (DURATION comment correcting an overestimate) are legitimate.
679
+ if (maxRegionDuration > 0 && maxRegionDuration !== this.currentLayout.duration) {
700
680
  const oldDuration = this.currentLayout.duration;
701
681
  this.currentLayout.duration = maxRegionDuration;
702
682
 
@@ -961,6 +941,60 @@ export class RendererLite {
961
941
  this.navigateToWidget(targetWidget.id);
962
942
  }
963
943
 
944
+ // ── Layout Helpers ───────────────────────────────────────────────
945
+
946
+ /**
947
+ * Get media file URL for storedAs filename.
948
+ * @param {string} storedAs - The storedAs filename (e.g. "42_abc123.jpg")
949
+ * @returns {string} Full URL for the media file
950
+ */
951
+ _mediaFileUrl(storedAs) {
952
+ return `${window.location.origin}${PLAYER_API}/media/file/${storedAs}`;
953
+ }
954
+
955
+ /**
956
+ * Position a widget element to fill its region (hidden by default).
957
+ * @param {HTMLElement} element
958
+ */
959
+ _positionWidgetElement(element) {
960
+ Object.assign(element.style, {
961
+ position: 'absolute',
962
+ top: '0',
963
+ left: '0',
964
+ width: '100%',
965
+ height: '100%',
966
+ visibility: 'hidden',
967
+ opacity: '0',
968
+ });
969
+ }
970
+
971
+ /**
972
+ * Apply a background image with cover styling.
973
+ * @param {HTMLElement} element
974
+ * @param {string} url - Image URL
975
+ */
976
+ _applyBackgroundImage(element, url) {
977
+ Object.assign(element.style, {
978
+ backgroundImage: `url(${url})`,
979
+ backgroundSize: 'cover',
980
+ backgroundPosition: 'center',
981
+ backgroundRepeat: 'no-repeat',
982
+ });
983
+ }
984
+
985
+ /**
986
+ * Clear all region timers in a region map.
987
+ * @param {Map} regions - Region map (regionId → region)
988
+ */
989
+ _clearRegionTimers(regions) {
990
+ for (const [, region] of regions) {
991
+ if (region.timer) {
992
+ clearTimeout(region.timer);
993
+ region.timer = null;
994
+ }
995
+ }
996
+ }
997
+
964
998
  // ── Layout Rendering ──────────────────────────────────────────────
965
999
 
966
1000
  /**
@@ -980,13 +1014,9 @@ export class RendererLite {
980
1014
  // OPTIMIZATION: Reuse existing elements for same layout (Arexibo pattern)
981
1015
  this.log.info(`Replaying layout ${layoutId} - reusing elements (no recreation!)`);
982
1016
 
983
- // Stop all region timers
984
- for (const [regionId, region] of this.regions) {
985
- if (region.timer) {
986
- clearTimeout(region.timer);
987
- region.timer = null;
988
- }
989
- // Reset to first widget
1017
+ // Stop all region timers and reset to first widget
1018
+ this._clearRegionTimers(this.regions);
1019
+ for (const [, region] of this.regions) {
990
1020
  region.currentIndex = 0;
991
1021
  }
992
1022
 
@@ -998,7 +1028,6 @@ export class RendererLite {
998
1028
  this.layoutEndEmitted = false;
999
1029
 
1000
1030
  // DON'T call stopCurrentLayout() - keep elements alive!
1001
- // DON'T clear mediaUrlCache - keep blob URLs alive!
1002
1031
  // DON'T recreate regions/elements - already exist!
1003
1032
 
1004
1033
  // Emit layout start event
@@ -1045,50 +1074,11 @@ export class RendererLite {
1045
1074
  this.container.style.backgroundImage = ''; // Reset previous
1046
1075
 
1047
1076
  // Apply background image if specified in XLF
1048
- if (layout.background && this.options.getMediaUrl) {
1049
- try {
1050
- const bgUrl = await this.options.getMediaUrl(parseInt(layout.background));
1051
- if (bgUrl) {
1052
- this.container.style.backgroundImage = `url(${bgUrl})`;
1053
- this.container.style.backgroundSize = 'cover';
1054
- this.container.style.backgroundPosition = 'center';
1055
- this.container.style.backgroundRepeat = 'no-repeat';
1056
- this.log.info(`Background image set: ${layout.background}`);
1057
- }
1058
- } catch (err) {
1059
- this.log.warn('Failed to load background image:', err);
1060
- }
1061
- }
1062
-
1063
- // PRE-FETCH: Get all media URLs in parallel (huge speedup!)
1064
- if (this.options.getMediaUrl) {
1065
- const mediaPromises = [];
1066
- this.mediaUrlCache.clear(); // Clear previous layout's cache
1067
-
1068
- for (const region of layout.regions) {
1069
- for (const widget of region.widgets) {
1070
- if (widget.fileId) {
1071
- const fileId = parseInt(widget.fileId || widget.id);
1072
- if (!this.mediaUrlCache.has(fileId)) {
1073
- mediaPromises.push(
1074
- this.options.getMediaUrl(fileId)
1075
- .then(url => {
1076
- this.mediaUrlCache.set(fileId, url);
1077
- })
1078
- .catch(err => {
1079
- this.log.warn(`Failed to fetch media ${fileId}:`, err);
1080
- })
1081
- );
1082
- }
1083
- }
1084
- }
1085
- }
1086
-
1087
- if (mediaPromises.length > 0) {
1088
- this.log.info(`Pre-fetching ${mediaPromises.length} media URLs in parallel...`);
1089
- await Promise.all(mediaPromises);
1090
- this.log.info(`All media URLs pre-fetched`);
1091
- }
1077
+ // With storedAs refactor, background may be a filename (e.g. "43.png") or a numeric fileId
1078
+ if (layout.background) {
1079
+ const saveAs = this.options.fileIdToSaveAs?.get(String(layout.background)) || layout.background;
1080
+ this._applyBackgroundImage(this.container, this._mediaFileUrl(saveAs));
1081
+ this.log.info(`Background image set: ${layout.background} ${saveAs}`);
1092
1082
  }
1093
1083
 
1094
1084
  // Create regions
@@ -1106,13 +1096,7 @@ export class RendererLite {
1106
1096
 
1107
1097
  try {
1108
1098
  const element = await this.createWidgetElement(widget, region);
1109
- element.style.position = 'absolute';
1110
- element.style.top = '0';
1111
- element.style.left = '0';
1112
- element.style.width = '100%';
1113
- element.style.height = '100%';
1114
- element.style.visibility = 'hidden'; // Hidden by default
1115
- element.style.opacity = '0';
1099
+ this._positionWidgetElement(element);
1116
1100
  region.element.appendChild(element);
1117
1101
  region.widgetElements.set(widget.id, element);
1118
1102
  } catch (error) {
@@ -1512,22 +1496,8 @@ export class RendererLite {
1512
1496
  audio.loop = audioNode.loop;
1513
1497
  audio.volume = Math.max(0, Math.min(1, audioNode.volume / 100));
1514
1498
 
1515
- // Resolve audio URI via cache/proxy
1516
- const mediaId = parseInt(audioNode.mediaId);
1517
- let audioSrc = mediaId ? this.mediaUrlCache.get(mediaId) : null;
1518
-
1519
- if (!audioSrc && mediaId && this.options.getMediaUrl) {
1520
- // Async — fire and forget, set src when ready
1521
- this.options.getMediaUrl(mediaId).then(url => {
1522
- audio.src = url;
1523
- }).catch(() => {
1524
- audio.src = `${window.location.origin}${PLAYER_API}/media/${audioNode.uri}`;
1525
- });
1526
- } else if (!audioSrc) {
1527
- audio.src = `${window.location.origin}${PLAYER_API}/media/${audioNode.uri}`;
1528
- } else {
1529
- audio.src = audioSrc;
1530
- }
1499
+ // Direct URL from storedAs filename
1500
+ audio.src = audioNode.uri ? this._mediaFileUrl(audioNode.uri) : '';
1531
1501
 
1532
1502
  // Append to DOM to prevent garbage collection in some browsers
1533
1503
  audio.style.display = 'none';
@@ -1661,12 +1631,15 @@ export class RendererLite {
1661
1631
  * @param {Object} widget - Widget config (duration may be updated)
1662
1632
  */
1663
1633
  _parseDurationComments(html, widget) {
1634
+ const oldDuration = widget.duration;
1635
+
1664
1636
  const durationMatch = html.match(/<!--\s*DURATION=(\d+)\s*-->/);
1665
1637
  if (durationMatch) {
1666
1638
  const newDuration = parseInt(durationMatch[1], 10);
1667
1639
  if (newDuration > 0) {
1668
1640
  this.log.info(`Widget ${widget.id}: DURATION comment overrides duration ${widget.duration}→${newDuration}s`);
1669
1641
  widget.duration = newDuration;
1642
+ if (widget.duration !== oldDuration) this.updateLayoutDuration();
1670
1643
  return;
1671
1644
  }
1672
1645
  }
@@ -1680,6 +1653,8 @@ export class RendererLite {
1680
1653
  widget.duration = newDuration;
1681
1654
  }
1682
1655
  }
1656
+
1657
+ if (widget.duration !== oldDuration) this.updateLayoutDuration();
1683
1658
  }
1684
1659
 
1685
1660
  /**
@@ -1760,6 +1735,7 @@ export class RendererLite {
1760
1735
  showFn(regionId, widgetIndex);
1761
1736
 
1762
1737
  const duration = widget.duration * 1000;
1738
+ this.log.info(`Region ${regionId} widget ${widget.id} (${widget.type}) playing for ${widget.duration}s (useDuration=${widget.useDuration}, index ${widgetIndex}/${region.widgets.length})`);
1763
1739
  region.timer = setTimeout(() => {
1764
1740
  this._handleWidgetCycleEnd(widget, region, regionId, widgetIndex, showFn, hideFn, onCycleComplete, playNext);
1765
1741
  }, duration);
@@ -1791,10 +1767,11 @@ export class RendererLite {
1791
1767
  onCycleComplete?.();
1792
1768
  }
1793
1769
 
1794
- // Non-looping region (loop=0): stop after one full cycle
1795
- if (nextIndex === 0 && region.config?.loop === false) {
1796
- // Show the last widget again and keep it visible
1797
- showFn(regionId, region.widgets.length - 1);
1770
+ // Non-looping single-widget region (loop=0): don't replay.
1771
+ // Multi-widget regions (playlists) always cycle regardless of loop setting
1772
+ // in Xibo, loop=0 only means "don't repeat a single media item."
1773
+ if (nextIndex === 0 && region.config?.loop === false && region.widgets.length === 1) {
1774
+ showFn(regionId, 0);
1798
1775
  return;
1799
1776
  }
1800
1777
 
@@ -1886,17 +1863,12 @@ export class RendererLite {
1886
1863
 
1887
1864
  img.style.opacity = '0';
1888
1865
 
1889
- // Get media URL from cache (already pre-fetched!) or fetch on-demand
1890
- const fileId = parseInt(widget.fileId || widget.id);
1891
- let imageSrc = this.mediaUrlCache.get(fileId);
1866
+ // Direct URL from storedAs filename store key = widget reference = serve URL
1867
+ const src = widget.options.uri
1868
+ ? this._mediaFileUrl(widget.options.uri)
1869
+ : '';
1892
1870
 
1893
- if (!imageSrc && this.options.getMediaUrl) {
1894
- imageSrc = await this.options.getMediaUrl(fileId);
1895
- } else if (!imageSrc) {
1896
- imageSrc = `${window.location.origin}${PLAYER_API}/media/${widget.options.uri}`;
1897
- }
1898
-
1899
- img.src = imageSrc;
1871
+ img.src = src;
1900
1872
  return img;
1901
1873
  }
1902
1874
 
@@ -1919,27 +1891,22 @@ export class RendererLite {
1919
1891
  video.controls = false; // Hidden by default — toggle with V key in PWA
1920
1892
  video.playsInline = true; // Prevent fullscreen on mobile
1921
1893
 
1922
- // Get media URL from cache (already pre-fetched!) or fetch on-demand
1923
- const fileId = parseInt(widget.fileId || widget.id);
1894
+ // Direct URL from storedAs filename
1895
+ const storedAs = widget.options.uri || '';
1896
+ const fileId = widget.fileId || widget.id;
1924
1897
 
1925
1898
  // Handle video end - pause on last frame instead of showing black
1926
1899
  // Widget cycling will restart the video via updateMediaElement()
1927
1900
  const onEnded = () => {
1928
1901
  if (widget.options.loop === '1') {
1929
1902
  video.currentTime = 0;
1930
- this.log.info(`Video ${fileId} ended - reset to start, waiting for widget cycle to replay`);
1903
+ this.log.info(`Video ${storedAs} ended - reset to start, waiting for widget cycle to replay`);
1931
1904
  } else {
1932
- this.log.info(`Video ${fileId} ended - paused on last frame`);
1905
+ this.log.info(`Video ${storedAs} ended - paused on last frame`);
1933
1906
  }
1934
1907
  };
1935
1908
  video.addEventListener('ended', onEnded);
1936
- let videoSrc = this.mediaUrlCache.get(fileId);
1937
-
1938
- if (!videoSrc && this.options.getMediaUrl) {
1939
- videoSrc = await this.options.getMediaUrl(fileId);
1940
- } else if (!videoSrc) {
1941
- videoSrc = `${window.location.origin}${PLAYER_API}/media/${fileId}`;
1942
- }
1909
+ let videoSrc = storedAs ? this._mediaFileUrl(storedAs) : '';
1943
1910
 
1944
1911
  // HLS/DASH streaming support
1945
1912
  const isHlsStream = videoSrc.includes('.m3u8');
@@ -1985,7 +1952,7 @@ export class RendererLite {
1985
1952
  const createdForLayoutId = this.currentLayoutId;
1986
1953
  const onLoadedMetadata = () => {
1987
1954
  const videoDuration = Math.floor(video.duration);
1988
- this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
1955
+ this.log.info(`Video ${storedAs} duration detected: ${videoDuration}s`);
1989
1956
 
1990
1957
  if (widget.duration === 0 || widget.useDuration === 0) {
1991
1958
  widget.duration = videoDuration;
@@ -1994,14 +1961,14 @@ export class RendererLite {
1994
1961
  if (this.currentLayoutId === createdForLayoutId) {
1995
1962
  this.updateLayoutDuration();
1996
1963
  } else {
1997
- this.log.info(`Video ${fileId} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
1964
+ this.log.info(`Video ${storedAs} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
1998
1965
  }
1999
1966
  }
2000
1967
  };
2001
1968
  video.addEventListener('loadedmetadata', onLoadedMetadata);
2002
1969
 
2003
1970
  const onLoadedData = () => {
2004
- this.log.info('Video loaded and ready:', fileId);
1971
+ this.log.info('Video loaded and ready:', storedAs);
2005
1972
  };
2006
1973
  video.addEventListener('loadeddata', onLoadedData);
2007
1974
 
@@ -2009,13 +1976,13 @@ export class RendererLite {
2009
1976
  const error = video.error;
2010
1977
  const errorCode = error?.code;
2011
1978
  const errorMessage = error?.message || 'Unknown error';
2012
- this.log.warn(`Video error: ${fileId}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
2013
- this.emit('videoError', { fileId, errorCode, errorMessage, currentTime: video.currentTime });
1979
+ this.log.warn(`Video error: ${storedAs}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
1980
+ this.emit('videoError', { storedAs, fileId, errorCode, errorMessage, currentTime: video.currentTime });
2014
1981
  };
2015
1982
  video.addEventListener('error', onError);
2016
1983
 
2017
1984
  const onPlaying = () => {
2018
- this.log.info('Video playing:', fileId);
1985
+ this.log.info('Video playing:', storedAs);
2019
1986
  };
2020
1987
  video.addEventListener('playing', onPlaying);
2021
1988
 
@@ -2028,7 +1995,7 @@ export class RendererLite {
2028
1995
  ['playing', onPlaying],
2029
1996
  ];
2030
1997
 
2031
- this.log.info('Video element created:', fileId, video.src);
1998
+ this.log.info('Video element created:', storedAs, video.src);
2032
1999
 
2033
2000
  return video;
2034
2001
  }
@@ -2113,25 +2080,18 @@ export class RendererLite {
2113
2080
  audio.loop = widget.options.loop === '1';
2114
2081
  audio.volume = parseFloat(widget.options.volume || '100') / 100;
2115
2082
 
2116
- // Get media URL from cache (already pre-fetched!) or fetch on-demand
2117
- const fileId = parseInt(widget.fileId || widget.id);
2118
- let audioSrc = this.mediaUrlCache.get(fileId);
2119
-
2120
- if (!audioSrc && this.options.getMediaUrl) {
2121
- audioSrc = await this.options.getMediaUrl(fileId);
2122
- } else if (!audioSrc) {
2123
- audioSrc = `${window.location.origin}${PLAYER_API}/media/${fileId}`;
2124
- }
2125
-
2126
- audio.src = audioSrc;
2083
+ // Direct URL from storedAs filename
2084
+ const storedAs = widget.options.uri || '';
2085
+ const fileId = widget.fileId || widget.id;
2086
+ audio.src = storedAs ? this._mediaFileUrl(storedAs) : '';
2127
2087
 
2128
2088
  // Handle audio end - similar to video ended handling
2129
2089
  const onAudioEnded = () => {
2130
2090
  if (widget.options.loop === '1') {
2131
2091
  audio.currentTime = 0;
2132
- this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
2092
+ this.log.info(`Audio ${storedAs} ended - reset to start, waiting for widget cycle to replay`);
2133
2093
  } else {
2134
- this.log.info(`Audio ${fileId} ended - playback complete`);
2094
+ this.log.info(`Audio ${storedAs} ended - playback complete`);
2135
2095
  }
2136
2096
  };
2137
2097
  audio.addEventListener('ended', onAudioEnded);
@@ -2140,7 +2100,7 @@ export class RendererLite {
2140
2100
  const audioCreatedForLayoutId = this.currentLayoutId;
2141
2101
  const onAudioLoadedMetadata = () => {
2142
2102
  const audioDuration = Math.floor(audio.duration);
2143
- this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
2103
+ this.log.info(`Audio ${storedAs} duration detected: ${audioDuration}s`);
2144
2104
 
2145
2105
  if (widget.duration === 0 || widget.useDuration === 0) {
2146
2106
  widget.duration = audioDuration;
@@ -2149,7 +2109,7 @@ export class RendererLite {
2149
2109
  if (this.currentLayoutId === audioCreatedForLayoutId) {
2150
2110
  this.updateLayoutDuration();
2151
2111
  } else {
2152
- this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2112
+ this.log.info(`Audio ${storedAs} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2153
2113
  }
2154
2114
  }
2155
2115
  };
@@ -2158,7 +2118,7 @@ export class RendererLite {
2158
2118
  // Handle audio errors
2159
2119
  const onAudioError = () => {
2160
2120
  const error = audio.error;
2161
- this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
2121
+ this.log.warn(`Audio error (non-fatal): ${storedAs}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
2162
2122
  };
2163
2123
  audio.addEventListener('error', onAudioError);
2164
2124
 
@@ -2293,15 +2253,10 @@ export class RendererLite {
2293
2253
  }
2294
2254
  }
2295
2255
 
2296
- // Get PDF URL from cache (already pre-fetched!) or fetch on-demand
2297
- const fileId = parseInt(widget.fileId || widget.id);
2298
- let pdfUrl = this.mediaUrlCache.get(fileId);
2299
-
2300
- if (!pdfUrl && this.options.getMediaUrl) {
2301
- pdfUrl = await this.options.getMediaUrl(fileId);
2302
- } else if (!pdfUrl) {
2303
- pdfUrl = `${window.location.origin}${PLAYER_API}/media/${widget.options.uri}`;
2304
- }
2256
+ // Direct URL from storedAs filename
2257
+ let pdfUrl = widget.options.uri
2258
+ ? this._mediaFileUrl(widget.options.uri)
2259
+ : '';
2305
2260
 
2306
2261
  // Render PDF with multi-page cycling
2307
2262
  try {
@@ -2593,54 +2548,13 @@ export class RendererLite {
2593
2548
  wrapper.style.backgroundColor = layout.bgcolor;
2594
2549
 
2595
2550
  // Apply background image if specified
2596
- if (layout.background && this.options.getMediaUrl) {
2597
- try {
2598
- const bgUrl = await this.options.getMediaUrl(parseInt(layout.background));
2599
- if (bgUrl) {
2600
- wrapper.style.backgroundImage = `url(${bgUrl})`;
2601
- wrapper.style.backgroundSize = 'cover';
2602
- wrapper.style.backgroundPosition = 'center';
2603
- wrapper.style.backgroundRepeat = 'no-repeat';
2604
- }
2605
- } catch (err) {
2606
- this.log.warn('Preload: Failed to load background image:', err);
2607
- }
2608
- }
2609
-
2610
- // Pre-fetch all media URLs in parallel
2611
- const preloadMediaUrlCache = new Map();
2612
- if (this.options.getMediaUrl) {
2613
- const mediaPromises = [];
2614
-
2615
- for (const region of layout.regions) {
2616
- for (const widget of region.widgets) {
2617
- if (widget.fileId) {
2618
- const fileId = parseInt(widget.fileId || widget.id);
2619
- if (!preloadMediaUrlCache.has(fileId)) {
2620
- mediaPromises.push(
2621
- this.options.getMediaUrl(fileId)
2622
- .then(url => {
2623
- preloadMediaUrlCache.set(fileId, url);
2624
- })
2625
- .catch(err => {
2626
- this.log.warn(`Preload: Failed to fetch media ${fileId}:`, err);
2627
- })
2628
- );
2629
- }
2630
- }
2631
- }
2632
- }
2633
-
2634
- if (mediaPromises.length > 0) {
2635
- this.log.info(`Preload: fetching ${mediaPromises.length} media URLs...`);
2636
- await Promise.all(mediaPromises);
2637
- }
2551
+ // With storedAs refactor, background may be a filename or a numeric fileId
2552
+ if (layout.background) {
2553
+ const saveAs = this.options.fileIdToSaveAs?.get(String(layout.background)) || layout.background;
2554
+ this._applyBackgroundImage(wrapper, this._mediaFileUrl(saveAs));
2638
2555
  }
2639
2556
 
2640
- // Temporarily swap mediaUrlCache so createWidgetElement uses preload cache
2641
- const savedMediaUrlCache = this.mediaUrlCache;
2642
2557
  const savedCurrentLayoutId = this.currentLayoutId;
2643
- this.mediaUrlCache = preloadMediaUrlCache;
2644
2558
 
2645
2559
  // Create regions in the hidden wrapper
2646
2560
  const preloadRegions = new Map();
@@ -2680,8 +2594,9 @@ export class RendererLite {
2680
2594
  this.layoutBlobUrls = new Map();
2681
2595
  this.layoutBlobUrls.set(layoutId, preloadBlobUrls);
2682
2596
 
2683
- // Temporarily set currentLayoutId for trackBlobUrl to work
2684
- this.currentLayoutId = layoutId;
2597
+ // Set _preloadingLayoutId so trackBlobUrl routes to the correct layout
2598
+ // without corrupting currentLayoutId (which other code reads during awaits)
2599
+ this._preloadingLayoutId = layoutId;
2685
2600
 
2686
2601
  // Pre-create all widget elements
2687
2602
  for (const [regionId, region] of preloadRegions) {
@@ -2692,13 +2607,7 @@ export class RendererLite {
2692
2607
 
2693
2608
  try {
2694
2609
  const element = await this.createWidgetElement(widget, region);
2695
- element.style.position = 'absolute';
2696
- element.style.top = '0';
2697
- element.style.left = '0';
2698
- element.style.width = '100%';
2699
- element.style.height = '100%';
2700
- element.style.visibility = 'hidden';
2701
- element.style.opacity = '0';
2610
+ this._positionWidgetElement(element);
2702
2611
  region.element.appendChild(element);
2703
2612
  region.widgetElements.set(widget.id, element);
2704
2613
  } catch (error) {
@@ -2708,7 +2617,6 @@ export class RendererLite {
2708
2617
  }
2709
2618
 
2710
2619
  // Restore state
2711
- this.mediaUrlCache = savedMediaUrlCache;
2712
2620
  this.currentLayoutId = savedCurrentLayoutId;
2713
2621
 
2714
2622
  // Pause all videos in preloaded layout (autoplay starts them even when hidden)
@@ -2730,10 +2638,9 @@ export class RendererLite {
2730
2638
  layout,
2731
2639
  regions: preloadRegions,
2732
2640
  blobUrls: preloadBlobUrls,
2733
- mediaUrlCache: preloadMediaUrlCache
2734
2641
  });
2735
2642
 
2736
- this.log.info(`Layout ${layoutId} preloaded into pool (${preloadRegions.size} regions, ${preloadMediaUrlCache.size} media)`);
2643
+ this.log.info(`Layout ${layoutId} preloaded into pool (${preloadRegions.size} regions)`);
2737
2644
  return true;
2738
2645
 
2739
2646
  } catch (error) {
@@ -2782,15 +2689,12 @@ export class RendererLite {
2782
2689
  // Old layout was rendered normally — manual cleanup.
2783
2690
  // Region elements live directly in this.container (not a wrapper),
2784
2691
  // so we must remove them individually.
2785
- for (const [regionId, region] of this.regions) {
2786
- if (region.timer) {
2787
- clearTimeout(region.timer);
2788
- region.timer = null;
2789
- }
2692
+ this._clearRegionTimers(this.regions);
2693
+ for (const [, region] of this.regions) {
2790
2694
  // Release video/audio resources before removing from DOM
2791
2695
  LayoutPool.releaseMediaElements(region.element);
2792
2696
  // Apply region exit transition if configured, then remove
2793
- if (region.config && region.config.exitTransition) {
2697
+ if (region.config?.exitTransition) {
2794
2698
  const animation = Transitions.apply(
2795
2699
  region.element, region.config.exitTransition, false,
2796
2700
  region.width, region.height
@@ -2809,11 +2713,6 @@ export class RendererLite {
2809
2713
  if (oldLayoutId) {
2810
2714
  this.revokeBlobUrlsForLayout(oldLayoutId);
2811
2715
  }
2812
- for (const [fileId, blobUrl] of this.mediaUrlCache) {
2813
- if (blobUrl && typeof blobUrl === 'string' && blobUrl.startsWith('blob:')) {
2814
- URL.revokeObjectURL(blobUrl);
2815
- }
2816
- }
2817
2716
  }
2818
2717
 
2819
2718
  // Emit layoutEnd for old layout if timer hasn't already
@@ -2822,7 +2721,6 @@ export class RendererLite {
2822
2721
  }
2823
2722
 
2824
2723
  this.regions.clear();
2825
- this.mediaUrlCache.clear();
2826
2724
 
2827
2725
  // ── Activate preloaded layout ──
2828
2726
  preloaded.container.style.visibility = 'visible';
@@ -2833,16 +2731,15 @@ export class RendererLite {
2833
2731
  this.currentLayout = preloaded.layout;
2834
2732
  this.currentLayoutId = layoutId;
2835
2733
  this.regions = preloaded.regions;
2836
- this.mediaUrlCache = preloaded.mediaUrlCache || new Map();
2837
2734
  this.layoutEndEmitted = false;
2838
2735
 
2839
2736
  // Update container background to match preloaded layout
2840
2737
  this.container.style.backgroundColor = preloaded.layout.bgcolor;
2841
2738
  if (preloaded.container.style.backgroundImage) {
2842
- this.container.style.backgroundImage = preloaded.container.style.backgroundImage;
2843
- this.container.style.backgroundSize = preloaded.container.style.backgroundSize;
2844
- this.container.style.backgroundPosition = preloaded.container.style.backgroundPosition;
2845
- this.container.style.backgroundRepeat = preloaded.container.style.backgroundRepeat;
2739
+ // Copy background styles from preloaded wrapper to main container
2740
+ for (const prop of ['backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat']) {
2741
+ this.container.style[prop] = preloaded.container.style[prop];
2742
+ }
2846
2743
  } else {
2847
2744
  this.container.style.backgroundImage = '';
2848
2745
  }
@@ -2942,12 +2839,8 @@ export class RendererLite {
2942
2839
  }
2943
2840
 
2944
2841
  // Stop all regions
2842
+ this._clearRegionTimers(this.regions);
2945
2843
  for (const [regionId, region] of this.regions) {
2946
- if (region.timer) {
2947
- clearTimeout(region.timer);
2948
- region.timer = null;
2949
- }
2950
-
2951
2844
  // Stop current widget
2952
2845
  if (region.widgets.length > 0) {
2953
2846
  this.stopWidget(regionId, region.currentIndex);
@@ -2957,13 +2850,12 @@ export class RendererLite {
2957
2850
  LayoutPool.releaseMediaElements(region.element);
2958
2851
 
2959
2852
  // Apply region exit transition if configured, then remove
2960
- if (region.config && region.config.exitTransition) {
2853
+ if (region.config?.exitTransition) {
2961
2854
  const animation = Transitions.apply(
2962
2855
  region.element, region.config.exitTransition, false,
2963
2856
  region.width, region.height
2964
2857
  );
2965
2858
  if (animation) {
2966
- // Remove element after exit transition completes
2967
2859
  const el = region.element;
2968
2860
  animation.onfinish = () => el.remove();
2969
2861
  } else {
@@ -2974,17 +2866,10 @@ export class RendererLite {
2974
2866
  }
2975
2867
  }
2976
2868
 
2977
- // Revoke media blob URLs from cache
2978
- for (const [fileId, blobUrl] of this.mediaUrlCache) {
2979
- if (blobUrl && blobUrl.startsWith('blob:')) {
2980
- URL.revokeObjectURL(blobUrl);
2981
- }
2982
- }
2983
2869
  }
2984
2870
 
2985
2871
  // Clear state
2986
2872
  this.regions.clear();
2987
- this.mediaUrlCache.clear();
2988
2873
 
2989
2874
  // Emit layout end event only if timer hasn't already emitted it.
2990
2875
  // Timer-based layoutEnd (natural expiry) is authoritative — stopCurrentLayout
@@ -3033,34 +2918,6 @@ export class RendererLite {
3033
2918
  overlayDiv.style.pointerEvents = 'auto'; // Enable clicks on overlay
3034
2919
  overlayDiv.style.backgroundColor = layout.bgcolor;
3035
2920
 
3036
- // Pre-fetch all media URLs for overlay
3037
- if (this.options.getMediaUrl) {
3038
- const mediaPromises = [];
3039
- for (const region of layout.regions) {
3040
- for (const widget of region.widgets) {
3041
- if (widget.fileId) {
3042
- const fileId = parseInt(widget.fileId || widget.id);
3043
- if (!this.mediaUrlCache.has(fileId)) {
3044
- mediaPromises.push(
3045
- this.options.getMediaUrl(fileId)
3046
- .then(url => {
3047
- this.mediaUrlCache.set(fileId, url);
3048
- })
3049
- .catch(err => {
3050
- this.log.warn(`Failed to fetch overlay media ${fileId}:`, err);
3051
- })
3052
- );
3053
- }
3054
- }
3055
- }
3056
- }
3057
-
3058
- if (mediaPromises.length > 0) {
3059
- this.log.info(`Pre-fetching ${mediaPromises.length} overlay media URLs...`);
3060
- await Promise.all(mediaPromises);
3061
- }
3062
- }
3063
-
3064
2921
  // Calculate scale for overlay layout
3065
2922
  this.calculateScale(layout);
3066
2923
 
@@ -3102,8 +2959,7 @@ export class RendererLite {
3102
2959
 
3103
2960
  try {
3104
2961
  const element = await this.createWidgetElement(widget, region);
3105
- element.style.visibility = 'hidden';
3106
- element.style.opacity = '0';
2962
+ this._positionWidgetElement(element);
3107
2963
  region.element.appendChild(element);
3108
2964
  region.widgetElements.set(widget.id, element);
3109
2965
  } catch (error) {
@@ -65,7 +65,7 @@ describe('RendererLite - Overlay Rendering', () => {
65
65
  },
66
66
  container,
67
67
  {
68
- getMediaUrl: async (fileId) => `http://test.local/media/${fileId}`,
68
+ fileIdToSaveAs: new Map(),
69
69
  getWidgetHtml: async (widget) => widget.raw || '<p>Widget HTML</p>'
70
70
  }
71
71
  );
@@ -165,32 +165,6 @@ describe('RendererLite - Overlay Rendering', () => {
165
165
  expect(secondOverlayDiv).toBe(firstOverlayDiv);
166
166
  });
167
167
 
168
- it('should pre-fetch media URLs for overlay widgets', async () => {
169
- const getMediaUrl = vi.fn(async (fileId) => `http://test.local/media/${fileId}`);
170
-
171
- const customRenderer = new RendererLite(
172
- { cmsUrl: 'http://test.local', hardwareKey: 'test-key' },
173
- container,
174
- {
175
- getMediaUrl,
176
- getWidgetHtml: async (widget) => widget.raw
177
- }
178
- );
179
-
180
- const xlfWithMedia = `<?xml version="1.0"?>
181
- <layout width="1920" height="1080" bgcolor="#000000">
182
- <region id="1" width="400" height="200" top="0" left="0" zindex="0">
183
- <media id="10" fileId="555" type="image" duration="10">
184
- <options><uri>test.jpg</uri></options>
185
- </media>
186
- </region>
187
- </layout>`;
188
-
189
- await customRenderer.renderOverlay(xlfWithMedia, 300, 10);
190
-
191
- expect(getMediaUrl).toHaveBeenCalledWith(555);
192
- });
193
-
194
168
  it('should set overlay timer based on duration', async () => {
195
169
  vi.useFakeTimers();
196
170
 
@@ -11,7 +11,6 @@ import { RendererLite } from './renderer-lite.js';
11
11
  describe('RendererLite', () => {
12
12
  let container;
13
13
  let renderer;
14
- let mockGetMediaUrl;
15
14
  let mockGetWidgetHtml;
16
15
 
17
16
  beforeEach(() => {
@@ -29,7 +28,6 @@ describe('RendererLite', () => {
29
28
  }
30
29
 
31
30
  // Mock callbacks
32
- mockGetMediaUrl = vi.fn((fileId) => Promise.resolve(`blob://test-${fileId}`));
33
31
  mockGetWidgetHtml = vi.fn((widget) => Promise.resolve(`<html>Widget ${widget.id}</html>`));
34
32
 
35
33
  // Create renderer instance
@@ -37,7 +35,7 @@ describe('RendererLite', () => {
37
35
  { cmsUrl: 'https://test.com', hardwareKey: 'test-key' },
38
36
  container,
39
37
  {
40
- getMediaUrl: mockGetMediaUrl,
38
+ fileIdToSaveAs: new Map(),
41
39
  getWidgetHtml: mockGetWidgetHtml
42
40
  }
43
41
  );
@@ -415,7 +413,6 @@ describe('RendererLite', () => {
415
413
  expect(element.className).toBe('renderer-lite-widget');
416
414
  expect(element.style.width).toBe('100%');
417
415
  expect(element.style.height).toBe('100%');
418
- expect(mockGetMediaUrl).toHaveBeenCalledWith(1);
419
416
  });
420
417
 
421
418
  it('should default to objectFit contain and objectPosition center center', async () => {
@@ -566,7 +563,6 @@ describe('RendererLite', () => {
566
563
  expect(element.muted).toBe(true);
567
564
  // loop is intentionally false - handled manually via 'ended' event to avoid black frames
568
565
  expect(element.loop).toBe(false);
569
- expect(mockGetMediaUrl).toHaveBeenCalledWith(5);
570
566
  });
571
567
 
572
568
  it('should create text widget with iframe (blob fallback)', async () => {
@@ -1014,20 +1010,6 @@ describe('RendererLite', () => {
1014
1010
  });
1015
1011
 
1016
1012
  describe('Memory Management', () => {
1017
- it('should clear mediaUrlCache on layout switch', async () => {
1018
- const xlf1 = `<layout><region id="r1"></region></layout>`;
1019
- const xlf2 = `<layout><region id="r2"></region></layout>`;
1020
-
1021
- await renderer.renderLayout(xlf1, 1);
1022
- renderer.mediaUrlCache.set(1, 'blob://test-1');
1023
-
1024
- // Switch to different layout
1025
- await renderer.renderLayout(xlf2, 2);
1026
-
1027
- // Cache should be cleared
1028
- expect(renderer.mediaUrlCache.size).toBe(0);
1029
- });
1030
-
1031
1013
  it('should clear regions on stopCurrentLayout', async () => {
1032
1014
  const xlf = `
1033
1015
  <layout>
@@ -1111,8 +1093,22 @@ describe('RendererLite', () => {
1111
1093
  });
1112
1094
  });
1113
1095
 
1114
- describe('Parallel Media Pre-fetch', () => {
1115
- it('should pre-fetch all media URLs in parallel', async () => {
1096
+ describe('Media URL construction via fileIdToSaveAs', () => {
1097
+ it('should construct media URLs using fileIdToSaveAs map', async () => {
1098
+ const fileIdToSaveAs = new Map([
1099
+ ['1', '1.png'],
1100
+ ['5', '5.mp4'],
1101
+ ['7', '7.png']
1102
+ ]);
1103
+ const r = new RendererLite(
1104
+ { cmsUrl: 'https://test.com', hardwareKey: 'test-key' },
1105
+ container,
1106
+ {
1107
+ fileIdToSaveAs,
1108
+ getWidgetHtml: mockGetWidgetHtml
1109
+ }
1110
+ );
1111
+
1116
1112
  const xlf = `
1117
1113
  <layout>
1118
1114
  <region id="r1">
@@ -1129,16 +1125,11 @@ describe('RendererLite', () => {
1129
1125
  </layout>
1130
1126
  `;
1131
1127
 
1132
- await renderer.renderLayout(xlf, 1);
1133
-
1134
- // All media URLs should have been fetched
1135
- expect(mockGetMediaUrl).toHaveBeenCalledTimes(3);
1136
- expect(mockGetMediaUrl).toHaveBeenCalledWith(1);
1137
- expect(mockGetMediaUrl).toHaveBeenCalledWith(5);
1138
- expect(mockGetMediaUrl).toHaveBeenCalledWith(7);
1128
+ await r.renderLayout(xlf, 1);
1139
1129
 
1140
- // All should be in cache
1141
- expect(renderer.mediaUrlCache.size).toBe(3);
1130
+ // fileIdToSaveAs should have all 3 entries
1131
+ expect(fileIdToSaveAs.size).toBe(3);
1132
+ r.cleanup();
1142
1133
  });
1143
1134
  });
1144
1135
 
package/vitest.config.js CHANGED
@@ -4,5 +4,12 @@ export default defineConfig({
4
4
  test: {
5
5
  environment: 'jsdom',
6
6
  globals: true
7
+ },
8
+ resolve: {
9
+ alias: {
10
+ // hls.js is an optional runtime dependency (dynamic import in renderVideo).
11
+ // Alias to the monorepo mock so renderer tests work standalone.
12
+ 'hls.js': new URL('../../vitest.hls-mock.js', import.meta.url).pathname
13
+ }
7
14
  }
8
15
  });