@scarlett-player/embed 0.4.1 → 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/embed.js CHANGED
@@ -397,7 +397,9 @@ function createHLSPlugin(config) {
397
397
  const getRetryDelay = (retryCount) => {
398
398
  const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
399
399
  const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
400
- return baseDelay * Math.pow(backoffFactor, retryCount);
400
+ const delay = baseDelay * Math.pow(backoffFactor, retryCount);
401
+ const jitter = delay * (0.7 + Math.random() * 0.3);
402
+ return jitter;
401
403
  };
402
404
  const emitFatalError = (error, retriesExhausted) => {
403
405
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
@@ -886,7 +888,12 @@ var styles = `
886
888
  transition: height 0.15s ease;
887
889
  }
888
890
 
889
- .sp-progress-wrapper:hover .sp-progress,
891
+ @media (hover: hover) {
892
+ .sp-progress-wrapper:hover .sp-progress {
893
+ height: 5px;
894
+ }
895
+ }
896
+
890
897
  .sp-progress--dragging {
891
898
  height: 5px;
892
899
  }
@@ -932,11 +939,34 @@ var styles = `
932
939
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
933
940
  }
934
941
 
935
- .sp-progress-wrapper:hover .sp-progress__handle,
942
+ @media (hover: hover) {
943
+ .sp-progress-wrapper:hover .sp-progress__handle {
944
+ transform: translate(-50%, -50%) scale(1);
945
+ }
946
+ }
947
+
936
948
  .sp-progress--dragging .sp-progress__handle {
937
949
  transform: translate(-50%, -50%) scale(1);
938
950
  }
939
951
 
952
+ /* Thumbnail Preview */
953
+ .sp-thumbnail-preview {
954
+ position: absolute;
955
+ bottom: calc(100% + 8px);
956
+ transform: translateX(-50%);
957
+ pointer-events: none;
958
+ display: none;
959
+ z-index: 21;
960
+ border-radius: 4px;
961
+ overflow: hidden;
962
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
963
+ border: 2px solid rgba(255, 255, 255, 0.2);
964
+ }
965
+
966
+ .sp-thumbnail-preview__img {
967
+ background-repeat: no-repeat;
968
+ }
969
+
940
970
  /* Progress Tooltip */
941
971
  .sp-progress__tooltip {
942
972
  position: absolute;
@@ -956,8 +986,10 @@ var styles = `
956
986
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
957
987
  }
958
988
 
959
- .sp-progress-wrapper:hover .sp-progress__tooltip {
960
- opacity: 1;
989
+ @media (hover: hover) {
990
+ .sp-progress-wrapper:hover .sp-progress__tooltip {
991
+ opacity: 1;
992
+ }
961
993
  }
962
994
 
963
995
  /* ============================================
@@ -977,9 +1009,11 @@ var styles = `
977
1009
  flex-shrink: 0;
978
1010
  }
979
1011
 
980
- .sp-control:hover {
981
- color: #fff;
982
- background: rgba(255, 255, 255, 0.1);
1012
+ @media (hover: hover) {
1013
+ .sp-control:hover {
1014
+ color: #fff;
1015
+ background: rgba(255, 255, 255, 0.1);
1016
+ }
983
1017
  }
984
1018
 
985
1019
  .sp-control:active {
@@ -1048,7 +1082,12 @@ var styles = `
1048
1082
  transition: width 0.2s ease;
1049
1083
  }
1050
1084
 
1051
- .sp-volume:hover .sp-volume__slider-wrap,
1085
+ @media (hover: hover) {
1086
+ .sp-volume:hover .sp-volume__slider-wrap {
1087
+ width: 64px;
1088
+ }
1089
+ }
1090
+
1052
1091
  .sp-volume:focus-within .sp-volume__slider-wrap {
1053
1092
  width: 64px;
1054
1093
  }
@@ -1091,8 +1130,10 @@ var styles = `
1091
1130
  transition: background 0.15s ease, opacity 0.15s ease;
1092
1131
  }
1093
1132
 
1094
- .sp-live:hover {
1095
- background: rgba(255, 255, 255, 0.1);
1133
+ @media (hover: hover) {
1134
+ .sp-live:hover {
1135
+ background: rgba(255, 255, 255, 0.1);
1136
+ }
1096
1137
  }
1097
1138
 
1098
1139
  .sp-live__dot {
@@ -1111,6 +1152,16 @@ var styles = `
1111
1152
  animation: none;
1112
1153
  }
1113
1154
 
1155
+ .sp-live--behind span {
1156
+ text-decoration: underline;
1157
+ text-underline-offset: 2px;
1158
+ }
1159
+
1160
+ /* Progress bar live mode: accent color for filled bar */
1161
+ .sp-progress--live .sp-progress__filled {
1162
+ background: var(--sp-accent, #e50914);
1163
+ }
1164
+
1114
1165
  @keyframes sp-pulse {
1115
1166
  0%, 100% { opacity: 1; }
1116
1167
  50% { opacity: 0.4; }
@@ -1191,6 +1242,169 @@ var styles = `
1191
1242
  opacity: 1;
1192
1243
  }
1193
1244
 
1245
+ /* ============================================
1246
+ Settings Menu (Gear Icon)
1247
+ ============================================ */
1248
+ .sp-settings {
1249
+ position: relative;
1250
+ }
1251
+
1252
+ .sp-settings__btn {
1253
+ display: flex;
1254
+ align-items: center;
1255
+ }
1256
+
1257
+ .sp-settings-panel {
1258
+ position: absolute;
1259
+ bottom: calc(100% + 8px);
1260
+ right: 0;
1261
+ background: rgba(20, 20, 20, 0.95);
1262
+ backdrop-filter: blur(8px);
1263
+ -webkit-backdrop-filter: blur(8px);
1264
+ border-radius: 8px;
1265
+ min-width: 200px;
1266
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
1267
+ opacity: 0;
1268
+ visibility: hidden;
1269
+ transform: translateY(8px);
1270
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
1271
+ z-index: 20;
1272
+ overflow: hidden;
1273
+ }
1274
+
1275
+ .sp-settings-panel--open {
1276
+ opacity: 1;
1277
+ visibility: visible;
1278
+ transform: translateY(0);
1279
+ }
1280
+
1281
+ /* Main menu rows */
1282
+ .sp-settings-panel--main {
1283
+ padding: 4px 0;
1284
+ }
1285
+
1286
+ .sp-settings-panel__row {
1287
+ display: flex;
1288
+ align-items: center;
1289
+ justify-content: space-between;
1290
+ padding: 10px 16px;
1291
+ font-size: 13px;
1292
+ color: rgba(255, 255, 255, 0.9);
1293
+ cursor: pointer;
1294
+ transition: background 0.1s ease;
1295
+ }
1296
+
1297
+ .sp-settings-panel__row:hover {
1298
+ background: rgba(255, 255, 255, 0.1);
1299
+ }
1300
+
1301
+ .sp-settings-panel__label {
1302
+ font-weight: 500;
1303
+ }
1304
+
1305
+ .sp-settings-panel__value {
1306
+ display: flex;
1307
+ align-items: center;
1308
+ gap: 4px;
1309
+ color: rgba(255, 255, 255, 0.6);
1310
+ font-size: 12px;
1311
+ }
1312
+
1313
+ .sp-settings-panel__arrow {
1314
+ display: flex;
1315
+ align-items: center;
1316
+ transform: rotate(-90deg);
1317
+ }
1318
+
1319
+ .sp-settings-panel__arrow svg {
1320
+ width: 16px;
1321
+ height: 16px;
1322
+ fill: currentColor;
1323
+ }
1324
+
1325
+ /* Sub-menu panels */
1326
+ .sp-settings-panel--sub {
1327
+ padding: 0;
1328
+ }
1329
+
1330
+ .sp-settings-panel__header {
1331
+ display: flex;
1332
+ align-items: center;
1333
+ gap: 8px;
1334
+ padding: 10px 16px;
1335
+ font-size: 13px;
1336
+ font-weight: 600;
1337
+ color: rgba(255, 255, 255, 0.9);
1338
+ cursor: pointer;
1339
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
1340
+ transition: background 0.1s ease;
1341
+ }
1342
+
1343
+ .sp-settings-panel__header:hover {
1344
+ background: rgba(255, 255, 255, 0.1);
1345
+ }
1346
+
1347
+ .sp-settings-panel__back {
1348
+ display: flex;
1349
+ align-items: center;
1350
+ transform: rotate(-90deg);
1351
+ }
1352
+
1353
+ .sp-settings-panel__back svg {
1354
+ width: 16px;
1355
+ height: 16px;
1356
+ fill: currentColor;
1357
+ }
1358
+
1359
+ .sp-settings-panel__header-label {
1360
+ flex: 1;
1361
+ }
1362
+
1363
+ .sp-settings-panel__item {
1364
+ display: flex;
1365
+ align-items: center;
1366
+ justify-content: space-between;
1367
+ padding: 10px 16px;
1368
+ font-size: 13px;
1369
+ color: rgba(255, 255, 255, 0.8);
1370
+ cursor: pointer;
1371
+ transition: background 0.1s ease, color 0.1s ease;
1372
+ }
1373
+
1374
+ .sp-settings-panel__item:hover {
1375
+ background: rgba(255, 255, 255, 0.1);
1376
+ color: #fff;
1377
+ }
1378
+
1379
+ .sp-settings-panel__item--active {
1380
+ color: var(--sp-accent, #e50914);
1381
+ }
1382
+
1383
+ .sp-settings-panel__check {
1384
+ width: 16px;
1385
+ height: 16px;
1386
+ fill: currentColor;
1387
+ margin-left: 8px;
1388
+ opacity: 0;
1389
+ }
1390
+
1391
+ .sp-settings-panel__check svg {
1392
+ width: 16px;
1393
+ height: 16px;
1394
+ fill: currentColor;
1395
+ }
1396
+
1397
+ .sp-settings-panel__item--active .sp-settings-panel__check {
1398
+ opacity: 1;
1399
+ }
1400
+
1401
+ /* ============================================
1402
+ Captions Button
1403
+ ============================================ */
1404
+ .sp-captions--active {
1405
+ color: var(--sp-accent, #e50914);
1406
+ }
1407
+
1194
1408
  /* ============================================
1195
1409
  Cast Button States
1196
1410
  ============================================ */
@@ -1202,6 +1416,122 @@ var styles = `
1202
1416
  opacity: 0.4;
1203
1417
  }
1204
1418
 
1419
+ /* ============================================
1420
+ Error Overlay
1421
+ ============================================ */
1422
+ .sp-error-overlay {
1423
+ position: absolute;
1424
+ top: 0;
1425
+ left: 0;
1426
+ right: 0;
1427
+ bottom: 0;
1428
+ background: rgba(0, 0, 0, 0.85);
1429
+ display: flex;
1430
+ align-items: center;
1431
+ justify-content: center;
1432
+ z-index: 25;
1433
+ opacity: 0;
1434
+ visibility: hidden;
1435
+ transition: opacity 0.25s ease, visibility 0.25s;
1436
+ }
1437
+
1438
+ .sp-error-overlay--visible {
1439
+ opacity: 1;
1440
+ visibility: visible;
1441
+ }
1442
+
1443
+ .sp-error-overlay__content {
1444
+ display: flex;
1445
+ flex-direction: column;
1446
+ align-items: center;
1447
+ text-align: center;
1448
+ padding: 24px;
1449
+ max-width: 360px;
1450
+ }
1451
+
1452
+ .sp-error-overlay__icon {
1453
+ color: rgba(255, 255, 255, 0.7);
1454
+ margin-bottom: 16px;
1455
+ }
1456
+
1457
+ .sp-error-overlay__icon svg {
1458
+ width: 48px;
1459
+ height: 48px;
1460
+ fill: currentColor;
1461
+ }
1462
+
1463
+ .sp-error-overlay__message {
1464
+ color: rgba(255, 255, 255, 0.9);
1465
+ font-size: 15px;
1466
+ line-height: 1.5;
1467
+ margin: 0 0 24px;
1468
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1469
+ }
1470
+
1471
+ .sp-error-overlay__actions {
1472
+ display: flex;
1473
+ gap: 12px;
1474
+ flex-wrap: wrap;
1475
+ justify-content: center;
1476
+ }
1477
+
1478
+ .sp-error-overlay__retry {
1479
+ background: var(--sp-accent, #e50914);
1480
+ color: #fff;
1481
+ border: none;
1482
+ padding: 12px 24px;
1483
+ font-size: 14px;
1484
+ font-weight: 600;
1485
+ border-radius: 6px;
1486
+ cursor: pointer;
1487
+ min-width: 120px;
1488
+ min-height: 44px;
1489
+ transition: background 0.15s ease, transform 0.15s ease;
1490
+ font-family: inherit;
1491
+ }
1492
+
1493
+ .sp-error-overlay__retry:hover {
1494
+ filter: brightness(1.1);
1495
+ }
1496
+
1497
+ .sp-error-overlay__retry:active {
1498
+ transform: scale(0.96);
1499
+ }
1500
+
1501
+ .sp-error-overlay__retry:focus-visible {
1502
+ outline: 2px solid #fff;
1503
+ outline-offset: 2px;
1504
+ }
1505
+
1506
+ .sp-error-overlay__dismiss {
1507
+ background: none;
1508
+ color: rgba(255, 255, 255, 0.7);
1509
+ border: 1px solid rgba(255, 255, 255, 0.3);
1510
+ padding: 12px 24px;
1511
+ font-size: 14px;
1512
+ font-weight: 500;
1513
+ border-radius: 6px;
1514
+ cursor: pointer;
1515
+ min-width: 100px;
1516
+ min-height: 44px;
1517
+ transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
1518
+ font-family: inherit;
1519
+ }
1520
+
1521
+ .sp-error-overlay__dismiss:hover {
1522
+ color: #fff;
1523
+ border-color: rgba(255, 255, 255, 0.5);
1524
+ }
1525
+
1526
+ .sp-error-overlay__dismiss:active {
1527
+ transform: scale(0.96);
1528
+ }
1529
+
1530
+ .sp-error-overlay__dismiss:focus-visible {
1531
+ outline: 2px solid #fff;
1532
+ outline-offset: 2px;
1533
+ }
1534
+
1205
1535
  /* ============================================
1206
1536
  Buffering Indicator
1207
1537
  ============================================ */
@@ -1249,7 +1579,14 @@ var styles = `
1249
1579
  .sp-control,
1250
1580
  .sp-volume__slider-wrap,
1251
1581
  .sp-quality-menu,
1252
- .sp-buffering {
1582
+ .sp-settings-panel,
1583
+ .sp-settings-panel__row,
1584
+ .sp-settings-panel__item,
1585
+ .sp-settings-panel__header,
1586
+ .sp-buffering,
1587
+ .sp-error-overlay,
1588
+ .sp-error-overlay__retry,
1589
+ .sp-error-overlay__dismiss {
1253
1590
  transition: none;
1254
1591
  }
1255
1592
 
@@ -1284,7 +1621,15 @@ var icons = {
1284
1621
  chromecast: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`,
1285
1622
  chromecastConnected: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm18-7H5v1.63c3.96 1.28 7.09 4.41 8.37 8.37H19V7zM1 10v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`,
1286
1623
  airplay: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 22h12l-6-6-6 6zM21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4v-2H3V5h18v12h-4v2h4c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`,
1287
- 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>`
1624
+ captions: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z"/></svg>`,
1625
+ captionsOff: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.5 5.5v13h-15v-13h15zM19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2z"/></svg>`,
1626
+ checkmark: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
1627
+ chevronUp: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/></svg>`,
1628
+ chevronDown: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>`,
1629
+ 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>`,
1630
+ 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>`,
1631
+ 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>`,
1632
+ 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>`
1288
1633
  };
1289
1634
  function createElement(tag, attrs, children) {
1290
1635
  const el = document.createElement(tag);
@@ -1384,6 +1729,68 @@ var PlayButton = class {
1384
1729
  this.el.remove();
1385
1730
  }
1386
1731
  };
1732
+ var ThumbnailPreview = class {
1733
+ constructor() {
1734
+ this.config = null;
1735
+ this.loaded = false;
1736
+ this.el = createElement("div", { className: "sp-thumbnail-preview" });
1737
+ this.img = createElement("div", { className: "sp-thumbnail-preview__img" });
1738
+ this.el.appendChild(this.img);
1739
+ }
1740
+ getElement() {
1741
+ return this.el;
1742
+ }
1743
+ setConfig(config) {
1744
+ this.config = config;
1745
+ this.loaded = false;
1746
+ if (config) {
1747
+ this.img.style.width = `${config.width}px`;
1748
+ this.img.style.height = `${config.height}px`;
1749
+ this.el.style.width = `${config.width}px`;
1750
+ this.el.style.height = `${config.height}px`;
1751
+ const preload = new Image();
1752
+ preload.onload = () => {
1753
+ this.loaded = true;
1754
+ };
1755
+ preload.onerror = () => {
1756
+ this.config = null;
1757
+ this.loaded = false;
1758
+ };
1759
+ preload.src = config.src;
1760
+ }
1761
+ }
1762
+ /**
1763
+ * Update the thumbnail to show the frame at the given time.
1764
+ * @param time Time in seconds
1765
+ * @param percent Position as 0-1 fraction (for horizontal positioning)
1766
+ */
1767
+ show(time, percent) {
1768
+ if (!this.config || !this.loaded) {
1769
+ this.el.style.display = "none";
1770
+ return;
1771
+ }
1772
+ const { src, width, height, columns, interval } = this.config;
1773
+ const index = Math.floor(time / interval);
1774
+ const col = index % columns;
1775
+ const row = Math.floor(index / columns);
1776
+ this.img.style.backgroundImage = `url(${src})`;
1777
+ this.img.style.backgroundPosition = `-${col * width}px -${row * height}px`;
1778
+ this.img.style.backgroundSize = `${columns * width}px auto`;
1779
+ this.img.style.width = `${width}px`;
1780
+ this.img.style.height = `${height}px`;
1781
+ this.el.style.left = `${percent * 100}%`;
1782
+ this.el.style.display = "";
1783
+ }
1784
+ hide() {
1785
+ this.el.style.display = "none";
1786
+ }
1787
+ isConfigured() {
1788
+ return this.config !== null;
1789
+ }
1790
+ destroy() {
1791
+ this.el.remove();
1792
+ }
1793
+ };
1387
1794
  var ProgressBar = class {
1388
1795
  constructor(api) {
1389
1796
  this.isDragging = false;
@@ -1423,36 +1830,99 @@ var ProgressBar = class {
1423
1830
  }
1424
1831
  }
1425
1832
  };
1833
+ this.onTouchStart = (e) => {
1834
+ e.preventDefault();
1835
+ const video = getVideo(this.api.container);
1836
+ this.wasPlayingBeforeDrag = video ? !video.paused : false;
1837
+ this.isDragging = true;
1838
+ this.el.classList.add("sp-progress--dragging");
1839
+ this.lastSeekTime = 0;
1840
+ this.seek(e.touches[0].clientX, true);
1841
+ };
1842
+ this.onDocTouchMove = (e) => {
1843
+ if (this.isDragging) {
1844
+ e.preventDefault();
1845
+ this.seek(e.touches[0].clientX);
1846
+ this.updateVisualPosition(e.touches[0].clientX);
1847
+ }
1848
+ };
1849
+ this.onTouchEnd = (e) => {
1850
+ if (this.isDragging) {
1851
+ const clientX = e.changedTouches?.[0]?.clientX;
1852
+ if (clientX !== void 0) {
1853
+ this.seek(clientX, true);
1854
+ }
1855
+ this.isDragging = false;
1856
+ this.el.classList.remove("sp-progress--dragging");
1857
+ if (this.wasPlayingBeforeDrag) {
1858
+ const video = getVideo(this.api.container);
1859
+ if (video && video.paused) {
1860
+ const resumePlayback = () => {
1861
+ video.removeEventListener("seeked", resumePlayback);
1862
+ video.play().catch(() => {
1863
+ });
1864
+ };
1865
+ video.addEventListener("seeked", resumePlayback);
1866
+ }
1867
+ }
1868
+ this.tooltip.style.opacity = "0";
1869
+ this.thumbnailPreview.hide();
1870
+ }
1871
+ };
1426
1872
  this.onMouseMove = (e) => {
1427
1873
  this.updateTooltip(e.clientX);
1428
1874
  };
1429
1875
  this.onMouseLeave = () => {
1430
1876
  if (!this.isDragging) {
1431
1877
  this.tooltip.style.opacity = "0";
1878
+ this.thumbnailPreview.hide();
1432
1879
  }
1433
1880
  };
1434
1881
  this.onKeyDown = (e) => {
1435
1882
  const video = getVideo(this.api.container);
1436
1883
  if (!video) return;
1437
1884
  const step = 5;
1438
- const duration = this.api.getState("duration") || 0;
1439
- switch (e.key) {
1440
- case "ArrowLeft":
1441
- e.preventDefault();
1442
- video.currentTime = Math.max(0, video.currentTime - step);
1443
- break;
1444
- case "ArrowRight":
1445
- e.preventDefault();
1446
- video.currentTime = Math.min(duration, video.currentTime + step);
1447
- break;
1448
- case "Home":
1449
- e.preventDefault();
1450
- video.currentTime = 0;
1451
- break;
1452
- case "End":
1453
- e.preventDefault();
1454
- video.currentTime = duration;
1455
- break;
1885
+ const live = this.api.getState("live");
1886
+ const seekableRange = this.api.getState("seekableRange");
1887
+ if (live && seekableRange) {
1888
+ switch (e.key) {
1889
+ case "ArrowLeft":
1890
+ e.preventDefault();
1891
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - step);
1892
+ break;
1893
+ case "ArrowRight":
1894
+ e.preventDefault();
1895
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + step);
1896
+ break;
1897
+ case "Home":
1898
+ e.preventDefault();
1899
+ video.currentTime = seekableRange.start;
1900
+ break;
1901
+ case "End":
1902
+ e.preventDefault();
1903
+ video.currentTime = seekableRange.end;
1904
+ break;
1905
+ }
1906
+ } else {
1907
+ const duration = this.api.getState("duration") || 0;
1908
+ switch (e.key) {
1909
+ case "ArrowLeft":
1910
+ e.preventDefault();
1911
+ video.currentTime = Math.max(0, video.currentTime - step);
1912
+ break;
1913
+ case "ArrowRight":
1914
+ e.preventDefault();
1915
+ video.currentTime = Math.min(duration, video.currentTime + step);
1916
+ break;
1917
+ case "Home":
1918
+ e.preventDefault();
1919
+ video.currentTime = 0;
1920
+ break;
1921
+ case "End":
1922
+ e.preventDefault();
1923
+ video.currentTime = duration;
1924
+ break;
1925
+ }
1456
1926
  }
1457
1927
  };
1458
1928
  this.api = api;
@@ -1464,10 +1934,12 @@ var ProgressBar = class {
1464
1934
  this.handle = createElement("div", { className: "sp-progress__handle" });
1465
1935
  this.tooltip = createElement("div", { className: "sp-progress__tooltip" });
1466
1936
  this.tooltip.textContent = "0:00";
1937
+ this.thumbnailPreview = new ThumbnailPreview();
1467
1938
  track.appendChild(this.buffered);
1468
1939
  track.appendChild(this.filled);
1469
1940
  track.appendChild(this.handle);
1470
1941
  this.el.appendChild(track);
1942
+ this.el.appendChild(this.thumbnailPreview.getElement());
1471
1943
  this.el.appendChild(this.tooltip);
1472
1944
  this.wrapper.appendChild(this.el);
1473
1945
  this.el.setAttribute("role", "slider");
@@ -1477,9 +1949,13 @@ var ProgressBar = class {
1477
1949
  this.wrapper.addEventListener("mousedown", this.onMouseDown);
1478
1950
  this.wrapper.addEventListener("mousemove", this.onMouseMove);
1479
1951
  this.wrapper.addEventListener("mouseleave", this.onMouseLeave);
1952
+ this.wrapper.addEventListener("touchstart", this.onTouchStart, { passive: false });
1480
1953
  this.el.addEventListener("keydown", this.onKeyDown);
1481
1954
  document.addEventListener("mousemove", this.onDocMouseMove);
1482
1955
  document.addEventListener("mouseup", this.onMouseUp);
1956
+ document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
1957
+ document.addEventListener("touchend", this.onTouchEnd);
1958
+ document.addEventListener("touchcancel", this.onTouchEnd);
1483
1959
  }
1484
1960
  render() {
1485
1961
  return this.wrapper;
@@ -1492,11 +1968,40 @@ var ProgressBar = class {
1492
1968
  hide() {
1493
1969
  this.wrapper.classList.remove("sp-progress-wrapper--visible");
1494
1970
  }
1971
+ /** Set thumbnail sprite configuration */
1972
+ setThumbnails(config) {
1973
+ this.thumbnailPreview.setConfig(config);
1974
+ }
1495
1975
  update() {
1496
1976
  const currentTime = this.api.getState("currentTime") || 0;
1497
1977
  const duration = this.api.getState("duration") || 0;
1498
1978
  const bufferedRanges = this.api.getState("buffered");
1499
- if (duration > 0) {
1979
+ const live = this.api.getState("live");
1980
+ const seekableRange = this.api.getState("seekableRange");
1981
+ const thumbnails = this.api.getState("thumbnails");
1982
+ if (thumbnails && !this.thumbnailPreview.isConfigured()) {
1983
+ this.thumbnailPreview.setConfig(thumbnails);
1984
+ }
1985
+ this.el.classList.toggle("sp-progress--live", !!live);
1986
+ if (live && seekableRange) {
1987
+ const rangeLength = seekableRange.end - seekableRange.start;
1988
+ if (rangeLength > 0) {
1989
+ const progress = (currentTime - seekableRange.start) / rangeLength * 100;
1990
+ this.filled.style.width = `${Math.max(0, Math.min(100, progress))}%`;
1991
+ this.handle.style.left = `${Math.max(0, Math.min(100, progress))}%`;
1992
+ }
1993
+ if (bufferedRanges && bufferedRanges.length > 0) {
1994
+ const rangeLength2 = seekableRange.end - seekableRange.start;
1995
+ if (rangeLength2 > 0) {
1996
+ const bufferedEnd = bufferedRanges.end(bufferedRanges.length - 1);
1997
+ const bufferedPercent = (bufferedEnd - seekableRange.start) / rangeLength2 * 100;
1998
+ this.buffered.style.width = `${Math.max(0, Math.min(100, bufferedPercent))}%`;
1999
+ }
2000
+ }
2001
+ this.el.setAttribute("aria-valuemax", String(Math.floor(seekableRange.end)));
2002
+ this.el.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
2003
+ this.el.setAttribute("aria-valuetext", `${Math.floor(seekableRange.end - currentTime)} seconds behind live`);
2004
+ } else if (duration > 0) {
1500
2005
  const progress = currentTime / duration * 100;
1501
2006
  this.filled.style.width = `${progress}%`;
1502
2007
  this.handle.style.left = `${progress}%`;
@@ -1513,6 +2018,12 @@ var ProgressBar = class {
1513
2018
  getTimeFromPosition(clientX) {
1514
2019
  const rect = this.el.getBoundingClientRect();
1515
2020
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
2021
+ const live = this.api.getState("live");
2022
+ const seekableRange = this.api.getState("seekableRange");
2023
+ if (live && seekableRange) {
2024
+ const rangeLength = seekableRange.end - seekableRange.start;
2025
+ return seekableRange.start + percent * rangeLength;
2026
+ }
1516
2027
  const duration = this.api.getState("duration") || 0;
1517
2028
  return percent * duration;
1518
2029
  }
@@ -1520,8 +2031,18 @@ var ProgressBar = class {
1520
2031
  const rect = this.el.getBoundingClientRect();
1521
2032
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1522
2033
  const time = this.getTimeFromPosition(clientX);
1523
- this.tooltip.textContent = formatTime$1(time);
2034
+ const live = this.api.getState("live");
2035
+ const seekableRange = this.api.getState("seekableRange");
2036
+ if (live && seekableRange) {
2037
+ const behindLive = seekableRange.end - time;
2038
+ this.tooltip.textContent = formatLiveTime(behindLive);
2039
+ } else {
2040
+ this.tooltip.textContent = formatTime$1(time);
2041
+ }
1524
2042
  this.tooltip.style.left = `${percent * 100}%`;
2043
+ if (this.thumbnailPreview.isConfigured()) {
2044
+ this.thumbnailPreview.show(time, percent);
2045
+ }
1525
2046
  }
1526
2047
  updateVisualPosition(clientX) {
1527
2048
  const rect = this.el.getBoundingClientRect();
@@ -1544,8 +2065,13 @@ var ProgressBar = class {
1544
2065
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
1545
2066
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
1546
2067
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
2068
+ this.wrapper.removeEventListener("touchstart", this.onTouchStart);
1547
2069
  document.removeEventListener("mousemove", this.onDocMouseMove);
1548
2070
  document.removeEventListener("mouseup", this.onMouseUp);
2071
+ document.removeEventListener("touchmove", this.onDocTouchMove);
2072
+ document.removeEventListener("touchend", this.onTouchEnd);
2073
+ document.removeEventListener("touchcancel", this.onTouchEnd);
2074
+ this.thumbnailPreview.destroy();
1549
2075
  this.wrapper.remove();
1550
2076
  }
1551
2077
  };
@@ -1594,6 +2120,20 @@ var VolumeControl = class {
1594
2120
  this.onMouseUp = () => {
1595
2121
  this.isDragging = false;
1596
2122
  };
2123
+ this.onTouchStart = (e) => {
2124
+ e.preventDefault();
2125
+ this.isDragging = true;
2126
+ this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));
2127
+ };
2128
+ this.onDocTouchMove = (e) => {
2129
+ if (this.isDragging) {
2130
+ e.preventDefault();
2131
+ this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));
2132
+ }
2133
+ };
2134
+ this.onTouchEnd = () => {
2135
+ this.isDragging = false;
2136
+ };
1597
2137
  this.onKeyDown = (e) => {
1598
2138
  const video = getVideo(this.api.container);
1599
2139
  if (!video) return;
@@ -1633,9 +2173,13 @@ var VolumeControl = class {
1633
2173
  this.el.appendChild(this.btn);
1634
2174
  this.el.appendChild(sliderWrap);
1635
2175
  this.slider.addEventListener("mousedown", this.onMouseDown);
2176
+ this.slider.addEventListener("touchstart", this.onTouchStart, { passive: false });
1636
2177
  this.slider.addEventListener("keydown", this.onKeyDown);
1637
2178
  document.addEventListener("mousemove", this.onDocMouseMove);
1638
2179
  document.addEventListener("mouseup", this.onMouseUp);
2180
+ document.addEventListener("touchmove", this.onDocTouchMove, { passive: false });
2181
+ document.addEventListener("touchend", this.onTouchEnd);
2182
+ document.addEventListener("touchcancel", this.onTouchEnd);
1639
2183
  }
1640
2184
  render() {
1641
2185
  return this.el;
@@ -1680,26 +2224,40 @@ var VolumeControl = class {
1680
2224
  return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1681
2225
  }
1682
2226
  destroy() {
2227
+ this.slider.removeEventListener("mousedown", this.onMouseDown);
2228
+ this.slider.removeEventListener("touchstart", this.onTouchStart);
2229
+ this.slider.removeEventListener("keydown", this.onKeyDown);
1683
2230
  document.removeEventListener("mousemove", this.onDocMouseMove);
1684
2231
  document.removeEventListener("mouseup", this.onMouseUp);
2232
+ document.removeEventListener("touchmove", this.onDocTouchMove);
2233
+ document.removeEventListener("touchend", this.onTouchEnd);
2234
+ document.removeEventListener("touchcancel", this.onTouchEnd);
1685
2235
  this.el.remove();
1686
2236
  }
1687
2237
  };
1688
2238
  var LiveIndicator = class {
1689
2239
  constructor(api) {
1690
- this.api = api;
1691
- this.el = createElement("div", { className: "sp-live" });
1692
- this.el.innerHTML = '<div class="sp-live__dot"></div><span>LIVE</span>';
1693
- this.el.setAttribute("role", "button");
1694
- this.el.setAttribute("aria-label", "Seek to live");
1695
- this.el.setAttribute("tabindex", "0");
1696
- this.el.onclick = () => this.seekToLive();
1697
- this.el.onkeydown = (e) => {
2240
+ this.handleClick = () => {
2241
+ this.seekToLive();
2242
+ };
2243
+ this.handleKeyDown = (e) => {
1698
2244
  if (e.key === "Enter" || e.key === " ") {
1699
2245
  e.preventDefault();
1700
2246
  this.seekToLive();
1701
2247
  }
1702
2248
  };
2249
+ this.api = api;
2250
+ this.el = createElement("div", { className: "sp-live" });
2251
+ this.dot = createElement("div", { className: "sp-live__dot" });
2252
+ this.label = document.createElement("span");
2253
+ this.label.textContent = "LIVE";
2254
+ this.el.appendChild(this.dot);
2255
+ this.el.appendChild(this.label);
2256
+ this.el.setAttribute("role", "button");
2257
+ this.el.setAttribute("aria-label", "Seek to live");
2258
+ this.el.setAttribute("tabindex", "0");
2259
+ this.el.addEventListener("click", this.handleClick);
2260
+ this.el.addEventListener("keydown", this.handleKeyDown);
1703
2261
  }
1704
2262
  render() {
1705
2263
  return this.el;
@@ -1710,8 +2268,12 @@ var LiveIndicator = class {
1710
2268
  this.el.style.display = live ? "" : "none";
1711
2269
  if (liveEdge) {
1712
2270
  this.el.classList.remove("sp-live--behind");
2271
+ this.label.textContent = "LIVE";
2272
+ this.el.setAttribute("aria-label", "At live edge");
1713
2273
  } else {
1714
2274
  this.el.classList.add("sp-live--behind");
2275
+ this.label.textContent = "GO LIVE";
2276
+ this.el.setAttribute("aria-label", "Seek to live");
1715
2277
  }
1716
2278
  }
1717
2279
  seekToLive() {
@@ -1723,6 +2285,8 @@ var LiveIndicator = class {
1723
2285
  }
1724
2286
  }
1725
2287
  destroy() {
2288
+ this.el.removeEventListener("click", this.handleClick);
2289
+ this.el.removeEventListener("keydown", this.handleKeyDown);
1726
2290
  this.el.remove();
1727
2291
  }
1728
2292
  };
@@ -2022,13 +2586,579 @@ var Spacer = class {
2022
2586
  this.el.remove();
2023
2587
  }
2024
2588
  };
2589
+ function getUserMessage(error) {
2590
+ if (!error) return "Something went wrong.";
2591
+ const msg = error.message?.toLowerCase() || "";
2592
+ if (msg.includes("network") || msg.includes("timeout") || msg.includes("fetch") || msg.includes("connection")) {
2593
+ return "Having trouble connecting. Check your internet and try again.";
2594
+ }
2595
+ if (msg.includes("manifest")) {
2596
+ return "Unable to load video. Please try again.";
2597
+ }
2598
+ if (msg.includes("decode") || msg.includes("media") || msg.includes("format") || msg.includes("codec")) {
2599
+ return "This video can't be played right now.";
2600
+ }
2601
+ if (msg.includes("not found") || msg.includes("404") || msg.includes("source") || msg.includes("not supported")) {
2602
+ return "Video not found.";
2603
+ }
2604
+ return "Something went wrong.";
2605
+ }
2606
+ var ErrorOverlay = class {
2607
+ constructor(api) {
2608
+ this.visible = false;
2609
+ this.lastSource = null;
2610
+ this.handleRetry = () => {
2611
+ if (this.retryBtn.disabled) return;
2612
+ this.retryBtn.disabled = true;
2613
+ this.hide();
2614
+ const source = this.api.getState("source");
2615
+ const src = source?.src || this.lastSource;
2616
+ if (src) {
2617
+ this.api.emit("error:retry", { src });
2618
+ const video = this.api.container.querySelector("video");
2619
+ if (video) {
2620
+ video.src = src;
2621
+ video.load();
2622
+ video.play().catch(() => {
2623
+ });
2624
+ }
2625
+ }
2626
+ setTimeout(() => {
2627
+ this.retryBtn.disabled = false;
2628
+ }, 1e3);
2629
+ };
2630
+ this.handleDismiss = () => {
2631
+ this.hide();
2632
+ this.api.emit("error:dismiss", void 0);
2633
+ };
2634
+ this.api = api;
2635
+ const overlay = document.createElement("div");
2636
+ overlay.className = "sp-error-overlay";
2637
+ overlay.setAttribute("role", "alert");
2638
+ overlay.setAttribute("aria-live", "assertive");
2639
+ const content = document.createElement("div");
2640
+ content.className = "sp-error-overlay__content";
2641
+ const iconEl = document.createElement("div");
2642
+ iconEl.className = "sp-error-overlay__icon";
2643
+ iconEl.innerHTML = icons.error;
2644
+ const messageEl = document.createElement("p");
2645
+ messageEl.className = "sp-error-overlay__message";
2646
+ messageEl.textContent = "Something went wrong.";
2647
+ const actions = document.createElement("div");
2648
+ actions.className = "sp-error-overlay__actions";
2649
+ this.retryBtn = document.createElement("button");
2650
+ this.retryBtn.className = "sp-error-overlay__retry";
2651
+ this.retryBtn.setAttribute("type", "button");
2652
+ this.retryBtn.setAttribute("aria-label", "Try again");
2653
+ this.retryBtn.textContent = "Try Again";
2654
+ this.retryBtn.addEventListener("click", this.handleRetry);
2655
+ this.dismissBtn = document.createElement("button");
2656
+ this.dismissBtn.className = "sp-error-overlay__dismiss";
2657
+ this.dismissBtn.setAttribute("type", "button");
2658
+ this.dismissBtn.setAttribute("aria-label", "Go back");
2659
+ this.dismissBtn.textContent = "Go Back";
2660
+ this.dismissBtn.addEventListener("click", this.handleDismiss);
2661
+ actions.appendChild(this.retryBtn);
2662
+ actions.appendChild(this.dismissBtn);
2663
+ content.appendChild(iconEl);
2664
+ content.appendChild(messageEl);
2665
+ content.appendChild(actions);
2666
+ overlay.appendChild(content);
2667
+ this.el = overlay;
2668
+ }
2669
+ render() {
2670
+ return this.el;
2671
+ }
2672
+ /** Show the error overlay with the given error */
2673
+ show(error) {
2674
+ const message = getUserMessage(error);
2675
+ const messageEl = this.el.querySelector(".sp-error-overlay__message");
2676
+ if (messageEl) {
2677
+ messageEl.textContent = message;
2678
+ }
2679
+ const source = this.api.getState("source");
2680
+ if (source?.src) {
2681
+ this.lastSource = source.src;
2682
+ }
2683
+ this.visible = true;
2684
+ this.retryBtn.disabled = false;
2685
+ this.el.classList.add("sp-error-overlay--visible");
2686
+ }
2687
+ /** Hide the error overlay */
2688
+ hide() {
2689
+ this.visible = false;
2690
+ this.el.classList.remove("sp-error-overlay--visible");
2691
+ }
2692
+ isVisible() {
2693
+ return this.visible;
2694
+ }
2695
+ update() {
2696
+ const playbackState = this.api.getState("playbackState");
2697
+ if (this.visible && playbackState !== "error" && playbackState !== "loading") {
2698
+ const playing = this.api.getState("playing");
2699
+ if (playing) {
2700
+ this.hide();
2701
+ }
2702
+ }
2703
+ }
2704
+ destroy() {
2705
+ this.retryBtn.removeEventListener("click", this.handleRetry);
2706
+ this.dismissBtn.removeEventListener("click", this.handleDismiss);
2707
+ this.el.remove();
2708
+ }
2709
+ };
2710
+ var SPEED_OPTIONS = [
2711
+ { label: "0.5x", value: 0.5 },
2712
+ { label: "0.75x", value: 0.75 },
2713
+ { label: "Normal", value: 1 },
2714
+ { label: "1.25x", value: 1.25 },
2715
+ { label: "1.5x", value: 1.5 },
2716
+ { label: "2x", value: 2 }
2717
+ ];
2718
+ var SettingsMenu = class {
2719
+ constructor(api) {
2720
+ this.isOpen = false;
2721
+ this.currentPanel = "main";
2722
+ this.lastQualitiesJson = "";
2723
+ this.api = api;
2724
+ this.el = createElement("div", { className: "sp-settings" });
2725
+ this.btn = createButton("sp-settings__btn", "Settings", icons.settings);
2726
+ this.btn.setAttribute("aria-haspopup", "true");
2727
+ this.btn.setAttribute("aria-expanded", "false");
2728
+ this.btn.addEventListener("click", (e) => {
2729
+ e.stopPropagation();
2730
+ this.toggle();
2731
+ });
2732
+ this.panel = createElement("div", { className: "sp-settings-panel" });
2733
+ this.panel.setAttribute("role", "menu");
2734
+ this.panel.addEventListener("click", (e) => e.stopPropagation());
2735
+ this.el.appendChild(this.btn);
2736
+ this.el.appendChild(this.panel);
2737
+ this.closeHandler = (e) => {
2738
+ if (!this.el.contains(e.target)) {
2739
+ this.close();
2740
+ }
2741
+ };
2742
+ document.addEventListener("click", this.closeHandler);
2743
+ this.keyHandler = (e) => {
2744
+ if (!this.isOpen) return;
2745
+ if (e.key === "Escape") {
2746
+ e.preventDefault();
2747
+ e.stopPropagation();
2748
+ if (this.currentPanel !== "main") {
2749
+ this.showPanel("main");
2750
+ } else {
2751
+ this.close();
2752
+ this.btn.focus();
2753
+ }
2754
+ }
2755
+ };
2756
+ document.addEventListener("keydown", this.keyHandler);
2757
+ }
2758
+ render() {
2759
+ return this.el;
2760
+ }
2761
+ update() {
2762
+ const qualities = this.api.getState("qualities") || [];
2763
+ const qualitiesJson = JSON.stringify(qualities.map((q) => q.id));
2764
+ if (qualitiesJson !== this.lastQualitiesJson) {
2765
+ this.lastQualitiesJson = qualitiesJson;
2766
+ if (this.isOpen && this.currentPanel === "quality") {
2767
+ this.renderQualityPanel();
2768
+ }
2769
+ }
2770
+ if (this.isOpen) {
2771
+ if (this.currentPanel === "quality") {
2772
+ this.updateQualityActiveStates();
2773
+ } else if (this.currentPanel === "speed") {
2774
+ this.updateSpeedActiveStates();
2775
+ } else if (this.currentPanel === "captions") {
2776
+ this.updateCaptionsActiveStates();
2777
+ }
2778
+ }
2779
+ }
2780
+ toggle() {
2781
+ this.isOpen ? this.close() : this.open();
2782
+ }
2783
+ open() {
2784
+ this.isOpen = true;
2785
+ this.currentPanel = "main";
2786
+ this.renderMainPanel();
2787
+ this.panel.classList.add("sp-settings-panel--open");
2788
+ this.btn.setAttribute("aria-expanded", "true");
2789
+ }
2790
+ close() {
2791
+ this.isOpen = false;
2792
+ this.currentPanel = "main";
2793
+ this.panel.classList.remove("sp-settings-panel--open");
2794
+ this.btn.setAttribute("aria-expanded", "false");
2795
+ }
2796
+ showPanel(panel) {
2797
+ this.currentPanel = panel;
2798
+ switch (panel) {
2799
+ case "main":
2800
+ this.renderMainPanel();
2801
+ break;
2802
+ case "quality":
2803
+ this.renderQualityPanel();
2804
+ break;
2805
+ case "speed":
2806
+ this.renderSpeedPanel();
2807
+ break;
2808
+ case "captions":
2809
+ this.renderCaptionsPanel();
2810
+ break;
2811
+ }
2812
+ }
2813
+ renderMainPanel() {
2814
+ this.panel.innerHTML = "";
2815
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--main";
2816
+ const qualities = this.api.getState("qualities") || [];
2817
+ const currentQuality = this.api.getState("currentQuality");
2818
+ const playbackRate = this.api.getState("playbackRate") ?? 1;
2819
+ if (qualities.length > 0) {
2820
+ const qualityRow = this.createMainRow(
2821
+ "Quality",
2822
+ currentQuality?.label || "Auto",
2823
+ () => this.showPanel("quality")
2824
+ );
2825
+ this.panel.appendChild(qualityRow);
2826
+ }
2827
+ const textTracks = this.api.getState("textTracks") || [];
2828
+ if (textTracks.length > 0) {
2829
+ const currentTextTrack = this.api.getState("currentTextTrack");
2830
+ const captionsLabel = currentTextTrack ? currentTextTrack.label : "Off";
2831
+ const captionsRow = this.createMainRow(
2832
+ "Captions",
2833
+ captionsLabel,
2834
+ () => this.showPanel("captions")
2835
+ );
2836
+ this.panel.appendChild(captionsRow);
2837
+ }
2838
+ const speedLabel = playbackRate === 1 ? "Normal" : `${playbackRate}x`;
2839
+ const speedRow = this.createMainRow(
2840
+ "Speed",
2841
+ speedLabel,
2842
+ () => this.showPanel("speed")
2843
+ );
2844
+ this.panel.appendChild(speedRow);
2845
+ }
2846
+ createMainRow(label, value, onClick2) {
2847
+ const row = createElement("div", { className: "sp-settings-panel__row" });
2848
+ row.setAttribute("role", "menuitem");
2849
+ row.setAttribute("tabindex", "0");
2850
+ row.setAttribute("aria-haspopup", "true");
2851
+ const labelEl = createElement("span", { className: "sp-settings-panel__label" });
2852
+ labelEl.textContent = label;
2853
+ const rightSide = createElement("span", { className: "sp-settings-panel__value" });
2854
+ rightSide.textContent = value;
2855
+ const arrow = createElement("span", { className: "sp-settings-panel__arrow" });
2856
+ arrow.innerHTML = icons.chevronDown;
2857
+ rightSide.appendChild(arrow);
2858
+ row.appendChild(labelEl);
2859
+ row.appendChild(rightSide);
2860
+ row.addEventListener("click", (e) => {
2861
+ e.preventDefault();
2862
+ onClick2();
2863
+ });
2864
+ row.addEventListener("keydown", (e) => {
2865
+ if (e.key === "Enter" || e.key === " ") {
2866
+ e.preventDefault();
2867
+ onClick2();
2868
+ }
2869
+ });
2870
+ return row;
2871
+ }
2872
+ renderQualityPanel() {
2873
+ this.panel.innerHTML = "";
2874
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2875
+ const header = this.createSubHeader("Quality");
2876
+ this.panel.appendChild(header);
2877
+ const qualities = this.api.getState("qualities") || [];
2878
+ const currentQuality = this.api.getState("currentQuality");
2879
+ const activeId = currentQuality?.id || "auto";
2880
+ const autoItem = this.createMenuItem("Auto", "auto", activeId === "auto");
2881
+ autoItem.addEventListener("click", (e) => {
2882
+ e.preventDefault();
2883
+ this.selectQuality("auto");
2884
+ });
2885
+ this.panel.appendChild(autoItem);
2886
+ const sorted = [...qualities].sort(
2887
+ (a, b) => b.height - a.height
2888
+ );
2889
+ for (const q of sorted) {
2890
+ if (q.id === "auto") continue;
2891
+ const item = this.createMenuItem(q.label, q.id, q.id === activeId);
2892
+ item.addEventListener("click", (e) => {
2893
+ e.preventDefault();
2894
+ this.selectQuality(q.id);
2895
+ });
2896
+ this.panel.appendChild(item);
2897
+ }
2898
+ }
2899
+ renderSpeedPanel() {
2900
+ this.panel.innerHTML = "";
2901
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2902
+ const header = this.createSubHeader("Speed");
2903
+ this.panel.appendChild(header);
2904
+ const currentRate = this.api.getState("playbackRate") ?? 1;
2905
+ for (const opt of SPEED_OPTIONS) {
2906
+ const isActive = Math.abs(currentRate - opt.value) < 0.01;
2907
+ const item = this.createMenuItem(opt.label, String(opt.value), isActive);
2908
+ item.addEventListener("click", (e) => {
2909
+ e.preventDefault();
2910
+ this.selectSpeed(opt.value);
2911
+ });
2912
+ this.panel.appendChild(item);
2913
+ }
2914
+ }
2915
+ renderCaptionsPanel() {
2916
+ this.panel.innerHTML = "";
2917
+ this.panel.className = "sp-settings-panel sp-settings-panel--open sp-settings-panel--sub";
2918
+ const header = this.createSubHeader("Captions");
2919
+ this.panel.appendChild(header);
2920
+ const textTracks = this.api.getState("textTracks") || [];
2921
+ const currentTextTrack = this.api.getState("currentTextTrack");
2922
+ const activeId = currentTextTrack?.id || "off";
2923
+ const offItem = this.createMenuItem("Off", "off", activeId === "off");
2924
+ offItem.addEventListener("click", (e) => {
2925
+ e.preventDefault();
2926
+ this.selectCaption(null);
2927
+ });
2928
+ this.panel.appendChild(offItem);
2929
+ for (const track of textTracks) {
2930
+ const item = this.createMenuItem(track.label, track.id, track.id === activeId);
2931
+ item.addEventListener("click", (e) => {
2932
+ e.preventDefault();
2933
+ this.selectCaption(track.id);
2934
+ });
2935
+ this.panel.appendChild(item);
2936
+ }
2937
+ }
2938
+ selectCaption(trackId) {
2939
+ this.api.emit("track:text", { trackId });
2940
+ this.close();
2941
+ }
2942
+ updateCaptionsActiveStates() {
2943
+ const currentTextTrack = this.api.getState("currentTextTrack");
2944
+ const activeId = currentTextTrack?.id || "off";
2945
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
2946
+ items.forEach((item) => {
2947
+ const id = item.getAttribute("data-id");
2948
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
2949
+ });
2950
+ }
2951
+ createSubHeader(title) {
2952
+ const header = createElement("div", { className: "sp-settings-panel__header" });
2953
+ header.setAttribute("role", "menuitem");
2954
+ header.setAttribute("tabindex", "0");
2955
+ const backArrow = createElement("span", { className: "sp-settings-panel__back" });
2956
+ backArrow.innerHTML = icons.chevronUp;
2957
+ const label = createElement("span", { className: "sp-settings-panel__header-label" });
2958
+ label.textContent = title;
2959
+ header.appendChild(backArrow);
2960
+ header.appendChild(label);
2961
+ header.addEventListener("click", (e) => {
2962
+ e.preventDefault();
2963
+ this.showPanel("main");
2964
+ });
2965
+ header.addEventListener("keydown", (e) => {
2966
+ if (e.key === "Enter" || e.key === " ") {
2967
+ e.preventDefault();
2968
+ this.showPanel("main");
2969
+ }
2970
+ });
2971
+ return header;
2972
+ }
2973
+ createMenuItem(label, dataId, isActive) {
2974
+ const item = createElement("div", {
2975
+ className: `sp-settings-panel__item${isActive ? " sp-settings-panel__item--active" : ""}`
2976
+ });
2977
+ item.setAttribute("role", "menuitem");
2978
+ item.setAttribute("tabindex", "0");
2979
+ item.setAttribute("data-id", dataId);
2980
+ const labelEl = createElement("span");
2981
+ labelEl.textContent = label;
2982
+ const check = createElement("span", { className: "sp-settings-panel__check" });
2983
+ check.innerHTML = icons.checkmark;
2984
+ item.appendChild(labelEl);
2985
+ item.appendChild(check);
2986
+ item.addEventListener("keydown", (e) => {
2987
+ if (e.key === "Enter" || e.key === " ") {
2988
+ e.preventDefault();
2989
+ item.click();
2990
+ }
2991
+ });
2992
+ return item;
2993
+ }
2994
+ selectQuality(qualityId) {
2995
+ this.api.emit("quality:select", {
2996
+ quality: qualityId,
2997
+ auto: qualityId === "auto"
2998
+ });
2999
+ this.close();
3000
+ }
3001
+ selectSpeed(rate) {
3002
+ this.api.emit("playback:ratechange", { rate });
3003
+ const video = this.api.container.querySelector("video");
3004
+ if (video) {
3005
+ video.playbackRate = rate;
3006
+ }
3007
+ this.close();
3008
+ }
3009
+ updateQualityActiveStates() {
3010
+ const currentQuality = this.api.getState("currentQuality");
3011
+ const activeId = currentQuality?.id || "auto";
3012
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
3013
+ items.forEach((item) => {
3014
+ const id = item.getAttribute("data-id");
3015
+ item.classList.toggle("sp-settings-panel__item--active", id === activeId);
3016
+ });
3017
+ }
3018
+ updateSpeedActiveStates() {
3019
+ const currentRate = this.api.getState("playbackRate") ?? 1;
3020
+ const items = this.panel.querySelectorAll(".sp-settings-panel__item");
3021
+ items.forEach((item) => {
3022
+ const id = item.getAttribute("data-id");
3023
+ const value = parseFloat(id || "1");
3024
+ item.classList.toggle(
3025
+ "sp-settings-panel__item--active",
3026
+ Math.abs(currentRate - value) < 0.01
3027
+ );
3028
+ });
3029
+ }
3030
+ getPanel() {
3031
+ return this.currentPanel;
3032
+ }
3033
+ isMenuOpen() {
3034
+ return this.isOpen;
3035
+ }
3036
+ destroy() {
3037
+ document.removeEventListener("click", this.closeHandler);
3038
+ document.removeEventListener("keydown", this.keyHandler);
3039
+ this.el.remove();
3040
+ }
3041
+ };
3042
+ var DEFAULT_SKIP_SECONDS = 10;
3043
+ var SkipButton = class {
3044
+ constructor(api, direction, seconds = DEFAULT_SKIP_SECONDS) {
3045
+ this.clickHandler = () => {
3046
+ this.skip();
3047
+ };
3048
+ this.api = api;
3049
+ this.direction = direction;
3050
+ this.seconds = seconds;
3051
+ const icon = direction === "backward" ? icons.replay10 : icons.forward10;
3052
+ const label = direction === "backward" ? `Rewind ${seconds} seconds` : `Forward ${seconds} seconds`;
3053
+ this.el = createButton(
3054
+ `sp-skip sp-skip--${direction}`,
3055
+ label,
3056
+ icon
3057
+ );
3058
+ this.el.addEventListener("click", this.clickHandler);
3059
+ }
3060
+ render() {
3061
+ return this.el;
3062
+ }
3063
+ update() {
3064
+ const live = this.api.getState("live");
3065
+ const duration = this.api.getState("duration") ?? 0;
3066
+ const seekableRange = this.api.getState("seekableRange");
3067
+ if (live && !seekableRange) {
3068
+ this.el.style.display = "none";
3069
+ return;
3070
+ }
3071
+ if (live && seekableRange) {
3072
+ this.el.style.display = "";
3073
+ return;
3074
+ }
3075
+ if (duration === 0) {
3076
+ this.el.style.display = "none";
3077
+ return;
3078
+ }
3079
+ this.el.style.display = "";
3080
+ }
3081
+ skip() {
3082
+ const video = getVideo(this.api.container);
3083
+ if (!video) return;
3084
+ const live = this.api.getState("live");
3085
+ const seekableRange = this.api.getState("seekableRange");
3086
+ if (live && seekableRange) {
3087
+ if (this.direction === "backward") {
3088
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - this.seconds);
3089
+ } else {
3090
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + this.seconds);
3091
+ }
3092
+ return;
3093
+ }
3094
+ const duration = video.duration || 0;
3095
+ if (!duration || !isFinite(duration)) return;
3096
+ if (this.direction === "backward") {
3097
+ video.currentTime = Math.max(0, video.currentTime - this.seconds);
3098
+ } else {
3099
+ video.currentTime = Math.min(duration, video.currentTime + this.seconds);
3100
+ }
3101
+ }
3102
+ destroy() {
3103
+ this.el.removeEventListener("click", this.clickHandler);
3104
+ this.el.remove();
3105
+ }
3106
+ };
3107
+ var CaptionsButton = class {
3108
+ constructor(api) {
3109
+ this.clickHandler = () => {
3110
+ this.toggle();
3111
+ };
3112
+ this.api = api;
3113
+ this.el = createButton("sp-captions", "Captions", icons.captionsOff);
3114
+ this.el.addEventListener("click", this.clickHandler);
3115
+ }
3116
+ render() {
3117
+ return this.el;
3118
+ }
3119
+ update() {
3120
+ const textTracks = this.api.getState("textTracks") || [];
3121
+ const currentTrack = this.api.getState("currentTextTrack");
3122
+ if (textTracks.length === 0) {
3123
+ this.el.style.display = "none";
3124
+ return;
3125
+ }
3126
+ this.el.style.display = "";
3127
+ if (currentTrack) {
3128
+ this.el.innerHTML = icons.captions;
3129
+ this.el.setAttribute("aria-label", `Captions: ${currentTrack.label}`);
3130
+ this.el.classList.add("sp-captions--active");
3131
+ } else {
3132
+ this.el.innerHTML = icons.captionsOff;
3133
+ this.el.setAttribute("aria-label", "Captions");
3134
+ this.el.classList.remove("sp-captions--active");
3135
+ }
3136
+ }
3137
+ toggle() {
3138
+ const textTracks = this.api.getState("textTracks") || [];
3139
+ const currentTrack = this.api.getState("currentTextTrack");
3140
+ if (textTracks.length === 0) return;
3141
+ if (currentTrack) {
3142
+ this.api.emit("track:text", { trackId: null });
3143
+ } else {
3144
+ this.api.emit("track:text", { trackId: textTracks[0].id });
3145
+ }
3146
+ }
3147
+ destroy() {
3148
+ this.el.removeEventListener("click", this.clickHandler);
3149
+ this.el.remove();
3150
+ }
3151
+ };
2025
3152
  var DEFAULT_LAYOUT = [
2026
3153
  "play",
3154
+ "skip-backward",
3155
+ "skip-forward",
2027
3156
  "volume",
2028
3157
  "time",
2029
3158
  "live-indicator",
2030
3159
  "spacer",
2031
- "quality",
3160
+ "settings",
3161
+ "captions",
2032
3162
  "chromecast",
2033
3163
  "airplay",
2034
3164
  "pip",
@@ -2041,10 +3171,12 @@ function uiPlugin(config = {}) {
2041
3171
  let gradient = null;
2042
3172
  let progressBar = null;
2043
3173
  let bufferingIndicator = null;
3174
+ let errorOverlay = null;
2044
3175
  let styleEl = null;
2045
3176
  let controls = [];
2046
3177
  let hideTimeout = null;
2047
3178
  let stateUnsubscribe = null;
3179
+ let errorUnsubscribe = null;
2048
3180
  let controlsVisible = true;
2049
3181
  const layout = config.controls || DEFAULT_LAYOUT;
2050
3182
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
@@ -2052,6 +3184,10 @@ function uiPlugin(config = {}) {
2052
3184
  switch (slot) {
2053
3185
  case "play":
2054
3186
  return new PlayButton(api);
3187
+ case "skip-backward":
3188
+ return new SkipButton(api, "backward");
3189
+ case "skip-forward":
3190
+ return new SkipButton(api, "forward");
2055
3191
  case "volume":
2056
3192
  return new VolumeControl(api);
2057
3193
  case "progress":
@@ -2062,6 +3198,10 @@ function uiPlugin(config = {}) {
2062
3198
  return new LiveIndicator(api);
2063
3199
  case "quality":
2064
3200
  return new QualityMenu(api);
3201
+ case "settings":
3202
+ return new SettingsMenu(api);
3203
+ case "captions":
3204
+ return new CaptionsButton(api);
2065
3205
  case "chromecast":
2066
3206
  return new CastButton(api, "chromecast");
2067
3207
  case "airplay":
@@ -2085,6 +3225,7 @@ function uiPlugin(config = {}) {
2085
3225
  const isLoading = playbackState === "loading";
2086
3226
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
2087
3227
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
3228
+ errorOverlay?.update();
2088
3229
  };
2089
3230
  const showControls = () => {
2090
3231
  if (controlsVisible) {
@@ -2123,8 +3264,14 @@ function uiPlugin(config = {}) {
2123
3264
  };
2124
3265
  const handleKeyDown = (e) => {
2125
3266
  if (!api.container.contains(document.activeElement)) return;
3267
+ const activeEl = document.activeElement;
3268
+ if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
3269
+ return;
3270
+ }
2126
3271
  const video = api.container.querySelector("video");
2127
3272
  if (!video) return;
3273
+ const live = api.getState("live");
3274
+ const seekableRange = api.getState("seekableRange");
2128
3275
  switch (e.key) {
2129
3276
  case " ":
2130
3277
  case "k":
@@ -2145,12 +3292,20 @@ function uiPlugin(config = {}) {
2145
3292
  break;
2146
3293
  case "ArrowLeft":
2147
3294
  e.preventDefault();
2148
- video.currentTime = Math.max(0, video.currentTime - 5);
3295
+ if (live && seekableRange) {
3296
+ video.currentTime = Math.max(seekableRange.start, video.currentTime - 5);
3297
+ } else {
3298
+ video.currentTime = Math.max(0, video.currentTime - 5);
3299
+ }
2149
3300
  showControls();
2150
3301
  break;
2151
3302
  case "ArrowRight":
2152
3303
  e.preventDefault();
2153
- video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
3304
+ if (live && seekableRange) {
3305
+ video.currentTime = Math.min(seekableRange.end, video.currentTime + 5);
3306
+ } else {
3307
+ video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
3308
+ }
2154
3309
  showControls();
2155
3310
  break;
2156
3311
  case "ArrowUp":
@@ -2196,6 +3351,14 @@ function uiPlugin(config = {}) {
2196
3351
  bufferingIndicator.innerHTML = icons.spinner;
2197
3352
  bufferingIndicator.setAttribute("aria-hidden", "true");
2198
3353
  container.appendChild(bufferingIndicator);
3354
+ errorOverlay = new ErrorOverlay(api);
3355
+ container.appendChild(errorOverlay.render());
3356
+ errorUnsubscribe = api.on("error", (payload) => {
3357
+ if (payload?.fatal) {
3358
+ const error = api.getState("error") || new Error(payload.message || "Playback error");
3359
+ errorOverlay?.show(error);
3360
+ }
3361
+ });
2199
3362
  progressBar = new ProgressBar(api);
2200
3363
  container.appendChild(progressBar.render());
2201
3364
  if (!isPlaying) {
@@ -2239,6 +3402,8 @@ function uiPlugin(config = {}) {
2239
3402
  }
2240
3403
  stateUnsubscribe?.();
2241
3404
  stateUnsubscribe = null;
3405
+ errorUnsubscribe?.();
3406
+ errorUnsubscribe = null;
2242
3407
  if (api?.container) {
2243
3408
  api.container.removeEventListener("mousemove", handleInteraction);
2244
3409
  api.container.removeEventListener("mouseenter", handleInteraction);
@@ -2252,6 +3417,8 @@ function uiPlugin(config = {}) {
2252
3417
  controls = [];
2253
3418
  progressBar?.destroy();
2254
3419
  progressBar = null;
3420
+ errorOverlay?.destroy();
3421
+ errorOverlay = null;
2255
3422
  controlBar?.remove();
2256
3423
  controlBar = null;
2257
3424
  gradient?.remove();
@@ -4335,6 +5502,8 @@ const DEFAULT_STATE = {
4335
5502
  airplayActive: false,
4336
5503
  chromecastAvailable: false,
4337
5504
  chromecastActive: false,
5505
+ // Thumbnail Preview
5506
+ thumbnails: null,
4338
5507
  // UI State
4339
5508
  interacting: false,
4340
5509
  hovering: false,