@helios-project/player 0.48.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/dist/index.js ADDED
@@ -0,0 +1,1679 @@
1
+ import { DirectController, BridgeController } from "./controllers";
2
+ import { ClientSideExporter } from "./features/exporter";
3
+ import { HeliosTextTrack, HeliosTextTrackList, CueClass } from "./features/text-tracks";
4
+ import { parseSRT } from "./features/srt-parser";
5
+ export { ClientSideExporter };
6
+ class StaticTimeRange {
7
+ startVal;
8
+ endVal;
9
+ constructor(startVal, endVal) {
10
+ this.startVal = startVal;
11
+ this.endVal = endVal;
12
+ }
13
+ get length() {
14
+ return this.endVal > 0 ? 1 : 0;
15
+ }
16
+ start(index) {
17
+ if (index !== 0 || this.length === 0)
18
+ throw new Error("IndexSizeError");
19
+ return this.startVal;
20
+ }
21
+ end(index) {
22
+ if (index !== 0 || this.length === 0)
23
+ throw new Error("IndexSizeError");
24
+ return this.endVal;
25
+ }
26
+ }
27
+ const template = document.createElement("template");
28
+ template.innerHTML = `
29
+ <style>
30
+ :host {
31
+ display: block;
32
+ width: 100%;
33
+ aspect-ratio: 16 / 9;
34
+ background-color: #f0f0f0;
35
+ position: relative;
36
+ font-family: var(--helios-font-family, sans-serif);
37
+
38
+ /* CSS Variables for Theming */
39
+ --helios-controls-bg: rgba(0, 0, 0, 0.6);
40
+ --helios-text-color: white;
41
+ --helios-accent-color: #007bff;
42
+ --helios-range-track-color: #555;
43
+ --helios-range-selected-color: rgba(255, 255, 255, 0.2);
44
+ --helios-range-unselected-color: var(--helios-range-track-color);
45
+ --helios-font-family: sans-serif;
46
+ }
47
+ iframe {
48
+ width: 100%;
49
+ height: 100%;
50
+ border: none;
51
+ }
52
+ .controls {
53
+ position: absolute;
54
+ bottom: 0;
55
+ left: 0;
56
+ right: 0;
57
+ background: var(--helios-controls-bg);
58
+ display: flex;
59
+ align-items: center;
60
+ padding: 8px;
61
+ color: var(--helios-text-color);
62
+ transition: opacity 0.3s;
63
+ z-index: 2;
64
+ }
65
+ :host(:not([controls])) .controls {
66
+ display: none;
67
+ pointer-events: none;
68
+ }
69
+ .play-pause-btn {
70
+ background: none;
71
+ border: none;
72
+ color: var(--helios-text-color);
73
+ font-size: 24px;
74
+ cursor: pointer;
75
+ width: 40px;
76
+ height: 40px;
77
+ }
78
+ .volume-control {
79
+ display: flex;
80
+ align-items: center;
81
+ margin-right: 8px;
82
+ }
83
+ .volume-btn {
84
+ background: none;
85
+ border: none;
86
+ color: var(--helios-text-color);
87
+ font-size: 20px;
88
+ cursor: pointer;
89
+ width: 32px;
90
+ height: 32px;
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ }
95
+ .volume-slider {
96
+ width: 60px;
97
+ margin-left: 4px;
98
+ height: 4px;
99
+ -webkit-appearance: none;
100
+ background: var(--helios-range-track-color);
101
+ outline: none;
102
+ border-radius: 2px;
103
+ }
104
+ .volume-slider::-webkit-slider-thumb {
105
+ -webkit-appearance: none;
106
+ appearance: none;
107
+ width: 12px;
108
+ height: 12px;
109
+ background: var(--helios-text-color);
110
+ cursor: pointer;
111
+ border-radius: 50%;
112
+ }
113
+ .export-btn {
114
+ background-color: var(--helios-accent-color);
115
+ border: none;
116
+ color: var(--helios-text-color);
117
+ font-size: 14px;
118
+ font-weight: bold;
119
+ cursor: pointer;
120
+ padding: 6px 12px;
121
+ margin: 0 10px;
122
+ border-radius: 4px;
123
+ }
124
+ .export-btn:hover {
125
+ filter: brightness(0.9);
126
+ }
127
+ .export-btn:disabled {
128
+ background-color: #666;
129
+ cursor: not-allowed;
130
+ }
131
+ .scrubber-wrapper {
132
+ flex-grow: 1;
133
+ margin: 0 16px;
134
+ position: relative;
135
+ height: 8px;
136
+ display: flex;
137
+ align-items: center;
138
+ }
139
+ .scrubber {
140
+ width: 100%;
141
+ height: 100%;
142
+ margin: 0;
143
+ position: relative;
144
+ z-index: 1;
145
+ -webkit-appearance: none;
146
+ background: var(--helios-range-track-color);
147
+ outline: none;
148
+ opacity: 0.9;
149
+ transition: opacity .2s;
150
+ }
151
+ .scrubber::-webkit-slider-thumb {
152
+ -webkit-appearance: none;
153
+ appearance: none;
154
+ width: 16px;
155
+ height: 16px;
156
+ background: var(--helios-accent-color);
157
+ cursor: pointer;
158
+ border-radius: 50%;
159
+ }
160
+ .scrubber-tooltip {
161
+ position: absolute;
162
+ bottom: 100%;
163
+ transform: translateX(-50%);
164
+ background: rgba(0, 0, 0, 0.8);
165
+ color: white;
166
+ padding: 4px 8px;
167
+ border-radius: 4px;
168
+ font-size: 12px;
169
+ pointer-events: none;
170
+ white-space: nowrap;
171
+ z-index: 10;
172
+ margin-bottom: 8px;
173
+ }
174
+ .scrubber-tooltip.hidden {
175
+ display: none;
176
+ }
177
+ .markers-container {
178
+ position: absolute;
179
+ inset: 0;
180
+ pointer-events: none;
181
+ z-index: 2;
182
+ }
183
+ .marker {
184
+ position: absolute;
185
+ width: 4px;
186
+ height: 12px;
187
+ background-color: var(--helios-accent-color);
188
+ transform: translateX(-50%);
189
+ cursor: pointer;
190
+ pointer-events: auto;
191
+ border-radius: 2px;
192
+ top: -2px;
193
+ transition: transform 0.1s;
194
+ }
195
+ .marker:hover {
196
+ transform: translateX(-50%) scale(1.2);
197
+ z-index: 10;
198
+ }
199
+ .time-display {
200
+ min-width: 90px;
201
+ text-align: center;
202
+ }
203
+ .status-overlay {
204
+ position: absolute;
205
+ inset: 0;
206
+ background: rgba(0, 0, 0, 0.8);
207
+ backdrop-filter: blur(4px);
208
+ color: white;
209
+ display: flex;
210
+ flex-direction: column;
211
+ align-items: center;
212
+ justify-content: center;
213
+ z-index: 10;
214
+ transition: opacity 0.3s;
215
+ }
216
+ .status-overlay.hidden {
217
+ opacity: 0;
218
+ pointer-events: none;
219
+ }
220
+ .error-msg {
221
+ color: #ff6b6b;
222
+ margin-bottom: 10px;
223
+ font-size: 16px;
224
+ font-weight: bold;
225
+ }
226
+ .retry-btn {
227
+ background-color: #ff6b6b;
228
+ border: none;
229
+ color: white;
230
+ padding: 8px 16px;
231
+ border-radius: 4px;
232
+ cursor: pointer;
233
+ font-size: 14px;
234
+ margin-top: 10px;
235
+ }
236
+ .retry-btn:hover {
237
+ background-color: #ff5252;
238
+ }
239
+ .speed-selector {
240
+ background: rgba(0, 0, 0, 0.4);
241
+ color: var(--helios-text-color);
242
+ border: 1px solid var(--helios-range-track-color);
243
+ border-radius: 4px;
244
+ padding: 4px 8px;
245
+ margin-left: 8px;
246
+ font-size: 12px;
247
+ cursor: pointer;
248
+ }
249
+ .speed-selector:hover {
250
+ background: rgba(0, 0, 0, 0.6);
251
+ }
252
+ .speed-selector:focus {
253
+ outline: none;
254
+ border-color: var(--helios-accent-color);
255
+ }
256
+ .fullscreen-btn {
257
+ background: none;
258
+ border: none;
259
+ color: var(--helios-text-color);
260
+ font-size: 20px;
261
+ cursor: pointer;
262
+ width: 40px;
263
+ height: 40px;
264
+ margin-left: 8px;
265
+ }
266
+ .fullscreen-btn:hover {
267
+ color: var(--helios-accent-color);
268
+ }
269
+ .captions-container {
270
+ position: absolute;
271
+ bottom: 60px;
272
+ left: 50%;
273
+ transform: translateX(-50%);
274
+ width: 80%;
275
+ text-align: center;
276
+ pointer-events: none;
277
+ display: flex;
278
+ flex-direction: column;
279
+ align-items: center;
280
+ gap: 4px;
281
+ z-index: 5;
282
+ }
283
+ .caption-cue {
284
+ background: rgba(0, 0, 0, 0.7);
285
+ color: white;
286
+ padding: 4px 8px;
287
+ border-radius: 4px;
288
+ font-size: 16px;
289
+ text-shadow: 0 1px 2px black;
290
+ white-space: pre-wrap;
291
+ }
292
+ .cc-btn {
293
+ background: none;
294
+ border: none;
295
+ color: var(--helios-text-color);
296
+ font-size: 14px;
297
+ font-weight: bold;
298
+ cursor: pointer;
299
+ width: 32px;
300
+ height: 32px;
301
+ display: flex;
302
+ align-items: center;
303
+ justify-content: center;
304
+ margin-left: 4px;
305
+ opacity: 0.7;
306
+ }
307
+ .cc-btn:hover {
308
+ opacity: 1;
309
+ }
310
+ .cc-btn.active {
311
+ opacity: 1;
312
+ color: var(--helios-accent-color);
313
+ border-bottom: 2px solid var(--helios-accent-color);
314
+ }
315
+ .poster-container {
316
+ position: absolute;
317
+ inset: 0;
318
+ background-color: black;
319
+ z-index: 5;
320
+ display: flex;
321
+ align-items: center;
322
+ justify-content: center;
323
+ cursor: pointer;
324
+ transition: opacity 0.3s;
325
+ }
326
+ .poster-container.hidden {
327
+ opacity: 0;
328
+ pointer-events: none;
329
+ }
330
+ .poster-image {
331
+ position: absolute;
332
+ inset: 0;
333
+ width: 100%;
334
+ height: 100%;
335
+ object-fit: cover;
336
+ opacity: 0.6;
337
+ }
338
+ .big-play-btn {
339
+ position: relative;
340
+ z-index: 10;
341
+ background: rgba(0, 0, 0, 0.7);
342
+ border: 2px solid white;
343
+ border-radius: 50%;
344
+ width: 80px;
345
+ height: 80px;
346
+ color: white;
347
+ font-size: 40px;
348
+ display: flex;
349
+ align-items: center;
350
+ justify-content: center;
351
+ cursor: pointer;
352
+ transition: transform 0.2s;
353
+ }
354
+ .big-play-btn:hover {
355
+ transform: scale(1.1);
356
+ }
357
+
358
+ .click-layer {
359
+ position: absolute;
360
+ inset: 0;
361
+ z-index: 1;
362
+ background: transparent;
363
+ }
364
+ :host([interactive]) .click-layer {
365
+ pointer-events: none;
366
+ }
367
+
368
+ /* Responsive Layouts */
369
+ .controls.layout-compact .volume-slider {
370
+ display: none;
371
+ }
372
+ .controls.layout-tiny .volume-slider {
373
+ display: none;
374
+ }
375
+ .controls.layout-tiny .speed-selector {
376
+ display: none;
377
+ }
378
+ slot {
379
+ display: none;
380
+ }
381
+ </style>
382
+ <slot></slot>
383
+ <div class="status-overlay hidden" part="overlay">
384
+ <div class="status-text">Connecting...</div>
385
+ <button class="retry-btn" style="display: none">Retry</button>
386
+ </div>
387
+ <div class="poster-container hidden" part="poster">
388
+ <img class="poster-image" alt="Video poster" />
389
+ <div class="big-play-btn" aria-label="Play video">▶</div>
390
+ </div>
391
+ <iframe part="iframe" sandbox="allow-scripts allow-same-origin" title="Helios Composition Preview"></iframe>
392
+ <div class="click-layer" part="click-layer"></div>
393
+ <div class="captions-container" part="captions"></div>
394
+ <div class="controls" role="toolbar" aria-label="Playback Controls">
395
+ <button class="play-pause-btn" part="play-pause-button" aria-label="Play">▶</button>
396
+ <div class="volume-control">
397
+ <button class="volume-btn" part="volume-button" aria-label="Mute">🔊</button>
398
+ <input type="range" class="volume-slider" min="0" max="1" step="0.05" value="1" part="volume-slider" aria-label="Volume">
399
+ </div>
400
+ <button class="cc-btn" part="cc-button" aria-label="Toggle Captions">CC</button>
401
+ <button class="export-btn" part="export-button" aria-label="Export video">Export</button>
402
+ <select class="speed-selector" part="speed-selector" aria-label="Playback speed">
403
+ <option value="0.25">0.25x</option>
404
+ <option value="0.5">0.5x</option>
405
+ <option value="1" selected>1x</option>
406
+ <option value="2">2x</option>
407
+ </select>
408
+ <div class="scrubber-wrapper">
409
+ <div class="scrubber-tooltip hidden" part="tooltip"></div>
410
+ <div class="markers-container" part="markers"></div>
411
+ <input type="range" class="scrubber" min="0" value="0" step="1" part="scrubber" aria-label="Seek time">
412
+ </div>
413
+ <div class="time-display" part="time-display">0.00 / 0.00</div>
414
+ <button class="fullscreen-btn" part="fullscreen-button" aria-label="Toggle fullscreen">⛶</button>
415
+ </div>
416
+ `;
417
+ export class HeliosPlayer extends HTMLElement {
418
+ iframe;
419
+ _textTracks;
420
+ _domTracks = new Map();
421
+ playPauseBtn;
422
+ volumeBtn;
423
+ volumeSlider;
424
+ scrubber;
425
+ scrubberWrapper;
426
+ scrubberTooltip;
427
+ markersContainer;
428
+ timeDisplay;
429
+ exportBtn;
430
+ overlay;
431
+ statusText;
432
+ retryBtn;
433
+ retryAction;
434
+ speedSelector;
435
+ fullscreenBtn;
436
+ captionsContainer;
437
+ ccBtn;
438
+ showCaptions = false;
439
+ clickLayer;
440
+ posterContainer;
441
+ posterImage;
442
+ bigPlayBtn;
443
+ pendingSrc = null;
444
+ isLoaded = false;
445
+ resizeObserver;
446
+ controller = null;
447
+ // Keep track if we have direct access (optional, mainly for debugging/logging)
448
+ directHelios = null;
449
+ unsubscribe = null;
450
+ connectionInterval = null;
451
+ abortController = null;
452
+ isExporting = false;
453
+ isScrubbing = false;
454
+ wasPlayingBeforeScrub = false;
455
+ lastState = null;
456
+ pendingProps = null;
457
+ _error = null;
458
+ // --- Standard Media API States ---
459
+ static HAVE_NOTHING = 0;
460
+ static HAVE_METADATA = 1;
461
+ static HAVE_CURRENT_DATA = 2;
462
+ static HAVE_FUTURE_DATA = 3;
463
+ static HAVE_ENOUGH_DATA = 4;
464
+ static NETWORK_EMPTY = 0;
465
+ static NETWORK_IDLE = 1;
466
+ static NETWORK_LOADING = 2;
467
+ static NETWORK_NO_SOURCE = 3;
468
+ _readyState = HeliosPlayer.HAVE_NOTHING;
469
+ _networkState = HeliosPlayer.NETWORK_EMPTY;
470
+ get readyState() {
471
+ return this._readyState;
472
+ }
473
+ get networkState() {
474
+ return this._networkState;
475
+ }
476
+ get error() {
477
+ return this._error;
478
+ }
479
+ get currentSrc() {
480
+ return this.src;
481
+ }
482
+ // --- Standard Media API ---
483
+ canPlayType(type) {
484
+ // We strictly play Helios compositions, not standard video MIME types.
485
+ // Return empty string to be spec-compliant for video/mp4 etc.
486
+ return "";
487
+ }
488
+ get defaultMuted() {
489
+ return this.hasAttribute("muted");
490
+ }
491
+ set defaultMuted(val) {
492
+ if (val) {
493
+ this.setAttribute("muted", "");
494
+ }
495
+ else {
496
+ this.removeAttribute("muted");
497
+ }
498
+ }
499
+ _defaultPlaybackRate = 1.0;
500
+ get defaultPlaybackRate() {
501
+ return this._defaultPlaybackRate;
502
+ }
503
+ set defaultPlaybackRate(val) {
504
+ if (this._defaultPlaybackRate !== val) {
505
+ this._defaultPlaybackRate = val;
506
+ this.dispatchEvent(new Event("ratechange"));
507
+ }
508
+ }
509
+ _preservesPitch = true;
510
+ get preservesPitch() {
511
+ return this._preservesPitch;
512
+ }
513
+ set preservesPitch(val) {
514
+ this._preservesPitch = val;
515
+ }
516
+ get srcObject() {
517
+ return null;
518
+ }
519
+ set srcObject(val) {
520
+ console.warn("HeliosPlayer does not support srcObject");
521
+ }
522
+ get crossOrigin() {
523
+ return this.getAttribute("crossorigin");
524
+ }
525
+ set crossOrigin(val) {
526
+ if (val !== null) {
527
+ this.setAttribute("crossorigin", val);
528
+ }
529
+ else {
530
+ this.removeAttribute("crossorigin");
531
+ }
532
+ }
533
+ get seeking() {
534
+ // Return internal scrubbing state as seeking
535
+ return this.isScrubbing;
536
+ }
537
+ get buffered() {
538
+ return new StaticTimeRange(0, this.duration);
539
+ }
540
+ get seekable() {
541
+ return new StaticTimeRange(0, this.duration);
542
+ }
543
+ get played() {
544
+ // Standard Media API: played range matches duration
545
+ return new StaticTimeRange(0, this.duration);
546
+ }
547
+ get videoWidth() {
548
+ if (this.controller) {
549
+ const state = this.controller.getState();
550
+ if (state.width)
551
+ return state.width;
552
+ }
553
+ return parseFloat(this.getAttribute("width") || "0");
554
+ }
555
+ get videoHeight() {
556
+ if (this.controller) {
557
+ const state = this.controller.getState();
558
+ if (state.height)
559
+ return state.height;
560
+ }
561
+ return parseFloat(this.getAttribute("height") || "0");
562
+ }
563
+ get currentTime() {
564
+ if (!this.controller)
565
+ return 0;
566
+ const s = this.controller.getState();
567
+ return s.fps ? s.currentFrame / s.fps : 0;
568
+ }
569
+ set currentTime(val) {
570
+ if (this.controller) {
571
+ const s = this.controller.getState();
572
+ if (s.fps) {
573
+ // Dispatch events to satisfy Standard Media API expectations
574
+ this.dispatchEvent(new Event("seeking"));
575
+ this.controller.seek(Math.floor(val * s.fps));
576
+ this.dispatchEvent(new Event("seeked"));
577
+ }
578
+ }
579
+ }
580
+ get currentFrame() {
581
+ return this.controller ? this.controller.getState().currentFrame : 0;
582
+ }
583
+ set currentFrame(val) {
584
+ if (this.controller) {
585
+ // Dispatch events to satisfy Standard Media API expectations
586
+ this.dispatchEvent(new Event("seeking"));
587
+ this.controller.seek(Math.floor(val));
588
+ this.dispatchEvent(new Event("seeked"));
589
+ }
590
+ }
591
+ get duration() {
592
+ return this.controller ? this.controller.getState().duration : 0;
593
+ }
594
+ get paused() {
595
+ return this.controller ? !this.controller.getState().isPlaying : true;
596
+ }
597
+ get ended() {
598
+ if (!this.controller)
599
+ return false;
600
+ const s = this.controller.getState();
601
+ return s.currentFrame >= s.duration * s.fps - 1;
602
+ }
603
+ get volume() {
604
+ return this.controller ? this.controller.getState().volume ?? 1 : 1;
605
+ }
606
+ set volume(val) {
607
+ if (this.controller) {
608
+ this.controller.setAudioVolume(Math.max(0, Math.min(1, val)));
609
+ }
610
+ }
611
+ get muted() {
612
+ return this.controller ? !!this.controller.getState().muted : false;
613
+ }
614
+ set muted(val) {
615
+ if (this.controller) {
616
+ this.controller.setAudioMuted(val);
617
+ }
618
+ }
619
+ get interactive() {
620
+ return this.hasAttribute("interactive");
621
+ }
622
+ set interactive(val) {
623
+ if (val) {
624
+ this.setAttribute("interactive", "");
625
+ }
626
+ else {
627
+ this.removeAttribute("interactive");
628
+ }
629
+ }
630
+ get playbackRate() {
631
+ return this.controller ? this.controller.getState().playbackRate ?? 1 : 1;
632
+ }
633
+ set playbackRate(val) {
634
+ if (this.controller) {
635
+ this.controller.setPlaybackRate(val);
636
+ }
637
+ }
638
+ get fps() {
639
+ return this.controller ? this.controller.getState().fps : 0;
640
+ }
641
+ get src() {
642
+ return this.getAttribute("src") || "";
643
+ }
644
+ set src(val) {
645
+ this.setAttribute("src", val);
646
+ }
647
+ get autoplay() {
648
+ return this.hasAttribute("autoplay");
649
+ }
650
+ set autoplay(val) {
651
+ if (val) {
652
+ this.setAttribute("autoplay", "");
653
+ }
654
+ else {
655
+ this.removeAttribute("autoplay");
656
+ }
657
+ }
658
+ get loop() {
659
+ return this.hasAttribute("loop");
660
+ }
661
+ set loop(val) {
662
+ if (val) {
663
+ this.setAttribute("loop", "");
664
+ }
665
+ else {
666
+ this.removeAttribute("loop");
667
+ }
668
+ }
669
+ get controls() {
670
+ return this.hasAttribute("controls");
671
+ }
672
+ set controls(val) {
673
+ if (val) {
674
+ this.setAttribute("controls", "");
675
+ }
676
+ else {
677
+ this.removeAttribute("controls");
678
+ }
679
+ }
680
+ get poster() {
681
+ return this.getAttribute("poster") || "";
682
+ }
683
+ set poster(val) {
684
+ this.setAttribute("poster", val);
685
+ }
686
+ get preload() {
687
+ return this.getAttribute("preload") || "auto";
688
+ }
689
+ set preload(val) {
690
+ this.setAttribute("preload", val);
691
+ }
692
+ get sandbox() {
693
+ return this.getAttribute("sandbox") || "allow-scripts allow-same-origin";
694
+ }
695
+ set sandbox(val) {
696
+ this.setAttribute("sandbox", val);
697
+ }
698
+ async play() {
699
+ if (!this.isLoaded) {
700
+ this.setAttribute("autoplay", "");
701
+ this.load();
702
+ }
703
+ else if (this.controller) {
704
+ this.controller.play();
705
+ }
706
+ }
707
+ load() {
708
+ if (this.pendingSrc) {
709
+ const src = this.pendingSrc;
710
+ this.pendingSrc = null;
711
+ this.loadIframe(src);
712
+ }
713
+ else {
714
+ const src = this.getAttribute("src");
715
+ if (src) {
716
+ this.loadIframe(src);
717
+ }
718
+ }
719
+ }
720
+ pause() {
721
+ if (this.controller) {
722
+ this.controller.pause();
723
+ }
724
+ }
725
+ static get observedAttributes() {
726
+ return ["src", "width", "height", "autoplay", "loop", "controls", "export-format", "input-props", "poster", "muted", "interactive", "preload", "controlslist", "sandbox", "export-caption-mode"];
727
+ }
728
+ constructor() {
729
+ super();
730
+ this.attachShadow({ mode: "open" });
731
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
732
+ this.iframe = this.shadowRoot.querySelector("iframe");
733
+ this.playPauseBtn = this.shadowRoot.querySelector(".play-pause-btn");
734
+ this.volumeBtn = this.shadowRoot.querySelector(".volume-btn");
735
+ this.volumeSlider = this.shadowRoot.querySelector(".volume-slider");
736
+ this.scrubber = this.shadowRoot.querySelector(".scrubber");
737
+ this.scrubberWrapper = this.shadowRoot.querySelector(".scrubber-wrapper");
738
+ this.scrubberTooltip = this.shadowRoot.querySelector(".scrubber-tooltip");
739
+ this.markersContainer = this.shadowRoot.querySelector(".markers-container");
740
+ this.timeDisplay = this.shadowRoot.querySelector(".time-display");
741
+ this.exportBtn = this.shadowRoot.querySelector(".export-btn");
742
+ this.overlay = this.shadowRoot.querySelector(".status-overlay");
743
+ this.statusText = this.shadowRoot.querySelector(".status-text");
744
+ this.retryBtn = this.shadowRoot.querySelector(".retry-btn");
745
+ this.speedSelector = this.shadowRoot.querySelector(".speed-selector");
746
+ this.fullscreenBtn = this.shadowRoot.querySelector(".fullscreen-btn");
747
+ this.captionsContainer = this.shadowRoot.querySelector(".captions-container");
748
+ this.ccBtn = this.shadowRoot.querySelector(".cc-btn");
749
+ this.clickLayer = this.shadowRoot.querySelector(".click-layer");
750
+ this.posterContainer = this.shadowRoot.querySelector(".poster-container");
751
+ this.posterImage = this.shadowRoot.querySelector(".poster-image");
752
+ this.bigPlayBtn = this.shadowRoot.querySelector(".big-play-btn");
753
+ this.retryAction = () => this.retryConnection();
754
+ this.retryBtn.onclick = () => this.retryAction();
755
+ this.clickLayer.addEventListener("click", () => {
756
+ this.focus();
757
+ this.togglePlayPause();
758
+ });
759
+ this.clickLayer.addEventListener("dblclick", () => this.toggleFullscreen());
760
+ this._textTracks = new HeliosTextTrackList();
761
+ this.resizeObserver = new ResizeObserver((entries) => {
762
+ for (const entry of entries) {
763
+ const width = entry.contentRect.width;
764
+ const controls = this.shadowRoot.querySelector(".controls");
765
+ if (controls) {
766
+ controls.classList.toggle("layout-compact", width < 500);
767
+ controls.classList.toggle("layout-tiny", width < 350);
768
+ }
769
+ }
770
+ });
771
+ }
772
+ get textTracks() {
773
+ return this._textTracks;
774
+ }
775
+ addTextTrack(kind, label = "", language = "") {
776
+ const track = new HeliosTextTrack(kind, label, language, this);
777
+ this._textTracks.addTrack(track);
778
+ return track;
779
+ }
780
+ handleTrackModeChange(track) {
781
+ if (!this.controller)
782
+ return;
783
+ if (track.mode === 'showing') {
784
+ // Enforce mutual exclusivity for 'captions'
785
+ if (track.kind === 'captions') {
786
+ for (const t of this._textTracks) {
787
+ if (t !== track && t.kind === 'captions' && t.mode === 'showing') {
788
+ t.mode = 'hidden';
789
+ }
790
+ }
791
+ }
792
+ // Extract cues into the format Helios expects
793
+ const captions = track.cues.map((cue, index) => ({
794
+ id: cue.id || String(index + 1),
795
+ startTime: cue.startTime * 1000, // Convert seconds to milliseconds
796
+ endTime: cue.endTime * 1000, // Convert seconds to milliseconds
797
+ text: cue.text
798
+ }));
799
+ this.controller.setCaptions(captions);
800
+ }
801
+ else {
802
+ // If hiding/disabling, check if any other track is showing
803
+ const showingTrack = Array.from(this._textTracks).find(t => t.mode === 'showing' && t.kind === 'captions');
804
+ if (showingTrack) {
805
+ const captions = showingTrack.cues.map((cue, index) => ({
806
+ id: cue.id || String(index + 1),
807
+ startTime: cue.startTime * 1000, // Convert seconds to milliseconds
808
+ endTime: cue.endTime * 1000, // Convert seconds to milliseconds
809
+ text: cue.text
810
+ }));
811
+ this.controller.setCaptions(captions);
812
+ }
813
+ else {
814
+ this.controller.setCaptions([]);
815
+ }
816
+ }
817
+ }
818
+ attributeChangedCallback(name, oldVal, newVal) {
819
+ if (oldVal === newVal)
820
+ return;
821
+ if (name === "poster") {
822
+ this.posterImage.src = newVal;
823
+ this.updatePosterVisibility();
824
+ }
825
+ if (name === "src") {
826
+ const preload = this.getAttribute("preload") || "auto";
827
+ if (preload === "none" && !this.isLoaded) {
828
+ this.pendingSrc = newVal;
829
+ this.updatePosterVisibility();
830
+ // Hide loading/connecting status since we are deferring load
831
+ this.hideStatus();
832
+ }
833
+ else {
834
+ this.loadIframe(newVal);
835
+ }
836
+ }
837
+ if (name === "width" || name === "height") {
838
+ this.updateAspectRatio();
839
+ }
840
+ if (name === "loop") {
841
+ if (this.controller) {
842
+ this.controller.setLoop(this.hasAttribute("loop"));
843
+ }
844
+ }
845
+ if (name === "input-props") {
846
+ try {
847
+ const props = JSON.parse(newVal);
848
+ this.pendingProps = props;
849
+ if (this.controller) {
850
+ this.controller.setInputProps(props);
851
+ }
852
+ }
853
+ catch (e) {
854
+ console.warn("HeliosPlayer: Invalid JSON in input-props", e);
855
+ }
856
+ }
857
+ if (name === "muted") {
858
+ if (this.controller) {
859
+ this.controller.setAudioMuted(this.hasAttribute("muted"));
860
+ }
861
+ }
862
+ if (name === "controlslist") {
863
+ this.updateControlsVisibility();
864
+ }
865
+ if (name === "sandbox") {
866
+ const newValOrNull = this.getAttribute("sandbox");
867
+ // If attribute is missing (null), use default.
868
+ // If present (even if empty string ""), use it as is.
869
+ const flags = newValOrNull === null ? "allow-scripts allow-same-origin" : newValOrNull;
870
+ if (this.iframe.getAttribute("sandbox") !== flags) {
871
+ this.iframe.setAttribute("sandbox", flags);
872
+ // If we have a source, we must reload for new sandbox flags to apply
873
+ if (this.getAttribute("src")) {
874
+ this.loadIframe(this.getAttribute("src"));
875
+ }
876
+ }
877
+ }
878
+ }
879
+ updateControlsVisibility() {
880
+ if (!this.exportBtn || !this.fullscreenBtn)
881
+ return;
882
+ const attr = this.getAttribute("controlslist") || "";
883
+ const tokens = attr.toLowerCase().split(/\s+/);
884
+ if (tokens.includes("nodownload")) {
885
+ this.exportBtn.style.display = "none";
886
+ }
887
+ else {
888
+ this.exportBtn.style.removeProperty("display");
889
+ }
890
+ if (tokens.includes("nofullscreen")) {
891
+ this.fullscreenBtn.style.display = "none";
892
+ }
893
+ else {
894
+ this.fullscreenBtn.style.removeProperty("display");
895
+ }
896
+ }
897
+ get inputProps() {
898
+ return this.pendingProps;
899
+ }
900
+ set inputProps(val) {
901
+ this.pendingProps = val;
902
+ if (this.controller && val) {
903
+ this.controller.setInputProps(val);
904
+ }
905
+ }
906
+ connectedCallback() {
907
+ this.setAttribute("tabindex", "0");
908
+ this.iframe.addEventListener("load", this.handleIframeLoad);
909
+ window.addEventListener("message", this.handleWindowMessage);
910
+ this.addEventListener("keydown", this.handleKeydown);
911
+ document.addEventListener("fullscreenchange", this.updateFullscreenUI);
912
+ this.playPauseBtn.addEventListener("click", this.togglePlayPause);
913
+ this.volumeBtn.addEventListener("click", this.toggleMute);
914
+ this.volumeSlider.addEventListener("input", this.handleVolumeInput);
915
+ this.scrubber.addEventListener("input", this.handleScrubberInput);
916
+ this.scrubber.addEventListener("mousedown", this.handleScrubStart);
917
+ this.scrubber.addEventListener("change", this.handleScrubEnd);
918
+ this.scrubber.addEventListener("touchstart", this.handleScrubStart, { passive: true });
919
+ this.scrubber.addEventListener("touchend", this.handleScrubEnd);
920
+ this.scrubber.addEventListener("touchcancel", this.handleScrubEnd);
921
+ this.scrubberWrapper.addEventListener("mousemove", this.handleScrubberHover);
922
+ this.scrubberWrapper.addEventListener("mouseleave", this.handleScrubberLeave);
923
+ this.exportBtn.addEventListener("click", this.renderClientSide);
924
+ this.speedSelector.addEventListener("change", this.handleSpeedChange);
925
+ this.fullscreenBtn.addEventListener("click", this.toggleFullscreen);
926
+ this.ccBtn.addEventListener("click", this.toggleCaptions);
927
+ this.bigPlayBtn.addEventListener("click", this.handleBigPlayClick);
928
+ this.posterContainer.addEventListener("click", this.handleBigPlayClick);
929
+ const slot = this.shadowRoot.querySelector("slot");
930
+ if (slot) {
931
+ slot.addEventListener("slotchange", this.handleSlotChange);
932
+ // Initial check
933
+ this.handleSlotChange();
934
+ }
935
+ // Initial state: disabled until connected
936
+ this.setControlsDisabled(true);
937
+ // Ensure sandbox flags are correct on connect (handling if attribute was present before upgrade)
938
+ const sandboxAttr = this.getAttribute("sandbox");
939
+ const sandboxFlags = sandboxAttr === null ? "allow-scripts allow-same-origin" : sandboxAttr;
940
+ if (this.iframe.getAttribute("sandbox") !== sandboxFlags) {
941
+ this.iframe.setAttribute("sandbox", sandboxFlags);
942
+ }
943
+ // Only show connecting if we haven't already shown "Loading..." via attributeChangedCallback
944
+ // AND we are not deferring load (pendingSrc is null)
945
+ // AND we don't have a poster (which should take precedence visually)
946
+ if (this.overlay.classList.contains("hidden") && !this.pendingSrc && !this.hasAttribute("poster")) {
947
+ this.showStatus("Connecting...", false);
948
+ }
949
+ if (this.pendingSrc) {
950
+ this.updatePosterVisibility();
951
+ }
952
+ // Ensure aspect ratio is correct on connect
953
+ this.updateAspectRatio();
954
+ this.updateControlsVisibility();
955
+ this.resizeObserver.observe(this);
956
+ }
957
+ disconnectedCallback() {
958
+ this.resizeObserver.disconnect();
959
+ this.iframe.removeEventListener("load", this.handleIframeLoad);
960
+ window.removeEventListener("message", this.handleWindowMessage);
961
+ this.removeEventListener("keydown", this.handleKeydown);
962
+ document.removeEventListener("fullscreenchange", this.updateFullscreenUI);
963
+ this.playPauseBtn.removeEventListener("click", this.togglePlayPause);
964
+ this.volumeBtn.removeEventListener("click", this.toggleMute);
965
+ this.volumeSlider.removeEventListener("input", this.handleVolumeInput);
966
+ this.scrubber.removeEventListener("input", this.handleScrubberInput);
967
+ this.scrubber.removeEventListener("mousedown", this.handleScrubStart);
968
+ this.scrubber.removeEventListener("change", this.handleScrubEnd);
969
+ this.scrubber.removeEventListener("touchstart", this.handleScrubStart);
970
+ this.scrubber.removeEventListener("touchend", this.handleScrubEnd);
971
+ this.scrubber.removeEventListener("touchcancel", this.handleScrubEnd);
972
+ this.scrubberWrapper.removeEventListener("mousemove", this.handleScrubberHover);
973
+ this.scrubberWrapper.removeEventListener("mouseleave", this.handleScrubberLeave);
974
+ this.exportBtn.removeEventListener("click", this.renderClientSide);
975
+ this.speedSelector.removeEventListener("change", this.handleSpeedChange);
976
+ this.fullscreenBtn.removeEventListener("click", this.toggleFullscreen);
977
+ this.ccBtn.removeEventListener("click", this.toggleCaptions);
978
+ this.bigPlayBtn.removeEventListener("click", this.handleBigPlayClick);
979
+ this.posterContainer.removeEventListener("click", this.handleBigPlayClick);
980
+ const slot = this.shadowRoot.querySelector("slot");
981
+ if (slot) {
982
+ slot.removeEventListener("slotchange", this.handleSlotChange);
983
+ }
984
+ this.stopConnectionAttempts();
985
+ if (this.unsubscribe) {
986
+ this.unsubscribe();
987
+ }
988
+ if (this.controller) {
989
+ this.controller.pause();
990
+ this.controller.dispose();
991
+ }
992
+ }
993
+ loadIframe(src) {
994
+ this._error = null;
995
+ this._networkState = HeliosPlayer.NETWORK_LOADING;
996
+ this._readyState = HeliosPlayer.HAVE_NOTHING;
997
+ this.dispatchEvent(new Event('loadstart'));
998
+ this.iframe.src = src;
999
+ this.isLoaded = true;
1000
+ if (this.controller) {
1001
+ this.controller.pause();
1002
+ this.controller.dispose();
1003
+ this.controller = null;
1004
+ }
1005
+ this.setControlsDisabled(true);
1006
+ // Only show status if no poster, to avoid flashing/overlaying
1007
+ if (!this.hasAttribute("poster")) {
1008
+ this.showStatus("Loading...", false);
1009
+ }
1010
+ this.updatePosterVisibility();
1011
+ }
1012
+ handleBigPlayClick = () => {
1013
+ this.load();
1014
+ // If we are already loaded, just play
1015
+ if (this.controller) {
1016
+ this.controller.play();
1017
+ }
1018
+ else {
1019
+ // Set autoplay so the controller will play once connected
1020
+ this.setAttribute("autoplay", "");
1021
+ }
1022
+ };
1023
+ updatePosterVisibility() {
1024
+ if (this.pendingSrc) {
1025
+ this.posterContainer.classList.remove("hidden");
1026
+ return;
1027
+ }
1028
+ if (this.hasAttribute("poster")) {
1029
+ let shouldHide = false;
1030
+ if (this.controller) {
1031
+ const state = this.controller.getState();
1032
+ if (state.isPlaying || state.currentFrame > 0) {
1033
+ shouldHide = true;
1034
+ }
1035
+ }
1036
+ if (shouldHide) {
1037
+ this.posterContainer.classList.add("hidden");
1038
+ }
1039
+ else {
1040
+ this.posterContainer.classList.remove("hidden");
1041
+ }
1042
+ }
1043
+ else {
1044
+ this.posterContainer.classList.add("hidden");
1045
+ }
1046
+ }
1047
+ setControlsDisabled(disabled) {
1048
+ this.playPauseBtn.disabled = disabled;
1049
+ this.volumeBtn.disabled = disabled;
1050
+ this.volumeSlider.disabled = disabled;
1051
+ this.scrubber.disabled = disabled;
1052
+ this.speedSelector.disabled = disabled;
1053
+ this.fullscreenBtn.disabled = disabled;
1054
+ this.ccBtn.disabled = disabled;
1055
+ // Export is managed separately based on connection state
1056
+ if (disabled) {
1057
+ this.exportBtn.disabled = true;
1058
+ }
1059
+ }
1060
+ lockPlaybackControls(locked) {
1061
+ this.playPauseBtn.disabled = locked;
1062
+ this.volumeBtn.disabled = locked;
1063
+ this.volumeSlider.disabled = locked;
1064
+ this.scrubber.disabled = locked;
1065
+ this.speedSelector.disabled = locked;
1066
+ this.fullscreenBtn.disabled = locked;
1067
+ this.ccBtn.disabled = locked;
1068
+ }
1069
+ handleIframeLoad = () => {
1070
+ if (!this.iframe.contentWindow)
1071
+ return;
1072
+ this.startConnectionAttempts();
1073
+ };
1074
+ startConnectionAttempts() {
1075
+ this.stopConnectionAttempts();
1076
+ // 1. Bridge Mode (Fire and forget, wait for message)
1077
+ // We send this immediately so if the iframe is listening it can respond.
1078
+ this.iframe.contentWindow?.postMessage({ type: 'HELIOS_CONNECT' }, '*');
1079
+ // 2. Direct Mode (Polling)
1080
+ const checkDirect = () => {
1081
+ let directInstance;
1082
+ try {
1083
+ directInstance = this.iframe.contentWindow.helios;
1084
+ }
1085
+ catch (e) {
1086
+ // Access denied (Cross-origin)
1087
+ }
1088
+ if (directInstance) {
1089
+ console.log("HeliosPlayer: Connected via Direct Mode.");
1090
+ this.stopConnectionAttempts();
1091
+ this.hideStatus();
1092
+ this.directHelios = directInstance;
1093
+ this.setController(new DirectController(directInstance, this.iframe));
1094
+ this.exportBtn.disabled = false;
1095
+ return true;
1096
+ }
1097
+ return false;
1098
+ };
1099
+ // Check immediately to avoid unnecessary delay
1100
+ if (checkDirect())
1101
+ return;
1102
+ // We poll because window.helios might be set asynchronously.
1103
+ const startTime = Date.now();
1104
+ this.connectionInterval = window.setInterval(() => {
1105
+ // If we connected via Bridge in the meantime, stop polling
1106
+ if (this.controller) {
1107
+ this.stopConnectionAttempts();
1108
+ return;
1109
+ }
1110
+ if (checkDirect())
1111
+ return;
1112
+ // Timeout check (5 seconds)
1113
+ if (Date.now() - startTime > 5000) {
1114
+ this.stopConnectionAttempts();
1115
+ if (!this.controller) {
1116
+ this.showStatus("Connection Failed. Ensure window.helios is set or connectToParent() is called.", true);
1117
+ }
1118
+ }
1119
+ }, 100);
1120
+ }
1121
+ stopConnectionAttempts() {
1122
+ if (this.connectionInterval) {
1123
+ window.clearInterval(this.connectionInterval);
1124
+ this.connectionInterval = null;
1125
+ }
1126
+ }
1127
+ handleWindowMessage = (event) => {
1128
+ if (event.source !== this.iframe.contentWindow)
1129
+ return;
1130
+ // Check if this message is a handshake response
1131
+ if (event.data?.type === 'HELIOS_READY') {
1132
+ // If we receive a ready signal, we stop polling for direct access
1133
+ this.stopConnectionAttempts();
1134
+ this.hideStatus();
1135
+ // If we already have a controller (e.g. Direct Mode), we might stick with it
1136
+ if (!this.controller) {
1137
+ console.log("HeliosPlayer: Connected via Bridge Mode.");
1138
+ const iframeWin = this.iframe.contentWindow;
1139
+ if (iframeWin) {
1140
+ this.setController(new BridgeController(iframeWin, event.data.state));
1141
+ // Ensure we get the latest state immediately if provided
1142
+ if (event.data.state) {
1143
+ this.updateUI(event.data.state);
1144
+ }
1145
+ // Enable export for bridge mode
1146
+ this.exportBtn.disabled = false;
1147
+ }
1148
+ }
1149
+ }
1150
+ };
1151
+ handleSlotChange = () => {
1152
+ const slot = this.shadowRoot.querySelector("slot");
1153
+ if (!slot)
1154
+ return;
1155
+ const elements = slot.assignedElements();
1156
+ const currentTrackElements = new Set();
1157
+ elements.forEach((el) => {
1158
+ if (el.tagName === "TRACK") {
1159
+ const t = el;
1160
+ currentTrackElements.add(t);
1161
+ // Prevent duplicate track creation
1162
+ if (this._domTracks.has(t))
1163
+ return;
1164
+ const kind = t.getAttribute("kind") || "captions";
1165
+ const label = t.getAttribute("label") || "";
1166
+ const lang = t.getAttribute("srclang") || "";
1167
+ const src = t.getAttribute("src");
1168
+ const isDefault = t.hasAttribute("default");
1169
+ const textTrack = this.addTextTrack(kind, label, lang);
1170
+ this._domTracks.set(t, textTrack);
1171
+ if (src) {
1172
+ fetch(src)
1173
+ .then((res) => {
1174
+ if (!res.ok)
1175
+ throw new Error(`Status ${res.status}`);
1176
+ return res.text();
1177
+ })
1178
+ .then((srt) => {
1179
+ const cues = parseSRT(srt);
1180
+ cues.forEach(c => {
1181
+ textTrack.addCue(new CueClass(c.startTime, c.endTime, c.text));
1182
+ });
1183
+ if (isDefault) {
1184
+ textTrack.mode = 'showing';
1185
+ }
1186
+ else {
1187
+ textTrack.mode = 'disabled';
1188
+ }
1189
+ })
1190
+ .catch((err) => console.error("HeliosPlayer: Failed to load captions", err));
1191
+ }
1192
+ }
1193
+ });
1194
+ // Remove tracks that are no longer in the DOM
1195
+ for (const [el, track] of this._domTracks.entries()) {
1196
+ if (!currentTrackElements.has(el)) {
1197
+ if (track.mode === 'showing') {
1198
+ track.mode = 'hidden';
1199
+ }
1200
+ this._textTracks.removeTrack(track);
1201
+ this._domTracks.delete(el);
1202
+ }
1203
+ }
1204
+ };
1205
+ setController(controller) {
1206
+ // Clean up old controller
1207
+ if (this.controller) {
1208
+ this.controller.dispose();
1209
+ }
1210
+ if (this.unsubscribe) {
1211
+ this.unsubscribe();
1212
+ this.unsubscribe = null;
1213
+ }
1214
+ this.controller = controller;
1215
+ // Check for pending captions
1216
+ this.handleSlotChange();
1217
+ // Update States
1218
+ this._networkState = HeliosPlayer.NETWORK_IDLE;
1219
+ this._readyState = HeliosPlayer.HAVE_ENOUGH_DATA;
1220
+ // Dispatch Lifecycle Events
1221
+ this.dispatchEvent(new Event('loadedmetadata'));
1222
+ this.dispatchEvent(new Event('loadeddata'));
1223
+ this.dispatchEvent(new Event('canplay'));
1224
+ this.dispatchEvent(new Event('canplaythrough'));
1225
+ this.setControlsDisabled(false);
1226
+ if (this.pendingProps) {
1227
+ this.controller.setInputProps(this.pendingProps);
1228
+ }
1229
+ if (this.hasAttribute("muted")) {
1230
+ this.controller.setAudioMuted(true);
1231
+ }
1232
+ if (this.hasAttribute("loop")) {
1233
+ this.controller.setLoop(true);
1234
+ }
1235
+ const state = this.controller.getState();
1236
+ if (state) {
1237
+ this.scrubber.max = String(state.duration * state.fps);
1238
+ this.updateUI(state);
1239
+ }
1240
+ const unsubState = this.controller.subscribe((s) => this.updateUI(s));
1241
+ const unsubError = this.controller.onError((err) => {
1242
+ const message = err.message || String(err);
1243
+ this._error = {
1244
+ code: 4, // MEDIA_ERR_SRC_NOT_SUPPORTED as generic default
1245
+ message: message,
1246
+ MEDIA_ERR_ABORTED: 1,
1247
+ MEDIA_ERR_NETWORK: 2,
1248
+ MEDIA_ERR_DECODE: 3,
1249
+ MEDIA_ERR_SRC_NOT_SUPPORTED: 4
1250
+ };
1251
+ this.showStatus("Error: " + message, true, {
1252
+ label: "Reload",
1253
+ handler: () => this.retryConnection()
1254
+ });
1255
+ this.dispatchEvent(new CustomEvent('error', { detail: err }));
1256
+ });
1257
+ this.unsubscribe = () => {
1258
+ unsubState();
1259
+ unsubError();
1260
+ };
1261
+ if (this.hasAttribute("autoplay")) {
1262
+ this.controller.play();
1263
+ }
1264
+ }
1265
+ updateAspectRatio() {
1266
+ const w = parseFloat(this.getAttribute("width") || "");
1267
+ const h = parseFloat(this.getAttribute("height") || "");
1268
+ if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
1269
+ this.style.aspectRatio = `${w} / ${h}`;
1270
+ }
1271
+ else {
1272
+ this.style.removeProperty("aspect-ratio");
1273
+ }
1274
+ }
1275
+ togglePlayPause = () => {
1276
+ if (!this.controller)
1277
+ return;
1278
+ const state = this.controller.getState();
1279
+ const isFinished = state.currentFrame >= state.duration * state.fps - 1;
1280
+ if (isFinished) {
1281
+ // Restart the animation
1282
+ this.controller.seek(0);
1283
+ this.controller.play();
1284
+ }
1285
+ else if (state.isPlaying) {
1286
+ this.controller.pause();
1287
+ }
1288
+ else {
1289
+ this.controller.play();
1290
+ }
1291
+ };
1292
+ toggleMute = () => {
1293
+ if (!this.controller)
1294
+ return;
1295
+ const state = this.controller.getState();
1296
+ this.controller.setAudioMuted(!state.muted);
1297
+ };
1298
+ handleVolumeInput = () => {
1299
+ if (!this.controller)
1300
+ return;
1301
+ const vol = parseFloat(this.volumeSlider.value);
1302
+ this.controller.setAudioVolume(vol);
1303
+ if (vol > 0) {
1304
+ this.controller.setAudioMuted(false);
1305
+ }
1306
+ };
1307
+ handleScrubberInput = () => {
1308
+ const frame = parseInt(this.scrubber.value, 10);
1309
+ if (this.controller) {
1310
+ this.controller.seek(frame);
1311
+ }
1312
+ };
1313
+ handleScrubStart = () => {
1314
+ if (!this.controller)
1315
+ return;
1316
+ this.isScrubbing = true;
1317
+ this.dispatchEvent(new Event("seeking"));
1318
+ const state = this.controller.getState();
1319
+ this.wasPlayingBeforeScrub = state.isPlaying;
1320
+ if (this.wasPlayingBeforeScrub) {
1321
+ this.controller.pause();
1322
+ }
1323
+ };
1324
+ handleScrubEnd = () => {
1325
+ if (!this.controller)
1326
+ return;
1327
+ this.isScrubbing = false;
1328
+ this.dispatchEvent(new Event("seeked"));
1329
+ if (this.wasPlayingBeforeScrub) {
1330
+ this.controller.play();
1331
+ }
1332
+ };
1333
+ handleScrubberHover = (e) => {
1334
+ if (!this.controller)
1335
+ return;
1336
+ const state = this.controller.getState();
1337
+ const rect = this.scrubberWrapper.getBoundingClientRect();
1338
+ const offsetX = e.clientX - rect.left;
1339
+ const width = rect.width;
1340
+ const pct = Math.max(0, Math.min(1, offsetX / width));
1341
+ const time = pct * state.duration;
1342
+ this.scrubberTooltip.textContent = time.toFixed(2) + "s";
1343
+ this.scrubberTooltip.style.left = `${offsetX}px`;
1344
+ this.scrubberTooltip.classList.remove("hidden");
1345
+ };
1346
+ handleScrubberLeave = () => {
1347
+ this.scrubberTooltip.classList.add("hidden");
1348
+ };
1349
+ handleSpeedChange = () => {
1350
+ if (this.controller) {
1351
+ this.controller.setPlaybackRate(parseFloat(this.speedSelector.value));
1352
+ }
1353
+ };
1354
+ toggleCaptions = () => {
1355
+ this.showCaptions = !this.showCaptions;
1356
+ this.ccBtn.classList.toggle("active", this.showCaptions);
1357
+ if (this.controller) {
1358
+ this.updateUI(this.controller.getState());
1359
+ }
1360
+ };
1361
+ handleKeydown = (e) => {
1362
+ if (this.isExporting)
1363
+ return;
1364
+ // Allow bubbling from children (like buttons), but ignore inputs
1365
+ const target = e.composedPath()[0];
1366
+ if (target && target.tagName) {
1367
+ const tagName = target.tagName.toLowerCase();
1368
+ if (tagName === "input" || tagName === "select" || tagName === "textarea") {
1369
+ return;
1370
+ }
1371
+ // If focusing a button, Space triggers click natively. Avoid double toggle.
1372
+ if (e.key === " " && tagName === "button") {
1373
+ return;
1374
+ }
1375
+ }
1376
+ if (!this.controller)
1377
+ return;
1378
+ switch (e.key) {
1379
+ case " ":
1380
+ case "k":
1381
+ case "K":
1382
+ e.preventDefault(); // Prevent scrolling
1383
+ this.togglePlayPause();
1384
+ break;
1385
+ case "f":
1386
+ case "F":
1387
+ this.toggleFullscreen();
1388
+ break;
1389
+ case "ArrowRight":
1390
+ case "l":
1391
+ case "L":
1392
+ this.seekRelative(e.shiftKey ? 10 : 1);
1393
+ break;
1394
+ case "ArrowLeft":
1395
+ case "j":
1396
+ case "J":
1397
+ this.seekRelative(e.shiftKey ? -10 : -1);
1398
+ break;
1399
+ case "m":
1400
+ case "M":
1401
+ this.toggleMute();
1402
+ break;
1403
+ case ".":
1404
+ this.seekRelative(1);
1405
+ break;
1406
+ case ",":
1407
+ this.seekRelative(-1);
1408
+ break;
1409
+ case "i":
1410
+ case "I": {
1411
+ const s = this.controller.getState();
1412
+ const start = Math.floor(s.currentFrame);
1413
+ const totalFrames = s.duration * s.fps;
1414
+ let end = s.playbackRange ? s.playbackRange[1] : totalFrames;
1415
+ if (start >= end) {
1416
+ end = totalFrames;
1417
+ }
1418
+ this.controller.setPlaybackRange(start, end);
1419
+ break;
1420
+ }
1421
+ case "o":
1422
+ case "O": {
1423
+ const s = this.controller.getState();
1424
+ const end = Math.floor(s.currentFrame);
1425
+ let start = s.playbackRange ? s.playbackRange[0] : 0;
1426
+ if (end <= start) {
1427
+ start = 0;
1428
+ }
1429
+ this.controller.setPlaybackRange(start, end);
1430
+ break;
1431
+ }
1432
+ case "x":
1433
+ case "X":
1434
+ this.controller.clearPlaybackRange();
1435
+ break;
1436
+ }
1437
+ };
1438
+ seekRelative(frames) {
1439
+ if (!this.controller)
1440
+ return;
1441
+ const state = this.controller.getState();
1442
+ const newFrame = Math.max(0, Math.min(Math.floor(state.duration * state.fps), state.currentFrame + frames));
1443
+ this.controller.seek(newFrame);
1444
+ }
1445
+ toggleFullscreen = () => {
1446
+ if (!document.fullscreenElement) {
1447
+ this.requestFullscreen().catch((err) => {
1448
+ console.error(`Error attempting to enable fullscreen: ${err.message}`);
1449
+ });
1450
+ }
1451
+ else {
1452
+ document.exitFullscreen();
1453
+ }
1454
+ };
1455
+ updateFullscreenUI = () => {
1456
+ if (document.fullscreenElement === this) {
1457
+ this.fullscreenBtn.textContent = "↙";
1458
+ this.fullscreenBtn.title = "Exit Fullscreen";
1459
+ }
1460
+ else {
1461
+ this.fullscreenBtn.textContent = "⛶";
1462
+ this.fullscreenBtn.title = "Fullscreen";
1463
+ }
1464
+ };
1465
+ updateUI(state) {
1466
+ // Hide poster if we are playing or have advanced
1467
+ if (state.isPlaying || state.currentFrame > 0) {
1468
+ this.posterContainer.classList.add("hidden");
1469
+ }
1470
+ // Event Dispatching
1471
+ if (this.lastState) {
1472
+ if (state.isPlaying !== this.lastState.isPlaying) {
1473
+ this.dispatchEvent(new Event(state.isPlaying ? "play" : "pause"));
1474
+ }
1475
+ const wasFinished = this.lastState.currentFrame >= this.lastState.duration * this.lastState.fps - 1;
1476
+ const isFinishedNow = state.currentFrame >= state.duration * state.fps - 1;
1477
+ if (!wasFinished && isFinishedNow && !state.isPlaying) {
1478
+ this.dispatchEvent(new Event("ended"));
1479
+ }
1480
+ if (state.currentFrame !== this.lastState.currentFrame) {
1481
+ this.dispatchEvent(new Event("timeupdate"));
1482
+ }
1483
+ if (state.volume !== this.lastState.volume || state.muted !== this.lastState.muted) {
1484
+ this.dispatchEvent(new Event("volumechange"));
1485
+ }
1486
+ if (state.playbackRate !== this.lastState.playbackRate) {
1487
+ this.dispatchEvent(new Event("ratechange"));
1488
+ }
1489
+ if (state.duration !== this.lastState.duration) {
1490
+ this.dispatchEvent(new Event("durationchange"));
1491
+ }
1492
+ }
1493
+ const isFinished = state.currentFrame >= state.duration * state.fps - 1;
1494
+ if (isFinished) {
1495
+ this.playPauseBtn.textContent = "🔄"; // Restart button
1496
+ this.playPauseBtn.setAttribute("aria-label", "Restart");
1497
+ }
1498
+ else {
1499
+ this.playPauseBtn.textContent = state.isPlaying ? "❚❚" : "▶";
1500
+ this.playPauseBtn.setAttribute("aria-label", state.isPlaying ? "Pause" : "Play");
1501
+ }
1502
+ const isMuted = state.muted || state.volume === 0;
1503
+ this.volumeBtn.textContent = isMuted ? "🔇" : "🔊";
1504
+ this.volumeBtn.setAttribute("aria-label", isMuted ? "Unmute" : "Mute");
1505
+ this.volumeSlider.value = String(state.volume !== undefined ? state.volume : 1);
1506
+ if (!this.isScrubbing) {
1507
+ this.scrubber.value = String(state.currentFrame);
1508
+ }
1509
+ const currentTime = (state.currentFrame / state.fps).toFixed(2);
1510
+ const totalTime = state.duration.toFixed(2);
1511
+ this.timeDisplay.textContent = `${currentTime} / ${totalTime}`;
1512
+ this.scrubber.setAttribute("aria-valuenow", String(state.currentFrame));
1513
+ this.scrubber.setAttribute("aria-valuemin", "0");
1514
+ this.scrubber.setAttribute("aria-valuemax", String(state.duration * state.fps));
1515
+ this.scrubber.setAttribute("aria-valuetext", `${currentTime} of ${totalTime} seconds`);
1516
+ if (state.playbackRate !== undefined) {
1517
+ this.speedSelector.value = String(state.playbackRate);
1518
+ }
1519
+ // Update Markers
1520
+ const markersChanged = !this.lastState || state.markers !== this.lastState.markers;
1521
+ if (markersChanged) {
1522
+ this.markersContainer.innerHTML = "";
1523
+ if (state.markers && state.duration > 0) {
1524
+ state.markers.forEach((marker) => {
1525
+ const pct = (marker.time / state.duration) * 100;
1526
+ if (pct >= 0 && pct <= 100) {
1527
+ const el = document.createElement("div");
1528
+ el.className = "marker";
1529
+ el.style.left = `${pct}%`;
1530
+ if (marker.color)
1531
+ el.style.backgroundColor = marker.color;
1532
+ el.title = marker.label || "";
1533
+ el.addEventListener("click", (e) => {
1534
+ e.stopPropagation();
1535
+ if (this.controller) {
1536
+ this.controller.seek(Math.floor(marker.time * state.fps));
1537
+ }
1538
+ });
1539
+ this.markersContainer.appendChild(el);
1540
+ }
1541
+ });
1542
+ }
1543
+ }
1544
+ if (state.playbackRange) {
1545
+ const totalFrames = state.duration * state.fps;
1546
+ if (totalFrames > 0) {
1547
+ const [start, end] = state.playbackRange;
1548
+ const startPct = (start / totalFrames) * 100;
1549
+ const endPct = (end / totalFrames) * 100;
1550
+ this.scrubber.style.background = `linear-gradient(to right,
1551
+ var(--helios-range-unselected-color) 0%,
1552
+ var(--helios-range-unselected-color) ${startPct}%,
1553
+ var(--helios-range-selected-color) ${startPct}%,
1554
+ var(--helios-range-selected-color) ${endPct}%,
1555
+ var(--helios-range-unselected-color) ${endPct}%,
1556
+ var(--helios-range-unselected-color) 100%
1557
+ )`;
1558
+ }
1559
+ else {
1560
+ this.scrubber.style.background = '';
1561
+ }
1562
+ }
1563
+ else {
1564
+ this.scrubber.style.background = '';
1565
+ }
1566
+ this.captionsContainer.innerHTML = '';
1567
+ if (this.showCaptions && state.activeCaptions && state.activeCaptions.length > 0) {
1568
+ state.activeCaptions.forEach((cue) => {
1569
+ const div = document.createElement('div');
1570
+ div.className = 'caption-cue';
1571
+ div.textContent = cue.text;
1572
+ this.captionsContainer.appendChild(div);
1573
+ });
1574
+ }
1575
+ this.lastState = state;
1576
+ }
1577
+ // --- Loading / Error UI Helpers ---
1578
+ showStatus(msg, isError, action) {
1579
+ this.overlay.classList.remove("hidden");
1580
+ this.statusText.textContent = msg;
1581
+ this.retryBtn.style.display = isError ? "block" : "none";
1582
+ if (action) {
1583
+ this.retryBtn.textContent = action.label;
1584
+ this.retryAction = action.handler;
1585
+ }
1586
+ else {
1587
+ this.retryBtn.textContent = "Retry";
1588
+ this.retryAction = () => this.retryConnection();
1589
+ }
1590
+ // Optional: Add visual distinction for errors beyond just the button
1591
+ this.statusText.classList.toggle('error-msg', isError);
1592
+ }
1593
+ hideStatus() {
1594
+ this.overlay.classList.add("hidden");
1595
+ }
1596
+ getController() {
1597
+ return this.controller;
1598
+ }
1599
+ async getSchema() {
1600
+ if (this.controller) {
1601
+ return this.controller.getSchema();
1602
+ }
1603
+ return undefined;
1604
+ }
1605
+ retryConnection() {
1606
+ this.showStatus("Retrying...", false);
1607
+ // Reload iframe to force fresh start
1608
+ this.load();
1609
+ }
1610
+ renderClientSide = async () => {
1611
+ // If we are already exporting, this is a cancel request
1612
+ if (this.abortController) {
1613
+ this.abortController.abort();
1614
+ this.abortController = null;
1615
+ this.exportBtn.textContent = "Export";
1616
+ this.exportBtn.disabled = false;
1617
+ return;
1618
+ }
1619
+ // Export requires Controller (Direct or Bridge)
1620
+ if (!this.controller) {
1621
+ console.error("Export not available: Not connected.");
1622
+ return;
1623
+ }
1624
+ this.abortController = new AbortController();
1625
+ this.exportBtn.textContent = "Cancel";
1626
+ this.isExporting = true;
1627
+ this.lockPlaybackControls(true);
1628
+ const exporter = new ClientSideExporter(this.controller, this.iframe);
1629
+ const exportMode = (this.getAttribute("export-mode") || "auto");
1630
+ const canvasSelector = this.getAttribute("canvas-selector") || "canvas";
1631
+ const exportFormat = (this.getAttribute("export-format") || "mp4");
1632
+ const captionMode = (this.getAttribute("export-caption-mode") || "burn-in");
1633
+ let includeCaptions = this.showCaptions;
1634
+ if (this.showCaptions && captionMode === 'file') {
1635
+ const showingTrack = Array.from(this._textTracks).find(t => t.mode === 'showing' && t.kind === 'captions');
1636
+ if (showingTrack) {
1637
+ // Convert TextTrackCueList to Array before mapping
1638
+ const cues = Array.from(showingTrack.cues).map((cue) => ({
1639
+ startTime: cue.startTime,
1640
+ endTime: cue.endTime,
1641
+ text: cue.text
1642
+ }));
1643
+ exporter.saveCaptionsAsSRT(cues, "captions.srt");
1644
+ }
1645
+ includeCaptions = false;
1646
+ }
1647
+ try {
1648
+ await exporter.export({
1649
+ onProgress: (p) => {
1650
+ this.exportBtn.textContent = `Cancel (${Math.round(p * 100)}%)`;
1651
+ },
1652
+ signal: this.abortController.signal,
1653
+ mode: exportMode,
1654
+ canvasSelector: canvasSelector,
1655
+ format: exportFormat,
1656
+ includeCaptions: includeCaptions
1657
+ });
1658
+ }
1659
+ catch (e) {
1660
+ if (e.message !== "Export aborted") {
1661
+ this.showStatus("Export Failed: " + e.message, true, {
1662
+ label: "Dismiss",
1663
+ handler: () => this.hideStatus()
1664
+ });
1665
+ }
1666
+ console.error("Export failed or aborted", e);
1667
+ }
1668
+ finally {
1669
+ this.isExporting = false;
1670
+ this.lockPlaybackControls(false);
1671
+ this.exportBtn.textContent = "Export";
1672
+ this.exportBtn.disabled = false;
1673
+ this.abortController = null;
1674
+ }
1675
+ };
1676
+ }
1677
+ if (!customElements.get("helios-player")) {
1678
+ customElements.define("helios-player", HeliosPlayer);
1679
+ }