@xiboplayer/renderer 0.6.1 → 0.6.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.
@@ -41,12 +41,13 @@
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
  /**
47
48
  * Transition utilities for widget animations
48
49
  */
49
- const Transitions = {
50
+ export const Transitions = {
50
51
  /**
51
52
  * Apply fade in transition
52
53
  */
@@ -161,11 +162,15 @@ const Transitions = {
161
162
 
162
163
  switch (type) {
163
164
  case 'fade':
165
+ return isIn ? this.fadeIn(element, duration) : this.fadeOut(element, duration);
164
166
  case 'fadein':
165
167
  return isIn ? this.fadeIn(element, duration) : null;
166
168
  case 'fadeout':
167
169
  return isIn ? null : this.fadeOut(element, duration);
168
170
  case 'fly':
171
+ return isIn
172
+ ? this.flyIn(element, duration, direction, regionWidth, regionHeight)
173
+ : this.flyOut(element, duration, direction, regionWidth, regionHeight);
169
174
  case 'flyin':
170
175
  return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
171
176
  case 'flyout':
@@ -186,7 +191,7 @@ export class RendererLite {
186
191
  * @param {string} config.hardwareKey - Display hardware key
187
192
  * @param {HTMLElement} container - DOM container for rendering
188
193
  * @param {Object} options - Renderer options
189
- * @param {Function} options.getMediaUrl - Function to get media file URL (mediaId) => url
194
+ * @param {Map<string,string>} [options.fileIdToSaveAs] - Map from numeric file ID to storedAs filename (for layout backgrounds)
190
195
  * @param {Function} options.getWidgetHtml - Function to get widget HTML (layoutId, regionId, widgetId) => html
191
196
  */
192
197
  constructor(config, container, options = {}) {
@@ -203,6 +208,7 @@ export class RendererLite {
203
208
  // State
204
209
  this.currentLayout = null;
205
210
  this.currentLayoutId = null;
211
+ this._preloadingLayoutId = null; // Set during preload for blob URL tracking
206
212
  this.regions = new Map(); // regionId => { element, widgets, currentIndex, timer }
207
213
  this.layoutTimer = null;
208
214
  this.layoutEndEmitted = false; // Prevents double layoutEnd on stop after timer
@@ -210,7 +216,6 @@ export class RendererLite {
210
216
  this._layoutTimerStartedAt = null; // Date.now() when layout timer started
211
217
  this._layoutTimerDurationMs = null; // Total layout duration in ms
212
218
  this.widgetTimers = new Map(); // widgetId => timer
213
- this.mediaUrlCache = new Map(); // fileId => blob URL (for parallel pre-fetching)
214
219
  this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
215
220
  this.audioOverlays = new Map(); // widgetId => [HTMLAudioElement] (audio overlays for widgets)
216
221
 
@@ -238,6 +243,12 @@ export class RendererLite {
238
243
  // Setup container styles
239
244
  this.setupContainer();
240
245
 
246
+ // Interactive Control (XIC) event handlers
247
+ this.emitter.on('interactiveTrigger', (data) => this._handleInteractiveTrigger(data));
248
+ this.emitter.on('widgetExpire', (data) => this._handleWidgetExpire(data));
249
+ this.emitter.on('widgetExtendDuration', (data) => this._handleWidgetExtendDuration(data));
250
+ this.emitter.on('widgetSetDuration', (data) => this._handleWidgetSetDuration(data));
251
+
241
252
  this.log.info('Initialized');
242
253
  }
243
254
 
@@ -410,6 +421,7 @@ export class RendererLite {
410
421
  const regionAndDrawerEls = layoutEl.querySelectorAll(':scope > region, :scope > drawer');
411
422
  for (const regionEl of regionAndDrawerEls) {
412
423
  const isDrawer = regionEl.tagName === 'drawer';
424
+ const regionType = regionEl.getAttribute('type') || null;
413
425
  const region = {
414
426
  id: regionEl.getAttribute('id'),
415
427
  width: parseInt(regionEl.getAttribute('width') || '0'),
@@ -425,6 +437,7 @@ export class RendererLite {
425
437
  transitionDirection: null,
426
438
  loop: true, // Default: cycle widgets. Spec: loop=0 means single media stays visible
427
439
  isDrawer,
440
+ isCanvas: regionType === 'canvas', // Canvas regions render all widgets simultaneously
428
441
  widgets: []
429
442
  };
430
443
 
@@ -467,39 +480,28 @@ export class RendererLite {
467
480
  region.widgets.push(widget);
468
481
  }
469
482
 
483
+ // Auto-detect canvas from CMS "global" widget (CMS bundles canvas sub-widgets
484
+ // into a single type="global" media element in the XLF)
485
+ if (!region.isCanvas && region.widgets.some(w => w.type === 'global')) {
486
+ region.isCanvas = true;
487
+ }
488
+
470
489
  layout.regions.push(region);
471
490
 
472
491
  if (isDrawer) {
473
492
  this.log.info(`Parsed drawer: id=${region.id} with ${region.widgets.length} widgets`);
474
493
  }
494
+
495
+ if (region.isCanvas) {
496
+ this.log.info(`Parsed canvas region: id=${region.id} with ${region.widgets.length} widgets (all render simultaneously)`);
497
+ }
475
498
  }
476
499
 
477
500
  // Calculate layout duration if not specified (duration=0)
478
- // Drawers don't contribute to layout duration (they're action-triggered)
501
+ // Uses shared parseLayoutDuration() single source of truth for XLF-based duration calc
479
502
  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;
503
+ const { duration } = parseLayoutDuration(xlfXml);
504
+ layout.duration = duration;
503
505
  this.log.info(`Calculated layout duration: ${layout.duration}s (not specified in XLF)`);
504
506
  }
505
507
 
@@ -642,7 +644,7 @@ export class RendererLite {
642
644
  * @param {string} blobUrl - Blob URL to track
643
645
  */
644
646
  trackBlobUrl(blobUrl) {
645
- const layoutId = this.currentLayoutId || 0;
647
+ const layoutId = this._preloadingLayoutId || this.currentLayoutId || 0;
646
648
 
647
649
  if (!layoutId) {
648
650
  this.log.warn('trackBlobUrl called without currentLayoutId, tracking under key 0');
@@ -693,10 +695,10 @@ export class RendererLite {
693
695
  maxRegionDuration = Math.max(maxRegionDuration, regionDuration);
694
696
  }
695
697
 
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) {
698
+ // Update layout duration if recalculated value differs.
699
+ // Both upgrades (video metadata revealing longer duration) and downgrades
700
+ // (DURATION comment correcting an overestimate) are legitimate.
701
+ if (maxRegionDuration > 0 && maxRegionDuration !== this.currentLayout.duration) {
700
702
  const oldDuration = this.currentLayout.duration;
701
703
  this.currentLayout.duration = maxRegionDuration;
702
704
 
@@ -878,6 +880,133 @@ export class RendererLite {
878
880
  }
879
881
  }
880
882
 
883
+ // ── Interactive Control (XIC) ─────────────────────────────────────
884
+
885
+ /**
886
+ * Find a region containing a widget by widget ID.
887
+ * Searches main regions first, then overlay regions.
888
+ * @param {string} widgetId
889
+ * @returns {{ regionId: string, region: Object, widget: Object, widgetIndex: number, regionMap: Map }|null}
890
+ */
891
+ _findRegionByWidgetId(widgetId) {
892
+ // Search main regions
893
+ for (const [regionId, region] of this.regions) {
894
+ const widgetIndex = region.widgets.findIndex(w => w.id === widgetId);
895
+ if (widgetIndex !== -1) {
896
+ return { regionId, region, widget: region.widgets[widgetIndex], widgetIndex, regionMap: this.regions };
897
+ }
898
+ }
899
+ // Search overlay regions
900
+ for (const overlay of this.activeOverlays.values()) {
901
+ if (!overlay.regions) continue;
902
+ for (const [regionId, region] of overlay.regions) {
903
+ const widgetIndex = region.widgets.findIndex(w => w.id === widgetId);
904
+ if (widgetIndex !== -1) {
905
+ return { regionId, region, widget: region.widgets[widgetIndex], widgetIndex, regionMap: overlay.regions };
906
+ }
907
+ }
908
+ }
909
+ return null;
910
+ }
911
+
912
+ /**
913
+ * Advance a region to its next widget using the standard cycle.
914
+ * @param {string} regionId
915
+ * @param {Map} regionMap - The Map containing this region (main or overlay)
916
+ */
917
+ _advanceRegion(regionId, regionMap) {
918
+ const region = regionMap.get(regionId);
919
+ if (!region) return;
920
+ region.currentIndex = (region.currentIndex + 1) % region.widgets.length;
921
+ const isMain = regionMap === this.regions;
922
+ this._startRegionCycle(
923
+ region, regionId,
924
+ isMain ? (rid, idx) => this.renderWidget(rid, idx) : (rid, idx) => this.renderWidget(rid, idx),
925
+ isMain ? (rid, idx) => this.stopWidget(rid, idx) : (rid, idx) => this.stopWidget(rid, idx),
926
+ isMain ? () => this.checkLayoutComplete() : undefined
927
+ );
928
+ }
929
+
930
+ /**
931
+ * Handle interactiveTrigger XIC event — navigate to a target widget.
932
+ * @param {{ targetId: string, triggerCode: string }} data
933
+ */
934
+ _handleInteractiveTrigger({ targetId, triggerCode }) {
935
+ this.log.info(`XIC interactiveTrigger: target=${targetId} code=${triggerCode}`);
936
+ const found = this._findRegionByWidgetId(targetId);
937
+ if (found) {
938
+ this.navigateToWidget(targetId);
939
+ } else {
940
+ this.log.warn(`XIC interactiveTrigger: widget ${targetId} not found`);
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Handle widgetExpire XIC event — immediately expire a widget and advance.
946
+ * @param {{ widgetId: string }} data
947
+ */
948
+ _handleWidgetExpire({ widgetId }) {
949
+ const found = this._findRegionByWidgetId(widgetId);
950
+ if (!found) {
951
+ this.log.warn(`XIC widgetExpire: widget ${widgetId} not found`);
952
+ return;
953
+ }
954
+ const { regionId, region, widgetIndex, regionMap } = found;
955
+ this.log.info(`XIC widgetExpire: widget=${widgetId} region=${regionId}`);
956
+ if (region.timer) {
957
+ clearTimeout(region.timer);
958
+ region.timer = null;
959
+ }
960
+ this.stopWidget(regionId, widgetIndex);
961
+ this._advanceRegion(regionId, regionMap);
962
+ }
963
+
964
+ /**
965
+ * Handle widgetExtendDuration XIC event — extend the current widget timer.
966
+ * @param {{ widgetId: string, duration: number }} data - duration in seconds (added to remaining)
967
+ */
968
+ _handleWidgetExtendDuration({ widgetId, duration }) {
969
+ const found = this._findRegionByWidgetId(widgetId);
970
+ if (!found) {
971
+ this.log.warn(`XIC widgetExtendDuration: widget ${widgetId} not found`);
972
+ return;
973
+ }
974
+ const { regionId, region } = found;
975
+ this.log.info(`XIC widgetExtendDuration: widget=${widgetId} +${duration}s`);
976
+ if (region.timer) {
977
+ clearTimeout(region.timer);
978
+ region.timer = null;
979
+ }
980
+ // Re-arm timer with the extended duration
981
+ region.timer = setTimeout(() => {
982
+ this.stopWidget(regionId, region.currentIndex);
983
+ this._advanceRegion(regionId, found.regionMap);
984
+ }, duration * 1000);
985
+ }
986
+
987
+ /**
988
+ * Handle widgetSetDuration XIC event — replace the widget timer with an absolute duration.
989
+ * @param {{ widgetId: string, duration: number }} data - duration in seconds (absolute)
990
+ */
991
+ _handleWidgetSetDuration({ widgetId, duration }) {
992
+ const found = this._findRegionByWidgetId(widgetId);
993
+ if (!found) {
994
+ this.log.warn(`XIC widgetSetDuration: widget ${widgetId} not found`);
995
+ return;
996
+ }
997
+ const { regionId, region } = found;
998
+ this.log.info(`XIC widgetSetDuration: widget=${widgetId} ${duration}s`);
999
+ if (region.timer) {
1000
+ clearTimeout(region.timer);
1001
+ region.timer = null;
1002
+ }
1003
+ // Set timer with the absolute duration
1004
+ region.timer = setTimeout(() => {
1005
+ this.stopWidget(regionId, region.currentIndex);
1006
+ this._advanceRegion(regionId, found.regionMap);
1007
+ }, duration * 1000);
1008
+ }
1009
+
881
1010
  /**
882
1011
  * Navigate to a specific widget within a region (for navWidget actions)
883
1012
  */
@@ -914,6 +1043,9 @@ export class RendererLite {
914
1043
  if (region.isDrawer && nextIndex === 0) {
915
1044
  region.element.style.display = 'none';
916
1045
  this.log.info(`Drawer region ${regionId} hidden (cycle complete)`);
1046
+ } else if (region.isDrawer) {
1047
+ // Continue cycling through remaining drawer widgets (will hide on wrap to 0)
1048
+ this.navigateToWidget(region.widgets[nextIndex].id);
917
1049
  } else {
918
1050
  this.startRegion(regionId);
919
1051
  }
@@ -961,6 +1093,60 @@ export class RendererLite {
961
1093
  this.navigateToWidget(targetWidget.id);
962
1094
  }
963
1095
 
1096
+ // ── Layout Helpers ───────────────────────────────────────────────
1097
+
1098
+ /**
1099
+ * Get media file URL for storedAs filename.
1100
+ * @param {string} storedAs - The storedAs filename (e.g. "42_abc123.jpg")
1101
+ * @returns {string} Full URL for the media file
1102
+ */
1103
+ _mediaFileUrl(storedAs) {
1104
+ return `${window.location.origin}${PLAYER_API}/media/file/${storedAs}`;
1105
+ }
1106
+
1107
+ /**
1108
+ * Position a widget element to fill its region (hidden by default).
1109
+ * @param {HTMLElement} element
1110
+ */
1111
+ _positionWidgetElement(element) {
1112
+ Object.assign(element.style, {
1113
+ position: 'absolute',
1114
+ top: '0',
1115
+ left: '0',
1116
+ width: '100%',
1117
+ height: '100%',
1118
+ visibility: 'hidden',
1119
+ opacity: '0',
1120
+ });
1121
+ }
1122
+
1123
+ /**
1124
+ * Apply a background image with cover styling.
1125
+ * @param {HTMLElement} element
1126
+ * @param {string} url - Image URL
1127
+ */
1128
+ _applyBackgroundImage(element, url) {
1129
+ Object.assign(element.style, {
1130
+ backgroundImage: `url(${url})`,
1131
+ backgroundSize: 'cover',
1132
+ backgroundPosition: 'center',
1133
+ backgroundRepeat: 'no-repeat',
1134
+ });
1135
+ }
1136
+
1137
+ /**
1138
+ * Clear all region timers in a region map.
1139
+ * @param {Map} regions - Region map (regionId → region)
1140
+ */
1141
+ _clearRegionTimers(regions) {
1142
+ for (const [, region] of regions) {
1143
+ if (region.timer) {
1144
+ clearTimeout(region.timer);
1145
+ region.timer = null;
1146
+ }
1147
+ }
1148
+ }
1149
+
964
1150
  // ── Layout Rendering ──────────────────────────────────────────────
965
1151
 
966
1152
  /**
@@ -980,13 +1166,9 @@ export class RendererLite {
980
1166
  // OPTIMIZATION: Reuse existing elements for same layout (Arexibo pattern)
981
1167
  this.log.info(`Replaying layout ${layoutId} - reusing elements (no recreation!)`);
982
1168
 
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
1169
+ // Stop all region timers and reset to first widget
1170
+ this._clearRegionTimers(this.regions);
1171
+ for (const [, region] of this.regions) {
990
1172
  region.currentIndex = 0;
991
1173
  }
992
1174
 
@@ -998,7 +1180,6 @@ export class RendererLite {
998
1180
  this.layoutEndEmitted = false;
999
1181
 
1000
1182
  // DON'T call stopCurrentLayout() - keep elements alive!
1001
- // DON'T clear mediaUrlCache - keep blob URLs alive!
1002
1183
  // DON'T recreate regions/elements - already exist!
1003
1184
 
1004
1185
  // Emit layout start event
@@ -1045,50 +1226,11 @@ export class RendererLite {
1045
1226
  this.container.style.backgroundImage = ''; // Reset previous
1046
1227
 
1047
1228
  // 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
- }
1229
+ // With storedAs refactor, background may be a filename (e.g. "43.png") or a numeric fileId
1230
+ if (layout.background) {
1231
+ const saveAs = this.options.fileIdToSaveAs?.get(String(layout.background)) || layout.background;
1232
+ this._applyBackgroundImage(this.container, this._mediaFileUrl(saveAs));
1233
+ this.log.info(`Background image set: ${layout.background} ${saveAs}`);
1092
1234
  }
1093
1235
 
1094
1236
  // Create regions
@@ -1106,13 +1248,7 @@ export class RendererLite {
1106
1248
 
1107
1249
  try {
1108
1250
  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';
1251
+ this._positionWidgetElement(element);
1116
1252
  region.element.appendChild(element);
1117
1253
  region.widgetElements.set(widget.id, element);
1118
1254
  } catch (error) {
@@ -1192,6 +1328,7 @@ export class RendererLite {
1192
1328
  height: regionConfig.height * sf,
1193
1329
  complete: false, // Track if region has played all widgets once
1194
1330
  isDrawer: regionConfig.isDrawer || false,
1331
+ isCanvas: regionConfig.isCanvas || false, // Canvas regions render all widgets simultaneously
1195
1332
  widgetElements: new Map() // widgetId -> DOM element (for element reuse)
1196
1333
  });
1197
1334
  }
@@ -1465,13 +1602,15 @@ export class RendererLite {
1465
1602
  region.element.appendChild(element);
1466
1603
  }
1467
1604
 
1468
- // Hide all other widgets in region
1605
+ // Hide all other widgets in region (skip for canvas — all widgets stay visible)
1469
1606
  // Cancel fill:forwards animations first — they override inline styles
1470
- for (const [widgetId, widgetEl] of region.widgetElements) {
1471
- if (widgetId !== widget.id) {
1472
- widgetEl.getAnimations?.().forEach(a => a.cancel());
1473
- widgetEl.style.visibility = 'hidden';
1474
- widgetEl.style.opacity = '0';
1607
+ if (!region.isCanvas) {
1608
+ for (const [widgetId, widgetEl] of region.widgetElements) {
1609
+ if (widgetId !== widget.id) {
1610
+ widgetEl.getAnimations?.().forEach(a => a.cancel());
1611
+ widgetEl.style.visibility = 'hidden';
1612
+ widgetEl.style.opacity = '0';
1613
+ }
1475
1614
  }
1476
1615
  }
1477
1616
 
@@ -1512,22 +1651,8 @@ export class RendererLite {
1512
1651
  audio.loop = audioNode.loop;
1513
1652
  audio.volume = Math.max(0, Math.min(1, audioNode.volume / 100));
1514
1653
 
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
- }
1654
+ // Direct URL from storedAs filename
1655
+ audio.src = audioNode.uri ? this._mediaFileUrl(audioNode.uri) : '';
1531
1656
 
1532
1657
  // Append to DOM to prevent garbage collection in some browsers
1533
1658
  audio.style.display = 'none';
@@ -1661,12 +1786,15 @@ export class RendererLite {
1661
1786
  * @param {Object} widget - Widget config (duration may be updated)
1662
1787
  */
1663
1788
  _parseDurationComments(html, widget) {
1789
+ const oldDuration = widget.duration;
1790
+
1664
1791
  const durationMatch = html.match(/<!--\s*DURATION=(\d+)\s*-->/);
1665
1792
  if (durationMatch) {
1666
1793
  const newDuration = parseInt(durationMatch[1], 10);
1667
1794
  if (newDuration > 0) {
1668
1795
  this.log.info(`Widget ${widget.id}: DURATION comment overrides duration ${widget.duration}→${newDuration}s`);
1669
1796
  widget.duration = newDuration;
1797
+ if (widget.duration !== oldDuration) this.updateLayoutDuration();
1670
1798
  return;
1671
1799
  }
1672
1800
  }
@@ -1680,6 +1808,8 @@ export class RendererLite {
1680
1808
  widget.duration = newDuration;
1681
1809
  }
1682
1810
  }
1811
+
1812
+ if (widget.duration !== oldDuration) this.updateLayoutDuration();
1683
1813
  }
1684
1814
 
1685
1815
  /**
@@ -1723,10 +1853,17 @@ export class RendererLite {
1723
1853
  const idx = Math.floor(Math.random() * groupWidgets.length);
1724
1854
  selectedWidget = groupWidgets[idx];
1725
1855
  } else {
1726
- // Round-robin based on cycle index
1727
- const cycleIdx = this._subPlaylistCycleIndex.get(groupId) || 0;
1728
- selectedWidget = groupWidgets[cycleIdx % groupWidgets.length];
1729
- this._subPlaylistCycleIndex.set(groupId, cycleIdx + 1);
1856
+ // Round-robin based on cycle index, respecting playCount
1857
+ const state = this._subPlaylistCycleIndex.get(groupId) || { widgetIdx: 0, playsDone: 0 };
1858
+ selectedWidget = groupWidgets[state.widgetIdx % groupWidgets.length];
1859
+ const effectivePlayCount = selectedWidget.playCount || 1;
1860
+
1861
+ state.playsDone++;
1862
+ if (state.playsDone >= effectivePlayCount) {
1863
+ state.widgetIdx++;
1864
+ state.playsDone = 0;
1865
+ }
1866
+ this._subPlaylistCycleIndex.set(groupId, state);
1730
1867
  }
1731
1868
 
1732
1869
  this.log.info(`Sub-playlist cycle: group ${groupId} selected widget ${selectedWidget.id} (${groupWidgets.length} in group)`);
@@ -1747,6 +1884,13 @@ export class RendererLite {
1747
1884
  _startRegionCycle(region, regionId, showFn, hideFn, onCycleComplete) {
1748
1885
  if (!region || region.widgets.length === 0) return;
1749
1886
 
1887
+ // Canvas regions: render ALL widgets simultaneously (stacked), no cycling.
1888
+ // Duration = max widget duration; region completes when the longest widget expires.
1889
+ if (region.isCanvas) {
1890
+ this._startCanvasRegion(region, regionId, showFn, onCycleComplete);
1891
+ return;
1892
+ }
1893
+
1750
1894
  // Non-looping region with a single widget: show it and stay (spec: loop=0)
1751
1895
  if (region.widgets.length === 1) {
1752
1896
  showFn(regionId, 0);
@@ -1760,6 +1904,7 @@ export class RendererLite {
1760
1904
  showFn(regionId, widgetIndex);
1761
1905
 
1762
1906
  const duration = widget.duration * 1000;
1907
+ this.log.info(`Region ${regionId} widget ${widget.id} (${widget.type}) playing for ${widget.duration}s (useDuration=${widget.useDuration}, index ${widgetIndex}/${region.widgets.length})`);
1763
1908
  region.timer = setTimeout(() => {
1764
1909
  this._handleWidgetCycleEnd(widget, region, regionId, widgetIndex, showFn, hideFn, onCycleComplete, playNext);
1765
1910
  }, duration);
@@ -1768,6 +1913,37 @@ export class RendererLite {
1768
1913
  playNext();
1769
1914
  }
1770
1915
 
1916
+ /**
1917
+ * Start a canvas region — render all widgets simultaneously (stacked).
1918
+ * Canvas regions show every widget at once rather than cycling through them.
1919
+ * The region duration is the maximum widget duration.
1920
+ * @param {Object} region - Region state
1921
+ * @param {string} regionId - Region ID
1922
+ * @param {Function} showFn - Show widget function (regionId, widgetIndex)
1923
+ * @param {Function} onCycleComplete - Callback when region completes
1924
+ */
1925
+ _startCanvasRegion(region, regionId, showFn, onCycleComplete) {
1926
+ // Show all widgets at once
1927
+ for (let i = 0; i < region.widgets.length; i++) {
1928
+ showFn(regionId, i);
1929
+ }
1930
+
1931
+ // Mark region as complete after max widget duration
1932
+ const maxDuration = Math.max(...region.widgets.map(w => w.duration)) * 1000;
1933
+ if (maxDuration > 0) {
1934
+ region.timer = setTimeout(() => {
1935
+ if (!region.complete) {
1936
+ region.complete = true;
1937
+ onCycleComplete?.();
1938
+ }
1939
+ }, maxDuration);
1940
+ } else {
1941
+ // No duration — immediately complete
1942
+ region.complete = true;
1943
+ onCycleComplete?.();
1944
+ }
1945
+ }
1946
+
1771
1947
  /**
1772
1948
  * Handle widget cycle end — shared logic for timer-based and event-based cycling
1773
1949
  */
@@ -1791,10 +1967,11 @@ export class RendererLite {
1791
1967
  onCycleComplete?.();
1792
1968
  }
1793
1969
 
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);
1970
+ // Non-looping single-widget region (loop=0): don't replay.
1971
+ // Multi-widget regions (playlists) always cycle regardless of loop setting
1972
+ // in Xibo, loop=0 only means "don't repeat a single media item."
1973
+ if (nextIndex === 0 && region.config?.loop === false && region.widgets.length === 1) {
1974
+ showFn(regionId, 0);
1798
1975
  return;
1799
1976
  }
1800
1977
 
@@ -1886,17 +2063,12 @@ export class RendererLite {
1886
2063
 
1887
2064
  img.style.opacity = '0';
1888
2065
 
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);
1892
-
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
- }
2066
+ // Direct URL from storedAs filename store key = widget reference = serve URL
2067
+ const src = widget.options.uri
2068
+ ? this._mediaFileUrl(widget.options.uri)
2069
+ : '';
1898
2070
 
1899
- img.src = imageSrc;
2071
+ img.src = src;
1900
2072
  return img;
1901
2073
  }
1902
2074
 
@@ -1919,27 +2091,22 @@ export class RendererLite {
1919
2091
  video.controls = false; // Hidden by default — toggle with V key in PWA
1920
2092
  video.playsInline = true; // Prevent fullscreen on mobile
1921
2093
 
1922
- // Get media URL from cache (already pre-fetched!) or fetch on-demand
1923
- const fileId = parseInt(widget.fileId || widget.id);
2094
+ // Direct URL from storedAs filename
2095
+ const storedAs = widget.options.uri || '';
2096
+ const fileId = widget.fileId || widget.id;
1924
2097
 
1925
2098
  // Handle video end - pause on last frame instead of showing black
1926
2099
  // Widget cycling will restart the video via updateMediaElement()
1927
2100
  const onEnded = () => {
1928
2101
  if (widget.options.loop === '1') {
1929
2102
  video.currentTime = 0;
1930
- this.log.info(`Video ${fileId} ended - reset to start, waiting for widget cycle to replay`);
2103
+ this.log.info(`Video ${storedAs} ended - reset to start, waiting for widget cycle to replay`);
1931
2104
  } else {
1932
- this.log.info(`Video ${fileId} ended - paused on last frame`);
2105
+ this.log.info(`Video ${storedAs} ended - paused on last frame`);
1933
2106
  }
1934
2107
  };
1935
2108
  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
- }
2109
+ let videoSrc = storedAs ? this._mediaFileUrl(storedAs) : '';
1943
2110
 
1944
2111
  // HLS/DASH streaming support
1945
2112
  const isHlsStream = videoSrc.includes('.m3u8');
@@ -1985,7 +2152,7 @@ export class RendererLite {
1985
2152
  const createdForLayoutId = this.currentLayoutId;
1986
2153
  const onLoadedMetadata = () => {
1987
2154
  const videoDuration = Math.floor(video.duration);
1988
- this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
2155
+ this.log.info(`Video ${storedAs} duration detected: ${videoDuration}s`);
1989
2156
 
1990
2157
  if (widget.duration === 0 || widget.useDuration === 0) {
1991
2158
  widget.duration = videoDuration;
@@ -1994,14 +2161,14 @@ export class RendererLite {
1994
2161
  if (this.currentLayoutId === createdForLayoutId) {
1995
2162
  this.updateLayoutDuration();
1996
2163
  } else {
1997
- this.log.info(`Video ${fileId} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
2164
+ this.log.info(`Video ${storedAs} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
1998
2165
  }
1999
2166
  }
2000
2167
  };
2001
2168
  video.addEventListener('loadedmetadata', onLoadedMetadata);
2002
2169
 
2003
2170
  const onLoadedData = () => {
2004
- this.log.info('Video loaded and ready:', fileId);
2171
+ this.log.info('Video loaded and ready:', storedAs);
2005
2172
  };
2006
2173
  video.addEventListener('loadeddata', onLoadedData);
2007
2174
 
@@ -2009,13 +2176,13 @@ export class RendererLite {
2009
2176
  const error = video.error;
2010
2177
  const errorCode = error?.code;
2011
2178
  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 });
2179
+ this.log.warn(`Video error: ${storedAs}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
2180
+ this.emit('videoError', { storedAs, fileId, errorCode, errorMessage, currentTime: video.currentTime });
2014
2181
  };
2015
2182
  video.addEventListener('error', onError);
2016
2183
 
2017
2184
  const onPlaying = () => {
2018
- this.log.info('Video playing:', fileId);
2185
+ this.log.info('Video playing:', storedAs);
2019
2186
  };
2020
2187
  video.addEventListener('playing', onPlaying);
2021
2188
 
@@ -2028,7 +2195,7 @@ export class RendererLite {
2028
2195
  ['playing', onPlaying],
2029
2196
  ];
2030
2197
 
2031
- this.log.info('Video element created:', fileId, video.src);
2198
+ this.log.info('Video element created:', storedAs, video.src);
2032
2199
 
2033
2200
  return video;
2034
2201
  }
@@ -2113,25 +2280,18 @@ export class RendererLite {
2113
2280
  audio.loop = widget.options.loop === '1';
2114
2281
  audio.volume = parseFloat(widget.options.volume || '100') / 100;
2115
2282
 
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;
2283
+ // Direct URL from storedAs filename
2284
+ const storedAs = widget.options.uri || '';
2285
+ const fileId = widget.fileId || widget.id;
2286
+ audio.src = storedAs ? this._mediaFileUrl(storedAs) : '';
2127
2287
 
2128
2288
  // Handle audio end - similar to video ended handling
2129
2289
  const onAudioEnded = () => {
2130
2290
  if (widget.options.loop === '1') {
2131
2291
  audio.currentTime = 0;
2132
- this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
2292
+ this.log.info(`Audio ${storedAs} ended - reset to start, waiting for widget cycle to replay`);
2133
2293
  } else {
2134
- this.log.info(`Audio ${fileId} ended - playback complete`);
2294
+ this.log.info(`Audio ${storedAs} ended - playback complete`);
2135
2295
  }
2136
2296
  };
2137
2297
  audio.addEventListener('ended', onAudioEnded);
@@ -2140,7 +2300,7 @@ export class RendererLite {
2140
2300
  const audioCreatedForLayoutId = this.currentLayoutId;
2141
2301
  const onAudioLoadedMetadata = () => {
2142
2302
  const audioDuration = Math.floor(audio.duration);
2143
- this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
2303
+ this.log.info(`Audio ${storedAs} duration detected: ${audioDuration}s`);
2144
2304
 
2145
2305
  if (widget.duration === 0 || widget.useDuration === 0) {
2146
2306
  widget.duration = audioDuration;
@@ -2149,7 +2309,7 @@ export class RendererLite {
2149
2309
  if (this.currentLayoutId === audioCreatedForLayoutId) {
2150
2310
  this.updateLayoutDuration();
2151
2311
  } else {
2152
- this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2312
+ this.log.info(`Audio ${storedAs} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
2153
2313
  }
2154
2314
  }
2155
2315
  };
@@ -2158,7 +2318,7 @@ export class RendererLite {
2158
2318
  // Handle audio errors
2159
2319
  const onAudioError = () => {
2160
2320
  const error = audio.error;
2161
- this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
2321
+ this.log.warn(`Audio error (non-fatal): ${storedAs}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
2162
2322
  };
2163
2323
  audio.addEventListener('error', onAudioError);
2164
2324
 
@@ -2293,15 +2453,10 @@ export class RendererLite {
2293
2453
  }
2294
2454
  }
2295
2455
 
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
- }
2456
+ // Direct URL from storedAs filename
2457
+ let pdfUrl = widget.options.uri
2458
+ ? this._mediaFileUrl(widget.options.uri)
2459
+ : '';
2305
2460
 
2306
2461
  // Render PDF with multi-page cycling
2307
2462
  try {
@@ -2593,54 +2748,13 @@ export class RendererLite {
2593
2748
  wrapper.style.backgroundColor = layout.bgcolor;
2594
2749
 
2595
2750
  // 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
- }
2751
+ // With storedAs refactor, background may be a filename or a numeric fileId
2752
+ if (layout.background) {
2753
+ const saveAs = this.options.fileIdToSaveAs?.get(String(layout.background)) || layout.background;
2754
+ this._applyBackgroundImage(wrapper, this._mediaFileUrl(saveAs));
2638
2755
  }
2639
2756
 
2640
- // Temporarily swap mediaUrlCache so createWidgetElement uses preload cache
2641
- const savedMediaUrlCache = this.mediaUrlCache;
2642
2757
  const savedCurrentLayoutId = this.currentLayoutId;
2643
- this.mediaUrlCache = preloadMediaUrlCache;
2644
2758
 
2645
2759
  // Create regions in the hidden wrapper
2646
2760
  const preloadRegions = new Map();
@@ -2680,8 +2794,9 @@ export class RendererLite {
2680
2794
  this.layoutBlobUrls = new Map();
2681
2795
  this.layoutBlobUrls.set(layoutId, preloadBlobUrls);
2682
2796
 
2683
- // Temporarily set currentLayoutId for trackBlobUrl to work
2684
- this.currentLayoutId = layoutId;
2797
+ // Set _preloadingLayoutId so trackBlobUrl routes to the correct layout
2798
+ // without corrupting currentLayoutId (which other code reads during awaits)
2799
+ this._preloadingLayoutId = layoutId;
2685
2800
 
2686
2801
  // Pre-create all widget elements
2687
2802
  for (const [regionId, region] of preloadRegions) {
@@ -2692,13 +2807,7 @@ export class RendererLite {
2692
2807
 
2693
2808
  try {
2694
2809
  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';
2810
+ this._positionWidgetElement(element);
2702
2811
  region.element.appendChild(element);
2703
2812
  region.widgetElements.set(widget.id, element);
2704
2813
  } catch (error) {
@@ -2708,7 +2817,6 @@ export class RendererLite {
2708
2817
  }
2709
2818
 
2710
2819
  // Restore state
2711
- this.mediaUrlCache = savedMediaUrlCache;
2712
2820
  this.currentLayoutId = savedCurrentLayoutId;
2713
2821
 
2714
2822
  // Pause all videos in preloaded layout (autoplay starts them even when hidden)
@@ -2730,10 +2838,9 @@ export class RendererLite {
2730
2838
  layout,
2731
2839
  regions: preloadRegions,
2732
2840
  blobUrls: preloadBlobUrls,
2733
- mediaUrlCache: preloadMediaUrlCache
2734
2841
  });
2735
2842
 
2736
- this.log.info(`Layout ${layoutId} preloaded into pool (${preloadRegions.size} regions, ${preloadMediaUrlCache.size} media)`);
2843
+ this.log.info(`Layout ${layoutId} preloaded into pool (${preloadRegions.size} regions)`);
2737
2844
  return true;
2738
2845
 
2739
2846
  } catch (error) {
@@ -2782,15 +2889,12 @@ export class RendererLite {
2782
2889
  // Old layout was rendered normally — manual cleanup.
2783
2890
  // Region elements live directly in this.container (not a wrapper),
2784
2891
  // 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
- }
2892
+ this._clearRegionTimers(this.regions);
2893
+ for (const [, region] of this.regions) {
2790
2894
  // Release video/audio resources before removing from DOM
2791
2895
  LayoutPool.releaseMediaElements(region.element);
2792
2896
  // Apply region exit transition if configured, then remove
2793
- if (region.config && region.config.exitTransition) {
2897
+ if (region.config?.exitTransition) {
2794
2898
  const animation = Transitions.apply(
2795
2899
  region.element, region.config.exitTransition, false,
2796
2900
  region.width, region.height
@@ -2809,11 +2913,6 @@ export class RendererLite {
2809
2913
  if (oldLayoutId) {
2810
2914
  this.revokeBlobUrlsForLayout(oldLayoutId);
2811
2915
  }
2812
- for (const [fileId, blobUrl] of this.mediaUrlCache) {
2813
- if (blobUrl && typeof blobUrl === 'string' && blobUrl.startsWith('blob:')) {
2814
- URL.revokeObjectURL(blobUrl);
2815
- }
2816
- }
2817
2916
  }
2818
2917
 
2819
2918
  // Emit layoutEnd for old layout if timer hasn't already
@@ -2822,7 +2921,6 @@ export class RendererLite {
2822
2921
  }
2823
2922
 
2824
2923
  this.regions.clear();
2825
- this.mediaUrlCache.clear();
2826
2924
 
2827
2925
  // ── Activate preloaded layout ──
2828
2926
  preloaded.container.style.visibility = 'visible';
@@ -2833,16 +2931,15 @@ export class RendererLite {
2833
2931
  this.currentLayout = preloaded.layout;
2834
2932
  this.currentLayoutId = layoutId;
2835
2933
  this.regions = preloaded.regions;
2836
- this.mediaUrlCache = preloaded.mediaUrlCache || new Map();
2837
2934
  this.layoutEndEmitted = false;
2838
2935
 
2839
2936
  // Update container background to match preloaded layout
2840
2937
  this.container.style.backgroundColor = preloaded.layout.bgcolor;
2841
2938
  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;
2939
+ // Copy background styles from preloaded wrapper to main container
2940
+ for (const prop of ['backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat']) {
2941
+ this.container.style[prop] = preloaded.container.style[prop];
2942
+ }
2846
2943
  } else {
2847
2944
  this.container.style.backgroundImage = '';
2848
2945
  }
@@ -2942,12 +3039,8 @@ export class RendererLite {
2942
3039
  }
2943
3040
 
2944
3041
  // Stop all regions
3042
+ this._clearRegionTimers(this.regions);
2945
3043
  for (const [regionId, region] of this.regions) {
2946
- if (region.timer) {
2947
- clearTimeout(region.timer);
2948
- region.timer = null;
2949
- }
2950
-
2951
3044
  // Stop current widget
2952
3045
  if (region.widgets.length > 0) {
2953
3046
  this.stopWidget(regionId, region.currentIndex);
@@ -2957,13 +3050,12 @@ export class RendererLite {
2957
3050
  LayoutPool.releaseMediaElements(region.element);
2958
3051
 
2959
3052
  // Apply region exit transition if configured, then remove
2960
- if (region.config && region.config.exitTransition) {
3053
+ if (region.config?.exitTransition) {
2961
3054
  const animation = Transitions.apply(
2962
3055
  region.element, region.config.exitTransition, false,
2963
3056
  region.width, region.height
2964
3057
  );
2965
3058
  if (animation) {
2966
- // Remove element after exit transition completes
2967
3059
  const el = region.element;
2968
3060
  animation.onfinish = () => el.remove();
2969
3061
  } else {
@@ -2974,17 +3066,10 @@ export class RendererLite {
2974
3066
  }
2975
3067
  }
2976
3068
 
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
3069
  }
2984
3070
 
2985
3071
  // Clear state
2986
3072
  this.regions.clear();
2987
- this.mediaUrlCache.clear();
2988
3073
 
2989
3074
  // Emit layout end event only if timer hasn't already emitted it.
2990
3075
  // Timer-based layoutEnd (natural expiry) is authoritative — stopCurrentLayout
@@ -3033,34 +3118,6 @@ export class RendererLite {
3033
3118
  overlayDiv.style.pointerEvents = 'auto'; // Enable clicks on overlay
3034
3119
  overlayDiv.style.backgroundColor = layout.bgcolor;
3035
3120
 
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
3121
  // Calculate scale for overlay layout
3065
3122
  this.calculateScale(layout);
3066
3123
 
@@ -3090,6 +3147,7 @@ export class RendererLite {
3090
3147
  width: regionConfig.width * sf,
3091
3148
  height: regionConfig.height * sf,
3092
3149
  complete: false,
3150
+ isCanvas: regionConfig.isCanvas || false,
3093
3151
  widgetElements: new Map()
3094
3152
  });
3095
3153
  }
@@ -3102,8 +3160,7 @@ export class RendererLite {
3102
3160
 
3103
3161
  try {
3104
3162
  const element = await this.createWidgetElement(widget, region);
3105
- element.style.visibility = 'hidden';
3106
- element.style.opacity = '0';
3163
+ this._positionWidgetElement(element);
3107
3164
  region.element.appendChild(element);
3108
3165
  region.widgetElements.set(widget.id, element);
3109
3166
  } catch (error) {