@scarlett-player/ui 0.4.1 → 0.5.1

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.cjs CHANGED
@@ -138,7 +138,12 @@ var styles = `
138
138
  transition: height 0.15s ease;
139
139
  }
140
140
 
141
- .sp-progress-wrapper:hover .sp-progress,
141
+ @media (hover: hover) {
142
+ .sp-progress-wrapper:hover .sp-progress {
143
+ height: 5px;
144
+ }
145
+ }
146
+
142
147
  .sp-progress--dragging {
143
148
  height: 5px;
144
149
  }
@@ -184,11 +189,34 @@ var styles = `
184
189
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
185
190
  }
186
191
 
187
- .sp-progress-wrapper:hover .sp-progress__handle,
192
+ @media (hover: hover) {
193
+ .sp-progress-wrapper:hover .sp-progress__handle {
194
+ transform: translate(-50%, -50%) scale(1);
195
+ }
196
+ }
197
+
188
198
  .sp-progress--dragging .sp-progress__handle {
189
199
  transform: translate(-50%, -50%) scale(1);
190
200
  }
191
201
 
202
+ /* Thumbnail Preview */
203
+ .sp-thumbnail-preview {
204
+ position: absolute;
205
+ bottom: calc(100% + 8px);
206
+ transform: translateX(-50%);
207
+ pointer-events: none;
208
+ display: none;
209
+ z-index: 21;
210
+ border-radius: 4px;
211
+ overflow: hidden;
212
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
213
+ border: 2px solid rgba(255, 255, 255, 0.2);
214
+ }
215
+
216
+ .sp-thumbnail-preview__img {
217
+ background-repeat: no-repeat;
218
+ }
219
+
192
220
  /* Progress Tooltip */
193
221
  .sp-progress__tooltip {
194
222
  position: absolute;
@@ -208,8 +236,10 @@ var styles = `
208
236
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
209
237
  }
210
238
 
211
- .sp-progress-wrapper:hover .sp-progress__tooltip {
212
- opacity: 1;
239
+ @media (hover: hover) {
240
+ .sp-progress-wrapper:hover .sp-progress__tooltip {
241
+ opacity: 1;
242
+ }
213
243
  }
214
244
 
215
245
  /* ============================================
@@ -229,9 +259,11 @@ var styles = `
229
259
  flex-shrink: 0;
230
260
  }
231
261
 
