@scarlett-player/embed 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/embed.js CHANGED
@@ -144,6 +144,9 @@ function setupVideoEventHandlers(video, api) {
144
144
  video.addEventListener(event, handler);
145
145
  handlers.push({ event, handler });
146
146
  };
147
+ addHandler("play", () => {
148
+ api.setState("paused", false);
149
+ });
147
150
  addHandler("playing", () => {
148
151
  api.setState("playing", true);
149
152
  api.setState("paused", false);
@@ -394,7 +397,9 @@ function createHLSPlugin(config) {
394
397
  const getRetryDelay = (retryCount) => {
395
398
  const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
396
399
  const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
397
- 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;
398
403
  };
399
404
  const emitFatalError = (error, retriesExhausted) => {
400
405
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
@@ -608,6 +613,20 @@ function createHLSPlugin(config) {
608
613
  if (!isNaN(levelIndex) && levelIndex >= 0 && levelIndex < hls.levels.length) {
609
614
  hls.nextLevel = levelIndex;
610
615
  api?.logger.debug(`Quality: queued switch to level ${levelIndex}`);
616
+ const targetLevel = hls.levels[levelIndex];
617
+ if (targetLevel) {
618
+ const label = formatLevel(targetLevel);
619
+ api?.setState("currentQuality", {
620
+ id: `level-${levelIndex}`,
621
+ label: `${label}...`,
622
+ // Ellipsis indicates switching in progress
623
+ width: targetLevel.width,
624
+ height: targetLevel.height,
625
+ bitrate: targetLevel.bitrate,
626
+ active: false
627
+ // Not yet active
628
+ });
629
+ }
611
630
  }
612
631
  }
613
632
  });
@@ -869,7 +888,12 @@ var styles = `
869
888
  transition: height 0.15s ease;
870
889
  }
871
890
 
872
- .sp-progress-wrapper:hover .sp-progress,
891
+ @media (hover: hover) {
892
+ .sp-progress-wrapper:hover .sp-progress {
893
+ height: 5px;
894
+ }
895
+ }
896
+
873
897
  .sp-progress--dragging {
874
898
  height: 5px;
875
899
  }
@@ -915,11 +939,34 @@ var styles = `
915
939
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
916
940
  }
917
941
 
918
- .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
+
919
948
  .sp-progress--dragging .sp-progress__handle {
920
949
  transform: translate(-50%, -50%) scale(1);
921
950
  }
922
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
+
923
970
  /* Progress Tooltip */
924
971
  .sp-progress__tooltip {
925
972
  position: absolute;
@@ -939,8 +986,10 @@ var styles = `
939
986
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
940
987
  }
941
988
 
942
- .sp-progress-wrapper:hover .sp-progress__tooltip {
943
- opacity: 1;
989
+ @media (hover: hover) {
990
+ .sp-progress-wrapper:hover .sp-progress__tooltip {
991
+ opacity: 1;
992
+ }
944
993
  }
945
994
 
946
995
  /* ============================================
@@ -960,9 +1009,11 @@ var styles = `
960
1009
  flex-shrink: 0;
961
1010
  }
962
1011
 
963
- .sp-control:hover {
964
- color: #fff;
965
- 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
+ }
966
1017
  }
967
1018
 
968
1019
  .sp-control:active {
@@ -1031,7 +1082,12 @@ var styles = `
1031
1082
  transition: width 0.2s ease;
1032
1083
  }
1033
1084
 
1034
- .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
+
1035
1091
  .sp-volume:focus-within .sp-volume__slider-wrap {
1036
1092
  width: 64px;
1037
1093
  }
@@ -1074,8 +1130,10 @@ var styles = `
1074
1130
  transition: background 0.15s ease, opacity 0.15s ease;
1075
1131
  }
1076
1132
 
