@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.
@@ -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
- layoutCode: actionEl.getAttribute('layoutCode') || '',
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
- for (const regionEl of doc.querySelectorAll('region')) {
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 media/widgets
405
- for (const mediaEl of regionEl.querySelectorAll('media')) {
406
- const widget = this.parseWidget(mediaEl);
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
- this.startRegion(regionId);
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: regionConfig.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
- hideFn(regionId, widgetIndex);
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
- img.style.objectFit = 'contain';
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
- video.style.objectFit = 'contain';
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
- console.warn('[RendererLite] Cache URL failed (hard reload?), using original CMS URLs');
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
- console.warn('[RendererLite] Cache URL failed (hard reload?), using original CMS URLs');
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.element.remove();
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
- // Remove region element
2182
- region.element.remove();
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