@kaushal-satani-aipxperts/player-angular 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.
@@ -0,0 +1,1999 @@
1
+ import { UvpPlayerCore, cacheVideo, isCached, removeCachedVideo } from "@kaushal-satani-aipxperts/player-core";
2
+ import { icons } from "feather-icons";
3
+ const DEFAULT_TAG = "uvp-player";
4
+ const OBSERVED_ATTRIBUTES = [
5
+ "source-url",
6
+ "source-type",
7
+ "source-qualities",
8
+ "autoplay",
9
+ "muted",
10
+ "poster",
11
+ "controls",
12
+ "auto-next",
13
+ "loop",
14
+ "playback-rate",
15
+ "volume",
16
+ "custom-controls",
17
+ "playlist"
18
+ ];
19
+ /**
20
+ * Universal Video Player custom element.
21
+ *
22
+ * Provides a framework-agnostic video player with support for MP4, HLS, and DASH,
23
+ * including offline caching and custom UI controls.
24
+ *
25
+ * @example
26
+ * <uvp-player source-url="https://example.com/video.mp4"></uvp-player>
27
+ */
28
+ export class UvpPlayerElement extends HTMLElement {
29
+ static get observedAttributes() {
30
+ return [...OBSERVED_ATTRIBUTES];
31
+ }
32
+ constructor() {
33
+ super();
34
+ this.cleanupCallbacks = [];
35
+ this.isCustomControlsMode = false;
36
+ this.showControls = true;
37
+ this.isUserSeeking = false;
38
+ this.controlsHideTimer = null;
39
+ this.isFullscreen = false;
40
+ this.progressRafId = null;
41
+ this.lastStableDuration = 0;
42
+ this.handleDocumentClick = (event) => {
43
+ const target = event.target;
44
+ const isInsidePlayer = this.contains(target);
45
+ if (!isInsidePlayer) {
46
+ this.closeSettingsMenu();
47
+ this.playlistPanel.classList.remove("open");
48
+ }
49
+ };
50
+ this.handleVideoProgress = () => {
51
+ let duration = this.videoEl.duration;
52
+ // Stabilize duration: different quality levels in HLS/DASH can have slightly different
53
+ // reported durations. We "lock" to the maximum seen duration for the current source
54
+ // to prevent the progress bar from jittering during quality transitions.
55
+ if (Number.isFinite(duration) && duration > 0) {
56
+ if (duration > this.lastStableDuration) {
57
+ this.lastStableDuration = duration;
58
+ }
59
+ else {
60
+ duration = this.lastStableDuration;
61
+ }
62
+ }
63
+ else if (this.lastStableDuration > 0) {
64
+ duration = this.lastStableDuration;
65
+ }
66
+ else {
67
+ duration = 0;
68
+ }
69
+ const current = this.videoEl.currentTime || 0;
70
+ const playedPercent = duration > 0 ? (current / duration) * 100 : 0;
71
+ if (!this.isUserSeeking && duration > 0) {
72
+ this.progressInput.value = String(playedPercent);
73
+ }
74
+ this.updateProgressVisual(playedPercent);
75
+ this.progressBufferedEl.style.width = `${this.getBufferedPercent(duration, current)}%`;
76
+ this.currentTimeEl.textContent = this.formatTime(current);
77
+ this.durationEl.textContent = this.formatTime(duration);
78
+ };
79
+ this.handleQualityUiRefresh = () => {
80
+ this.refreshQualityUi();
81
+ };
82
+ this.handlePlayStateChange = () => {
83
+ const paused = this.videoEl.paused;
84
+ this.setButtonIcon(this.controlPlayPauseButton, paused ? "play" : "pause");
85
+ this.controlPlayPauseButton.setAttribute("aria-label", paused ? "Play" : "Pause");
86
+ if (paused) {
87
+ this.stopProgressLoop();
88
+ }
89
+ else {
90
+ this.startProgressLoop();
91
+ }
92
+ this.markInteraction();
93
+ this.updateNavigationButtons();
94
+ };
95
+ this.handleVolumeChange = () => {
96
+ const muted = this.videoEl.muted || this.videoEl.volume === 0;
97
+ this.setButtonIcon(this.muteButton, muted ? "volume-x" : "volume-2");
98
+ this.muteButton.setAttribute("aria-label", muted ? "Unmute" : "Mute");
99
+ const displayVolume = muted ? 0 : (this.videoEl.volume ?? 0);
100
+ this.volumeInput.value = String(displayVolume);
101
+ // Update track background for custom fill look
102
+ const percent = displayVolume * 100;
103
+ this.volumeInput.style.background = `linear-gradient(to right, var(--uvp-accent) 0%, var(--uvp-accent) ${percent}%, rgba(255, 255, 255, 0.2) ${percent}%, rgba(255, 255, 255, 0.2) 100%)`;
104
+ };
105
+ this.handleFullscreenChange = () => {
106
+ const fsEl = document.fullscreenElement ||
107
+ document.webkitFullscreenElement ||
108
+ document.mozFullScreenElement ||
109
+ document.msFullscreenElement;
110
+ this.isFullscreen = Boolean(fsEl);
111
+ this.setButtonIcon(this.fullscreenButton, this.isFullscreen ? "minimize" : "maximize");
112
+ this.fullscreenButton.setAttribute("aria-label", this.isFullscreen ? "Exit fullscreen" : "Enter fullscreen");
113
+ };
114
+ this.handleKeyDown = (event) => {
115
+ // Ignore if user is typing in an input or textarea
116
+ const target = event.target;
117
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
118
+ return;
119
+ }
120
+ // Only handle shortcuts if the player is visible or was recently interacted with
121
+ // For a more robust implementation, we could check if the player is in view.
122
+ switch (event.code) {
123
+ case "Space":
124
+ event.preventDefault();
125
+ if (this.videoEl.paused)
126
+ void this.play();
127
+ else
128
+ this.pause();
129
+ break;
130
+ case "ArrowRight":
131
+ event.preventDefault();
132
+ this.seek(this.videoEl.currentTime + 10);
133
+ break;
134
+ case "ArrowLeft":
135
+ event.preventDefault();
136
+ this.seek(this.videoEl.currentTime - 10);
137
+ break;
138
+ case "ArrowUp":
139
+ event.preventDefault();
140
+ this.setVolume(this.videoEl.volume + 0.1);
141
+ if (this.videoEl.muted)
142
+ this.core.setMuted(false);
143
+ this.handleVolumeChange();
144
+ this.markInteraction();
145
+ break;
146
+ case "ArrowDown":
147
+ event.preventDefault();
148
+ this.setVolume(this.videoEl.volume - 0.1);
149
+ this.handleVolumeChange();
150
+ this.markInteraction();
151
+ break;
152
+ case "KeyF":
153
+ event.preventDefault();
154
+ this.toggleFullscreen();
155
+ break;
156
+ case "KeyM":
157
+ event.preventDefault();
158
+ this.core.setMuted(!this.videoEl.muted);
159
+ this.handleVolumeChange();
160
+ break;
161
+ case "Escape":
162
+ if (this.isFullscreen) {
163
+ event.preventDefault();
164
+ this.toggleFullscreen();
165
+ }
166
+ break;
167
+ }
168
+ };
169
+ this.markInteraction = () => {
170
+ if (this.isCustomControlsMode || !this.showControls)
171
+ return;
172
+ this.shellEl.classList.remove("ui-hidden");
173
+ this.clearHideTimer();
174
+ if (this.videoEl.paused)
175
+ return;
176
+ this.controlsHideTimer = window.setTimeout(() => {
177
+ this.shellEl.classList.add("ui-hidden");
178
+ this.closeSettingsMenu();
179
+ }, 2200);
180
+ };
181
+ this.shadowRootRef = this.attachShadow({ mode: "open" });
182
+ this.shadowRootRef.innerHTML = `
183
+ <style>
184
+ * {
185
+ box-sizing: border-box;
186
+ }
187
+
188
+ :host {
189
+ display: block;
190
+ width: 100%;
191
+ height: 100%;
192
+ min-width: 100%;
193
+ min-height: 100%;
194
+ --uvp-bg: #2A2F35;
195
+ --uvp-control-bg: rgba(96, 21, 107, 0.85);
196
+ --uvp-text: #FFFFFF;
197
+ --uvp-accent: #A400BC;
198
+ --uvp-accent-2: #4664E1;
199
+ --uvp-accent-3: #00D17F;
200
+ --uvp-border: rgba(255, 255, 255, 0.15);
201
+ --uvp-radius: 14px;
202
+ }
203
+
204
+ .shell {
205
+ position: relative;
206
+ width: 100%;
207
+ height: 100%;
208
+ background: #000;
209
+ overflow: hidden;
210
+ display: flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ border-radius: 12px;
214
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
215
+ border: 1px solid rgba(255, 255, 255, 0.1);
216
+ }
217
+
218
+ :host(:fullscreen) .shell,
219
+ :host(:-webkit-full-screen) .shell,
220
+ :host(:-moz-full-screen) .shell,
221
+ .shell:fullscreen,
222
+ .shell:-webkit-full-screen,
223
+ .shell:-moz-full-screen {
224
+ width: 100vw;
225
+ height: 100vh;
226
+ border-radius: 0;
227
+ border: none;
228
+ max-width: none;
229
+ max-height: none;
230
+ }
231
+
232
+ .settings-wrap {
233
+ position: relative;
234
+ display: flex;
235
+ align-items: center;
236
+ }
237
+
238
+ .settings-menu {
239
+ position: absolute;
240
+ bottom: calc(100% + 12px);
241
+ right: -20px;
242
+ background: rgba(15, 15, 15, 0.98);
243
+ border: 1px solid rgba(255, 255, 255, 0.1);
244
+ border-radius: 12px;
245
+ width: 220px;
246
+ max-height: 350px;
247
+ overflow-y: auto;
248
+ padding: 6px;
249
+ display: flex;
250
+ flex-direction: column;
251
+ z-index: 10;
252
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
253
+ transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
254
+ transform-origin: bottom right;
255
+ }
256
+
257
+ .settings-menu.hidden {
258
+ display: none;
259
+ opacity: 0;
260
+ transform: scale(0.95);
261
+ }
262
+
263
+ .menu-screen {
264
+ display: flex;
265
+ flex-direction: column;
266
+ gap: 2px;
267
+ }
268
+
269
+ .menu-header {
270
+ display: flex;
271
+ align-items: center;
272
+ padding: 6px 8px;
273
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
274
+ margin-bottom: 4px;
275
+ }
276
+
277
+ .menu-back {
278
+ background: transparent !important;
279
+ border: none !important;
280
+ color: var(--uvp-text);
281
+ cursor: pointer;
282
+ padding: 0;
283
+ margin-right: 8px;
284
+ display: flex;
285
+ align-items: center;
286
+ justify-content: center;
287
+ border-radius: 50%;
288
+ width: 24px;
289
+ height: 24px;
290
+ box-shadow: none !important;
291
+ }
292
+
293
+ .menu-back:hover {
294
+ background: rgba(255, 255, 255, 0.1) !important;
295
+ }
296
+
297
+ .menu-title {
298
+ font-weight: 600;
299
+ font-size: 13px;
300
+ }
301
+
302
+ .settings-item {
303
+ border: none !important;
304
+ background: transparent !important;
305
+ color: var(--uvp-text);
306
+ border-radius: 6px;
307
+ min-height: 32px;
308
+ height: auto !important;
309
+ display: flex;
310
+ align-items: center;
311
+ justify-content: space-between;
312
+ padding: 0 8px;
313
+ font: 12px/1 'Outfit', Inter, system-ui, sans-serif;
314
+ transition: background 150ms ease;
315
+ cursor: pointer;
316
+ }
317
+
318
+ .settings-item:hover {
319
+ background: rgba(255, 255, 255, 0.1);
320
+ }
321
+
322
+ .settings-item .item-left {
323
+ display: flex;
324
+ align-items: center;
325
+ gap: 10px;
326
+ }
327
+
328
+ .settings-item .item-right {
329
+ display: flex;
330
+ align-items: center;
331
+ gap: 6px;
332
+ color: rgba(255, 255, 255, 0.5);
333
+ font-size: 12px;
334
+ }
335
+
336
+ .settings-item svg {
337
+ opacity: 0.7;
338
+ }
339
+
340
+ /* Mobile Bottom Sheet */
341
+
342
+
343
+ /* Toggle Switch Styles */
344
+ .switch {
345
+ position: relative;
346
+ display: inline-block;
347
+ width: 34px;
348
+ height: 20px;
349
+ pointer-events: none;
350
+ }
351
+
352
+ .switch input {
353
+ opacity: 0;
354
+ width: 0;
355
+ height: 0;
356
+ }
357
+
358
+ .slider {
359
+ position: absolute;
360
+ cursor: pointer;
361
+ top: 0;
362
+ left: 0;
363
+ right: 0;
364
+ bottom: 0;
365
+ background-color: rgba(255, 255, 255, 0.2);
366
+ transition: .3s;
367
+ border-radius: 34px;
368
+ }
369
+
370
+ .slider:before {
371
+ position: absolute;
372
+ content: "";
373
+ height: 14px;
374
+ width: 14px;
375
+ left: 3px;
376
+ bottom: 3px;
377
+ background-color: white;
378
+ transition: .3s;
379
+ border-radius: 50%;
380
+ }
381
+
382
+ input:checked + .slider {
383
+ background-color: var(--uvp-accent);
384
+ }
385
+
386
+ input:checked + .slider:before {
387
+ transform: translateX(14px);
388
+ }
389
+
390
+ /* Playlist Panel */
391
+ .playlist-panel {
392
+ position: absolute;
393
+ top: 0;
394
+ right: 0;
395
+ bottom: 0;
396
+ width: 320px;
397
+ background: #1a1a1a;
398
+ border-left: 1px solid rgba(255, 255, 255, 0.1);
399
+ z-index: 100;
400
+ transform: translateX(100%);
401
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
402
+ display: flex;
403
+ flex-direction: column;
404
+ box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
405
+ pointer-events: auto;
406
+ }
407
+
408
+ .playlist-panel.open {
409
+ transform: translateX(0);
410
+ }
411
+
412
+ .playlist-header {
413
+ padding: 20px;
414
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
415
+ display: flex;
416
+ align-items: center;
417
+ justify-content: space-between;
418
+ }
419
+
420
+ .playlist-header h3 {
421
+ margin: 0;
422
+ font-size: 16px;
423
+ font-weight: 600;
424
+ color: var(--uvp-text);
425
+ }
426
+
427
+ .playlist-close {
428
+ background: transparent;
429
+ border: none;
430
+ color: rgba(255, 255, 255, 0.5);
431
+ cursor: pointer;
432
+ padding: 4px;
433
+ transition: color 0.2s;
434
+ }
435
+
436
+ .playlist-close:hover {
437
+ color: var(--uvp-text);
438
+ }
439
+
440
+ .playlist-items {
441
+ flex: 1;
442
+ overflow-y: auto;
443
+ padding: 8px 0;
444
+ }
445
+
446
+ /* Custom Scrollbar for Playlist */
447
+ .playlist-items::-webkit-scrollbar {
448
+ width: 6px;
449
+ }
450
+ .playlist-items::-webkit-scrollbar-track {
451
+ background: transparent;
452
+ }
453
+ .playlist-items::-webkit-scrollbar-thumb {
454
+ background: rgba(255, 255, 255, 0.1);
455
+ border-radius: 3px;
456
+ }
457
+ .playlist-items::-webkit-scrollbar-thumb:hover {
458
+ background: rgba(255, 255, 255, 0.2);
459
+ }
460
+
461
+ .playlist-item {
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 12px;
465
+ padding: 12px 20px;
466
+ cursor: pointer;
467
+ transition: background 0.2s;
468
+ border-left: 3px solid transparent;
469
+ }
470
+
471
+ .playlist-item:hover {
472
+ background: rgba(255, 255, 255, 0.05);
473
+ }
474
+
475
+ .playlist-item.active {
476
+ background: rgba(164, 0, 188, 0.15);
477
+ border-left-color: var(--uvp-accent);
478
+ }
479
+
480
+ .playlist-item-thumb {
481
+ width: 80px;
482
+ height: 45px;
483
+ background: #333;
484
+ border-radius: 4px;
485
+ overflow: hidden;
486
+ flex-shrink: 0;
487
+ position: relative;
488
+ display: flex;
489
+ align-items: center;
490
+ justify-content: center;
491
+ }
492
+
493
+ .playlist-item-thumb::after {
494
+ content: '';
495
+ display: block;
496
+ width: 20px;
497
+ height: 20px;
498
+ background: rgba(255, 255, 255, 0.1);
499
+ border-radius: 50%;
500
+ }
501
+
502
+ .playlist-item-thumb img {
503
+ position: absolute;
504
+ top: 0;
505
+ left: 0;
506
+ width: 100%;
507
+ height: 100%;
508
+ object-fit: cover;
509
+ z-index: 2;
510
+ }
511
+
512
+ .playlist-item-info {
513
+ flex: 1;
514
+ min-width: 0;
515
+ }
516
+
517
+ .playlist-item-title {
518
+ font-size: 14px;
519
+ font-weight: 500;
520
+ color: var(--uvp-text);
521
+ margin-bottom: 2px;
522
+ white-space: nowrap;
523
+ overflow: hidden;
524
+ text-overflow: ellipsis;
525
+ }
526
+
527
+ .playlist-item-meta {
528
+ font-size: 12px;
529
+ color: rgba(255, 255, 255, 0.5);
530
+ }
531
+
532
+ .playlist-item.active .playlist-item-title {
533
+ color: var(--uvp-accent);
534
+ }
535
+
536
+ video {
537
+ width: 100%;
538
+ height: 100%;
539
+ display: block;
540
+ background: #000;
541
+ object-fit: contain;
542
+ }
543
+
544
+ .center-actions {
545
+ position: absolute;
546
+ left: 50%;
547
+ top: 50%;
548
+ transform: translate(-50%, -50%);
549
+ display: flex;
550
+ align-items: center;
551
+ gap: 16px;
552
+ transition: opacity 200ms ease;
553
+ }
554
+
555
+ .center-actions button {
556
+ width: 64px;
557
+ height: 64px;
558
+ border-radius: 999px;
559
+ border: none;
560
+ background: transparent;
561
+ color: #FFFFFF;
562
+ display: grid;
563
+ place-items: center;
564
+ cursor: pointer;
565
+ transition: transform 200ms ease, background 200ms ease, box-shadow 200ms ease;
566
+ }
567
+
568
+ .center-actions button:hover:not(:disabled) {
569
+ transform: scale(1.1);
570
+ background: rgba(255, 255, 255, 0.15);
571
+ }
572
+
573
+ .center-actions button:active:not(:disabled) {
574
+ transform: scale(0.95);
575
+ }
576
+
577
+ .center-actions .center-play-pause {
578
+ width: 80px;
579
+ height: 80px;
580
+ background: linear-gradient(135deg, #A400BC 0%, #60156B 100%);
581
+ border: 2px solid rgba(255, 255, 255, 0.4);
582
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
583
+ }
584
+
585
+ .center-actions .center-play-pause:hover:not(:disabled) {
586
+ background: linear-gradient(135deg, #D54DE8 0%, #A400BC 100%);
587
+ box-shadow: 0 6px 20px rgba(164, 0, 188, 0.4);
588
+ }
589
+
590
+ .center-actions button svg,
591
+ .icon-btn svg {
592
+ width: 24px;
593
+ height: 24px;
594
+ stroke: currentColor;
595
+ display: block;
596
+ margin: 0 auto;
597
+ }
598
+
599
+ .center-actions .center-play-pause svg {
600
+ width: 32px;
601
+ height: 32px;
602
+ }
603
+
604
+ .center-actions button:disabled,
605
+ .controls-row button:disabled {
606
+ opacity: 0.35;
607
+ cursor: not-allowed;
608
+ pointer-events: none;
609
+ }
610
+
611
+ .controls {
612
+ position: absolute;
613
+ left: 0;
614
+ right: 0;
615
+ bottom: 0;
616
+ padding: 16px;
617
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
618
+ display: flex;
619
+ flex-direction: column;
620
+ gap: 12px;
621
+ transition: opacity 250ms ease;
622
+ }
623
+
624
+ .controls-row {
625
+ display: flex;
626
+ align-items: center;
627
+ gap: 12px;
628
+ color: var(--uvp-text);
629
+ font: 14px/1.2 'Outfit', Inter, system-ui, sans-serif;
630
+ }
631
+
632
+ .controls-row button,
633
+ .controls-row select {
634
+ border-radius: 10px;
635
+ border: 1px solid rgba(255, 255, 255, 0.2);
636
+ background: rgba(96, 21, 107, 0.4);
637
+ color: var(--uvp-text);
638
+ height: 36px;
639
+ padding: 0 12px;
640
+ cursor: pointer;
641
+ transition: all 200ms ease;
642
+ }
643
+
644
+ .icon-btn {
645
+ width: 40px;
646
+ min-width: 40px;
647
+ height: 40px;
648
+ border: none !important;
649
+ background: transparent !important;
650
+ box-shadow: none;
651
+ padding: 0;
652
+ display: inline-flex;
653
+ align-items: center;
654
+ justify-content: center;
655
+ border-radius: 999px !important;
656
+ }
657
+
658
+ .icon-btn:hover {
659
+ background: rgba(255, 255, 255, 0.15) !important;
660
+ transform: translateY(-1px);
661
+ }
662
+
663
+ .icon-btn:active {
664
+ transform: translateY(0);
665
+ }
666
+
667
+ .speed-wrap {
668
+ position: relative;
669
+ }
670
+
671
+ .speed-button {
672
+ border: none !important;
673
+ background: transparent !important;
674
+ color: var(--uvp-text);
675
+ font-weight: 700;
676
+ min-width: 40px;
677
+ height: 36px !important;
678
+ padding: 0 8px !important;
679
+ border-radius: 8px !important;
680
+ }
681
+
682
+ .speed-button:hover {
683
+ background: rgba(255, 255, 255, 0.15) !important;
684
+ }
685
+
686
+ .speed-list, .quality-list {
687
+ display: flex;
688
+ flex-direction: column;
689
+ gap: 2px;
690
+ padding: 4px;
691
+ }
692
+
693
+ .speed-item, .quality-item {
694
+ border: none !important;
695
+ background: transparent !important;
696
+ color: var(--uvp-text);
697
+ border-radius: 6px;
698
+ min-height: 32px;
699
+ height: auto !important;
700
+ display: flex;
701
+ align-items: center;
702
+ justify-content: space-between;
703
+ padding: 0 8px;
704
+ font: 12px/1 'Outfit', Inter, system-ui, sans-serif;
705
+ transition: all 150ms ease;
706
+ cursor: pointer;
707
+ width: 100%;
708
+ text-align: left;
709
+ }
710
+
711
+ .speed-item:hover, .quality-item:hover {
712
+ background: rgba(255, 255, 255, 0.08) !important;
713
+ }
714
+
715
+ .speed-item.active, .quality-item.active {
716
+ color: var(--uvp-accent);
717
+ font-weight: 600;
718
+ background: rgba(164, 0, 188, 0.1) !important;
719
+ }
720
+
721
+ .speed-item .check, .quality-item .check {
722
+ width: 18px;
723
+ height: 18px;
724
+ display: inline-flex;
725
+ align-items: center;
726
+ justify-content: center;
727
+ opacity: 0;
728
+ flex-shrink: 0;
729
+ stroke: currentColor;
730
+ }
731
+
732
+ .speed-item.active .check, .quality-item.active .check {
733
+ opacity: 1;
734
+ }
735
+
736
+ .spacer {
737
+ flex: 1;
738
+ }
739
+
740
+ .controls-row input[type="range"] {
741
+ accent-color: var(--uvp-accent);
742
+ }
743
+
744
+ .progress-wrap {
745
+ position: relative;
746
+ flex: 1;
747
+ height: 20px;
748
+ display: flex;
749
+ align-items: center;
750
+ overflow: visible;
751
+ margin: 0 12px;
752
+ }
753
+
754
+ .progress-track,
755
+ .progress-buffered,
756
+ .progress-played {
757
+ position: absolute;
758
+ left: 0;
759
+ right: 0;
760
+ height: 6px;
761
+ border-radius: 999px;
762
+ }
763
+
764
+ .progress-track {
765
+ background: rgba(255, 255, 255, 0.2);
766
+ z-index: 1;
767
+ }
768
+
769
+ .progress-buffered {
770
+ width: 0%;
771
+ right: auto;
772
+ background: rgba(255, 255, 255, 0.4);
773
+ transition: width 240ms linear;
774
+ z-index: 2;
775
+ }
776
+
777
+ .progress-played {
778
+ width: 0%;
779
+ right: auto;
780
+ background: linear-gradient(90deg, #A400BC 0%, #D54DE8 100%);
781
+ transition: width 90ms linear;
782
+ z-index: 3;
783
+ }
784
+
785
+
786
+ .progress-thumb {
787
+ position: absolute;
788
+ top: 50%;
789
+ left: 0%;
790
+ width: 16px;
791
+ height: 16px;
792
+ border-radius: 999px;
793
+ background: #FFFFFF;
794
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3), 0 0 0 4px rgba(164, 0, 188, 0.3);
795
+ transform: translate(-50%, -50%);
796
+ transition: left 90ms linear, transform 150ms ease;
797
+ z-index: 4;
798
+ pointer-events: none;
799
+ }
800
+
801
+ /* Removed hover scaling from progress thumb */
802
+
803
+ .progress {
804
+ position: absolute;
805
+ inset: 0;
806
+ width: 100%;
807
+ margin: 0;
808
+ -webkit-appearance: none;
809
+ appearance: none;
810
+ background: transparent;
811
+ cursor: pointer;
812
+ z-index: 5;
813
+ }
814
+
815
+ .progress::-webkit-slider-runnable-track {
816
+ height: 20px;
817
+ background: transparent;
818
+ }
819
+
820
+ .progress::-webkit-slider-thumb {
821
+ -webkit-appearance: none;
822
+ appearance: none;
823
+ width: 20px;
824
+ height: 20px;
825
+ background: transparent;
826
+ }
827
+
828
+ .volume {
829
+ width: 70px;
830
+ height: 4px;
831
+ -webkit-appearance: none;
832
+ appearance: none;
833
+ background: rgba(255, 255, 255, 0.2);
834
+ border-radius: 999px;
835
+ outline: none;
836
+ }
837
+
838
+ .volume::-webkit-slider-thumb {
839
+ -webkit-appearance: none;
840
+ appearance: none;
841
+ width: 12px;
842
+ height: 12px;
843
+ background: var(--uvp-accent);
844
+ border-radius: 50%;
845
+ cursor: pointer;
846
+ border: 2px solid #fff;
847
+ box-shadow: 0 0 5px rgba(0,0,0,0.3);
848
+ transition: transform 150ms ease;
849
+ }
850
+
851
+ .volume::-moz-range-thumb {
852
+ width: 12px;
853
+ height: 12px;
854
+ background: var(--uvp-accent);
855
+ border-radius: 50%;
856
+ cursor: pointer;
857
+ border: 2px solid #fff;
858
+ box-shadow: 0 0 5px rgba(0,0,0,0.3);
859
+ }
860
+
861
+ .spacer {
862
+ flex: 1;
863
+ }
864
+
865
+ .time {
866
+ min-width: 50px;
867
+ text-align: center;
868
+ font-variant-numeric: tabular-nums;
869
+ font-weight: 500;
870
+ }
871
+
872
+ .hidden {
873
+ display: none !important;
874
+ }
875
+
876
+ .shell.ui-hidden .controls,
877
+ .shell.ui-hidden .center-actions {
878
+ opacity: 0;
879
+ pointer-events: none;
880
+ }
881
+
882
+ @media (max-width: 600px) {
883
+ .settings-menu {
884
+ position: fixed;
885
+ bottom: 0;
886
+ left: 0;
887
+ right: 0;
888
+ width: 100%;
889
+ max-height: 70vh;
890
+ border-radius: 24px 24px 0 0;
891
+ padding: 16px 8px 32px 8px;
892
+ transform-origin: bottom center;
893
+ }
894
+
895
+ .settings-menu.hidden {
896
+ transform: translateY(100%);
897
+ }
898
+
899
+ .settings-wrap {
900
+ position: static;
901
+ }
902
+
903
+ .settings-scrim {
904
+ position: fixed;
905
+ inset: 0;
906
+ background: rgba(0, 0, 0, 0.6);
907
+ backdrop-filter: blur(4px);
908
+ z-index: 9;
909
+ opacity: 0;
910
+ pointer-events: none;
911
+ transition: opacity 0.3s ease;
912
+ }
913
+
914
+ .settings-scrim.visible {
915
+ opacity: 1;
916
+ pointer-events: auto;
917
+ }
918
+
919
+ .mute, .volume {
920
+ display: none !important;
921
+ }
922
+
923
+ .time {
924
+ font-size: 11px;
925
+ min-width: 36px;
926
+ }
927
+
928
+ .progress-wrap {
929
+ margin: 0 12px;
930
+ }
931
+
932
+ .controls-row {
933
+ gap: 0px;
934
+ }
935
+
936
+ .controls {
937
+ gap: 4px;
938
+ padding: 12px;
939
+ }
940
+
941
+ /* Mobile Playlist Bottom Sheet */
942
+ .playlist-panel {
943
+ position: fixed;
944
+ bottom: 0;
945
+ left: 0;
946
+ right: 0;
947
+ top: auto;
948
+ width: 100%;
949
+ height: auto;
950
+ background: rgba(15, 15, 15, 0.98);
951
+ border-radius: 24px 24px 0 0;
952
+ transform: translateY(100%);
953
+ border-left: none;
954
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
955
+ z-index: 1000;
956
+ }
957
+
958
+ .playlist-panel.open {
959
+ transform: translateY(0);
960
+ }
961
+
962
+ .playlist-header {
963
+ padding: 12px 20px;
964
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
965
+ }
966
+
967
+ .playlist-header h3 {
968
+ font-size: 14px;
969
+ }
970
+
971
+ .playlist-items {
972
+ display: flex;
973
+ flex-direction: row;
974
+ overflow-x: auto;
975
+ padding: 16px 20px 24px 20px;
976
+ gap: 16px;
977
+ scroll-snap-type: x mandatory;
978
+ scroll-padding: 0 20px;
979
+ -webkit-overflow-scrolling: touch;
980
+ }
981
+
982
+ .playlist-items::-webkit-scrollbar {
983
+ display: none;
984
+ }
985
+
986
+ .playlist-item {
987
+ flex-direction: column;
988
+ width: 140px;
989
+ padding: 0;
990
+ background: transparent;
991
+ border-left: none;
992
+ border-bottom: 3px solid transparent;
993
+ align-items: flex-start;
994
+ gap: 8px;
995
+ flex-shrink: 0;
996
+ scroll-snap-align: start;
997
+ }
998
+
999
+ .playlist-item.active {
1000
+ background: transparent;
1001
+ border-left-color: transparent;
1002
+ border-bottom-color: var(--uvp-accent);
1003
+ }
1004
+
1005
+ .playlist-item-thumb {
1006
+ width: 100%;
1007
+ height: 78px;
1008
+ border-radius: 8px;
1009
+ }
1010
+
1011
+ .playlist-item-info {
1012
+ padding: 2px 4px;
1013
+ }
1014
+
1015
+ .playlist-item-title {
1016
+ font-size: 12px;
1017
+ white-space: normal;
1018
+ display: -webkit-box;
1019
+ -webkit-line-clamp: 2;
1020
+ -webkit-box-orient: vertical;
1021
+ overflow: hidden;
1022
+ line-height: 1.3;
1023
+ }
1024
+
1025
+ .playlist-item-meta {
1026
+ font-size: 10px;
1027
+ margin-top: 2px;
1028
+ }
1029
+ }
1030
+ </style>
1031
+ <div class="shell">
1032
+ <video part="video"></video>
1033
+ <div class="playlist-panel" part="playlist-panel">
1034
+ <div class="playlist-header">
1035
+ <h3>Playlist</h3>
1036
+ <button class="playlist-close" aria-label="Close playlist"></button>
1037
+ </div>
1038
+ <div class="playlist-items" part="playlist-items"></div>
1039
+ </div>
1040
+ <div class="center-actions" part="center-actions">
1041
+ <!-- Center buttons removed/moved to bottom bar -->
1042
+ </div>
1043
+ <div class="controls" part="controls">
1044
+ <div class="controls-row">
1045
+ <span class="time current-time">0:00</span>
1046
+ <div class="progress-wrap" part="progress-wrap">
1047
+ <div class="progress-track"></div>
1048
+ <div class="progress-buffered"></div>
1049
+ <div class="progress-played"></div>
1050
+ <div class="progress-thumb"></div>
1051
+ <input class="progress" part="progress" type="range" min="0" max="100" step="0.1" value="0" />
1052
+ </div>
1053
+ <span class="time duration">0:00</span>
1054
+ </div>
1055
+ <div class="controls-row">
1056
+ <button class="control-prev icon-btn" part="control-prev" aria-label="Previous"></button>
1057
+ <button class="control-play-pause icon-btn" part="control-play-pause" aria-label="Play"></button>
1058
+ <button class="control-next icon-btn" part="control-next" aria-label="Next"></button>
1059
+ <button part="mute" class="mute icon-btn" aria-label="Mute"></button>
1060
+ <input class="volume" part="volume" type="range" min="0" max="1" step="0.05" value="1" />
1061
+ <span class="spacer"></span>
1062
+ <div class="settings-scrim"></div>
1063
+ <div class="settings-wrap">
1064
+ <button part="settings" class="settings-button icon-btn" aria-label="Settings" aria-expanded="false"></button>
1065
+ <div class="settings-menu hidden" part="settings-menu">
1066
+ <!-- Main Screen -->
1067
+ <div class="menu-screen" data-screen="main">
1068
+ <div class="settings-item" data-action="quality-screen">
1069
+ <div class="item-left">
1070
+ <span class="icon-quality"></span>
1071
+ <span>Quality</span>
1072
+ </div>
1073
+ <div class="item-right">
1074
+ <span class="current-quality-value">Auto</span>
1075
+ <span class="icon-chevron-right"></span>
1076
+ </div>
1077
+ </div>
1078
+ <div class="settings-item" data-action="speed-screen">
1079
+ <div class="item-left">
1080
+ <span class="icon-speed"></span>
1081
+ <span>Playback Speed</span>
1082
+ </div>
1083
+ <div class="item-right">
1084
+ <span class="current-speed-value">1x</span>
1085
+ <span class="icon-chevron-right"></span>
1086
+ </div>
1087
+ </div>
1088
+ <div class="settings-item" data-action="autoplay">
1089
+ <div class="item-left">
1090
+ <span class="icon-play"></span>
1091
+ <span>Auto Play</span>
1092
+ </div>
1093
+ <label class="switch">
1094
+ <input type="checkbox" class="autoplay-toggle">
1095
+ <span class="slider"></span>
1096
+ </label>
1097
+ </div>
1098
+ <div class="settings-item" data-action="loop">
1099
+ <div class="item-left">
1100
+ <span class="icon-repeat"></span>
1101
+ <span>Loop</span>
1102
+ </div>
1103
+ <label class="switch">
1104
+ <input type="checkbox" class="loop-toggle">
1105
+ <span class="slider"></span>
1106
+ </label>
1107
+ </div>
1108
+ <div class="settings-item" data-action="download">
1109
+ <div class="item-left">
1110
+ <span class="download-icon"></span>
1111
+ <span class="download-text">Download</span>
1112
+ </div>
1113
+ </div>
1114
+ </div>
1115
+
1116
+ <!-- Quality Screen -->
1117
+ <div class="menu-screen hidden" data-screen="quality">
1118
+ <div class="menu-header">
1119
+ <button class="menu-back" data-target="main">
1120
+ <span class="icon-arrow-left"></span>
1121
+ </button>
1122
+ <span class="menu-title">Quality</span>
1123
+ </div>
1124
+ <div class="quality-list"></div>
1125
+ </div>
1126
+
1127
+ <!-- Speed Screen -->
1128
+ <div class="menu-screen hidden" data-screen="speed">
1129
+ <div class="menu-header">
1130
+ <button class="menu-back" data-target="main">
1131
+ <span class="icon-arrow-left"></span>
1132
+ </button>
1133
+ <span class="menu-title">Playback Speed</span>
1134
+ </div>
1135
+ <div class="speed-list">
1136
+ <button class="speed-item" data-rate="0.5"><span>0.5x</span><span class="check"></span></button>
1137
+ <button class="speed-item active" data-rate="1"><span>1x</span><span class="check"></span></button>
1138
+ <button class="speed-item" data-rate="1.5"><span>1.5x</span><span class="check"></span></button>
1139
+ <button class="speed-item" data-rate="2"><span>2x</span><span class="check"></span></button>
1140
+ </div>
1141
+ </div>
1142
+ </div>
1143
+ </div>
1144
+ <button part="playlist-toggle" class="playlist-toggle icon-btn hidden" aria-label="Show playlist"></button>
1145
+ <button part="fullscreen" class="fullscreen icon-btn" aria-label="Enter fullscreen"></button>
1146
+ </div>
1147
+ </div>
1148
+ <slot name="controls" class="hidden"></slot>
1149
+ </div>
1150
+ `;
1151
+ this.shellEl = this.shadowRootRef.querySelector(".shell");
1152
+ this.videoEl = this.shadowRootRef.querySelector("video");
1153
+ this.centerActionsEl = this.shadowRootRef.querySelector(".center-actions");
1154
+ this.controlPrevButton = this.shadowRootRef.querySelector(".control-prev");
1155
+ this.controlPlayPauseButton = this.shadowRootRef.querySelector(".control-play-pause");
1156
+ this.controlNextButton = this.shadowRootRef.querySelector(".control-next");
1157
+ this.progressPlayedEl = this.shadowRootRef.querySelector(".progress-played");
1158
+ this.progressBufferedEl = this.shadowRootRef.querySelector(".progress-buffered");
1159
+ this.progressThumbEl = this.shadowRootRef.querySelector(".progress-thumb");
1160
+ this.controlsEl = this.shadowRootRef.querySelector(".controls");
1161
+ this.progressInput = this.shadowRootRef.querySelector(".progress");
1162
+ this.currentTimeEl = this.shadowRootRef.querySelector(".current-time");
1163
+ this.durationEl = this.shadowRootRef.querySelector(".duration");
1164
+ this.muteButton = this.shadowRootRef.querySelector(".mute");
1165
+ this.volumeInput = this.shadowRootRef.querySelector(".volume");
1166
+ this.settingsScrim = this.shadowRootRef.querySelector(".settings-scrim");
1167
+ this.settingsWrap = this.shadowRootRef.querySelector(".settings-wrap");
1168
+ this.settingsButton = this.shadowRootRef.querySelector(".settings-button");
1169
+ this.settingsMenu = this.shadowRootRef.querySelector(".settings-menu");
1170
+ this.fullscreenButton = this.shadowRootRef.querySelector(".fullscreen");
1171
+ this.autoplayToggle = this.shadowRootRef.querySelector(".autoplay-toggle");
1172
+ this.loopToggle = this.shadowRootRef.querySelector(".loop-toggle");
1173
+ this.playlistButton = this.shadowRootRef.querySelector(".playlist-toggle");
1174
+ this.playlistPanel = this.shadowRootRef.querySelector(".playlist-panel");
1175
+ this.playlistCloseButton = this.shadowRootRef.querySelector(".playlist-close");
1176
+ this.playlistItemsContainer = this.shadowRootRef.querySelector(".playlist-items");
1177
+ this.slotEl = this.shadowRootRef.querySelector('slot[name="controls"]');
1178
+ this.isCustomControlsMode = this.readBooleanAttr("custom-controls", false);
1179
+ this.showControls = this.readBooleanAttr("controls", true);
1180
+ this.core = new UvpPlayerCore(this.readConfigFromAttributes());
1181
+ this.core.attachMediaElement(this.videoEl);
1182
+ this.bindUiEvents();
1183
+ this.updateUiVisibility();
1184
+ this.refreshDownloadUi();
1185
+ }
1186
+ connectedCallback() {
1187
+ if (!this.core.getMediaElement()) {
1188
+ this.core.attachMediaElement(this.videoEl);
1189
+ }
1190
+ const events = [
1191
+ "ready",
1192
+ "sourcechange",
1193
+ "play",
1194
+ "pause",
1195
+ "timeupdate",
1196
+ "ended",
1197
+ "playlistend",
1198
+ "qualitychange",
1199
+ "downloadstatus",
1200
+ "qualitiesdiscovered",
1201
+ "error"
1202
+ ];
1203
+ this.cleanupCallbacks = events.map((eventName) => this.core.on(eventName, (detail) => {
1204
+ if (eventName === "sourcechange") {
1205
+ this.lastStableDuration = 0;
1206
+ this.handlePlayStateChange();
1207
+ }
1208
+ this.dispatchEvent(new CustomEvent(eventName, {
1209
+ detail,
1210
+ bubbles: true,
1211
+ composed: true
1212
+ }));
1213
+ }));
1214
+ this.cleanupCallbacks.push(this.core.on("qualitiesdiscovered", () => {
1215
+ this.refreshQualityUi();
1216
+ }));
1217
+ this.videoEl.addEventListener("timeupdate", this.handleVideoProgress);
1218
+ this.videoEl.addEventListener("loadedmetadata", this.handleVideoProgress);
1219
+ this.videoEl.addEventListener("progress", this.handleVideoProgress);
1220
+ this.videoEl.addEventListener("play", this.handlePlayStateChange);
1221
+ this.videoEl.addEventListener("pause", this.handlePlayStateChange);
1222
+ this.videoEl.addEventListener("volumechange", this.handleVolumeChange);
1223
+ this.videoEl.addEventListener("loadedmetadata", this.handleQualityUiRefresh);
1224
+ document.addEventListener("fullscreenchange", this.handleFullscreenChange);
1225
+ document.addEventListener("webkitfullscreenchange", this.handleFullscreenChange);
1226
+ document.addEventListener("mozfullscreenchange", this.handleFullscreenChange);
1227
+ document.addEventListener("MSFullscreenChange", this.handleFullscreenChange);
1228
+ this.handlePlayStateChange();
1229
+ this.handleVideoProgress();
1230
+ this.handleVolumeChange();
1231
+ this.handleFullscreenChange();
1232
+ const config = this.core.getConfig();
1233
+ this.autoplayToggle.checked = Boolean(config.autoplay || config.autoNext);
1234
+ this.loopToggle.checked = Boolean(config.loop);
1235
+ this.renderStaticIcons();
1236
+ this.refreshPlaylistUi();
1237
+ this.refreshQualityUi();
1238
+ this.core.on("sourcechange", () => {
1239
+ this.updateNavigationButtons();
1240
+ this.refreshQualityUi();
1241
+ this.refreshPlaylistUi();
1242
+ this.updateUiVisibility();
1243
+ this.refreshDownloadUi();
1244
+ });
1245
+ this.markInteraction();
1246
+ document.addEventListener("keydown", this.handleKeyDown);
1247
+ document.addEventListener("click", this.handleDocumentClick);
1248
+ }
1249
+ refreshPlaylistUi() {
1250
+ const playlist = this.core.getPlaylist();
1251
+ const currentIndex = this.core.getCurrentIndex();
1252
+ this.playlistButton.classList.toggle("hidden", playlist.length <= 1);
1253
+ this.playlistItemsContainer.innerHTML = "";
1254
+ playlist.forEach((item, index) => {
1255
+ const el = document.createElement("div");
1256
+ el.className = `playlist-item ${index === currentIndex ? "active" : ""}`;
1257
+ el.innerHTML = `
1258
+ <div class="playlist-item-thumb">
1259
+ ${item.poster ? `<img src="${item.poster}" alt="">` : ""}
1260
+ </div>
1261
+ <div class="playlist-item-info">
1262
+ <div class="playlist-item-title">${item.title || `Video ${index + 1}`}</div>
1263
+ <div class="playlist-item-meta">${item.type.toUpperCase()}</div>
1264
+ </div>
1265
+ `;
1266
+ el.addEventListener("click", () => {
1267
+ this.core.goTo(index);
1268
+ this.playlistPanel.classList.remove("open");
1269
+ this.markInteraction();
1270
+ });
1271
+ this.playlistItemsContainer.appendChild(el);
1272
+ });
1273
+ }
1274
+ disconnectedCallback() {
1275
+ document.removeEventListener("keydown", this.handleKeyDown);
1276
+ document.removeEventListener("click", this.handleDocumentClick);
1277
+ this.cleanupCallbacks.forEach((cleanup) => cleanup());
1278
+ this.cleanupCallbacks = [];
1279
+ this.videoEl.removeEventListener("timeupdate", this.handleVideoProgress);
1280
+ this.videoEl.removeEventListener("loadedmetadata", this.handleVideoProgress);
1281
+ this.videoEl.removeEventListener("progress", this.handleVideoProgress);
1282
+ this.videoEl.removeEventListener("play", this.handlePlayStateChange);
1283
+ this.videoEl.removeEventListener("pause", this.handlePlayStateChange);
1284
+ this.videoEl.removeEventListener("volumechange", this.handleVolumeChange);
1285
+ this.videoEl.removeEventListener("loadedmetadata", this.handleQualityUiRefresh);
1286
+ document.removeEventListener("fullscreenchange", this.handleFullscreenChange);
1287
+ document.removeEventListener("webkitfullscreenchange", this.handleFullscreenChange);
1288
+ document.removeEventListener("mozfullscreenchange", this.handleFullscreenChange);
1289
+ document.removeEventListener("MSFullscreenChange", this.handleFullscreenChange);
1290
+ this.shellEl.removeEventListener("mousemove", this.markInteraction);
1291
+ this.shellEl.removeEventListener("click", this.markInteraction);
1292
+ this.shellEl.removeEventListener("touchstart", this.markInteraction);
1293
+ this.shellEl.removeEventListener("mouseenter", this.markInteraction);
1294
+ this.clearHideTimer();
1295
+ this.stopProgressLoop();
1296
+ this.core.detachMediaElement();
1297
+ }
1298
+ attributeChangedCallback(name, _oldValue, _newValue) {
1299
+ this.applyAttributeToCore(name);
1300
+ }
1301
+ /**
1302
+ * Starts video playback.
1303
+ * @returns A promise that resolves when playback starts.
1304
+ */
1305
+ play() {
1306
+ return this.core.play();
1307
+ }
1308
+ /**
1309
+ * Pauses video playback.
1310
+ */
1311
+ pause() {
1312
+ this.core.pause();
1313
+ }
1314
+ /**
1315
+ * Seeks to a specific time in the video.
1316
+ * @param seconds - The time in seconds to seek to.
1317
+ */
1318
+ seek(seconds) {
1319
+ this.core.seek(seconds);
1320
+ }
1321
+ /**
1322
+ * Plays the next item in the playlist.
1323
+ */
1324
+ next() {
1325
+ this.core.next();
1326
+ }
1327
+ /**
1328
+ * Plays the previous item in the playlist.
1329
+ */
1330
+ previous() {
1331
+ this.core.previous();
1332
+ }
1333
+ /**
1334
+ * Sets the playback rate (speed) of the video.
1335
+ * @param rate - The playback rate (e.g., 1.0, 1.5, 2.0).
1336
+ */
1337
+ setPlaybackRate(rate) {
1338
+ this.core.setPlaybackRate(rate);
1339
+ }
1340
+ /**
1341
+ * Sets the volume of the video.
1342
+ * @param value - The volume level (0.0 to 1.0).
1343
+ */
1344
+ setVolume(value) {
1345
+ this.core.setVolume(value);
1346
+ }
1347
+ /**
1348
+ * Returns the underlying player core instance for advanced control.
1349
+ * @returns The UvpPlayerCore instance.
1350
+ */
1351
+ getApi() {
1352
+ return this.core;
1353
+ }
1354
+ readConfigFromAttributes() {
1355
+ return {
1356
+ source: this.buildSourceFromAttributes(),
1357
+ autoplay: this.hasAttribute("autoplay"),
1358
+ muted: this.hasAttribute("muted"),
1359
+ poster: this.getAttribute("poster") ?? undefined,
1360
+ controls: false,
1361
+ autoNext: this.readBooleanAttr("auto-next", false),
1362
+ loop: this.readBooleanAttr("loop", false),
1363
+ playbackRate: this.readNumberAttr("playback-rate", 1),
1364
+ volume: this.readNumberAttr("volume", 1)
1365
+ };
1366
+ }
1367
+ buildSourceFromAttributes() {
1368
+ const url = this.getAttribute("source-url");
1369
+ if (!url)
1370
+ return undefined;
1371
+ const type = this.getAttribute("source-type") ?? "mp4";
1372
+ const qualities = this.parseSourceQualitiesAttr();
1373
+ return {
1374
+ id: "single",
1375
+ url,
1376
+ type,
1377
+ ...(qualities.length > 0 ? { qualities } : {})
1378
+ };
1379
+ }
1380
+ parseSourceQualitiesAttr() {
1381
+ const raw = this.getAttribute("source-qualities");
1382
+ if (!raw?.trim())
1383
+ return [];
1384
+ try {
1385
+ const parsed = JSON.parse(raw);
1386
+ if (!Array.isArray(parsed))
1387
+ return [];
1388
+ const out = [];
1389
+ for (const entry of parsed) {
1390
+ if (!entry || typeof entry !== "object")
1391
+ continue;
1392
+ const obj = entry;
1393
+ const id = typeof obj.id === "string" ? obj.id : "";
1394
+ if (!id)
1395
+ continue;
1396
+ const label = typeof obj.label === "string"
1397
+ ? obj.label
1398
+ : typeof obj.height === "number" && Number.isFinite(obj.height)
1399
+ ? `${obj.height}p`
1400
+ : id;
1401
+ const item = { id, label };
1402
+ if (typeof obj.height === "number" && Number.isFinite(obj.height)) {
1403
+ item.height = obj.height;
1404
+ }
1405
+ if (typeof obj.bitrateKbps === "number" && Number.isFinite(obj.bitrateKbps)) {
1406
+ item.bitrateKbps = obj.bitrateKbps;
1407
+ }
1408
+ out.push(item);
1409
+ }
1410
+ return out;
1411
+ }
1412
+ catch {
1413
+ return [];
1414
+ }
1415
+ }
1416
+ applyAttributeToCore(name) {
1417
+ switch (name) {
1418
+ case "source-url":
1419
+ case "source-type":
1420
+ case "source-qualities":
1421
+ this.core.updateConfig({
1422
+ source: this.buildSourceFromAttributes()
1423
+ });
1424
+ this.updateNavigationButtons();
1425
+ this.refreshQualityUi();
1426
+ break;
1427
+ case "playlist":
1428
+ this.core.updateConfig({
1429
+ playlist: this.parsePlaylistAttr()
1430
+ });
1431
+ this.updateNavigationButtons();
1432
+ this.refreshQualityUi();
1433
+ this.refreshPlaylistUi();
1434
+ break;
1435
+ case "autoplay":
1436
+ this.core.updateConfig({ autoplay: this.hasAttribute("autoplay") });
1437
+ break;
1438
+ case "muted":
1439
+ this.core.updateConfig({ muted: this.hasAttribute("muted") });
1440
+ break;
1441
+ case "poster":
1442
+ this.core.updateConfig({ poster: this.getAttribute("poster") ?? "" });
1443
+ break;
1444
+ case "controls":
1445
+ this.showControls = this.readBooleanAttr("controls", true);
1446
+ this.updateUiVisibility();
1447
+ break;
1448
+ case "auto-next":
1449
+ this.core.updateConfig({ autoNext: this.readBooleanAttr("auto-next", false) });
1450
+ this.updateNavigationButtons();
1451
+ break;
1452
+ case "loop":
1453
+ this.core.updateConfig({ loop: this.readBooleanAttr("loop", false) });
1454
+ this.updateNavigationButtons();
1455
+ break;
1456
+ case "playback-rate":
1457
+ this.core.setPlaybackRate(this.readNumberAttr("playback-rate", 1));
1458
+ this.updateSpeedUi(this.readNumberAttr("playback-rate", 1));
1459
+ break;
1460
+ case "volume":
1461
+ this.core.setVolume(this.readNumberAttr("volume", 1));
1462
+ break;
1463
+ case "custom-controls":
1464
+ this.isCustomControlsMode = this.readBooleanAttr("custom-controls", false);
1465
+ this.updateUiVisibility();
1466
+ break;
1467
+ default:
1468
+ break;
1469
+ }
1470
+ }
1471
+ readBooleanAttr(name, fallback) {
1472
+ if (!this.hasAttribute(name))
1473
+ return fallback;
1474
+ const value = this.getAttribute(name);
1475
+ if (value === "" || value === "true")
1476
+ return true;
1477
+ if (value === "false")
1478
+ return false;
1479
+ return fallback;
1480
+ }
1481
+ readNumberAttr(name, fallback) {
1482
+ const value = this.getAttribute(name);
1483
+ if (value == null)
1484
+ return fallback;
1485
+ const parsed = Number(value);
1486
+ return Number.isFinite(parsed) ? parsed : fallback;
1487
+ }
1488
+ bindUiEvents() {
1489
+ this.controlPlayPauseButton.addEventListener("click", () => {
1490
+ if (this.videoEl.paused)
1491
+ void this.play();
1492
+ else
1493
+ this.pause();
1494
+ this.markInteraction();
1495
+ });
1496
+ this.controlPrevButton.addEventListener("click", () => {
1497
+ if (this.controlPrevButton.disabled)
1498
+ return;
1499
+ this.previous();
1500
+ this.updateNavigationButtons();
1501
+ this.markInteraction();
1502
+ });
1503
+ this.controlNextButton.addEventListener("click", () => {
1504
+ if (this.controlNextButton.disabled)
1505
+ return;
1506
+ this.next();
1507
+ this.updateNavigationButtons();
1508
+ this.markInteraction();
1509
+ });
1510
+ this.progressInput.addEventListener("input", () => {
1511
+ this.isUserSeeking = true;
1512
+ const percent = Number(this.progressInput.value);
1513
+ const duration = this.videoEl.duration || 0;
1514
+ this.currentTimeEl.textContent = this.formatTime((percent / 100) * duration);
1515
+ this.updateProgressVisual(percent);
1516
+ this.markInteraction();
1517
+ });
1518
+ this.progressInput.addEventListener("change", () => {
1519
+ const duration = this.videoEl.duration || 0;
1520
+ if (duration > 0) {
1521
+ const percent = Number(this.progressInput.value);
1522
+ this.seek((percent / 100) * duration);
1523
+ }
1524
+ this.isUserSeeking = false;
1525
+ this.markInteraction();
1526
+ });
1527
+ this.muteButton.addEventListener("click", () => {
1528
+ this.core.setMuted(!this.videoEl.muted);
1529
+ this.handleVolumeChange();
1530
+ this.markInteraction();
1531
+ });
1532
+ this.volumeInput.addEventListener("input", () => {
1533
+ const value = Number(this.volumeInput.value);
1534
+ this.setVolume(value);
1535
+ if (value > 0 && this.videoEl.muted)
1536
+ this.core.setMuted(false);
1537
+ this.handleVolumeChange();
1538
+ this.markInteraction();
1539
+ });
1540
+ this.fullscreenButton.addEventListener("click", () => {
1541
+ this.toggleFullscreen();
1542
+ });
1543
+ this.settingsButton.addEventListener("click", (event) => {
1544
+ event.stopPropagation();
1545
+ const isHidden = this.settingsMenu.classList.contains("hidden");
1546
+ if (isHidden) {
1547
+ this.openSettingsMenu();
1548
+ this.playlistPanel.classList.remove("open");
1549
+ }
1550
+ else {
1551
+ this.closeSettingsMenu();
1552
+ }
1553
+ this.markInteraction();
1554
+ });
1555
+ this.settingsScrim.addEventListener("click", () => {
1556
+ this.closeSettingsMenu();
1557
+ this.playlistPanel.classList.remove("open");
1558
+ });
1559
+ // Screen Transitions
1560
+ this.settingsMenu.querySelectorAll(".settings-item[data-action]").forEach((item) => {
1561
+ item.addEventListener("click", (event) => {
1562
+ event.stopPropagation();
1563
+ const action = item.dataset.action;
1564
+ if (action === "quality-screen") {
1565
+ this.switchMenuScreen("quality");
1566
+ }
1567
+ else if (action === "speed-screen") {
1568
+ this.switchMenuScreen("speed");
1569
+ }
1570
+ else if (action === "autoplay") {
1571
+ const newState = !this.autoplayToggle.checked;
1572
+ this.autoplayToggle.checked = newState;
1573
+ this.core.updateConfig({ autoplay: newState, autoNext: newState });
1574
+ }
1575
+ else if (action === "loop") {
1576
+ const newState = !this.loopToggle.checked;
1577
+ this.loopToggle.checked = newState;
1578
+ this.core.updateConfig({ loop: newState });
1579
+ }
1580
+ else if (action === "download") {
1581
+ this.handleDownloadAction(item);
1582
+ }
1583
+ this.markInteraction();
1584
+ });
1585
+ });
1586
+ this.settingsMenu.querySelectorAll(".menu-back").forEach((btn) => {
1587
+ btn.addEventListener("click", (event) => {
1588
+ event.stopPropagation();
1589
+ const target = btn.dataset.target || "main";
1590
+ this.switchMenuScreen(target);
1591
+ this.markInteraction();
1592
+ });
1593
+ });
1594
+ // Speed Selection
1595
+ this.settingsMenu.querySelectorAll(".speed-item").forEach((item) => {
1596
+ item.addEventListener("click", (event) => {
1597
+ event.stopPropagation();
1598
+ const rate = Number(item.dataset.rate);
1599
+ this.setPlaybackRate(rate);
1600
+ this.updateSpeedUi(rate);
1601
+ this.closeSettingsMenu();
1602
+ this.markInteraction();
1603
+ });
1604
+ });
1605
+ this.playlistButton.addEventListener("click", (event) => {
1606
+ event.stopPropagation();
1607
+ const isOpen = this.playlistPanel.classList.toggle("open");
1608
+ if (isOpen) {
1609
+ this.closeSettingsMenu();
1610
+ }
1611
+ this.settingsScrim.classList.toggle("visible", isOpen);
1612
+ this.markInteraction();
1613
+ });
1614
+ this.playlistCloseButton.addEventListener("click", (event) => {
1615
+ event.stopPropagation();
1616
+ this.playlistPanel.classList.remove("open");
1617
+ this.settingsScrim.classList.remove("visible");
1618
+ this.markInteraction();
1619
+ });
1620
+ this.shadowRootRef.addEventListener("click", (event) => {
1621
+ const target = event.target;
1622
+ const isInsideSettings = this.settingsWrap.contains(target);
1623
+ const isInsidePlaylist = this.playlistPanel.contains(target) || this.playlistButton.contains(target);
1624
+ const isInsideScrim = this.settingsScrim.contains(target);
1625
+ if (!isInsideSettings || isInsideScrim) {
1626
+ this.closeSettingsMenu();
1627
+ }
1628
+ if (!isInsidePlaylist) {
1629
+ this.playlistPanel.classList.remove("open");
1630
+ }
1631
+ });
1632
+ this.videoEl.addEventListener("ratechange", () => {
1633
+ this.updateSpeedUi(this.videoEl.playbackRate || 1);
1634
+ this.markInteraction();
1635
+ });
1636
+ this.fullscreenButton.addEventListener("click", () => {
1637
+ this.toggleFullscreen();
1638
+ });
1639
+ this.shellEl.addEventListener("mousemove", this.markInteraction);
1640
+ this.shellEl.addEventListener("click", this.markInteraction);
1641
+ this.shellEl.addEventListener("touchstart", this.markInteraction);
1642
+ this.shellEl.addEventListener("mouseenter", this.markInteraction);
1643
+ this.updateNavigationButtons();
1644
+ }
1645
+ openSettingsMenu() {
1646
+ this.refreshDownloadUi();
1647
+ this.switchMenuScreen("main");
1648
+ this.settingsMenu.classList.remove("hidden");
1649
+ this.settingsButton.setAttribute("aria-expanded", "true");
1650
+ this.settingsScrim.classList.add("visible");
1651
+ }
1652
+ closeSettingsMenu() {
1653
+ this.settingsMenu.classList.add("hidden");
1654
+ this.settingsButton.setAttribute("aria-expanded", "false");
1655
+ this.settingsScrim.classList.remove("visible");
1656
+ }
1657
+ switchMenuScreen(screenId) {
1658
+ this.settingsMenu.querySelectorAll(".menu-screen").forEach((screen) => {
1659
+ const isTarget = screen.dataset.screen === screenId;
1660
+ screen.classList.toggle("hidden", !isTarget);
1661
+ });
1662
+ }
1663
+ handleDownloadAction(item) {
1664
+ const source = this.core.getCurrentSource();
1665
+ if (!source)
1666
+ return;
1667
+ if (source.type !== "mp4" && source.type !== "mov" && source.type !== "hls" && source.type !== "dash") {
1668
+ alert("Downloading is only supported for MP4, MOV, HLS, and DASH formats.");
1669
+ return;
1670
+ }
1671
+ const isDownloading = item.dataset.status === "downloading";
1672
+ const isDownloaded = item.dataset.status === "downloaded";
1673
+ if (isDownloading)
1674
+ return;
1675
+ if (isDownloaded) {
1676
+ removeCachedVideo(source.id).then(() => {
1677
+ this.refreshDownloadUi();
1678
+ });
1679
+ }
1680
+ else {
1681
+ item.dataset.status = "downloading";
1682
+ const textEl = item.querySelector(".download-text");
1683
+ if (textEl)
1684
+ textEl.textContent = "Downloading 0%";
1685
+ cacheVideo(source.id, source.url, (data) => {
1686
+ if (data.status === "in_progress") {
1687
+ if (textEl)
1688
+ textEl.textContent = `Downloading ${Math.round(data.progress || 0)}%`;
1689
+ }
1690
+ else if (data.status === "completed") {
1691
+ item.dataset.status = "none";
1692
+ this.refreshDownloadUi();
1693
+ // Optional: Auto-switch source logic can be added here
1694
+ }
1695
+ else if (data.status === "error") {
1696
+ if (textEl)
1697
+ textEl.textContent = "Download failed";
1698
+ setTimeout(() => {
1699
+ item.dataset.status = "none";
1700
+ this.refreshDownloadUi();
1701
+ }, 2000);
1702
+ }
1703
+ }, source.type);
1704
+ }
1705
+ }
1706
+ refreshDownloadUi() {
1707
+ const source = this.core.getCurrentSource();
1708
+ const downloadItem = this.settingsMenu.querySelector('[data-action="download"]');
1709
+ if (!downloadItem)
1710
+ return;
1711
+ if (!source || (source.type !== "mp4" && source.type !== "mov" && source.type !== "hls" && source.type !== "dash")) {
1712
+ downloadItem.style.opacity = "0.5";
1713
+ downloadItem.style.pointerEvents = "none";
1714
+ const textEl = downloadItem.querySelector(".download-text");
1715
+ if (textEl)
1716
+ textEl.textContent = "Download (Unsupported)";
1717
+ return;
1718
+ }
1719
+ downloadItem.style.opacity = "1";
1720
+ downloadItem.style.pointerEvents = "auto";
1721
+ const textEl = downloadItem.querySelector(".download-text");
1722
+ const iconEl = downloadItem.querySelector(".download-icon");
1723
+ if (downloadItem.dataset.status === "downloading") {
1724
+ // Don't overwrite downloading state
1725
+ return;
1726
+ }
1727
+ isCached(source.id).then((cached) => {
1728
+ // Re-check if source is still the same after async call
1729
+ if (this.core.getCurrentSource()?.id !== source.id)
1730
+ return;
1731
+ if (cached) {
1732
+ downloadItem.dataset.status = "downloaded";
1733
+ if (textEl)
1734
+ textEl.textContent = "Remove Download";
1735
+ if (iconEl) {
1736
+ iconEl.innerHTML = icons.trash.toSvg({ width: "16", height: "16", "stroke-width": "2.5" });
1737
+ }
1738
+ }
1739
+ else {
1740
+ downloadItem.dataset.status = "none";
1741
+ if (textEl)
1742
+ textEl.textContent = "Download";
1743
+ if (iconEl) {
1744
+ iconEl.innerHTML = icons.download.toSvg({ width: "16", height: "16", "stroke-width": "2.5" });
1745
+ }
1746
+ }
1747
+ });
1748
+ }
1749
+ updateUiVisibility() {
1750
+ const useCustom = this.isCustomControlsMode;
1751
+ const controlsEnabled = this.showControls;
1752
+ this.videoEl.controls = false;
1753
+ this.controlsEl.classList.toggle("hidden", useCustom || !controlsEnabled);
1754
+ this.centerActionsEl.classList.toggle("hidden", useCustom || !controlsEnabled);
1755
+ this.slotEl.classList.toggle("hidden", !useCustom || !controlsEnabled);
1756
+ if (useCustom || !controlsEnabled) {
1757
+ this.shellEl.classList.remove("ui-hidden");
1758
+ this.clearHideTimer();
1759
+ }
1760
+ else {
1761
+ this.markInteraction();
1762
+ }
1763
+ }
1764
+ toggleFullscreen() {
1765
+ const doc = document;
1766
+ const el = this;
1767
+ if (this.isFullscreen) {
1768
+ if (document.exitFullscreen) {
1769
+ void document.exitFullscreen();
1770
+ }
1771
+ else if (doc.webkitExitFullscreen) {
1772
+ void doc.webkitExitFullscreen();
1773
+ }
1774
+ else if (doc.mozCancelFullScreen) {
1775
+ void doc.mozCancelFullScreen();
1776
+ }
1777
+ else if (doc.msExitFullscreen) {
1778
+ void doc.msExitFullscreen();
1779
+ }
1780
+ }
1781
+ else {
1782
+ const options = { navigationUI: "hide" };
1783
+ const el = this;
1784
+ const request = async () => {
1785
+ try {
1786
+ if (el.requestFullscreen) {
1787
+ await el.requestFullscreen(options);
1788
+ }
1789
+ else if (el.webkitRequestFullscreen) {
1790
+ await el.webkitRequestFullscreen(options);
1791
+ }
1792
+ else if (el.mozRequestFullScreen) {
1793
+ await el.mozRequestFullScreen(options);
1794
+ }
1795
+ else if (el.msRequestFullscreen) {
1796
+ await el.msRequestFullscreen(options);
1797
+ }
1798
+ }
1799
+ catch (err) {
1800
+ // Fallback without options
1801
+ try {
1802
+ if (el.requestFullscreen) {
1803
+ await el.requestFullscreen();
1804
+ }
1805
+ else if (el.webkitRequestFullscreen) {
1806
+ await el.webkitRequestFullscreen();
1807
+ }
1808
+ else if (el.mozRequestFullScreen) {
1809
+ await el.mozRequestFullScreen();
1810
+ }
1811
+ else if (el.msRequestFullscreen) {
1812
+ await el.msRequestFullscreen();
1813
+ }
1814
+ }
1815
+ catch (finalErr) {
1816
+ console.error("Fullscreen request failed completely:", finalErr);
1817
+ }
1818
+ }
1819
+ };
1820
+ void request();
1821
+ }
1822
+ this.markInteraction();
1823
+ }
1824
+ formatTime(seconds) {
1825
+ if (!Number.isFinite(seconds) || seconds <= 0)
1826
+ return "0:00";
1827
+ const total = Math.floor(seconds);
1828
+ const mins = Math.floor(total / 60);
1829
+ const secs = total % 60;
1830
+ return `${mins}:${String(secs).padStart(2, "0")}`;
1831
+ }
1832
+ clearHideTimer() {
1833
+ if (this.controlsHideTimer == null)
1834
+ return;
1835
+ window.clearTimeout(this.controlsHideTimer);
1836
+ this.controlsHideTimer = null;
1837
+ }
1838
+ updateProgressVisual(percent) {
1839
+ const clamped = Math.max(0, Math.min(100, percent));
1840
+ this.progressPlayedEl.style.width = `${clamped}%`;
1841
+ this.progressThumbEl.style.left = `${clamped}%`;
1842
+ }
1843
+ updateNavigationButtons() {
1844
+ const canPrev = this.core.canGoPrevious();
1845
+ const canNext = this.core.canGoNext();
1846
+ this.controlPrevButton.disabled = !canPrev;
1847
+ this.controlNextButton.disabled = !canNext;
1848
+ }
1849
+ parsePlaylistAttr() {
1850
+ const raw = this.getAttribute("playlist");
1851
+ if (!raw)
1852
+ return [];
1853
+ try {
1854
+ const parsed = JSON.parse(raw);
1855
+ if (!Array.isArray(parsed))
1856
+ return [];
1857
+ return parsed.map((item) => ({
1858
+ ...item,
1859
+ id: item.id || Math.random().toString(36).substring(7)
1860
+ }));
1861
+ }
1862
+ catch {
1863
+ return [];
1864
+ }
1865
+ }
1866
+ refreshQualityUi() {
1867
+ const qualities = this.core.getQualities();
1868
+ const currentQuality = this.core.getCurrentQuality();
1869
+ const currentQualityLabel = currentQuality?.label || "Auto";
1870
+ // Update overlay value text
1871
+ const valueEl = this.settingsMenu.querySelector(".current-quality-value");
1872
+ if (valueEl)
1873
+ valueEl.textContent = currentQualityLabel;
1874
+ const listEl = this.settingsMenu.querySelector(".quality-list");
1875
+ if (!listEl)
1876
+ return;
1877
+ listEl.innerHTML = "";
1878
+ const createItem = (id, label) => {
1879
+ const el = document.createElement("button");
1880
+ const isActive = id === (currentQuality?.id || null);
1881
+ el.className = `quality-item ${isActive ? "active" : ""}`;
1882
+ el.innerHTML = `<span>${label}</span><span class="check"></span>`;
1883
+ const checkEl = el.querySelector(".check");
1884
+ if (checkEl) {
1885
+ checkEl.innerHTML = icons.check.toSvg({ width: "16", height: "16", "stroke-width": "2.5" });
1886
+ }
1887
+ el.addEventListener("click", (event) => {
1888
+ event.stopPropagation();
1889
+ this.core.setSelectedQuality(id);
1890
+ this.closeSettingsMenu();
1891
+ this.markInteraction();
1892
+ });
1893
+ return el;
1894
+ };
1895
+ // Always add Auto
1896
+ listEl.appendChild(createItem(null, "Auto"));
1897
+ qualities.forEach((q) => {
1898
+ const label = q.label || (q.height ? `${q.height}p` : q.id);
1899
+ listEl.appendChild(createItem(q.id, label));
1900
+ });
1901
+ }
1902
+ renderStaticIcons() {
1903
+ this.setButtonIcon(this.controlPrevButton, "skip-back");
1904
+ this.setButtonIcon(this.controlNextButton, "skip-forward");
1905
+ this.setButtonIcon(this.muteButton, "volume-2");
1906
+ this.setButtonIcon(this.settingsButton, "settings");
1907
+ this.setButtonIcon(this.fullscreenButton, "maximize");
1908
+ this.setButtonIcon(this.playlistButton, "list");
1909
+ this.setButtonIcon(this.playlistCloseButton, "x");
1910
+ // Unified Menu Icons
1911
+ const setIcon = (selector, name, size = "18") => {
1912
+ this.settingsMenu.querySelectorAll(selector).forEach((el) => {
1913
+ el.innerHTML = icons[name].toSvg({ width: size, height: size, "stroke-width": "2" });
1914
+ });
1915
+ };
1916
+ setIcon(".icon-quality", "monitor");
1917
+ setIcon(".icon-speed", "fast-forward");
1918
+ setIcon(".icon-play", "play");
1919
+ setIcon(".icon-repeat", "repeat");
1920
+ setIcon(".icon-arrow-left", "arrow-left", "20");
1921
+ this.settingsMenu.querySelectorAll(".icon-chevron-right").forEach((el) => {
1922
+ el.innerHTML = icons["chevron-right"].toSvg({ width: "16", height: "16", "stroke-width": "2.5" });
1923
+ });
1924
+ const downloadIcon = this.settingsMenu.querySelector(".download-icon");
1925
+ if (downloadIcon) {
1926
+ downloadIcon.innerHTML = icons.download.toSvg({ width: "16", height: "16", "stroke-width": "2.5" });
1927
+ }
1928
+ this.settingsMenu.querySelectorAll(".check").forEach((element) => {
1929
+ element.innerHTML = icons.check.toSvg({
1930
+ width: "16",
1931
+ height: "16",
1932
+ "stroke-width": "2.5"
1933
+ });
1934
+ });
1935
+ }
1936
+ setButtonIcon(button, iconName) {
1937
+ const icon = icons[iconName];
1938
+ if (!icon)
1939
+ return;
1940
+ button.innerHTML = icon.toSvg({
1941
+ width: "26",
1942
+ height: "26",
1943
+ "stroke-width": "2.2"
1944
+ });
1945
+ }
1946
+ updateSpeedUi(rate) {
1947
+ const normalized = [0.5, 1, 1.5, 2].includes(rate) ? rate : 1;
1948
+ const valueEl = this.settingsMenu.querySelector(".current-speed-value");
1949
+ if (valueEl)
1950
+ valueEl.textContent = `${normalized}x`;
1951
+ this.settingsMenu.querySelectorAll(".speed-item").forEach((item) => {
1952
+ const itemRate = Number(item.dataset.rate);
1953
+ item.classList.toggle("active", itemRate === normalized);
1954
+ });
1955
+ }
1956
+ getBufferedPercent(duration, currentTime) {
1957
+ if (!duration || !Number.isFinite(duration) || duration <= 0)
1958
+ return 0;
1959
+ const ranges = this.videoEl.buffered;
1960
+ if (!ranges || ranges.length === 0)
1961
+ return 0;
1962
+ let end = 0;
1963
+ for (let i = 0; i < ranges.length; i += 1) {
1964
+ const start = ranges.start(i);
1965
+ const rangeEnd = ranges.end(i);
1966
+ if (currentTime >= start && currentTime <= rangeEnd) {
1967
+ end = Math.max(end, rangeEnd);
1968
+ }
1969
+ else {
1970
+ end = Math.max(end, rangeEnd);
1971
+ }
1972
+ }
1973
+ return Math.max(0, Math.min(100, (end / duration) * 100));
1974
+ }
1975
+ startProgressLoop() {
1976
+ this.stopProgressLoop();
1977
+ const tick = () => {
1978
+ if (this.videoEl.paused || this.videoEl.ended) {
1979
+ this.progressRafId = null;
1980
+ return;
1981
+ }
1982
+ this.handleVideoProgress();
1983
+ this.progressRafId = window.requestAnimationFrame(tick);
1984
+ };
1985
+ this.progressRafId = window.requestAnimationFrame(tick);
1986
+ }
1987
+ stopProgressLoop() {
1988
+ if (this.progressRafId == null)
1989
+ return;
1990
+ window.cancelAnimationFrame(this.progressRafId);
1991
+ this.progressRafId = null;
1992
+ }
1993
+ }
1994
+ export function defineUvpPlayerElement(tagName = DEFAULT_TAG) {
1995
+ if (!customElements.get(tagName)) {
1996
+ customElements.define(tagName, UvpPlayerElement);
1997
+ }
1998
+ }
1999
+ //# sourceMappingURL=index.js.map