@scarlett-player/ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -107,7 +107,12 @@ var styles = `
107
107
  transition: height 0.15s ease;
108
108
  }
109
109
 
110
- .sp-progress-wrapper:hover .sp-progress,
110
+ @media (hover: hover) {
111
+ .sp-progress-wrapper:hover .sp-progress {
112
+ height: 5px;
113
+ }
114
+ }
115
+
111
116
  .sp-progress--dragging {
112
117
  height: 5px;
113
118
  }
@@ -153,11 +158,34 @@ var styles = `
153
158
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
154
159
  }
155
160
 
156
- .sp-progress-wrapper:hover .sp-progress__handle,
161
+ @media (hover: hover) {
162
+ .sp-progress-wrapper:hover .sp-progress__handle {
163
+ transform: translate(-50%, -50%) scale(1);
164
+ }
165
+ }
166
+
157
167
  .sp-progress--dragging .sp-progress__handle {
158
168
  transform: translate(-50%, -50%) scale(1);
159
169
  }
160
170
 
171
+ /* Thumbnail Preview */
172
+ .sp-thumbnail-preview {
173
+ position: absolute;
174
+ bottom: calc(100% + 8px);
175
+ transform: translateX(-50%);
176
+ pointer-events: none;
177
+ display: none;
178
+ z-index: 21;
179
+ border-radius: 4px;
180
+ overflow: hidden;
181
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
182
+ border: 2px solid rgba(255, 255, 255, 0.2);
183
+ }
184
+
185
+ .sp-thumbnail-preview__img {
186
+ background-repeat: no-repeat;
187
+ }
188
+
161
189
  /* Progress Tooltip */
162
190
  .sp-progress__tooltip {
163
191
  position: absolute;
@@ -177,8 +205,10 @@ var styles = `
177
205
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
178
206
  }
179
207
 
180
- .sp-progress-wrapper:hover .sp-progress__tooltip {
181
- opacity: 1;
208
+ @media (hover: hover) {
209
+ .sp-progress-wrapper:hover .sp-progress__tooltip {
210
+ opacity: 1;
211
+ }
182
212
  }
183
213
 
184
214
  /* ============================================
@@ -198,9 +228,11 @@ var styles = `
198
228
  flex-shrink: 0;
199
229
  }
200
230
 