232
- .sp-control:hover {
233
- color: #fff;
234
- background: rgba(255, 255, 255, 0.1);
262
+ @media (hover: hover) {
263
+ .sp-control:hover {
264
+ color: #fff;
265
+ background: rgba(255, 255, 255, 0.1);
266
+ }
235
267
  }
236
268
 
237
269
  .sp-control:active {
@@ -300,7 +332,12 @@ var styles = `
300
332
  transition: width 0.2s ease;
301
333
  }
302
334
 
303
- .sp-volume:hover .sp-volume__slider-wrap,
335
+ @media (hover: hover) {
336
+ .sp-volume:hover .sp-volume__slider-wrap {
337
+ width: 64px;
338
+ }
339
+ }
340
+
304
341
  .sp-volume:focus-within .sp-volume__slider-wrap {
305
342
  width: 64px;
306
343
  }
@@ -343,8 +380,10 @@ var styles = `
343
380
  transition: background 0.15s ease, opacity 0.15s ease;
344
381
  }
345
382
 
346
- .sp-live:hover {
347
- background: rgba(255, 255, 255, 0.1);
383
+ @media (hover: hover) {
384
+ .sp-live:hover {
385
+ background: rgba(255, 255, 255, 0.1);
386
+ }
348
387
  }
349
388
 
350
389
  .sp-live__dot {
@@ -363,6 +402,16 @@ var styles = `
363
402
  animation: none;
364
403
  }
365
404
 
405
+ .sp-live--behind span {
406
+ text-decoration: underline;
407
+ text-underline-offset: 2px;
408
+ }
409
+
410
+ /* Progress bar live mode: accent color for filled bar */
411
+ .sp-progress--live .sp-progress__filled {
412
+ background: var(--sp-accent, #e50914);
413
+ }
414
+
366
415
  @keyframes sp-pulse {
367
416
  0%, 100% { opacity: 1; }
368
417
  50% { opacity: 0.4; }
@@ -443,6 +492,169 @@ var styles = `
443
492
  opacity: 1;
444
493
  }
445
494
 
495
+ /* ============================================
496
+ Settings Menu (Gear Icon)
497
+ ============================================ */
498
+ .sp-settings {
499
+ position: relative;
500
+ }
501
+
502
+ .sp-settings__btn {
503
+ display: flex;
504
+ align-items: center;
505
+ }
506
+
507
+ .sp-settings-panel {
508
+ position: absolute;
509
+ bottom: calc(100% + 8px);
510
+ right: 0;
511
+ background: rgba(20, 20, 20, 0.95);
512
+ backdrop-filter: blur(8px);
513
+ -webkit-backdrop-filter: blur(8px);
514
+ border-radius: 8px;
515
+ min-width: 200px;
516
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
517
+ opacity: 0;
518
+ visibility: hidden;
519
+ transform: translateY(8px);
520
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
521
+ z-index: 20;
522
+ overflow: hidden;
523
+ }
524
+
525
+ .sp-settings-panel--open {
526
+ opacity: 1;
527
+ visibility: visible;
528
+ transform: translateY(0);
529
+ }
530
+
531
+ /* Main menu rows */
532
+ .sp-settings-panel--main {
533
+ padding: 4px 0;
534
+ }
535
+
536
+ .sp-settings-panel__row {
537
+ display: flex;
538
+ align-items: center;
539
+ justify-content: space-between;
540
+ padding: 10px 16px;
541
+ font-size: 13px;
542
+ color: rgba(255, 255, 255, 0.9);
543
+ cursor: pointer;
544
+ transition: background 0.1s ease;
545
+ }
546
+
547
+ .sp-settings-panel__row:hover {
548
+ background: rgba(255, 255, 255, 0.1);
549
+ }
550
+
551
+ .sp-settings-panel__label {
552
+ font-weight: 500;
553
+ }
554
+
555
+ .sp-settings-panel__value {
556
+ display: flex;
557
+ align-items: center;
558
+ gap: 4px;
559
+ color: rgba(255, 255, 255, 0.6);
560
+ font-size: 12px;
561
+ }
562
+
563
+ .sp-settings-panel__arrow {
564
+ display: flex;
565
+ align-items: center;
566
+ transform: rotate(-90deg);
567
+ }
568
+
569
+ .sp-settings-panel__arrow svg {
570
+ width: 16px;
571
+ height: 16px;
572
+ fill: currentColor;
573
+ }
574
+
575
+ /* Sub-menu panels */
576
+ .sp-settings-panel--sub {
577
+ padding: 0;
578
+ }
579
+
580
+ .sp-settings-panel__header {
581
+ display: flex;
582
+ align-items: center;
583
+ gap: 8px;
584
+ padding: 10px 16px;
585
+ font-size: 13px;
586
+ font-weight: 600;
587
+ color: rgba(255, 255, 255, 0.9);
588
+ cursor: pointer;
589
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
590
+ transition: background 0.1s ease;
591
+ }
592
+
593
+ .sp-settings-panel__header:hover {
594
+ background: rgba(255, 255, 255, 0.1);
595
+ }
596
+
597
+ .sp-settings-panel__back {
598
+ display: flex;
599
+ align-items: center;
600
+ transform: rotate(-90deg);
601
+ }
602
+
603
+ .sp-settings-panel__back svg {
604
+ width: 16px;
605
+ height: 16px;
606
+ fill: currentColor;
607
+ }
608
+
609
+ .sp-settings-panel__header-label {
610
+ flex: 1;
611
+ }
612
+
613
+ .sp-settings-panel__item {
614
+ display: flex;
615
+ align-items: center;
616
+ justify-content: space-between;
617
+ padding: 10px 16px;
618
+ font-size: 13px;
619
+ color: rgba(255, 255, 255, 0.8);
620
+ cursor: pointer;
621
+ transition: background 0.1s ease, color 0.1s ease;
622
+ }
623
+
624
+ .sp-settings-panel__item:hover {
625
+ background: rgba(255, 255, 255, 0.1);
626
+ color: #fff;
627
+ }
628
+
629
+ .sp-settings-panel__item--active {
630
+ color: var(--sp-accent, #e50914);
631
+ }
632
+
633
+ .sp-settings-panel__check {
634
+ width: 16px;
635
+ height: 16px;
636
+ fill: currentColor;
637
+ margin-left: 8px;
638
+ opacity: 0;
639
+ }
640
+
641
+ .sp-settings-panel__check svg {
642
+ width: 16px;
643
+ height: 16px;
644
+ fill: currentColor;
645
+ }
646
+
647
+ .sp-settings-panel__item--active .sp-settings-panel__check {
648
+ opacity: 1;
649
+ }
650
+
651
+ /* ============================================
652
+ Captions Button
653
+ ============================================ */
654
+ .sp-captions--active {
655
+ color: var(--sp-accent, #e50914);
656
+ }
657
+
446
658
  /* ============================================
447
659
  Cast Button States
448
660
  ============================================ */
@@ -454,6 +666,122 @@ var styles = `
454
666
  opacity: 0.4;
455
667
  }
456
668
 
669
+ /* ============================================
670
+ Error Overlay
671
+ ============================================ */
672
+ .sp-error-overlay {
673
+ position: absolute;
674
+ top: 0;
675
+ left: 0;
676
+ right: 0;
677
+ bottom: 0;
678
+ background: rgba(0, 0, 0, 0.85);
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: center;
682
+ z-index: 25;
683
+ opacity: 0;
684
+ visibility: hidden;
685
+ transition: opacity 0.25s ease, visibility 0.25s;
686
+ }
687
+
688
+ .sp-error-overlay--visible {
689
+ opacity: 1;
690
+ visibility: visible;
691
+ }
692
+
693
+ .sp-error-overlay__content {
694
+ display: flex;
695
+ flex-direction: column;
696
+ align-items: center;
697
+ text-align: center;
698
+ padding: 24px;
699
+ max-width: 360px;
700
+ }
701
+
702
+ .sp-error-overlay__icon {
703
+ color: rgba(255, 255, 255, 0.7);
704
+ margin-bottom: 16px;
705
+ }
706
+
707
+ .sp-error-overlay__icon svg {
708
+ width: 48px;
709
+ height: 48px;
710
+ fill: currentColor;
711
+ }
712
+
713
+ .sp-error-overlay__message {
714
+ color: rgba(255, 255, 255, 0.9);
715
+ font-size: 15px;
716
+ line-height: 1.5;
717
+ margin: 0 0 24px;
718
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
719
+ }
720
+
721
+ .sp-error-overlay__actions {
722
+ display: flex;
723
+ gap: 12px;
724
+ flex-wrap: wrap;
725
+ justify-content: center;
726
+ }
727
+
728
+ .sp-error-overlay__retry {
729
+ background: var(--sp-accent, #e50914);
730
+ color: #fff;
731
+ border: none;
732
+ padding: 12px 24px;
733
+ font-size: 14px;
734
+ font-weight: 600;
735
+ border-radius: 6px;
736
+ cursor: pointer;
737
+ min-width: 120px;
738
+ min-height: 44px;
739
+ transition: background 0.15s ease, transform 0.15s ease;
740
+ font-family: inherit;
741
+ }
742
+
743
+ .sp-error-overlay__retry:hover {
744
+ filter: brightness(1.1);
745
+ }
746
+
747
+ .sp-error-overlay__retry:active {
748
+ transform: scale(0.96);
749
+ }
750
+
751
+ .sp-error-overlay__retry:focus-visible {
752
+ outline: 2px solid #fff;
753
+ outline-offset: 2px;
754
+ }
755
+
756
+ .sp-error-overlay__dismiss {
757
+ background: none;
758
+ color: rgba(255, 255, 255, 0.7);
759
+ border: 1px solid rgba(255, 255, 255, 0.3);
760
+ padding: 12px 24px;
761
+ font-size: 14px;
762
+ font-weight: 500;
763
+ border-radius: 6px;
764
+ cursor: pointer;
765
+ min-width: 100px;
766
+ min-height: 44px;
767
+ transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
768
+ font-family: inherit;
769
+ }
770
+
771
+ .sp-error-overlay__dismiss:hover {
772
+ color: #fff;
773
+ border-color: rgba(255, 255, 255, 0.5);
774
+ }
775
+
776
+ .sp-error-overlay__dismiss:active {
777
+ transform: scale(0.96);
778
+ }
779
+
780
+ .sp-error-overlay__dismiss:focus-visible {
781
+ outline: 2px solid #fff;
782
+ outline-offset: 2px;
783
+ }
784
+
457
785
  /* ============================================
458
786
  Buffering Indicator
459
787
  ============================================ */
@@ -501,7 +829,14 @@ var styles = `
501
829
  .sp-control,
502
830
  .sp-volume__slider-wrap,
503
831
  .sp-quality-menu,
504
- .sp-buffering {
832
+ .sp-settings-panel,
833
+ .sp-settings-panel__row,
834
+ .sp-settings-panel__item,
835
+ .sp-settings-panel__header,
836
+ .sp-buffering,
837
+ .sp-error-overlay,
838
+ .sp-error-overlay__retry,
839
+ .sp-error-overlay__dismiss {
505
840
  transition: none;
506
841
  }
507
842
 
@@ -547,8 +882,9 @@ var icons = {
547
882
  spinner: `<svg viewBox="0 0 24 24" fill="currentColor" class="sp-spin"><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/></svg>`,
548
883
  skipForward: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>`,
549
884
  skipBack: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/></svg>`,
550
- forward10: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/><text x="12" y="15" text-anchor="middle" font-size="7" font-weight="600">10</text></svg>`,
551
- replay10: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/><text x="12" y="15" text-anchor="middle" font-size="7" font-weight="600">10</text></svg>`
885
+ forward10: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/><path d="M10.9 16V11.73l-.72.36-.48-.86 1.48-.73h.85V16h-1.13zm2.77-2.14c0-.66.13-1.2.38-1.6.26-.41.66-.62 1.2-.62.55 0 .95.21 1.21.62.25.4.38.94.38 1.6 0 .67-.13 1.2-.38 1.61-.26.41-.66.61-1.21.61-.54 0-.94-.2-1.2-.61-.25-.41-.38-.94-.38-1.61zm1.12 0c0 .45.05.79.15 1.03.1.23.26.35.48.35s.38-.12.49-.35c.1-.24.15-.58.15-1.03s-.05-.78-.15-1.02c-.11-.23-.27-.35-.49-.35s-.38.12-.48.35c-.1.24-.15.57-.15 1.02z"/></svg>`,
886
+ replay10: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/><path d="M10.9 16V11.73l-.72.36-.48-.86 1.48-.73h.85V16h-1.13zm2.77-2.14c0-.66.13-1.2.38-1.6.26-.41.66-.62 1.2-.62.55 0 .95.21 1.21.62.25.4.38.94.38 1.6 0 .67-.13 1.2-.38 1.61-.26.41-.66.61-1.21.61-.54 0-.94-.2-1.2-.61-.25-.41-.38-.94-.38-1.61zm1.12 0c0 .45.05.79.15 1.03.1.23.26.35.48.35s.38-.12.49-.35c.1-.24.15-.58.15-1.03s-.05-.78-.15-1.02c-.11-.23-.27-.35-.49-.35s-.38.12-.48.35c-.1.24-.15.57-.15 1.02z"/></svg>`,
887
+ error: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>`
552
888
  };
553
889
 
554
890
  // src/utils/dom.ts
@@ -664,6 +1000,70 @@ var PlayButton = class {
664
1000
  }
665
1001
  };
666
1002
 
1003
+ // src/controls/ThumbnailPreview.ts
1004
+ var ThumbnailPreview = class {
1005
+ constructor() {
1006
+ this.config = null;
1007
+ this.loaded = false;
1008
+ this.el = createElement("div", { className: "sp-thumbnail-preview" });
1009
+ this.img = createElement("div", { className: "sp-thumbnail-preview__img" });
1010
+ this.el.appendChild(this.img);
1011
+ }
1012
+ getElement() {
1013
+ return this.el;
1014
+ }
1015
+ setConfig(config) {
1016
+ this.config = config;
1017
+ this.loaded = false;
1018
+ if (config) {
1019
+ this.img.style.width = `${config.width}px`;
1020
+ this.img.style.height = `${config.height}px`;
1021
+ this.el.style.width = `${config.width}px`;
1022
+ this.el.style.height = `${config.height}px`;
1023
+ const preload = new Image();
1024
+ preload.onload = () => {
1025
+ this.loaded = true;
1026
+ };
1027
+ preload.onerror = () => {
1028
+ this.config = null;
1029
+ this.loaded = false;
1030
+ };
1031
+ preload.src = config.src;
1032
+ }
1033
+ }
1034
+ /**
1035
+ * Update the thumbnail to show the frame at the given time.
1036
+ * @param time Time in seconds
1037
+ * @param percent Position as 0-1 fraction (for horizontal positioning)
1038
+ */
1039
+ show(time, percent) {
1040
+ if (!this.config || !this.loaded) {
1041
+ this.el.style.display = "none";
1042
+ return;
1043
+ }
1044
+ const { src, width, height, columns, interval } = this.config;
1045
+ const index = Math.floor(time / interval);
1046
+ const col = index % columns;
1047
+ const row = Math.floor(index / columns);
1048
+ this.img.style.backgroundImage = `url(${src})`;
1049
+ this.img.style.backgroundPosition = `-${col * width}px -${row * height}px`;
1050
+ this.img.style.backgroundSize = `${columns * width}px auto`;
1051
+ this.img.style.width = `${width}px`;
1052
+ this.img.style.height = `${height}px`;
1053
+ this.el.style.left = `${percent * 100}%`;
1054
+ this.el.style.display = "";
1055
+ }
1056
+ hide() {
1057
+ this.el.style.display = "none";
1058
+ }
1059
+ isConfigured() {
1060
+ return this.config !== null;
1061
+ }
1062
+ destroy() {
1063
+ this.el.remove();
1064
+ }
1065
+ };
1066
+
667
1067
  // src/controls/ProgressBar.ts
668
1068
  var ProgressBar = class {
669
1069
  constructor(api) {
@@ -705,36 +1105,99 @@ var ProgressBar = class {
705
1105
  }
706
1106
  }
707
1107
  };
1108
+ this.onTouchStart = (e) => {
1109
+ e.preventDefault();
1110
+ const video = getVideo(this.api.container);
1111
+ this.wasPlayingBeforeDrag = video ? !video.paused : false;
1112
+ this.isDragging = true;
1113
+ this.el.classList.add("sp-progress--dragging");
1114
+ this.lastSeekTime = 0;
1115
+ this.seek(e.touches[0].clientX, true);
1116
+ };
1117
+ this.onDocTouchMove = (e) => {
1118
+ if (this.isDragging) {
1119
+ e.preventDefault();
1120
+ this.seek(e.touches[0].clientX);
1121
+ this.updateVisualPosition(e.touches[0].clientX);
1122
+ }
1123
+ };
1124
+ this.onTouchEnd = (e) => {
1125
+ if (this.isDragging) {
1126
+ const clientX = e.changedTouches?.[0]?.clientX;
1127
+ if (clientX !== void 0) {
1128
+ this.seek(clientX, true);
1129
+ }
1130
+ this.isDragging = false;
1131
+ this.el.classList.remove("sp-progress--dragging");
1132
+ if (this.wasPlayingBeforeDrag) {
1133
+ const video = getVideo(this.api.container);
1134
+ if (video && video.paused) {
1135
+ const resumePlayback = () => {
1136
+ video.removeEventListener("seeked", resumePlayback);
1137
+ video.play().catch(() => {
1138
+ });
1139
+ };
1140
+ video.addEventListener("seeked", resumePlayback);
1141
+ }
1142
+ }
1143
+ this.tooltip.style.opacity = "0";
1144
+ this.thumbnailPreview.hide();
1145
+ }
1146
+ };
708
1147
  this.onMouseMove = (e) => {
709
1148
  this.updateTooltip(e.clientX);
710
1149
  };
711
1150
  this.onMouseLeave = () => {
712
1151
  if (!this.isDragging) {
713
1152
  this.tooltip.style.opacity = "0";
1153
+ this.thumbnailPreview.hide();
714
1154
  }
715
1155
  };
716
1156
  this.onKeyDown = (e) => {
717
1157
  const video = getVideo(this.api.container);
718
1158
  if (!video) return;
719
1159
  const step = 5;
720
- const duration = this.api.getState("duration") || 0;
721
- switch (e.key) {
722
- case "ArrowLeft":
723
- e.preventDefault();
724
- video.currentTime = Math.max(0, video.currentTime - step);
725
- break;
726
- case "ArrowRight":
727
- e.preventDefault();
728
- video.currentTime = Math.min(duration, video.currentTime + step);
729
- break;
730
- case "Home":
731
- e.preventDefault();
732
- video.currentTime = 0;
733
- break;
734
- case "End":
735
- e.preventDefault();
736
- video.currentTime = duration;
737
- break;
1160
+ const live = this.api.getState("live");
1161
+ const seekableRange = this.api.getState("seekableRange");
1162
+ if (live && seekableRange) {
1163
+ switch (e.key) {
1164
+ case "ArrowLeft":
1165
+ e.preventDefault();
1166
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - step);
1167
+ break;
1168
+ case "ArrowRight":
1169
+ e.preventDefault();
1170
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + step);
1171
+ break;
1172
+ case "Home":
1173
+ e.preventDefault();
1174
+ video.currentTime = seekableRange.start;
1175
+ break;
1176
+ case "End":
1177
+ e.preventDefault();
1178
+ video.currentTime = seekableRange.end;
1179
+ break;
1180
+ }
1181
+ } else {
1182
+ const duration = this.api.getState("duration") || 0;
1183
+ switch (e.key) {
1184
+ case "ArrowLeft":
1185
+ e.preventDefault();
1186
+ video.currentTime = Math.max(0, video.currentTime - step);
1187
+ break;
1188
+ case "ArrowRight":
1189
+ e.preventDefault();
1190
+ video.currentTime = Math.min(duration, video.currentTime + step);
1191
+ break;
1192
+ case "Home":
1193
+ e.preventDefault();
1194
+ video.currentTime = 0;
1195
+ break;
1196
+ case "End":
1197
+ e.preventDefault();
1198
+ video.currentTime = duration;
1199
+ break;
1200
+ }
738
1201
  }
739
1202
  };
740
1203
  this.api = api;
@@ -746,10 +1209,12 @@ var ProgressBar = class {
746
1209
  this.handle = createElement("div", { className: "sp-progress__handle" });
747
1210
  this.tooltip = createElement("div", { className: "sp-progress__tooltip" });
748
1211
  this.tooltip.textContent = "0:00";
1212
+ this.thumbnailPreview = new ThumbnailPreview();
749
1213
  track.appendChild(this.buffered);
750
1214
  track.appendChild(this.filled);
751
1215
  track.appendChild(this.handle);
752
1216
  this.el.appendChild(track);
1217
+ this.el.appendChild(this.thumbnailPreview.getElement());
753
1218
  this.el.appendChild(this.tooltip);
754
1219
  this.wrapper.appendChild(this.el);
755
1220
  this.el.setAttribute("role", "slider");
@@ -759,9 +1224,13 @@ var ProgressBar = class {
759
1224
  this.wrapper.addEventListener("mousedown", this.onMouseDown);
760
1225
  this.wrapper.addEventListener("mousemove", this.onMouseMove);
761
1226
  this.wrapper.addEventListener("mouseleave", this.onMouseLeave);
1227
+ this.wrapper.addEventListener("touchstart", this.onTouchStart, { passive: false });
762
1228
  this.el.addEventListener("keydown", this.onKeyDown);
763
1229
  document.addEventListener("mousemove", this.onDocMouseMove);
764
1230
  document.addEventListener("mouseup", this.onMouseUp);
1231
+ document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
1232
+ document.addEventListener("touchend", this.onTouchEnd);
1233
+ document.addEventListener("touchcancel", this.onTouchEnd);
765
1234
  }
766
1235
  render() {
767
1236
  return this.wrapper;
@@ -774,11 +1243,40 @@ var ProgressBar = class {
774
1243
  hide() {
775
1244
  this.wrapper.classList.remove("sp-progress-wrapper--visible");
776
1245
  }
1246
+ /** Set thumbnail sprite configuration */
1247
+ setThumbnails(config) {
1248
+ this.thumbnailPreview.setConfig(config);
1249
+ }
777
1250
  update() {
778
1251
  const currentTime = this.api.getState("currentTime") || 0;
779
1252
  const duration = this.api.getState("duration") || 0;
780
1253
  const bufferedRanges = this.api.getState("buffered");
781
- if (duration > 0) {
1254
+ const live = this.api.getState("live");
1255
+ const seekableRange = this.api.getState("seekableRange");
1256
+ const thumbnails = this.api.getState("thumbnails");
1257
+ if (thumbnails && !this.thumbnailPreview.isConfigured()) {
1258
+ this.thumbnailPreview.setConfig(thumbnails);
1259
+ }
1260
+ this.el.classList.toggle("sp-progress--live", !!live);
1261
+ if (live && seekableRange) {
1262
+ const rangeLength = seekableRange.end - seekableRange.start;
1263
+ if (rangeLength > 0) {
1264
+ const progress = (currentTime - seekableRange.start) / rangeLength * 100;
1265
+ this.filled.style.width = `${Math.max(0, Math.min(100, progress))}%`;
1266
+ this.handle.style.left = `${Math.max(0, Math.min(100, progress))}%`;
1267
+ }
1268
+ if (bufferedRanges && bufferedRanges.length > 0) {
1269
+ const rangeLength2 = seekableRange.end - seekableRange.start;
1270
+ if (rangeLength2 > 0) {
1271
+ const bufferedEnd = bufferedRanges.end(bufferedRanges.length - 1);
1272
+ const bufferedPercent = (bufferedEnd - seekableRange.start) / rangeLength2 * 100;
1273
+ this.buffered.style.width = `${Math.max(0, Math.min(100, bufferedPercent))}%`;
1274
+ }
1275
+ }
1276
+ this.el.setAttribute("aria-valuemax", String(Math.floor(seekableRange.end)));
1277
+ this.el.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
1278
+ this.el.setAttribute("aria-valuetext", `${Math.floor(seekableRange.end - currentTime)} seconds behind live`);
1279
+ } else if (duration > 0) {
782
1280
  const progress = currentTime / duration * 100;
783
1281
  this.filled.style.width = `${progress}%`;
784
1282
  this.handle.style.left = `${progress}%`;
@@ -795,6 +1293,12 @@ var ProgressBar = class {
795
1293
  getTimeFromPosition(clientX) {
796
1294
  const rect = this.el.getBoundingClientRect();
797
1295
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1296
+ const live = this.api.getState("live");
1297
+ const seekableRange = this.api.getState("seekableRange");
1298
+ if (live && seekableRange) {
1299
+ const rangeLength = seekableRange.end - seekableRange.start;
1300
+ return seekableRange.start + percent * rangeLength;
1301
+ }
798
1302
  const duration = this.api.getState("duration") || 0;
799
1303
  return percent * duration;
800
1304
  }
@@ -802,8 +1306,18 @@ var ProgressBar = class {
802
1306
  const rect = this.el.getBoundingClientRect();
803
1307
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
804
1308
  const time = this.getTimeFromPosition(clientX);
805
- this.tooltip.textContent = formatTime(time);
1309
+ const live = this.api.getState("live");
1310
+ const seekableRange = this.api.getState("seekableRange");
1311
+ if (live && seekableRange) {
1312
+ const behindLive = seekableRange.end - time;
1313
+ this.tooltip.textContent = formatLiveTime(behindLive);
1314
+ } else {
1315
+ this.tooltip.textContent = formatTime(time);
1316
+ }
806
1317
  this.tooltip.style.left = `${percent * 100}%`;
1318
+ if (this.thumbnailPreview.isConfigured()) {
1319
+ this.thumbnailPreview.show(time, percent);
1320
+ }
807
1321
  }
808
1322
  updateVisualPosition(clientX) {
809
1323
  const rect = this.el.getBoundingClientRect();
@@ -826,8 +1340,13 @@ var ProgressBar = class {
826
1340
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
827
1341
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
828
1342
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
1343
+ this.wrapper.removeEventListener("touchstart", this.onTouchStart);
829
1344
  document.removeEventListener("mousemove", this.onDocMouseMove);
830
1345
  document.removeEventListener("mouseup", this.onMouseUp);
1346
+ document.removeEventListener("touchmove", this.onDocTouchMove);
1347
+ document.removeEventListener("touchend", this.onTouchEnd);
1348
+ document.removeEventListener("touchcancel", this.onTouchEnd);
1349
+ this.thumbnailPreview.destroy();
831
1350
  this.wrapper.remove();
832
1351
  }
833
1352
  };
@@ -880,6 +1399,20 @@ var VolumeControl = class {
880
1399
  this.onMouseUp = () => {
881
1400
  this.isDragging = false;
882
1401
  };
1402
+ this.onTouchStart = (e) => {
1403
+ e.preventDefault();
1404
+ this.isDragging = true;
1405
+ this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));
1406
+ };
1407
+ this.onDocTouchMove = (e) => {
1408
+ if (this.isDragging) {
1409
+ e.preventDefault();
1410
+ this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));
1411
+ }
1412
+ };
1413
+ this.onTouchEnd = () => {
1414
+ this.isDragging = false;
1415
+ };
883
1416
  this.onKeyDown = (e) => {
884
1417
  const video = getVideo(this.api.container);
885
1418
  if (!video) return;
@@ -919,9 +1452,13 @@ var VolumeControl = class {
919
1452
  this.el.appendChild(this.btn);
920
1453
  this.el.appendChild(sliderWrap);
921
1454
  this.slider.addEventListener("mousedown", this.onMouseDown);
1455
+ this.slider.addEventListener("touchstart", this.onTouchStart, { passive: false });
922
1456
  this.slider.addEventListener("keydown", this.onKeyDown);
923
1457
  document.addEventListener("mousemove", this.onDocMouseMove);
924
1458
  document.addEventListener("mouseup", this.onMouseUp);
1459
+ document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
1460
+ document.addEventListener("touchend", this.onTouchEnd);
1461
+ document.addEventListener("touchcancel", this.onTouchEnd);
925
1462
  }
926
1463
  render() {
927
1464
  return this.el;
@@ -966,8 +1503,14 @@ var VolumeControl = class {
966
1503
  return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
967
1504
  }
968
1505
  destroy() {
1506
+ this.slider.removeEventListener("mousedown", this.onMouseDown);
1507
+ this.slider.removeEventListener("touchstart", this.onTouchStart);
1508
+ this.slider.removeEventListener("keydown", this.onKeyDown);
969
1509
  document.removeEventListener("mousemove", this.onDocMouseMove);
970
1510
  document.removeEventListener("mouseup", this.onMouseUp);
1511
+ document.removeEventListener("touchmove", this.onDocTouchMove);
1512
+ document.removeEventListener("touchend", this.onTouchEnd);
1513
+ document.removeEventListener("touchcancel", this.onTouchEnd);
971
1514
  this.el.remove();
972
1515
  }
973
1516
  };
@@ -975,19 +1518,27 @@ var VolumeControl = class {
975
1518
  // src/controls/LiveIndicator.ts
976
1519
  var LiveIndicator = class {
977
1520
  constructor(api) {
978
- this.api = api;
979
- this.el = createElement("div", { className: "sp-live" });
980
- this.el.innerHTML = '<div class="sp-live__dot"></div><span>LIVE</span>';
981
- this.el.setAttribute("role", "button");
982
- this.el.setAttribute("aria-label", "Seek to live");
983
- this.el.setAttribute("tabindex", "0");
984
- this.el.onclick = () => this.seekToLive();
985
- this.el.onkeydown = (e) => {
1521
+ this.handleClick = () => {
1522
+ this.seekToLive();
1523
+ };
1524
+ this.handleKeyDown = (e) => {
986
1525
  if (e.key === "Enter" || e.key === " ") {
987
1526
  e.preventDefault();
988
1527
  this.seekToLive();
989
1528
  }
990
1529
  };
1530
+ this.api = api;
1531
+ this.el = createElement("div", { className: "sp-live" });
1532
+ this.dot = createElement("div", { className: "sp-live__dot" });
1533
+ this.label = document.createElement("span");
1534
+ this.label.textContent = "LIVE";
1535
+ this.el.appendChild(this.dot);
1536
+ this.el.appendChild(this.label);
1537
+ this.el.setAttribute("role", "button");
1538
+ this.el.setAttribute("aria-label", "Seek to live");
1539
+ this.el.setAttribute("tabindex", "0");
1540
+ this.el.addEventListener("click", this.handleClick);
1541
+ this.el.addEventListener("keydown", this.handleKeyDown);
991
1542
  }
992
1543
  render() {
993
1544
  return this.el;
@@ -998,8 +1549,12 @@ var LiveIndicator = class {
998
1549
  this.el.style.display = live ? "" : "none";
999
1550
  if (liveEdge) {
1000
1551
  this.el.classList.remove("sp-live--behind");
1552
+ this.label.textContent = "LIVE";
1553
+ this.el.setAttribute("aria-label", "At live edge");
1001
1554
  } else {
1002
1555
  this.el.classList.add("sp-live--behind");
1556
+ this.label.textContent = "GO LIVE";
1557
+ this.el.setAttribute("aria-label", "Seek to live");
1003
1558
  }
1004
1559
  }
1005
1560
  seekToLive() {
@@ -1011,6 +1566,8 @@ var LiveIndicator = class {
1011
1566
  }
1012
1567
  }
1013
1568
  destroy() {
1569
+ this.el.removeEventListener("click", this.handleClick);
1570
+ this.el.removeEventListener("keydown", this.handleKeyDown);
1014
1571
  this.el.remove();
1015
1572
  }
1016
1573
  };
@@ -1321,14 +1878,588 @@ var Spacer = class {
1321
1878
  }
1322
1879
  };
1323
1880
 
1881
+ // src/controls/ErrorOverlay.ts
1882
+ function getUserMessage(error) {
1883
+ if (!error) return "Something went wrong.";
1884
+ const msg = error.message?.toLowerCase() || "";
1885
+ if (msg.includes("network") || msg.includes("timeout") || msg.includes("fetch") || msg.includes("connection")) {
1886
+ return "Having trouble connecting. Check your internet and try again.";
1887
+ }
1888
+ if (msg.includes("manifest")) {
1889
+ return "Unable to load video. Please try again.";
1890
+ }
1891
+ if (msg.includes("decode") || msg.includes("media") || msg.includes("format") || msg.includes("codec")) {
1892
+ return "This video can't be played right now.";
1893
+ }
1894
+ if (msg.includes("not found") || msg.includes("404") || msg.includes("source") || msg.includes("not supported")) {
1895
+ return "Video not found.";
1896
+ }
1897
+ return "Something went wrong.";
1898
+ }
1899
+ var ErrorOverlay = class {
1900
+ constructor(api) {
1901
+ this.visible = false;
1902
+ this.lastSource = null;
1903
+ this.handleRetry = () => {
1904
+ if (this.retryBtn.disabled) return;
1905
+ this.retryBtn.disabled = true;
1906
+ this.hide();
1907
+ const source = this.api.getState("source");
1908
+ const src = source?.src || this.lastSource;
1909
+ if (src) {
1910
+ this.api.emit("error:retry", { src });
1911
+ const video = this.api.container.querySelector("video");
1912
+ if (video) {
1913
+ video.src = src;
1914
+ video.load();
1915
+ video.play().catch(() => {
1916
+ });
1917
+ }
1918
+ }
1919
+ setTimeout(() => {
1920
+ this.retryBtn.disabled = false;
1921
+ }, 1e3);
1922
+ };
1923
+ this.handleDismiss = () => {
1924
+ this.hide();
1925
+ this.api.emit("error:dismiss", void 0);
1926
+ };
1927
+ this.api = api;
1928
+ const overlay = document.createElement("div");
1929
+ overlay.className = "sp-error-overlay";
1930
+ overlay.setAttribute("role", "alert");
1931
+ overlay.setAttribute("aria-live", "assertive");
1932
+ const content = document.createElement("div");
1933
+ content.className = "sp-error-overlay__content";
1934
+ const iconEl = document.createElement("div");
1935
+ iconEl.className = "sp-error-overlay__icon";
1936
+ iconEl.innerHTML = icons.error;
1937
+ const messageEl = document.createElement("p");
1938
+ messageEl.className = "sp-error-overlay__message";
1939
+ messageEl.textContent = "Something went wrong.";
1940
+ const actions = document.createElement("div");
1941
+ actions.className = "sp-error-overlay__actions";
1942
+ this.retryBtn = document.createElement("button");
1943
+ this.retryBtn.className = "sp-error-overlay__retry";
1944
+ this.retryBtn.setAttribute("type", "button");
1945
+ this.retryBtn.setAttribute("aria-label", "Try again");
1946
+ this.retryBtn.textContent = "Try Again";
1947
+ this.retryBtn.addEventListener("click", this.handleRetry);
1948
+ this.dismissBtn = document.createElement("button");
1949
+ this.dismissBtn.className = "sp-error-overlay__dismiss";
1950
+ this.dismissBtn.setAttribute("type", "button");
1951
+ this.dismissBtn.setAttribute("aria-label", "Go back");
1952
+ this.dismissBtn.textContent = "Go Back";
1953
+ this.dismissBtn.addEventListener("click", this.handleDismiss);
1954
+ actions.appendChild(this.retryBtn);
1955
+ actions.appendChild(this.dismissBtn);
1956
+ content.appendChild(iconEl);
1957
+ content.appendChild(messageEl);
1958
+ content.appendChild(actions);
1959
+ overlay.appendChild(content);
1960
+ this.el = overlay;
1961
+ }
1962
+ render() {
1963
+ return this.el;
1964
+ }
1965
+ /** Show the error overlay with the given error */
1966
+ show(error) {
1967
+ const message = getUserMessage(error);
1968
+ const messageEl = this.el.querySelector(".sp-error-overlay__message");
1969
+ if (messageEl) {
1970
+ messageEl.textContent = message;
1971
+ }
1972
+ const source = this.api.getState("source");
1973
+ if (source?.src) {
1974
+ this.lastSource = source.src;
1975
+ }
1976
+ this.visible = true;
1977
+ this.retryBtn.disabled = false;
1978
+ this.el.classList.add("sp-error-overlay--visible");
1979
+ }
1980
+ /** Hide the error overlay */
1981
+ hide() {
1982
+ this.visible = false;
1983
+ this.el.classList.remove("sp-error-overlay--visible");
1984
+ }
1985
+ isVisible() {
1986
+ return this.visible;
1987
+ }
1988
+ update() {
1989
+ const playbackState = this.api.getState("playbackState");
1990
+ if (this.visible && playbackState !== "error" && playbackState !== "loading") {
1991
+ const playing = this.api.getState("playing");
1992
+ if (playing) {
1993
+ this.hide();
1994
+ }
1995
+ }
1996
+ }
1997
+ destroy() {
1998
+ this.retryBtn.removeEventListener("click", this.handleRetry);
1999
+ this.dismissBtn.removeEventListener("click", this.handleDismiss);
2000
+ this.el.remove();
2001
+ }
2002
+ };
2003
+
2004
+ // src/controls/SettingsMenu.ts
2005
+ var SPEED_OPTIONS = [
2006
+ { label: "0.5x", value: 0.5 },
2007
+ { label: "0.75x", value: 0.75 },
2008
+ { label: "Normal", value: 1 },
2009
+ { label: "1.25x", value: 1.25 },
2010
+ { label: "1.5x", value: 1.5 },
2011
+ { label: "2x", value: 2 }
2012
+ ];
2013
+ var SettingsMenu = class {
2014
+ constructor(api) {
2015
+ this.isOpen = false;
2016
+ this.currentPanel = "main";
2017
+ this.lastQualitiesJson = "";
2018
+ this.api = api;
2019
+ this.el = createElement("div", { className: "sp-settings" });
2020
+ this.btn = createButton("sp-settings__btn", "Settings", icons.settings);
2021
+ this.btn.setAttribute("aria-haspopup", "true");
2022
+ this.btn.setAttribute("aria-expanded", "false");
2023
+ this.btn.addEventListener("click", (e) => {
2024
+ e.stopPropagation();
2025
+ this.toggle();
2026
+ });
2027
+ this.panel = createElement("div", { className: "sp-settings-panel" });
2028
+ this.panel.setAttribute("role", "menu");
2029
+ this.panel.addEventListener("click", (e) => e.stopPropagation());
2030
+ this.el.appendChild(this.btn);
2031
+ this.el.appendChild(this.panel);
2032
+ this.closeHandler = (e) => {
2033
+ if (!this.el.contains(e.target)) {
2034
+ this.close();
2035
+ }
2036
+ };
2037
+ document.addEventListener("click", this.closeHandler);
2038
+ this.keyHandler = (e) => {
2039
+ if (!this.isOpen) return;
2040
+ if (e.key === "Escape") {
2041
+ e.preventDefault();
2042
+ e.stopPropagation();
2043
+ if (this.currentPanel !== "main") {
2044
+ this.showPanel("main");
2045
+ } else {
2046
+ this.close();
2047
+ this.btn.focus();
2048
+ }
2049
+ }
2050
+ };
2051
+ document.addEventListener("keydown", this.keyHandler);
2052
+ }
2053
+ render() {
2054
+ return this.el;
2055
+ }
2056
+ update() {
2057
+ const qualities = this.api.getState("qualities") || [];
2058
+ const qualitiesJson = JSON.stringify(qualities.map((q) => q.id));
2059
+ if (qualitiesJson !== this.lastQualitiesJson) {
2060
+ this.lastQualitiesJson = qualitiesJson;
2061
+ if (this.isOpen && this.currentPanel === "quality") {
2062
+ this.renderQualityPanel();
2063
+ }
2064
+ }
2065
+ if (this.isOpen) {
2066
+ if (this.currentPanel === "quality") {
2067
+ this.updateQualityActiveStates();
2068
+ } else if (this.currentPanel === "speed") {
2069
+ this.updateSpeedActiveStates();
2070
+ } else if (this.currentPanel === "captions") {
2071
+ this.updateCaptionsActiveStates();
2072
+ }
2073
+ }
2074
+ }
2075
+ toggle() {
2076
+ this.isOpen ? this.close() : this.open();
2077
+ }
2078
+ open() {
2079
+ this.isOpen = true;
2080
+ this.currentPanel = "main";
2081
+ this.renderMainPanel();
2082
+ this.panel.classList.add("sp-settings-panel--open");
2083
+ this.btn.setAttribute("aria-expanded", "true");
2084
+ }
2085
+ close() {
2086
+ this.isOpen = false;
2087
+ this.currentPanel = "main";
2088
+ this.panel.classList.remove("sp-settings-panel--open");
2089
+ this.btn.setAttribute("aria-expanded", "false");
2090
+ }
2091
+ showPanel(panel) {
2092
+ this.currentPanel = panel;
2093
+ switch (panel) {
2094
+ case "main":
2095
+ this.renderMainPanel();
2096
+ break;
2097
+ case "quality":
2098
+ this.renderQualityPanel();
2099
+ break;
2100
+ case "speed":
2101
+ this.renderSpeedPanel();
2102
+ break;
2103
+ case "captions":
2104
+ this.renderCaptionsPanel();
2105
+ break;
2106
+ }
2107
+ }
2108
+ renderMainPanel() {
2109
+ this.panel.innerHTML = "";
2110
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--main";
2111
+ const qualities = this.api.getState("qualities") || [];
2112
+ const currentQuality = this.api.getState("currentQuality");
2113
+ const playbackRate = this.api.getState("playbackRate") ?? 1;
2114
+ if (qualities.length > 0) {
2115
+ const qualityRow = this.createMainRow(
2116
+ "Quality",
2117
+ currentQuality?.label || "Auto",
2118
+ () => this.showPanel("quality")
2119
+ );
2120
+ this.panel.appendChild(qualityRow);
2121
+ }
2122
+ const textTracks = this.api.getState("textTracks") || [];
2123
+ if (textTracks.length > 0) {
2124
+ const currentTextTrack = this.api.getState("currentTextTrack");
2125
+ const captionsLabel = currentTextTrack ? currentTextTrack.label : "Off";
2126
+ const captionsRow = this.createMainRow(
2127
+ "Captions",
2128
+ captionsLabel,
2129
+ () => this.showPanel("captions")
2130
+ );
2131
+ this.panel.appendChild(captionsRow);
2132
+ }
2133
+ const speedLabel = playbackRate === 1 ? "Normal" : `${playbackRate}x`;
2134
+ const speedRow = this.createMainRow(
2135
+ "Speed",
2136
+ speedLabel,
2137
+ () => this.showPanel("speed")
2138
+ );
2139
+ this.panel.appendChild(speedRow);
2140
+ }
2141
+ createMainRow(label, value, onClick2) {
2142
+ const row = createElement("div", { className: "sp-settings-panel__row" });
2143
+ row.setAttribute("role", "menuitem");
2144
+ row.setAttribute("tabindex", "0");
2145
+ row.setAttribute("aria-haspopup", "true");
2146
+ const labelEl = createElement("span", { className: "sp-settings-panel__label" });
2147
+ labelEl.textContent = label;
2148
+ const rightSide = createElement("span", { className: "sp-settings-panel__value" });
2149
+ rightSide.textContent = value;
2150
+ const arrow = createElement("span", { className: "sp-settings-panel__arrow" });
2151
+ arrow.innerHTML = icons.chevronDown;
2152
+ rightSide.appendChild(arrow);
2153
+ row.appendChild(labelEl);
2154
+ row.appendChild(rightSide);
2155
+ row.addEventListener("click", (e) => {
2156
+ e.preventDefault();
2157
+ onClick2();
2158
+ });
2159
+ row.addEventListener("keydown", (e) => {
2160
+ if (e.key === "Enter" || e.key === " ") {
2161
+ e.preventDefault();
2162
+ onClick2();
2163
+ }
2164
+ });
2165
+ return row;
2166
+ }
2167
+ renderQualityPanel() {
2168
+ this.panel.innerHTML = "";
2169
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2170
+ const header = this.createSubHeader("Quality");
2171
+ this.panel.appendChild(header);
2172
+ const qualities = this.api.getState("qualities") || [];
2173
+ const currentQuality = this.api.getState("currentQuality");
2174
+ const activeId = currentQuality?.id || "auto";
2175
+ const autoItem = this.createMenuItem("Auto", "auto", activeId === "auto");
2176
+ autoItem.addEventListener("click", (e) => {
2177
+ e.preventDefault();
2178
+ this.selectQuality("auto");
2179
+ });
2180
+ this.panel.appendChild(autoItem);
2181
+ const sorted = [...qualities].sort(
2182
+ (a, b) => b.height - a.height
2183
+ );
2184
+ for (const q of sorted) {
2185
+ if (q.id === "auto") continue;
2186
+ const item = this.createMenuItem(q.label, q.id, q.id === activeId);
2187
+ item.addEventListener("click", (e) => {
2188
+ e.preventDefault();
2189
+ this.selectQuality(q.id);
2190
+ });
2191
+ this.panel.appendChild(item);
2192
+ }
2193
+ }
2194
+ renderSpeedPanel() {
2195
+ this.panel.innerHTML = "";
2196
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2197
+ const header = this.createSubHeader("Speed");
2198
+ this.panel.appendChild(header);
2199
+ const currentRate = this.api.getState("playbackRate") ?? 1;
2200
+ for (const opt of SPEED_OPTIONS) {
2201
+ const isActive = Math.abs(currentRate - opt.value) < 0.01;
2202
+ const item = this.createMenuItem(opt.label, String(opt.value), isActive);
2203
+ item.addEventListener("click", (e) => {
2204
+ e.preventDefault();
2205
+ this.selectSpeed(opt.value);
2206
+ });
2207
+ this.panel.appendChild(item);
2208
+ }
2209
+ }
2210
+ renderCaptionsPanel() {
2211
+ this.panel.innerHTML = "";
2212
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2213
+ const header = this.createSubHeader("Captions");
2214
+ this.panel.appendChild(header);
2215
+ const textTracks = this.api.getState("textTracks") || [];
2216
+ const currentTextTrack = this.api.getState("currentTextTrack");
2217
+ const activeId = currentTextTrack?.id || "off";
2218
+ const offItem = this.createMenuItem("Off", "off", activeId === "off");
2219
+ offItem.addEventListener("click", (e) => {
2220
+ e.preventDefault();
2221
+ this.selectCaption(null);
2222
+ });
2223
+ this.panel.appendChild(offItem);
2224
+ for (const track of textTracks) {
2225
+ const item = this.createMenuItem(track.label, track.id, track.id === activeId);
2226
+ item.addEventListener("click", (e) => {
2227
+ e.preventDefault();
2228
+ this.selectCaption(track.id);
2229
+ });
2230
+ this.panel.appendChild(item);
2231
+ }
2232
+ }
2233
+ selectCaption(trackId) {
2234
+ this.api.emit("track:text", { trackId });
2235
+ this.close();
2236
+ }
2237
+ updateCaptionsActiveStates() {
2238
+ const currentTextTrack = this.api.getState("currentTextTrack");
2239
+ const activeId = currentTextTrack?.id || "off";
2240
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2241
+ items.forEach((item) => {
2242
+ const id = item.getAttribute("data-id");
2243
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
2244
+ });
2245
+ }
2246
+ createSubHeader(title) {
2247
+ const header = createElement("div", { className: "sp-settings-panel__header" });
2248
+ header.setAttribute("role", "menuitem");
2249
+ header.setAttribute("tabindex", "0");
2250
+ const backArrow = createElement("span", { className: "sp-settings-panel__back" });
2251
+ backArrow.innerHTML = icons.chevronUp;
2252
+ const label = createElement("span", { className: "sp-settings-panel__header-label" });
2253
+ label.textContent = title;
2254
+ header.appendChild(backArrow);
2255
+ header.appendChild(label);
2256
+ header.addEventListener("click", (e) => {
2257
+ e.preventDefault();
2258
+ this.showPanel("main");
2259
+ });
2260
+ header.addEventListener("keydown", (e) => {
2261
+ if (e.key === "Enter" || e.key === " ") {
2262
+ e.preventDefault();
2263
+ this.showPanel("main");
2264
+ }
2265
+ });
2266
+ return header;
2267
+ }
2268
+ createMenuItem(label, dataId, isActive) {
2269
+ const item = createElement("div", {
2270
+ className: `sp-settings-panel__item${isActive ? " sp-settings-panel__item--active" : ""}`
2271
+ });
2272
+ item.setAttribute("role", "menuitem");
2273
+ item.setAttribute("tabindex", "0");
2274
+ item.setAttribute("data-id", dataId);
2275
+ const labelEl = createElement("span");
2276
+ labelEl.textContent = label;
2277
+ const check = createElement("span", { className: "sp-settings-panel__check" });
2278
+ check.innerHTML = icons.checkmark;
2279
+ item.appendChild(labelEl);
2280
+ item.appendChild(check);
2281
+ item.addEventListener("keydown", (e) => {
2282
+ if (e.key === "Enter" || e.key === " ") {
2283
+ e.preventDefault();
2284
+ item.click();
2285
+ }
2286
+ });
2287
+ return item;
2288
+ }
2289
+ selectQuality(qualityId) {
2290
+ this.api.emit("quality:select", {
2291
+ quality: qualityId,
2292
+ auto: qualityId === "auto"
2293
+ });
2294
+ this.close();
2295
+ }
2296
+ selectSpeed(rate) {
2297
+ this.api.emit("playback:ratechange", { rate });
2298
+ const video = this.api.container.querySelector("video");
2299
+ if (video) {
2300
+ video.playbackRate = rate;
2301
+ }
2302
+ this.close();
2303
+ }
2304
+ updateQualityActiveStates() {
2305
+ const currentQuality = this.api.getState("currentQuality");
2306
+ const activeId = currentQuality?.id || "auto";
2307
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2308
+ items.forEach((item) => {
2309
+ const id = item.getAttribute("data-id");
2310
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
2311
+ });
2312
+ }
2313
+ updateSpeedActiveStates() {
2314
+ const currentRate = this.api.getState("playbackRate") ?? 1;
2315
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2316
+ items.forEach((item) => {
2317
+ const id = item.getAttribute("data-id");
2318
+ const value = parseFloat(id || "1");
2319
+ item.classList.toggle(
2320
+ "sp-settings-panel__item--active",
2321
+ Math.abs(currentRate - value) < 0.01
2322
+ );
2323
+ });
2324
+ }
2325
+ getPanel() {
2326
+ return this.currentPanel;
2327
+ }
2328
+ isMenuOpen() {
2329
+ return this.isOpen;
2330
+ }
2331
+ destroy() {
2332
+ document.removeEventListener("click", this.closeHandler);
2333
+ document.removeEventListener("keydown", this.keyHandler);
2334
+ this.el.remove();
2335
+ }
2336
+ };
2337
+
2338
+ // src/controls/SkipButton.ts
2339
+ var DEFAULT_SKIP_SECONDS = 10;
2340
+ var SkipButton = class {
2341
+ constructor(api, direction, seconds = DEFAULT_SKIP_SECONDS) {
2342
+ this.clickHandler = () => {
2343
+ this.skip();
2344
+ };
2345
+ this.api = api;
2346
+ this.direction = direction;
2347
+ this.seconds = seconds;
2348
+ const icon = direction === "backward" ? icons.replay10 : icons.forward10;
2349
+ const label = direction === "backward" ? `Rewind ${seconds} seconds` : `Forward ${seconds} seconds`;
2350
+ this.el = createButton(
2351
+ `sp-skip sp-skip--${direction}`,
2352
+ label,
2353
+ icon
2354
+ );
2355
+ this.el.addEventListener("click", this.clickHandler);
2356
+ }
2357
+ render() {
2358
+ return this.el;
2359
+ }
2360
+ update() {
2361
+ const live = this.api.getState("live");
2362
+ const duration = this.api.getState("duration") ?? 0;
2363
+ const seekableRange = this.api.getState("seekableRange");
2364
+ if (live && !seekableRange) {
2365
+ this.el.style.display = "none";
2366
+ return;
2367
+ }
2368
+ if (live && seekableRange) {
2369
+ this.el.style.display = "";
2370
+ return;
2371
+ }
2372
+ if (duration === 0) {
2373
+ this.el.style.display = "none";
2374
+ return;
2375
+ }
2376
+ this.el.style.display = "";
2377
+ }
2378
+ skip() {
2379
+ const video = getVideo(this.api.container);
2380
+ if (!video) return;
2381
+ const live = this.api.getState("live");
2382
+ const seekableRange = this.api.getState("seekableRange");
2383
+ if (live && seekableRange) {
2384
+ if (this.direction === "backward") {
2385
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - this.seconds);
2386
+ } else {
2387
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + this.seconds);
2388
+ }
2389
+ return;
2390
+ }
2391
+ const duration = video.duration || 0;
2392
+ if (!duration || !isFinite(duration)) return;
2393
+ if (this.direction === "backward") {
2394
+ video.currentTime = Math.max(0, video.currentTime - this.seconds);
2395
+ } else {
2396
+ video.currentTime = Math.min(duration, video.currentTime + this.seconds);
2397
+ }
2398
+ }
2399
+ destroy() {
2400
+ this.el.removeEventListener("click", this.clickHandler);
2401
+ this.el.remove();
2402
+ }
2403
+ };
2404
+
2405
+ // src/controls/CaptionsButton.ts
2406
+ var CaptionsButton = class {
2407
+ constructor(api) {
2408
+ this.clickHandler = () => {
2409
+ this.toggle();
2410
+ };
2411
+ this.api = api;
2412
+ this.el = createButton("sp-captions", "Captions", icons.captionsOff);
2413
+ this.el.addEventListener("click", this.clickHandler);
2414
+ }
2415
+ render() {
2416
+ return this.el;
2417
+ }
2418
+ update() {
2419
+ const textTracks = this.api.getState("textTracks") || [];
2420
+ const currentTrack = this.api.getState("currentTextTrack");
2421
+ if (textTracks.length === 0) {
2422
+ this.el.style.display = "none";
2423
+ return;
2424
+ }
2425
+ this.el.style.display = "";
2426
+ if (currentTrack) {
2427
+ this.el.innerHTML = icons.captions;
2428
+ this.el.setAttribute("aria-label", `Captions: ${currentTrack.label}`);
2429
+ this.el.classList.add("sp-captions--active");
2430
+ } else {
2431
+ this.el.innerHTML = icons.captionsOff;
2432
+ this.el.setAttribute("aria-label", "Captions");
2433
+ this.el.classList.remove("sp-captions--active");
2434
+ }
2435
+ }
2436
+ toggle() {
2437
+ const textTracks = this.api.getState("textTracks") || [];
2438
+ const currentTrack = this.api.getState("currentTextTrack");
2439
+ if (textTracks.length === 0) return;
2440
+ if (currentTrack) {
2441
+ this.api.emit("track:text", { trackId: null });
2442
+ } else {
2443
+ this.api.emit("track:text", { trackId: textTracks[0].id });
2444
+ }
2445
+ }
2446
+ destroy() {
2447
+ this.el.removeEventListener("click", this.clickHandler);
2448
+ this.el.remove();
2449
+ }
2450
+ };
2451
+
1324
2452
  // src/index.ts
1325
2453
  var DEFAULT_LAYOUT = [
1326
2454
  "play",
2455
+ "skip-backward",
2456
+ "skip-forward",
1327
2457
  "volume",
1328
2458
  "time",
1329
2459
  "live-indicator",
1330
2460
  "spacer",
1331
- "quality",
2461
+ "settings",
2462
+ "captions",
1332
2463
  "chromecast",
1333
2464
  "airplay",
1334
2465
  "pip",
@@ -1341,10 +2472,12 @@ function uiPlugin(config = {}) {
1341
2472
  let gradient = null;
1342
2473
  let progressBar = null;
1343
2474
  let bufferingIndicator = null;
2475
+ let errorOverlay = null;
1344
2476
  let styleEl = null;
1345
2477
  let controls = [];
1346
2478
  let hideTimeout = null;
1347
2479
  let stateUnsubscribe = null;
2480
+ let errorUnsubscribe = null;
1348
2481
  let controlsVisible = true;
1349
2482
  const layout = config.controls || DEFAULT_LAYOUT;
1350
2483
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
@@ -1352,6 +2485,10 @@ function uiPlugin(config = {}) {
1352
2485
  switch (slot) {
1353
2486
  case "play":
1354
2487
  return new PlayButton(api);
2488
+ case "skip-backward":
2489
+ return new SkipButton(api, "backward");
2490
+ case "skip-forward":
2491
+ return new SkipButton(api, "forward");
1355
2492
  case "volume":
1356
2493
  return new VolumeControl(api);
1357
2494
  case "progress":
@@ -1362,6 +2499,10 @@ function uiPlugin(config = {}) {
1362
2499
  return new LiveIndicator(api);
1363
2500
  case "quality":
1364
2501
  return new QualityMenu(api);
2502
+ case "settings":
2503
+ return new SettingsMenu(api);
2504
+ case "captions":
2505
+ return new CaptionsButton(api);
1365
2506
  case "chromecast":
1366
2507
  return new CastButton(api, "chromecast");
1367
2508
  case "airplay":
@@ -1385,6 +2526,7 @@ function uiPlugin(config = {}) {
1385
2526
  const isLoading = playbackState === "loading";
1386
2527
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
1387
2528
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
2529
+ errorOverlay?.update();
1388
2530
  };
1389
2531
  const showControls = () => {
1390
2532
  if (controlsVisible) {
@@ -1423,8 +2565,14 @@ function uiPlugin(config = {}) {
1423
2565
  };
1424
2566
  const handleKeyDown = (e) => {
1425
2567
  if (!api.container.contains(document.activeElement)) return;
2568
+ const activeEl = document.activeElement;
2569
+ if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
2570
+ return;
2571
+ }
1426
2572
  const video = api.container.querySelector("video");
1427
2573
  if (!video) return;
2574
+ const live = api.getState("live");
2575
+ const seekableRange = api.getState("seekableRange");
1428
2576
  switch (e.key) {
1429
2577
  case " ":
1430
2578
  case "k":
@@ -1445,12 +2593,20 @@ function uiPlugin(config = {}) {
1445
2593
  break;
1446
2594
  case "ArrowLeft":
1447
2595
  e.preventDefault();
1448
- video.currentTime = Math.max(0, video.currentTime - 5);
2596
+ if (live && seekableRange) {
2597
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - 5);
2598
+ } else {
2599
+ video.currentTime = Math.max(0, video.currentTime - 5);
2600
+ }
1449
2601
  showControls();
1450
2602
  break;
1451
2603
  case "ArrowRight":
1452
2604
  e.preventDefault();
1453
- video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
2605
+ if (live && seekableRange) {
2606
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + 5);
2607
+ } else {
2608
+ video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
2609
+ }
1454
2610
  showControls();
1455
2611
  break;
1456
2612
  case "ArrowUp":
@@ -1496,6 +2652,14 @@ function uiPlugin(config = {}) {
1496
2652
  bufferingIndicator.innerHTML = icons.spinner;
1497
2653
  bufferingIndicator.setAttribute("aria-hidden", "true");
1498
2654
  container.appendChild(bufferingIndicator);
2655
+ errorOverlay = new ErrorOverlay(api);
2656
+ container.appendChild(errorOverlay.render());
2657
+ errorUnsubscribe = api.on("error", (payload) => {
2658
+ if (payload?.fatal) {
2659
+ const error = api.getState("error") || new Error(payload.message || "Playback error");
2660
+ errorOverlay?.show(error);
2661
+ }
2662
+ });
1499
2663
  progressBar = new ProgressBar(api);
1500
2664
  container.appendChild(progressBar.render());
1501
2665
  if (!isPlaying) {
@@ -1539,6 +2703,8 @@ function uiPlugin(config = {}) {
1539
2703
  }
1540
2704
  stateUnsubscribe?.();
1541
2705
  stateUnsubscribe = null;
2706
+ errorUnsubscribe?.();
2707
+ errorUnsubscribe = null;
1542
2708
  if (api?.container) {
1543
2709
  api.container.removeEventListener("mousemove", handleInteraction);
1544
2710
  api.container.removeEventListener("mouseenter", handleInteraction);
@@ -1552,6 +2718,8 @@ function uiPlugin(config = {}) {
1552
2718
  controls = [];
1553
2719
  progressBar?.destroy();
1554
2720
  progressBar = null;
2721
+ errorOverlay?.destroy();
2722
+ errorOverlay = null;
1555
2723
  controlBar?.remove();
1556
2724
  controlBar = null;
1557
2725
  gradient?.remove();