@xiboplayer/renderer 0.5.19 → 0.6.0
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.js +15 -10
- package/src/renderer-lite.js +149 -97
- package/src/renderer-lite.overlays.test.js +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "RendererLite - Fast, efficient XLF layout rendering engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"nanoevents": "^9.1.0",
|
|
15
15
|
"pdfjs-dist": "^4.10.38",
|
|
16
|
-
"@xiboplayer/cache": "0.
|
|
17
|
-
"@xiboplayer/utils": "0.
|
|
16
|
+
"@xiboplayer/cache": "0.6.0",
|
|
17
|
+
"@xiboplayer/utils": "0.6.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"vitest": "^2.0.0",
|
package/src/layout.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { cacheWidgetHtml } from '@xiboplayer/cache';
|
|
7
|
-
import { createLogger } from '@xiboplayer/utils';
|
|
7
|
+
import { createLogger, PLAYER_API } from '@xiboplayer/utils';
|
|
8
8
|
|
|
9
9
|
const log = createLogger('Layout');
|
|
10
10
|
|
|
@@ -157,10 +157,10 @@ export class LayoutTranslator {
|
|
|
157
157
|
|
|
158
158
|
// Try to get cached widget HTML from ContentStore via proxy
|
|
159
159
|
try {
|
|
160
|
-
const resp = await fetch(`/store/
|
|
160
|
+
const resp = await fetch(`/store${PLAYER_API}/widgets/${layoutId}/${regionId}/${id}`);
|
|
161
161
|
if (resp.ok) {
|
|
162
162
|
raw = await resp.text();
|
|
163
|
-
options.widgetCacheKey =
|
|
163
|
+
options.widgetCacheKey = `${PLAYER_API}/widgets/${layoutId}/${regionId}/${id}`;
|
|
164
164
|
log.info(`Using stored widget HTML (${raw.length} chars) - CMS update pending`);
|
|
165
165
|
} else {
|
|
166
166
|
log.error(`No stored version available for widget ${id}`);
|
|
@@ -466,7 +466,7 @@ ${mediaJS}
|
|
|
466
466
|
switch (media.type) {
|
|
467
467
|
case 'image':
|
|
468
468
|
// Use absolute URL within service worker scope
|
|
469
|
-
const imageSrc = `${window.location.origin}/
|
|
469
|
+
const imageSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
|
|
470
470
|
startFn = `() => {
|
|
471
471
|
const region = document.getElementById('region_${regionId}');
|
|
472
472
|
const img = document.createElement('img');
|
|
@@ -490,7 +490,7 @@ ${mediaJS}
|
|
|
490
490
|
case 'video':
|
|
491
491
|
// All videos use cache URL pattern
|
|
492
492
|
// Background-downloaded videos will auto-reload when cache completes
|
|
493
|
-
const videoSrc = `${window.location.origin}/
|
|
493
|
+
const videoSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
|
|
494
494
|
const videoFilename = media.options.uri;
|
|
495
495
|
|
|
496
496
|
startFn = `() => {
|
|
@@ -515,8 +515,8 @@ ${mediaJS}
|
|
|
515
515
|
video.play();
|
|
516
516
|
}
|
|
517
517
|
};
|
|
518
|
+
video._retryOnCache = retryOnCache;
|
|
518
519
|
window.addEventListener('media-cached', retryOnCache);
|
|
519
|
-
video.dataset.cacheListener = 'attached';
|
|
520
520
|
|
|
521
521
|
region.innerHTML = '';
|
|
522
522
|
region.appendChild(video);
|
|
@@ -536,6 +536,11 @@ ${mediaJS}
|
|
|
536
536
|
const region = document.getElementById('region_${regionId}');
|
|
537
537
|
const video = document.querySelector('#region_${regionId} video');
|
|
538
538
|
if (video) {
|
|
539
|
+
// Remove global media-cached listener to prevent leak
|
|
540
|
+
if (video._retryOnCache) {
|
|
541
|
+
window.removeEventListener('media-cached', video._retryOnCache);
|
|
542
|
+
video._retryOnCache = null;
|
|
543
|
+
}
|
|
539
544
|
const transOut = ${transOut};
|
|
540
545
|
if (transOut && window.Transitions) {
|
|
541
546
|
const regionRect = region.getBoundingClientRect();
|
|
@@ -559,7 +564,7 @@ ${mediaJS}
|
|
|
559
564
|
// Text/ticker widgets use the same iframe pattern as default widgets.
|
|
560
565
|
// If no widgetCacheKey, fall through to the default case which handles unsupported types.
|
|
561
566
|
if (media.options.widgetCacheKey) {
|
|
562
|
-
const textUrl = `${window.location.origin}
|
|
567
|
+
const textUrl = `${window.location.origin}${media.options.widgetCacheKey}`;
|
|
563
568
|
const iframe = this._generateIframeWidgetJS(regionId, media.id, textUrl, transIn, transOut);
|
|
564
569
|
startFn = iframe.startFn;
|
|
565
570
|
stopFn = iframe.stopFn;
|
|
@@ -568,7 +573,7 @@ ${mediaJS}
|
|
|
568
573
|
// Fall through to default (handles missing widgetCacheKey as unsupported)
|
|
569
574
|
|
|
570
575
|
case 'audio':
|
|
571
|
-
const audioSrc = `${window.location.origin}/
|
|
576
|
+
const audioSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
|
|
572
577
|
const audioId = `audio_${regionId}_${media.id}`;
|
|
573
578
|
const audioLoop = media.options.loop === '1';
|
|
574
579
|
const audioVolume = (parseInt(media.options.volume || '100') / 100).toFixed(2);
|
|
@@ -684,7 +689,7 @@ ${mediaJS}
|
|
|
684
689
|
break;
|
|
685
690
|
|
|
686
691
|
case 'pdf':
|
|
687
|
-
const pdfSrc = `${window.location.origin}/
|
|
692
|
+
const pdfSrc = `${window.location.origin}${PLAYER_API}/media/${media.options.uri}`;
|
|
688
693
|
const pdfContainerId = `pdf_${regionId}_${media.id}`;
|
|
689
694
|
const pdfDuration = duration; // Total duration for entire PDF
|
|
690
695
|
|
|
@@ -897,7 +902,7 @@ ${mediaJS}
|
|
|
897
902
|
// Widgets (clock, calendar, weather, etc.) - use cache URL pattern in /player/ scope for SW
|
|
898
903
|
// Keep widget iframes alive across duration cycles (arexibo behavior)
|
|
899
904
|
if (media.options.widgetCacheKey) {
|
|
900
|
-
const widgetUrl = `${window.location.origin}
|
|
905
|
+
const widgetUrl = `${window.location.origin}${media.options.widgetCacheKey}`;
|
|
901
906
|
const iframe = this._generateIframeWidgetJS(regionId, media.id, widgetUrl, transIn, transOut);
|
|
902
907
|
startFn = iframe.startFn;
|
|
903
908
|
stopFn = iframe.stopFn;
|
package/src/renderer-lite.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
import { createNanoEvents } from 'nanoevents';
|
|
43
|
-
import { createLogger, isDebug } from '@xiboplayer/utils';
|
|
43
|
+
import { createLogger, isDebug, PLAYER_API } from '@xiboplayer/utils';
|
|
44
44
|
import { LayoutPool } from './layout-pool.js';
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -642,13 +642,17 @@ export class RendererLite {
|
|
|
642
642
|
* @param {string} blobUrl - Blob URL to track
|
|
643
643
|
*/
|
|
644
644
|
trackBlobUrl(blobUrl) {
|
|
645
|
-
|
|
645
|
+
const layoutId = this.currentLayoutId || 0;
|
|
646
646
|
|
|
647
|
-
if (!
|
|
648
|
-
this.
|
|
647
|
+
if (!layoutId) {
|
|
648
|
+
this.log.warn('trackBlobUrl called without currentLayoutId, tracking under key 0');
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
-
this.layoutBlobUrls.
|
|
651
|
+
if (!this.layoutBlobUrls.has(layoutId)) {
|
|
652
|
+
this.layoutBlobUrls.set(layoutId, new Set());
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
this.layoutBlobUrls.get(layoutId).add(blobUrl);
|
|
652
656
|
}
|
|
653
657
|
|
|
654
658
|
/**
|
|
@@ -1369,15 +1373,16 @@ export class RendererLite {
|
|
|
1369
1373
|
return Promise.resolve();
|
|
1370
1374
|
}
|
|
1371
1375
|
return new Promise((resolve) => {
|
|
1372
|
-
const timer = setTimeout(() => {
|
|
1373
|
-
this.log.warn(`Image ready timeout for widget ${widget.id}`);
|
|
1374
|
-
resolve();
|
|
1375
|
-
}, READY_TIMEOUT);
|
|
1376
1376
|
const onLoad = () => {
|
|
1377
1377
|
imgEl.removeEventListener('load', onLoad);
|
|
1378
1378
|
clearTimeout(timer);
|
|
1379
1379
|
resolve();
|
|
1380
1380
|
};
|
|
1381
|
+
const timer = setTimeout(() => {
|
|
1382
|
+
imgEl.removeEventListener('load', onLoad);
|
|
1383
|
+
this.log.warn(`Image ready timeout for widget ${widget.id}`);
|
|
1384
|
+
resolve();
|
|
1385
|
+
}, READY_TIMEOUT);
|
|
1381
1386
|
imgEl.addEventListener('load', onLoad);
|
|
1382
1387
|
});
|
|
1383
1388
|
}
|
|
@@ -1516,10 +1521,10 @@ export class RendererLite {
|
|
|
1516
1521
|
this.options.getMediaUrl(mediaId).then(url => {
|
|
1517
1522
|
audio.src = url;
|
|
1518
1523
|
}).catch(() => {
|
|
1519
|
-
audio.src = `${window.location.origin}/
|
|
1524
|
+
audio.src = `${window.location.origin}${PLAYER_API}/media/${audioNode.uri}`;
|
|
1520
1525
|
});
|
|
1521
1526
|
} else if (!audioSrc) {
|
|
1522
|
-
audio.src = `${window.location.origin}/
|
|
1527
|
+
audio.src = `${window.location.origin}${PLAYER_API}/media/${audioNode.uri}`;
|
|
1523
1528
|
} else {
|
|
1524
1529
|
audio.src = audioSrc;
|
|
1525
1530
|
}
|
|
@@ -1592,9 +1597,31 @@ export class RendererLite {
|
|
|
1592
1597
|
videoEl.srcObject = null;
|
|
1593
1598
|
}
|
|
1594
1599
|
|
|
1600
|
+
// Destroy HLS.js instance to free worker + buffers
|
|
1601
|
+
if (videoEl?._hlsInstance) {
|
|
1602
|
+
videoEl._hlsInstance.destroy();
|
|
1603
|
+
videoEl._hlsInstance = null;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Remove event listeners to prevent accumulation across widget cycles
|
|
1607
|
+
if (videoEl?._eventCleanup) {
|
|
1608
|
+
for (const [event, handler] of videoEl._eventCleanup) {
|
|
1609
|
+
videoEl.removeEventListener(event, handler);
|
|
1610
|
+
}
|
|
1611
|
+
videoEl._eventCleanup = null;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1595
1614
|
const audioEl = widgetElement.querySelector('audio');
|
|
1596
1615
|
if (audioEl && widget.options.loop !== '1') audioEl.pause();
|
|
1597
1616
|
|
|
1617
|
+
// Remove audio event listeners
|
|
1618
|
+
if (audioEl?._eventCleanup) {
|
|
1619
|
+
for (const [event, handler] of audioEl._eventCleanup) {
|
|
1620
|
+
audioEl.removeEventListener(event, handler);
|
|
1621
|
+
}
|
|
1622
|
+
audioEl._eventCleanup = null;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1598
1625
|
// Stop audio overlays attached to this widget
|
|
1599
1626
|
this._stopAudioOverlays(widget.id);
|
|
1600
1627
|
|
|
@@ -1866,7 +1893,7 @@ export class RendererLite {
|
|
|
1866
1893
|
if (!imageSrc && this.options.getMediaUrl) {
|
|
1867
1894
|
imageSrc = await this.options.getMediaUrl(fileId);
|
|
1868
1895
|
} else if (!imageSrc) {
|
|
1869
|
-
imageSrc = `${window.location.origin}/
|
|
1896
|
+
imageSrc = `${window.location.origin}${PLAYER_API}/media/${widget.options.uri}`;
|
|
1870
1897
|
}
|
|
1871
1898
|
|
|
1872
1899
|
img.src = imageSrc;
|
|
@@ -1892,28 +1919,26 @@ export class RendererLite {
|
|
|
1892
1919
|
video.controls = false; // Hidden by default — toggle with V key in PWA
|
|
1893
1920
|
video.playsInline = true; // Prevent fullscreen on mobile
|
|
1894
1921
|
|
|
1922
|
+
// Get media URL from cache (already pre-fetched!) or fetch on-demand
|
|
1923
|
+
const fileId = parseInt(widget.fileId || widget.id);
|
|
1924
|
+
|
|
1895
1925
|
// Handle video end - pause on last frame instead of showing black
|
|
1896
1926
|
// Widget cycling will restart the video via updateMediaElement()
|
|
1897
|
-
|
|
1927
|
+
const onEnded = () => {
|
|
1898
1928
|
if (widget.options.loop === '1') {
|
|
1899
|
-
// For looping videos: seek back to start but stay paused on first frame
|
|
1900
|
-
// This avoids black frames - shows first frame until widget cycles
|
|
1901
1929
|
video.currentTime = 0;
|
|
1902
1930
|
this.log.info(`Video ${fileId} ended - reset to start, waiting for widget cycle to replay`);
|
|
1903
1931
|
} else {
|
|
1904
|
-
// For non-looping videos: stay paused on last frame
|
|
1905
1932
|
this.log.info(`Video ${fileId} ended - paused on last frame`);
|
|
1906
1933
|
}
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
// Get media URL from cache (already pre-fetched!) or fetch on-demand
|
|
1910
|
-
const fileId = parseInt(widget.fileId || widget.id);
|
|
1934
|
+
};
|
|
1935
|
+
video.addEventListener('ended', onEnded);
|
|
1911
1936
|
let videoSrc = this.mediaUrlCache.get(fileId);
|
|
1912
1937
|
|
|
1913
1938
|
if (!videoSrc && this.options.getMediaUrl) {
|
|
1914
1939
|
videoSrc = await this.options.getMediaUrl(fileId);
|
|
1915
1940
|
} else if (!videoSrc) {
|
|
1916
|
-
videoSrc = `${window.location.origin}/
|
|
1941
|
+
videoSrc = `${window.location.origin}${PLAYER_API}/media/${fileId}`;
|
|
1917
1942
|
}
|
|
1918
1943
|
|
|
1919
1944
|
// HLS/DASH streaming support
|
|
@@ -1958,49 +1983,50 @@ export class RendererLite {
|
|
|
1958
1983
|
// loadedmetadata fires (e.g. video was preloaded for next layout), we must
|
|
1959
1984
|
// NOT update the current layout's duration with a different layout's video.
|
|
1960
1985
|
const createdForLayoutId = this.currentLayoutId;
|
|
1961
|
-
|
|
1986
|
+
const onLoadedMetadata = () => {
|
|
1962
1987
|
const videoDuration = Math.floor(video.duration);
|
|
1963
1988
|
this.log.info(`Video ${fileId} duration detected: ${videoDuration}s`);
|
|
1964
1989
|
|
|
1965
|
-
// Always update widget duration — it's the widget's own data, safe
|
|
1966
|
-
// even if this video was preloaded for a different layout.
|
|
1967
1990
|
if (widget.duration === 0 || widget.useDuration === 0) {
|
|
1968
1991
|
widget.duration = videoDuration;
|
|
1969
1992
|
this.log.info(`Updated widget ${widget.id} duration to ${videoDuration}s (useDuration=0)`);
|
|
1970
1993
|
|
|
1971
|
-
// Only recalculate current layout's timer if this video belongs to it.
|
|
1972
|
-
// Preloaded layouts will pick up the corrected widget.duration when
|
|
1973
|
-
// they start playing (via updateLayoutDuration() in swapToPreloadedLayout).
|
|
1974
1994
|
if (this.currentLayoutId === createdForLayoutId) {
|
|
1975
1995
|
this.updateLayoutDuration();
|
|
1976
1996
|
} else {
|
|
1977
1997
|
this.log.info(`Video ${fileId} duration set but layout timer not updated (preloaded for layout ${createdForLayoutId}, current is ${this.currentLayoutId})`);
|
|
1978
1998
|
}
|
|
1979
1999
|
}
|
|
1980
|
-
}
|
|
2000
|
+
};
|
|
2001
|
+
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
1981
2002
|
|
|
1982
|
-
|
|
1983
|
-
video.addEventListener('loadeddata', () => {
|
|
2003
|
+
const onLoadedData = () => {
|
|
1984
2004
|
this.log.info('Video loaded and ready:', fileId);
|
|
1985
|
-
}
|
|
2005
|
+
};
|
|
2006
|
+
video.addEventListener('loadeddata', onLoadedData);
|
|
1986
2007
|
|
|
1987
|
-
|
|
1988
|
-
video.addEventListener('error', (e) => {
|
|
2008
|
+
const onError = () => {
|
|
1989
2009
|
const error = video.error;
|
|
1990
2010
|
const errorCode = error?.code;
|
|
1991
2011
|
const errorMessage = error?.message || 'Unknown error';
|
|
2012
|
+
this.log.warn(`Video error: ${fileId}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
|
|
2013
|
+
this.emit('videoError', { fileId, errorCode, errorMessage, currentTime: video.currentTime });
|
|
2014
|
+
};
|
|
2015
|
+
video.addEventListener('error', onError);
|
|
1992
2016
|
|
|
1993
|
-
|
|
1994
|
-
// These are often transient codec warnings that don't prevent playback
|
|
1995
|
-
this.log.warn(`Video error (non-fatal, logged only): ${fileId}, code: ${errorCode}, time: ${video.currentTime.toFixed(1)}s, message: ${errorMessage}`);
|
|
1996
|
-
|
|
1997
|
-
// Do NOT emit error events - video errors are logged but not surfaced to UI
|
|
1998
|
-
// Video will either recover (transient decode error) or fail completely (handled elsewhere)
|
|
1999
|
-
});
|
|
2000
|
-
|
|
2001
|
-
video.addEventListener('playing', () => {
|
|
2017
|
+
const onPlaying = () => {
|
|
2002
2018
|
this.log.info('Video playing:', fileId);
|
|
2003
|
-
}
|
|
2019
|
+
};
|
|
2020
|
+
video.addEventListener('playing', onPlaying);
|
|
2021
|
+
|
|
2022
|
+
// Store listener references for cleanup in _hideWidget()
|
|
2023
|
+
video._eventCleanup = [
|
|
2024
|
+
['ended', onEnded],
|
|
2025
|
+
['loadedmetadata', onLoadedMetadata],
|
|
2026
|
+
['loadeddata', onLoadedData],
|
|
2027
|
+
['error', onError],
|
|
2028
|
+
['playing', onPlaying],
|
|
2029
|
+
];
|
|
2004
2030
|
|
|
2005
2031
|
this.log.info('Video element created:', fileId, video.src);
|
|
2006
2032
|
|
|
@@ -2094,24 +2120,25 @@ export class RendererLite {
|
|
|
2094
2120
|
if (!audioSrc && this.options.getMediaUrl) {
|
|
2095
2121
|
audioSrc = await this.options.getMediaUrl(fileId);
|
|
2096
2122
|
} else if (!audioSrc) {
|
|
2097
|
-
audioSrc = `${window.location.origin}/
|
|
2123
|
+
audioSrc = `${window.location.origin}${PLAYER_API}/media/${fileId}`;
|
|
2098
2124
|
}
|
|
2099
2125
|
|
|
2100
2126
|
audio.src = audioSrc;
|
|
2101
2127
|
|
|
2102
2128
|
// Handle audio end - similar to video ended handling
|
|
2103
|
-
|
|
2129
|
+
const onAudioEnded = () => {
|
|
2104
2130
|
if (widget.options.loop === '1') {
|
|
2105
2131
|
audio.currentTime = 0;
|
|
2106
2132
|
this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
|
|
2107
2133
|
} else {
|
|
2108
2134
|
this.log.info(`Audio ${fileId} ended - playback complete`);
|
|
2109
2135
|
}
|
|
2110
|
-
}
|
|
2136
|
+
};
|
|
2137
|
+
audio.addEventListener('ended', onAudioEnded);
|
|
2111
2138
|
|
|
2112
2139
|
// Detect audio duration for dynamic layout timing (when useDuration=0)
|
|
2113
2140
|
const audioCreatedForLayoutId = this.currentLayoutId;
|
|
2114
|
-
|
|
2141
|
+
const onAudioLoadedMetadata = () => {
|
|
2115
2142
|
const audioDuration = Math.floor(audio.duration);
|
|
2116
2143
|
this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
|
|
2117
2144
|
|
|
@@ -2125,13 +2152,22 @@ export class RendererLite {
|
|
|
2125
2152
|
this.log.info(`Audio ${fileId} duration set but layout timer not updated (preloaded for layout ${audioCreatedForLayoutId}, current is ${this.currentLayoutId})`);
|
|
2126
2153
|
}
|
|
2127
2154
|
}
|
|
2128
|
-
}
|
|
2155
|
+
};
|
|
2156
|
+
audio.addEventListener('loadedmetadata', onAudioLoadedMetadata);
|
|
2129
2157
|
|
|
2130
2158
|
// Handle audio errors
|
|
2131
|
-
|
|
2159
|
+
const onAudioError = () => {
|
|
2132
2160
|
const error = audio.error;
|
|
2133
2161
|
this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
|
|
2134
|
-
}
|
|
2162
|
+
};
|
|
2163
|
+
audio.addEventListener('error', onAudioError);
|
|
2164
|
+
|
|
2165
|
+
// Store listener references for cleanup in _hideWidget()
|
|
2166
|
+
audio._eventCleanup = [
|
|
2167
|
+
['ended', onAudioEnded],
|
|
2168
|
+
['loadedmetadata', onAudioLoadedMetadata],
|
|
2169
|
+
['error', onAudioError],
|
|
2170
|
+
];
|
|
2135
2171
|
|
|
2136
2172
|
// Visual feedback
|
|
2137
2173
|
const icon = document.createElement('div');
|
|
@@ -2223,7 +2259,14 @@ export class RendererLite {
|
|
|
2223
2259
|
}
|
|
2224
2260
|
|
|
2225
2261
|
/**
|
|
2226
|
-
* Render PDF widget
|
|
2262
|
+
* Render PDF widget — single reusable canvas, page-by-page cycling.
|
|
2263
|
+
*
|
|
2264
|
+
* Memory strategy:
|
|
2265
|
+
* - One canvas is created and reused for all pages (no DOM churn)
|
|
2266
|
+
* - Each page is rendered sequentially (avoids concurrent render errors)
|
|
2267
|
+
* - page.cleanup() releases PDF.js internal page buffers after each render
|
|
2268
|
+
* - pdf.destroy() releases the entire document on widget teardown
|
|
2269
|
+
* - Active renderTask is cancelled on cleanup to prevent stale renders
|
|
2227
2270
|
*/
|
|
2228
2271
|
async renderPdf(widget, region) {
|
|
2229
2272
|
const container = document.createElement('div');
|
|
@@ -2257,7 +2300,7 @@ export class RendererLite {
|
|
|
2257
2300
|
if (!pdfUrl && this.options.getMediaUrl) {
|
|
2258
2301
|
pdfUrl = await this.options.getMediaUrl(fileId);
|
|
2259
2302
|
} else if (!pdfUrl) {
|
|
2260
|
-
pdfUrl = `${window.location.origin}/
|
|
2303
|
+
pdfUrl = `${window.location.origin}${PLAYER_API}/media/${widget.options.uri}`;
|
|
2261
2304
|
}
|
|
2262
2305
|
|
|
2263
2306
|
// Render PDF with multi-page cycling
|
|
@@ -2269,68 +2312,77 @@ export class RendererLite {
|
|
|
2269
2312
|
const timePerPage = (duration * 1000) / totalPages;
|
|
2270
2313
|
this.log.info(`[pdf] PDF loaded: ${totalPages} pages, ${duration}s duration, ${(timePerPage / 1000).toFixed(1)}s/page`);
|
|
2271
2314
|
|
|
2272
|
-
//
|
|
2273
|
-
const
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
return canvas;
|
|
2288
|
-
};
|
|
2289
|
-
|
|
2290
|
-
// Page indicator (bottom-right)
|
|
2315
|
+
// Measure page size from first page to set up the single reusable canvas
|
|
2316
|
+
const page1 = await pdf.getPage(1);
|
|
2317
|
+
const viewport0 = page1.getViewport({ scale: 1 });
|
|
2318
|
+
const scale = Math.min(region.width / viewport0.width, region.height / viewport0.height);
|
|
2319
|
+
page1.cleanup();
|
|
2320
|
+
|
|
2321
|
+
const canvas = document.createElement('canvas');
|
|
2322
|
+
canvas.className = 'pdf-page';
|
|
2323
|
+
canvas.width = Math.floor(viewport0.width * scale);
|
|
2324
|
+
canvas.height = Math.floor(viewport0.height * scale);
|
|
2325
|
+
canvas.style.cssText = 'display:block;margin:auto;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);';
|
|
2326
|
+
const ctx = canvas.getContext('2d');
|
|
2327
|
+
container.appendChild(canvas);
|
|
2328
|
+
|
|
2329
|
+
// Page indicator (bottom-right, v1-style pill)
|
|
2291
2330
|
const indicator = document.createElement('div');
|
|
2292
|
-
indicator.style.cssText = 'position:absolute;bottom:
|
|
2331
|
+
indicator.style.cssText = 'position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,0.7);color:white;padding:8px 12px;border-radius:4px;font:14px system-ui;z-index:1;';
|
|
2293
2332
|
container.appendChild(indicator);
|
|
2294
2333
|
|
|
2295
2334
|
let currentPage = 1;
|
|
2296
|
-
|
|
2335
|
+
let cycleTimer = null;
|
|
2336
|
+
let activeRenderTask = null;
|
|
2337
|
+
let stopped = false;
|
|
2297
2338
|
|
|
2339
|
+
// Render one page at a time on the single canvas. Sequential scheduling
|
|
2340
|
+
// (setTimeout after render completes) avoids the "Cannot use the same
|
|
2341
|
+
// canvas during multiple render() operations" error from PDF.js.
|
|
2298
2342
|
const cyclePage = async () => {
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2343
|
+
if (stopped) return;
|
|
2344
|
+
indicator.textContent = `Page ${currentPage} / ${totalPages}`;
|
|
2345
|
+
|
|
2346
|
+
const page = await pdf.getPage(currentPage);
|
|
2347
|
+
const scaledViewport = page.getViewport({ scale });
|
|
2348
|
+
|
|
2349
|
+
// Clear and render on the reusable canvas
|
|
2350
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
2351
|
+
activeRenderTask = page.render({ canvasContext: ctx, viewport: scaledViewport });
|
|
2352
|
+
try {
|
|
2353
|
+
await activeRenderTask.promise;
|
|
2354
|
+
} catch (e) {
|
|
2355
|
+
// RenderingCancelledException is expected when stopped during render
|
|
2356
|
+
if (stopped) return;
|
|
2357
|
+
throw e;
|
|
2307
2358
|
}
|
|
2359
|
+
activeRenderTask = null;
|
|
2360
|
+
page.cleanup(); // Release PDF.js internal page buffers
|
|
2308
2361
|
|
|
2309
|
-
//
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
container.appendChild(canvas);
|
|
2313
|
-
// Trigger reflow then fade in
|
|
2314
|
-
canvas.offsetHeight; // eslint-disable-line no-unused-expressions
|
|
2315
|
-
canvas.style.transition = 'opacity 0.3s';
|
|
2316
|
-
canvas.style.opacity = '1';
|
|
2317
|
-
|
|
2318
|
-
// Schedule next page
|
|
2319
|
-
if (totalPages > 1) {
|
|
2320
|
-
const timer = setTimeout(() => {
|
|
2362
|
+
// Schedule next page (only after current render completes)
|
|
2363
|
+
if (totalPages > 1 && !stopped) {
|
|
2364
|
+
cycleTimer = setTimeout(() => {
|
|
2321
2365
|
currentPage = currentPage >= totalPages ? 1 : currentPage + 1;
|
|
2322
2366
|
cyclePage();
|
|
2323
2367
|
}, timePerPage);
|
|
2324
|
-
pageTimers.push(timer);
|
|
2325
2368
|
}
|
|
2326
2369
|
};
|
|
2327
2370
|
|
|
2328
2371
|
await cyclePage();
|
|
2329
2372
|
|
|
2330
|
-
//
|
|
2373
|
+
// Cleanup: cancel active render, clear timer, release PDF document
|
|
2331
2374
|
container._pdfCleanup = () => {
|
|
2332
|
-
|
|
2333
|
-
|
|
2375
|
+
stopped = true;
|
|
2376
|
+
if (cycleTimer) clearTimeout(cycleTimer);
|
|
2377
|
+
cycleTimer = null;
|
|
2378
|
+
if (activeRenderTask) {
|
|
2379
|
+
activeRenderTask.cancel();
|
|
2380
|
+
activeRenderTask = null;
|
|
2381
|
+
}
|
|
2382
|
+
// Zero canvas dimensions to release GPU backing store
|
|
2383
|
+
canvas.width = 0;
|
|
2384
|
+
canvas.height = 0;
|
|
2385
|
+
pdf.destroy();
|
|
2334
2386
|
};
|
|
2335
2387
|
|
|
2336
2388
|
} catch (error) {
|
|
@@ -486,8 +486,9 @@ describe('RendererLite - Overlay Rendering', () => {
|
|
|
486
486
|
it('should track blob URLs per overlay layout', async () => {
|
|
487
487
|
await renderer.renderOverlay(overlayXLF, 200, 10);
|
|
488
488
|
|
|
489
|
-
// Blob URLs should be tracked
|
|
490
|
-
|
|
489
|
+
// Blob URLs should be tracked (under overlay ID 200, or fallback key 0 if
|
|
490
|
+
// currentLayoutId wasn't set, or empty if no blob URLs were created)
|
|
491
|
+
expect(renderer.layoutBlobUrls.has(200) || renderer.layoutBlobUrls.has(0) || renderer.layoutBlobUrls.size === 0).toBe(true);
|
|
491
492
|
});
|
|
492
493
|
});
|
|
493
494
|
});
|