@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.
- package/package.json +4 -3
- package/src/index.d.ts +1 -1
- package/src/layout-pool.test.js +1 -14
- package/src/layout.js +6 -0
- package/src/renderer-lite.ic.test.js +203 -0
- package/src/renderer-lite.js +349 -292
- package/src/renderer-lite.overlays.test.js +1 -27
- package/src/renderer-lite.test.js +511 -60
- package/vitest.config.js +9 -0
package/src/renderer-lite.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
501
|
+
// Uses shared parseLayoutDuration() — single source of truth for XLF-based duration calc
|
|
479
502
|
if (layout.duration === 0) {
|
|
480
|
-
|
|
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
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
if (maxRegionDuration > 0 && maxRegionDuration
|
|
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
|
-
|
|
985
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
|
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
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
-
//
|
|
1516
|
-
|
|
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
|
|
1728
|
-
selectedWidget = groupWidgets[
|
|
1729
|
-
|
|
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):
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
//
|
|
1890
|
-
const
|
|
1891
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1923
|
-
const
|
|
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 ${
|
|
2103
|
+
this.log.info(`Video ${storedAs} ended - reset to start, waiting for widget cycle to replay`);
|
|
1931
2104
|
} else {
|
|
1932
|
-
this.log.info(`Video ${
|
|
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.
|
|
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 ${
|
|
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 ${
|
|
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:',
|
|
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: ${
|
|
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:',
|
|
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:',
|
|
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
|
-
//
|
|
2117
|
-
const
|
|
2118
|
-
|
|
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 ${
|
|
2292
|
+
this.log.info(`Audio ${storedAs} ended - reset to start, waiting for widget cycle to replay`);
|
|
2133
2293
|
} else {
|
|
2134
|
-
this.log.info(`Audio ${
|
|
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 ${
|
|
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 ${
|
|
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): ${
|
|
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
|
-
//
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
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
|
-
//
|
|
2684
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2786
|
-
|
|
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
|
|
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
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
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
|
|
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
|
|
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) {
|