@xiboplayer/renderer 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/layout.js +6 -0
- package/src/renderer-lite.ic.test.js +203 -0
- package/src/renderer-lite.js +212 -11
- package/src/renderer-lite.test.js +493 -33
- package/vitest.config.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "RendererLite - Fast, efficient XLF layout rendering engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"nanoevents": "^9.1.0",
|
|
15
15
|
"pdfjs-dist": "^4.10.38",
|
|
16
|
-
"@xiboplayer/
|
|
17
|
-
"@xiboplayer/
|
|
18
|
-
"@xiboplayer/
|
|
16
|
+
"@xiboplayer/utils": "0.6.3",
|
|
17
|
+
"@xiboplayer/cache": "0.6.3",
|
|
18
|
+
"@xiboplayer/schedule": "0.6.3"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"vitest": "^2.0.0",
|
package/src/layout.js
CHANGED
|
@@ -303,10 +303,16 @@ window.Transitions = {
|
|
|
303
303
|
const direction = transitionConfig.direction || 'N';
|
|
304
304
|
|
|
305
305
|
switch (type) {
|
|
306
|
+
case 'fade':
|
|
307
|
+
return isIn ? this.fadeIn(element, duration) : this.fadeOut(element, duration);
|
|
306
308
|
case 'fadein':
|
|
307
309
|
return isIn ? this.fadeIn(element, duration) : null;
|
|
308
310
|
case 'fadeout':
|
|
309
311
|
return isIn ? null : this.fadeOut(element, duration);
|
|
312
|
+
case 'fly':
|
|
313
|
+
return isIn
|
|
314
|
+
? this.flyIn(element, duration, direction, regionWidth, regionHeight)
|
|
315
|
+
: this.flyOut(element, duration, direction, regionWidth, regionHeight);
|
|
310
316
|
case 'flyin':
|
|
311
317
|
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
312
318
|
case 'flyout':
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RendererLite Interactive Control (XIC) Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the XIC event handlers: interactiveTrigger, widgetExpire,
|
|
5
|
+
* widgetExtendDuration, widgetSetDuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
9
|
+
import { RendererLite } from './renderer-lite.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a minimal RendererLite instance with stubbed DOM and methods.
|
|
13
|
+
*/
|
|
14
|
+
function createRenderer() {
|
|
15
|
+
const container = document.createElement('div');
|
|
16
|
+
const renderer = new RendererLite(
|
|
17
|
+
{ cmsUrl: 'http://localhost', hardwareKey: 'test' },
|
|
18
|
+
container,
|
|
19
|
+
{ logLevel: 'silent' }
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Stub methods that touch DOM or async operations
|
|
23
|
+
renderer.renderWidget = vi.fn();
|
|
24
|
+
renderer.stopWidget = vi.fn();
|
|
25
|
+
renderer.checkLayoutComplete = vi.fn();
|
|
26
|
+
renderer._startRegionCycle = vi.fn();
|
|
27
|
+
renderer.navigateToWidget = vi.fn();
|
|
28
|
+
|
|
29
|
+
return renderer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Populate renderer with a fake region containing widgets.
|
|
34
|
+
*/
|
|
35
|
+
function addRegion(renderer, regionId, widgets) {
|
|
36
|
+
renderer.regions.set(regionId, {
|
|
37
|
+
element: document.createElement('div'),
|
|
38
|
+
config: { id: regionId },
|
|
39
|
+
widgets,
|
|
40
|
+
currentIndex: 0,
|
|
41
|
+
timer: null,
|
|
42
|
+
width: 100,
|
|
43
|
+
height: 100,
|
|
44
|
+
complete: false,
|
|
45
|
+
isDrawer: false,
|
|
46
|
+
widgetElements: new Map()
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('RendererLite XIC', () => {
|
|
51
|
+
let renderer;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
renderer = createRenderer();
|
|
55
|
+
addRegion(renderer, 'region-1', [
|
|
56
|
+
{ id: 'w1', type: 'text', duration: 10, options: {} },
|
|
57
|
+
{ id: 'w2', type: 'image', duration: 20, options: {} },
|
|
58
|
+
{ id: 'w3', type: 'video', duration: 30, options: {} }
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('_findRegionByWidgetId', () => {
|
|
63
|
+
it('should find a widget in main regions', () => {
|
|
64
|
+
const result = renderer._findRegionByWidgetId('w2');
|
|
65
|
+
expect(result).not.toBeNull();
|
|
66
|
+
expect(result.regionId).toBe('region-1');
|
|
67
|
+
expect(result.widgetIndex).toBe(1);
|
|
68
|
+
expect(result.widget.id).toBe('w2');
|
|
69
|
+
expect(result.regionMap).toBe(renderer.regions);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return null for unknown widget', () => {
|
|
73
|
+
const result = renderer._findRegionByWidgetId('w-unknown');
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should find a widget in overlay regions', () => {
|
|
78
|
+
const overlayRegions = new Map();
|
|
79
|
+
overlayRegions.set('overlay-r1', {
|
|
80
|
+
element: document.createElement('div'),
|
|
81
|
+
config: { id: 'overlay-r1' },
|
|
82
|
+
widgets: [{ id: 'ow1', type: 'text', duration: 5, options: {} }],
|
|
83
|
+
currentIndex: 0,
|
|
84
|
+
timer: null,
|
|
85
|
+
width: 50,
|
|
86
|
+
height: 50,
|
|
87
|
+
complete: false,
|
|
88
|
+
isDrawer: false,
|
|
89
|
+
widgetElements: new Map()
|
|
90
|
+
});
|
|
91
|
+
renderer.activeOverlays.set(100, { regions: overlayRegions });
|
|
92
|
+
|
|
93
|
+
const result = renderer._findRegionByWidgetId('ow1');
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result.regionId).toBe('overlay-r1');
|
|
96
|
+
expect(result.widgetIndex).toBe(0);
|
|
97
|
+
expect(result.regionMap).toBe(overlayRegions);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('_handleInteractiveTrigger', () => {
|
|
102
|
+
it('should call navigateToWidget when target exists', () => {
|
|
103
|
+
renderer.emit('interactiveTrigger', { targetId: 'w2', triggerCode: 'btn1' });
|
|
104
|
+
expect(renderer.navigateToWidget).toHaveBeenCalledWith('w2');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should not call navigateToWidget when target is unknown', () => {
|
|
108
|
+
renderer.emit('interactiveTrigger', { targetId: 'w-missing', triggerCode: 'btn1' });
|
|
109
|
+
expect(renderer.navigateToWidget).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('_handleWidgetExpire', () => {
|
|
114
|
+
it('should clear timer, stop widget, and advance region', () => {
|
|
115
|
+
const region = renderer.regions.get('region-1');
|
|
116
|
+
region.timer = setTimeout(() => {}, 99999);
|
|
117
|
+
region.currentIndex = 0;
|
|
118
|
+
|
|
119
|
+
renderer.emit('widgetExpire', { widgetId: 'w1' });
|
|
120
|
+
|
|
121
|
+
expect(region.timer).toBeNull();
|
|
122
|
+
expect(renderer.stopWidget).toHaveBeenCalledWith('region-1', 0);
|
|
123
|
+
expect(renderer._startRegionCycle).toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should do nothing for unknown widget', () => {
|
|
127
|
+
renderer.emit('widgetExpire', { widgetId: 'w-missing' });
|
|
128
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('_handleWidgetExtendDuration', () => {
|
|
133
|
+
it('should clear existing timer and re-arm with extended duration', () => {
|
|
134
|
+
vi.useFakeTimers();
|
|
135
|
+
const region = renderer.regions.get('region-1');
|
|
136
|
+
region.timer = setTimeout(() => {}, 99999);
|
|
137
|
+
|
|
138
|
+
renderer.emit('widgetExtendDuration', { widgetId: 'w1', duration: 15 });
|
|
139
|
+
|
|
140
|
+
// Timer should be re-armed (not null)
|
|
141
|
+
expect(region.timer).not.toBeNull();
|
|
142
|
+
// stopWidget should NOT have been called yet (timer hasn't fired)
|
|
143
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
144
|
+
|
|
145
|
+
// Advance time to fire the new timer
|
|
146
|
+
vi.advanceTimersByTime(15000);
|
|
147
|
+
expect(renderer.stopWidget).toHaveBeenCalledWith('region-1', 0);
|
|
148
|
+
|
|
149
|
+
vi.useRealTimers();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should do nothing for unknown widget', () => {
|
|
153
|
+
renderer.emit('widgetExtendDuration', { widgetId: 'w-missing', duration: 10 });
|
|
154
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('_handleWidgetSetDuration', () => {
|
|
159
|
+
it('should clear existing timer and set absolute duration', () => {
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
const region = renderer.regions.get('region-1');
|
|
162
|
+
region.timer = setTimeout(() => {}, 99999);
|
|
163
|
+
|
|
164
|
+
renderer.emit('widgetSetDuration', { widgetId: 'w2', duration: 5 });
|
|
165
|
+
|
|
166
|
+
expect(region.timer).not.toBeNull();
|
|
167
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
168
|
+
|
|
169
|
+
vi.advanceTimersByTime(5000);
|
|
170
|
+
// w2 is at index 1, but the region's currentIndex determines what gets stopped
|
|
171
|
+
expect(renderer.stopWidget).toHaveBeenCalled();
|
|
172
|
+
|
|
173
|
+
vi.useRealTimers();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should do nothing for unknown widget', () => {
|
|
177
|
+
renderer.emit('widgetSetDuration', { widgetId: 'w-missing', duration: 10 });
|
|
178
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('_advanceRegion', () => {
|
|
183
|
+
it('should increment currentIndex and call _startRegionCycle', () => {
|
|
184
|
+
const region = renderer.regions.get('region-1');
|
|
185
|
+
region.currentIndex = 0;
|
|
186
|
+
|
|
187
|
+
renderer._advanceRegion('region-1', renderer.regions);
|
|
188
|
+
|
|
189
|
+
expect(region.currentIndex).toBe(1);
|
|
190
|
+
expect(renderer._startRegionCycle).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should wrap around at end of widget list', () => {
|
|
194
|
+
const region = renderer.regions.get('region-1');
|
|
195
|
+
region.currentIndex = 2; // last widget
|
|
196
|
+
|
|
197
|
+
renderer._advanceRegion('region-1', renderer.regions);
|
|
198
|
+
|
|
199
|
+
expect(region.currentIndex).toBe(0);
|
|
200
|
+
expect(renderer._startRegionCycle).toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/renderer-lite.js
CHANGED
|
@@ -47,7 +47,7 @@ import { LayoutPool } from './layout-pool.js';
|
|
|
47
47
|
/**
|
|
48
48
|
* Transition utilities for widget animations
|
|
49
49
|
*/
|
|
50
|
-
const Transitions = {
|
|
50
|
+
export const Transitions = {
|
|
51
51
|
/**
|
|
52
52
|
* Apply fade in transition
|
|
53
53
|
*/
|
|
@@ -162,11 +162,15 @@ const Transitions = {
|
|
|
162
162
|
|
|
163
163
|
switch (type) {
|
|
164
164
|
case 'fade':
|
|
165
|
+
return isIn ? this.fadeIn(element, duration) : this.fadeOut(element, duration);
|
|
165
166
|
case 'fadein':
|
|
166
167
|
return isIn ? this.fadeIn(element, duration) : null;
|
|
167
168
|
case 'fadeout':
|
|
168
169
|
return isIn ? null : this.fadeOut(element, duration);
|
|
169
170
|
case 'fly':
|
|
171
|
+
return isIn
|
|
172
|
+
? this.flyIn(element, duration, direction, regionWidth, regionHeight)
|
|
173
|
+
: this.flyOut(element, duration, direction, regionWidth, regionHeight);
|
|
170
174
|
case 'flyin':
|
|
171
175
|
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
172
176
|
case 'flyout':
|
|
@@ -239,6 +243,12 @@ export class RendererLite {
|
|
|
239
243
|
// Setup container styles
|
|
240
244
|
this.setupContainer();
|
|
241
245
|
|
|
246
|
+
// Interactive Control (XIC) event handlers
|
|
247
|
+
this.emitter.on('interactiveTrigger', (data) => this._handleInteractiveTrigger(data));
|
|
248
|
+
this.emitter.on('widgetExpire', (data) => this._handleWidgetExpire(data));
|
|
249
|
+
this.emitter.on('widgetExtendDuration', (data) => this._handleWidgetExtendDuration(data));
|
|
250
|
+
this.emitter.on('widgetSetDuration', (data) => this._handleWidgetSetDuration(data));
|
|
251
|
+
|
|
242
252
|
this.log.info('Initialized');
|
|
243
253
|
}
|
|
244
254
|
|
|
@@ -411,6 +421,7 @@ export class RendererLite {
|
|
|
411
421
|
const regionAndDrawerEls = layoutEl.querySelectorAll(':scope > region, :scope > drawer');
|
|
412
422
|
for (const regionEl of regionAndDrawerEls) {
|
|
413
423
|
const isDrawer = regionEl.tagName === 'drawer';
|
|
424
|
+
const regionType = regionEl.getAttribute('type') || null;
|
|
414
425
|
const region = {
|
|
415
426
|
id: regionEl.getAttribute('id'),
|
|
416
427
|
width: parseInt(regionEl.getAttribute('width') || '0'),
|
|
@@ -426,6 +437,7 @@ export class RendererLite {
|
|
|
426
437
|
transitionDirection: null,
|
|
427
438
|
loop: true, // Default: cycle widgets. Spec: loop=0 means single media stays visible
|
|
428
439
|
isDrawer,
|
|
440
|
+
isCanvas: regionType === 'canvas', // Canvas regions render all widgets simultaneously
|
|
429
441
|
widgets: []
|
|
430
442
|
};
|
|
431
443
|
|
|
@@ -468,11 +480,21 @@ export class RendererLite {
|
|
|
468
480
|
region.widgets.push(widget);
|
|
469
481
|
}
|
|
470
482
|
|
|
483
|
+
// Auto-detect canvas from CMS "global" widget (CMS bundles canvas sub-widgets
|
|
484
|
+
// into a single type="global" media element in the XLF)
|
|
485
|
+
if (!region.isCanvas && region.widgets.some(w => w.type === 'global')) {
|
|
486
|
+
region.isCanvas = true;
|
|
487
|
+
}
|
|
488
|
+
|
|
471
489
|
layout.regions.push(region);
|
|
472
490
|
|
|
473
491
|
if (isDrawer) {
|
|
474
492
|
this.log.info(`Parsed drawer: id=${region.id} with ${region.widgets.length} widgets`);
|
|
475
493
|
}
|
|
494
|
+
|
|
495
|
+
if (region.isCanvas) {
|
|
496
|
+
this.log.info(`Parsed canvas region: id=${region.id} with ${region.widgets.length} widgets (all render simultaneously)`);
|
|
497
|
+
}
|
|
476
498
|
}
|
|
477
499
|
|
|
478
500
|
// Calculate layout duration if not specified (duration=0)
|
|
@@ -858,6 +880,133 @@ export class RendererLite {
|
|
|
858
880
|
}
|
|
859
881
|
}
|
|
860
882
|
|
|
883
|
+
// ── Interactive Control (XIC) ─────────────────────────────────────
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Find a region containing a widget by widget ID.
|
|
887
|
+
* Searches main regions first, then overlay regions.
|
|
888
|
+
* @param {string} widgetId
|
|
889
|
+
* @returns {{ regionId: string, region: Object, widget: Object, widgetIndex: number, regionMap: Map }|null}
|
|
890
|
+
*/
|
|
891
|
+
_findRegionByWidgetId(widgetId) {
|
|
892
|
+
// Search main regions
|
|
893
|
+
for (const [regionId, region] of this.regions) {
|
|
894
|
+
const widgetIndex = region.widgets.findIndex(w => w.id === widgetId);
|
|
895
|
+
if (widgetIndex !== -1) {
|
|
896
|
+
return { regionId, region, widget: region.widgets[widgetIndex], widgetIndex, regionMap: this.regions };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// Search overlay regions
|
|
900
|
+
for (const overlay of this.activeOverlays.values()) {
|
|
901
|
+
if (!overlay.regions) continue;
|
|
902
|
+
for (const [regionId, region] of overlay.regions) {
|
|
903
|
+
const widgetIndex = region.widgets.findIndex(w => w.id === widgetId);
|
|
904
|
+
if (widgetIndex !== -1) {
|
|
905
|
+
return { regionId, region, widget: region.widgets[widgetIndex], widgetIndex, regionMap: overlay.regions };
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Advance a region to its next widget using the standard cycle.
|
|
914
|
+
* @param {string} regionId
|
|
915
|
+
* @param {Map} regionMap - The Map containing this region (main or overlay)
|
|
916
|
+
*/
|
|
917
|
+
_advanceRegion(regionId, regionMap) {
|
|
918
|
+
const region = regionMap.get(regionId);
|
|
919
|
+
if (!region) return;
|
|
920
|
+
region.currentIndex = (region.currentIndex + 1) % region.widgets.length;
|
|
921
|
+
const isMain = regionMap === this.regions;
|
|
922
|
+
this._startRegionCycle(
|
|
923
|
+
region, regionId,
|
|
924
|
+
isMain ? (rid, idx) => this.renderWidget(rid, idx) : (rid, idx) => this.renderWidget(rid, idx),
|
|
925
|
+
isMain ? (rid, idx) => this.stopWidget(rid, idx) : (rid, idx) => this.stopWidget(rid, idx),
|
|
926
|
+
isMain ? () => this.checkLayoutComplete() : undefined
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Handle interactiveTrigger XIC event — navigate to a target widget.
|
|
932
|
+
* @param {{ targetId: string, triggerCode: string }} data
|
|
933
|
+
*/
|
|
934
|
+
_handleInteractiveTrigger({ targetId, triggerCode }) {
|
|
935
|
+
this.log.info(`XIC interactiveTrigger: target=${targetId} code=${triggerCode}`);
|
|
936
|
+
const found = this._findRegionByWidgetId(targetId);
|
|
937
|
+
if (found) {
|
|
938
|
+
this.navigateToWidget(targetId);
|
|
939
|
+
} else {
|
|
940
|
+
this.log.warn(`XIC interactiveTrigger: widget ${targetId} not found`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Handle widgetExpire XIC event — immediately expire a widget and advance.
|
|
946
|
+
* @param {{ widgetId: string }} data
|
|
947
|
+
*/
|
|
948
|
+
_handleWidgetExpire({ widgetId }) {
|
|
949
|
+
const found = this._findRegionByWidgetId(widgetId);
|
|
950
|
+
if (!found) {
|
|
951
|
+
this.log.warn(`XIC widgetExpire: widget ${widgetId} not found`);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const { regionId, region, widgetIndex, regionMap } = found;
|
|
955
|
+
this.log.info(`XIC widgetExpire: widget=${widgetId} region=${regionId}`);
|
|
956
|
+
if (region.timer) {
|
|
957
|
+
clearTimeout(region.timer);
|
|
958
|
+
region.timer = null;
|
|
959
|
+
}
|
|
960
|
+
this.stopWidget(regionId, widgetIndex);
|
|
961
|
+
this._advanceRegion(regionId, regionMap);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Handle widgetExtendDuration XIC event — extend the current widget timer.
|
|
966
|
+
* @param {{ widgetId: string, duration: number }} data - duration in seconds (added to remaining)
|
|
967
|
+
*/
|
|
968
|
+
_handleWidgetExtendDuration({ widgetId, duration }) {
|
|
969
|
+
const found = this._findRegionByWidgetId(widgetId);
|
|
970
|
+
if (!found) {
|
|
971
|
+
this.log.warn(`XIC widgetExtendDuration: widget ${widgetId} not found`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const { regionId, region } = found;
|
|
975
|
+
this.log.info(`XIC widgetExtendDuration: widget=${widgetId} +${duration}s`);
|
|
976
|
+
if (region.timer) {
|
|
977
|
+
clearTimeout(region.timer);
|
|
978
|
+
region.timer = null;
|
|
979
|
+
}
|
|
980
|
+
// Re-arm timer with the extended duration
|
|
981
|
+
region.timer = setTimeout(() => {
|
|
982
|
+
this.stopWidget(regionId, region.currentIndex);
|
|
983
|
+
this._advanceRegion(regionId, found.regionMap);
|
|
984
|
+
}, duration * 1000);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Handle widgetSetDuration XIC event — replace the widget timer with an absolute duration.
|
|
989
|
+
* @param {{ widgetId: string, duration: number }} data - duration in seconds (absolute)
|
|
990
|
+
*/
|
|
991
|
+
_handleWidgetSetDuration({ widgetId, duration }) {
|
|
992
|
+
const found = this._findRegionByWidgetId(widgetId);
|
|
993
|
+
if (!found) {
|
|
994
|
+
this.log.warn(`XIC widgetSetDuration: widget ${widgetId} not found`);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const { regionId, region } = found;
|
|
998
|
+
this.log.info(`XIC widgetSetDuration: widget=${widgetId} ${duration}s`);
|
|
999
|
+
if (region.timer) {
|
|
1000
|
+
clearTimeout(region.timer);
|
|
1001
|
+
region.timer = null;
|
|
1002
|
+
}
|
|
1003
|
+
// Set timer with the absolute duration
|
|
1004
|
+
region.timer = setTimeout(() => {
|
|
1005
|
+
this.stopWidget(regionId, region.currentIndex);
|
|
1006
|
+
this._advanceRegion(regionId, found.regionMap);
|
|
1007
|
+
}, duration * 1000);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
861
1010
|
/**
|
|
862
1011
|
* Navigate to a specific widget within a region (for navWidget actions)
|
|
863
1012
|
*/
|
|
@@ -894,6 +1043,9 @@ export class RendererLite {
|
|
|
894
1043
|
if (region.isDrawer && nextIndex === 0) {
|
|
895
1044
|
region.element.style.display = 'none';
|
|
896
1045
|
this.log.info(`Drawer region ${regionId} hidden (cycle complete)`);
|
|
1046
|
+
} else if (region.isDrawer) {
|
|
1047
|
+
// Continue cycling through remaining drawer widgets (will hide on wrap to 0)
|
|
1048
|
+
this.navigateToWidget(region.widgets[nextIndex].id);
|
|
897
1049
|
} else {
|
|
898
1050
|
this.startRegion(regionId);
|
|
899
1051
|
}
|
|
@@ -1176,6 +1328,7 @@ export class RendererLite {
|
|
|
1176
1328
|
height: regionConfig.height * sf,
|
|
1177
1329
|
complete: false, // Track if region has played all widgets once
|
|
1178
1330
|
isDrawer: regionConfig.isDrawer || false,
|
|
1331
|
+
isCanvas: regionConfig.isCanvas || false, // Canvas regions render all widgets simultaneously
|
|
1179
1332
|
widgetElements: new Map() // widgetId -> DOM element (for element reuse)
|
|
1180
1333
|
});
|
|
1181
1334
|
}
|
|
@@ -1449,13 +1602,15 @@ export class RendererLite {
|
|
|
1449
1602
|
region.element.appendChild(element);
|
|
1450
1603
|
}
|
|
1451
1604
|
|
|
1452
|
-
// Hide all other widgets in region
|
|
1605
|
+
// Hide all other widgets in region (skip for canvas — all widgets stay visible)
|
|
1453
1606
|
// Cancel fill:forwards animations first — they override inline styles
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1607
|
+
if (!region.isCanvas) {
|
|
1608
|
+
for (const [widgetId, widgetEl] of region.widgetElements) {
|
|
1609
|
+
if (widgetId !== widget.id) {
|
|
1610
|
+
widgetEl.getAnimations?.().forEach(a => a.cancel());
|
|
1611
|
+
widgetEl.style.visibility = 'hidden';
|
|
1612
|
+
widgetEl.style.opacity = '0';
|
|
1613
|
+
}
|
|
1459
1614
|
}
|
|
1460
1615
|
}
|
|
1461
1616
|
|
|
@@ -1698,10 +1853,17 @@ export class RendererLite {
|
|
|
1698
1853
|
const idx = Math.floor(Math.random() * groupWidgets.length);
|
|
1699
1854
|
selectedWidget = groupWidgets[idx];
|
|
1700
1855
|
} else {
|
|
1701
|
-
// Round-robin based on cycle index
|
|
1702
|
-
const
|
|
1703
|
-
selectedWidget = groupWidgets[
|
|
1704
|
-
|
|
1856
|
+
// Round-robin based on cycle index, respecting playCount
|
|
1857
|
+
const state = this._subPlaylistCycleIndex.get(groupId) || { widgetIdx: 0, playsDone: 0 };
|
|
1858
|
+
selectedWidget = groupWidgets[state.widgetIdx % groupWidgets.length];
|
|
1859
|
+
const effectivePlayCount = selectedWidget.playCount || 1;
|
|
1860
|
+
|
|
1861
|
+
state.playsDone++;
|
|
1862
|
+
if (state.playsDone >= effectivePlayCount) {
|
|
1863
|
+
state.widgetIdx++;
|
|
1864
|
+
state.playsDone = 0;
|
|
1865
|
+
}
|
|
1866
|
+
this._subPlaylistCycleIndex.set(groupId, state);
|
|
1705
1867
|
}
|
|
1706
1868
|
|
|
1707
1869
|
this.log.info(`Sub-playlist cycle: group ${groupId} selected widget ${selectedWidget.id} (${groupWidgets.length} in group)`);
|
|
@@ -1722,6 +1884,13 @@ export class RendererLite {
|
|
|
1722
1884
|
_startRegionCycle(region, regionId, showFn, hideFn, onCycleComplete) {
|
|
1723
1885
|
if (!region || region.widgets.length === 0) return;
|
|
1724
1886
|
|
|
1887
|
+
// Canvas regions: render ALL widgets simultaneously (stacked), no cycling.
|
|
1888
|
+
// Duration = max widget duration; region completes when the longest widget expires.
|
|
1889
|
+
if (region.isCanvas) {
|
|
1890
|
+
this._startCanvasRegion(region, regionId, showFn, onCycleComplete);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1725
1894
|
// Non-looping region with a single widget: show it and stay (spec: loop=0)
|
|
1726
1895
|
if (region.widgets.length === 1) {
|
|
1727
1896
|
showFn(regionId, 0);
|
|
@@ -1744,6 +1913,37 @@ export class RendererLite {
|
|
|
1744
1913
|
playNext();
|
|
1745
1914
|
}
|
|
1746
1915
|
|
|
1916
|
+
/**
|
|
1917
|
+
* Start a canvas region — render all widgets simultaneously (stacked).
|
|
1918
|
+
* Canvas regions show every widget at once rather than cycling through them.
|
|
1919
|
+
* The region duration is the maximum widget duration.
|
|
1920
|
+
* @param {Object} region - Region state
|
|
1921
|
+
* @param {string} regionId - Region ID
|
|
1922
|
+
* @param {Function} showFn - Show widget function (regionId, widgetIndex)
|
|
1923
|
+
* @param {Function} onCycleComplete - Callback when region completes
|
|
1924
|
+
*/
|
|
1925
|
+
_startCanvasRegion(region, regionId, showFn, onCycleComplete) {
|
|
1926
|
+
// Show all widgets at once
|
|
1927
|
+
for (let i = 0; i < region.widgets.length; i++) {
|
|
1928
|
+
showFn(regionId, i);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Mark region as complete after max widget duration
|
|
1932
|
+
const maxDuration = Math.max(...region.widgets.map(w => w.duration)) * 1000;
|
|
1933
|
+
if (maxDuration > 0) {
|
|
1934
|
+
region.timer = setTimeout(() => {
|
|
1935
|
+
if (!region.complete) {
|
|
1936
|
+
region.complete = true;
|
|
1937
|
+
onCycleComplete?.();
|
|
1938
|
+
}
|
|
1939
|
+
}, maxDuration);
|
|
1940
|
+
} else {
|
|
1941
|
+
// No duration — immediately complete
|
|
1942
|
+
region.complete = true;
|
|
1943
|
+
onCycleComplete?.();
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1747
1947
|
/**
|
|
1748
1948
|
* Handle widget cycle end — shared logic for timer-based and event-based cycling
|
|
1749
1949
|
*/
|
|
@@ -2947,6 +3147,7 @@ export class RendererLite {
|
|
|
2947
3147
|
width: regionConfig.width * sf,
|
|
2948
3148
|
height: regionConfig.height * sf,
|
|
2949
3149
|
complete: false,
|
|
3150
|
+
isCanvas: regionConfig.isCanvas || false,
|
|
2950
3151
|
widgetElements: new Map()
|
|
2951
3152
|
});
|
|
2952
3153
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
-
import { RendererLite } from './renderer-lite.js';
|
|
9
|
+
import { RendererLite, Transitions } from './renderer-lite.js';
|
|
10
10
|
|
|
11
11
|
describe('RendererLite', () => {
|
|
12
12
|
let container;
|
|
@@ -964,48 +964,218 @@ describe('RendererLite', () => {
|
|
|
964
964
|
});
|
|
965
965
|
|
|
966
966
|
describe('Transitions', () => {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
967
|
+
let element;
|
|
968
|
+
let mockAnimate;
|
|
969
|
+
let capturedKeyframes;
|
|
970
|
+
let capturedTiming;
|
|
971
|
+
|
|
972
|
+
beforeEach(() => {
|
|
973
|
+
element = document.createElement('div');
|
|
974
|
+
capturedKeyframes = null;
|
|
975
|
+
capturedTiming = null;
|
|
976
|
+
mockAnimate = vi.fn((keyframes, timing) => {
|
|
977
|
+
capturedKeyframes = keyframes;
|
|
978
|
+
capturedTiming = timing;
|
|
979
|
+
return { onfinish: null, cancel: vi.fn() };
|
|
980
|
+
});
|
|
981
|
+
element.animate = mockAnimate;
|
|
982
|
+
});
|
|
971
983
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
};
|
|
984
|
+
it('should apply fade in transition', () => {
|
|
985
|
+
const result = Transitions.apply(element, { type: 'fadeIn', duration: 1000 }, true, 1920, 1080);
|
|
986
|
+
|
|
987
|
+
expect(result).toBeTruthy();
|
|
988
|
+
expect(capturedKeyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]);
|
|
989
|
+
expect(capturedTiming.duration).toBe(1000);
|
|
990
|
+
expect(capturedTiming.easing).toBe('linear');
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it('should apply fade out transition', () => {
|
|
994
|
+
const result = Transitions.apply(element, { type: 'fadeOut', duration: 800 }, false, 1920, 1080);
|
|
995
|
+
|
|
996
|
+
expect(result).toBeTruthy();
|
|
997
|
+
expect(capturedKeyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]);
|
|
998
|
+
expect(capturedTiming.duration).toBe(800);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('should apply generic "fade" as fadeIn when isIn=true', () => {
|
|
1002
|
+
const result = Transitions.apply(element, { type: 'fade', duration: 500 }, true, 1920, 1080);
|
|
1003
|
+
|
|
1004
|
+
expect(result).toBeTruthy();
|
|
1005
|
+
expect(capturedKeyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('should apply generic "fade" as fadeOut when isIn=false', () => {
|
|
1009
|
+
const result = Transitions.apply(element, { type: 'fade', duration: 500 }, false, 1920, 1080);
|
|
1010
|
+
|
|
1011
|
+
expect(result).toBeTruthy();
|
|
1012
|
+
expect(capturedKeyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('should apply fly in from North', () => {
|
|
1016
|
+
const result = Transitions.apply(
|
|
1017
|
+
element, { type: 'flyIn', duration: 500, direction: 'N' }, true, 1920, 1080
|
|
1018
|
+
);
|
|
977
1019
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1020
|
+
expect(result).toBeTruthy();
|
|
1021
|
+
expect(capturedKeyframes[0].transform).toBe('translate(0px, -1080px)');
|
|
1022
|
+
expect(capturedKeyframes[1].transform).toBe('translate(0, 0)');
|
|
1023
|
+
expect(capturedTiming.easing).toBe('ease-out');
|
|
1024
|
+
});
|
|
981
1025
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
expect.objectContaining({ opacity: '0' }),
|
|
986
|
-
expect.objectContaining({ opacity: '1' })
|
|
987
|
-
])
|
|
1026
|
+
it('should apply fly in from East', () => {
|
|
1027
|
+
Transitions.apply(
|
|
1028
|
+
element, { type: 'flyIn', duration: 500, direction: 'E' }, true, 1920, 1080
|
|
988
1029
|
);
|
|
1030
|
+
|
|
1031
|
+
expect(capturedKeyframes[0].transform).toBe('translate(1920px, 0px)');
|
|
1032
|
+
expect(capturedKeyframes[1].transform).toBe('translate(0, 0)');
|
|
989
1033
|
});
|
|
990
1034
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1035
|
+
it('should apply fly in from South', () => {
|
|
1036
|
+
Transitions.apply(
|
|
1037
|
+
element, { type: 'flyIn', duration: 500, direction: 'S' }, true, 1920, 1080
|
|
1038
|
+
);
|
|
994
1039
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1040
|
+
expect(capturedKeyframes[0].transform).toBe('translate(0px, 1080px)');
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it('should apply fly in from West', () => {
|
|
1044
|
+
Transitions.apply(
|
|
1045
|
+
element, { type: 'flyIn', duration: 500, direction: 'W' }, true, 1920, 1080
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
expect(capturedKeyframes[0].transform).toBe('translate(-1920px, 0px)');
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it('should apply fly in from diagonal directions (NE, SE, SW, NW)', () => {
|
|
1052
|
+
// NE
|
|
1053
|
+
Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'NE' }, true, 1920, 1080);
|
|
1054
|
+
expect(capturedKeyframes[0].transform).toBe('translate(1920px, -1080px)');
|
|
1055
|
+
|
|
1056
|
+
// SE
|
|
1057
|
+
Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'SE' }, true, 1920, 1080);
|
|
1058
|
+
expect(capturedKeyframes[0].transform).toBe('translate(1920px, 1080px)');
|
|
1000
1059
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1060
|
+
// SW
|
|
1061
|
+
Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'SW' }, true, 1920, 1080);
|
|
1062
|
+
expect(capturedKeyframes[0].transform).toBe('translate(-1920px, 1080px)');
|
|
1063
|
+
|
|
1064
|
+
// NW
|
|
1065
|
+
Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'NW' }, true, 1920, 1080);
|
|
1066
|
+
expect(capturedKeyframes[0].transform).toBe('translate(-1920px, -1080px)');
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('should apply fly out with direction S', () => {
|
|
1070
|
+
const result = Transitions.apply(
|
|
1071
|
+
element, { type: 'flyOut', duration: 1500, direction: 'S' }, false, 1920, 1080
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
expect(result).toBeTruthy();
|
|
1075
|
+
expect(capturedKeyframes[0].transform).toBe('translate(0, 0)');
|
|
1076
|
+
expect(capturedKeyframes[1].transform).toBe('translate(0px, -1080px)');
|
|
1077
|
+
expect(capturedTiming.easing).toBe('ease-in');
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it('should apply generic "fly" as flyIn when isIn=true', () => {
|
|
1081
|
+
const result = Transitions.apply(
|
|
1082
|
+
element, { type: 'fly', duration: 500, direction: 'E' }, true, 1920, 1080
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
expect(result).toBeTruthy();
|
|
1086
|
+
expect(capturedKeyframes[0].transform).toBe('translate(1920px, 0px)');
|
|
1087
|
+
expect(capturedKeyframes[1].transform).toBe('translate(0, 0)');
|
|
1088
|
+
expect(capturedTiming.easing).toBe('ease-out');
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('should apply generic "fly" as flyOut when isIn=false', () => {
|
|
1092
|
+
const result = Transitions.apply(
|
|
1093
|
+
element, { type: 'fly', duration: 500, direction: 'W' }, false, 1920, 1080
|
|
1094
|
+
);
|
|
1095
|
+
|
|
1096
|
+
expect(result).toBeTruthy();
|
|
1097
|
+
expect(capturedKeyframes[0].transform).toBe('translate(0, 0)');
|
|
1098
|
+
expect(capturedKeyframes[1].transform).toContain('px');
|
|
1099
|
+
expect(capturedTiming.easing).toBe('ease-in');
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it('should not apply flyIn when isIn=false', () => {
|
|
1103
|
+
const result = Transitions.apply(
|
|
1104
|
+
element, { type: 'flyIn', duration: 500, direction: 'N' }, false, 1920, 1080
|
|
1105
|
+
);
|
|
1106
|
+
expect(result).toBeNull();
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('should not apply flyOut when isIn=true', () => {
|
|
1110
|
+
const result = Transitions.apply(
|
|
1111
|
+
element, { type: 'flyOut', duration: 500, direction: 'N' }, true, 1920, 1080
|
|
1112
|
+
);
|
|
1113
|
+
expect(result).toBeNull();
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('should default direction to N when missing', () => {
|
|
1117
|
+
Transitions.apply(element, { type: 'flyIn', duration: 500 }, true, 1920, 1080);
|
|
1118
|
+
|
|
1119
|
+
// N direction: translateY(-height)
|
|
1120
|
+
expect(capturedKeyframes[0].transform).toBe('translate(0px, -1080px)');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('should default duration to 1000 when missing', () => {
|
|
1124
|
+
Transitions.apply(element, { type: 'fadeIn' }, true, 1920, 1080);
|
|
1125
|
+
|
|
1126
|
+
expect(capturedTiming.duration).toBe(1000);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it('should return null for unknown transition type', () => {
|
|
1130
|
+
const result = Transitions.apply(element, { type: 'slide' }, true, 1920, 1080);
|
|
1131
|
+
expect(result).toBeNull();
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it('should return null when config is null', () => {
|
|
1135
|
+
expect(Transitions.apply(element, null, true, 1920, 1080)).toBeNull();
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('should return null when config has no type', () => {
|
|
1139
|
+
expect(Transitions.apply(element, { duration: 500 }, true, 1920, 1080)).toBeNull();
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it('should be case-insensitive for type matching', () => {
|
|
1143
|
+
const result = Transitions.apply(element, { type: 'FadeIn', duration: 500 }, true, 1920, 1080);
|
|
1144
|
+
expect(result).toBeTruthy();
|
|
1145
|
+
expect(capturedKeyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it('should parse fly transitions from XLF with generic "fly" type', () => {
|
|
1149
|
+
const xlf = `
|
|
1150
|
+
<layout>
|
|
1151
|
+
<region id="r1">
|
|
1152
|
+
<media id="m1" type="image" duration="10">
|
|
1153
|
+
<options>
|
|
1154
|
+
<transIn>fly</transIn>
|
|
1155
|
+
<transInDuration>500</transInDuration>
|
|
1156
|
+
<transInDirection>E</transInDirection>
|
|
1157
|
+
<transOut>fly</transOut>
|
|
1158
|
+
<transOutDuration>500</transOutDuration>
|
|
1159
|
+
<transOutDirection>NW</transOutDirection>
|
|
1160
|
+
</options>
|
|
1161
|
+
</media>
|
|
1162
|
+
</region>
|
|
1163
|
+
</layout>
|
|
1164
|
+
`;
|
|
1003
1165
|
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1166
|
+
const layout = renderer.parseXlf(xlf);
|
|
1167
|
+
const widget = layout.regions[0].widgets[0];
|
|
1006
1168
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1169
|
+
expect(widget.transitions.in).toEqual({
|
|
1170
|
+
type: 'fly',
|
|
1171
|
+
duration: 500,
|
|
1172
|
+
direction: 'E'
|
|
1173
|
+
});
|
|
1174
|
+
expect(widget.transitions.out).toEqual({
|
|
1175
|
+
type: 'fly',
|
|
1176
|
+
duration: 500,
|
|
1177
|
+
direction: 'NW'
|
|
1178
|
+
});
|
|
1009
1179
|
});
|
|
1010
1180
|
});
|
|
1011
1181
|
|
|
@@ -1789,6 +1959,57 @@ describe('RendererLite', () => {
|
|
|
1789
1959
|
|
|
1790
1960
|
vi.useRealTimers();
|
|
1791
1961
|
});
|
|
1962
|
+
|
|
1963
|
+
it('should hide multi-widget drawer after cycling through all widgets', async () => {
|
|
1964
|
+
vi.useFakeTimers();
|
|
1965
|
+
|
|
1966
|
+
const xlf = `
|
|
1967
|
+
<layout width="1920" height="1080" duration="60">
|
|
1968
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1969
|
+
<media id="m1" type="image" duration="60" fileId="1">
|
|
1970
|
+
<options><uri>test.png</uri></options>
|
|
1971
|
+
</media>
|
|
1972
|
+
</region>
|
|
1973
|
+
<drawer id="d1" width="400" height="300" top="100" left="100">
|
|
1974
|
+
<media id="dm1" type="image" duration="3" fileId="2">
|
|
1975
|
+
<options><uri>d1.png</uri></options>
|
|
1976
|
+
</media>
|
|
1977
|
+
<media id="dm2" type="image" duration="3" fileId="3">
|
|
1978
|
+
<options><uri>d2.png</uri></options>
|
|
1979
|
+
</media>
|
|
1980
|
+
<media id="dm3" type="image" duration="3" fileId="4">
|
|
1981
|
+
<options><uri>d3.png</uri></options>
|
|
1982
|
+
</media>
|
|
1983
|
+
</drawer>
|
|
1984
|
+
</layout>
|
|
1985
|
+
`;
|
|
1986
|
+
|
|
1987
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
1988
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1989
|
+
await renderPromise;
|
|
1990
|
+
|
|
1991
|
+
const drawerRegion = renderer.regions.get('d1');
|
|
1992
|
+
expect(drawerRegion.element.style.display).toBe('none');
|
|
1993
|
+
|
|
1994
|
+
// Navigate to first drawer widget
|
|
1995
|
+
renderer.navigateToWidget('dm1');
|
|
1996
|
+
expect(drawerRegion.element.style.display).toBe('');
|
|
1997
|
+
expect(drawerRegion.currentIndex).toBe(0);
|
|
1998
|
+
|
|
1999
|
+
// After dm1 duration → advances to dm2, still visible
|
|
2000
|
+
await vi.advanceTimersByTimeAsync(3100);
|
|
2001
|
+
expect(drawerRegion.element.style.display).toBe('');
|
|
2002
|
+
|
|
2003
|
+
// After dm2 duration → advances to dm3, still visible
|
|
2004
|
+
await vi.advanceTimersByTimeAsync(3100);
|
|
2005
|
+
expect(drawerRegion.element.style.display).toBe('');
|
|
2006
|
+
|
|
2007
|
+
// After dm3 duration → wraps to 0, drawer hidden
|
|
2008
|
+
await vi.advanceTimersByTimeAsync(3100);
|
|
2009
|
+
expect(drawerRegion.element.style.display).toBe('none');
|
|
2010
|
+
|
|
2011
|
+
vi.useRealTimers();
|
|
2012
|
+
});
|
|
1792
2013
|
});
|
|
1793
2014
|
|
|
1794
2015
|
describe('Sub-Playlist (#10)', () => {
|
|
@@ -1897,6 +2118,65 @@ describe('RendererLite', () => {
|
|
|
1897
2118
|
expect(ids.some(id => id.startsWith('a'))).toBe(true);
|
|
1898
2119
|
expect(ids.some(id => id.startsWith('b'))).toBe(true);
|
|
1899
2120
|
});
|
|
2121
|
+
|
|
2122
|
+
it('should repeat widget playCount times before advancing (#188)', () => {
|
|
2123
|
+
const widgets = [
|
|
2124
|
+
{ id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
2125
|
+
displayOrder: 1, cyclePlayback: true, playCount: 2, isRandom: false },
|
|
2126
|
+
{ id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
2127
|
+
displayOrder: 2, cyclePlayback: true, playCount: 2, isRandom: false },
|
|
2128
|
+
];
|
|
2129
|
+
|
|
2130
|
+
renderer._subPlaylistCycleIndex = new Map();
|
|
2131
|
+
|
|
2132
|
+
const r1 = renderer._applyCyclePlayback(widgets);
|
|
2133
|
+
const r2 = renderer._applyCyclePlayback(widgets);
|
|
2134
|
+
const r3 = renderer._applyCyclePlayback(widgets);
|
|
2135
|
+
const r4 = renderer._applyCyclePlayback(widgets);
|
|
2136
|
+
|
|
2137
|
+
// m1 plays twice, then m2 plays twice
|
|
2138
|
+
expect(r1[0].id).toBe('m1');
|
|
2139
|
+
expect(r2[0].id).toBe('m1');
|
|
2140
|
+
expect(r3[0].id).toBe('m2');
|
|
2141
|
+
expect(r4[0].id).toBe('m2');
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
it('should treat playCount=0 or missing as 1 (#188)', () => {
|
|
2145
|
+
const widgets = [
|
|
2146
|
+
{ id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
2147
|
+
displayOrder: 1, cyclePlayback: true, playCount: 0, isRandom: false },
|
|
2148
|
+
{ id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
2149
|
+
displayOrder: 2, cyclePlayback: true, isRandom: false },
|
|
2150
|
+
];
|
|
2151
|
+
|
|
2152
|
+
renderer._subPlaylistCycleIndex = new Map();
|
|
2153
|
+
|
|
2154
|
+
const r1 = renderer._applyCyclePlayback(widgets);
|
|
2155
|
+
const r2 = renderer._applyCyclePlayback(widgets);
|
|
2156
|
+
|
|
2157
|
+
// Should advance every cycle (playCount defaults to 1)
|
|
2158
|
+
expect(r1[0].id).toBe('m1');
|
|
2159
|
+
expect(r2[0].id).toBe('m2');
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
it('should repeat playCount=3 times before advancing (#188)', () => {
|
|
2163
|
+
const widgets = [
|
|
2164
|
+
{ id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
2165
|
+
displayOrder: 1, cyclePlayback: true, playCount: 3, isRandom: false },
|
|
2166
|
+
{ id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
2167
|
+
displayOrder: 2, cyclePlayback: true, playCount: 3, isRandom: false },
|
|
2168
|
+
];
|
|
2169
|
+
|
|
2170
|
+
renderer._subPlaylistCycleIndex = new Map();
|
|
2171
|
+
|
|
2172
|
+
const results = [];
|
|
2173
|
+
for (let i = 0; i < 6; i++) {
|
|
2174
|
+
results.push(renderer._applyCyclePlayback(widgets)[0].id);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// m1 x3, m2 x3
|
|
2178
|
+
expect(results).toEqual(['m1', 'm1', 'm1', 'm2', 'm2', 'm2']);
|
|
2179
|
+
});
|
|
1900
2180
|
});
|
|
1901
2181
|
|
|
1902
2182
|
// ── Medium-Priority Spec Compliance ────────────────────────────────
|
|
@@ -2238,4 +2518,184 @@ describe('RendererLite', () => {
|
|
|
2238
2518
|
expect(layout.regions[0].widgets[0].commands).toEqual([]);
|
|
2239
2519
|
});
|
|
2240
2520
|
});
|
|
2521
|
+
|
|
2522
|
+
describe('Canvas Regions (#186)', () => {
|
|
2523
|
+
it('should parse region with type="canvas" as isCanvas', () => {
|
|
2524
|
+
const xlf = `
|
|
2525
|
+
<layout width="1920" height="1080" duration="60">
|
|
2526
|
+
<region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
|
|
2527
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2528
|
+
<options><uri>img1.png</uri></options>
|
|
2529
|
+
</media>
|
|
2530
|
+
<media id="m2" type="image" duration="15" fileId="2">
|
|
2531
|
+
<options><uri>img2.png</uri></options>
|
|
2532
|
+
</media>
|
|
2533
|
+
</region>
|
|
2534
|
+
</layout>
|
|
2535
|
+
`;
|
|
2536
|
+
|
|
2537
|
+
const layout = renderer.parseXlf(xlf);
|
|
2538
|
+
|
|
2539
|
+
expect(layout.regions).toHaveLength(1);
|
|
2540
|
+
expect(layout.regions[0].isCanvas).toBe(true);
|
|
2541
|
+
expect(layout.regions[0].widgets).toHaveLength(2);
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
it('should auto-detect canvas from type="global" widget', () => {
|
|
2545
|
+
const xlf = `
|
|
2546
|
+
<layout width="1920" height="1080" duration="60">
|
|
2547
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2548
|
+
<media id="m1" type="global" duration="30" fileId="1">
|
|
2549
|
+
<options></options>
|
|
2550
|
+
</media>
|
|
2551
|
+
</region>
|
|
2552
|
+
</layout>
|
|
2553
|
+
`;
|
|
2554
|
+
|
|
2555
|
+
const layout = renderer.parseXlf(xlf);
|
|
2556
|
+
|
|
2557
|
+
expect(layout.regions[0].isCanvas).toBe(true);
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
it('should NOT mark normal regions as canvas', () => {
|
|
2561
|
+
const xlf = `
|
|
2562
|
+
<layout width="1920" height="1080" duration="60">
|
|
2563
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2564
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2565
|
+
<options><uri>test.png</uri></options>
|
|
2566
|
+
</media>
|
|
2567
|
+
</region>
|
|
2568
|
+
</layout>
|
|
2569
|
+
`;
|
|
2570
|
+
|
|
2571
|
+
const layout = renderer.parseXlf(xlf);
|
|
2572
|
+
|
|
2573
|
+
expect(layout.regions[0].isCanvas).toBe(false);
|
|
2574
|
+
});
|
|
2575
|
+
|
|
2576
|
+
it('should store isCanvas flag in region state after createRegion', async () => {
|
|
2577
|
+
const regionConfig = {
|
|
2578
|
+
id: 'r1',
|
|
2579
|
+
width: 1920,
|
|
2580
|
+
height: 1080,
|
|
2581
|
+
top: 0,
|
|
2582
|
+
left: 0,
|
|
2583
|
+
zindex: 0,
|
|
2584
|
+
isCanvas: true,
|
|
2585
|
+
widgets: []
|
|
2586
|
+
};
|
|
2587
|
+
|
|
2588
|
+
await renderer.createRegion(regionConfig);
|
|
2589
|
+
|
|
2590
|
+
const region = renderer.regions.get('r1');
|
|
2591
|
+
expect(region.isCanvas).toBe(true);
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
it('should render all canvas widgets simultaneously', async () => {
|
|
2595
|
+
vi.useFakeTimers();
|
|
2596
|
+
|
|
2597
|
+
const xlf = `
|
|
2598
|
+
<layout width="1920" height="1080" duration="60">
|
|
2599
|
+
<region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
|
|
2600
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2601
|
+
<options><uri>img1.png</uri></options>
|
|
2602
|
+
</media>
|
|
2603
|
+
<media id="m2" type="image" duration="15" fileId="2">
|
|
2604
|
+
<options><uri>img2.png</uri></options>
|
|
2605
|
+
</media>
|
|
2606
|
+
<media id="m3" type="image" duration="20" fileId="3">
|
|
2607
|
+
<options><uri>img3.png</uri></options>
|
|
2608
|
+
</media>
|
|
2609
|
+
</region>
|
|
2610
|
+
</layout>
|
|
2611
|
+
`;
|
|
2612
|
+
|
|
2613
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
2614
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
2615
|
+
|
|
2616
|
+
const region = renderer.regions.get('r1');
|
|
2617
|
+
expect(region).toBeDefined();
|
|
2618
|
+
expect(region.isCanvas).toBe(true);
|
|
2619
|
+
|
|
2620
|
+
// All 3 widgets should be visible simultaneously
|
|
2621
|
+
let visibleCount = 0;
|
|
2622
|
+
for (const [, el] of region.widgetElements) {
|
|
2623
|
+
if (el.style.visibility === 'visible') visibleCount++;
|
|
2624
|
+
}
|
|
2625
|
+
expect(visibleCount).toBe(3);
|
|
2626
|
+
|
|
2627
|
+
// Clean up
|
|
2628
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
2629
|
+
await renderPromise;
|
|
2630
|
+
vi.useRealTimers();
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
it('should not cycle canvas region widgets', async () => {
|
|
2634
|
+
vi.useFakeTimers();
|
|
2635
|
+
|
|
2636
|
+
const xlf = `
|
|
2637
|
+
<layout width="1920" height="1080" duration="60">
|
|
2638
|
+
<region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
|
|
2639
|
+
<media id="m1" type="image" duration="5" fileId="1">
|
|
2640
|
+
<options><uri>img1.png</uri></options>
|
|
2641
|
+
</media>
|
|
2642
|
+
<media id="m2" type="image" duration="5" fileId="2">
|
|
2643
|
+
<options><uri>img2.png</uri></options>
|
|
2644
|
+
</media>
|
|
2645
|
+
</region>
|
|
2646
|
+
</layout>
|
|
2647
|
+
`;
|
|
2648
|
+
|
|
2649
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
2650
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
2651
|
+
|
|
2652
|
+
const region = renderer.regions.get('r1');
|
|
2653
|
+
|
|
2654
|
+
// After widget durations expire, both should still be visible (no cycling)
|
|
2655
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
2656
|
+
|
|
2657
|
+
let visibleCount = 0;
|
|
2658
|
+
for (const [, el] of region.widgetElements) {
|
|
2659
|
+
if (el.style.visibility === 'visible') visibleCount++;
|
|
2660
|
+
}
|
|
2661
|
+
expect(visibleCount).toBe(2);
|
|
2662
|
+
|
|
2663
|
+
// Clean up
|
|
2664
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
2665
|
+
await renderPromise;
|
|
2666
|
+
vi.useRealTimers();
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
it('should mark canvas region complete after max widget duration', async () => {
|
|
2670
|
+
vi.useFakeTimers();
|
|
2671
|
+
|
|
2672
|
+
const xlf = `
|
|
2673
|
+
<layout width="1920" height="1080">
|
|
2674
|
+
<region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
|
|
2675
|
+
<media id="m1" type="image" duration="5" fileId="1">
|
|
2676
|
+
<options><uri>img1.png</uri></options>
|
|
2677
|
+
</media>
|
|
2678
|
+
<media id="m2" type="image" duration="10" fileId="2">
|
|
2679
|
+
<options><uri>img2.png</uri></options>
|
|
2680
|
+
</media>
|
|
2681
|
+
</region>
|
|
2682
|
+
</layout>
|
|
2683
|
+
`;
|
|
2684
|
+
|
|
2685
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
2686
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
2687
|
+
|
|
2688
|
+
const region = renderer.regions.get('r1');
|
|
2689
|
+
expect(region.complete).toBe(false);
|
|
2690
|
+
|
|
2691
|
+
// Advance past max widget duration (10s)
|
|
2692
|
+
await vi.advanceTimersByTimeAsync(9000);
|
|
2693
|
+
expect(region.complete).toBe(true);
|
|
2694
|
+
|
|
2695
|
+
// Clean up
|
|
2696
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
2697
|
+
await renderPromise;
|
|
2698
|
+
vi.useRealTimers();
|
|
2699
|
+
});
|
|
2700
|
+
});
|
|
2241
2701
|
});
|
package/vitest.config.js
CHANGED
|
@@ -9,7 +9,9 @@ export default defineConfig({
|
|
|
9
9
|
alias: {
|
|
10
10
|
// hls.js is an optional runtime dependency (dynamic import in renderVideo).
|
|
11
11
|
// Alias to the monorepo mock so renderer tests work standalone.
|
|
12
|
-
'hls.js': new URL('../../vitest.hls-mock.js', import.meta.url).pathname
|
|
12
|
+
'hls.js': new URL('../../vitest.hls-mock.js', import.meta.url).pathname,
|
|
13
|
+
'@xiboplayer/schedule': new URL('../schedule/src/index.js', import.meta.url).pathname,
|
|
14
|
+
'@xiboplayer/utils': new URL('../utils/src/index.js', import.meta.url).pathname
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
});
|