1077
- .sp-live:hover {
1078
- background: rgba(255, 255, 255, 0.1);
1133
+ @media (hover: hover) {
1134
+ .sp-live:hover {
1135
+ background: rgba(255, 255, 255, 0.1);
1136
+ }
1079
1137
  }
1080
1138
 
1081
1139
  .sp-live__dot {
@@ -1094,6 +1152,16 @@ var styles = `
1094
1152
  animation: none;
1095
1153
  }
1096
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
+
1097
1165
  @keyframes sp-pulse {
1098
1166
  0%, 100% { opacity: 1; }
1099
1167
  50% { opacity: 0.4; }
@@ -1174,6 +1242,169 @@ var styles = `
1174
1242
  opacity: 1;
1175
1243
  }
1176
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
+
1177
1408
  /* ============================================
1178
1409
  Cast Button States
1179
1410
  ============================================ */
@@ -1185,6 +1416,122 @@ var styles = `
1185
1416
  opacity: 0.4;
1186
1417
  }
1187
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
+
1188
1535
  /* ============================================
1189
1536
  Buffering Indicator
1190
1537
  ============================================ */
@@ -1232,7 +1579,14 @@ var styles = `
1232
1579
  .sp-control,
1233
1580
  .sp-volume__slider-wrap,
1234
1581
  .sp-quality-menu,
1235
- .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 {
1236
1590
  transition: none;
1237
1591
  }
1238
1592
 
@@ -1267,7 +1621,15 @@ var icons = {
1267
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>`,
1268
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>`,
1269
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>`,
1270
- 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>`
1271
1633
  };
1272
1634
  function createElement(tag, attrs, children) {
1273
1635
  const el = document.createElement(tag);
@@ -1351,12 +1713,11 @@ var PlayButton = class {
1351
1713
  const video = getVideo(this.api.container);
1352
1714
  if (!video) return;
1353
1715
  const ended = this.api.getState("ended");
1354
- const playing = this.api.getState("playing");
1355
1716
  if (ended) {
1356
1717
  video.currentTime = 0;
1357
1718
  video.play().catch(() => {
1358
1719
  });
1359
- } else if (playing) {
1720
+ } else if (!video.paused) {
1360
1721
  video.pause();
1361
1722
  } else {
1362
1723
  video.play().catch(() => {
@@ -1368,6 +1729,68 @@ var PlayButton = class {
1368
1729
  this.el.remove();
1369
1730
  }
1370
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
+ };
1371
1794
  var ProgressBar = class {
1372
1795
  constructor(api) {
1373
1796
  this.isDragging = false;
@@ -1407,36 +1830,99 @@ var ProgressBar = class {
1407
1830
  }
1408
1831
  }
1409
1832
  };
1410
- this.onMouseMove = (e) => {
1411
- this.updateTooltip(e.clientX);
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);
1412
1841
  };
1413
- this.onMouseLeave = () => {
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
+ };
1872
+ this.onMouseMove = (e) => {
1873
+ this.updateTooltip(e.clientX);
1874
+ };
1875
+ this.onMouseLeave = () => {
1414
1876
  if (!this.isDragging) {
1415
1877
  this.tooltip.style.opacity = "0";
1878
+ this.thumbnailPreview.hide();
1416
1879
  }
1417
1880
  };
1418
1881
  this.onKeyDown = (e) => {
1419
1882
  const video = getVideo(this.api.container);
1420
1883
  if (!video) return;
1421
1884
  const step = 5;
1422
- const duration = this.api.getState("duration") || 0;
1423
- switch (e.key) {
1424
- case "ArrowLeft":
1425
- e.preventDefault();
1426
- video.currentTime = Math.max(0, video.currentTime - step);
1427
- break;
1428
- case "ArrowRight":
1429
- e.preventDefault();
1430
- video.currentTime = Math.min(duration, video.currentTime + step);
1431
- break;
1432
- case "Home":
1433
- e.preventDefault();
1434
- video.currentTime = 0;
1435
- break;
1436
- case "End":
1437
- e.preventDefault();
1438
- video.currentTime = duration;
1439
- 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
+ }
1440
1926
  }
1441
1927
  };
1442
1928
  this.api = api;
@@ -1448,10 +1934,12 @@ var ProgressBar = class {
1448
1934
  this.handle = createElement("div", { className: "sp-progress__handle" });
1449
1935
  this.tooltip = createElement("div", { className: "sp-progress__tooltip" });
1450
1936
  this.tooltip.textContent = "0:00";
1937
+ this.thumbnailPreview = new ThumbnailPreview();
1451
1938
  track.appendChild(this.buffered);
1452
1939
  track.appendChild(this.filled);
1453
1940
  track.appendChild(this.handle);
1454
1941
  this.el.appendChild(track);
1942
+ this.el.appendChild(this.thumbnailPreview.getElement());
1455
1943
  this.el.appendChild(this.tooltip);
1456
1944
  this.wrapper.appendChild(this.el);
1457
1945
  this.el.setAttribute("role", "slider");
@@ -1461,9 +1949,13 @@ var ProgressBar = class {
1461
1949
  this.wrapper.addEventListener("mousedown", this.onMouseDown);
1462
1950
  this.wrapper.addEventListener("mousemove", this.onMouseMove);
1463
1951
  this.wrapper.addEventListener("mouseleave", this.onMouseLeave);
1952
+ this.wrapper.addEventListener("touchstart", this.onTouchStart, { passive: false });
1464
1953
  this.el.addEventListener("keydown", this.onKeyDown);
1465
1954
  document.addEventListener("mousemove", this.onDocMouseMove);
1466
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);
1467
1959
  }
1468
1960
  render() {
1469
1961
  return this.wrapper;
@@ -1476,11 +1968,40 @@ var ProgressBar = class {
1476
1968
  hide() {
1477
1969
  this.wrapper.classList.remove("sp-progress-wrapper--visible");
1478
1970
  }
1971
+ /** Set thumbnail sprite configuration */
1972
+ setThumbnails(config) {
1973
+ this.thumbnailPreview.setConfig(config);
1974
+ }
1479
1975
  update() {
1480
1976
  const currentTime = this.api.getState("currentTime") || 0;
1481
1977
  const duration = this.api.getState("duration") || 0;
1482
1978
  const bufferedRanges = this.api.getState("buffered");
1483
- 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) {
1484
2005
  const progress = currentTime / duration * 100;
1485
2006
  this.filled.style.width = `${progress}%`;
1486
2007
  this.handle.style.left = `${progress}%`;
@@ -1497,6 +2018,12 @@ var ProgressBar = class {
1497
2018
  getTimeFromPosition(clientX) {
1498
2019
  const rect = this.el.getBoundingClientRect();
1499
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
+ }
1500
2027
  const duration = this.api.getState("duration") || 0;
1501
2028
  return percent * duration;
1502
2029
  }
@@ -1504,8 +2031,18 @@ var ProgressBar = class {
1504
2031
  const rect = this.el.getBoundingClientRect();
1505
2032
  const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1506
2033
  const time = this.getTimeFromPosition(clientX);
1507
- 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
+ }
1508
2042
  this.tooltip.style.left = `${percent * 100}%`;
2043
+ if (this.thumbnailPreview.isConfigured()) {
2044
+ this.thumbnailPreview.show(time, percent);
2045
+ }
1509
2046
  }
1510
2047
  updateVisualPosition(clientX) {
1511
2048
  const rect = this.el.getBoundingClientRect();
@@ -1528,8 +2065,13 @@ var ProgressBar = class {
1528
2065
  this.wrapper.removeEventListener("mousedown", this.onMouseDown);
1529
2066
  this.wrapper.removeEventListener("mousemove", this.onMouseMove);
1530
2067
  this.wrapper.removeEventListener("mouseleave", this.onMouseLeave);
2068
+ this.wrapper.removeEventListener("touchstart", this.onTouchStart);
1531
2069
  document.removeEventListener("mousemove", this.onDocMouseMove);
1532
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();
1533
2075
  this.wrapper.remove();
1534
2076
  }
1535
2077
  };
@@ -1578,6 +2120,20 @@ var VolumeControl = class {
1578
2120
  this.onMouseUp = () => {
1579
2121
  this.isDragging = false;
1580
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
+ };
1581
2137
  this.onKeyDown = (e) => {
1582
2138
  const video = getVideo(this.api.container);
1583
2139
  if (!video) return;
@@ -1617,9 +2173,13 @@ var VolumeControl = class {
1617
2173
  this.el.appendChild(this.btn);
1618
2174
  this.el.appendChild(sliderWrap);
1619
2175
  this.slider.addEventListener("mousedown", this.onMouseDown);
2176
+ this.slider.addEventListener("touchstart", this.onTouchStart, { passive: false });
1620
2177
  this.slider.addEventListener("keydown", this.onKeyDown);
1621
2178
  document.addEventListener("mousemove", this.onDocMouseMove);
1622
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);
1623
2183
  }
1624
2184
  render() {
1625
2185
  return this.el;
@@ -1664,26 +2224,40 @@ var VolumeControl = class {
1664
2224
  return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1665
2225
  }
1666
2226
  destroy() {
2227
+ this.slider.removeEventListener("mousedown", this.onMouseDown);
2228
+ this.slider.removeEventListener("touchstart", this.onTouchStart);
2229
+ this.slider.removeEventListener("keydown", this.onKeyDown);
1667
2230
  document.removeEventListener("mousemove", this.onDocMouseMove);
1668
2231
  document.removeEventListener("mouseup", this.onMouseUp);
2232
+ document.removeEventListener("touchmove", this.onDocTouchMove);
2233
+ document.removeEventListener("touchend", this.onTouchEnd);
2234
+ document.removeEventListener("touchcancel", this.onTouchEnd);
1669
2235
  this.el.remove();
1670
2236
  }
1671
2237
  };
1672
2238
  var LiveIndicator = class {
1673
2239
  constructor(api) {
1674
- this.api = api;
1675
- this.el = createElement("div", { className: "sp-live" });
1676
- this.el.innerHTML = '<div class="sp-live__dot"></div><span>LIVE</span>';
1677
- this.el.setAttribute("role", "button");
1678
- this.el.setAttribute("aria-label", "Seek to live");
1679
- this.el.setAttribute("tabindex", "0");
1680
- this.el.onclick = () => this.seekToLive();
1681
- this.el.onkeydown = (e) => {
2240
+ this.handleClick = () => {
2241
+ this.seekToLive();
2242
+ };
2243
+ this.handleKeyDown = (e) => {
1682
2244
  if (e.key === "Enter" || e.key === " ") {
1683
2245
  e.preventDefault();
1684
2246
  this.seekToLive();
1685
2247
  }
1686
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);
1687
2261
  }
1688
2262
  render() {
1689
2263
  return this.el;
@@ -1694,8 +2268,12 @@ var LiveIndicator = class {
1694
2268
  this.el.style.display = live ? "" : "none";
1695
2269
  if (liveEdge) {
1696
2270
  this.el.classList.remove("sp-live--behind");
2271
+ this.label.textContent = "LIVE";
2272
+ this.el.setAttribute("aria-label", "At live edge");
1697
2273
  } else {
1698
2274
  this.el.classList.add("sp-live--behind");
2275
+ this.label.textContent = "GO LIVE";
2276
+ this.el.setAttribute("aria-label", "Seek to live");
1699
2277
  }
1700
2278
  }
1701
2279
  seekToLive() {
@@ -1707,6 +2285,8 @@ var LiveIndicator = class {
1707
2285
  }
1708
2286
  }
1709
2287
  destroy() {
2288
+ this.el.removeEventListener("click", this.handleClick);
2289
+ this.el.removeEventListener("keydown", this.handleKeyDown);
1710
2290
  this.el.remove();
1711
2291
  }
1712
2292
  };
@@ -2006,13 +2586,579 @@ var Spacer = class {
2006
2586
  this.el.remove();
2007
2587
  }
2008
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
+ };
2009
3152
  var DEFAULT_LAYOUT = [
2010
3153
  "play",
3154
+ "skip-backward",
3155
+ "skip-forward",
2011
3156
  "volume",
2012
3157
  "time",
2013
3158
  "live-indicator",
2014
3159
  "spacer",
2015
- "quality",
3160
+ "settings",
3161
+ "captions",
2016
3162
  "chromecast",
2017
3163
  "airplay",
2018
3164
  "pip",
@@ -2025,10 +3171,12 @@ function uiPlugin(config = {}) {
2025
3171
  let gradient = null;
2026
3172
  let progressBar = null;
2027
3173
  let bufferingIndicator = null;
3174
+ let errorOverlay = null;
2028
3175
  let styleEl = null;
2029
3176
  let controls = [];
2030
3177
  let hideTimeout = null;
2031
3178
  let stateUnsubscribe = null;
3179
+ let errorUnsubscribe = null;
2032
3180
  let controlsVisible = true;
2033
3181
  const layout = config.controls || DEFAULT_LAYOUT;
2034
3182
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
@@ -2036,6 +3184,10 @@ function uiPlugin(config = {}) {
2036
3184
  switch (slot) {
2037
3185
  case "play":
2038
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");
2039
3191
  case "volume":
2040
3192
  return new VolumeControl(api);
2041
3193
  case "progress":
@@ -2046,6 +3198,10 @@ function uiPlugin(config = {}) {
2046
3198
  return new LiveIndicator(api);
2047
3199
  case "quality":
2048
3200
  return new QualityMenu(api);
3201
+ case "settings":
3202
+ return new SettingsMenu(api);
3203
+ case "captions":
3204
+ return new CaptionsButton(api);
2049
3205
  case "chromecast":
2050
3206
  return new CastButton(api, "chromecast");
2051
3207
  case "airplay":
@@ -2069,6 +3225,7 @@ function uiPlugin(config = {}) {
2069
3225
  const isLoading = playbackState === "loading";
2070
3226
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
2071
3227
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
3228
+ errorOverlay?.update();
2072
3229
  };
2073
3230
  const showControls = () => {
2074
3231
  if (controlsVisible) {
@@ -2107,8 +3264,14 @@ function uiPlugin(config = {}) {
2107
3264
  };
2108
3265
  const handleKeyDown = (e) => {
2109
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
+ }
2110
3271
  const video = api.container.querySelector("video");
2111
3272
  if (!video) return;
3273
+ const live = api.getState("live");
3274
+ const seekableRange = api.getState("seekableRange");
2112
3275
  switch (e.key) {
2113
3276
  case " ":
2114
3277
  case "k":
@@ -2129,12 +3292,20 @@ function uiPlugin(config = {}) {
2129
3292
  break;
2130
3293
  case "ArrowLeft":
2131
3294
  e.preventDefault();
2132
- 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
+ }
2133
3300
  showControls();
2134
3301
  break;
2135
3302
  case "ArrowRight":
2136
3303
  e.preventDefault();
2137
- 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
+ }
2138
3309
  showControls();
2139
3310
  break;
2140
3311
  case "ArrowUp":
@@ -2171,19 +3342,30 @@ function uiPlugin(config = {}) {
2171
3342
  if (containerStyle.position === "static") {
2172
3343
  container.style.position = "relative";
2173
3344
  }
3345
+ const isPlaying = api.getState("playing");
2174
3346
  gradient = document.createElement("div");
2175
- gradient.className = "sp-gradient sp-gradient--visible";
3347
+ gradient.className = isPlaying ? "sp-gradient" : "sp-gradient sp-gradient--visible";
2176
3348
  container.appendChild(gradient);
2177
3349
  bufferingIndicator = document.createElement("div");
2178
3350
  bufferingIndicator.className = "sp-buffering";
2179
3351
  bufferingIndicator.innerHTML = icons.spinner;
2180
3352
  bufferingIndicator.setAttribute("aria-hidden", "true");
2181
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
+ });
2182
3362
  progressBar = new ProgressBar(api);
2183
3363
  container.appendChild(progressBar.render());
2184
- progressBar.show();
3364
+ if (!isPlaying) {
3365
+ progressBar.show();
3366
+ }
2185
3367
  controlBar = document.createElement("div");
2186
- controlBar.className = "sp-controls sp-controls--visible";
3368
+ controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
2187
3369
  controlBar.setAttribute("role", "toolbar");
2188
3370
  controlBar.setAttribute("aria-label", "Video controls");
2189
3371
  for (const slot of layout) {
@@ -2206,6 +3388,11 @@ function uiPlugin(config = {}) {
2206
3388
  if (!container.hasAttribute("tabindex")) {
2207
3389
  container.setAttribute("tabindex", "0");
2208
3390
  }
3391
+ controlsVisible = !isPlaying;
3392
+ api.setState("controlsVisible", controlsVisible);
3393
+ if (isPlaying) {
3394
+ resetHideTimer();
3395
+ }
2209
3396
  api.logger.debug("UI controls plugin initialized");
2210
3397
  },
2211
3398
  async destroy() {
@@ -2215,6 +3402,8 @@ function uiPlugin(config = {}) {
2215
3402
  }
2216
3403
  stateUnsubscribe?.();
2217
3404
  stateUnsubscribe = null;
3405
+ errorUnsubscribe?.();
3406
+ errorUnsubscribe = null;
2218
3407
  if (api?.container) {
2219
3408
  api.container.removeEventListener("mousemove", handleInteraction);
2220
3409
  api.container.removeEventListener("mouseenter", handleInteraction);
@@ -2228,6 +3417,8 @@ function uiPlugin(config = {}) {
2228
3417
  controls = [];
2229
3418
  progressBar?.destroy();
2230
3419
  progressBar = null;
3420
+ errorOverlay?.destroy();
3421
+ errorOverlay = null;
2231
3422
  controlBar?.remove();
2232
3423
  controlBar = null;
2233
3424
  gradient?.remove();
@@ -4311,6 +5502,8 @@ const DEFAULT_STATE = {
4311
5502
  airplayActive: false,
4312
5503
  chromecastAvailable: false,
4313
5504
  chromecastActive: false,
5505
+ // Thumbnail Preview
5506
+ thumbnails: null,
4314
5507
  // UI State
4315
5508
  interacting: false,
4316
5509
  hovering: false,