@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.
@@ -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
 
@@ -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: regionConfig.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
- 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();
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
- img.style.objectFit = 'contain';
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
- video.style.objectFit = 'contain';
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
- console.warn('[RendererLite] Cache URL failed (hard reload?), using original CMS URLs');
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
- window.pdfjsLib.GlobalWorkerOptions.workerSrc = `${window.location.origin}/player/pdf.worker.min.mjs`;
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
- console.warn('[RendererLite] Cache URL failed (hard reload?), using original CMS URLs');
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.element.remove();
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
- // Remove region element
2182
- region.element.remove();
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