@xiboplayer/renderer 0.3.7 → 0.4.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 +12 -9
- package/src/renderer-lite.js +594 -34
- package/src/renderer-lite.test.js +1269 -0
package/src/renderer-lite.js
CHANGED
|
@@ -160,10 +160,12 @@ const Transitions = {
|
|
|
160
160
|
const direction = transitionConfig.direction || 'N';
|
|
161
161
|
|
|
162
162
|
switch (type) {
|
|
163
|
+
case 'fade':
|
|
163
164
|
case 'fadein':
|
|
164
165
|
return isIn ? this.fadeIn(element, duration) : null;
|
|
165
166
|
case 'fadeout':
|
|
166
167
|
return isIn ? null : this.fadeOut(element, duration);
|
|
168
|
+
case 'fly':
|
|
167
169
|
case 'flyin':
|
|
168
170
|
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
169
171
|
case 'flyout':
|
|
@@ -211,6 +213,7 @@ export class RendererLite {
|
|
|
211
213
|
this.widgetTimers = new Map(); // widgetId => timer
|
|
212
214
|
this.mediaUrlCache = new Map(); // fileId => blob URL (for parallel pre-fetching)
|
|
213
215
|
this.layoutBlobUrls = new Map(); // layoutId => Set<blobUrl> (for lifecycle tracking)
|
|
216
|
+
this.audioOverlays = new Map(); // widgetId => [HTMLAudioElement] (audio overlays for widgets)
|
|
214
217
|
|
|
215
218
|
// Scale state (for fitting layout to screen)
|
|
216
219
|
this.scaleFactor = 1;
|
|
@@ -225,6 +228,9 @@ export class RendererLite {
|
|
|
225
228
|
this._keydownHandler = null; // Document keydown listener (single, shared)
|
|
226
229
|
this._keyboardActions = []; // Active keyboard actions for current layout
|
|
227
230
|
|
|
231
|
+
// Sub-playlist cycle state (round-robin per parentWidgetId group)
|
|
232
|
+
this._subPlaylistCycleIndex = new Map();
|
|
233
|
+
|
|
228
234
|
// Layout preload pool (2-layout pool for instant transitions)
|
|
229
235
|
this.layoutPool = new LayoutPool(2);
|
|
230
236
|
this.preloadTimer = null;
|
|
@@ -346,11 +352,16 @@ export class RendererLite {
|
|
|
346
352
|
for (const actionEl of parentEl.children) {
|
|
347
353
|
if (actionEl.tagName !== 'action') continue;
|
|
348
354
|
actions.push({
|
|
355
|
+
id: actionEl.getAttribute('id') || '',
|
|
349
356
|
actionType: actionEl.getAttribute('actionType') || '',
|
|
350
357
|
triggerType: actionEl.getAttribute('triggerType') || '',
|
|
351
358
|
triggerCode: actionEl.getAttribute('triggerCode') || '',
|
|
352
|
-
|
|
359
|
+
source: actionEl.getAttribute('source') || '',
|
|
360
|
+
sourceId: actionEl.getAttribute('sourceId') || '',
|
|
361
|
+
target: actionEl.getAttribute('target') || '',
|
|
353
362
|
targetId: actionEl.getAttribute('targetId') || '',
|
|
363
|
+
widgetId: actionEl.getAttribute('widgetId') || '',
|
|
364
|
+
layoutCode: actionEl.getAttribute('layoutCode') || '',
|
|
354
365
|
commandCode: actionEl.getAttribute('commandCode') || ''
|
|
355
366
|
});
|
|
356
367
|
}
|
|
@@ -373,48 +384,102 @@ export class RendererLite {
|
|
|
373
384
|
|
|
374
385
|
const layoutDurationAttr = layoutEl.getAttribute('duration');
|
|
375
386
|
const layout = {
|
|
387
|
+
schemaVersion: parseInt(layoutEl.getAttribute('schemaVersion') || '1'),
|
|
376
388
|
width: parseInt(layoutEl.getAttribute('width') || '1920'),
|
|
377
389
|
height: parseInt(layoutEl.getAttribute('height') || '1080'),
|
|
378
390
|
duration: layoutDurationAttr ? parseInt(layoutDurationAttr) : 0, // 0 = calculate from widgets
|
|
379
|
-
bgcolor: layoutEl.getAttribute('bgcolor') || '#000000',
|
|
391
|
+
bgcolor: layoutEl.getAttribute('backgroundColor') || layoutEl.getAttribute('bgcolor') || '#000000',
|
|
380
392
|
background: layoutEl.getAttribute('background') || null, // Background image fileId
|
|
381
393
|
enableStat: layoutEl.getAttribute('enableStat') !== '0', // absent or "1" = enabled
|
|
394
|
+
actions: this.parseActions(layoutEl),
|
|
382
395
|
regions: []
|
|
383
396
|
};
|
|
384
397
|
|
|
398
|
+
if (layout.schemaVersion > 1) {
|
|
399
|
+
this.log.debug(`XLF schema version: ${layout.schemaVersion}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
385
402
|
if (layoutDurationAttr) {
|
|
386
403
|
this.log.info(`Layout duration from XLF: ${layout.duration}s`);
|
|
387
404
|
} else {
|
|
388
405
|
this.log.info(`Layout duration NOT in XLF, will calculate from widgets`);
|
|
389
406
|
}
|
|
390
407
|
|
|
391
|
-
// Parse regions
|
|
392
|
-
|
|
408
|
+
// Parse regions and drawers (drawers are invisible regions for interactive actions)
|
|
409
|
+
const regionAndDrawerEls = layoutEl.querySelectorAll(':scope > region, :scope > drawer');
|
|
410
|
+
for (const regionEl of regionAndDrawerEls) {
|
|
411
|
+
const isDrawer = regionEl.tagName === 'drawer';
|
|
393
412
|
const region = {
|
|
394
413
|
id: regionEl.getAttribute('id'),
|
|
395
|
-
width: parseInt(regionEl.getAttribute('width')),
|
|
396
|
-
height: parseInt(regionEl.getAttribute('height')),
|
|
397
|
-
top: parseInt(regionEl.getAttribute('top')),
|
|
398
|
-
left: parseInt(regionEl.getAttribute('left')),
|
|
399
|
-
zindex: parseInt(regionEl.getAttribute('zindex') || '0'),
|
|
414
|
+
width: parseInt(regionEl.getAttribute('width') || '0'),
|
|
415
|
+
height: parseInt(regionEl.getAttribute('height') || '0'),
|
|
416
|
+
top: parseInt(regionEl.getAttribute('top') || '0'),
|
|
417
|
+
left: parseInt(regionEl.getAttribute('left') || '0'),
|
|
418
|
+
zindex: parseInt(regionEl.getAttribute('zindex') || (isDrawer ? '2000' : '0')),
|
|
419
|
+
enableStat: regionEl.getAttribute('enableStat') !== '0',
|
|
400
420
|
actions: this.parseActions(regionEl),
|
|
421
|
+
exitTransition: null,
|
|
422
|
+
transitionType: null, // Region-level default widget transition type
|
|
423
|
+
transitionDuration: null,
|
|
424
|
+
transitionDirection: null,
|
|
425
|
+
loop: true, // Default: cycle widgets. Spec: loop=0 means single media stays visible
|
|
426
|
+
isDrawer,
|
|
401
427
|
widgets: []
|
|
402
428
|
};
|
|
403
429
|
|
|
404
|
-
// Parse
|
|
405
|
-
|
|
406
|
-
|
|
430
|
+
// Parse region-level options (exit transitions, loop)
|
|
431
|
+
// Use direct children only to avoid matching <options> inside <media>
|
|
432
|
+
const regionOptionsEl = Array.from(regionEl.children).find(el => el.tagName === 'options');
|
|
433
|
+
if (regionOptionsEl) {
|
|
434
|
+
const exitTransType = regionOptionsEl.querySelector('exitTransType');
|
|
435
|
+
if (exitTransType && exitTransType.textContent) {
|
|
436
|
+
const exitTransDuration = regionOptionsEl.querySelector('exitTransDuration');
|
|
437
|
+
const exitTransDirection = regionOptionsEl.querySelector('exitTransDirection');
|
|
438
|
+
region.exitTransition = {
|
|
439
|
+
type: exitTransType.textContent,
|
|
440
|
+
duration: parseInt((exitTransDuration && exitTransDuration.textContent) || '1000'),
|
|
441
|
+
direction: (exitTransDirection && exitTransDirection.textContent) || 'N'
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Region loop option: 0 = single media stays on screen, 1 = cycles (default)
|
|
446
|
+
const loopEl = regionOptionsEl.querySelector('loop');
|
|
447
|
+
if (loopEl) {
|
|
448
|
+
region.loop = loopEl.textContent !== '0';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Region-level default transition for widgets (applied if widget has no own transition)
|
|
452
|
+
const transType = regionOptionsEl.querySelector('transitionType');
|
|
453
|
+
if (transType && transType.textContent) {
|
|
454
|
+
region.transitionType = transType.textContent;
|
|
455
|
+
const transDuration = regionOptionsEl.querySelector('transitionDuration');
|
|
456
|
+
const transDirection = regionOptionsEl.querySelector('transitionDirection');
|
|
457
|
+
region.transitionDuration = parseInt((transDuration && transDuration.textContent) || '1000');
|
|
458
|
+
region.transitionDirection = (transDirection && transDirection.textContent) || 'N';
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Parse media/widgets (use direct children to avoid nested matches)
|
|
463
|
+
for (const child of regionEl.children) {
|
|
464
|
+
if (child.tagName !== 'media') continue;
|
|
465
|
+
const widget = this.parseWidget(child);
|
|
407
466
|
region.widgets.push(widget);
|
|
408
467
|
}
|
|
409
468
|
|
|
410
469
|
layout.regions.push(region);
|
|
470
|
+
|
|
471
|
+
if (isDrawer) {
|
|
472
|
+
this.log.info(`Parsed drawer: id=${region.id} with ${region.widgets.length} widgets`);
|
|
473
|
+
}
|
|
411
474
|
}
|
|
412
475
|
|
|
413
476
|
// Calculate layout duration if not specified (duration=0)
|
|
477
|
+
// Drawers don't contribute to layout duration (they're action-triggered)
|
|
414
478
|
if (layout.duration === 0) {
|
|
415
479
|
let maxDuration = 0;
|
|
416
480
|
|
|
417
481
|
for (const region of layout.regions) {
|
|
482
|
+
if (region.isDrawer) continue;
|
|
418
483
|
let regionDuration = 0;
|
|
419
484
|
|
|
420
485
|
// Calculate region duration based on widgets
|
|
@@ -490,17 +555,84 @@ export class RendererLite {
|
|
|
490
555
|
// Parse widget-level actions
|
|
491
556
|
const actions = this.parseActions(mediaEl);
|
|
492
557
|
|
|
558
|
+
// Parse audio overlay nodes (<audio> child elements on the widget)
|
|
559
|
+
// Spec format: <audio><uri volume="" loop="" mediaId="">filename.mp3</uri></audio>
|
|
560
|
+
// Also supports flat format: <audio mediaId="" uri="" volume="" loop="">
|
|
561
|
+
const audioNodes = [];
|
|
562
|
+
for (const child of mediaEl.children) {
|
|
563
|
+
if (child.tagName.toLowerCase() === 'audio') {
|
|
564
|
+
const uriEl = child.querySelector('uri');
|
|
565
|
+
if (uriEl) {
|
|
566
|
+
// Spec format: attributes on <uri>, filename as text content
|
|
567
|
+
audioNodes.push({
|
|
568
|
+
mediaId: uriEl.getAttribute('mediaId') || null,
|
|
569
|
+
uri: uriEl.textContent || '',
|
|
570
|
+
volume: parseInt(uriEl.getAttribute('volume') || '100'),
|
|
571
|
+
loop: uriEl.getAttribute('loop') === '1'
|
|
572
|
+
});
|
|
573
|
+
} else {
|
|
574
|
+
// Flat format fallback: attributes directly on <audio>
|
|
575
|
+
audioNodes.push({
|
|
576
|
+
mediaId: child.getAttribute('mediaId') || null,
|
|
577
|
+
uri: child.getAttribute('uri') || '',
|
|
578
|
+
volume: parseInt(child.getAttribute('volume') || '100'),
|
|
579
|
+
loop: child.getAttribute('loop') === '1'
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Parse commands on media (shell/native commands triggered on widget start)
|
|
586
|
+
// Spec: <commands><command commandCode="code" commandString="args"/></commands>
|
|
587
|
+
const commands = [];
|
|
588
|
+
const commandsEl = Array.from(mediaEl.children).find(el => el.tagName === 'commands');
|
|
589
|
+
if (commandsEl) {
|
|
590
|
+
for (const cmdEl of commandsEl.children) {
|
|
591
|
+
if (cmdEl.tagName === 'command') {
|
|
592
|
+
commands.push({
|
|
593
|
+
commandCode: cmdEl.getAttribute('commandCode') || '',
|
|
594
|
+
commandString: cmdEl.getAttribute('commandString') || ''
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Sub-playlist attributes (widgets grouped by parentWidgetId)
|
|
601
|
+
const parentWidgetId = mediaEl.getAttribute('parentWidgetId') || null;
|
|
602
|
+
const displayOrder = parseInt(mediaEl.getAttribute('displayOrder') || '0');
|
|
603
|
+
const cyclePlayback = mediaEl.getAttribute('cyclePlayback') === '1';
|
|
604
|
+
const playCount = parseInt(mediaEl.getAttribute('playCount') || '0');
|
|
605
|
+
const isRandom = mediaEl.getAttribute('isRandom') === '1';
|
|
606
|
+
|
|
607
|
+
// Media expiry dates (per-widget time-gating within a layout)
|
|
608
|
+
const fromDt = mediaEl.getAttribute('fromDt') || mediaEl.getAttribute('fromdt') || null;
|
|
609
|
+
const toDt = mediaEl.getAttribute('toDt') || mediaEl.getAttribute('todt') || null;
|
|
610
|
+
|
|
611
|
+
// Render mode: 'native' (player renders directly) or 'html' (use GetResource)
|
|
612
|
+
const render = mediaEl.getAttribute('render') || null;
|
|
613
|
+
|
|
493
614
|
return {
|
|
494
615
|
type,
|
|
495
616
|
duration,
|
|
496
617
|
useDuration, // Whether to use specified duration (1) or media length (0)
|
|
497
618
|
id,
|
|
498
619
|
fileId, // Media library file ID for cache lookup
|
|
620
|
+
render, // 'native' or 'html' — null means use type-based dispatch
|
|
621
|
+
fromDt, // Widget valid-from date (Y-m-d H:i:s)
|
|
622
|
+
toDt, // Widget valid-to date (Y-m-d H:i:s)
|
|
499
623
|
enableStat: mediaEl.getAttribute('enableStat') !== '0', // absent or "1" = enabled
|
|
624
|
+
webhookUrl: options.webhookUrl || null,
|
|
500
625
|
options,
|
|
501
626
|
raw,
|
|
502
627
|
transitions,
|
|
503
|
-
actions
|
|
628
|
+
actions,
|
|
629
|
+
audioNodes, // Audio overlays attached to this widget
|
|
630
|
+
commands, // Shell commands triggered on widget start
|
|
631
|
+
parentWidgetId,
|
|
632
|
+
displayOrder,
|
|
633
|
+
cyclePlayback,
|
|
634
|
+
playCount,
|
|
635
|
+
isRandom
|
|
504
636
|
};
|
|
505
637
|
}
|
|
506
638
|
|
|
@@ -544,6 +676,7 @@ export class RendererLite {
|
|
|
544
676
|
let maxRegionDuration = 0;
|
|
545
677
|
|
|
546
678
|
for (const region of this.currentLayout.regions) {
|
|
679
|
+
if (region.isDrawer) continue;
|
|
547
680
|
let regionDuration = 0;
|
|
548
681
|
|
|
549
682
|
for (const widget of region.widgets) {
|
|
@@ -601,6 +734,16 @@ export class RendererLite {
|
|
|
601
734
|
const allKeyboardActions = [];
|
|
602
735
|
let touchActionCount = 0;
|
|
603
736
|
|
|
737
|
+
// Layout-level actions (attached to the main container)
|
|
738
|
+
for (const action of (layout.actions || [])) {
|
|
739
|
+
if (action.triggerType === 'touch') {
|
|
740
|
+
this.attachTouchAction(this.container, action, null, null);
|
|
741
|
+
touchActionCount++;
|
|
742
|
+
} else if (action.triggerType?.startsWith('keyboard:')) {
|
|
743
|
+
allKeyboardActions.push(action);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
604
747
|
for (const regionConfig of layout.regions) {
|
|
605
748
|
const region = this.regions.get(regionConfig.id);
|
|
606
749
|
if (!region) continue;
|
|
@@ -737,6 +880,12 @@ export class RendererLite {
|
|
|
737
880
|
|
|
738
881
|
this.log.info(`Navigating to widget ${targetWidgetId} in region ${regionId} (index ${widgetIndex})`);
|
|
739
882
|
|
|
883
|
+
// Show drawer region if hidden (drawers start display:none)
|
|
884
|
+
if (region.isDrawer && region.element.style.display === 'none') {
|
|
885
|
+
region.element.style.display = '';
|
|
886
|
+
this.log.info(`Drawer region ${regionId} revealed`);
|
|
887
|
+
}
|
|
888
|
+
|
|
740
889
|
if (region.timer) {
|
|
741
890
|
clearTimeout(region.timer);
|
|
742
891
|
region.timer = null;
|
|
@@ -753,7 +902,22 @@ export class RendererLite {
|
|
|
753
902
|
this.stopWidget(regionId, widgetIndex);
|
|
754
903
|
const nextIndex = (widgetIndex + 1) % region.widgets.length;
|
|
755
904
|
region.currentIndex = nextIndex;
|
|
756
|
-
|
|
905
|
+
// For drawers, hide again after last widget; for normal regions, continue cycling
|
|
906
|
+
if (region.isDrawer && nextIndex === 0) {
|
|
907
|
+
region.element.style.display = 'none';
|
|
908
|
+
this.log.info(`Drawer region ${regionId} hidden (cycle complete)`);
|
|
909
|
+
} else {
|
|
910
|
+
this.startRegion(regionId);
|
|
911
|
+
}
|
|
912
|
+
}, duration);
|
|
913
|
+
} else if (region.isDrawer) {
|
|
914
|
+
// Single-widget drawer: hide after widget duration
|
|
915
|
+
const widget = region.widgets[widgetIndex];
|
|
916
|
+
const duration = widget.duration * 1000;
|
|
917
|
+
region.timer = setTimeout(() => {
|
|
918
|
+
this.stopWidget(regionId, widgetIndex);
|
|
919
|
+
region.element.style.display = 'none';
|
|
920
|
+
this.log.info(`Drawer region ${regionId} hidden (single widget done)`);
|
|
757
921
|
}, duration);
|
|
758
922
|
}
|
|
759
923
|
return;
|
|
@@ -832,8 +996,9 @@ export class RendererLite {
|
|
|
832
996
|
// Emit layout start event
|
|
833
997
|
this.emit('layoutStart', layoutId, this.currentLayout);
|
|
834
998
|
|
|
835
|
-
// Restart all regions from widget 0
|
|
999
|
+
// Restart all regions from widget 0 (except drawers)
|
|
836
1000
|
for (const [regionId, region] of this.regions) {
|
|
1001
|
+
if (region.isDrawer) continue;
|
|
837
1002
|
this.startRegion(regionId);
|
|
838
1003
|
}
|
|
839
1004
|
|
|
@@ -950,8 +1115,9 @@ export class RendererLite {
|
|
|
950
1115
|
// Emit layout start event
|
|
951
1116
|
this.emit('layoutStart', layoutId, layout);
|
|
952
1117
|
|
|
953
|
-
// Start all regions
|
|
1118
|
+
// Start all regions (except drawers — they're action-triggered)
|
|
954
1119
|
for (const [regionId, region] of this.regions) {
|
|
1120
|
+
if (region.isDrawer) continue;
|
|
955
1121
|
this.startRegion(regionId);
|
|
956
1122
|
}
|
|
957
1123
|
|
|
@@ -983,22 +1149,36 @@ export class RendererLite {
|
|
|
983
1149
|
regionEl.style.zIndex = regionConfig.zindex;
|
|
984
1150
|
regionEl.style.overflow = 'hidden';
|
|
985
1151
|
|
|
1152
|
+
// Drawer regions start fully hidden — shown only by navWidget actions
|
|
1153
|
+
if (regionConfig.isDrawer) {
|
|
1154
|
+
regionEl.style.display = 'none';
|
|
1155
|
+
}
|
|
1156
|
+
|
|
986
1157
|
// Apply scaled positioning
|
|
987
1158
|
this.applyRegionScale(regionEl, regionConfig);
|
|
988
1159
|
|
|
989
1160
|
this.container.appendChild(regionEl);
|
|
990
1161
|
|
|
1162
|
+
// Filter expired widgets (fromDt/toDt time-gating within XLF)
|
|
1163
|
+
let widgets = regionConfig.widgets.filter(w => this._isWidgetActive(w));
|
|
1164
|
+
|
|
1165
|
+
// For regions with sub-playlist cycle playback, select which widgets play this cycle
|
|
1166
|
+
if (widgets.some(w => w.cyclePlayback)) {
|
|
1167
|
+
widgets = this._applyCyclePlayback(widgets);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
991
1170
|
// Store region state (dimensions use scaled values for transitions)
|
|
992
1171
|
const sf = this.scaleFactor;
|
|
993
1172
|
this.regions.set(regionConfig.id, {
|
|
994
1173
|
element: regionEl,
|
|
995
1174
|
config: regionConfig,
|
|
996
|
-
widgets
|
|
1175
|
+
widgets,
|
|
997
1176
|
currentIndex: 0,
|
|
998
1177
|
timer: null,
|
|
999
1178
|
width: regionConfig.width * sf,
|
|
1000
1179
|
height: regionConfig.height * sf,
|
|
1001
1180
|
complete: false, // Track if region has played all widgets once
|
|
1181
|
+
isDrawer: regionConfig.isDrawer || false,
|
|
1002
1182
|
widgetElements: new Map() // widgetId -> DOM element (for element reuse)
|
|
1003
1183
|
});
|
|
1004
1184
|
}
|
|
@@ -1027,6 +1207,11 @@ export class RendererLite {
|
|
|
1027
1207
|
* @returns {Promise<HTMLElement>} Widget DOM element
|
|
1028
1208
|
*/
|
|
1029
1209
|
async createWidgetElement(widget, region) {
|
|
1210
|
+
// render="html" forces GetResource iframe regardless of native type
|
|
1211
|
+
if (widget.render === 'html') {
|
|
1212
|
+
return await this.renderGenericWidget(widget, region);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1030
1215
|
switch (widget.type) {
|
|
1031
1216
|
case 'image':
|
|
1032
1217
|
return await this.renderImage(widget, region);
|
|
@@ -1041,6 +1226,13 @@ export class RendererLite {
|
|
|
1041
1226
|
return await this.renderPdf(widget, region);
|
|
1042
1227
|
case 'webpage':
|
|
1043
1228
|
return await this.renderWebpage(widget, region);
|
|
1229
|
+
case 'localvideo':
|
|
1230
|
+
return await this.renderVideo(widget, region);
|
|
1231
|
+
case 'powerpoint':
|
|
1232
|
+
case 'flash':
|
|
1233
|
+
// Legacy Windows-only types — show placeholder instead of failing silently
|
|
1234
|
+
this.log.warn(`Widget type '${widget.type}' is not supported on web players (widget ${widget.id})`);
|
|
1235
|
+
return this._renderUnsupportedPlaceholder(widget, region);
|
|
1044
1236
|
default:
|
|
1045
1237
|
// Generic widget (clock, calendar, weather, etc.)
|
|
1046
1238
|
return await this.renderGenericWidget(widget, region);
|
|
@@ -1125,6 +1317,27 @@ export class RendererLite {
|
|
|
1125
1317
|
});
|
|
1126
1318
|
}
|
|
1127
1319
|
|
|
1320
|
+
// Audio widgets: wait for playback to start
|
|
1321
|
+
const audioEl = this.findMediaElement(element, 'AUDIO');
|
|
1322
|
+
if (audioEl) {
|
|
1323
|
+
if (!audioEl.paused && audioEl.readyState >= 3) {
|
|
1324
|
+
return Promise.resolve();
|
|
1325
|
+
}
|
|
1326
|
+
return new Promise((resolve) => {
|
|
1327
|
+
const timer = setTimeout(() => {
|
|
1328
|
+
this.log.warn(`Audio ready timeout (${READY_TIMEOUT}ms) for widget ${widget.id}`);
|
|
1329
|
+
resolve();
|
|
1330
|
+
}, READY_TIMEOUT);
|
|
1331
|
+
const onPlaying = () => {
|
|
1332
|
+
audioEl.removeEventListener('playing', onPlaying);
|
|
1333
|
+
clearTimeout(timer);
|
|
1334
|
+
this.log.info(`Audio widget ${widget.id} ready (playing)`);
|
|
1335
|
+
resolve();
|
|
1336
|
+
};
|
|
1337
|
+
audioEl.addEventListener('playing', onPlaying);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1128
1341
|
// Image widgets: wait for image decode
|
|
1129
1342
|
const imgEl = this.findMediaElement(element, 'IMG');
|
|
1130
1343
|
if (imgEl) {
|
|
@@ -1235,9 +1448,86 @@ export class RendererLite {
|
|
|
1235
1448
|
element.style.opacity = '1';
|
|
1236
1449
|
}
|
|
1237
1450
|
|
|
1451
|
+
// Start audio overlays attached to this widget
|
|
1452
|
+
this._startAudioOverlays(widget);
|
|
1453
|
+
|
|
1238
1454
|
return widget;
|
|
1239
1455
|
}
|
|
1240
1456
|
|
|
1457
|
+
/**
|
|
1458
|
+
* Start audio overlay elements for a widget.
|
|
1459
|
+
* Audio overlays are <audio> child nodes in the XLF that play alongside
|
|
1460
|
+
* the visual widget (e.g. background music for an image slideshow).
|
|
1461
|
+
* @param {Object} widget - Widget config with audioNodes array
|
|
1462
|
+
*/
|
|
1463
|
+
_startAudioOverlays(widget) {
|
|
1464
|
+
if (!widget.audioNodes || widget.audioNodes.length === 0) return;
|
|
1465
|
+
|
|
1466
|
+
// Stop any existing audio overlays for this widget first
|
|
1467
|
+
this._stopAudioOverlays(widget.id);
|
|
1468
|
+
|
|
1469
|
+
const audioElements = [];
|
|
1470
|
+
for (const audioNode of widget.audioNodes) {
|
|
1471
|
+
if (!audioNode.uri) continue;
|
|
1472
|
+
|
|
1473
|
+
const audio = document.createElement('audio');
|
|
1474
|
+
audio.autoplay = true;
|
|
1475
|
+
audio.loop = audioNode.loop;
|
|
1476
|
+
audio.volume = Math.max(0, Math.min(1, audioNode.volume / 100));
|
|
1477
|
+
|
|
1478
|
+
// Resolve audio URI via cache/proxy
|
|
1479
|
+
const mediaId = parseInt(audioNode.mediaId);
|
|
1480
|
+
let audioSrc = mediaId ? this.mediaUrlCache.get(mediaId) : null;
|
|
1481
|
+
|
|
1482
|
+
if (!audioSrc && mediaId && this.options.getMediaUrl) {
|
|
1483
|
+
// Async — fire and forget, set src when ready
|
|
1484
|
+
this.options.getMediaUrl(mediaId).then(url => {
|
|
1485
|
+
audio.src = url;
|
|
1486
|
+
}).catch(() => {
|
|
1487
|
+
audio.src = `${window.location.origin}/player/cache/media/${audioNode.uri}`;
|
|
1488
|
+
});
|
|
1489
|
+
} else if (!audioSrc) {
|
|
1490
|
+
audio.src = `${window.location.origin}/player/cache/media/${audioNode.uri}`;
|
|
1491
|
+
} else {
|
|
1492
|
+
audio.src = audioSrc;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Append to DOM to prevent garbage collection in some browsers
|
|
1496
|
+
audio.style.display = 'none';
|
|
1497
|
+
this.container.appendChild(audio);
|
|
1498
|
+
|
|
1499
|
+
// Handle autoplay restrictions gracefully (play() may return undefined in some envs)
|
|
1500
|
+
const playPromise = audio.play();
|
|
1501
|
+
if (playPromise && playPromise.catch) playPromise.catch(() => {});
|
|
1502
|
+
|
|
1503
|
+
audioElements.push(audio);
|
|
1504
|
+
this.log.info(`Audio overlay started for widget ${widget.id}: ${audioNode.uri} (loop=${audioNode.loop}, vol=${audioNode.volume})`);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (audioElements.length > 0) {
|
|
1508
|
+
this.audioOverlays.set(widget.id, audioElements);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Stop and clean up audio overlay elements for a widget.
|
|
1514
|
+
* @param {string} widgetId - Widget ID
|
|
1515
|
+
*/
|
|
1516
|
+
_stopAudioOverlays(widgetId) {
|
|
1517
|
+
const audioElements = this.audioOverlays.get(widgetId);
|
|
1518
|
+
if (!audioElements) return;
|
|
1519
|
+
|
|
1520
|
+
for (const audio of audioElements) {
|
|
1521
|
+
audio.pause();
|
|
1522
|
+
audio.removeAttribute('src');
|
|
1523
|
+
audio.load(); // Release resources
|
|
1524
|
+
if (audio.parentNode) audio.parentNode.removeChild(audio); // Remove from DOM
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
this.audioOverlays.delete(widgetId);
|
|
1528
|
+
this.log.info(`Audio overlays stopped for widget ${widgetId}`);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1241
1531
|
/**
|
|
1242
1532
|
* Core: hide a widget in a region (shared by main layout + overlay).
|
|
1243
1533
|
* Returns { widget, animPromise } synchronously — callers await animPromise if needed.
|
|
@@ -1266,9 +1556,115 @@ export class RendererLite {
|
|
|
1266
1556
|
const audioEl = widgetElement.querySelector('audio');
|
|
1267
1557
|
if (audioEl && widget.options.loop !== '1') audioEl.pause();
|
|
1268
1558
|
|
|
1559
|
+
// Stop audio overlays attached to this widget
|
|
1560
|
+
this._stopAudioOverlays(widget.id);
|
|
1561
|
+
|
|
1269
1562
|
return { widget, animPromise };
|
|
1270
1563
|
}
|
|
1271
1564
|
|
|
1565
|
+
/**
|
|
1566
|
+
* Check if a widget is within its valid time window (fromDt/toDt).
|
|
1567
|
+
* Widgets without dates are always active.
|
|
1568
|
+
* @param {Object} widget - Widget config with optional fromDt/toDt
|
|
1569
|
+
* @returns {boolean}
|
|
1570
|
+
*/
|
|
1571
|
+
_isWidgetActive(widget) {
|
|
1572
|
+
const now = new Date();
|
|
1573
|
+
if (widget.fromDt) {
|
|
1574
|
+
const from = new Date(widget.fromDt);
|
|
1575
|
+
if (now < from) return false;
|
|
1576
|
+
}
|
|
1577
|
+
if (widget.toDt) {
|
|
1578
|
+
const to = new Date(widget.toDt);
|
|
1579
|
+
if (now > to) return false;
|
|
1580
|
+
}
|
|
1581
|
+
return true;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Parse NUMITEMS and DURATION HTML comments from GetResource responses.
|
|
1586
|
+
* CMS embeds these in widget HTML to override duration for dynamic content
|
|
1587
|
+
* (e.g. DataSet tickers, RSS feeds). Format: <!-- NUMITEMS=5 --> <!-- DURATION=30 -->
|
|
1588
|
+
* DURATION takes precedence; otherwise NUMITEMS × widget.duration is used.
|
|
1589
|
+
* @param {string} html - Widget HTML content
|
|
1590
|
+
* @param {Object} widget - Widget config (duration may be updated)
|
|
1591
|
+
*/
|
|
1592
|
+
_parseDurationComments(html, widget) {
|
|
1593
|
+
const durationMatch = html.match(/<!--\s*DURATION=(\d+)\s*-->/);
|
|
1594
|
+
if (durationMatch) {
|
|
1595
|
+
const newDuration = parseInt(durationMatch[1], 10);
|
|
1596
|
+
if (newDuration > 0) {
|
|
1597
|
+
this.log.info(`Widget ${widget.id}: DURATION comment overrides duration ${widget.duration}→${newDuration}s`);
|
|
1598
|
+
widget.duration = newDuration;
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const numItemsMatch = html.match(/<!--\s*NUMITEMS=(\d+)\s*-->/);
|
|
1604
|
+
if (numItemsMatch) {
|
|
1605
|
+
const numItems = parseInt(numItemsMatch[1], 10);
|
|
1606
|
+
if (numItems > 0 && widget.duration > 0) {
|
|
1607
|
+
const newDuration = numItems * widget.duration;
|
|
1608
|
+
this.log.info(`Widget ${widget.id}: NUMITEMS=${numItems} × ${widget.duration}s = ${newDuration}s`);
|
|
1609
|
+
widget.duration = newDuration;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Apply sub-playlist cycle playback filtering.
|
|
1616
|
+
* Groups widgets by parentWidgetId, then selects one widget per group for this cycle.
|
|
1617
|
+
* Non-grouped widgets pass through unchanged.
|
|
1618
|
+
*
|
|
1619
|
+
* @param {Array} widgets - All widgets in the region
|
|
1620
|
+
* @returns {Array} Filtered widgets for this playback cycle
|
|
1621
|
+
*/
|
|
1622
|
+
_applyCyclePlayback(widgets) {
|
|
1623
|
+
// Track cycle indices per group for deterministic round-robin
|
|
1624
|
+
if (!this._subPlaylistCycleIndex) {
|
|
1625
|
+
this._subPlaylistCycleIndex = new Map();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Group widgets by parentWidgetId
|
|
1629
|
+
const groups = new Map(); // parentWidgetId → [widgets]
|
|
1630
|
+
const result = [];
|
|
1631
|
+
|
|
1632
|
+
for (const widget of widgets) {
|
|
1633
|
+
if (widget.parentWidgetId && widget.cyclePlayback) {
|
|
1634
|
+
if (!groups.has(widget.parentWidgetId)) {
|
|
1635
|
+
groups.set(widget.parentWidgetId, []);
|
|
1636
|
+
}
|
|
1637
|
+
groups.get(widget.parentWidgetId).push(widget);
|
|
1638
|
+
} else {
|
|
1639
|
+
// Non-grouped widget: add a placeholder to preserve order
|
|
1640
|
+
result.push({ type: 'direct', widget });
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// For each group, select one widget for this cycle
|
|
1645
|
+
for (const [groupId, groupWidgets] of groups) {
|
|
1646
|
+
// Sort by displayOrder
|
|
1647
|
+
groupWidgets.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
1648
|
+
|
|
1649
|
+
let selectedWidget;
|
|
1650
|
+
if (groupWidgets.some(w => w.isRandom)) {
|
|
1651
|
+
// Random selection
|
|
1652
|
+
const idx = Math.floor(Math.random() * groupWidgets.length);
|
|
1653
|
+
selectedWidget = groupWidgets[idx];
|
|
1654
|
+
} else {
|
|
1655
|
+
// Round-robin based on cycle index
|
|
1656
|
+
const cycleIdx = this._subPlaylistCycleIndex.get(groupId) || 0;
|
|
1657
|
+
selectedWidget = groupWidgets[cycleIdx % groupWidgets.length];
|
|
1658
|
+
this._subPlaylistCycleIndex.set(groupId, cycleIdx + 1);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
this.log.info(`Sub-playlist cycle: group ${groupId} selected widget ${selectedWidget.id} (${groupWidgets.length} in group)`);
|
|
1662
|
+
result.push({ type: 'direct', widget: selectedWidget });
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
return result.map(r => r.widget);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1272
1668
|
/**
|
|
1273
1669
|
* Core: cycle through widgets in a region (shared by main layout + overlay)
|
|
1274
1670
|
* @param {Object} region - Region state object
|
|
@@ -1280,6 +1676,7 @@ export class RendererLite {
|
|
|
1280
1676
|
_startRegionCycle(region, regionId, showFn, hideFn, onCycleComplete) {
|
|
1281
1677
|
if (!region || region.widgets.length === 0) return;
|
|
1282
1678
|
|
|
1679
|
+
// Non-looping region with a single widget: show it and stay (spec: loop=0)
|
|
1283
1680
|
if (region.widgets.length === 1) {
|
|
1284
1681
|
showFn(regionId, 0);
|
|
1285
1682
|
return;
|
|
@@ -1293,22 +1690,47 @@ export class RendererLite {
|
|
|
1293
1690
|
|
|
1294
1691
|
const duration = widget.duration * 1000;
|
|
1295
1692
|
region.timer = setTimeout(() => {
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const nextIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
1299
|
-
if (nextIndex === 0 && !region.complete) {
|
|
1300
|
-
region.complete = true;
|
|
1301
|
-
onCycleComplete?.();
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
region.currentIndex = nextIndex;
|
|
1305
|
-
playNext();
|
|
1693
|
+
this._handleWidgetCycleEnd(widget, region, regionId, widgetIndex, showFn, hideFn, onCycleComplete, playNext);
|
|
1306
1694
|
}, duration);
|
|
1307
1695
|
};
|
|
1308
1696
|
|
|
1309
1697
|
playNext();
|
|
1310
1698
|
}
|
|
1311
1699
|
|
|
1700
|
+
/**
|
|
1701
|
+
* Handle widget cycle end — shared logic for timer-based and event-based cycling
|
|
1702
|
+
*/
|
|
1703
|
+
_handleWidgetCycleEnd(widget, region, regionId, widgetIndex, showFn, hideFn, onCycleComplete, playNext) {
|
|
1704
|
+
// Emit widgetAction if widget has a webhook URL configured
|
|
1705
|
+
if (widget.webhookUrl) {
|
|
1706
|
+
this.emit('widgetAction', {
|
|
1707
|
+
type: 'durationEnd',
|
|
1708
|
+
widgetId: widget.id,
|
|
1709
|
+
layoutId: this.currentLayoutId,
|
|
1710
|
+
regionId,
|
|
1711
|
+
url: widget.webhookUrl
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
hideFn(regionId, widgetIndex);
|
|
1716
|
+
|
|
1717
|
+
const nextIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
1718
|
+
if (nextIndex === 0 && !region.complete) {
|
|
1719
|
+
region.complete = true;
|
|
1720
|
+
onCycleComplete?.();
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Non-looping region (loop=0): stop after one full cycle
|
|
1724
|
+
if (nextIndex === 0 && region.config?.loop === false) {
|
|
1725
|
+
// Show the last widget again and keep it visible
|
|
1726
|
+
showFn(regionId, region.widgets.length - 1);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
region.currentIndex = nextIndex;
|
|
1731
|
+
playNext();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1312
1734
|
async renderWidget(regionId, widgetIndex) {
|
|
1313
1735
|
const region = this.regions.get(regionId);
|
|
1314
1736
|
if (!region) return;
|
|
@@ -1323,6 +1745,19 @@ export class RendererLite {
|
|
|
1323
1745
|
type: widget.type, duration: widget.duration,
|
|
1324
1746
|
enableStat: widget.enableStat
|
|
1325
1747
|
});
|
|
1748
|
+
|
|
1749
|
+
// Execute commands attached to this widget (shell/native commands)
|
|
1750
|
+
if (widget.commands && widget.commands.length > 0) {
|
|
1751
|
+
for (const cmd of widget.commands) {
|
|
1752
|
+
this.emit('widgetCommand', {
|
|
1753
|
+
commandCode: cmd.commandCode,
|
|
1754
|
+
commandString: cmd.commandString,
|
|
1755
|
+
widgetId: widget.id,
|
|
1756
|
+
regionId,
|
|
1757
|
+
layoutId: this.currentLayoutId
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1326
1761
|
}
|
|
1327
1762
|
} catch (error) {
|
|
1328
1763
|
this.log.error(`Error rendering widget:`, error);
|
|
@@ -1359,7 +1794,18 @@ export class RendererLite {
|
|
|
1359
1794
|
img.className = 'renderer-lite-widget';
|
|
1360
1795
|
img.style.width = '100%';
|
|
1361
1796
|
img.style.height = '100%';
|
|
1362
|
-
|
|
1797
|
+
// Scale type: stretch → fill, center → none (natural size), default → contain
|
|
1798
|
+
const scaleType = widget.options.scaleType;
|
|
1799
|
+
const fitMap = { stretch: 'fill', center: 'none', fit: 'contain' };
|
|
1800
|
+
img.style.objectFit = fitMap[scaleType] || 'contain';
|
|
1801
|
+
|
|
1802
|
+
// Alignment: map align/valign to CSS object-position
|
|
1803
|
+
const alignMap = { left: 'left', center: 'center', right: 'right' };
|
|
1804
|
+
const valignMap = { top: 'top', middle: 'center', bottom: 'bottom' };
|
|
1805
|
+
const hPos = alignMap[widget.options.align] || 'center';
|
|
1806
|
+
const vPos = valignMap[widget.options.valign] || 'center';
|
|
1807
|
+
img.style.objectPosition = `${hPos} ${vPos}`;
|
|
1808
|
+
|
|
1363
1809
|
img.style.opacity = '0';
|
|
1364
1810
|
|
|
1365
1811
|
// Get media URL from cache (already pre-fetched!) or fetch on-demand
|
|
@@ -1384,7 +1830,9 @@ export class RendererLite {
|
|
|
1384
1830
|
video.className = 'renderer-lite-widget';
|
|
1385
1831
|
video.style.width = '100%';
|
|
1386
1832
|
video.style.height = '100%';
|
|
1387
|
-
|
|
1833
|
+
const vScaleType = widget.options.scaleType;
|
|
1834
|
+
const vFitMap = { stretch: 'fill', center: 'none', fit: 'contain' };
|
|
1835
|
+
video.style.objectFit = vFitMap[vScaleType] || 'contain';
|
|
1388
1836
|
video.style.opacity = '1'; // Immediately visible
|
|
1389
1837
|
video.autoplay = true;
|
|
1390
1838
|
video.preload = 'auto'; // Eagerly buffer - chunks are pre-warmed in SW BlobCache
|
|
@@ -1528,6 +1976,34 @@ export class RendererLite {
|
|
|
1528
1976
|
|
|
1529
1977
|
audio.src = audioSrc;
|
|
1530
1978
|
|
|
1979
|
+
// Handle audio end - similar to video ended handling
|
|
1980
|
+
audio.addEventListener('ended', () => {
|
|
1981
|
+
if (widget.options.loop === '1') {
|
|
1982
|
+
audio.currentTime = 0;
|
|
1983
|
+
this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
|
|
1984
|
+
} else {
|
|
1985
|
+
this.log.info(`Audio ${fileId} ended - playback complete`);
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
// Detect audio duration for dynamic layout timing (when useDuration=0)
|
|
1990
|
+
audio.addEventListener('loadedmetadata', () => {
|
|
1991
|
+
const audioDuration = Math.floor(audio.duration);
|
|
1992
|
+
this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
|
|
1993
|
+
|
|
1994
|
+
if (widget.duration === 0 || widget.useDuration === 0) {
|
|
1995
|
+
widget.duration = audioDuration;
|
|
1996
|
+
this.log.info(`Updated widget ${widget.id} duration to ${audioDuration}s (useDuration=0)`);
|
|
1997
|
+
this.updateLayoutDuration();
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
|
|
2001
|
+
// Handle audio errors
|
|
2002
|
+
audio.addEventListener('error', () => {
|
|
2003
|
+
const error = audio.error;
|
|
2004
|
+
this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
|
|
2005
|
+
});
|
|
2006
|
+
|
|
1531
2007
|
// Visual feedback
|
|
1532
2008
|
const icon = document.createElement('div');
|
|
1533
2009
|
icon.innerHTML = '♪';
|
|
@@ -1581,7 +2057,7 @@ export class RendererLite {
|
|
|
1581
2057
|
try {
|
|
1582
2058
|
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
1583
2059
|
if (!iframe.contentDocument?.querySelector('base')) {
|
|
1584
|
-
|
|
2060
|
+
self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
|
|
1585
2061
|
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
1586
2062
|
const blobUrl = URL.createObjectURL(blob);
|
|
1587
2063
|
self.trackBlobUrl(blobUrl);
|
|
@@ -1591,11 +2067,21 @@ export class RendererLite {
|
|
|
1591
2067
|
}, { once: true });
|
|
1592
2068
|
}
|
|
1593
2069
|
|
|
2070
|
+
// Parse NUMITEMS/DURATION from fallback HTML (cache path)
|
|
2071
|
+
if (result.fallback) {
|
|
2072
|
+
this._parseDurationComments(result.fallback, widget);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1594
2075
|
return iframe;
|
|
1595
2076
|
}
|
|
1596
2077
|
html = result;
|
|
1597
2078
|
}
|
|
1598
2079
|
|
|
2080
|
+
if (html) {
|
|
2081
|
+
// Parse NUMITEMS/DURATION HTML comments for dynamic widget duration
|
|
2082
|
+
this._parseDurationComments(html, widget);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
1599
2085
|
// Fallback: Create blob URL for iframe
|
|
1600
2086
|
const blob = new Blob([html], { type: 'text/html' });
|
|
1601
2087
|
const blobUrl = URL.createObjectURL(blob);
|
|
@@ -1680,6 +2166,13 @@ export class RendererLite {
|
|
|
1680
2166
|
* Render webpage widget
|
|
1681
2167
|
*/
|
|
1682
2168
|
async renderWebpage(widget, region) {
|
|
2169
|
+
// modeId=1 (or absent) = Open Natively (direct URL), modeId=0 = Manual/GetResource
|
|
2170
|
+
const modeId = parseInt(widget.options.modeId || '1');
|
|
2171
|
+
if (modeId === 0) {
|
|
2172
|
+
// GetResource mode: treat like a generic widget (fetch HTML from CMS)
|
|
2173
|
+
return await this.renderGenericWidget(widget, region);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
1683
2176
|
const iframe = document.createElement('iframe');
|
|
1684
2177
|
iframe.className = 'renderer-lite-widget';
|
|
1685
2178
|
iframe.style.width = '100%';
|
|
@@ -1718,7 +2211,7 @@ export class RendererLite {
|
|
|
1718
2211
|
try {
|
|
1719
2212
|
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
1720
2213
|
if (!iframe.contentDocument?.querySelector('base')) {
|
|
1721
|
-
|
|
2214
|
+
self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
|
|
1722
2215
|
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
1723
2216
|
const blobUrl = URL.createObjectURL(blob);
|
|
1724
2217
|
self.trackBlobUrl(blobUrl);
|
|
@@ -1728,12 +2221,21 @@ export class RendererLite {
|
|
|
1728
2221
|
}, { once: true });
|
|
1729
2222
|
}
|
|
1730
2223
|
|
|
2224
|
+
// Parse NUMITEMS/DURATION from fallback HTML (cache path)
|
|
2225
|
+
if (result.fallback) {
|
|
2226
|
+
this._parseDurationComments(result.fallback, widget);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
1731
2229
|
return iframe;
|
|
1732
2230
|
}
|
|
1733
2231
|
html = result;
|
|
1734
2232
|
}
|
|
1735
2233
|
|
|
1736
2234
|
if (html) {
|
|
2235
|
+
// Parse NUMITEMS/DURATION HTML comments for dynamic widget duration
|
|
2236
|
+
// Format: <!-- NUMITEMS=5 --> and <!-- DURATION=30 -->
|
|
2237
|
+
this._parseDurationComments(html, widget);
|
|
2238
|
+
|
|
1737
2239
|
const blob = new Blob([html], { type: 'text/html' });
|
|
1738
2240
|
const blobUrl = URL.createObjectURL(blob);
|
|
1739
2241
|
iframe.src = blobUrl;
|
|
@@ -1748,6 +2250,24 @@ export class RendererLite {
|
|
|
1748
2250
|
return iframe;
|
|
1749
2251
|
}
|
|
1750
2252
|
|
|
2253
|
+
/**
|
|
2254
|
+
* Render a placeholder for unsupported widget types (powerpoint, flash)
|
|
2255
|
+
*/
|
|
2256
|
+
_renderUnsupportedPlaceholder(widget, region) {
|
|
2257
|
+
const div = document.createElement('div');
|
|
2258
|
+
div.className = 'renderer-lite-widget';
|
|
2259
|
+
div.style.width = '100%';
|
|
2260
|
+
div.style.height = '100%';
|
|
2261
|
+
div.style.display = 'flex';
|
|
2262
|
+
div.style.alignItems = 'center';
|
|
2263
|
+
div.style.justifyContent = 'center';
|
|
2264
|
+
div.style.backgroundColor = '#111';
|
|
2265
|
+
div.style.color = '#666';
|
|
2266
|
+
div.style.fontSize = '14px';
|
|
2267
|
+
div.textContent = `Unsupported: ${widget.type}`;
|
|
2268
|
+
return div;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
1751
2271
|
// ── Layout Preload Pool ─────────────────────────────────────────────
|
|
1752
2272
|
|
|
1753
2273
|
/**
|
|
@@ -2030,7 +2550,21 @@ export class RendererLite {
|
|
|
2030
2550
|
v.removeAttribute('src');
|
|
2031
2551
|
v.load();
|
|
2032
2552
|
});
|
|
2033
|
-
region
|
|
2553
|
+
// Apply region exit transition if configured, then remove
|
|
2554
|
+
if (region.config && region.config.exitTransition) {
|
|
2555
|
+
const animation = Transitions.apply(
|
|
2556
|
+
region.element, region.config.exitTransition, false,
|
|
2557
|
+
region.width, region.height
|
|
2558
|
+
);
|
|
2559
|
+
if (animation) {
|
|
2560
|
+
const el = region.element;
|
|
2561
|
+
animation.onfinish = () => el.remove();
|
|
2562
|
+
} else {
|
|
2563
|
+
region.element.remove();
|
|
2564
|
+
}
|
|
2565
|
+
} else {
|
|
2566
|
+
region.element.remove();
|
|
2567
|
+
}
|
|
2034
2568
|
}
|
|
2035
2569
|
// Revoke blob URLs
|
|
2036
2570
|
if (oldLayoutId) {
|
|
@@ -2178,8 +2712,22 @@ export class RendererLite {
|
|
|
2178
2712
|
this.stopWidget(regionId, region.currentIndex);
|
|
2179
2713
|
}
|
|
2180
2714
|
|
|
2181
|
-
//
|
|
2182
|
-
region.
|
|
2715
|
+
// Apply region exit transition if configured, then remove
|
|
2716
|
+
if (region.config && region.config.exitTransition) {
|
|
2717
|
+
const animation = Transitions.apply(
|
|
2718
|
+
region.element, region.config.exitTransition, false,
|
|
2719
|
+
region.width, region.height
|
|
2720
|
+
);
|
|
2721
|
+
if (animation) {
|
|
2722
|
+
// Remove element after exit transition completes
|
|
2723
|
+
const el = region.element;
|
|
2724
|
+
animation.onfinish = () => el.remove();
|
|
2725
|
+
} else {
|
|
2726
|
+
region.element.remove();
|
|
2727
|
+
}
|
|
2728
|
+
} else {
|
|
2729
|
+
region.element.remove();
|
|
2730
|
+
}
|
|
2183
2731
|
}
|
|
2184
2732
|
|
|
2185
2733
|
// Revoke media blob URLs from cache
|
|
@@ -2528,6 +3076,13 @@ export class RendererLite {
|
|
|
2528
3076
|
this.log.info('Playback paused');
|
|
2529
3077
|
}
|
|
2530
3078
|
|
|
3079
|
+
/**
|
|
3080
|
+
* Check if playback is currently paused.
|
|
3081
|
+
*/
|
|
3082
|
+
isPaused() {
|
|
3083
|
+
return this._paused;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
2531
3086
|
/**
|
|
2532
3087
|
* Resume playback: restart layout timer with remaining time, resume media and widget cycling.
|
|
2533
3088
|
*/
|
|
@@ -2578,6 +3133,11 @@ export class RendererLite {
|
|
|
2578
3133
|
this.stopAllOverlays();
|
|
2579
3134
|
this.stopCurrentLayout();
|
|
2580
3135
|
|
|
3136
|
+
// Clean up any remaining audio overlays
|
|
3137
|
+
for (const widgetId of this.audioOverlays.keys()) {
|
|
3138
|
+
this._stopAudioOverlays(widgetId);
|
|
3139
|
+
}
|
|
3140
|
+
|
|
2581
3141
|
// Clear the layout preload pool
|
|
2582
3142
|
this.layoutPool.clear();
|
|
2583
3143
|
|