@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.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
@@ -647,12 +983,11 @@ var PlayButton = class {
647
983
  const video = getVideo(this.api.container);
648
984
  if (!video) return;
649
985
  const ended = this.api.getState("ended");
650
- const playing = this.api.getState("playing");
651
986
  if (ended) {
652
987
  video.currentTime = 0;
653
988
  video.play().catch(() => {
654
989
  });
655
- } else if (playing) {
990
+ } else if (!video.paused) {
656
991
  video.pause();
657
992
  } else {
658
993
  video.play().catch(() => {
@@ -665,6 +1000,70 @@ var PlayButton = class {
665
1000
  }
666
1001
  };
667
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
+
668
1067
  // src/controls/ProgressBar.ts
669
1068
  var ProgressBar = class {
670
1069
  constructor(api) {
@@ -706,36 +1105,99 @@ var ProgressBar = class {
706
1105
  }
707
1106
  }
708
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
+ };
709
1147
  this.onMouseMove = (e) => {
710
1148
  this.updateTooltip(e.clientX);
711
1149
  };
712
1150
  this.onMouseLeave = () => {
713
1151
  if (!this.isDragging) {
714
1152
  this.tooltip.style.opacity = "0";
1153
+ this.thumbnailPreview.hide();
715
1154
  }
716
1155
  };
717
1156
  this.onKeyDown = (e) => {
718
1157
  const video = getVideo(this.api.container);
719
1158
  if (!video) return;
720
1159
  const step = 5;
721
- const duration = this.api.getState("duration") || 0;
722
- switch (e.key) {
723
- case "ArrowLeft":
724
- e.preventDefault();
725
- video.currentTime = Math.max(0, video.currentTime - step);
726
- break;
727
- case "ArrowRight":
728
- e.preventDefault();
729
- video.currentTime = Math.min(duration, video.currentTime + step);
730
- break;
731
- case "Home":
732
- e.preventDefault();
733
- video.currentTime = 0;
734
- break;
735
- case "End":
736
- e.preventDefault();
737
- video.currentTime = duration;
738
- 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
+ }
739
1201
  }
740
1202
  };
741
1203
  this.api = api;
@@ -747,10 +1209,12 @@ var ProgressBar = class {
747
1209
  this.handle = createElement("div", { className: "sp-progress__handle" });
748
1210
  this.tooltip = createElement("div", { className: "sp-progress__tooltip" });
749
1211
  this.tooltip.textContent = "0:00";
1212
+ this.thumbnailPreview = new ThumbnailPreview();
750
1213
  track.appendChild(this.buffered);
751
1214
  track.appendChild(this.filled);
752
1215
  track.appendChild(this.handle);
753
1216
  this.el.appendChild(track);
1217
+ this.el.appendChild(this.thumbnailPreview.getElement());
754
1218
  this.el.appendChild(this.tooltip);
755
1219
  this.wrapper.appendChild(this.el);
756
1220
  this.el.setAttribute("role", "slider");
@@ -760,9 +1224,13 @@ var ProgressBar = class {
760
1224
  this.wrapper.addEventListener("mousedown", this.onMouseDown);
761
1225
  this.wrapper.addEventListener("mousemove", this.onMouseMove);
762
1226
  this.wrapper.addEventListener("mouseleave", this.onMouseLeave);
1227
+ this.wrapper.addEventListener("touchstart", this.onTouchStart, { passive: false });
763
1228
  this.el.addEventListener("keydown", this.onKeyDown);
764
1229
  document.addEventListener("mousemove", this.onDocMouseMove);
765
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);
766
1234
  }
767
1235
  render() {
768
1236
  return this.wrapper;
@@ -775,11 +1243,40 @@ var ProgressBar = class {
775
1243
  hide() {
776
1244
  this.wrapper.classList.remove("sp-progress-wrapper--visible");
777
1245
  }
1246
+ /** Set thumbnail sprite configuration */
1247
+ setThumbnails(config) {
1248
+ this.thumbnailPreview.setConfig(config);
1249
+ }
778
1250
  update() {
779
1251
  const currentTime = this.api.getState("currentTime") || 0;
780
1252
  const duration = this.api.getState("duration") || 0;
781
1253
  const bufferedRanges = this.api.getState("buffered");
782
- 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) {
783
1280
  const progress = currentTime / duration * 100;
784
1281
  this.filled.style.width = `${progress}%`;
785
1282
  this.handle.style.left = `${progress}%`;
@@ -796,6 +1293,12 @@ var ProgressBar = class {
796
1293
  getTimeFromPosition(clientX) {
797
1294
  const rect = this.el.getBoundingClientRect();
798
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
+ }
799
1302
  const duration = this.api.getState("duration") || 0;
800
1303
  return percent * duration;
801
1304
  }
@@ -803,8 +1306,18 @@ var ProgressBar = class {
803
1306
  const rect = this.el.getBoundingClientRect();
804
1307
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
805
1308
  const time = this.getTimeFromPosition(clientX);
806
- 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
+ }
807
1317
  this.tooltip.style.left = `${percent * 100}%`;
1318
+ if (this.thumbnailPreview.isConfigured()) {
1319
+ this.thumbnailPreview.show(time, percent);
1320
+ }
808
1321
  }
809
1322
  updateVisualPosition(clientX) {
810
1323
  const rect = this.el.getBoundingClientRect();
@@ -827,8 +1340,13 @@ var ProgressBar = class {
827
1340
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
828
1341
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
829
1342
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
1343
+ this.wrapper.removeEventListener("touchstart", this.onTouchStart);
830
1344
  document.removeEventListener("mousemove", this.onDocMouseMove);
831
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();
832
1350
  this.wrapper.remove();
833
1351
  }
834
1352
  };
@@ -881,6 +1399,20 @@ var VolumeControl = class {
881
1399
  this.onMouseUp = () => {
882
1400
  this.isDragging = false;
883
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
+ };
884
1416
  this.onKeyDown = (e) => {
885
1417
  const video = getVideo(this.api.container);
886
1418
  if (!video) return;
@@ -920,9 +1452,13 @@ var VolumeControl = class {
920
1452
  this.el.appendChild(this.btn);
921
1453
  this.el.appendChild(sliderWrap);
922
1454
  this.slider.addEventListener("mousedown", this.onMouseDown);
1455
+ this.slider.addEventListener("touchstart", this.onTouchStart, { passive: false });
923
1456
  this.slider.addEventListener("keydown", this.onKeyDown);
924
1457
  document.addEventListener("mousemove", this.onDocMouseMove);
925
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);
926
1462
  }
927
1463
  render() {
928
1464
  return this.el;
@@ -967,8 +1503,14 @@ var VolumeControl = class {
967
1503
  return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
968
1504
  }
969
1505
  destroy() {
1506
+ this.slider.removeEventListener("mousedown", this.onMouseDown);
1507
+ this.slider.removeEventListener("touchstart", this.onTouchStart);
1508
+ this.slider.removeEventListener("keydown", this.onKeyDown);
970
1509
  document.removeEventListener("mousemove", this.onDocMouseMove);
971
1510
  document.removeEventListener("mouseup", this.onMouseUp);
1511
+ document.removeEventListener("touchmove", this.onDocTouchMove);
1512
+ document.removeEventListener("touchend", this.onTouchEnd);
1513
+ document.removeEventListener("touchcancel", this.onTouchEnd);
972
1514
  this.el.remove();
973
1515
  }
974
1516
  };
@@ -976,19 +1518,27 @@ var VolumeControl = class {
976
1518
  // src/controls/LiveIndicator.ts
977
1519
  var LiveIndicator = class {
978
1520
  constructor(api) {
979
- this.api = api;
980
- this.el = createElement("div", { className: "sp-live" });
981
- this.el.innerHTML = '<div class="sp-live__dot"></div><span>LIVE</span>';
982
- this.el.setAttribute("role", "button");
983
- this.el.setAttribute("aria-label", "Seek to live");
984
- this.el.setAttribute("tabindex", "0");
985
- this.el.onclick = () => this.seekToLive();
986
- this.el.onkeydown = (e) => {
1521
+ this.handleClick = () => {
1522
+ this.seekToLive();
1523
+ };
1524
+ this.handleKeyDown = (e) => {
987
1525
  if (e.key === "Enter" || e.key === " ") {
988
1526
  e.preventDefault();
989
1527
  this.seekToLive();
990
1528
  }
991
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);
992
1542
  }
993
1543
  render() {
994
1544
  return this.el;
@@ -999,8 +1549,12 @@ var LiveIndicator = class {
999
1549
  this.el.style.display = live ? "" : "none";
1000
1550
  if (liveEdge) {
1001
1551
  this.el.classList.remove("sp-live--behind");
1552
+ this.label.textContent = "LIVE";
1553
+ this.el.setAttribute("aria-label", "At live edge");
1002
1554
  } else {
1003
1555
  this.el.classList.add("sp-live--behind");
1556
+ this.label.textContent = "GO LIVE";
1557
+ this.el.setAttribute("aria-label", "Seek to live");
1004
1558
  }
1005
1559
  }
1006
1560
  seekToLive() {
@@ -1012,6 +1566,8 @@ var LiveIndicator = class {
1012
1566
  }
1013
1567
  }
1014
1568
  destroy() {
1569
+ this.el.removeEventListener("click", this.handleClick);
1570
+ this.el.removeEventListener("keydown", this.handleKeyDown);
1015
1571
  this.el.remove();
1016
1572
  }
1017
1573
  };
@@ -1322,14 +1878,588 @@ var Spacer = class {
1322
1878
  }
1323
1879
  };
1324
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
+
1325
2452
  // src/index.ts
1326
2453
  var DEFAULT_LAYOUT = [
1327
2454
  "play",
2455
+ "skip-backward",
2456
+ "skip-forward",
1328
2457
  "volume",
1329
2458
  "time",
1330
2459
  "live-indicator",
1331
2460
  "spacer",
1332
- "quality",
2461
+ "settings",
2462
+ "captions",
1333
2463
  "chromecast",
1334
2464
  "airplay",
1335
2465
  "pip",
@@ -1342,10 +2472,12 @@ function uiPlugin(config = {}) {
1342
2472
  let gradient = null;
1343
2473
  let progressBar = null;
1344
2474
  let bufferingIndicator = null;
2475
+ let errorOverlay = null;
1345
2476
  let styleEl = null;
1346
2477
  let controls = [];
1347
2478
  let hideTimeout = null;
1348
2479
  let stateUnsubscribe = null;
2480
+ let errorUnsubscribe = null;
1349
2481
  let controlsVisible = true;
1350
2482
  const layout = config.controls || DEFAULT_LAYOUT;
1351
2483
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
@@ -1353,6 +2485,10 @@ function uiPlugin(config = {}) {
1353
2485
  switch (slot) {
1354
2486
  case "play":
1355
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");
1356
2492
  case "volume":
1357
2493
  return new VolumeControl(api);
1358
2494
  case "progress":
@@ -1363,6 +2499,10 @@ function uiPlugin(config = {}) {
1363
2499
  return new LiveIndicator(api);
1364
2500
  case "quality":
1365
2501
  return new QualityMenu(api);
2502
+ case "settings":
2503
+ return new SettingsMenu(api);
2504
+ case "captions":
2505
+ return new CaptionsButton(api);
1366
2506
  case "chromecast":
1367
2507
  return new CastButton(api, "chromecast");
1368
2508
  case "airplay":
@@ -1386,6 +2526,7 @@ function uiPlugin(config = {}) {
1386
2526
  const isLoading = playbackState === "loading";
1387
2527
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
1388
2528
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
2529
+ errorOverlay?.update();
1389
2530
  };
1390
2531
  const showControls = () => {
1391
2532
  if (controlsVisible) {
@@ -1424,8 +2565,14 @@ function uiPlugin(config = {}) {
1424
2565
  };
1425
2566
  const handleKeyDown = (e) => {
1426
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
+ }
1427
2572
  const video = api.container.querySelector("video");
1428
2573
  if (!video) return;
2574
+ const live = api.getState("live");
2575
+ const seekableRange = api.getState("seekableRange");
1429
2576
  switch (e.key) {
1430
2577
  case " ":
1431
2578
  case "k":
@@ -1446,12 +2593,20 @@ function uiPlugin(config = {}) {
1446
2593
  break;
1447
2594
  case "ArrowLeft":
1448
2595
  e.preventDefault();
1449
- 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
+ }
1450
2601
  showControls();
1451
2602
  break;
1452
2603
  case "ArrowRight":
1453
2604
  e.preventDefault();
1454
- 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
+ }
1455
2610
  showControls();
1456
2611
  break;
1457
2612
  case "ArrowUp":
@@ -1488,19 +2643,30 @@ function uiPlugin(config = {}) {
1488
2643
  if (containerStyle.position === "static") {
1489
2644
  container.style.position = "relative";
1490
2645
  }
2646
+ const isPlaying = api.getState("playing");
1491
2647
  gradient = document.createElement("div");
1492
- gradient.className = "sp-gradient sp-gradient--visible";
2648
+ gradient.className = isPlaying ? "sp-gradient" : "sp-gradient sp-gradient--visible";
1493
2649
  container.appendChild(gradient);
1494
2650
  bufferingIndicator = document.createElement("div");
1495
2651
  bufferingIndicator.className = "sp-buffering";
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
- progressBar.show();
2665
+ if (!isPlaying) {
2666
+ progressBar.show();
2667
+ }
1502
2668
  controlBar = document.createElement("div");
1503
- controlBar.className = "sp-controls sp-controls--visible";
2669
+ controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
1504
2670
  controlBar.setAttribute("role", "toolbar");
1505
2671
  controlBar.setAttribute("aria-label", "Video controls");
1506
2672
  for (const slot of layout) {
@@ -1523,6 +2689,11 @@ function uiPlugin(config = {}) {
1523
2689
  if (!container.hasAttribute("tabindex")) {
1524
2690
  container.setAttribute("tabindex", "0");
1525
2691
  }
2692
+ controlsVisible = !isPlaying;
2693
+ api.setState("controlsVisible", controlsVisible);
2694
+ if (isPlaying) {
2695
+ resetHideTimer();
2696
+ }
1526
2697
  api.logger.debug("UI controls plugin initialized");
1527
2698
  },
1528
2699
  async destroy() {
@@ -1532,6 +2703,8 @@ function uiPlugin(config = {}) {
1532
2703
  }
1533
2704
  stateUnsubscribe?.();
1534
2705
  stateUnsubscribe = null;
2706
+ errorUnsubscribe?.();
2707
+ errorUnsubscribe = null;
1535
2708
  if (api?.container) {
1536
2709
  api.container.removeEventListener("mousemove", handleInteraction);
1537
2710
  api.container.removeEventListener("mouseenter", handleInteraction);
@@ -1545,6 +2718,8 @@ function uiPlugin(config = {}) {
1545
2718
  controls = [];
1546
2719
  progressBar?.destroy();
1547
2720
  progressBar = null;
2721
+ errorOverlay?.destroy();
2722
+ errorOverlay = null;
1548
2723
  controlBar?.remove();
1549
2724
  controlBar = null;
1550
2725
  gradient?.remove();