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