@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/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();