@xiboplayer/renderer 0.5.7 → 0.5.9
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 +3 -3
- package/src/layout-pool.js +59 -11
- package/src/layout.js +28 -23
- package/src/renderer-lite.js +37 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "RendererLite - Fast, efficient XLF layout rendering engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"nanoevents": "^9.1.0",
|
|
14
14
|
"pdfjs-dist": "^4.10.38",
|
|
15
|
-
"@xiboplayer/
|
|
16
|
-
"@xiboplayer/
|
|
15
|
+
"@xiboplayer/utils": "0.5.9",
|
|
16
|
+
"@xiboplayer/cache": "0.5.9"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"vitest": "^2.0.0",
|
package/src/layout-pool.js
CHANGED
|
@@ -101,7 +101,7 @@ export class LayoutPool {
|
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
103
|
* Evict a specific layout from the pool.
|
|
104
|
-
*
|
|
104
|
+
* Releases video/audio resources, revokes blob URLs, and removes the container from the DOM.
|
|
105
105
|
* @param {number} layoutId
|
|
106
106
|
*/
|
|
107
107
|
evict(layoutId) {
|
|
@@ -110,6 +110,22 @@ export class LayoutPool {
|
|
|
110
110
|
|
|
111
111
|
log.info(`Evicting layout ${layoutId} from pool`);
|
|
112
112
|
|
|
113
|
+
// Stop any active region timers
|
|
114
|
+
if (entry.regions) {
|
|
115
|
+
for (const [regionId, region] of entry.regions) {
|
|
116
|
+
if (region.timer) {
|
|
117
|
+
clearTimeout(region.timer);
|
|
118
|
+
region.timer = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Release all video/audio resources BEFORE removing from DOM.
|
|
124
|
+
// Removing a <video> with an active src leaks decoded frame buffers.
|
|
125
|
+
if (entry.container) {
|
|
126
|
+
LayoutPool.releaseMediaElements(entry.container);
|
|
127
|
+
}
|
|
128
|
+
|
|
113
129
|
// Revoke blob URLs
|
|
114
130
|
if (entry.blobUrls && entry.blobUrls.size > 0) {
|
|
115
131
|
entry.blobUrls.forEach(url => {
|
|
@@ -127,16 +143,6 @@ export class LayoutPool {
|
|
|
127
143
|
}
|
|
128
144
|
}
|
|
129
145
|
|
|
130
|
-
// Stop any active region timers
|
|
131
|
-
if (entry.regions) {
|
|
132
|
-
for (const [regionId, region] of entry.regions) {
|
|
133
|
-
if (region.timer) {
|
|
134
|
-
clearTimeout(region.timer);
|
|
135
|
-
region.timer = null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
146
|
// Remove container from DOM
|
|
141
147
|
if (entry.container && entry.container.parentNode) {
|
|
142
148
|
entry.container.remove();
|
|
@@ -150,6 +156,48 @@ export class LayoutPool {
|
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Release all video and audio elements inside a container.
|
|
161
|
+
* Must be called BEFORE removing the container from the DOM —
|
|
162
|
+
* browsers keep decoded frame buffers alive for detached <video> elements
|
|
163
|
+
* that still have a src.
|
|
164
|
+
*
|
|
165
|
+
* @param {HTMLElement} container
|
|
166
|
+
*/
|
|
167
|
+
static releaseMediaElements(container) {
|
|
168
|
+
let videoCount = 0;
|
|
169
|
+
let hlsCount = 0;
|
|
170
|
+
|
|
171
|
+
container.querySelectorAll('video').forEach(v => {
|
|
172
|
+
// Destroy hls.js instance if attached (stored by renderVideo)
|
|
173
|
+
if (v._hlsInstance) {
|
|
174
|
+
v._hlsInstance.destroy();
|
|
175
|
+
v._hlsInstance = null;
|
|
176
|
+
hlsCount++;
|
|
177
|
+
}
|
|
178
|
+
// Stop MediaStream tracks (webcam/mic)
|
|
179
|
+
if (v._mediaStream) {
|
|
180
|
+
v._mediaStream.getTracks().forEach(t => t.stop());
|
|
181
|
+
v._mediaStream = null;
|
|
182
|
+
v.srcObject = null;
|
|
183
|
+
}
|
|
184
|
+
v.pause();
|
|
185
|
+
v.removeAttribute('src');
|
|
186
|
+
v.load(); // Forces browser to release decoded buffers
|
|
187
|
+
videoCount++;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
container.querySelectorAll('audio').forEach(a => {
|
|
191
|
+
a.pause();
|
|
192
|
+
a.removeAttribute('src');
|
|
193
|
+
a.load();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (videoCount > 0) {
|
|
197
|
+
log.info(`Released ${videoCount} video(s)${hlsCount ? ` (${hlsCount} HLS)` : ''}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
153
201
|
/**
|
|
154
202
|
* Evict the least-recently-used warm entry.
|
|
155
203
|
* Only warm entries are eligible for eviction (never the hot layout).
|
package/src/layout.js
CHANGED
|
@@ -155,23 +155,19 @@ export class LayoutTranslator {
|
|
|
155
155
|
if (!raw && lastError) {
|
|
156
156
|
log.warn('All retries failed, checking for cached widget HTML...');
|
|
157
157
|
|
|
158
|
-
// Try to get cached widget HTML
|
|
158
|
+
// Try to get cached widget HTML from ContentStore via proxy
|
|
159
159
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
raw = await cached.text();
|
|
166
|
-
options.widgetCacheKey = cachedKey;
|
|
167
|
-
log.info(`Using cached widget HTML (${raw.length} chars) - CMS update pending`);
|
|
160
|
+
const resp = await fetch(`/store/widget/${layoutId}/${regionId}/${id}`);
|
|
161
|
+
if (resp.ok) {
|
|
162
|
+
raw = await resp.text();
|
|
163
|
+
options.widgetCacheKey = `/cache/widget/${layoutId}/${regionId}/${id}`;
|
|
164
|
+
log.info(`Using stored widget HTML (${raw.length} chars) - CMS update pending`);
|
|
168
165
|
} else {
|
|
169
|
-
log.error(`No
|
|
170
|
-
// Show minimal placeholder that doesn't look like an error
|
|
166
|
+
log.error(`No stored version available for widget ${id}`);
|
|
171
167
|
raw = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:18px;">Content updating...</div>`;
|
|
172
168
|
}
|
|
173
|
-
} catch (
|
|
174
|
-
log.error('
|
|
169
|
+
} catch (storeError) {
|
|
170
|
+
log.error('Store fallback failed:', storeError);
|
|
175
171
|
raw = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:18px;">Content updating...</div>`;
|
|
176
172
|
}
|
|
177
173
|
}
|
|
@@ -330,10 +326,15 @@ Object.keys(regions).forEach(id => {
|
|
|
330
326
|
playRegion(id);
|
|
331
327
|
});
|
|
332
328
|
|
|
329
|
+
// Track active timers per region so layout teardown can cancel them
|
|
330
|
+
const regionTimers = {};
|
|
331
|
+
|
|
333
332
|
function playRegion(id) {
|
|
334
333
|
const region = regions[id];
|
|
335
334
|
if (!region || region.media.length === 0) return;
|
|
336
335
|
|
|
336
|
+
regionTimers[id] = [];
|
|
337
|
+
|
|
337
338
|
// If only one media item, just show it and don't cycle (arexibo behavior)
|
|
338
339
|
if (region.media.length === 1) {
|
|
339
340
|
const media = region.media[0];
|
|
@@ -349,15 +350,21 @@ function playRegion(id) {
|
|
|
349
350
|
if (media.start) media.start();
|
|
350
351
|
|
|
351
352
|
const duration = media.duration || 10;
|
|
352
|
-
setTimeout(() => {
|
|
353
|
+
const timerId = setTimeout(() => {
|
|
353
354
|
if (media.stop) media.stop();
|
|
354
355
|
currentIndex = (currentIndex + 1) % region.media.length;
|
|
355
356
|
playNext();
|
|
356
357
|
}, duration * 1000);
|
|
358
|
+
regionTimers[id].push(timerId);
|
|
357
359
|
}
|
|
358
360
|
|
|
359
361
|
playNext();
|
|
360
362
|
}
|
|
363
|
+
|
|
364
|
+
// Cleanup function — called before layout teardown
|
|
365
|
+
window._stopAllRegions = function() {
|
|
366
|
+
Object.values(regionTimers).forEach(timers => timers.forEach(t => clearTimeout(t)));
|
|
367
|
+
};
|
|
361
368
|
</script>
|
|
362
369
|
</body>
|
|
363
370
|
</html>`;
|
|
@@ -815,6 +822,9 @@ ${mediaJS}
|
|
|
815
822
|
}
|
|
816
823
|
}
|
|
817
824
|
|
|
825
|
+
// Store live timer array on element for cleanup (not JSON — stays current)
|
|
826
|
+
container._pageTimers = pageTimers;
|
|
827
|
+
|
|
818
828
|
// Start cycling
|
|
819
829
|
await cyclePage();
|
|
820
830
|
|
|
@@ -827,9 +837,6 @@ ${mediaJS}
|
|
|
827
837
|
container.style.opacity = '1';
|
|
828
838
|
}
|
|
829
839
|
|
|
830
|
-
// Store timers for cleanup
|
|
831
|
-
container.dataset.pageTimers = JSON.stringify(pageTimers.map(t => t));
|
|
832
|
-
|
|
833
840
|
} catch (error) {
|
|
834
841
|
console.error('[PDF] Render failed:', error);
|
|
835
842
|
container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">Failed to load PDF</div>';
|
|
@@ -841,12 +848,10 @@ ${mediaJS}
|
|
|
841
848
|
const region = document.getElementById('region_${regionId}');
|
|
842
849
|
const container = document.getElementById('${pdfContainerId}');
|
|
843
850
|
if (container) {
|
|
844
|
-
// Clear page cycling timers
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
JSON.parse(timers).forEach(t => clearTimeout(t));
|
|
849
|
-
} catch (e) {}
|
|
851
|
+
// Clear page cycling timers (live array, always current)
|
|
852
|
+
if (container._pageTimers) {
|
|
853
|
+
container._pageTimers.forEach(t => clearTimeout(t));
|
|
854
|
+
container._pageTimers.length = 0;
|
|
850
855
|
}
|
|
851
856
|
|
|
852
857
|
const transOut = ${transOut};
|
package/src/renderer-lite.js
CHANGED
|
@@ -689,30 +689,33 @@ export class RendererLite {
|
|
|
689
689
|
maxRegionDuration = Math.max(maxRegionDuration, regionDuration);
|
|
690
690
|
}
|
|
691
691
|
|
|
692
|
-
// If we calculated a
|
|
693
|
-
|
|
692
|
+
// If we calculated a LONGER duration, update layout.
|
|
693
|
+
// Never downgrade — widgets with useDuration=0 start at duration=0
|
|
694
|
+
// until loadedmetadata fires, so early calculations undercount.
|
|
695
|
+
if (maxRegionDuration > 0 && maxRegionDuration > this.currentLayout.duration) {
|
|
694
696
|
const oldDuration = this.currentLayout.duration;
|
|
695
697
|
this.currentLayout.duration = maxRegionDuration;
|
|
696
698
|
|
|
697
699
|
this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
|
|
698
700
|
this.emit('layoutDurationUpdated', this.currentLayoutId, maxRegionDuration);
|
|
699
701
|
|
|
700
|
-
// Reset layout timer with
|
|
702
|
+
// Reset layout timer with REMAINING time — not full duration.
|
|
701
703
|
// If startLayoutTimerWhenReady() hasn't fired yet (still waiting for widgets),
|
|
702
704
|
// it will pick up the updated duration when it starts the timer.
|
|
703
705
|
if (this.layoutTimer) {
|
|
704
706
|
clearTimeout(this.layoutTimer);
|
|
705
707
|
|
|
706
|
-
const
|
|
708
|
+
const elapsed = Date.now() - (this._layoutTimerStartedAt || Date.now());
|
|
709
|
+
const remainingMs = Math.max(1000, this.currentLayout.duration * 1000 - elapsed);
|
|
707
710
|
this.layoutTimer = setTimeout(() => {
|
|
708
711
|
this.log.info(`Layout ${this.currentLayoutId} duration expired (${this.currentLayout.duration}s)`);
|
|
709
712
|
if (this.currentLayoutId) {
|
|
710
713
|
this.layoutEndEmitted = true;
|
|
711
714
|
this.emit('layoutEnd', this.currentLayoutId);
|
|
712
715
|
}
|
|
713
|
-
},
|
|
716
|
+
}, remainingMs);
|
|
714
717
|
|
|
715
|
-
this.log.info(`Layout timer
|
|
718
|
+
this.log.info(`Layout timer adjusted to ${(remainingMs / 1000).toFixed(1)}s remaining (elapsed ${(elapsed / 1000).toFixed(1)}s of ${this.currentLayout.duration}s)`);
|
|
716
719
|
} else {
|
|
717
720
|
this.log.info(`Layout duration updated to ${maxRegionDuration}s (timer not yet started, will use new value)`);
|
|
718
721
|
}
|
|
@@ -1920,10 +1923,12 @@ export class RendererLite {
|
|
|
1920
1923
|
const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
|
|
1921
1924
|
hls.loadSource(videoSrc);
|
|
1922
1925
|
hls.attachMedia(video);
|
|
1926
|
+
video._hlsInstance = hls; // Store for cleanup on eviction
|
|
1923
1927
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
1924
1928
|
if (data.fatal) {
|
|
1925
1929
|
this.log.error(`HLS fatal error: ${data.type}`, data.details);
|
|
1926
1930
|
hls.destroy();
|
|
1931
|
+
video._hlsInstance = null;
|
|
1927
1932
|
}
|
|
1928
1933
|
});
|
|
1929
1934
|
this.log.info(`HLS stream (hls.js): ${fileId}`);
|
|
@@ -1941,17 +1946,28 @@ export class RendererLite {
|
|
|
1941
1946
|
}
|
|
1942
1947
|
|
|
1943
1948
|
// Detect video duration for dynamic layout timing (when useDuration=0)
|
|
1949
|
+
// Capture the layout ID at creation time — if the layout changes before
|
|
1950
|
+
// loadedmetadata fires (e.g. video was preloaded for next layout), we must
|
|
1951
|
+
// NOT update the current layout's duration with a different layout's video.
|
|
1952
|
+
const createdForLayoutId = this.currentLayoutId;
|
|
1944
1953
|
video.addEventListener('loadedmetadata', () => {
|
|
1945
1954
|
const videoDuration = Math.floor(video.duration);
|
|
1946
1955
|
this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
|
|
1947
1956
|
|
|
1948
|
-
//
|
|
1957
|
+
// Always update widget duration — it's the widget's own data, safe
|
|
1958
|
+
// even if this video was preloaded for a different layout.
|
|
1949
1959
|
if (widget.duration === 0 || widget.useDuration === 0) {
|
|
1950
1960
|
widget.duration = videoDuration;
|
|
1951
1961
|
this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
|
|
1952
1962
|
|
|
1953
|
-
//
|
|
1954
|
-
|
|
1963
|
+
// Only recalculate current layout's timer if this video belongs to it.
|
|
1964
|
+
// Preloaded layouts will pick up the corrected widget.duration when
|
|
1965
|
+
// they start playing (via updateLayoutDuration() in swapToPreloadedLayout).
|
|
1966
|
+
if (this.currentLayoutId === createdForLayoutId) {
|
|
1967
|
+
this.updateLayoutDuration();
|
|
1968
|
+
} else {
|
|
1969
|
+
this.log.info(`Video ${fileId} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
|
|
1970
|
+
}
|
|
1955
1971
|
}
|
|
1956
1972
|
});
|
|
1957
1973
|
|
|
@@ -2086,6 +2102,7 @@ export class RendererLite {
|
|
|
2086
2102
|
});
|
|
2087
2103
|
|
|
2088
2104
|
// Detect audio duration for dynamic layout timing (when useDuration=0)
|
|
2105
|
+
const audioCreatedForLayoutId = this.currentLayoutId;
|
|
2089
2106
|
audio.addEventListener('loadedmetadata', () => {
|
|
2090
2107
|
const audioDuration = Math.floor(audio.duration);
|
|
2091
2108
|
this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
|
|
@@ -2093,7 +2110,12 @@ export class RendererLite {
|
|
|
2093
2110
|
if (widget.duration === 0 || widget.useDuration === 0) {
|
|
2094
2111
|
widget.duration = audioDuration;
|
|
2095
2112
|
this.log.info(`Updated widget ${widget.id} duration to ${audioDuration}s (useDuration=0)`);
|
|
2096
|
-
|
|
2113
|
+
|
|
2114
|
+
if (this.currentLayoutId === audioCreatedForLayoutId) {
|
|
2115
|
+
this.updateLayoutDuration();
|
|
2116
|
+
} else {
|
|
2117
|
+
this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
|
|
2118
|
+
}
|
|
2097
2119
|
}
|
|
2098
2120
|
});
|
|
2099
2121
|
|
|
@@ -2650,12 +2672,8 @@ export class RendererLite {
|
|
|
2650
2672
|
clearTimeout(region.timer);
|
|
2651
2673
|
region.timer = null;
|
|
2652
2674
|
}
|
|
2653
|
-
// Release video resources
|
|
2654
|
-
region.element
|
|
2655
|
-
v.pause();
|
|
2656
|
-
v.removeAttribute('src');
|
|
2657
|
-
v.load();
|
|
2658
|
-
});
|
|
2675
|
+
// Release video/audio resources before removing from DOM
|
|
2676
|
+
LayoutPool.releaseMediaElements(region.element);
|
|
2659
2677
|
// Apply region exit transition if configured, then remove
|
|
2660
2678
|
if (region.config && region.config.exitTransition) {
|
|
2661
2679
|
const animation = Transitions.apply(
|
|
@@ -2818,6 +2836,9 @@ export class RendererLite {
|
|
|
2818
2836
|
this.stopWidget(regionId, region.currentIndex);
|
|
2819
2837
|
}
|
|
2820
2838
|
|
|
2839
|
+
// Release video/audio resources before removing from DOM
|
|
2840
|
+
LayoutPool.releaseMediaElements(region.element);
|
|
2841
|
+
|
|
2821
2842
|
// Apply region exit transition if configured, then remove
|
|
2822
2843
|
if (region.config && region.config.exitTransition) {
|
|
2823
2844
|
const animation = Transitions.apply(
|