201
- .sp-control:hover {
202
- color: #fff;
203
- background: rgba(255, 255, 255, 0.1);
231
+ @media (hover: hover) {
232
+ .sp-control:hover {
233
+ color: #fff;
234
+ background: rgba(255, 255, 255, 0.1);
235
+ }
204
236
  }
205
237
 
206
238
  .sp-control:active {
@@ -269,7 +301,12 @@ var styles = `
269
301
  transition: width 0.2s ease;
270
302
  }
271
303
 
272
- .sp-volume:hover .sp-volume__slider-wrap,
304
+ @media (hover: hover) {
305
+ .sp-volume:hover .sp-volume__slider-wrap {
306
+ width: 64px;
307
+ }
308
+ }
309
+
273
310
  .sp-volume:focus-within .sp-volume__slider-wrap {
274
311
  width: 64px;
275
312
  }
@@ -312,8 +349,10 @@ var styles = `
312
349
  transition: background 0.15s ease, opacity 0.15s ease;
313
350
  }
314
351
 
315
- .sp-live:hover {
316
- background: rgba(255, 255, 255, 0.1);
352
+ @media (hover: hover) {
353
+ .sp-live:hover {
354
+ background: rgba(255, 255, 255, 0.1);
355
+ }
317
356
  }
318
357
 
319
358
  .sp-live__dot {
@@ -332,6 +371,16 @@ var styles = `
332
371
  animation: none;
333
372
  }
334
373
 
374
+ .sp-live--behind span {
375
+ text-decoration: underline;
376
+ text-underline-offset: 2px;
377
+ }
378
+
379
+ /* Progress bar live mode: accent color for filled bar */
380
+ .sp-progress--live .sp-progress__filled {
381
+ background: var(--sp-accent, #e50914);
382
+ }
383
+
335
384
  @keyframes sp-pulse {
336
385
  0%, 100% { opacity: 1; }
337
386
  50% { opacity: 0.4; }
@@ -412,6 +461,169 @@ var styles = `
412
461
  opacity: 1;
413
462
  }
414
463
 
464
+ /* ============================================
465
+ Settings Menu (Gear Icon)
466
+ ============================================ */
467
+ .sp-settings {
468
+ position: relative;
469
+ }
470
+
471
+ .sp-settings__btn {
472
+ display: flex;
473
+ align-items: center;
474
+ }
475
+
476
+ .sp-settings-panel {
477
+ position: absolute;
478
+ bottom: calc(100% + 8px);
479
+ right: 0;
480
+ background: rgba(20, 20, 20, 0.95);
481
+ backdrop-filter: blur(8px);
482
+ -webkit-backdrop-filter: blur(8px);
483
+ border-radius: 8px;
484
+ min-width: 200px;
485
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
486
+ opacity: 0;
487
+ visibility: hidden;
488
+ transform: translateY(8px);
489
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
490
+ z-index: 20;
491
+ overflow: hidden;
492
+ }
493
+
494
+ .sp-settings-panel--open {
495
+ opacity: 1;
496
+ visibility: visible;
497
+ transform: translateY(0);
498
+ }
499
+
500
+ /* Main menu rows */
501
+ .sp-settings-panel--main {
502
+ padding: 4px 0;
503
+ }
504
+
505
+ .sp-settings-panel__row {
506
+ display: flex;
507
+ align-items: center;
508
+ justify-content: space-between;
509
+ padding: 10px 16px;
510
+ font-size: 13px;
511
+ color: rgba(255, 255, 255, 0.9);
512
+ cursor: pointer;
513
+ transition: background 0.1s ease;
514
+ }
515
+
516
+ .sp-settings-panel__row:hover {
517
+ background: rgba(255, 255, 255, 0.1);
518
+ }
519
+
520
+ .sp-settings-panel__label {
521
+ font-weight: 500;
522
+ }
523
+
524
+ .sp-settings-panel__value {
525
+ display: flex;
526
+ align-items: center;
527
+ gap: 4px;
528
+ color: rgba(255, 255, 255, 0.6);
529
+ font-size: 12px;
530
+ }
531
+
532
+ .sp-settings-panel__arrow {
533
+ display: flex;
534
+ align-items: center;
535
+ transform: rotate(-90deg);
536
+ }
537
+
538
+ .sp-settings-panel__arrow svg {
539
+ width: 16px;
540
+ height: 16px;
541
+ fill: currentColor;
542
+ }
543
+
544
+ /* Sub-menu panels */
545
+ .sp-settings-panel--sub {
546
+ padding: 0;
547
+ }
548
+
549
+ .sp-settings-panel__header {
550
+ display: flex;
551
+ align-items: center;
552
+ gap: 8px;
553
+ padding: 10px 16px;
554
+ font-size: 13px;
555
+ font-weight: 600;
556
+ color: rgba(255, 255, 255, 0.9);
557
+ cursor: pointer;
558
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
559
+ transition: background 0.1s ease;
560
+ }
561
+
562
+ .sp-settings-panel__header:hover {
563
+ background: rgba(255, 255, 255, 0.1);
564
+ }
565
+
566
+ .sp-settings-panel__back {
567
+ display: flex;
568
+ align-items: center;
569
+ transform: rotate(-90deg);
570
+ }
571
+
572
+ .sp-settings-panel__back svg {
573
+ width: 16px;
574
+ height: 16px;
575
+ fill: currentColor;
576
+ }
577
+
578
+ .sp-settings-panel__header-label {
579
+ flex: 1;
580
+ }
581
+
582
+ .sp-settings-panel__item {
583
+ display: flex;
584
+ align-items: center;
585
+ justify-content: space-between;
586
+ padding: 10px 16px;
587
+ font-size: 13px;
588
+ color: rgba(255, 255, 255, 0.8);
589
+ cursor: pointer;
590
+ transition: background 0.1s ease, color 0.1s ease;
591
+ }
592
+
593
+ .sp-settings-panel__item:hover {
594
+ background: rgba(255, 255, 255, 0.1);
595
+ color: #fff;
596
+ }
597
+
598
+ .sp-settings-panel__item--active {
599
+ color: var(--sp-accent, #e50914);
600
+ }
601
+
602
+ .sp-settings-panel__check {
603
+ width: 16px;
604
+ height: 16px;
605
+ fill: currentColor;
606
+ margin-left: 8px;
607
+ opacity: 0;
608
+ }
609
+
610
+ .sp-settings-panel__check svg {
611
+ width: 16px;
612
+ height: 16px;
613
+ fill: currentColor;
614
+ }
615
+
616
+ .sp-settings-panel__item--active .sp-settings-panel__check {
617
+ opacity: 1;
618
+ }
619
+
620
+ /* ============================================
621
+ Captions Button
622
+ ============================================ */
623
+ .sp-captions--active {
624
+ color: var(--sp-accent, #e50914);
625
+ }
626
+
415
627
  /* ============================================
416
628
  Cast Button States
417
629
  ============================================ */
@@ -423,6 +635,122 @@ var styles = `
423
635
  opacity: 0.4;
424
636
  }
425
637
 
638
+ /* ============================================
639
+ Error Overlay
640
+ ============================================ */
641
+ .sp-error-overlay {
642
+ position: absolute;
643
+ top: 0;
644
+ left: 0;
645
+ right: 0;
646
+ bottom: 0;
647
+ background: rgba(0, 0, 0, 0.85);
648
+ display: flex;
649
+ align-items: center;
650
+ justify-content: center;
651
+ z-index: 25;
652
+ opacity: 0;
653
+ visibility: hidden;
654
+ transition: opacity 0.25s ease, visibility 0.25s;
655
+ }
656
+
657
+ .sp-error-overlay--visible {
658
+ opacity: 1;
659
+ visibility: visible;
660
+ }
661
+
662
+ .sp-error-overlay__content {
663
+ display: flex;
664
+ flex-direction: column;
665
+ align-items: center;
666
+ text-align: center;
667
+ padding: 24px;
668
+ max-width: 360px;
669
+ }
670
+
671
+ .sp-error-overlay__icon {
672
+ color: rgba(255, 255, 255, 0.7);
673
+ margin-bottom: 16px;
674
+ }
675
+
676
+ .sp-error-overlay__icon svg {
677
+ width: 48px;
678
+ height: 48px;
679
+ fill: currentColor;
680
+ }
681
+
682
+ .sp-error-overlay__message {
683
+ color: rgba(255, 255, 255, 0.9);
684
+ font-size: 15px;
685
+ line-height: 1.5;
686
+ margin: 0 0 24px;
687
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
688
+ }
689
+
690
+ .sp-error-overlay__actions {
691
+ display: flex;
692
+ gap: 12px;
693
+ flex-wrap: wrap;
694
+ justify-content: center;
695
+ }
696
+
697
+ .sp-error-overlay__retry {
698
+ background: var(--sp-accent, #e50914);
699
+ color: #fff;
700
+ border: none;
701
+ padding: 12px 24px;
702
+ font-size: 14px;
703
+ font-weight: 600;
704
+ border-radius: 6px;
705
+ cursor: pointer;
706
+ min-width: 120px;
707
+ min-height: 44px;
708
+ transition: background 0.15s ease, transform 0.15s ease;
709
+ font-family: inherit;
710
+ }
711
+
712
+ .sp-error-overlay__retry:hover {
713
+ filter: brightness(1.1);
714
+ }
715
+
716
+ .sp-error-overlay__retry:active {
717
+ transform: scale(0.96);
718
+ }
719
+
720
+ .sp-error-overlay__retry:focus-visible {
721
+ outline: 2px solid #fff;
722
+ outline-offset: 2px;
723
+ }
724
+
725
+ .sp-error-overlay__dismiss {
726
+ background: none;
727
+ color: rgba(255, 255, 255, 0.7);
728
+ border: 1px solid rgba(255, 255, 255, 0.3);
729
+ padding: 12px 24px;
730
+ font-size: 14px;
731
+ font-weight: 500;
732
+ border-radius: 6px;
733
+ cursor: pointer;
734
+ min-width: 100px;
735
+ min-height: 44px;
736
+ transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
737
+ font-family: inherit;
738
+ }
739
+
740
+ .sp-error-overlay__dismiss:hover {
741
+ color: #fff;
742
+ border-color: rgba(255, 255, 255, 0.5);
743
+ }
744
+
745
+ .sp-error-overlay__dismiss:active {
746
+ transform: scale(0.96);
747
+ }
748
+
749
+ .sp-error-overlay__dismiss:focus-visible {
750
+ outline: 2px solid #fff;
751
+ outline-offset: 2px;
752
+ }
753
+
426
754
  /* ============================================
427
755
  Buffering Indicator
428
756
  ============================================ */
@@ -470,7 +798,14 @@ var styles = `
470
798
  .sp-control,
471
799
  .sp-volume__slider-wrap,
472
800
  .sp-quality-menu,
473
- .sp-buffering {
801
+ .sp-settings-panel,
802
+ .sp-settings-panel__row,
803
+ .sp-settings-panel__item,
804
+ .sp-settings-panel__header,
805
+ .sp-buffering,
806
+ .sp-error-overlay,
807
+ .sp-error-overlay__retry,
808
+ .sp-error-overlay__dismiss {
474
809
  transition: none;
475
810
  }
476
811
 
@@ -516,8 +851,9 @@ var icons = {
516
851
  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>`,
517
852
  skipForward: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>`,
518
853
  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>`,
519
- 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>`,
520
- 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>`
854
+ 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>`,
855
+ 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>`,
856
+ 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>`
521
857
  };
522
858
 
523
859
  // src/utils/dom.ts
@@ -616,12 +952,11 @@ var PlayButton = class {
616
952
  const video = getVideo(this.api.container);
617
953
  if (!video) return;
618
954
  const ended = this.api.getState("ended");
619
- const playing = this.api.getState("playing");
620
955
  if (ended) {
621
956
  video.currentTime = 0;
622
957
  video.play().catch(() => {
623
958
  });
624
- } else if (playing) {
959
+ } else if (!video.paused) {
625
960
  video.pause();
626
961
  } else {
627
962
  video.play().catch(() => {
@@ -634,6 +969,70 @@ var PlayButton = class {
634
969
  }
635
970
  };
636
971
 
972
+ // src/controls/ThumbnailPreview.ts
973
+ var ThumbnailPreview = class {
974
+ constructor() {
975
+ this.config = null;
976
+ this.loaded = false;
977
+ this.el = createElement("div", { className: "sp-thumbnail-preview" });
978
+ this.img = createElement("div", { className: "sp-thumbnail-preview__img" });
979
+ this.el.appendChild(this.img);
980
+ }
981
+ getElement() {
982
+ return this.el;
983
+ }
984
+ setConfig(config) {
985
+ this.config = config;
986
+ this.loaded = false;
987
+ if (config) {
988
+ this.img.style.width = `${config.width}px`;
989
+ this.img.style.height = `${config.height}px`;
990
+ this.el.style.width = `${config.width}px`;
991
+ this.el.style.height = `${config.height}px`;
992
+ const preload = new Image();
993
+ preload.onload = () => {
994
+ this.loaded = true;
995
+ };
996
+ preload.onerror = () => {
997
+ this.config = null;
998
+ this.loaded = false;
999
+ };
1000
+ preload.src = config.src;
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Update the thumbnail to show the frame at the given time.
1005
+ * @param time Time in seconds
1006
+ * @param percent Position as 0-1 fraction (for horizontal positioning)
1007
+ */
1008
+ show(time, percent) {
1009
+ if (!this.config || !this.loaded) {
1010
+ this.el.style.display = "none";
1011
+ return;
1012
+ }
1013
+ const { src, width, height, columns, interval } = this.config;
1014
+ const index = Math.floor(time / interval);
1015
+ const col = index % columns;
1016
+ const row = Math.floor(index / columns);
1017
+ this.img.style.backgroundImage = `url(${src})`;
1018
+ this.img.style.backgroundPosition = `-${col * width}px -${row * height}px`;
1019
+ this.img.style.backgroundSize = `${columns * width}px auto`;
1020
+ this.img.style.width = `${width}px`;
1021
+ this.img.style.height = `${height}px`;
1022
+ this.el.style.left = `${percent * 100}%`;
1023
+ this.el.style.display = "";
1024
+ }
1025
+ hide() {
1026
+ this.el.style.display = "none";
1027
+ }
1028
+ isConfigured() {
1029
+ return this.config !== null;
1030
+ }
1031
+ destroy() {
1032
+ this.el.remove();
1033
+ }
1034
+ };
1035
+
637
1036
  // src/controls/ProgressBar.ts
638
1037
  var ProgressBar = class {
639
1038
  constructor(api) {
@@ -675,36 +1074,99 @@ var ProgressBar = class {
675
1074
  }
676
1075
  }
677
1076
  };
1077
+ this.onTouchStart = (e) => {
1078
+ e.preventDefault();
1079
+ const video = getVideo(this.api.container);
1080
+ this.wasPlayingBeforeDrag = video ? !video.paused : false;
1081
+ this.isDragging = true;
1082
+ this.el.classList.add("sp-progress--dragging");
1083
+ this.lastSeekTime = 0;
1084
+ this.seek(e.touches[0].clientX, true);
1085
+ };
1086
+ this.onDocTouchMove = (e) => {
1087
+ if (this.isDragging) {
1088
+ e.preventDefault();
1089
+ this.seek(e.touches[0].clientX);
1090
+ this.updateVisualPosition(e.touches[0].clientX);
1091
+ }
1092
+ };
1093
+ this.onTouchEnd = (e) => {
1094
+ if (this.isDragging) {
1095
+ const clientX = e.changedTouches?.[0]?.clientX;
1096
+ if (clientX !== void 0) {
1097
+ this.seek(clientX, true);
1098
+ }
1099
+ this.isDragging = false;
1100
+ this.el.classList.remove("sp-progress--dragging");
1101
+ if (this.wasPlayingBeforeDrag) {
1102
+ const video = getVideo(this.api.container);
1103
+ if (video && video.paused) {
1104
+ const resumePlayback = () => {
1105
+ video.removeEventListener("seeked", resumePlayback);
1106
+ video.play().catch(() => {
1107
+ });
1108
+ };
1109
+ video.addEventListener("seeked", resumePlayback);
1110
+ }
1111
+ }
1112
+ this.tooltip.style.opacity = "0";
1113
+ this.thumbnailPreview.hide();
1114
+ }
1115
+ };
678
1116
  this.onMouseMove = (e) => {
679
1117
  this.updateTooltip(e.clientX);
680
1118
  };
681
1119
  this.onMouseLeave = () => {
682
1120
  if (!this.isDragging) {
683
1121
  this.tooltip.style.opacity = "0";
1122
+ this.thumbnailPreview.hide();
684
1123
  }
685
1124
  };
686
1125
  this.onKeyDown = (e) => {
687
1126
  const video = getVideo(this.api.container);
688
1127
  if (!video) return;
689
1128
  const step = 5;
690
- const duration = this.api.getState("duration") || 0;
691
- switch (e.key) {
692
- case "ArrowLeft":
693
- e.preventDefault();
694
- video.currentTime = Math.max(0, video.currentTime - step);
695
- break;
696
- case "ArrowRight":
697
- e.preventDefault();
698
- video.currentTime = Math.min(duration, video.currentTime + step);
699
- break;
700
- case "Home":
701
- e.preventDefault();
702
- video.currentTime = 0;
703
- break;
704
- case "End":
705
- e.preventDefault();
706
- video.currentTime = duration;
707
- break;
1129
+ const live = this.api.getState("live");
1130
+ const seekableRange = this.api.getState("seekableRange");
1131
+ if (live && seekableRange) {
1132
+ switch (e.key) {
1133
+ case "ArrowLeft":
1134
+ e.preventDefault();
1135
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - step);
1136
+ break;
1137
+ case "ArrowRight":
1138
+ e.preventDefault();
1139
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + step);
1140
+ break;
1141
+ case "Home":
1142
+ e.preventDefault();
1143
+ video.currentTime = seekableRange.start;
1144
+ break;
1145
+ case "End":
1146
+ e.preventDefault();
1147
+ video.currentTime = seekableRange.end;
1148
+ break;
1149
+ }
1150
+ } else {
1151
+ const duration = this.api.getState("duration") || 0;
1152
+ switch (e.key) {
1153
+ case "ArrowLeft":
1154
+ e.preventDefault();
1155
+ video.currentTime = Math.max(0, video.currentTime - step);
1156
+ break;
1157
+ case "ArrowRight":
1158
+ e.preventDefault();
1159
+ video.currentTime = Math.min(duration, video.currentTime + step);
1160
+ break;
1161
+ case "Home":
1162
+ e.preventDefault();
1163
+ video.currentTime = 0;
1164
+ break;
1165
+ case "End":
1166
+ e.preventDefault();
1167
+ video.currentTime = duration;
1168
+ break;
1169
+ }
708
1170
  }
709
1171
  };
710
1172
  this.api = api;
@@ -716,10 +1178,12 @@ var ProgressBar = class {
716
1178
  this.handle = createElement("div", { className: "sp-progress__handle" });
717
1179
  this.tooltip = createElement("div", { className: "sp-progress__tooltip" });
718
1180
  this.tooltip.textContent = "0:00";
1181
+ this.thumbnailPreview = new ThumbnailPreview();
719
1182
  track.appendChild(this.buffered);
720
1183
  track.appendChild(this.filled);
721
1184
  track.appendChild(this.handle);
722
1185
  this.el.appendChild(track);
1186
+ this.el.appendChild(this.thumbnailPreview.getElement());
723
1187
  this.el.appendChild(this.tooltip);
724
1188
  this.wrapper.appendChild(this.el);
725
1189
  this.el.setAttribute("role", "slider");
@@ -729,9 +1193,13 @@ var ProgressBar = class {
729
1193
  this.wrapper.addEventListener("mousedown", this.onMouseDown);
730
1194
  this.wrapper.addEventListener("mousemove", this.onMouseMove);
731
1195
  this.wrapper.addEventListener("mouseleave", this.onMouseLeave);
1196
+ this.wrapper.addEventListener("touchstart", this.onTouchStart, { passive: false });
732
1197
  this.el.addEventListener("keydown", this.onKeyDown);
733
1198
  document.addEventListener("mousemove", this.onDocMouseMove);
734
1199
  document.addEventListener("mouseup", this.onMouseUp);
1200
+ document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
1201
+ document.addEventListener("touchend", this.onTouchEnd);
1202
+ document.addEventListener("touchcancel", this.onTouchEnd);
735
1203
  }
736
1204
  render() {
737
1205
  return this.wrapper;
@@ -744,11 +1212,40 @@ var ProgressBar = class {
744
1212
  hide() {
745
1213
  this.wrapper.classList.remove("sp-progress-wrapper--visible");
746
1214
  }
1215
+ /** Set thumbnail sprite configuration */
1216
+ setThumbnails(config) {
1217
+ this.thumbnailPreview.setConfig(config);
1218
+ }
747
1219
  update() {
748
1220
  const currentTime = this.api.getState("currentTime") || 0;
749
1221
  const duration = this.api.getState("duration") || 0;
750
1222
  const bufferedRanges = this.api.getState("buffered");
751
- if (duration > 0) {
1223
+ const live = this.api.getState("live");
1224
+ const seekableRange = this.api.getState("seekableRange");
1225
+ const thumbnails = this.api.getState("thumbnails");
1226
+ if (thumbnails && !this.thumbnailPreview.isConfigured()) {
1227
+ this.thumbnailPreview.setConfig(thumbnails);
1228
+ }
1229
+ this.el.classList.toggle("sp-progress--live", !!live);
1230
+ if (live && seekableRange) {
1231
+ const rangeLength = seekableRange.end - seekableRange.start;
1232
+ if (rangeLength > 0) {
1233
+ const progress = (currentTime - seekableRange.start) / rangeLength * 100;
1234
+ this.filled.style.width = `${Math.max(0, Math.min(100, progress))}%`;
1235
+ this.handle.style.left = `${Math.max(0, Math.min(100, progress))}%`;
1236
+ }
1237
+ if (bufferedRanges && bufferedRanges.length > 0) {
1238
+ const rangeLength2 = seekableRange.end - seekableRange.start;
1239
+ if (rangeLength2 > 0) {
1240
+ const bufferedEnd = bufferedRanges.end(bufferedRanges.length - 1);
1241
+ const bufferedPercent = (bufferedEnd - seekableRange.start) / rangeLength2 * 100;
1242
+ this.buffered.style.width = `${Math.max(0, Math.min(100, bufferedPercent))}%`;
1243
+ }
1244
+ }
1245
+ this.el.setAttribute("aria-valuemax", String(Math.floor(seekableRange.end)));
1246
+ this.el.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
1247
+ this.el.setAttribute("aria-valuetext", `${Math.floor(seekableRange.end - currentTime)} seconds behind live`);
1248
+ } else if (duration > 0) {
752
1249
  const progress = currentTime / duration * 100;
753
1250
  this.filled.style.width = `${progress}%`;
754
1251
  this.handle.style.left = `${progress}%`;
@@ -765,6 +1262,12 @@ var ProgressBar = class {
765
1262
  getTimeFromPosition(clientX) {
766
1263
  const rect = this.el.getBoundingClientRect();
767
1264
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1265
+ const live = this.api.getState("live");
1266
+ const seekableRange = this.api.getState("seekableRange");
1267
+ if (live && seekableRange) {
1268
+ const rangeLength = seekableRange.end - seekableRange.start;
1269
+ return seekableRange.start + percent * rangeLength;
1270
+ }
768
1271
  const duration = this.api.getState("duration") || 0;
769
1272
  return percent * duration;
770
1273
  }
@@ -772,8 +1275,18 @@ var ProgressBar = class {
772
1275
  const rect = this.el.getBoundingClientRect();
773
1276
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
774
1277
  const time = this.getTimeFromPosition(clientX);
775
- this.tooltip.textContent = formatTime(time);
1278
+ const live = this.api.getState("live");
1279
+ const seekableRange = this.api.getState("seekableRange");
1280
+ if (live && seekableRange) {
1281
+ const behindLive = seekableRange.end - time;
1282
+ this.tooltip.textContent = formatLiveTime(behindLive);
1283
+ } else {
1284
+ this.tooltip.textContent = formatTime(time);
1285
+ }
776
1286
  this.tooltip.style.left = `${percent * 100}%`;
1287
+ if (this.thumbnailPreview.isConfigured()) {
1288
+ this.thumbnailPreview.show(time, percent);
1289
+ }
777
1290
  }
778
1291
  updateVisualPosition(clientX) {
779
1292
  const rect = this.el.getBoundingClientRect();
@@ -796,8 +1309,13 @@ var ProgressBar = class {
796
1309
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
797
1310
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
798
1311
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
1312
+ this.wrapper.removeEventListener("touchstart", this.onTouchStart);
799
1313
  document.removeEventListener("mousemove", this.onDocMouseMove);
800
1314
  document.removeEventListener("mouseup", this.onMouseUp);
1315
+ document.removeEventListener("touchmove", this.onDocTouchMove);
1316
+ document.removeEventListener("touchend", this.onTouchEnd);
1317
+ document.removeEventListener("touchcancel", this.onTouchEnd);
1318
+ this.thumbnailPreview.destroy();
801
1319
  this.wrapper.remove();
802
1320
  }
803
1321
  };
@@ -850,6 +1368,20 @@ var VolumeControl = class {
850
1368
  this.onMouseUp = () => {
851
1369
  this.isDragging = false;
852
1370
  };
1371
+ this.onTouchStart = (e) => {
1372
+ e.preventDefault();
1373
+ this.isDragging = true;
1374
+ this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));
1375
+ };
1376
+ this.onDocTouchMove = (e) => {
1377
+ if (this.isDragging) {
1378
+ e.preventDefault();
1379
+ this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));
1380
+ }
1381
+ };
1382
+ this.onTouchEnd = () => {
1383
+ this.isDragging = false;
1384
+ };
853
1385
  this.onKeyDown = (e) => {
854
1386
  const video = getVideo(this.api.container);
855
1387
  if (!video) return;
@@ -889,9 +1421,13 @@ var VolumeControl = class {
889
1421
  this.el.appendChild(this.btn);
890
1422
  this.el.appendChild(sliderWrap);
891
1423
  this.slider.addEventListener("mousedown", this.onMouseDown);
1424
+ this.slider.addEventListener("touchstart", this.onTouchStart, { passive: false });
892
1425
  this.slider.addEventListener("keydown", this.onKeyDown);
893
1426
  document.addEventListener("mousemove", this.onDocMouseMove);
894
1427
  document.addEventListener("mouseup", this.onMouseUp);
1428
+ document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
1429
+ document.addEventListener("touchend", this.onTouchEnd);
1430
+ document.addEventListener("touchcancel", this.onTouchEnd);
895
1431
  }
896
1432
  render() {
897
1433
  return this.el;
@@ -936,8 +1472,14 @@ var VolumeControl = class {
936
1472
  return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
937
1473
  }
938
1474
  destroy() {
1475
+ this.slider.removeEventListener("mousedown", this.onMouseDown);
1476
+ this.slider.removeEventListener("touchstart", this.onTouchStart);
1477
+ this.slider.removeEventListener("keydown", this.onKeyDown);
939
1478
  document.removeEventListener("mousemove", this.onDocMouseMove);
940
1479
  document.removeEventListener("mouseup", this.onMouseUp);
1480
+ document.removeEventListener("touchmove", this.onDocTouchMove);
1481
+ document.removeEventListener("touchend", this.onTouchEnd);
1482
+ document.removeEventListener("touchcancel", this.onTouchEnd);
941
1483
  this.el.remove();
942
1484
  }
943
1485
  };
@@ -945,19 +1487,27 @@ var VolumeControl = class {
945
1487
  // src/controls/LiveIndicator.ts
946
1488
  var LiveIndicator = class {
947
1489
  constructor(api) {
948
- this.api = api;
949
- this.el = createElement("div", { className: "sp-live" });
950
- this.el.innerHTML = '<div class="sp-live__dot"></div><span>LIVE</span>';
951
- this.el.setAttribute("role", "button");
952
- this.el.setAttribute("aria-label", "Seek to live");
953
- this.el.setAttribute("tabindex", "0");
954
- this.el.onclick = () => this.seekToLive();
955
- this.el.onkeydown = (e) => {
1490
+ this.handleClick = () => {
1491
+ this.seekToLive();
1492
+ };
1493
+ this.handleKeyDown = (e) => {
956
1494
  if (e.key === "Enter" || e.key === " ") {
957
1495
  e.preventDefault();
958
1496
  this.seekToLive();
959
1497
  }
960
1498
  };
1499
+ this.api = api;
1500
+ this.el = createElement("div", { className: "sp-live" });
1501
+ this.dot = createElement("div", { className: "sp-live__dot" });
1502
+ this.label = document.createElement("span");
1503
+ this.label.textContent = "LIVE";
1504
+ this.el.appendChild(this.dot);
1505
+ this.el.appendChild(this.label);
1506
+ this.el.setAttribute("role", "button");
1507
+ this.el.setAttribute("aria-label", "Seek to live");
1508
+ this.el.setAttribute("tabindex", "0");
1509
+ this.el.addEventListener("click", this.handleClick);
1510
+ this.el.addEventListener("keydown", this.handleKeyDown);
961
1511
  }
962
1512
  render() {
963
1513
  return this.el;
@@ -968,8 +1518,12 @@ var LiveIndicator = class {
968
1518
  this.el.style.display = live ? "" : "none";
969
1519
  if (liveEdge) {
970
1520
  this.el.classList.remove("sp-live--behind");
1521
+ this.label.textContent = "LIVE";
1522
+ this.el.setAttribute("aria-label", "At live edge");
971
1523
  } else {
972
1524
  this.el.classList.add("sp-live--behind");
1525
+ this.label.textContent = "GO LIVE";
1526
+ this.el.setAttribute("aria-label", "Seek to live");
973
1527
  }
974
1528
  }
975
1529
  seekToLive() {
@@ -981,6 +1535,8 @@ var LiveIndicator = class {
981
1535
  }
982
1536
  }
983
1537
  destroy() {
1538
+ this.el.removeEventListener("click", this.handleClick);
1539
+ this.el.removeEventListener("keydown", this.handleKeyDown);
984
1540
  this.el.remove();
985
1541
  }
986
1542
  };
@@ -1291,14 +1847,588 @@ var Spacer = class {
1291
1847
  }
1292
1848
  };
1293
1849
 
1850
+ // src/controls/ErrorOverlay.ts
1851
+ function getUserMessage(error) {
1852
+ if (!error) return "Something went wrong.";
1853
+ const msg = error.message?.toLowerCase() || "";
1854
+ if (msg.includes("network") || msg.includes("timeout") || msg.includes("fetch") || msg.includes("connection")) {
1855
+ return "Having trouble connecting. Check your internet and try again.";
1856
+ }
1857
+ if (msg.includes("manifest")) {
1858
+ return "Unable to load video. Please try again.";
1859
+ }
1860
+ if (msg.includes("decode") || msg.includes("media") || msg.includes("format") || msg.includes("codec")) {
1861
+ return "This video can't be played right now.";
1862
+ }
1863
+ if (msg.includes("not found") || msg.includes("404") || msg.includes("source") || msg.includes("not supported")) {
1864
+ return "Video not found.";
1865
+ }
1866
+ return "Something went wrong.";
1867
+ }
1868
+ var ErrorOverlay = class {
1869
+ constructor(api) {
1870
+ this.visible = false;
1871
+ this.lastSource = null;
1872
+ this.handleRetry = () => {
1873
+ if (this.retryBtn.disabled) return;
1874
+ this.retryBtn.disabled = true;
1875
+ this.hide();
1876
+ const source = this.api.getState("source");
1877
+ const src = source?.src || this.lastSource;
1878
+ if (src) {
1879
+ this.api.emit("error:retry", { src });
1880
+ const video = this.api.container.querySelector("video");
1881
+ if (video) {
1882
+ video.src = src;
1883
+ video.load();
1884
+ video.play().catch(() => {
1885
+ });
1886
+ }
1887
+ }
1888
+ setTimeout(() => {
1889
+ this.retryBtn.disabled = false;
1890
+ }, 1e3);
1891
+ };
1892
+ this.handleDismiss = () => {
1893
+ this.hide();
1894
+ this.api.emit("error:dismiss", void 0);
1895
+ };
1896
+ this.api = api;
1897
+ const overlay = document.createElement("div");
1898
+ overlay.className = "sp-error-overlay";
1899
+ overlay.setAttribute("role", "alert");
1900
+ overlay.setAttribute("aria-live", "assertive");
1901
+ const content = document.createElement("div");
1902
+ content.className = "sp-error-overlay__content";
1903
+ const iconEl = document.createElement("div");
1904
+ iconEl.className = "sp-error-overlay__icon";
1905
+ iconEl.innerHTML = icons.error;
1906
+ const messageEl = document.createElement("p");
1907
+ messageEl.className = "sp-error-overlay__message";
1908
+ messageEl.textContent = "Something went wrong.";
1909
+ const actions = document.createElement("div");
1910
+ actions.className = "sp-error-overlay__actions";
1911
+ this.retryBtn = document.createElement("button");
1912
+ this.retryBtn.className = "sp-error-overlay__retry";
1913
+ this.retryBtn.setAttribute("type", "button");
1914
+ this.retryBtn.setAttribute("aria-label", "Try again");
1915
+ this.retryBtn.textContent = "Try Again";
1916
+ this.retryBtn.addEventListener("click", this.handleRetry);
1917
+ this.dismissBtn = document.createElement("button");
1918
+ this.dismissBtn.className = "sp-error-overlay__dismiss";
1919
+ this.dismissBtn.setAttribute("type", "button");
1920
+ this.dismissBtn.setAttribute("aria-label", "Go back");
1921
+ this.dismissBtn.textContent = "Go Back";
1922
+ this.dismissBtn.addEventListener("click", this.handleDismiss);
1923
+ actions.appendChild(this.retryBtn);
1924
+ actions.appendChild(this.dismissBtn);
1925
+ content.appendChild(iconEl);
1926
+ content.appendChild(messageEl);
1927
+ content.appendChild(actions);
1928
+ overlay.appendChild(content);
1929
+ this.el = overlay;
1930
+ }
1931
+ render() {
1932
+ return this.el;
1933
+ }
1934
+ /** Show the error overlay with the given error */
1935
+ show(error) {
1936
+ const message = getUserMessage(error);
1937
+ const messageEl = this.el.querySelector(".sp-error-overlay__message");
1938
+ if (messageEl) {
1939
+ messageEl.textContent = message;
1940
+ }
1941
+ const source = this.api.getState("source");
1942
+ if (source?.src) {
1943
+ this.lastSource = source.src;
1944
+ }
1945
+ this.visible = true;
1946
+ this.retryBtn.disabled = false;
1947
+ this.el.classList.add("sp-error-overlay--visible");
1948
+ }
1949
+ /** Hide the error overlay */
1950
+ hide() {
1951
+ this.visible = false;
1952
+ this.el.classList.remove("sp-error-overlay--visible");
1953
+ }
1954
+ isVisible() {
1955
+ return this.visible;
1956
+ }
1957
+ update() {
1958
+ const playbackState = this.api.getState("playbackState");
1959
+ if (this.visible && playbackState !== "error" && playbackState !== "loading") {
1960
+ const playing = this.api.getState("playing");
1961
+ if (playing) {
1962
+ this.hide();
1963
+ }
1964
+ }
1965
+ }
1966
+ destroy() {
1967
+ this.retryBtn.removeEventListener("click", this.handleRetry);
1968
+ this.dismissBtn.removeEventListener("click", this.handleDismiss);
1969
+ this.el.remove();
1970
+ }
1971
+ };
1972
+
1973
+ // src/controls/SettingsMenu.ts
1974
+ var SPEED_OPTIONS = [
1975
+ { label: "0.5x", value: 0.5 },
1976
+ { label: "0.75x", value: 0.75 },
1977
+ { label: "Normal", value: 1 },
1978
+ { label: "1.25x", value: 1.25 },
1979
+ { label: "1.5x", value: 1.5 },
1980
+ { label: "2x", value: 2 }
1981
+ ];
1982
+ var SettingsMenu = class {
1983
+ constructor(api) {
1984
+ this.isOpen = false;
1985
+ this.currentPanel = "main";
1986
+ this.lastQualitiesJson = "";
1987
+ this.api = api;
1988
+ this.el = createElement("div", { className: "sp-settings" });
1989
+ this.btn = createButton("sp-settings__btn", "Settings", icons.settings);
1990
+ this.btn.setAttribute("aria-haspopup", "true");
1991
+ this.btn.setAttribute("aria-expanded", "false");
1992
+ this.btn.addEventListener("click", (e) => {
1993
+ e.stopPropagation();
1994
+ this.toggle();
1995
+ });
1996
+ this.panel = createElement("div", { className: "sp-settings-panel" });
1997
+ this.panel.setAttribute("role", "menu");
1998
+ this.panel.addEventListener("click", (e) => e.stopPropagation());
1999
+ this.el.appendChild(this.btn);
2000
+ this.el.appendChild(this.panel);
2001
+ this.closeHandler = (e) => {
2002
+ if (!this.el.contains(e.target)) {
2003
+ this.close();
2004
+ }
2005
+ };
2006
+ document.addEventListener("click", this.closeHandler);
2007
+ this.keyHandler = (e) => {
2008
+ if (!this.isOpen) return;
2009
+ if (e.key === "Escape") {
2010
+ e.preventDefault();
2011
+ e.stopPropagation();
2012
+ if (this.currentPanel !== "main") {
2013
+ this.showPanel("main");
2014
+ } else {
2015
+ this.close();
2016
+ this.btn.focus();
2017
+ }
2018
+ }
2019
+ };
2020
+ document.addEventListener("keydown", this.keyHandler);
2021
+ }
2022
+ render() {
2023
+ return this.el;
2024
+ }
2025
+ update() {
2026
+ const qualities = this.api.getState("qualities") || [];
2027
+ const qualitiesJson = JSON.stringify(qualities.map((q) => q.id));
2028
+ if (qualitiesJson !== this.lastQualitiesJson) {
2029
+ this.lastQualitiesJson = qualitiesJson;
2030
+ if (this.isOpen && this.currentPanel === "quality") {
2031
+ this.renderQualityPanel();
2032
+ }
2033
+ }
2034
+ if (this.isOpen) {
2035
+ if (this.currentPanel === "quality") {
2036
+ this.updateQualityActiveStates();
2037
+ } else if (this.currentPanel === "speed") {
2038
+ this.updateSpeedActiveStates();
2039
+ } else if (this.currentPanel === "captions") {
2040
+ this.updateCaptionsActiveStates();
2041
+ }
2042
+ }
2043
+ }
2044
+ toggle() {
2045
+ this.isOpen ? this.close() : this.open();
2046
+ }
2047
+ open() {
2048
+ this.isOpen = true;
2049
+ this.currentPanel = "main";
2050
+ this.renderMainPanel();
2051
+ this.panel.classList.add("sp-settings-panel--open");
2052
+ this.btn.setAttribute("aria-expanded", "true");
2053
+ }
2054
+ close() {
2055
+ this.isOpen = false;
2056
+ this.currentPanel = "main";
2057
+ this.panel.classList.remove("sp-settings-panel--open");
2058
+ this.btn.setAttribute("aria-expanded", "false");
2059
+ }
2060
+ showPanel(panel) {
2061
+ this.currentPanel = panel;
2062
+ switch (panel) {
2063
+ case "main":
2064
+ this.renderMainPanel();
2065
+ break;
2066
+ case "quality":
2067
+ this.renderQualityPanel();
2068
+ break;
2069
+ case "speed":
2070
+ this.renderSpeedPanel();
2071
+ break;
2072
+ case "captions":
2073
+ this.renderCaptionsPanel();
2074
+ break;
2075
+ }
2076
+ }
2077
+ renderMainPanel() {
2078
+ this.panel.innerHTML = "";
2079
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--main";
2080
+ const qualities = this.api.getState("qualities") || [];
2081
+ const currentQuality = this.api.getState("currentQuality");
2082
+ const playbackRate = this.api.getState("playbackRate") ?? 1;
2083
+ if (qualities.length > 0) {
2084
+ const qualityRow = this.createMainRow(
2085
+ "Quality",
2086
+ currentQuality?.label || "Auto",
2087
+ () => this.showPanel("quality")
2088
+ );
2089
+ this.panel.appendChild(qualityRow);
2090
+ }
2091
+ const textTracks = this.api.getState("textTracks") || [];
2092
+ if (textTracks.length > 0) {
2093
+ const currentTextTrack = this.api.getState("currentTextTrack");
2094
+ const captionsLabel = currentTextTrack ? currentTextTrack.label : "Off";
2095
+ const captionsRow = this.createMainRow(
2096
+ "Captions",
2097
+ captionsLabel,
2098
+ () => this.showPanel("captions")
2099
+ );
2100
+ this.panel.appendChild(captionsRow);
2101
+ }
2102
+ const speedLabel = playbackRate === 1 ? "Normal" : `${playbackRate}x`;
2103
+ const speedRow = this.createMainRow(
2104
+ "Speed",
2105
+ speedLabel,
2106
+ () => this.showPanel("speed")
2107
+ );
2108
+ this.panel.appendChild(speedRow);
2109
+ }
2110
+ createMainRow(label, value, onClick2) {
2111
+ const row = createElement("div", { className: "sp-settings-panel__row" });
2112
+ row.setAttribute("role", "menuitem");
2113
+ row.setAttribute("tabindex", "0");
2114
+ row.setAttribute("aria-haspopup", "true");
2115
+ const labelEl = createElement("span", { className: "sp-settings-panel__label" });
2116
+ labelEl.textContent = label;
2117
+ const rightSide = createElement("span", { className: "sp-settings-panel__value" });
2118
+ rightSide.textContent = value;
2119
+ const arrow = createElement("span", { className: "sp-settings-panel__arrow" });
2120
+ arrow.innerHTML = icons.chevronDown;
2121
+ rightSide.appendChild(arrow);
2122
+ row.appendChild(labelEl);
2123
+ row.appendChild(rightSide);
2124
+ row.addEventListener("click", (e) => {
2125
+ e.preventDefault();
2126
+ onClick2();
2127
+ });
2128
+ row.addEventListener("keydown", (e) => {
2129
+ if (e.key === "Enter" || e.key === " ") {
2130
+ e.preventDefault();
2131
+ onClick2();
2132
+ }
2133
+ });
2134
+ return row;
2135
+ }
2136
+ renderQualityPanel() {
2137
+ this.panel.innerHTML = "";
2138
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2139
+ const header = this.createSubHeader("Quality");
2140
+ this.panel.appendChild(header);
2141
+ const qualities = this.api.getState("qualities") || [];
2142
+ const currentQuality = this.api.getState("currentQuality");
2143
+ const activeId = currentQuality?.id || "auto";
2144
+ const autoItem = this.createMenuItem("Auto", "auto", activeId === "auto");
2145
+ autoItem.addEventListener("click", (e) => {
2146
+ e.preventDefault();
2147
+ this.selectQuality("auto");
2148
+ });
2149
+ this.panel.appendChild(autoItem);
2150
+ const sorted = [...qualities].sort(
2151
+ (a, b) => b.height - a.height
2152
+ );
2153
+ for (const q of sorted) {
2154
+ if (q.id === "auto") continue;
2155
+ const item = this.createMenuItem(q.label, q.id, q.id === activeId);
2156
+ item.addEventListener("click", (e) => {
2157
+ e.preventDefault();
2158
+ this.selectQuality(q.id);
2159
+ });
2160
+ this.panel.appendChild(item);
2161
+ }
2162
+ }
2163
+ renderSpeedPanel() {
2164
+ this.panel.innerHTML = "";
2165
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2166
+ const header = this.createSubHeader("Speed");
2167
+ this.panel.appendChild(header);
2168
+ const currentRate = this.api.getState("playbackRate") ?? 1;
2169
+ for (const opt of SPEED_OPTIONS) {
2170
+ const isActive = Math.abs(currentRate - opt.value) < 0.01;
2171
+ const item = this.createMenuItem(opt.label, String(opt.value), isActive);
2172
+ item.addEventListener("click", (e) => {
2173
+ e.preventDefault();
2174
+ this.selectSpeed(opt.value);
2175
+ });
2176
+ this.panel.appendChild(item);
2177
+ }
2178
+ }
2179
+ renderCaptionsPanel() {
2180
+ this.panel.innerHTML = "";
2181
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2182
+ const header = this.createSubHeader("Captions");
2183
+ this.panel.appendChild(header);
2184
+ const textTracks = this.api.getState("textTracks") || [];
2185
+ const currentTextTrack = this.api.getState("currentTextTrack");
2186
+ const activeId = currentTextTrack?.id || "off";
2187
+ const offItem = this.createMenuItem("Off", "off", activeId === "off");
2188
+ offItem.addEventListener("click", (e) => {
2189
+ e.preventDefault();
2190
+ this.selectCaption(null);
2191
+ });
2192
+ this.panel.appendChild(offItem);
2193
+ for (const track of textTracks) {
2194
+ const item = this.createMenuItem(track.label, track.id, track.id === activeId);
2195
+ item.addEventListener("click", (e) => {
2196
+ e.preventDefault();
2197
+ this.selectCaption(track.id);
2198
+ });
2199
+ this.panel.appendChild(item);
2200
+ }
2201
+ }
2202
+ selectCaption(trackId) {
2203
+ this.api.emit("track:text", { trackId });
2204
+ this.close();
2205
+ }
2206
+ updateCaptionsActiveStates() {
2207
+ const currentTextTrack = this.api.getState("currentTextTrack");
2208
+ const activeId = currentTextTrack?.id || "off";
2209
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2210
+ items.forEach((item) => {
2211
+ const id = item.getAttribute("data-id");
2212
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
2213
+ });
2214
+ }
2215
+ createSubHeader(title) {
2216
+ const header = createElement("div", { className: "sp-settings-panel__header" });
2217
+ header.setAttribute("role", "menuitem");
2218
+ header.setAttribute("tabindex", "0");
2219
+ const backArrow = createElement("span", { className: "sp-settings-panel__back" });
2220
+ backArrow.innerHTML = icons.chevronUp;
2221
+ const label = createElement("span", { className: "sp-settings-panel__header-label" });
2222
+ label.textContent = title;
2223
+ header.appendChild(backArrow);
2224
+ header.appendChild(label);
2225
+ header.addEventListener("click", (e) => {
2226
+ e.preventDefault();
2227
+ this.showPanel("main");
2228
+ });
2229
+ header.addEventListener("keydown", (e) => {
2230
+ if (e.key === "Enter" || e.key === " ") {
2231
+ e.preventDefault();
2232
+ this.showPanel("main");
2233
+ }
2234
+ });
2235
+ return header;
2236
+ }
2237
+ createMenuItem(label, dataId, isActive) {
2238
+ const item = createElement("div", {
2239
+ className: `sp-settings-panel__item${isActive ? " sp-settings-panel__item--active" : ""}`
2240
+ });
2241
+ item.setAttribute("role", "menuitem");
2242
+ item.setAttribute("tabindex", "0");
2243
+ item.setAttribute("data-id", dataId);
2244
+ const labelEl = createElement("span");
2245
+ labelEl.textContent = label;
2246
+ const check = createElement("span", { className: "sp-settings-panel__check" });
2247
+ check.innerHTML = icons.checkmark;
2248
+ item.appendChild(labelEl);
2249
+ item.appendChild(check);
2250
+ item.addEventListener("keydown", (e) => {
2251
+ if (e.key === "Enter" || e.key === " ") {
2252
+ e.preventDefault();
2253
+ item.click();
2254
+ }
2255
+ });
2256
+ return item;
2257
+ }
2258
+ selectQuality(qualityId) {
2259
+ this.api.emit("quality:select", {
2260
+ quality: qualityId,
2261
+ auto: qualityId === "auto"
2262
+ });
2263
+ this.close();
2264
+ }
2265
+ selectSpeed(rate) {
2266
+ this.api.emit("playback:ratechange", { rate });
2267
+ const video = this.api.container.querySelector("video");
2268
+ if (video) {
2269
+ video.playbackRate = rate;
2270
+ }
2271
+ this.close();
2272
+ }
2273
+ updateQualityActiveStates() {
2274
+ const currentQuality = this.api.getState("currentQuality");
2275
+ const activeId = currentQuality?.id || "auto";
2276
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2277
+ items.forEach((item) => {
2278
+ const id = item.getAttribute("data-id");
2279
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
2280
+ });
2281
+ }
2282
+ updateSpeedActiveStates() {
2283
+ const currentRate = this.api.getState("playbackRate") ?? 1;
2284
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2285
+ items.forEach((item) => {
2286
+ const id = item.getAttribute("data-id");
2287
+ const value = parseFloat(id || "1");
2288
+ item.classList.toggle(
2289
+ "sp-settings-panel__item--active",
2290
+ Math.abs(currentRate - value) < 0.01
2291
+ );
2292
+ });
2293
+ }
2294
+ getPanel() {
2295
+ return this.currentPanel;
2296
+ }
2297
+ isMenuOpen() {
2298
+ return this.isOpen;
2299
+ }
2300
+ destroy() {
2301
+ document.removeEventListener("click", this.closeHandler);
2302
+ document.removeEventListener("keydown", this.keyHandler);
2303
+ this.el.remove();
2304
+ }
2305
+ };
2306
+
2307
+ // src/controls/SkipButton.ts
2308
+ var DEFAULT_SKIP_SECONDS = 10;
2309
+ var SkipButton = class {
2310
+ constructor(api, direction, seconds = DEFAULT_SKIP_SECONDS) {
2311
+ this.clickHandler = () => {
2312
+ this.skip();
2313
+ };
2314
+ this.api = api;
2315
+ this.direction = direction;
2316
+ this.seconds = seconds;
2317
+ const icon = direction === "backward" ? icons.replay10 : icons.forward10;
2318
+ const label = direction === "backward" ? `Rewind ${seconds} seconds` : `Forward ${seconds} seconds`;
2319
+ this.el = createButton(
2320
+ `sp-skip sp-skip--${direction}`,
2321
+ label,
2322
+ icon
2323
+ );
2324
+ this.el.addEventListener("click", this.clickHandler);
2325
+ }
2326
+ render() {
2327
+ return this.el;
2328
+ }
2329
+ update() {
2330
+ const live = this.api.getState("live");
2331
+ const duration = this.api.getState("duration") ?? 0;
2332
+ const seekableRange = this.api.getState("seekableRange");
2333
+ if (live && !seekableRange) {
2334
+ this.el.style.display = "none";
2335
+ return;
2336
+ }
2337
+ if (live && seekableRange) {
2338
+ this.el.style.display = "";
2339
+ return;
2340
+ }
2341
+ if (duration === 0) {
2342
+ this.el.style.display = "none";
2343
+ return;
2344
+ }
2345
+ this.el.style.display = "";
2346
+ }
2347
+ skip() {
2348
+ const video = getVideo(this.api.container);
2349
+ if (!video) return;
2350
+ const live = this.api.getState("live");
2351
+ const seekableRange = this.api.getState("seekableRange");
2352
+ if (live && seekableRange) {
2353
+ if (this.direction === "backward") {
2354
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - this.seconds);
2355
+ } else {
2356
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + this.seconds);
2357
+ }
2358
+ return;
2359
+ }
2360
+ const duration = video.duration || 0;
2361
+ if (!duration || !isFinite(duration)) return;
2362
+ if (this.direction === "backward") {
2363
+ video.currentTime = Math.max(0, video.currentTime - this.seconds);
2364
+ } else {
2365
+ video.currentTime = Math.min(duration, video.currentTime + this.seconds);
2366
+ }
2367
+ }
2368
+ destroy() {
2369
+ this.el.removeEventListener("click", this.clickHandler);
2370
+ this.el.remove();
2371
+ }
2372
+ };
2373
+
2374
+ // src/controls/CaptionsButton.ts
2375
+ var CaptionsButton = class {
2376
+ constructor(api) {
2377
+ this.clickHandler = () => {
2378
+ this.toggle();
2379
+ };
2380
+ this.api = api;
2381
+ this.el = createButton("sp-captions", "Captions", icons.captionsOff);
2382
+ this.el.addEventListener("click", this.clickHandler);
2383
+ }
2384
+ render() {
2385
+ return this.el;
2386
+ }
2387
+ update() {
2388
+ const textTracks = this.api.getState("textTracks") || [];
2389
+ const currentTrack = this.api.getState("currentTextTrack");
2390
+ if (textTracks.length === 0) {
2391
+ this.el.style.display = "none";
2392
+ return;
2393
+ }
2394
+ this.el.style.display = "";
2395
+ if (currentTrack) {
2396
+ this.el.innerHTML = icons.captions;
2397
+ this.el.setAttribute("aria-label", `Captions: ${currentTrack.label}`);
2398
+ this.el.classList.add("sp-captions--active");
2399
+ } else {
2400
+ this.el.innerHTML = icons.captionsOff;
2401
+ this.el.setAttribute("aria-label", "Captions");
2402
+ this.el.classList.remove("sp-captions--active");
2403
+ }
2404
+ }
2405
+ toggle() {
2406
+ const textTracks = this.api.getState("textTracks") || [];
2407
+ const currentTrack = this.api.getState("currentTextTrack");
2408
+ if (textTracks.length === 0) return;
2409
+ if (currentTrack) {
2410
+ this.api.emit("track:text", { trackId: null });
2411
+ } else {
2412
+ this.api.emit("track:text", { trackId: textTracks[0].id });
2413
+ }
2414
+ }
2415
+ destroy() {
2416
+ this.el.removeEventListener("click", this.clickHandler);
2417
+ this.el.remove();
2418
+ }
2419
+ };
2420
+
1294
2421
  // src/index.ts
1295
2422
  var DEFAULT_LAYOUT = [
1296
2423
  "play",
2424
+ "skip-backward",
2425
+ "skip-forward",
1297
2426
  "volume",
1298
2427
  "time",
1299
2428
  "live-indicator",
1300
2429
  "spacer",
1301
- "quality",
2430
+ "settings",
2431
+ "captions",
1302
2432
  "chromecast",
1303
2433
  "airplay",
1304
2434
  "pip",
@@ -1311,10 +2441,12 @@ function uiPlugin(config = {}) {
1311
2441
  let gradient = null;
1312
2442
  let progressBar = null;
1313
2443
  let bufferingIndicator = null;
2444
+ let errorOverlay = null;
1314
2445
  let styleEl = null;
1315
2446
  let controls = [];
1316
2447
  let hideTimeout = null;
1317
2448
  let stateUnsubscribe = null;
2449
+ let errorUnsubscribe = null;
1318
2450
  let controlsVisible = true;
1319
2451
  const layout = config.controls || DEFAULT_LAYOUT;
1320
2452
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
@@ -1322,6 +2454,10 @@ function uiPlugin(config = {}) {
1322
2454
  switch (slot) {
1323
2455
  case "play":
1324
2456
  return new PlayButton(api);
2457
+ case "skip-backward":
2458
+ return new SkipButton(api, "backward");
2459
+ case "skip-forward":
2460
+ return new SkipButton(api, "forward");
1325
2461
  case "volume":
1326
2462
  return new VolumeControl(api);
1327
2463
  case "progress":
@@ -1332,6 +2468,10 @@ function uiPlugin(config = {}) {
1332
2468
  return new LiveIndicator(api);
1333
2469
  case "quality":
1334
2470
  return new QualityMenu(api);
2471
+ case "settings":
2472
+ return new SettingsMenu(api);
2473
+ case "captions":
2474
+ return new CaptionsButton(api);
1335
2475
  case "chromecast":
1336
2476
  return new CastButton(api, "chromecast");
1337
2477
  case "airplay":
@@ -1355,6 +2495,7 @@ function uiPlugin(config = {}) {
1355
2495
  const isLoading = playbackState === "loading";
1356
2496
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
1357
2497
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
2498
+ errorOverlay?.update();
1358
2499
  };
1359
2500
  const showControls = () => {
1360
2501
  if (controlsVisible) {
@@ -1393,8 +2534,14 @@ function uiPlugin(config = {}) {
1393
2534
  };
1394
2535
  const handleKeyDown = (e) => {
1395
2536
  if (!api.container.contains(document.activeElement)) return;
2537
+ const activeEl = document.activeElement;
2538
+ if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
2539
+ return;
2540
+ }
1396
2541
  const video = api.container.querySelector("video");
1397
2542
  if (!video) return;
2543
+ const live = api.getState("live");
2544
+ const seekableRange = api.getState("seekableRange");
1398
2545
  switch (e.key) {
1399
2546
  case " ":
1400
2547
  case "k":
@@ -1415,12 +2562,20 @@ function uiPlugin(config = {}) {
1415
2562
  break;
1416
2563
  case "ArrowLeft":
1417
2564
  e.preventDefault();
1418
- video.currentTime = Math.max(0, video.currentTime - 5);
2565
+ if (live && seekableRange) {
2566
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - 5);
2567
+ } else {
2568
+ video.currentTime = Math.max(0, video.currentTime - 5);
2569
+ }
1419
2570
  showControls();
1420
2571
  break;
1421
2572
  case "ArrowRight":
1422
2573
  e.preventDefault();
1423
- video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
2574
+ if (live && seekableRange) {
2575
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + 5);
2576
+ } else {
2577
+ video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
2578
+ }
1424
2579
  showControls();
1425
2580
  break;
1426
2581
  case "ArrowUp":
@@ -1457,19 +2612,30 @@ function uiPlugin(config = {}) {
1457
2612
  if (containerStyle.position === "static") {
1458
2613
  container.style.position = "relative";
1459
2614
  }
2615
+ const isPlaying = api.getState("playing");
1460
2616
  gradient = document.createElement("div");
1461
- gradient.className = "sp-gradient sp-gradient--visible";
2617
+ gradient.className = isPlaying ? "sp-gradient" : "sp-gradient sp-gradient--visible";
1462
2618
  container.appendChild(gradient);
1463
2619
  bufferingIndicator = document.createElement("div");
1464
2620
  bufferingIndicator.className = "sp-buffering";
1465
2621
  bufferingIndicator.innerHTML = icons.spinner;
1466
2622
  bufferingIndicator.setAttribute("aria-hidden", "true");
1467
2623
  container.appendChild(bufferingIndicator);
2624
+ errorOverlay = new ErrorOverlay(api);
2625
+ container.appendChild(errorOverlay.render());
2626
+ errorUnsubscribe = api.on("error", (payload) => {
2627
+ if (payload?.fatal) {
2628
+ const error = api.getState("error") || new Error(payload.message || "Playback error");
2629
+ errorOverlay?.show(error);
2630
+ }
2631
+ });
1468
2632
  progressBar = new ProgressBar(api);
1469
2633
  container.appendChild(progressBar.render());
1470
- progressBar.show();
2634
+ if (!isPlaying) {
2635
+ progressBar.show();
2636
+ }
1471
2637
  controlBar = document.createElement("div");
1472
- controlBar.className = "sp-controls sp-controls--visible";
2638
+ controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
1473
2639
  controlBar.setAttribute("role", "toolbar");
1474
2640
  controlBar.setAttribute("aria-label", "Video controls");
1475
2641
  for (const slot of layout) {
@@ -1492,6 +2658,11 @@ function uiPlugin(config = {}) {
1492
2658
  if (!container.hasAttribute("tabindex")) {
1493
2659
  container.setAttribute("tabindex", "0");
1494
2660
  }
2661
+ controlsVisible = !isPlaying;
2662
+ api.setState("controlsVisible", controlsVisible);
2663
+ if (isPlaying) {
2664
+ resetHideTimer();
2665
+ }
1495
2666
  api.logger.debug("UI controls plugin initialized");
1496
2667
  },
1497
2668
  async destroy() {
@@ -1501,6 +2672,8 @@ function uiPlugin(config = {}) {
1501
2672
  }
1502
2673
  stateUnsubscribe?.();
1503
2674
  stateUnsubscribe = null;
2675
+ errorUnsubscribe?.();
2676
+ errorUnsubscribe = null;
1504
2677
  if (api?.container) {
1505
2678
  api.container.removeEventListener("mousemove", handleInteraction);
1506
2679
  api.container.removeEventListener("mouseenter", handleInteraction);
@@ -1514,6 +2687,8 @@ function uiPlugin(config = {}) {
1514
2687
  controls = [];
1515
2688
  progressBar?.destroy();
1516
2689
  progressBar = null;
2690
+ errorOverlay?.destroy();
2691
+ errorOverlay = null;
1517
2692
  controlBar?.remove();
1518
2693
  controlBar = null;
1519
2694
  gradient?.remove();