@xiboplayer/renderer 0.3.7 → 0.4.1
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/README.md +3 -1
- package/docs/RENDERER_COMPARISON.md +9 -18
- package/package.json +3 -3
- package/src/layout.js +12 -9
- package/src/renderer-lite.js +700 -35
- 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
|
|
|
@@ -933,6 +1098,11 @@ export class RendererLite {
|
|
|
933
1098
|
|
|
934
1099
|
try {
|
|
935
1100
|
const element = await this.createWidgetElement(widget, region);
|
|
1101
|
+
element.style.position = 'absolute';
|
|
1102
|
+
element.style.top = '0';
|
|
1103
|
+
element.style.left = '0';
|
|
1104
|
+
element.style.width = '100%';
|
|
1105
|
+
element.style.height = '100%';
|
|
936
1106
|
element.style.visibility = 'hidden'; // Hidden by default
|
|
937
1107
|
element.style.opacity = '0';
|
|
938
1108
|
region.element.appendChild(element);
|
|
@@ -950,8 +1120,9 @@ export class RendererLite {
|
|
|
950
1120
|
// Emit layout start event
|
|
951
1121
|
this.emit('layoutStart', layoutId, layout);
|
|
952
1122
|
|
|
953
|
-
// Start all regions
|
|
1123
|
+
// Start all regions (except drawers — they're action-triggered)
|
|
954
1124
|
for (const [regionId, region] of this.regions) {
|
|
1125
|
+
if (region.isDrawer) continue;
|
|
955
1126
|
this.startRegion(regionId);
|
|
956
1127
|
}
|
|
957
1128
|
|
|
@@ -983,22 +1154,36 @@ export class RendererLite {
|
|
|
983
1154
|
regionEl.style.zIndex = regionConfig.zindex;
|
|
984
1155
|
regionEl.style.overflow = 'hidden';
|
|
985
1156
|
|
|
1157
|
+
// Drawer regions start fully hidden — shown only by navWidget actions
|
|
1158
|
+
if (regionConfig.isDrawer) {
|
|
1159
|
+
regionEl.style.display = 'none';
|
|
1160
|
+
}
|
|
1161
|
+
|
|
986
1162
|
// Apply scaled positioning
|
|
987
1163
|
this.applyRegionScale(regionEl, regionConfig);
|
|
988
1164
|
|
|
989
1165
|
this.container.appendChild(regionEl);
|
|
990
1166
|
|
|
1167
|
+
// Filter expired widgets (fromDt/toDt time-gating within XLF)
|
|
1168
|
+
let widgets = regionConfig.widgets.filter(w => this._isWidgetActive(w));
|
|
1169
|
+
|
|
1170
|
+
// For regions with sub-playlist cycle playback, select which widgets play this cycle
|
|
1171
|
+
if (widgets.some(w => w.cyclePlayback)) {
|
|
1172
|
+
widgets = this._applyCyclePlayback(widgets);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
991
1175
|
// Store region state (dimensions use scaled values for transitions)
|
|
992
1176
|
const sf = this.scaleFactor;
|
|
993
1177
|
this.regions.set(regionConfig.id, {
|
|
994
1178
|
element: regionEl,
|
|
995
1179
|
config: regionConfig,
|
|
996
|
-
widgets
|
|
1180
|
+
widgets,
|
|
997
1181
|
currentIndex: 0,
|
|
998
1182
|
timer: null,
|
|
999
1183
|
width: regionConfig.width * sf,
|
|
1000
1184
|
height: regionConfig.height * sf,
|
|
1001
1185
|
complete: false, // Track if region has played all widgets once
|
|
1186
|
+
isDrawer: regionConfig.isDrawer || false,
|
|
1002
1187
|
widgetElements: new Map() // widgetId -> DOM element (for element reuse)
|
|
1003
1188
|
});
|
|
1004
1189
|
}
|
|
@@ -1027,6 +1212,12 @@ export class RendererLite {
|
|
|
1027
1212
|
* @returns {Promise<HTMLElement>} Widget DOM element
|
|
1028
1213
|
*/
|
|
1029
1214
|
async createWidgetElement(widget, region) {
|
|
1215
|
+
// render="html" forces GetResource iframe regardless of native type,
|
|
1216
|
+
// EXCEPT for types we handle natively (PDF: CMS bundle can't work cross-origin)
|
|
1217
|
+
if (widget.render === 'html' && widget.type !== 'pdf') {
|
|
1218
|
+
return await this.renderGenericWidget(widget, region);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1030
1221
|
switch (widget.type) {
|
|
1031
1222
|
case 'image':
|
|
1032
1223
|
return await this.renderImage(widget, region);
|
|
@@ -1041,6 +1232,15 @@ export class RendererLite {
|
|
|
1041
1232
|
return await this.renderPdf(widget, region);
|
|
1042
1233
|
case 'webpage':
|
|
1043
1234
|
return await this.renderWebpage(widget, region);
|
|
1235
|
+
case 'localvideo':
|
|
1236
|
+
return await this.renderVideo(widget, region);
|
|
1237
|
+
case 'videoin':
|
|
1238
|
+
return await this.renderVideoIn(widget, region);
|
|
1239
|
+
case 'powerpoint':
|
|
1240
|
+
case 'flash':
|
|
1241
|
+
// Legacy Windows-only types — show placeholder instead of failing silently
|
|
1242
|
+
this.log.warn(`Widget type '${widget.type}' is not supported on web players (widget ${widget.id})`);
|
|
1243
|
+
return this._renderUnsupportedPlaceholder(widget, region);
|
|
1044
1244
|
default:
|
|
1045
1245
|
// Generic widget (clock, calendar, weather, etc.)
|
|
1046
1246
|
return await this.renderGenericWidget(widget, region);
|
|
@@ -1067,6 +1267,18 @@ export class RendererLite {
|
|
|
1067
1267
|
// Restart video or audio on widget show (even if looping)
|
|
1068
1268
|
const mediaEl = this.findMediaElement(element, 'VIDEO') || this.findMediaElement(element, 'AUDIO');
|
|
1069
1269
|
if (mediaEl) {
|
|
1270
|
+
// Re-acquire webcam stream if it was stopped during _hideWidget()
|
|
1271
|
+
if (mediaEl.tagName === 'VIDEO' && mediaEl._mediaConstraints && !mediaEl._mediaStream) {
|
|
1272
|
+
navigator.mediaDevices.getUserMedia(mediaEl._mediaConstraints).then(stream => {
|
|
1273
|
+
mediaEl.srcObject = stream;
|
|
1274
|
+
mediaEl._mediaStream = stream;
|
|
1275
|
+
this.log.info(`Webcam stream re-acquired for widget ${widget.id}`);
|
|
1276
|
+
}).catch(e => {
|
|
1277
|
+
this.log.warn('Failed to re-acquire webcam stream:', e.message);
|
|
1278
|
+
});
|
|
1279
|
+
return; // srcObject auto-plays, no need for _restartMediaElement
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1070
1282
|
this._restartMediaElement(mediaEl);
|
|
1071
1283
|
this.log.info(`${mediaEl.tagName === 'VIDEO' ? 'Video' : 'Audio'} restarted: ${widget.fileId || widget.id}`);
|
|
1072
1284
|
}
|
|
@@ -1125,6 +1337,27 @@ export class RendererLite {
|
|
|
1125
1337
|
});
|
|
1126
1338
|
}
|
|
1127
1339
|
|
|
1340
|
+
// Audio widgets: wait for playback to start
|
|
1341
|
+
const audioEl = this.findMediaElement(element, 'AUDIO');
|
|
1342
|
+
if (audioEl) {
|
|
1343
|
+
if (!audioEl.paused && audioEl.readyState >= 3) {
|
|
1344
|
+
return Promise.resolve();
|
|
1345
|
+
}
|
|
1346
|
+
return new Promise((resolve) => {
|
|
1347
|
+
const timer = setTimeout(() => {
|
|
1348
|
+
this.log.warn(`Audio ready timeout (${READY_TIMEOUT}ms) for widget ${widget.id}`);
|
|
1349
|
+
resolve();
|
|
1350
|
+
}, READY_TIMEOUT);
|
|
1351
|
+
const onPlaying = () => {
|
|
1352
|
+
audioEl.removeEventListener('playing', onPlaying);
|
|
1353
|
+
clearTimeout(timer);
|
|
1354
|
+
this.log.info(`Audio widget ${widget.id} ready (playing)`);
|
|
1355
|
+
resolve();
|
|
1356
|
+
};
|
|
1357
|
+
audioEl.addEventListener('playing', onPlaying);
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1128
1361
|
// Image widgets: wait for image decode
|
|
1129
1362
|
const imgEl = this.findMediaElement(element, 'IMG');
|
|
1130
1363
|
if (imgEl) {
|
|
@@ -1214,19 +1447,27 @@ export class RendererLite {
|
|
|
1214
1447
|
if (!element) {
|
|
1215
1448
|
this.log.warn(`Widget ${widget.id} not pre-created, creating now`);
|
|
1216
1449
|
element = await this.createWidgetElement(widget, region);
|
|
1450
|
+
element.style.position = 'absolute';
|
|
1451
|
+
element.style.top = '0';
|
|
1452
|
+
element.style.left = '0';
|
|
1453
|
+
element.style.width = '100%';
|
|
1454
|
+
element.style.height = '100%';
|
|
1217
1455
|
region.widgetElements.set(widget.id, element);
|
|
1218
1456
|
region.element.appendChild(element);
|
|
1219
1457
|
}
|
|
1220
1458
|
|
|
1221
1459
|
// Hide all other widgets in region
|
|
1460
|
+
// Cancel fill:forwards animations first — they override inline styles
|
|
1222
1461
|
for (const [widgetId, widgetEl] of region.widgetElements) {
|
|
1223
1462
|
if (widgetId !== widget.id) {
|
|
1463
|
+
widgetEl.getAnimations?.().forEach(a => a.cancel());
|
|
1224
1464
|
widgetEl.style.visibility = 'hidden';
|
|
1225
1465
|
widgetEl.style.opacity = '0';
|
|
1226
1466
|
}
|
|
1227
1467
|
}
|
|
1228
1468
|
|
|
1229
1469
|
this.updateMediaElement(element, widget);
|
|
1470
|
+
element.getAnimations?.().forEach(a => a.cancel());
|
|
1230
1471
|
element.style.visibility = 'visible';
|
|
1231
1472
|
|
|
1232
1473
|
if (widget.transitions.in) {
|
|
@@ -1235,9 +1476,86 @@ export class RendererLite {
|
|
|
1235
1476
|
element.style.opacity = '1';
|
|
1236
1477
|
}
|
|
1237
1478
|
|
|
1479
|
+
// Start audio overlays attached to this widget
|
|
1480
|
+
this._startAudioOverlays(widget);
|
|
1481
|
+
|
|
1238
1482
|
return widget;
|
|
1239
1483
|
}
|
|
1240
1484
|
|
|
1485
|
+
/**
|
|
1486
|
+
* Start audio overlay elements for a widget.
|
|
1487
|
+
* Audio overlays are <audio> child nodes in the XLF that play alongside
|
|
1488
|
+
* the visual widget (e.g. background music for an image slideshow).
|
|
1489
|
+
* @param {Object} widget - Widget config with audioNodes array
|
|
1490
|
+
*/
|
|
1491
|
+
_startAudioOverlays(widget) {
|
|
1492
|
+
if (!widget.audioNodes || widget.audioNodes.length === 0) return;
|
|
1493
|
+
|
|
1494
|
+
// Stop any existing audio overlays for this widget first
|
|
1495
|
+
this._stopAudioOverlays(widget.id);
|
|
1496
|
+
|
|
1497
|
+
const audioElements = [];
|
|
1498
|
+
for (const audioNode of widget.audioNodes) {
|
|
1499
|
+
if (!audioNode.uri) continue;
|
|
1500
|
+
|
|
1501
|
+
const audio = document.createElement('audio');
|
|
1502
|
+
audio.autoplay = true;
|
|
1503
|
+
audio.loop = audioNode.loop;
|
|
1504
|
+
audio.volume = Math.max(0, Math.min(1, audioNode.volume / 100));
|
|
1505
|
+
|
|
1506
|
+
// Resolve audio URI via cache/proxy
|
|
1507
|
+
const mediaId = parseInt(audioNode.mediaId);
|
|
1508
|
+
let audioSrc = mediaId ? this.mediaUrlCache.get(mediaId) : null;
|
|
1509
|
+
|
|
1510
|
+
if (!audioSrc && mediaId && this.options.getMediaUrl) {
|
|
1511
|
+
// Async — fire and forget, set src when ready
|
|
1512
|
+
this.options.getMediaUrl(mediaId).then(url => {
|
|
1513
|
+
audio.src = url;
|
|
1514
|
+
}).catch(() => {
|
|
1515
|
+
audio.src = `${window.location.origin}/player/cache/media/${audioNode.uri}`;
|
|
1516
|
+
});
|
|
1517
|
+
} else if (!audioSrc) {
|
|
1518
|
+
audio.src = `${window.location.origin}/player/cache/media/${audioNode.uri}`;
|
|
1519
|
+
} else {
|
|
1520
|
+
audio.src = audioSrc;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Append to DOM to prevent garbage collection in some browsers
|
|
1524
|
+
audio.style.display = 'none';
|
|
1525
|
+
this.container.appendChild(audio);
|
|
1526
|
+
|
|
1527
|
+
// Handle autoplay restrictions gracefully (play() may return undefined in some envs)
|
|
1528
|
+
const playPromise = audio.play();
|
|
1529
|
+
if (playPromise && playPromise.catch) playPromise.catch(() => {});
|
|
1530
|
+
|
|
1531
|
+
audioElements.push(audio);
|
|
1532
|
+
this.log.info(`Audio overlay started for widget ${widget.id}: ${audioNode.uri} (loop=${audioNode.loop}, vol=${audioNode.volume})`);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (audioElements.length > 0) {
|
|
1536
|
+
this.audioOverlays.set(widget.id, audioElements);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Stop and clean up audio overlay elements for a widget.
|
|
1542
|
+
* @param {string} widgetId - Widget ID
|
|
1543
|
+
*/
|
|
1544
|
+
_stopAudioOverlays(widgetId) {
|
|
1545
|
+
const audioElements = this.audioOverlays.get(widgetId);
|
|
1546
|
+
if (!audioElements) return;
|
|
1547
|
+
|
|
1548
|
+
for (const audio of audioElements) {
|
|
1549
|
+
audio.pause();
|
|
1550
|
+
audio.removeAttribute('src');
|
|
1551
|
+
audio.load(); // Release resources
|
|
1552
|
+
if (audio.parentNode) audio.parentNode.removeChild(audio); // Remove from DOM
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
this.audioOverlays.delete(widgetId);
|
|
1556
|
+
this.log.info(`Audio overlays stopped for widget ${widgetId}`);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1241
1559
|
/**
|
|
1242
1560
|
* Core: hide a widget in a region (shared by main layout + overlay).
|
|
1243
1561
|
* Returns { widget, animPromise } synchronously — callers await animPromise if needed.
|
|
@@ -1263,12 +1581,125 @@ export class RendererLite {
|
|
|
1263
1581
|
const videoEl = widgetElement.querySelector('video');
|
|
1264
1582
|
if (videoEl && widget.options.loop !== '1') videoEl.pause();
|
|
1265
1583
|
|
|
1584
|
+
// Stop MediaStream tracks (webcam/mic) to release the device
|
|
1585
|
+
if (videoEl?._mediaStream) {
|
|
1586
|
+
videoEl._mediaStream.getTracks().forEach(t => t.stop());
|
|
1587
|
+
videoEl._mediaStream = null;
|
|
1588
|
+
videoEl.srcObject = null;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1266
1591
|
const audioEl = widgetElement.querySelector('audio');
|
|
1267
1592
|
if (audioEl && widget.options.loop !== '1') audioEl.pause();
|
|
1268
1593
|
|
|
1594
|
+
// Stop audio overlays attached to this widget
|
|
1595
|
+
this._stopAudioOverlays(widget.id);
|
|
1596
|
+
|
|
1269
1597
|
return { widget, animPromise };
|
|
1270
1598
|
}
|
|
1271
1599
|
|
|
1600
|
+
/**
|
|
1601
|
+
* Check if a widget is within its valid time window (fromDt/toDt).
|
|
1602
|
+
* Widgets without dates are always active.
|
|
1603
|
+
* @param {Object} widget - Widget config with optional fromDt/toDt
|
|
1604
|
+
* @returns {boolean}
|
|
1605
|
+
*/
|
|
1606
|
+
_isWidgetActive(widget) {
|
|
1607
|
+
const now = new Date();
|
|
1608
|
+
if (widget.fromDt) {
|
|
1609
|
+
const from = new Date(widget.fromDt);
|
|
1610
|
+
if (now < from) return false;
|
|
1611
|
+
}
|
|
1612
|
+
if (widget.toDt) {
|
|
1613
|
+
const to = new Date(widget.toDt);
|
|
1614
|
+
if (now > to) return false;
|
|
1615
|
+
}
|
|
1616
|
+
return true;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Parse NUMITEMS and DURATION HTML comments from GetResource responses.
|
|
1621
|
+
* CMS embeds these in widget HTML to override duration for dynamic content
|
|
1622
|
+
* (e.g. DataSet tickers, RSS feeds). Format: <!-- NUMITEMS=5 --> <!-- DURATION=30 -->
|
|
1623
|
+
* DURATION takes precedence; otherwise NUMITEMS × widget.duration is used.
|
|
1624
|
+
* @param {string} html - Widget HTML content
|
|
1625
|
+
* @param {Object} widget - Widget config (duration may be updated)
|
|
1626
|
+
*/
|
|
1627
|
+
_parseDurationComments(html, widget) {
|
|
1628
|
+
const durationMatch = html.match(/<!--\s*DURATION=(\d+)\s*-->/);
|
|
1629
|
+
if (durationMatch) {
|
|
1630
|
+
const newDuration = parseInt(durationMatch[1], 10);
|
|
1631
|
+
if (newDuration > 0) {
|
|
1632
|
+
this.log.info(`Widget ${widget.id}: DURATION comment overrides duration ${widget.duration}→${newDuration}s`);
|
|
1633
|
+
widget.duration = newDuration;
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const numItemsMatch = html.match(/<!--\s*NUMITEMS=(\d+)\s*-->/);
|
|
1639
|
+
if (numItemsMatch) {
|
|
1640
|
+
const numItems = parseInt(numItemsMatch[1], 10);
|
|
1641
|
+
if (numItems > 0 && widget.duration > 0) {
|
|
1642
|
+
const newDuration = numItems * widget.duration;
|
|
1643
|
+
this.log.info(`Widget ${widget.id}: NUMITEMS=${numItems} × ${widget.duration}s = ${newDuration}s`);
|
|
1644
|
+
widget.duration = newDuration;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Apply sub-playlist cycle playback filtering.
|
|
1651
|
+
* Groups widgets by parentWidgetId, then selects one widget per group for this cycle.
|
|
1652
|
+
* Non-grouped widgets pass through unchanged.
|
|
1653
|
+
*
|
|
1654
|
+
* @param {Array} widgets - All widgets in the region
|
|
1655
|
+
* @returns {Array} Filtered widgets for this playback cycle
|
|
1656
|
+
*/
|
|
1657
|
+
_applyCyclePlayback(widgets) {
|
|
1658
|
+
// Track cycle indices per group for deterministic round-robin
|
|
1659
|
+
if (!this._subPlaylistCycleIndex) {
|
|
1660
|
+
this._subPlaylistCycleIndex = new Map();
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Group widgets by parentWidgetId
|
|
1664
|
+
const groups = new Map(); // parentWidgetId → [widgets]
|
|
1665
|
+
const result = [];
|
|
1666
|
+
|
|
1667
|
+
for (const widget of widgets) {
|
|
1668
|
+
if (widget.parentWidgetId && widget.cyclePlayback) {
|
|
1669
|
+
if (!groups.has(widget.parentWidgetId)) {
|
|
1670
|
+
groups.set(widget.parentWidgetId, []);
|
|
1671
|
+
}
|
|
1672
|
+
groups.get(widget.parentWidgetId).push(widget);
|
|
1673
|
+
} else {
|
|
1674
|
+
// Non-grouped widget: add a placeholder to preserve order
|
|
1675
|
+
result.push({ type: 'direct', widget });
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// For each group, select one widget for this cycle
|
|
1680
|
+
for (const [groupId, groupWidgets] of groups) {
|
|
1681
|
+
// Sort by displayOrder
|
|
1682
|
+
groupWidgets.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
1683
|
+
|
|
1684
|
+
let selectedWidget;
|
|
1685
|
+
if (groupWidgets.some(w => w.isRandom)) {
|
|
1686
|
+
// Random selection
|
|
1687
|
+
const idx = Math.floor(Math.random() * groupWidgets.length);
|
|
1688
|
+
selectedWidget = groupWidgets[idx];
|
|
1689
|
+
} else {
|
|
1690
|
+
// Round-robin based on cycle index
|
|
1691
|
+
const cycleIdx = this._subPlaylistCycleIndex.get(groupId) || 0;
|
|
1692
|
+
selectedWidget = groupWidgets[cycleIdx % groupWidgets.length];
|
|
1693
|
+
this._subPlaylistCycleIndex.set(groupId, cycleIdx + 1);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
this.log.info(`Sub-playlist cycle: group ${groupId} selected widget ${selectedWidget.id} (${groupWidgets.length} in group)`);
|
|
1697
|
+
result.push({ type: 'direct', widget: selectedWidget });
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
return result.map(r => r.widget);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1272
1703
|
/**
|
|
1273
1704
|
* Core: cycle through widgets in a region (shared by main layout + overlay)
|
|
1274
1705
|
* @param {Object} region - Region state object
|
|
@@ -1280,6 +1711,7 @@ export class RendererLite {
|
|
|
1280
1711
|
_startRegionCycle(region, regionId, showFn, hideFn, onCycleComplete) {
|
|
1281
1712
|
if (!region || region.widgets.length === 0) return;
|
|
1282
1713
|
|
|
1714
|
+
// Non-looping region with a single widget: show it and stay (spec: loop=0)
|
|
1283
1715
|
if (region.widgets.length === 1) {
|
|
1284
1716
|
showFn(regionId, 0);
|
|
1285
1717
|
return;
|
|
@@ -1293,22 +1725,47 @@ export class RendererLite {
|
|
|
1293
1725
|
|
|
1294
1726
|
const duration = widget.duration * 1000;
|
|
1295
1727
|
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();
|
|
1728
|
+
this._handleWidgetCycleEnd(widget, region, regionId, widgetIndex, showFn, hideFn, onCycleComplete, playNext);
|
|
1306
1729
|
}, duration);
|
|
1307
1730
|
};
|
|
1308
1731
|
|
|
1309
1732
|
playNext();
|
|
1310
1733
|
}
|
|
1311
1734
|
|
|
1735
|
+
/**
|
|
1736
|
+
* Handle widget cycle end — shared logic for timer-based and event-based cycling
|
|
1737
|
+
*/
|
|
1738
|
+
_handleWidgetCycleEnd(widget, region, regionId, widgetIndex, showFn, hideFn, onCycleComplete, playNext) {
|
|
1739
|
+
// Emit widgetAction if widget has a webhook URL configured
|
|
1740
|
+
if (widget.webhookUrl) {
|
|
1741
|
+
this.emit('widgetAction', {
|
|
1742
|
+
type: 'durationEnd',
|
|
1743
|
+
widgetId: widget.id,
|
|
1744
|
+
layoutId: this.currentLayoutId,
|
|
1745
|
+
regionId,
|
|
1746
|
+
url: widget.webhookUrl
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
hideFn(regionId, widgetIndex);
|
|
1751
|
+
|
|
1752
|
+
const nextIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
1753
|
+
if (nextIndex === 0 && !region.complete) {
|
|
1754
|
+
region.complete = true;
|
|
1755
|
+
onCycleComplete?.();
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Non-looping region (loop=0): stop after one full cycle
|
|
1759
|
+
if (nextIndex === 0 && region.config?.loop === false) {
|
|
1760
|
+
// Show the last widget again and keep it visible
|
|
1761
|
+
showFn(regionId, region.widgets.length - 1);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
region.currentIndex = nextIndex;
|
|
1766
|
+
playNext();
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1312
1769
|
async renderWidget(regionId, widgetIndex) {
|
|
1313
1770
|
const region = this.regions.get(regionId);
|
|
1314
1771
|
if (!region) return;
|
|
@@ -1323,6 +1780,19 @@ export class RendererLite {
|
|
|
1323
1780
|
type: widget.type, duration: widget.duration,
|
|
1324
1781
|
enableStat: widget.enableStat
|
|
1325
1782
|
});
|
|
1783
|
+
|
|
1784
|
+
// Execute commands attached to this widget (shell/native commands)
|
|
1785
|
+
if (widget.commands && widget.commands.length > 0) {
|
|
1786
|
+
for (const cmd of widget.commands) {
|
|
1787
|
+
this.emit('widgetCommand', {
|
|
1788
|
+
commandCode: cmd.commandCode,
|
|
1789
|
+
commandString: cmd.commandString,
|
|
1790
|
+
widgetId: widget.id,
|
|
1791
|
+
regionId,
|
|
1792
|
+
layoutId: this.currentLayoutId
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1326
1796
|
}
|
|
1327
1797
|
} catch (error) {
|
|
1328
1798
|
this.log.error(`Error rendering widget:`, error);
|
|
@@ -1359,7 +1829,22 @@ export class RendererLite {
|
|
|
1359
1829
|
img.className = 'renderer-lite-widget';
|
|
1360
1830
|
img.style.width = '100%';
|
|
1361
1831
|
img.style.height = '100%';
|
|
1362
|
-
|
|
1832
|
+
// Scale type mapping (CMS image.xml):
|
|
1833
|
+
// center (default) → contain: scale proportionally to fit region, centered
|
|
1834
|
+
// stretch → fill: ignore aspect ratio, fill entire region
|
|
1835
|
+
// fit → cover: scale proportionally to fill region, crop excess
|
|
1836
|
+
const scaleType = widget.options.scaleType;
|
|
1837
|
+
const fitMap = { stretch: 'fill', center: 'contain', fit: 'cover' };
|
|
1838
|
+
img.style.objectFit = fitMap[scaleType] || 'contain';
|
|
1839
|
+
|
|
1840
|
+
// Alignment: map alignId/valignId to CSS object-position
|
|
1841
|
+
// XLF tags are <alignId> and <valignId> (from CMS image.xml property ids)
|
|
1842
|
+
const alignMap = { left: 'left', center: 'center', right: 'right' };
|
|
1843
|
+
const valignMap = { top: 'top', middle: 'center', bottom: 'bottom' };
|
|
1844
|
+
const hPos = alignMap[widget.options.alignId] || 'center';
|
|
1845
|
+
const vPos = valignMap[widget.options.valignId] || 'center';
|
|
1846
|
+
img.style.objectPosition = `${hPos} ${vPos}`;
|
|
1847
|
+
|
|
1363
1848
|
img.style.opacity = '0';
|
|
1364
1849
|
|
|
1365
1850
|
// Get media URL from cache (already pre-fetched!) or fetch on-demand
|
|
@@ -1384,7 +1869,9 @@ export class RendererLite {
|
|
|
1384
1869
|
video.className = 'renderer-lite-widget';
|
|
1385
1870
|
video.style.width = '100%';
|
|
1386
1871
|
video.style.height = '100%';
|
|
1387
|
-
|
|
1872
|
+
const vScaleType = widget.options.scaleType;
|
|
1873
|
+
const vFitMap = { stretch: 'fill', center: 'none', fit: 'contain' };
|
|
1874
|
+
video.style.objectFit = vFitMap[vScaleType] || 'contain';
|
|
1388
1875
|
video.style.opacity = '1'; // Immediately visible
|
|
1389
1876
|
video.autoplay = true;
|
|
1390
1877
|
video.preload = 'auto'; // Eagerly buffer - chunks are pre-warmed in SW BlobCache
|
|
@@ -1495,6 +1982,65 @@ export class RendererLite {
|
|
|
1495
1982
|
return video;
|
|
1496
1983
|
}
|
|
1497
1984
|
|
|
1985
|
+
/**
|
|
1986
|
+
* Render videoin (webcam/microphone) widget.
|
|
1987
|
+
* Uses getUserMedia() to capture live video from camera hardware.
|
|
1988
|
+
* @param {Object} widget - Widget config with options (sourceId, showFullScreen, mirror, mute, captureAudio)
|
|
1989
|
+
* @param {Object} region - Region dimensions (width, height)
|
|
1990
|
+
* @returns {HTMLVideoElement}
|
|
1991
|
+
*/
|
|
1992
|
+
async renderVideoIn(widget, region) {
|
|
1993
|
+
const video = document.createElement('video');
|
|
1994
|
+
video.className = 'renderer-lite-widget';
|
|
1995
|
+
video.style.width = '100%';
|
|
1996
|
+
video.style.height = '100%';
|
|
1997
|
+
video.style.objectFit = widget.options.showFullScreen === '1' ? 'cover' : 'contain';
|
|
1998
|
+
video.autoplay = true;
|
|
1999
|
+
video.playsInline = true;
|
|
2000
|
+
video.controls = false;
|
|
2001
|
+
video.muted = widget.options.mute !== '0'; // Muted by default to prevent audio feedback
|
|
2002
|
+
|
|
2003
|
+
// Mirror mode (front-facing camera)
|
|
2004
|
+
if (widget.options.mirror === '1') {
|
|
2005
|
+
video.style.transform = 'scaleX(-1)';
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Build getUserMedia constraints
|
|
2009
|
+
const videoConstraints = {
|
|
2010
|
+
width: { ideal: region.width },
|
|
2011
|
+
height: { ideal: region.height },
|
|
2012
|
+
};
|
|
2013
|
+
const deviceId = widget.options.sourceId || widget.options.deviceId;
|
|
2014
|
+
if (deviceId) {
|
|
2015
|
+
videoConstraints.deviceId = { exact: deviceId };
|
|
2016
|
+
} else {
|
|
2017
|
+
videoConstraints.facingMode = widget.options.facingMode || 'environment';
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
const constraints = {
|
|
2021
|
+
video: videoConstraints,
|
|
2022
|
+
audio: widget.options.captureAudio === '1',
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
// Store constraints for re-acquisition after layout transitions
|
|
2026
|
+
video._mediaConstraints = constraints;
|
|
2027
|
+
|
|
2028
|
+
try {
|
|
2029
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
2030
|
+
video.srcObject = stream;
|
|
2031
|
+
video._mediaStream = stream;
|
|
2032
|
+
this.log.info(`Webcam stream acquired for widget ${widget.id} (tracks: ${stream.getTracks().length})`);
|
|
2033
|
+
} catch (e) {
|
|
2034
|
+
this.log.warn(`getUserMedia failed for widget ${widget.id}: ${e.message}`);
|
|
2035
|
+
return this._renderUnsupportedPlaceholder(
|
|
2036
|
+
{ ...widget, type: 'Camera unavailable' },
|
|
2037
|
+
region
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
return video;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
1498
2044
|
/**
|
|
1499
2045
|
* Render audio widget
|
|
1500
2046
|
*/
|
|
@@ -1528,6 +2074,34 @@ export class RendererLite {
|
|
|
1528
2074
|
|
|
1529
2075
|
audio.src = audioSrc;
|
|
1530
2076
|
|
|
2077
|
+
// Handle audio end - similar to video ended handling
|
|
2078
|
+
audio.addEventListener('ended', () => {
|
|
2079
|
+
if (widget.options.loop === '1') {
|
|
2080
|
+
audio.currentTime = 0;
|
|
2081
|
+
this.log.info(`Audio ${fileId} ended - reset to start, waiting for widget cycle to replay`);
|
|
2082
|
+
} else {
|
|
2083
|
+
this.log.info(`Audio ${fileId} ended - playback complete`);
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
// Detect audio duration for dynamic layout timing (when useDuration=0)
|
|
2088
|
+
audio.addEventListener('loadedmetadata', () => {
|
|
2089
|
+
const audioDuration = Math.floor(audio.duration);
|
|
2090
|
+
this.log.info(`Audio ${fileId} duration detected: ${audioDuration}s`);
|
|
2091
|
+
|
|
2092
|
+
if (widget.duration === 0 || widget.useDuration === 0) {
|
|
2093
|
+
widget.duration = audioDuration;
|
|
2094
|
+
this.log.info(`Updated widget ${widget.id} duration to ${audioDuration}s (useDuration=0)`);
|
|
2095
|
+
this.updateLayoutDuration();
|
|
2096
|
+
}
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
// Handle audio errors
|
|
2100
|
+
audio.addEventListener('error', () => {
|
|
2101
|
+
const error = audio.error;
|
|
2102
|
+
this.log.warn(`Audio error (non-fatal): ${fileId}, code: ${error?.code}, message: ${error?.message || 'Unknown'}`);
|
|
2103
|
+
});
|
|
2104
|
+
|
|
1531
2105
|
// Visual feedback
|
|
1532
2106
|
const icon = document.createElement('div');
|
|
1533
2107
|
icon.innerHTML = '♪';
|
|
@@ -1581,7 +2155,7 @@ export class RendererLite {
|
|
|
1581
2155
|
try {
|
|
1582
2156
|
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
1583
2157
|
if (!iframe.contentDocument?.querySelector('base')) {
|
|
1584
|
-
|
|
2158
|
+
self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
|
|
1585
2159
|
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
1586
2160
|
const blobUrl = URL.createObjectURL(blob);
|
|
1587
2161
|
self.trackBlobUrl(blobUrl);
|
|
@@ -1591,11 +2165,21 @@ export class RendererLite {
|
|
|
1591
2165
|
}, { once: true });
|
|
1592
2166
|
}
|
|
1593
2167
|
|
|
2168
|
+
// Parse NUMITEMS/DURATION from fallback HTML (cache path)
|
|
2169
|
+
if (result.fallback) {
|
|
2170
|
+
this._parseDurationComments(result.fallback, widget);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1594
2173
|
return iframe;
|
|
1595
2174
|
}
|
|
1596
2175
|
html = result;
|
|
1597
2176
|
}
|
|
1598
2177
|
|
|
2178
|
+
if (html) {
|
|
2179
|
+
// Parse NUMITEMS/DURATION HTML comments for dynamic widget duration
|
|
2180
|
+
this._parseDurationComments(html, widget);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
1599
2183
|
// Fallback: Create blob URL for iframe
|
|
1600
2184
|
const blob = new Blob([html], { type: 'text/html' });
|
|
1601
2185
|
const blobUrl = URL.createObjectURL(blob);
|
|
@@ -1624,7 +2208,9 @@ export class RendererLite {
|
|
|
1624
2208
|
try {
|
|
1625
2209
|
const pdfjsModule = await import('pdfjs-dist');
|
|
1626
2210
|
window.pdfjsLib = pdfjsModule;
|
|
1627
|
-
|
|
2211
|
+
// Derive worker path from current page location (works for /player/pwa/ and /player/)
|
|
2212
|
+
const basePath = window.location.pathname.replace(/\/[^/]*$/, '/');
|
|
2213
|
+
window.pdfjsLib.GlobalWorkerOptions.workerSrc = `${window.location.origin}${basePath}pdf.worker.min.mjs`;
|
|
1628
2214
|
} catch (error) {
|
|
1629
2215
|
this.log.error('PDF.js not available:', error);
|
|
1630
2216
|
container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">PDF viewer unavailable</div>';
|
|
@@ -1680,6 +2266,13 @@ export class RendererLite {
|
|
|
1680
2266
|
* Render webpage widget
|
|
1681
2267
|
*/
|
|
1682
2268
|
async renderWebpage(widget, region) {
|
|
2269
|
+
// modeId=1 (or absent) = Open Natively (direct URL), modeId=0 = Manual/GetResource
|
|
2270
|
+
const modeId = parseInt(widget.options.modeId || '1');
|
|
2271
|
+
if (modeId === 0) {
|
|
2272
|
+
// GetResource mode: treat like a generic widget (fetch HTML from CMS)
|
|
2273
|
+
return await this.renderGenericWidget(widget, region);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
1683
2276
|
const iframe = document.createElement('iframe');
|
|
1684
2277
|
iframe.className = 'renderer-lite-widget';
|
|
1685
2278
|
iframe.style.width = '100%';
|
|
@@ -1718,7 +2311,7 @@ export class RendererLite {
|
|
|
1718
2311
|
try {
|
|
1719
2312
|
// Our cached widget HTML has a <base> tag; server 404 page doesn't
|
|
1720
2313
|
if (!iframe.contentDocument?.querySelector('base')) {
|
|
1721
|
-
|
|
2314
|
+
self.log.warn('Cache URL failed (hard reload?), using original CMS URLs');
|
|
1722
2315
|
const blob = new Blob([result.fallback], { type: 'text/html' });
|
|
1723
2316
|
const blobUrl = URL.createObjectURL(blob);
|
|
1724
2317
|
self.trackBlobUrl(blobUrl);
|
|
@@ -1728,12 +2321,21 @@ export class RendererLite {
|
|
|
1728
2321
|
}, { once: true });
|
|
1729
2322
|
}
|
|
1730
2323
|
|
|
2324
|
+
// Parse NUMITEMS/DURATION from fallback HTML (cache path)
|
|
2325
|
+
if (result.fallback) {
|
|
2326
|
+
this._parseDurationComments(result.fallback, widget);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
1731
2329
|
return iframe;
|
|
1732
2330
|
}
|
|
1733
2331
|
html = result;
|
|
1734
2332
|
}
|
|
1735
2333
|
|
|
1736
2334
|
if (html) {
|
|
2335
|
+
// Parse NUMITEMS/DURATION HTML comments for dynamic widget duration
|
|
2336
|
+
// Format: <!-- NUMITEMS=5 --> and <!-- DURATION=30 -->
|
|
2337
|
+
this._parseDurationComments(html, widget);
|
|
2338
|
+
|
|
1737
2339
|
const blob = new Blob([html], { type: 'text/html' });
|
|
1738
2340
|
const blobUrl = URL.createObjectURL(blob);
|
|
1739
2341
|
iframe.src = blobUrl;
|
|
@@ -1748,6 +2350,24 @@ export class RendererLite {
|
|
|
1748
2350
|
return iframe;
|
|
1749
2351
|
}
|
|
1750
2352
|
|
|
2353
|
+
/**
|
|
2354
|
+
* Render a placeholder for unsupported widget types (powerpoint, flash)
|
|
2355
|
+
*/
|
|
2356
|
+
_renderUnsupportedPlaceholder(widget, region) {
|
|
2357
|
+
const div = document.createElement('div');
|
|
2358
|
+
div.className = 'renderer-lite-widget';
|
|
2359
|
+
div.style.width = '100%';
|
|
2360
|
+
div.style.height = '100%';
|
|
2361
|
+
div.style.display = 'flex';
|
|
2362
|
+
div.style.alignItems = 'center';
|
|
2363
|
+
div.style.justifyContent = 'center';
|
|
2364
|
+
div.style.backgroundColor = '#111';
|
|
2365
|
+
div.style.color = '#666';
|
|
2366
|
+
div.style.fontSize = '14px';
|
|
2367
|
+
div.textContent = `Unsupported: ${widget.type}`;
|
|
2368
|
+
return div;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
1751
2371
|
// ── Layout Preload Pool ─────────────────────────────────────────────
|
|
1752
2372
|
|
|
1753
2373
|
/**
|
|
@@ -1934,6 +2554,11 @@ export class RendererLite {
|
|
|
1934
2554
|
|
|
1935
2555
|
try {
|
|
1936
2556
|
const element = await this.createWidgetElement(widget, region);
|
|
2557
|
+
element.style.position = 'absolute';
|
|
2558
|
+
element.style.top = '0';
|
|
2559
|
+
element.style.left = '0';
|
|
2560
|
+
element.style.width = '100%';
|
|
2561
|
+
element.style.height = '100%';
|
|
1937
2562
|
element.style.visibility = 'hidden';
|
|
1938
2563
|
element.style.opacity = '0';
|
|
1939
2564
|
region.element.appendChild(element);
|
|
@@ -2030,7 +2655,21 @@ export class RendererLite {
|
|
|
2030
2655
|
v.removeAttribute('src');
|
|
2031
2656
|
v.load();
|
|
2032
2657
|
});
|
|
2033
|
-
region
|
|
2658
|
+
// Apply region exit transition if configured, then remove
|
|
2659
|
+
if (region.config && region.config.exitTransition) {
|
|
2660
|
+
const animation = Transitions.apply(
|
|
2661
|
+
region.element, region.config.exitTransition, false,
|
|
2662
|
+
region.width, region.height
|
|
2663
|
+
);
|
|
2664
|
+
if (animation) {
|
|
2665
|
+
const el = region.element;
|
|
2666
|
+
animation.onfinish = () => el.remove();
|
|
2667
|
+
} else {
|
|
2668
|
+
region.element.remove();
|
|
2669
|
+
}
|
|
2670
|
+
} else {
|
|
2671
|
+
region.element.remove();
|
|
2672
|
+
}
|
|
2034
2673
|
}
|
|
2035
2674
|
// Revoke blob URLs
|
|
2036
2675
|
if (oldLayoutId) {
|
|
@@ -2178,8 +2817,22 @@ export class RendererLite {
|
|
|
2178
2817
|
this.stopWidget(regionId, region.currentIndex);
|
|
2179
2818
|
}
|
|
2180
2819
|
|
|
2181
|
-
//
|
|
2182
|
-
region.
|
|
2820
|
+
// Apply region exit transition if configured, then remove
|
|
2821
|
+
if (region.config && region.config.exitTransition) {
|
|
2822
|
+
const animation = Transitions.apply(
|
|
2823
|
+
region.element, region.config.exitTransition, false,
|
|
2824
|
+
region.width, region.height
|
|
2825
|
+
);
|
|
2826
|
+
if (animation) {
|
|
2827
|
+
// Remove element after exit transition completes
|
|
2828
|
+
const el = region.element;
|
|
2829
|
+
animation.onfinish = () => el.remove();
|
|
2830
|
+
} else {
|
|
2831
|
+
region.element.remove();
|
|
2832
|
+
}
|
|
2833
|
+
} else {
|
|
2834
|
+
region.element.remove();
|
|
2835
|
+
}
|
|
2183
2836
|
}
|
|
2184
2837
|
|
|
2185
2838
|
// Revoke media blob URLs from cache
|
|
@@ -2528,6 +3181,13 @@ export class RendererLite {
|
|
|
2528
3181
|
this.log.info('Playback paused');
|
|
2529
3182
|
}
|
|
2530
3183
|
|
|
3184
|
+
/**
|
|
3185
|
+
* Check if playback is currently paused.
|
|
3186
|
+
*/
|
|
3187
|
+
isPaused() {
|
|
3188
|
+
return this._paused;
|
|
3189
|
+
}
|
|
3190
|
+
|
|
2531
3191
|
/**
|
|
2532
3192
|
* Resume playback: restart layout timer with remaining time, resume media and widget cycling.
|
|
2533
3193
|
*/
|
|
@@ -2578,6 +3238,11 @@ export class RendererLite {
|
|
|
2578
3238
|
this.stopAllOverlays();
|
|
2579
3239
|
this.stopCurrentLayout();
|
|
2580
3240
|
|
|
3241
|
+
// Clean up any remaining audio overlays
|
|
3242
|
+
for (const widgetId of this.audioOverlays.keys()) {
|
|
3243
|
+
this._stopAudioOverlays(widgetId);
|
|
3244
|
+
}
|
|
3245
|
+
|
|
2581
3246
|
// Clear the layout preload pool
|
|
2582
3247
|
this.layoutPool.clear();
|
|
2583
3248
|
|