@xiboplayer/renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/PERFORMANCE_OPTIMIZATIONS.md +451 -0
- package/docs/README.md +98 -0
- package/docs/RENDERER_COMPARISON.md +483 -0
- package/docs/TRANSITIONS.md +180 -0
- package/package.json +40 -0
- package/src/index.js +4 -0
- package/src/layout-pool.js +245 -0
- package/src/layout-pool.test.js +373 -0
- package/src/layout.js +1073 -0
- package/src/renderer-lite.js +2637 -0
- package/src/renderer-lite.overlays.test.js +493 -0
- package/src/renderer-lite.test.js +901 -0
- package/vitest.config.js +8 -0
package/src/layout.js
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout translator - XLF to HTML
|
|
3
|
+
* Based on arexibo layout.rs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Transition utility functions
|
|
7
|
+
const Transitions = {
|
|
8
|
+
/**
|
|
9
|
+
* Apply fade in transition
|
|
10
|
+
*/
|
|
11
|
+
fadeIn(element, duration) {
|
|
12
|
+
const keyframes = [
|
|
13
|
+
{ opacity: 0 },
|
|
14
|
+
{ opacity: 1 }
|
|
15
|
+
];
|
|
16
|
+
const timing = {
|
|
17
|
+
duration: duration,
|
|
18
|
+
easing: 'linear',
|
|
19
|
+
fill: 'forwards'
|
|
20
|
+
};
|
|
21
|
+
return element.animate(keyframes, timing);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Apply fade out transition
|
|
26
|
+
*/
|
|
27
|
+
fadeOut(element, duration) {
|
|
28
|
+
const keyframes = [
|
|
29
|
+
{ opacity: 1 },
|
|
30
|
+
{ opacity: 0, zIndex: 0 }
|
|
31
|
+
];
|
|
32
|
+
const timing = {
|
|
33
|
+
duration: duration,
|
|
34
|
+
easing: 'linear',
|
|
35
|
+
fill: 'forwards'
|
|
36
|
+
};
|
|
37
|
+
return element.animate(keyframes, timing);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get fly keyframes based on compass direction
|
|
42
|
+
*/
|
|
43
|
+
getFlyKeyframes(direction, width, height, isIn) {
|
|
44
|
+
const keyframes = { from: {}, to: {} };
|
|
45
|
+
|
|
46
|
+
// Map compass directions to transform values
|
|
47
|
+
const dirMap = {
|
|
48
|
+
'N': { x: 0, y: isIn ? -height : height },
|
|
49
|
+
'NE': { x: isIn ? width : -width, y: isIn ? -height : height },
|
|
50
|
+
'E': { x: isIn ? width : -width, y: 0 },
|
|
51
|
+
'SE': { x: isIn ? width : -width, y: isIn ? height : -height },
|
|
52
|
+
'S': { x: 0, y: isIn ? height : -height },
|
|
53
|
+
'SW': { x: isIn ? -width : width, y: isIn ? height : -height },
|
|
54
|
+
'W': { x: isIn ? -width : width, y: 0 },
|
|
55
|
+
'NW': { x: isIn ? -width : width, y: isIn ? -height : height }
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const offset = dirMap[direction] || dirMap['N'];
|
|
59
|
+
|
|
60
|
+
if (isIn) {
|
|
61
|
+
keyframes.from = {
|
|
62
|
+
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
63
|
+
opacity: 0
|
|
64
|
+
};
|
|
65
|
+
keyframes.to = {
|
|
66
|
+
transform: 'translate(0, 0)',
|
|
67
|
+
opacity: 1
|
|
68
|
+
};
|
|
69
|
+
} else {
|
|
70
|
+
keyframes.from = {
|
|
71
|
+
transform: 'translate(0, 0)',
|
|
72
|
+
opacity: 1
|
|
73
|
+
};
|
|
74
|
+
keyframes.to = {
|
|
75
|
+
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
76
|
+
opacity: 0
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return keyframes;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply fly in transition
|
|
85
|
+
*/
|
|
86
|
+
flyIn(element, duration, direction, regionWidth, regionHeight) {
|
|
87
|
+
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, true);
|
|
88
|
+
const timing = {
|
|
89
|
+
duration: duration,
|
|
90
|
+
easing: 'ease-out',
|
|
91
|
+
fill: 'forwards'
|
|
92
|
+
};
|
|
93
|
+
return element.animate([keyframes.from, keyframes.to], timing);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply fly out transition
|
|
98
|
+
*/
|
|
99
|
+
flyOut(element, duration, direction, regionWidth, regionHeight) {
|
|
100
|
+
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, false);
|
|
101
|
+
const timing = {
|
|
102
|
+
duration: duration,
|
|
103
|
+
easing: 'ease-in',
|
|
104
|
+
fill: 'forwards'
|
|
105
|
+
};
|
|
106
|
+
return element.animate([keyframes.from, keyframes.to], timing);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Apply transition based on type
|
|
111
|
+
*/
|
|
112
|
+
apply(element, transitionConfig, isIn, regionWidth, regionHeight) {
|
|
113
|
+
if (!transitionConfig || !transitionConfig.type) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const type = transitionConfig.type.toLowerCase();
|
|
118
|
+
const duration = transitionConfig.duration || 1000;
|
|
119
|
+
const direction = transitionConfig.direction || 'N';
|
|
120
|
+
|
|
121
|
+
switch (type) {
|
|
122
|
+
case 'fadein':
|
|
123
|
+
return isIn ? this.fadeIn(element, duration) : null;
|
|
124
|
+
case 'fadeout':
|
|
125
|
+
return isIn ? null : this.fadeOut(element, duration);
|
|
126
|
+
case 'flyin':
|
|
127
|
+
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
128
|
+
case 'flyout':
|
|
129
|
+
return isIn ? null : this.flyOut(element, duration, direction, regionWidth, regionHeight);
|
|
130
|
+
default:
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export class LayoutTranslator {
|
|
137
|
+
constructor(xmds) {
|
|
138
|
+
this.xmds = xmds;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Translate XLF XML to playable HTML
|
|
143
|
+
*/
|
|
144
|
+
async translateXLF(layoutId, xlfXml, cacheManager) {
|
|
145
|
+
const parser = new DOMParser();
|
|
146
|
+
const doc = parser.parseFromString(xlfXml, 'text/xml');
|
|
147
|
+
|
|
148
|
+
const layoutEl = doc.querySelector('layout');
|
|
149
|
+
if (!layoutEl) {
|
|
150
|
+
throw new Error('Invalid XLF: no <layout> element');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const width = parseInt(layoutEl.getAttribute('width') || '1920');
|
|
154
|
+
const height = parseInt(layoutEl.getAttribute('height') || '1080');
|
|
155
|
+
const bgcolor = layoutEl.getAttribute('bgcolor') || '#000000';
|
|
156
|
+
|
|
157
|
+
const regions = [];
|
|
158
|
+
for (const regionEl of doc.querySelectorAll('region')) {
|
|
159
|
+
regions.push(await this.translateRegion(layoutId, regionEl, cacheManager));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return this.generateHTML(width, height, bgcolor, regions);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Translate a single region
|
|
167
|
+
*/
|
|
168
|
+
async translateRegion(layoutId, regionEl, cacheManager) {
|
|
169
|
+
const id = regionEl.getAttribute('id');
|
|
170
|
+
const width = parseInt(regionEl.getAttribute('width'));
|
|
171
|
+
const height = parseInt(regionEl.getAttribute('height'));
|
|
172
|
+
const top = parseInt(regionEl.getAttribute('top'));
|
|
173
|
+
const left = parseInt(regionEl.getAttribute('left'));
|
|
174
|
+
const zindex = parseInt(regionEl.getAttribute('zindex') || '0');
|
|
175
|
+
|
|
176
|
+
const media = [];
|
|
177
|
+
for (const mediaEl of regionEl.querySelectorAll('media')) {
|
|
178
|
+
media.push(await this.translateMedia(layoutId, id, mediaEl, cacheManager));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
width,
|
|
184
|
+
height,
|
|
185
|
+
top,
|
|
186
|
+
left,
|
|
187
|
+
zindex,
|
|
188
|
+
media
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Translate a single media item
|
|
194
|
+
*/
|
|
195
|
+
async translateMedia(layoutId, regionId, mediaEl, cacheManager) {
|
|
196
|
+
const type = mediaEl.getAttribute('type');
|
|
197
|
+
const duration = parseInt(mediaEl.getAttribute('duration') || '10');
|
|
198
|
+
const id = mediaEl.getAttribute('id');
|
|
199
|
+
|
|
200
|
+
const optionsEl = mediaEl.querySelector('options');
|
|
201
|
+
const rawEl = mediaEl.querySelector('raw');
|
|
202
|
+
|
|
203
|
+
const options = {};
|
|
204
|
+
if (optionsEl) {
|
|
205
|
+
for (const child of optionsEl.children) {
|
|
206
|
+
options[child.tagName] = child.textContent;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Parse transition information
|
|
211
|
+
const transitions = {
|
|
212
|
+
in: null,
|
|
213
|
+
out: null
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const transInEl = mediaEl.querySelector('options > transIn');
|
|
217
|
+
const transOutEl = mediaEl.querySelector('options > transOut');
|
|
218
|
+
const transInDurationEl = mediaEl.querySelector('options > transInDuration');
|
|
219
|
+
const transOutDurationEl = mediaEl.querySelector('options > transOutDuration');
|
|
220
|
+
const transInDirectionEl = mediaEl.querySelector('options > transInDirection');
|
|
221
|
+
const transOutDirectionEl = mediaEl.querySelector('options > transOutDirection');
|
|
222
|
+
|
|
223
|
+
if (transInEl && transInEl.textContent) {
|
|
224
|
+
transitions.in = {
|
|
225
|
+
type: transInEl.textContent,
|
|
226
|
+
duration: parseInt(transInDurationEl?.textContent || '1000'),
|
|
227
|
+
direction: transInDirectionEl?.textContent || 'N'
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (transOutEl && transOutEl.textContent) {
|
|
232
|
+
transitions.out = {
|
|
233
|
+
type: transOutEl.textContent,
|
|
234
|
+
duration: parseInt(transOutDurationEl?.textContent || '1000'),
|
|
235
|
+
direction: transOutDirectionEl?.textContent || 'N'
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// All videos use cache URL pattern
|
|
240
|
+
// Large videos download in background, small videos are already cached
|
|
241
|
+
// Service Worker handles both cases appropriately
|
|
242
|
+
|
|
243
|
+
let raw = rawEl ? rawEl.textContent : '';
|
|
244
|
+
|
|
245
|
+
// For widgets (clock, calendar, etc.), fetch rendered HTML from CMS
|
|
246
|
+
const widgetTypes = ['clock', 'clock-digital', 'clock-analogue', 'calendar', 'weather',
|
|
247
|
+
'currencies', 'stocks', 'twitter', 'global', 'embedded', 'text', 'ticker'];
|
|
248
|
+
if (widgetTypes.some(w => type.includes(w))) {
|
|
249
|
+
// Try to get widget HTML with retry logic for kiosk reliability
|
|
250
|
+
let retries = 3;
|
|
251
|
+
let lastError = null;
|
|
252
|
+
|
|
253
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
254
|
+
try {
|
|
255
|
+
console.log(`[Layout] Fetching resource for ${type} widget (layout=${layoutId}, region=${regionId}, media=${id}) - attempt ${attempt}/${retries}`);
|
|
256
|
+
raw = await this.xmds.getResource(layoutId, regionId, id);
|
|
257
|
+
console.log(`[Layout] Got resource HTML (${raw.length} chars)`);
|
|
258
|
+
|
|
259
|
+
// Store widget HTML in cache and save cache key for iframe src generation
|
|
260
|
+
const widgetCacheKey = await cacheManager.cacheWidgetHtml(layoutId, regionId, id, raw);
|
|
261
|
+
options.widgetCacheKey = widgetCacheKey;
|
|
262
|
+
|
|
263
|
+
// Success - break retry loop
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
} catch (error) {
|
|
267
|
+
lastError = error;
|
|
268
|
+
console.warn(`[Layout] Failed to get resource (attempt ${attempt}/${retries}):`, error.message);
|
|
269
|
+
|
|
270
|
+
// If not last attempt, wait before retry
|
|
271
|
+
if (attempt < retries) {
|
|
272
|
+
const delay = attempt * 2000; // 2s, 4s backoff
|
|
273
|
+
console.log(`[Layout] Retrying in ${delay}ms...`);
|
|
274
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// If all retries failed, try to use cached version as fallback
|
|
280
|
+
if (!raw && lastError) {
|
|
281
|
+
console.warn(`[Layout] All retries failed, checking for cached widget HTML...`);
|
|
282
|
+
|
|
283
|
+
// Try to get cached widget HTML
|
|
284
|
+
try {
|
|
285
|
+
const cachedKey = `/cache/widget/${layoutId}/${regionId}/${id}.html`;
|
|
286
|
+
const cached = await cacheManager.cache.match(new Request(window.location.origin + '/player' + cachedKey));
|
|
287
|
+
|
|
288
|
+
if (cached) {
|
|
289
|
+
raw = await cached.text();
|
|
290
|
+
options.widgetCacheKey = cachedKey;
|
|
291
|
+
console.log(`[Layout] Using cached widget HTML (${raw.length} chars) - CMS update pending`);
|
|
292
|
+
} else {
|
|
293
|
+
console.error(`[Layout] No cached version available for widget ${id}`);
|
|
294
|
+
// Show minimal placeholder that doesn't look like an error
|
|
295
|
+
raw = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:18px;">Content updating...</div>`;
|
|
296
|
+
}
|
|
297
|
+
} catch (cacheError) {
|
|
298
|
+
console.error(`[Layout] Cache fallback failed:`, cacheError);
|
|
299
|
+
raw = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:18px;">Content updating...</div>`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
type,
|
|
306
|
+
duration,
|
|
307
|
+
id,
|
|
308
|
+
options,
|
|
309
|
+
raw,
|
|
310
|
+
transitions
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Generate HTML from parsed layout
|
|
316
|
+
*/
|
|
317
|
+
generateHTML(width, height, bgcolor, regions) {
|
|
318
|
+
const regionHTML = regions.map(r => this.generateRegionHTML(r)).join('\n');
|
|
319
|
+
const regionJS = regions.map(r => this.generateRegionJS(r)).join(',\n');
|
|
320
|
+
|
|
321
|
+
return `<!DOCTYPE html>
|
|
322
|
+
<html>
|
|
323
|
+
<head>
|
|
324
|
+
<meta charset="utf-8">
|
|
325
|
+
<meta name="viewport" content="width=${width}, height=${height}">
|
|
326
|
+
<style>
|
|
327
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
328
|
+
html, body { width: 100%; height: 100%; overflow: hidden; }
|
|
329
|
+
body { background-color: ${bgcolor}; }
|
|
330
|
+
.region {
|
|
331
|
+
position: absolute;
|
|
332
|
+
overflow: hidden;
|
|
333
|
+
}
|
|
334
|
+
.media {
|
|
335
|
+
width: 100%;
|
|
336
|
+
height: 100%;
|
|
337
|
+
object-fit: contain;
|
|
338
|
+
}
|
|
339
|
+
iframe {
|
|
340
|
+
border: none;
|
|
341
|
+
width: 100%;
|
|
342
|
+
height: 100%;
|
|
343
|
+
}
|
|
344
|
+
</style>
|
|
345
|
+
</head>
|
|
346
|
+
<body>
|
|
347
|
+
${regionHTML}
|
|
348
|
+
<script>
|
|
349
|
+
// Transition utilities
|
|
350
|
+
window.Transitions = {
|
|
351
|
+
fadeIn(element, duration) {
|
|
352
|
+
const keyframes = [
|
|
353
|
+
{ opacity: 0 },
|
|
354
|
+
{ opacity: 1 }
|
|
355
|
+
];
|
|
356
|
+
const timing = {
|
|
357
|
+
duration: duration,
|
|
358
|
+
easing: 'linear',
|
|
359
|
+
fill: 'forwards'
|
|
360
|
+
};
|
|
361
|
+
return element.animate(keyframes, timing);
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
fadeOut(element, duration) {
|
|
365
|
+
const keyframes = [
|
|
366
|
+
{ opacity: 1 },
|
|
367
|
+
{ opacity: 0 }
|
|
368
|
+
];
|
|
369
|
+
const timing = {
|
|
370
|
+
duration: duration,
|
|
371
|
+
easing: 'linear',
|
|
372
|
+
fill: 'forwards'
|
|
373
|
+
};
|
|
374
|
+
return element.animate(keyframes, timing);
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
getFlyKeyframes(direction, width, height, isIn) {
|
|
378
|
+
const dirMap = {
|
|
379
|
+
'N': { x: 0, y: isIn ? -height : height },
|
|
380
|
+
'NE': { x: isIn ? width : -width, y: isIn ? -height : height },
|
|
381
|
+
'E': { x: isIn ? width : -width, y: 0 },
|
|
382
|
+
'SE': { x: isIn ? width : -width, y: isIn ? height : -height },
|
|
383
|
+
'S': { x: 0, y: isIn ? height : -height },
|
|
384
|
+
'SW': { x: isIn ? -width : width, y: isIn ? height : -height },
|
|
385
|
+
'W': { x: isIn ? -width : width, y: 0 },
|
|
386
|
+
'NW': { x: isIn ? -width : width, y: isIn ? -height : height }
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const offset = dirMap[direction] || dirMap['N'];
|
|
390
|
+
|
|
391
|
+
if (isIn) {
|
|
392
|
+
return [
|
|
393
|
+
{ transform: \`translate(\${offset.x}px, \${offset.y}px)\`, opacity: 0 },
|
|
394
|
+
{ transform: 'translate(0, 0)', opacity: 1 }
|
|
395
|
+
];
|
|
396
|
+
} else {
|
|
397
|
+
return [
|
|
398
|
+
{ transform: 'translate(0, 0)', opacity: 1 },
|
|
399
|
+
{ transform: \`translate(\${offset.x}px, \${offset.y}px)\`, opacity: 0 }
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
flyIn(element, duration, direction, regionWidth, regionHeight) {
|
|
405
|
+
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, true);
|
|
406
|
+
const timing = {
|
|
407
|
+
duration: duration,
|
|
408
|
+
easing: 'ease-out',
|
|
409
|
+
fill: 'forwards'
|
|
410
|
+
};
|
|
411
|
+
return element.animate(keyframes, timing);
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
flyOut(element, duration, direction, regionWidth, regionHeight) {
|
|
415
|
+
const keyframes = this.getFlyKeyframes(direction, regionWidth, regionHeight, false);
|
|
416
|
+
const timing = {
|
|
417
|
+
duration: duration,
|
|
418
|
+
easing: 'ease-in',
|
|
419
|
+
fill: 'forwards'
|
|
420
|
+
};
|
|
421
|
+
return element.animate(keyframes, timing);
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
apply(element, transitionConfig, isIn, regionWidth, regionHeight) {
|
|
425
|
+
if (!transitionConfig || !transitionConfig.type) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const type = transitionConfig.type.toLowerCase();
|
|
430
|
+
const duration = transitionConfig.duration || 1000;
|
|
431
|
+
const direction = transitionConfig.direction || 'N';
|
|
432
|
+
|
|
433
|
+
switch (type) {
|
|
434
|
+
case 'fadein':
|
|
435
|
+
return isIn ? this.fadeIn(element, duration) : null;
|
|
436
|
+
case 'fadeout':
|
|
437
|
+
return isIn ? null : this.fadeOut(element, duration);
|
|
438
|
+
case 'flyin':
|
|
439
|
+
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
440
|
+
case 'flyout':
|
|
441
|
+
return isIn ? null : this.flyOut(element, duration, direction, regionWidth, regionHeight);
|
|
442
|
+
default:
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const regions = {
|
|
449
|
+
${regionJS}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Auto-start all regions
|
|
453
|
+
Object.keys(regions).forEach(id => {
|
|
454
|
+
playRegion(id);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
function playRegion(id) {
|
|
458
|
+
const region = regions[id];
|
|
459
|
+
if (!region || region.media.length === 0) return;
|
|
460
|
+
|
|
461
|
+
// If only one media item, just show it and don't cycle (arexibo behavior)
|
|
462
|
+
if (region.media.length === 1) {
|
|
463
|
+
const media = region.media[0];
|
|
464
|
+
if (media.start) media.start();
|
|
465
|
+
return; // Don't schedule stop/restart
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Multiple media items - cycle normally
|
|
469
|
+
let currentIndex = 0;
|
|
470
|
+
|
|
471
|
+
function playNext() {
|
|
472
|
+
const media = region.media[currentIndex];
|
|
473
|
+
if (media.start) media.start();
|
|
474
|
+
|
|
475
|
+
const duration = media.duration || 10;
|
|
476
|
+
setTimeout(() => {
|
|
477
|
+
if (media.stop) media.stop();
|
|
478
|
+
currentIndex = (currentIndex + 1) % region.media.length;
|
|
479
|
+
playNext();
|
|
480
|
+
}, duration * 1000);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
playNext();
|
|
484
|
+
}
|
|
485
|
+
</script>
|
|
486
|
+
</body>
|
|
487
|
+
</html>`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Generate HTML for a region container
|
|
492
|
+
*/
|
|
493
|
+
generateRegionHTML(region) {
|
|
494
|
+
return ` <div id="region_${region.id}" class="region" style="
|
|
495
|
+
left: ${region.left}px;
|
|
496
|
+
top: ${region.top}px;
|
|
497
|
+
width: ${region.width}px;
|
|
498
|
+
height: ${region.height}px;
|
|
499
|
+
z-index: ${region.zindex};
|
|
500
|
+
"></div>`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Generate JavaScript for region media control
|
|
505
|
+
*/
|
|
506
|
+
generateRegionJS(region) {
|
|
507
|
+
const mediaJS = region.media.map(m => this.generateMediaJS(m, region.id)).join(',\n ');
|
|
508
|
+
|
|
509
|
+
return ` '${region.id}': {
|
|
510
|
+
media: [
|
|
511
|
+
${mediaJS}
|
|
512
|
+
]
|
|
513
|
+
}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Generate JavaScript for a single media item
|
|
518
|
+
*/
|
|
519
|
+
generateMediaJS(media, regionId) {
|
|
520
|
+
const duration = media.duration || 10;
|
|
521
|
+
const transIn = media.transitions?.in ? JSON.stringify(media.transitions.in) : 'null';
|
|
522
|
+
const transOut = media.transitions?.out ? JSON.stringify(media.transitions.out) : 'null';
|
|
523
|
+
let startFn = 'null';
|
|
524
|
+
let stopFn = 'null';
|
|
525
|
+
|
|
526
|
+
switch (media.type) {
|
|
527
|
+
case 'image':
|
|
528
|
+
// Use absolute URL within service worker scope
|
|
529
|
+
const imageSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
|
|
530
|
+
startFn = `() => {
|
|
531
|
+
const region = document.getElementById('region_${regionId}');
|
|
532
|
+
const img = document.createElement('img');
|
|
533
|
+
img.className = 'media';
|
|
534
|
+
img.src = '${imageSrc}';
|
|
535
|
+
img.style.opacity = '0';
|
|
536
|
+
region.innerHTML = '';
|
|
537
|
+
region.appendChild(img);
|
|
538
|
+
|
|
539
|
+
// Apply transition
|
|
540
|
+
const transIn = ${transIn};
|
|
541
|
+
if (transIn && window.Transitions) {
|
|
542
|
+
const regionRect = region.getBoundingClientRect();
|
|
543
|
+
window.Transitions.apply(img, transIn, true, regionRect.width, regionRect.height);
|
|
544
|
+
} else {
|
|
545
|
+
img.style.opacity = '1';
|
|
546
|
+
}
|
|
547
|
+
}`;
|
|
548
|
+
break;
|
|
549
|
+
|
|
550
|
+
case 'video':
|
|
551
|
+
// All videos use cache URL pattern
|
|
552
|
+
// Background-downloaded videos will auto-reload when cache completes
|
|
553
|
+
const videoSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
|
|
554
|
+
const videoFilename = media.options.uri;
|
|
555
|
+
|
|
556
|
+
startFn = `() => {
|
|
557
|
+
const region = document.getElementById('region_${regionId}');
|
|
558
|
+
const video = document.createElement('video');
|
|
559
|
+
video.className = 'media';
|
|
560
|
+
video.src = '${videoSrc}';
|
|
561
|
+
video.dataset.filename = '${videoFilename}';
|
|
562
|
+
video.autoplay = true;
|
|
563
|
+
video.muted = ${media.options.mute === '1' ? 'true' : 'false'};
|
|
564
|
+
video.loop = false;
|
|
565
|
+
video.style.width = '100%';
|
|
566
|
+
video.style.height = '100%';
|
|
567
|
+
video.style.objectFit = 'contain';
|
|
568
|
+
video.style.opacity = '0';
|
|
569
|
+
|
|
570
|
+
// Retry loading if cache completes while video is playing
|
|
571
|
+
const retryOnCache = (event) => {
|
|
572
|
+
if (event.detail.filename === '${videoFilename}' && video.error) {
|
|
573
|
+
console.log('[Video] Cache complete, reloading:', '${videoFilename}');
|
|
574
|
+
video.load();
|
|
575
|
+
video.play();
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
window.addEventListener('media-cached', retryOnCache);
|
|
579
|
+
video.dataset.cacheListener = 'attached';
|
|
580
|
+
|
|
581
|
+
region.innerHTML = '';
|
|
582
|
+
region.appendChild(video);
|
|
583
|
+
|
|
584
|
+
// Apply transition
|
|
585
|
+
const transIn = ${transIn};
|
|
586
|
+
if (transIn && window.Transitions) {
|
|
587
|
+
const regionRect = region.getBoundingClientRect();
|
|
588
|
+
window.Transitions.apply(video, transIn, true, regionRect.width, regionRect.height);
|
|
589
|
+
} else {
|
|
590
|
+
video.style.opacity = '1';
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
console.log('[Video] Playing:', '${media.options.uri}');
|
|
594
|
+
}`;
|
|
595
|
+
stopFn = `() => {
|
|
596
|
+
const region = document.getElementById('region_${regionId}');
|
|
597
|
+
const video = document.querySelector('#region_${regionId} video');
|
|
598
|
+
if (video) {
|
|
599
|
+
const transOut = ${transOut};
|
|
600
|
+
if (transOut && window.Transitions) {
|
|
601
|
+
const regionRect = region.getBoundingClientRect();
|
|
602
|
+
const animation = window.Transitions.apply(video, transOut, false, regionRect.width, regionRect.height);
|
|
603
|
+
if (animation) {
|
|
604
|
+
animation.onfinish = () => {
|
|
605
|
+
video.pause();
|
|
606
|
+
video.remove();
|
|
607
|
+
};
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
video.pause();
|
|
612
|
+
video.remove();
|
|
613
|
+
}
|
|
614
|
+
}`;
|
|
615
|
+
break;
|
|
616
|
+
|
|
617
|
+
case 'text':
|
|
618
|
+
case 'ticker':
|
|
619
|
+
// Use cache URL pattern for text/ticker widgets - must be in /player/ scope for SW
|
|
620
|
+
if (media.options.widgetCacheKey) {
|
|
621
|
+
const textUrl = `${window.location.origin}/player${media.options.widgetCacheKey}`;
|
|
622
|
+
const iframeId = `widget_${regionId}_${media.id}`;
|
|
623
|
+
startFn = `() => {
|
|
624
|
+
const region = document.getElementById('region_${regionId}');
|
|
625
|
+
let iframe = document.getElementById('${iframeId}');
|
|
626
|
+
if (!iframe) {
|
|
627
|
+
iframe = document.createElement('iframe');
|
|
628
|
+
iframe.id = '${iframeId}';
|
|
629
|
+
iframe.src = '${textUrl}';
|
|
630
|
+
iframe.style.width = '100%';
|
|
631
|
+
iframe.style.height = '100%';
|
|
632
|
+
iframe.style.border = 'none';
|
|
633
|
+
iframe.scrolling = 'no';
|
|
634
|
+
iframe.style.opacity = '0';
|
|
635
|
+
region.innerHTML = '';
|
|
636
|
+
region.appendChild(iframe);
|
|
637
|
+
|
|
638
|
+
// Apply transition after iframe loads
|
|
639
|
+
iframe.onload = () => {
|
|
640
|
+
const transIn = ${transIn};
|
|
641
|
+
if (transIn && window.Transitions) {
|
|
642
|
+
const regionRect = region.getBoundingClientRect();
|
|
643
|
+
window.Transitions.apply(iframe, transIn, true, regionRect.width, regionRect.height);
|
|
644
|
+
} else {
|
|
645
|
+
iframe.style.opacity = '1';
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
} else {
|
|
649
|
+
iframe.style.display = 'block';
|
|
650
|
+
iframe.style.opacity = '1';
|
|
651
|
+
}
|
|
652
|
+
}`;
|
|
653
|
+
stopFn = `() => {
|
|
654
|
+
const region = document.getElementById('region_${regionId}');
|
|
655
|
+
const iframe = document.getElementById('${iframeId}');
|
|
656
|
+
if (iframe) {
|
|
657
|
+
const transOut = ${transOut};
|
|
658
|
+
if (transOut && window.Transitions) {
|
|
659
|
+
const regionRect = region.getBoundingClientRect();
|
|
660
|
+
const animation = window.Transitions.apply(iframe, transOut, false, regionRect.width, regionRect.height);
|
|
661
|
+
if (animation) {
|
|
662
|
+
animation.onfinish = () => {
|
|
663
|
+
iframe.style.display = 'none';
|
|
664
|
+
};
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
iframe.style.display = 'none';
|
|
669
|
+
}
|
|
670
|
+
}`;
|
|
671
|
+
} else {
|
|
672
|
+
console.warn(`[Layout] Text media without widgetCacheKey`);
|
|
673
|
+
startFn = `() => console.log('Text media without cache key')`;
|
|
674
|
+
}
|
|
675
|
+
break;
|
|
676
|
+
|
|
677
|
+
case 'audio':
|
|
678
|
+
const audioSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
|
|
679
|
+
const audioId = `audio_${regionId}_${media.id}`;
|
|
680
|
+
const audioLoop = media.options.loop === '1';
|
|
681
|
+
const audioVolume = (parseInt(media.options.volume || '100') / 100).toFixed(2);
|
|
682
|
+
|
|
683
|
+
startFn = `() => {
|
|
684
|
+
const region = document.getElementById('region_${regionId}');
|
|
685
|
+
|
|
686
|
+
// Create audio element
|
|
687
|
+
const audio = document.createElement('audio');
|
|
688
|
+
audio.id = '${audioId}';
|
|
689
|
+
audio.className = 'media';
|
|
690
|
+
audio.src = '${audioSrc}';
|
|
691
|
+
audio.autoplay = true;
|
|
692
|
+
audio.loop = ${audioLoop};
|
|
693
|
+
audio.volume = ${audioVolume};
|
|
694
|
+
|
|
695
|
+
// Create visual feedback container
|
|
696
|
+
const visualContainer = document.createElement('div');
|
|
697
|
+
visualContainer.className = 'audio-visual';
|
|
698
|
+
visualContainer.style.cssText = \`
|
|
699
|
+
width: 100%;
|
|
700
|
+
height: 100%;
|
|
701
|
+
display: flex;
|
|
702
|
+
flex-direction: column;
|
|
703
|
+
align-items: center;
|
|
704
|
+
justify-content: center;
|
|
705
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
706
|
+
opacity: 0;
|
|
707
|
+
\`;
|
|
708
|
+
|
|
709
|
+
// Audio icon
|
|
710
|
+
const icon = document.createElement('div');
|
|
711
|
+
icon.innerHTML = '♪';
|
|
712
|
+
icon.style.cssText = \`
|
|
713
|
+
font-size: 120px;
|
|
714
|
+
color: white;
|
|
715
|
+
margin-bottom: 20px;
|
|
716
|
+
animation: pulse 2s ease-in-out infinite;
|
|
717
|
+
\`;
|
|
718
|
+
|
|
719
|
+
// Audio info
|
|
720
|
+
const info = document.createElement('div');
|
|
721
|
+
info.style.cssText = \`
|
|
722
|
+
color: white;
|
|
723
|
+
font-size: 24px;
|
|
724
|
+
text-align: center;
|
|
725
|
+
padding: 0 20px;
|
|
726
|
+
\`;
|
|
727
|
+
info.textContent = 'Playing Audio';
|
|
728
|
+
|
|
729
|
+
// Filename
|
|
730
|
+
const filename = document.createElement('div');
|
|
731
|
+
filename.style.cssText = \`
|
|
732
|
+
color: rgba(255,255,255,0.7);
|
|
733
|
+
font-size: 16px;
|
|
734
|
+
margin-top: 10px;
|
|
735
|
+
\`;
|
|
736
|
+
filename.textContent = '${media.options.uri}';
|
|
737
|
+
|
|
738
|
+
visualContainer.appendChild(icon);
|
|
739
|
+
visualContainer.appendChild(info);
|
|
740
|
+
visualContainer.appendChild(filename);
|
|
741
|
+
|
|
742
|
+
region.innerHTML = '';
|
|
743
|
+
region.appendChild(audio);
|
|
744
|
+
region.appendChild(visualContainer);
|
|
745
|
+
|
|
746
|
+
// Add pulse animation
|
|
747
|
+
const style = document.createElement('style');
|
|
748
|
+
style.textContent = \`
|
|
749
|
+
@keyframes pulse {
|
|
750
|
+
0%, 100% { transform: scale(1); opacity: 1; }
|
|
751
|
+
50% { transform: scale(1.1); opacity: 0.8; }
|
|
752
|
+
}
|
|
753
|
+
\`;
|
|
754
|
+
document.head.appendChild(style);
|
|
755
|
+
|
|
756
|
+
// Apply transition
|
|
757
|
+
const transIn = ${transIn};
|
|
758
|
+
if (transIn && window.Transitions) {
|
|
759
|
+
const regionRect = region.getBoundingClientRect();
|
|
760
|
+
window.Transitions.apply(visualContainer, transIn, true, regionRect.width, regionRect.height);
|
|
761
|
+
} else {
|
|
762
|
+
visualContainer.style.opacity = '1';
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
console.log('[Audio] Playing:', '${audioSrc}', 'Volume:', ${audioVolume}, 'Loop:', ${audioLoop});
|
|
766
|
+
}`;
|
|
767
|
+
|
|
768
|
+
stopFn = `() => {
|
|
769
|
+
const audio = document.getElementById('${audioId}');
|
|
770
|
+
if (audio) {
|
|
771
|
+
audio.pause();
|
|
772
|
+
audio.remove();
|
|
773
|
+
}
|
|
774
|
+
const region = document.getElementById('region_${regionId}');
|
|
775
|
+
if (region) {
|
|
776
|
+
const visualContainer = region.querySelector('.audio-visual');
|
|
777
|
+
if (visualContainer) {
|
|
778
|
+
const transOut = ${transOut};
|
|
779
|
+
if (transOut && window.Transitions) {
|
|
780
|
+
const regionRect = region.getBoundingClientRect();
|
|
781
|
+
const animation = window.Transitions.apply(visualContainer, transOut, false, regionRect.width, regionRect.height);
|
|
782
|
+
if (animation) {
|
|
783
|
+
animation.onfinish = () => visualContainer.remove();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
visualContainer.remove();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}`;
|
|
791
|
+
break;
|
|
792
|
+
|
|
793
|
+
case 'pdf':
|
|
794
|
+
const pdfSrc = `${window.location.origin}/player/cache/media/${media.options.uri}`;
|
|
795
|
+
const pdfContainerId = `pdf_${regionId}_${media.id}`;
|
|
796
|
+
const pdfDuration = duration; // Total duration for entire PDF
|
|
797
|
+
|
|
798
|
+
startFn = `async () => {
|
|
799
|
+
const container = document.createElement('div');
|
|
800
|
+
container.className = 'media pdf-container';
|
|
801
|
+
container.id = '${pdfContainerId}';
|
|
802
|
+
container.style.width = '100%';
|
|
803
|
+
container.style.height = '100%';
|
|
804
|
+
container.style.overflow = 'hidden';
|
|
805
|
+
container.style.backgroundColor = '#525659';
|
|
806
|
+
container.style.opacity = '0';
|
|
807
|
+
container.style.position = 'relative';
|
|
808
|
+
|
|
809
|
+
const region = document.getElementById('region_${regionId}');
|
|
810
|
+
region.innerHTML = '';
|
|
811
|
+
region.appendChild(container);
|
|
812
|
+
|
|
813
|
+
// Load PDF.js if not already loaded
|
|
814
|
+
if (typeof pdfjsLib === 'undefined') {
|
|
815
|
+
try {
|
|
816
|
+
const pdfjsModule = await import('pdfjs-dist');
|
|
817
|
+
window.pdfjsLib = pdfjsModule;
|
|
818
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = '${window.location.origin}/player/pdf.worker.min.mjs';
|
|
819
|
+
} catch (error) {
|
|
820
|
+
console.error('[PDF] Failed to load PDF.js:', error);
|
|
821
|
+
container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">PDF viewer unavailable</div>';
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Render PDF with multi-page support
|
|
827
|
+
try {
|
|
828
|
+
const loadingTask = pdfjsLib.getDocument('${pdfSrc}');
|
|
829
|
+
const pdf = await loadingTask.promise;
|
|
830
|
+
const totalPages = pdf.numPages;
|
|
831
|
+
|
|
832
|
+
// Calculate time per page (distribute total duration across all pages)
|
|
833
|
+
const timePerPage = (${pdfDuration} * 1000) / totalPages; // milliseconds per page
|
|
834
|
+
|
|
835
|
+
console.log(\`[PDF] Loading: \${totalPages} pages, \${timePerPage}ms per page\`);
|
|
836
|
+
|
|
837
|
+
const containerWidth = container.offsetWidth || ${width};
|
|
838
|
+
const containerHeight = container.offsetHeight || ${height};
|
|
839
|
+
|
|
840
|
+
// Create page indicator
|
|
841
|
+
const pageIndicator = document.createElement('div');
|
|
842
|
+
pageIndicator.className = 'pdf-page-indicator';
|
|
843
|
+
pageIndicator.style.cssText = \`
|
|
844
|
+
position: absolute;
|
|
845
|
+
bottom: 10px;
|
|
846
|
+
right: 10px;
|
|
847
|
+
background: rgba(0,0,0,0.7);
|
|
848
|
+
color: white;
|
|
849
|
+
padding: 8px 12px;
|
|
850
|
+
border-radius: 4px;
|
|
851
|
+
font-size: 14px;
|
|
852
|
+
z-index: 10;
|
|
853
|
+
\`;
|
|
854
|
+
container.appendChild(pageIndicator);
|
|
855
|
+
|
|
856
|
+
let currentPage = 1;
|
|
857
|
+
let pageTimers = [];
|
|
858
|
+
|
|
859
|
+
// Function to render a single page
|
|
860
|
+
async function renderPage(pageNum) {
|
|
861
|
+
const page = await pdf.getPage(pageNum);
|
|
862
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
863
|
+
|
|
864
|
+
// Calculate scale to fit page within container
|
|
865
|
+
const scaleX = containerWidth / viewport.width;
|
|
866
|
+
const scaleY = containerHeight / viewport.height;
|
|
867
|
+
const scale = Math.min(scaleX, scaleY);
|
|
868
|
+
|
|
869
|
+
const scaledViewport = page.getViewport({ scale });
|
|
870
|
+
|
|
871
|
+
const canvas = document.createElement('canvas');
|
|
872
|
+
canvas.className = 'pdf-page';
|
|
873
|
+
const context = canvas.getContext('2d');
|
|
874
|
+
canvas.width = scaledViewport.width;
|
|
875
|
+
canvas.height = scaledViewport.height;
|
|
876
|
+
|
|
877
|
+
// Center canvas in container
|
|
878
|
+
canvas.style.cssText = \`
|
|
879
|
+
display: block;
|
|
880
|
+
margin: auto;
|
|
881
|
+
margin-top: \${Math.max(0, (containerHeight - scaledViewport.height) / 2)}px;
|
|
882
|
+
position: absolute;
|
|
883
|
+
top: 0;
|
|
884
|
+
left: 50%;
|
|
885
|
+
transform: translateX(-50%);
|
|
886
|
+
opacity: 0;
|
|
887
|
+
transition: opacity 0.5s ease-in-out;
|
|
888
|
+
\`;
|
|
889
|
+
|
|
890
|
+
container.appendChild(canvas);
|
|
891
|
+
|
|
892
|
+
await page.render({
|
|
893
|
+
canvasContext: context,
|
|
894
|
+
viewport: scaledViewport
|
|
895
|
+
}).promise;
|
|
896
|
+
|
|
897
|
+
// Fade in new page
|
|
898
|
+
setTimeout(() => canvas.style.opacity = '1', 50);
|
|
899
|
+
|
|
900
|
+
return canvas;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Function to cycle through pages
|
|
904
|
+
async function cyclePage() {
|
|
905
|
+
// Update page indicator
|
|
906
|
+
pageIndicator.textContent = \`Page \${currentPage} / \${totalPages}\`;
|
|
907
|
+
|
|
908
|
+
// Remove old pages
|
|
909
|
+
const oldPages = container.querySelectorAll('.pdf-page');
|
|
910
|
+
oldPages.forEach(oldPage => {
|
|
911
|
+
if (oldPage !== container.lastChild) {
|
|
912
|
+
oldPage.style.opacity = '0';
|
|
913
|
+
setTimeout(() => oldPage.remove(), 500);
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// Render current page
|
|
918
|
+
await renderPage(currentPage);
|
|
919
|
+
|
|
920
|
+
console.log(\`[PDF] Showing page \${currentPage}/\${totalPages}\`);
|
|
921
|
+
|
|
922
|
+
// Schedule next page
|
|
923
|
+
if (totalPages > 1) {
|
|
924
|
+
const timer = setTimeout(() => {
|
|
925
|
+
currentPage = currentPage >= totalPages ? 1 : currentPage + 1;
|
|
926
|
+
cyclePage();
|
|
927
|
+
}, timePerPage);
|
|
928
|
+
pageTimers.push(timer);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Start cycling
|
|
933
|
+
await cyclePage();
|
|
934
|
+
|
|
935
|
+
// Apply transition to container
|
|
936
|
+
const transIn = ${transIn};
|
|
937
|
+
if (transIn && window.Transitions) {
|
|
938
|
+
const regionRect = region.getBoundingClientRect();
|
|
939
|
+
window.Transitions.apply(container, transIn, true, regionRect.width, regionRect.height);
|
|
940
|
+
} else {
|
|
941
|
+
container.style.opacity = '1';
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Store timers for cleanup
|
|
945
|
+
container.dataset.pageTimers = JSON.stringify(pageTimers.map(t => t));
|
|
946
|
+
|
|
947
|
+
} catch (error) {
|
|
948
|
+
console.error('[PDF] Render failed:', error);
|
|
949
|
+
container.innerHTML = '<div style="color:white;padding:20px;text-align:center;">Failed to load PDF</div>';
|
|
950
|
+
container.style.opacity = '1';
|
|
951
|
+
}
|
|
952
|
+
}`;
|
|
953
|
+
|
|
954
|
+
stopFn = `() => {
|
|
955
|
+
const region = document.getElementById('region_${regionId}');
|
|
956
|
+
const container = document.getElementById('${pdfContainerId}');
|
|
957
|
+
if (container) {
|
|
958
|
+
// Clear page cycling timers
|
|
959
|
+
const timers = container.dataset.pageTimers;
|
|
960
|
+
if (timers) {
|
|
961
|
+
try {
|
|
962
|
+
JSON.parse(timers).forEach(t => clearTimeout(t));
|
|
963
|
+
} catch (e) {}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const transOut = ${transOut};
|
|
967
|
+
if (transOut && window.Transitions) {
|
|
968
|
+
const regionRect = region.getBoundingClientRect();
|
|
969
|
+
const animation = window.Transitions.apply(container, transOut, false, regionRect.width, regionRect.height);
|
|
970
|
+
if (animation) {
|
|
971
|
+
animation.onfinish = () => {
|
|
972
|
+
container.remove();
|
|
973
|
+
};
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
container.remove();
|
|
978
|
+
}
|
|
979
|
+
}`;
|
|
980
|
+
break;
|
|
981
|
+
|
|
982
|
+
case 'webpage':
|
|
983
|
+
const url = media.options.uri;
|
|
984
|
+
startFn = `() => {
|
|
985
|
+
const region = document.getElementById('region_${regionId}');
|
|
986
|
+
const iframe = document.createElement('iframe');
|
|
987
|
+
iframe.src = '${url}';
|
|
988
|
+
iframe.style.opacity = '0';
|
|
989
|
+
region.innerHTML = '';
|
|
990
|
+
region.appendChild(iframe);
|
|
991
|
+
|
|
992
|
+
// Apply transition after iframe loads
|
|
993
|
+
iframe.onload = () => {
|
|
994
|
+
const transIn = ${transIn};
|
|
995
|
+
if (transIn && window.Transitions) {
|
|
996
|
+
const regionRect = region.getBoundingClientRect();
|
|
997
|
+
window.Transitions.apply(iframe, transIn, true, regionRect.width, regionRect.height);
|
|
998
|
+
} else {
|
|
999
|
+
iframe.style.opacity = '1';
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
}`;
|
|
1003
|
+
break;
|
|
1004
|
+
|
|
1005
|
+
default:
|
|
1006
|
+
// Widgets (clock, calendar, weather, etc.) - use cache URL pattern in /player/ scope for SW
|
|
1007
|
+
// Keep widget iframes alive across duration cycles (arexibo behavior)
|
|
1008
|
+
if (media.options.widgetCacheKey) {
|
|
1009
|
+
const widgetUrl = `${window.location.origin}/player${media.options.widgetCacheKey}`;
|
|
1010
|
+
const iframeId = `widget_${regionId}_${media.id}`;
|
|
1011
|
+
startFn = `() => {
|
|
1012
|
+
const region = document.getElementById('region_${regionId}');
|
|
1013
|
+
let iframe = document.getElementById('${iframeId}');
|
|
1014
|
+
if (!iframe) {
|
|
1015
|
+
iframe = document.createElement('iframe');
|
|
1016
|
+
iframe.id = '${iframeId}';
|
|
1017
|
+
iframe.src = '${widgetUrl}';
|
|
1018
|
+
iframe.style.width = '100%';
|
|
1019
|
+
iframe.style.height = '100%';
|
|
1020
|
+
iframe.style.border = 'none';
|
|
1021
|
+
iframe.scrolling = 'no';
|
|
1022
|
+
iframe.style.opacity = '0';
|
|
1023
|
+
region.innerHTML = '';
|
|
1024
|
+
region.appendChild(iframe);
|
|
1025
|
+
|
|
1026
|
+
// Apply transition after iframe loads
|
|
1027
|
+
iframe.onload = () => {
|
|
1028
|
+
const transIn = ${transIn};
|
|
1029
|
+
if (transIn && window.Transitions) {
|
|
1030
|
+
const regionRect = region.getBoundingClientRect();
|
|
1031
|
+
window.Transitions.apply(iframe, transIn, true, regionRect.width, regionRect.height);
|
|
1032
|
+
} else {
|
|
1033
|
+
iframe.style.opacity = '1';
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
} else {
|
|
1037
|
+
iframe.style.display = 'block';
|
|
1038
|
+
iframe.style.opacity = '1';
|
|
1039
|
+
}
|
|
1040
|
+
}`;
|
|
1041
|
+
stopFn = `() => {
|
|
1042
|
+
const region = document.getElementById('region_${regionId}');
|
|
1043
|
+
const iframe = document.getElementById('${iframeId}');
|
|
1044
|
+
if (iframe) {
|
|
1045
|
+
const transOut = ${transOut};
|
|
1046
|
+
if (transOut && window.Transitions) {
|
|
1047
|
+
const regionRect = region.getBoundingClientRect();
|
|
1048
|
+
const animation = window.Transitions.apply(iframe, transOut, false, regionRect.width, regionRect.height);
|
|
1049
|
+
if (animation) {
|
|
1050
|
+
animation.onfinish = () => {
|
|
1051
|
+
iframe.style.display = 'none';
|
|
1052
|
+
};
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
iframe.style.display = 'none';
|
|
1057
|
+
}
|
|
1058
|
+
}`;
|
|
1059
|
+
} else {
|
|
1060
|
+
console.warn(`[Layout] Unsupported media type: ${media.type}`);
|
|
1061
|
+
startFn = `() => console.log('Unsupported media type: ${media.type}')`;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return ` {
|
|
1066
|
+
start: ${startFn},
|
|
1067
|
+
stop: ${stopFn},
|
|
1068
|
+
duration: ${duration}
|
|
1069
|
+
}`;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export const layoutTranslator = new LayoutTranslator();
|