@scarlett-player/ui 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,84 @@
1
+ // src/index.ts
2
+ import { enterFullscreen as enterFullscreen2, exitFullscreen as exitFullscreen2, isFullscreen as isFullscreen2 } from "@scarlett-player/core";
3
+
4
+ // src/fit.ts
5
+ var UNKNOWN_RANK = 3;
6
+ var DEFAULT_PRIORITY = {
7
+ "bandwidth-indicator": { rank: 0, exit: "hide" },
8
+ "skip-backward": { rank: 1, exit: "overflow" },
9
+ "skip-forward": { rank: 1, exit: "overflow" },
10
+ pip: { rank: 2, exit: "overflow" },
11
+ chromecast: { rank: 4, exit: "overflow" },
12
+ airplay: { rank: 4, exit: "overflow" },
13
+ volume: { rank: 5, exit: "overflow" },
14
+ captions: { rank: 6, exit: "overflow" },
15
+ quality: { rank: 6, exit: "hide" },
16
+ time: { rank: 7, exit: "hide" },
17
+ play: { rank: "never", exit: "overflow" },
18
+ "live-indicator": { rank: "never", exit: "overflow" },
19
+ settings: { rank: "never", exit: "overflow" },
20
+ fullscreen: { rank: "never", exit: "overflow" },
21
+ spacer: { rank: "never", exit: "overflow" }
22
+ };
23
+ function resolveFitItems(layout, priority) {
24
+ return layout.map((id) => {
25
+ const rule = DEFAULT_PRIORITY[id];
26
+ const rank = priority?.[id] ?? rule?.rank ?? UNKNOWN_RANK;
27
+ return { id, rank, exit: rule?.exit ?? "overflow" };
28
+ });
29
+ }
30
+ function assertFitLayout(layout, priority) {
31
+ if (!layout.includes("quality") || layout.includes("settings")) {
32
+ return;
33
+ }
34
+ const [quality] = resolveFitItems(["quality"], priority);
35
+ if (quality.rank === "never") {
36
+ return;
37
+ }
38
+ throw new Error(
39
+ `uiPlugin: a layout with "quality" needs "settings" as well. The quality control hides when the bar does not fit, and the settings menu is where its Quality row lives. Add "settings" to controls, pin quality with priority: { quality: 'never' }, or set responsive: false.`
40
+ );
41
+ }
42
+ function needed(widths, gap, overflowButtonWidth, trayUsed) {
43
+ const content = widths.reduce((sum, width) => sum + width, 0);
44
+ const gaps = gap * Math.max(0, widths.length - 1);
45
+ const tray = trayUsed ? overflowButtonWidth + gap : 0;
46
+ return content + gaps + tray;
47
+ }
48
+ function planFit(items, available, gap, overflowButtonWidth) {
49
+ const ids = items.map((item) => item.id);
50
+ if (available <= 0) {
51
+ return { inBar: ids, overflow: [], hidden: [] };
52
+ }
53
+ const counted = items.filter((item) => item.visible && item.width > 0);
54
+ const remaining = [...counted];
55
+ const overflow = /* @__PURE__ */ new Set();
56
+ const hidden = /* @__PURE__ */ new Set();
57
+ while (needed(
58
+ remaining.map((item) => item.width),
59
+ gap,
60
+ overflowButtonWidth,
61
+ overflow.size > 0
62
+ ) > available) {
63
+ let victim = -1;
64
+ for (let i = 0; i < remaining.length; i++) {
65
+ const candidate = remaining[i];
66
+ if (candidate.rank === "never") continue;
67
+ if (victim === -1 || candidate.rank <= remaining[victim].rank) {
68
+ victim = i;
69
+ }
70
+ }
71
+ if (victim === -1) break;
72
+ const [removed] = remaining.splice(victim, 1);
73
+ (removed.exit === "hide" ? hidden : overflow).add(removed.id);
74
+ }
75
+ return {
76
+ inBar: ids.filter((id) => !overflow.has(id) && !hidden.has(id)),
77
+ overflow: ids.filter((id) => overflow.has(id)),
78
+ hidden: ids.filter((id) => hidden.has(id))
79
+ };
80
+ }
81
+
1
82
  // src/styles.ts
2
83
  var styles = `
3
84
  /* ============================================
@@ -59,7 +140,17 @@ var styles = `
59
140
  display: flex;
60
141
  align-items: center;
61
142
  padding: 0 12px 12px;
143
+ /* Composed through a variable so the fullscreen rule below can add the
144
+ device's own inset without restating the 12px. */
145
+ padding-bottom: calc(12px + var(--sp-inset-bottom, 0px));
62
146
  gap: 4px;
147
+ /* Declared on the bar because the fit needs the same number the volume rules
148
+ below use: the slider expands mid-interaction and the plugin reserves the
149
+ room in advance (see interactionReserve() in index.ts, which reads this
150
+ property off this element at init). One declaration, so the stylesheet and
151
+ the arithmetic cannot drift. Both the bar and the overflow tray are inside
152
+ .sp-controls, so a volume control inherits it wherever the fit put it. */
153
+ --sp-volume-slider-width: 64px;
63
154
  opacity: 0;
64
155
  transform: translateY(4px);
65
156
  transition: opacity 0.25s ease, transform 0.25s ease;
@@ -77,12 +168,37 @@ var styles = `
77
168
  pointer-events: none;
78
169
  }
79
170
 
171
+ /* ============================================
172
+ Safe Area (fullscreen only)
173
+
174
+ Scoped to :fullscreen on purpose. Applied unconditionally, the inset would
175
+ push an inline player's controls up on any page whose viewport meta says
176
+ viewport-fit=cover, where there is no notch or home indicator over the
177
+ player at all. Both the bar and the progress wrapper are direct children of
178
+ the container, which is the element that goes fullscreen.
179
+
180
+ The :-webkit-full-screen twin is a separate rule because an unknown
181
+ pseudo-class anywhere in a selector list invalidates the whole rule.
182
+
183
+ Nothing is needed in packages/embed/iframe.html: viewport-fit has no effect
184
+ inside an iframe.
185
+ ============================================ */
186
+ :fullscreen > .sp-controls,
187
+ :fullscreen > .sp-progress-wrapper {
188
+ --sp-inset-bottom: env(safe-area-inset-bottom, 0px);
189
+ }
190
+
191
+ :-webkit-full-screen > .sp-controls,
192
+ :-webkit-full-screen > .sp-progress-wrapper {
193
+ --sp-inset-bottom: env(safe-area-inset-bottom, 0px);
194
+ }
195
+
80
196
  /* ============================================
81
197
  Progress Bar (Above Controls)
82
198
  ============================================ */
83
199
  .sp-progress-wrapper {
84
200
  position: absolute;
85
- bottom: 48px;
201
+ bottom: calc(48px + var(--sp-inset-bottom, 0px));
86
202
  left: 12px;
87
203
  right: 12px;
88
204
  height: 20px;
@@ -98,6 +214,31 @@ var styles = `
98
214
  opacity: 1;
99
215
  }
100
216
 
217
+ /* Touch: a 20px wrapper is not a 20px target. The control bar is a later
218
+ sibling at the same z-index and spans 0..56px from the bottom, so it wins
219
+ hit-testing in the 48..56 overlap and the exclusive region for scrubbing is
220
+ 12px. The wrapper grows UPWARD to 44px (48..92) because growing downward
221
+ would be swallowed by the bar; the 3px bar itself stays exactly where it was
222
+ (centred 8.5px above the wrapper's bottom edge, which is what
223
+ align-items: center gave it inside 20px). The handle and tooltip
224
+ enlargements are gated behind (hover: hover) and never match a finger, but
225
+ .sp-progress--dragging is not, so the handle still appears mid-drag.
226
+
227
+ any-pointer, not pointer: (pointer: coarse) describes the PRIMARY pointer
228
+ only, so a hybrid laptop with a mouse and a touchscreen reports fine and kept
229
+ the 12px exclusive region under a finger. (any-pointer: coarse) is true
230
+ whenever a coarse pointer is available at all, which is the population that
231
+ needs the target. The cost on such a machine is 24px of extra hit area for
232
+ the mouse, over the player's own bottom edge. */
233
+ @media (any-pointer: coarse) {
234
+ .sp-progress-wrapper {
235
+ height: 44px;
236
+ align-items: flex-end;
237
+ padding-bottom: 8.5px;
238
+ box-sizing: border-box;
239
+ }
240
+ }
241
+
101
242
  .sp-progress {
102
243
  position: relative;
103
244
  width: 100%;
@@ -305,6 +446,61 @@ var styles = `
305
446
  min-width: 0;
306
447
  }
307
448
 
449
+ /* ============================================
450
+ Overflow Tray
451
+
452
+ The wrapper is deliberately unpositioned: the strip is absolutely
453
+ positioned against .sp-controls (the nearest positioned ancestor), so it
454
+ spans the bar's width and sits directly above it instead of hanging off a
455
+ 44px button.
456
+
457
+ The strip wraps horizontally and keeps overflow visible. A vertical menu of
458
+ 44px rows would be taller than a portrait phone player (211px at 375px wide,
459
+ measured 2026-09-05) and a scrolling one would clip the popovers registered
460
+ controls own.
461
+ ============================================ */
462
+ .sp-overflow {
463
+ display: flex;
464
+ align-items: center;
465
+ flex-shrink: 0;
466
+ }
467
+
468
+ .sp-overflow-tray {
469
+ position: absolute;
470
+ bottom: 100%;
471
+ left: 0;
472
+ right: 0;
473
+ display: flex;
474
+ flex-wrap: wrap;
475
+ justify-content: flex-end;
476
+ gap: 4px;
477
+ padding: 8px 12px;
478
+ background: rgba(20, 20, 20, 0.95);
479
+ backdrop-filter: blur(8px);
480
+ -webkit-backdrop-filter: blur(8px);
481
+ border-radius: 8px;
482
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
483
+ overflow: visible;
484
+ opacity: 0;
485
+ visibility: hidden;
486
+ transform: translateY(8px);
487
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
488
+ z-index: 20;
489
+ }
490
+
491
+ .sp-overflow-tray--open {
492
+ opacity: 1;
493
+ visibility: visible;
494
+ transform: translateY(0);
495
+ }
496
+
497
+ /* Beats a control's own inline style.display = '' on its next update(), so a
498
+ control the fit took off screen stays off screen until the fit says
499
+ otherwise. */
500
+ .sp-control--collapsed {
501
+ display: none !important;
502
+ }
503
+
308
504
  /* ============================================
309
505
  Time Display
310
506
  ============================================ */
@@ -332,14 +528,18 @@ var styles = `
332
528
  transition: width 0.2s ease;
333
529
  }
334
530
 
531
+ /* Both widths come from --sp-volume-slider-width on .sp-controls, which is also
532
+ what the fit reserves for this control. focus-within is deliberately not
533
+ gated on hover: a tap on the mute button focuses it, which is how the slider
534
+ opens on a phone. */
335
535
  @media (hover: hover) {
336
536
  .sp-volume:hover .sp-volume__slider-wrap {
337
- width: 64px;
537
+ width: var(--sp-volume-slider-width);
338
538
  }
339
539
  }
340
540
 
341
541
  .sp-volume:focus-within .sp-volume__slider-wrap {
342
- width: 64px;
542
+ width: var(--sp-volume-slider-width);
343
543
  }
344
544
 
345
545
  .sp-volume__slider {
@@ -440,6 +640,14 @@ var styles = `
440
640
  position: absolute;
441
641
  bottom: calc(100% + 8px);
442
642
  right: 0;
643
+ /* Bounded to the player, see .sp-settings-panel. border-box because the
644
+ bound is a content-box height by default and this menu adds 8px of padding
645
+ top and bottom: at the 139px bound a 211px player gives, it rendered 155px
646
+ and the host clipped the last 16px of it. */
647
+ box-sizing: border-box;
648
+ max-height: var(--sp-menu-max-height, none);
649
+ overflow-y: auto;
650
+ -webkit-overflow-scrolling: touch;
443
651
  background: rgba(20, 20, 20, 0.95);
444
652
  backdrop-filter: blur(8px);
445
653
  -webkit-backdrop-filter: blur(8px);
@@ -508,6 +716,29 @@ var styles = `
508
716
  position: absolute;
509
717
  bottom: calc(100% + 8px);
510
718
  right: 0;
719
+ /* Bounded to the room above the control bar, written by the UI plugin's
720
+ ResizeObserver as max(120px, container height - the bar's measured height
721
+ - 16px). The bar is measured rather than assumed because its
722
+ padding-bottom carries the safe-area inset in fullscreen, which moves the
723
+ anchor these menus hang from. The Speed sub-panel is 253px (a
724
+ 37px header plus six 36px rows) against a 211px portrait phone player, so
725
+ without this the host's overflow: hidden cuts off the Back header and the
726
+ first three speeds and playback speed is unreachable (measured at 375x211
727
+ on 2026-09-05). With the variable unset the panel behaves exactly as it
728
+ did before.
729
+
730
+ Not applied to .sp-overflow-tray: that one has to keep overflow visible so
731
+ the popovers its adopted controls own are not clipped.
732
+
733
+ border-box because max-height bounds the content box: .sp-settings-panel--main
734
+ is this same element with 4px of padding top and bottom, so wherever the
735
+ bound binds the main menu, it rendered 8px past it and the host clipped the
736
+ difference. The --sub views set padding: 0 and were already exact, which is
737
+ why the browser harness's speed-panel check could not see this. */
738
+ box-sizing: border-box;
739
+ max-height: var(--sp-menu-max-height, none);
740
+ overflow-y: auto;
741
+ -webkit-overflow-scrolling: touch;
511
742
  background: rgba(20, 20, 20, 0.95);
512
743
  backdrop-filter: blur(8px);
513
744
  -webkit-backdrop-filter: blur(8px);
@@ -519,7 +750,6 @@ var styles = `
519
750
  transform: translateY(8px);
520
751
  transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
521
752
  z-index: 20;
522
- overflow: hidden;
523
753
  }
524
754
 
525
755
  .sp-settings-panel--open {
@@ -666,6 +896,70 @@ var styles = `
666
896
  opacity: 0.4;
667
897
  }
668
898
 
899
+ /* ============================================
900
+ Big Play Button
901
+
902
+ z-index 12 puts it above the gradient (5) and above the gestures plugin's
903
+ tap surface (6), so a tap lands on the button and starts playback instead
904
+ of being read as a tap-to-toggle-controls gesture - exactly how the control
905
+ bar's play button (10) already behaves. It stays below the spinner (15) and
906
+ the error overlay (25), both of which own the middle of the picture when
907
+ they are up.
908
+
909
+ Hidden with visibility, not opacity alone, so it takes no pointer events
910
+ while it is away.
911
+ ============================================ */
912
+ .sp-big-play {
913
+ position: absolute;
914
+ top: 50%;
915
+ left: 50%;
916
+ transform: translate(-50%, -50%);
917
+ z-index: 12;
918
+ display: flex;
919
+ align-items: center;
920
+ justify-content: center;
921
+ /* Comfortably past the 44px minimum touch target the control bar uses. */
922
+ width: 72px;
923
+ height: 72px;
924
+ padding: 0;
925
+ border: none;
926
+ border-radius: 50%;
927
+ background: var(--sp-accent, #e50914);
928
+ color: #fff;
929
+ cursor: pointer;
930
+ opacity: 0;
931
+ visibility: hidden;
932
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
933
+ transition: opacity 0.2s ease, visibility 0.2s, transform 0.15s ease,
934
+ background 0.15s ease;
935
+ }
936
+
937
+ .sp-big-play--visible {
938
+ opacity: 1;
939
+ visibility: visible;
940
+ }
941
+
942
+ .sp-big-play svg {
943
+ width: 36px;
944
+ height: 36px;
945
+ fill: currentColor;
946
+ /* Optical centring: the play triangle's mass sits left of the glyph box. */
947
+ margin-left: 3px;
948
+ }
949
+
950
+ .sp-big-play:hover {
951
+ transform: translate(-50%, -50%) scale(1.06);
952
+ }
953
+
954
+ .sp-big-play:active {
955
+ transform: translate(-50%, -50%) scale(0.96);
956
+ }
957
+
958
+ .sp-big-play:focus-visible {
959
+ outline: 2px solid #fff;
960
+ outline-offset: 3px;
961
+ }
962
+
669
963
  /* ============================================
670
964
  Error Overlay
671
965
  ============================================ */
@@ -840,17 +1134,24 @@ var styles = `
840
1134
  .sp-control,
841
1135
  .sp-volume__slider-wrap,
842
1136
  .sp-quality-menu,
1137
+ .sp-overflow-tray,
843
1138
  .sp-settings-panel,
844
1139
  .sp-settings-panel__row,
845
1140
  .sp-settings-panel__item,
846
1141
  .sp-settings-panel__header,
847
1142
  .sp-buffering,
1143
+ .sp-big-play,
848
1144
  .sp-error-overlay,
849
1145
  .sp-error-overlay__retry,
850
1146
  .sp-error-overlay__dismiss {
851
1147
  transition: none;
852
1148
  }
853
1149
 
1150
+ .sp-big-play:hover,
1151
+ .sp-big-play:active {
1152
+ transform: translate(-50%, -50%);
1153
+ }
1154
+
854
1155
  .sp-live__dot,
855
1156
  .sp-spin {
856
1157
  animation: none;
@@ -889,6 +1190,8 @@ var icons = {
889
1190
  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>`,
890
1191
  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>`,
891
1192
  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>`,
1193
+ /** Vertical ellipsis for the overflow tray button. */
1194
+ more: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>`,
892
1195
  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>`,
893
1196
  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>`,
894
1197
  skipForward: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>`,
@@ -1023,6 +1326,112 @@ var PlayButton = class {
1023
1326
  }
1024
1327
  };
1025
1328
 
1329
+ // src/controls/BigPlayButton.ts
1330
+ var BigPlayButton = class {
1331
+ /**
1332
+ * @param api - Plugin API for state and container access
1333
+ * @param isOverlayVisible - Whether the error overlay is showing; the button
1334
+ * must not sit on top of it, and `error` state alone does not say (a
1335
+ * dismissed overlay leaves the error behind)
1336
+ */
1337
+ constructor(api, isOverlayVisible = () => false) {
1338
+ /**
1339
+ * Latched on the first `playing`.
1340
+ *
1341
+ * "Hidden from the first playing onward" cannot be read off `currentTime`
1342
+ * alone: a viewer who pauses in the first fraction of a second is still
1343
+ * mid-playback, and the button reappearing over live video would cover the
1344
+ * picture.
1345
+ */
1346
+ this.hasStarted = false;
1347
+ this.clickHandler = () => {
1348
+ this.start();
1349
+ };
1350
+ this.api = api;
1351
+ this.isOverlayVisible = isOverlayVisible;
1352
+ const btn = document.createElement("button");
1353
+ btn.className = "sp-big-play";
1354
+ btn.setAttribute("type", "button");
1355
+ btn.setAttribute("aria-label", "Play");
1356
+ setHTML(btn, icons.play);
1357
+ btn.addEventListener("click", this.clickHandler);
1358
+ this.el = btn;
1359
+ }
1360
+ render() {
1361
+ return this.el;
1362
+ }
1363
+ /**
1364
+ * Show or hide the button, and swap in the replay glyph after `ended`.
1365
+ *
1366
+ * Driven by the same `scheduleUpdate()` pass as every other control, so the
1367
+ * button cannot disagree with the control bar about what state playback is
1368
+ * in.
1369
+ */
1370
+ update() {
1371
+ const playing = this.api.getState("playing");
1372
+ const ended = this.hasEnded();
1373
+ const currentTime = this.api.getState("currentTime");
1374
+ const playbackState = this.api.getState("playbackState");
1375
+ const error = this.api.getState("error");
1376
+ if (playing) {
1377
+ this.hasStarted = true;
1378
+ }
1379
+ let visible;
1380
+ if (error || this.isOverlayVisible()) {
1381
+ visible = false;
1382
+ } else if (playbackState === "loading") {
1383
+ visible = false;
1384
+ } else if (playing) {
1385
+ visible = false;
1386
+ } else if (ended) {
1387
+ visible = true;
1388
+ } else if (this.hasStarted || currentTime !== 0) {
1389
+ visible = false;
1390
+ } else {
1391
+ visible = playbackState === "idle" || playbackState === "ready";
1392
+ }
1393
+ setHTML(this.el, ended ? icons.replay : icons.play);
1394
+ setAttr(this.el, "aria-label", ended ? "Replay" : "Play");
1395
+ this.el.classList.toggle("sp-big-play--visible", visible);
1396
+ }
1397
+ /**
1398
+ * Whether playback has actually ended, asked of the media element.
1399
+ *
1400
+ * NOT the `ended` state key. Measured in Chrome on 2026-09-02: neither
1401
+ * provider clears that key on a replay (only `load()` does), so after a
1402
+ * viewer replays a video it stays true for the rest of the session, while
1403
+ * `video.ended` correctly goes false the moment the position leaves the
1404
+ * end. Trusting the key would leave this button sitting over playing video,
1405
+ * and would make a later pause bring it back as Replay. The key is the
1406
+ * fallback for the window before a provider has created an element.
1407
+ */
1408
+ hasEnded() {
1409
+ const video = getVideo(this.api.container);
1410
+ return video ? video.ended : Boolean(this.api.getState("ended"));
1411
+ }
1412
+ /**
1413
+ * Start (or restart) playback.
1414
+ *
1415
+ * The same two branches as the control bar's play button: restart from zero
1416
+ * after the video ended, otherwise just play. There is no pause branch,
1417
+ * because this button is never on screen while playback is running. It reads
1418
+ * `video.ended` for the same reason `hasEnded()` does.
1419
+ */
1420
+ start() {
1421
+ const video = getVideo(this.api.container);
1422
+ if (!video) return;
1423
+ if (video.ended) {
1424
+ video.currentTime = 0;
1425
+ }
1426
+ video.play().catch(() => {
1427
+ });
1428
+ }
1429
+ destroy() {
1430
+ this.el.removeEventListener("click", this.clickHandler);
1431
+ this.el.remove();
1432
+ }
1433
+ };
1434
+
1026
1435
  // src/controls/ThumbnailPreview.ts
1027
1436
  var ThumbnailPreview = class {
1028
1437
  constructor() {
@@ -1951,6 +2360,7 @@ var PipButton = class {
1951
2360
  };
1952
2361
 
1953
2362
  // src/controls/FullscreenButton.ts
2363
+ import { enterFullscreen, exitFullscreen, isFullscreen } from "@scarlett-player/core";
1954
2364
  var FullscreenButton = class {
1955
2365
  constructor(api) {
1956
2366
  this.clickHandler = () => {
@@ -1973,16 +2383,22 @@ var FullscreenButton = class {
1973
2383
  setAttr(this.el, "aria-label", "Fullscreen");
1974
2384
  }
1975
2385
  }
2386
+ /**
2387
+ * Enter or leave fullscreen.
2388
+ *
2389
+ * The direction comes from the browser rather than from the `fullscreen`
2390
+ * state key: state is a report of what happened, and a stale one would invert
2391
+ * the button. Rejections are swallowed because the browser refuses these
2392
+ * routinely (no user gesture, denied by permission policy) and an unhandled
2393
+ * rejection helps nobody.
2394
+ */
1976
2395
  async toggle() {
1977
2396
  const container = this.api.container;
1978
- const video = getVideo(container);
1979
2397
  try {
1980
- if (document.fullscreenElement) {
1981
- await document.exitFullscreen();
1982
- } else if (container.requestFullscreen) {
1983
- await container.requestFullscreen();
1984
- } else if (video?.webkitEnterFullscreen) {
1985
- video.webkitEnterFullscreen();
2398
+ if (isFullscreen(container)) {
2399
+ await exitFullscreen(container);
2400
+ } else {
2401
+ await enterFullscreen(container);
1986
2402
  }
1987
2403
  } catch {
1988
2404
  }
@@ -2690,6 +3106,155 @@ var BandwidthIndicator = class {
2690
3106
  }
2691
3107
  };
2692
3108
 
3109
+ // src/controls/OverflowTray.ts
3110
+ var OverflowTray = class {
3111
+ /**
3112
+ * @param api - Plugin API, kept for parity with the other controls and for
3113
+ * logging; the tray itself reads no state
3114
+ */
3115
+ constructor(api) {
3116
+ this.api = api;
3117
+ this.isOpen = false;
3118
+ this.toggleHandler = () => {
3119
+ this.isOpen ? this.close() : this.open();
3120
+ };
3121
+ this.el = createElement("div", { className: "sp-overflow" });
3122
+ this.btn = createButton("sp-overflow__btn", "More controls", icons.more);
3123
+ this.btn.setAttribute("aria-haspopup", "true");
3124
+ this.btn.setAttribute("aria-expanded", "false");
3125
+ this.btn.addEventListener("click", this.toggleHandler);
3126
+ this.panel = createElement("div", {
3127
+ className: "sp-overflow-tray",
3128
+ role: "group",
3129
+ "aria-label": "More controls"
3130
+ });
3131
+ this.el.appendChild(this.btn);
3132
+ this.el.appendChild(this.panel);
3133
+ this.el.style.display = "none";
3134
+ this.closeHandler = (e) => {
3135
+ if (!this.el.contains(e.target)) {
3136
+ this.close();
3137
+ }
3138
+ };
3139
+ document.addEventListener("click", this.closeHandler);
3140
+ this.keyHandler = (e) => {
3141
+ if (!this.isOpen || e.key !== "Escape") return;
3142
+ e.preventDefault();
3143
+ e.stopPropagation();
3144
+ this.close();
3145
+ this.btn.focus();
3146
+ };
3147
+ document.addEventListener("keydown", this.keyHandler);
3148
+ }
3149
+ /**
3150
+ * The bar item to place in the control bar.
3151
+ *
3152
+ * @returns The wrapper holding the button and the strip
3153
+ */
3154
+ render() {
3155
+ return this.el;
3156
+ }
3157
+ /**
3158
+ * No state of its own: the fit loop owns what is inside it.
3159
+ */
3160
+ update() {
3161
+ }
3162
+ /**
3163
+ * Move a control's element into the tray.
3164
+ *
3165
+ * The element is moved as-is, so its class, icon, aria-label, event handlers
3166
+ * and `update()` all keep working.
3167
+ *
3168
+ * @param el - The control element leaving the bar
3169
+ */
3170
+ adopt(el) {
3171
+ this.panel.appendChild(el);
3172
+ this.syncVisibility();
3173
+ }
3174
+ /**
3175
+ * Take a control's element back out of the tray.
3176
+ *
3177
+ * The caller decides where in the bar it goes; this only detaches it and
3178
+ * updates the button's visibility.
3179
+ *
3180
+ * @param el - The control element returning to the bar
3181
+ * @returns The same element, detached
3182
+ */
3183
+ release(el) {
3184
+ if (el.parentNode === this.panel) {
3185
+ this.panel.removeChild(el);
3186
+ }
3187
+ this.syncVisibility();
3188
+ return el;
3189
+ }
3190
+ /**
3191
+ * Whether an element is currently held by the tray.
3192
+ *
3193
+ * @param el - Element to test
3194
+ * @returns True when the tray is its parent
3195
+ */
3196
+ holds(el) {
3197
+ return el.parentNode === this.panel;
3198
+ }
3199
+ /**
3200
+ * Open the strip.
3201
+ */
3202
+ open() {
3203
+ if (this.isOpen) return;
3204
+ this.isOpen = true;
3205
+ this.panel.classList.add("sp-overflow-tray--open");
3206
+ this.btn.setAttribute("aria-expanded", "true");
3207
+ }
3208
+ /**
3209
+ * Close the strip.
3210
+ *
3211
+ * Deliberately not called after a control inside it is used: skip, PiP and
3212
+ * cast are things a viewer taps more than once in a row.
3213
+ */
3214
+ close() {
3215
+ if (!this.isOpen) return;
3216
+ this.isOpen = false;
3217
+ this.panel.classList.remove("sp-overflow-tray--open");
3218
+ this.btn.setAttribute("aria-expanded", "false");
3219
+ }
3220
+ /**
3221
+ * Show the button only while the tray holds something the viewer can see.
3222
+ *
3223
+ * A control that hid itself (no cast device on the network, no text tracks)
3224
+ * can be sitting in the tray with `display: none`, and a button that opens an
3225
+ * empty strip is worse than no button at all.
3226
+ */
3227
+ syncVisibility() {
3228
+ const usable = Array.from(this.panel.children).some(
3229
+ (child) => child.style.display !== "none"
3230
+ );
3231
+ this.el.style.display = usable ? "" : "none";
3232
+ if (!usable && this.isOpen) {
3233
+ this.close();
3234
+ }
3235
+ }
3236
+ /**
3237
+ * Re-check the button's visibility after the controls have updated
3238
+ * themselves.
3239
+ */
3240
+ refresh() {
3241
+ this.syncVisibility();
3242
+ }
3243
+ /**
3244
+ * Remove the document listeners and the tray itself.
3245
+ *
3246
+ * Adopted elements are left where they are: they belong to their own
3247
+ * controls, which are destroyed by the plugin alongside this one.
3248
+ */
3249
+ destroy() {
3250
+ document.removeEventListener("click", this.closeHandler);
3251
+ document.removeEventListener("keydown", this.keyHandler);
3252
+ this.btn.removeEventListener("click", this.toggleHandler);
3253
+ this.el.remove();
3254
+ this.api.logger.debug("Overflow tray destroyed");
3255
+ }
3256
+ };
3257
+
2693
3258
  // src/control-registry.ts
2694
3259
  var registry = /* @__PURE__ */ new Map();
2695
3260
  var listeners = /* @__PURE__ */ new Set();
@@ -2716,6 +3281,9 @@ function resetControlRegistry() {
2716
3281
  listeners.clear();
2717
3282
  }
2718
3283
 
3284
+ // src/version.ts
3285
+ var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
3286
+
2719
3287
  // src/index.ts
2720
3288
  var DEFAULT_LAYOUT = [
2721
3289
  "play",
@@ -2734,6 +3302,14 @@ var DEFAULT_LAYOUT = [
2734
3302
  "fullscreen"
2735
3303
  ];
2736
3304
  var DEFAULT_HIDE_DELAY = 3e3;
3305
+ var UNMEASURED_CONTROL_WIDTH = 48;
3306
+ var FALLBACK_VOLUME_SLIDER_WIDTH = 64;
3307
+ var OVERFLOW_BUTTON_WIDTH = 44;
3308
+ var FALLBACK_BAR_PADDING_X = 24;
3309
+ var FALLBACK_BAR_GAP = 4;
3310
+ var MENU_HEIGHT_RESERVE = 16;
3311
+ var FALLBACK_BAR_HEIGHT = 56;
3312
+ var MIN_MENU_HEIGHT = 120;
2737
3313
  function uiPlugin(config = {}) {
2738
3314
  let api;
2739
3315
  let controlBar = null;
@@ -2741,6 +3317,7 @@ function uiPlugin(config = {}) {
2741
3317
  let progressBar = null;
2742
3318
  let bufferingIndicator = null;
2743
3319
  let errorOverlay = null;
3320
+ let bigPlayButton = null;
2744
3321
  let styleEl = null;
2745
3322
  let controls = [];
2746
3323
  let hideTimeout = null;
@@ -2751,8 +3328,22 @@ function uiPlugin(config = {}) {
2751
3328
  let recoveredUnsubscribe = null;
2752
3329
  let controlsVisible = true;
2753
3330
  let rafHandle = null;
3331
+ let tray = null;
3332
+ let entries = [];
3333
+ let timeEntry = null;
3334
+ let resizeObserver = null;
3335
+ let barPaddingX = FALLBACK_BAR_PADDING_X;
3336
+ let barGap = FALLBACK_BAR_GAP;
3337
+ let volumeSliderWidth = FALLBACK_VOLUME_SLIDER_WIDTH;
3338
+ let lastFitSignature = null;
3339
+ let fitPending = true;
2754
3340
  const layout = config.controls || DEFAULT_LAYOUT;
2755
3341
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
3342
+ const showBigPlayButton = config.bigPlayButton !== false;
3343
+ const responsive = config.responsive !== false;
3344
+ if (responsive) {
3345
+ assertFitLayout(layout, config.priority);
3346
+ }
2756
3347
  const createControl = (slot) => {
2757
3348
  switch (slot) {
2758
3349
  case "play":
@@ -2806,21 +3397,192 @@ function uiPlugin(config = {}) {
2806
3397
  if (!controlBar) {
2807
3398
  return;
2808
3399
  }
3400
+ const rules = new Map(
3401
+ resolveFitItems(layout, config.priority).map((template) => [template.id, template])
3402
+ );
2809
3403
  for (const slot of layout) {
2810
3404
  const control = createControl(slot);
2811
- if (control) {
2812
- controls.push(control);
2813
- controlBar.appendChild(control.render());
3405
+ if (!control) {
3406
+ continue;
2814
3407
  }
3408
+ controls.push(control);
3409
+ const el = control.render();
3410
+ controlBar.appendChild(el);
3411
+ const rule = rules.get(slot);
3412
+ const entry = {
3413
+ slot,
3414
+ control,
3415
+ el,
3416
+ rank: rule?.rank ?? "never",
3417
+ exit: rule?.exit ?? "overflow",
3418
+ width: -1
3419
+ };
3420
+ entries.push(entry);
3421
+ if (slot === "time") {
3422
+ timeEntry = entry;
3423
+ }
3424
+ }
3425
+ if (responsive) {
3426
+ tray = new OverflowTray(api);
3427
+ controls.push(tray);
3428
+ controlBar.appendChild(tray.render());
3429
+ placeTrayButton();
2815
3430
  }
2816
3431
  };
3432
+ const placeTrayButton = () => {
3433
+ if (!controlBar || !tray) {
3434
+ return;
3435
+ }
3436
+ const trayEl = tray.render();
3437
+ const fullscreen = entries.find((entry) => entry.slot === "fullscreen");
3438
+ const before = fullscreen && fullscreen.el.parentNode === controlBar ? fullscreen.el : null;
3439
+ if (before) {
3440
+ if (trayEl.nextSibling !== before) {
3441
+ controlBar.insertBefore(trayEl, before);
3442
+ }
3443
+ return;
3444
+ }
3445
+ if (controlBar.lastChild !== trayEl) {
3446
+ controlBar.appendChild(trayEl);
3447
+ }
3448
+ };
3449
+ const visibilitySignature = () => {
3450
+ let flags = "";
3451
+ for (const entry of entries) {
3452
+ flags += entry.el.style.display === "none" ? "0" : "1";
3453
+ }
3454
+ return `${flags}:${timeEntry?.el.textContent?.length ?? 0}`;
3455
+ };
3456
+ const applyFit = (plan) => {
3457
+ if (!controlBar || !tray) {
3458
+ return;
3459
+ }
3460
+ const overflow = new Set(plan.overflow);
3461
+ const hidden = new Set(plan.hidden);
3462
+ for (const entry of entries) {
3463
+ if (entry.slot === "spacer") {
3464
+ continue;
3465
+ }
3466
+ if (hidden.has(entry.slot)) {
3467
+ if (tray.holds(entry.el)) {
3468
+ returnToBar(entry);
3469
+ }
3470
+ entry.el.classList.add("sp-control--collapsed");
3471
+ continue;
3472
+ }
3473
+ entry.el.classList.remove("sp-control--collapsed");
3474
+ if (overflow.has(entry.slot)) {
3475
+ if (!tray.holds(entry.el)) {
3476
+ tray.adopt(entry.el);
3477
+ }
3478
+ } else if (tray.holds(entry.el)) {
3479
+ returnToBar(entry);
3480
+ }
3481
+ }
3482
+ tray.refresh();
3483
+ placeTrayButton();
3484
+ };
3485
+ const returnToBar = (entry) => {
3486
+ if (!controlBar || !tray) {
3487
+ return;
3488
+ }
3489
+ const el = tray.release(entry.el);
3490
+ const trayEl = tray.render();
3491
+ let before = trayEl.parentNode === controlBar ? trayEl : null;
3492
+ for (let i = entries.indexOf(entry) + 1; i < entries.length; i++) {
3493
+ if (entries[i].el.parentNode === controlBar) {
3494
+ before = entries[i].el;
3495
+ break;
3496
+ }
3497
+ }
3498
+ controlBar.insertBefore(el, before);
3499
+ };
3500
+ const expandedWidth = (entry) => {
3501
+ if (entry.slot !== "volume") {
3502
+ return 0;
3503
+ }
3504
+ const wrap = entry.el.querySelector(".sp-volume__slider-wrap");
3505
+ return wrap ? wrap.getBoundingClientRect().width : 0;
3506
+ };
3507
+ const interactionReserve = (entry) => entry.slot === "volume" ? volumeSliderWidth : 0;
3508
+ const readBarMetrics = (bar) => {
3509
+ const barStyle = getComputedStyle(bar);
3510
+ const paddingLeft = parseFloat(barStyle.paddingLeft);
3511
+ const paddingRight = parseFloat(barStyle.paddingRight);
3512
+ const gap = parseFloat(barStyle.columnGap || barStyle.gap);
3513
+ const sliderWidth = parseFloat(
3514
+ barStyle.getPropertyValue("--sp-volume-slider-width")
3515
+ );
3516
+ barPaddingX = Number.isFinite(paddingLeft) && Number.isFinite(paddingRight) ? paddingLeft + paddingRight : FALLBACK_BAR_PADDING_X;
3517
+ barGap = Number.isFinite(gap) ? gap : FALLBACK_BAR_GAP;
3518
+ volumeSliderWidth = Number.isFinite(sliderWidth) ? sliderWidth : FALLBACK_VOLUME_SLIDER_WIDTH;
3519
+ };
3520
+ const fitControls = () => {
3521
+ if (!responsive || !controlBar || !tray) {
3522
+ return;
3523
+ }
3524
+ if (controlBar.clientWidth === 0) {
3525
+ return;
3526
+ }
3527
+ readBarMetrics(controlBar);
3528
+ const spacerGaps = entries.filter(
3529
+ (entry) => entry.slot === "spacer" && entry.el.style.display !== "none"
3530
+ ).length;
3531
+ const available = controlBar.clientWidth - barPaddingX - spacerGaps * barGap;
3532
+ const items = [];
3533
+ for (const entry of entries) {
3534
+ if (entry.slot === "spacer") {
3535
+ continue;
3536
+ }
3537
+ const visible = entry.el.style.display !== "none";
3538
+ const measurable = visible && entry.el.parentNode === controlBar && !entry.el.classList.contains("sp-control--collapsed");
3539
+ if (measurable) {
3540
+ entry.width = entry.el.getBoundingClientRect().width - expandedWidth(entry);
3541
+ } else if (entry.width < 0) {
3542
+ entry.width = UNMEASURED_CONTROL_WIDTH;
3543
+ }
3544
+ items.push({
3545
+ id: entry.slot,
3546
+ rank: entry.rank,
3547
+ exit: entry.exit,
3548
+ width: entry.width + interactionReserve(entry),
3549
+ visible
3550
+ });
3551
+ }
3552
+ const trayEl = tray.render();
3553
+ const trayWidth = trayEl.style.display === "none" ? 0 : trayEl.getBoundingClientRect().width;
3554
+ applyFit(planFit(items, available, barGap, trayWidth || OVERFLOW_BUTTON_WIDTH));
3555
+ lastFitSignature = visibilitySignature();
3556
+ fitPending = false;
3557
+ };
3558
+ const maybeFit = () => {
3559
+ if (!responsive) {
3560
+ return;
3561
+ }
3562
+ if (!fitPending && visibilitySignature() === lastFitSignature) {
3563
+ return;
3564
+ }
3565
+ fitControls();
3566
+ };
3567
+ const applyMenuBounds = (height) => {
3568
+ const barHeight = controlBar?.offsetHeight || FALLBACK_BAR_HEIGHT;
3569
+ api?.container?.style.setProperty(
3570
+ "--sp-menu-max-height",
3571
+ `${Math.max(MIN_MENU_HEIGHT, Math.round(height) - barHeight - MENU_HEIGHT_RESERVE)}px`
3572
+ );
3573
+ };
2817
3574
  const rebuildControlBar = () => {
2818
3575
  if (!controlBar) {
2819
3576
  return;
2820
3577
  }
2821
3578
  controls.forEach((c) => c.destroy());
2822
3579
  controls = [];
3580
+ entries = [];
3581
+ timeEntry = null;
3582
+ tray = null;
2823
3583
  controlBar.replaceChildren();
3584
+ lastFitSignature = null;
3585
+ fitPending = true;
2824
3586
  populateControlBar();
2825
3587
  updateControls();
2826
3588
  };
@@ -2834,6 +3596,8 @@ function uiPlugin(config = {}) {
2834
3596
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
2835
3597
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
2836
3598
  errorOverlay?.update();
3599
+ bigPlayButton?.update();
3600
+ maybeFit();
2837
3601
  };
2838
3602
  const scheduleUpdate = () => {
2839
3603
  if (rafHandle !== null) return;
@@ -2912,11 +3676,11 @@ function uiPlugin(config = {}) {
2912
3676
  break;
2913
3677
  case "f":
2914
3678
  e.preventDefault();
2915
- if (document.fullscreenElement) {
2916
- document.exitFullscreen().catch(() => {
3679
+ if (isFullscreen2(api.container)) {
3680
+ exitFullscreen2(api.container).catch(() => {
2917
3681
  });
2918
3682
  } else {
2919
- api.container.requestFullscreen?.().catch(() => {
3683
+ enterFullscreen2(api.container).catch(() => {
2920
3684
  });
2921
3685
  }
2922
3686
  break;
@@ -2954,7 +3718,7 @@ function uiPlugin(config = {}) {
2954
3718
  id: "ui-controls",
2955
3719
  name: "UI Controls",
2956
3720
  type: "ui",
2957
- version: "1.0.0",
3721
+ version: PKG_VERSION,
2958
3722
  async init(pluginApi) {
2959
3723
  api = pluginApi;
2960
3724
  styleEl = document.createElement("style");
@@ -2995,6 +3759,10 @@ function uiPlugin(config = {}) {
2995
3759
  recoveredUnsubscribe = api.on("error:recovered", () => {
2996
3760
  errorOverlay?.hide();
2997
3761
  });
3762
+ if (showBigPlayButton) {
3763
+ bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
3764
+ container.appendChild(bigPlayButton.render());
3765
+ }
2998
3766
  progressBar = new ProgressBar(api);
2999
3767
  container.appendChild(progressBar.render());
3000
3768
  if (!isPlaying) {
@@ -3006,6 +3774,14 @@ function uiPlugin(config = {}) {
3006
3774
  controlBar.setAttribute("aria-label", "Video controls");
3007
3775
  populateControlBar();
3008
3776
  container.appendChild(controlBar);
3777
+ if (responsive && typeof ResizeObserver === "function") {
3778
+ resizeObserver = new ResizeObserver((observed) => {
3779
+ applyMenuBounds(observed[0]?.contentRect.height ?? container.clientHeight);
3780
+ fitPending = true;
3781
+ scheduleUpdate();
3782
+ });
3783
+ resizeObserver.observe(container);
3784
+ }
3009
3785
  controlRegistryUnsubscribe = onControlRegistered((id) => {
3010
3786
  if (!layout.includes(id)) {
3011
3787
  return;
@@ -3022,7 +3798,6 @@ function uiPlugin(config = {}) {
3022
3798
  container.addEventListener("click", handleInteraction);
3023
3799
  document.addEventListener("keydown", handleKeyDown);
3024
3800
  stateUnsubscribe = api.subscribeToState(scheduleUpdate);
3025
- document.addEventListener("fullscreenchange", scheduleUpdate);
3026
3801
  updateControls();
3027
3802
  if (!container.hasAttribute("tabindex")) {
3028
3803
  container.setAttribute("tabindex", "0");
@@ -3043,6 +3818,9 @@ function uiPlugin(config = {}) {
3043
3818
  cancelAnimationFrame(rafHandle);
3044
3819
  rafHandle = null;
3045
3820
  }
3821
+ resizeObserver?.disconnect();
3822
+ resizeObserver = null;
3823
+ api?.container?.style.removeProperty("--sp-menu-max-height");
3046
3824
  stateUnsubscribe?.();
3047
3825
  stateUnsubscribe = null;
3048
3826
  errorUnsubscribe?.();
@@ -3061,15 +3839,19 @@ function uiPlugin(config = {}) {
3061
3839
  api.container.removeEventListener("click", handleInteraction);
3062
3840
  }
3063
3841
  document.removeEventListener("keydown", handleKeyDown);
3064
- document.removeEventListener("fullscreenchange", scheduleUpdate);
3065
3842
  controlRegistryUnsubscribe?.();
3066
3843
  controlRegistryUnsubscribe = null;
3067
3844
  controls.forEach((c) => c.destroy());
3068
3845
  controls = [];
3846
+ entries = [];
3847
+ timeEntry = null;
3848
+ tray = null;
3069
3849
  progressBar?.destroy();
3070
3850
  progressBar = null;
3071
3851
  errorOverlay?.destroy();
3072
3852
  errorOverlay = null;
3853
+ bigPlayButton?.destroy();
3854
+ bigPlayButton = null;
3073
3855
  controlBar?.remove();
3074
3856
  controlBar = null;
3075
3857
  gradient?.remove();
@@ -3117,13 +3899,17 @@ function uiPlugin(config = {}) {
3117
3899
  }
3118
3900
  var index_default = uiPlugin;
3119
3901
  export {
3902
+ DEFAULT_PRIORITY,
3903
+ assertFitLayout,
3120
3904
  index_default as default,
3121
3905
  formatLiveTime,
3122
3906
  formatTime,
3123
3907
  getControlFactory,
3124
3908
  icons,
3909
+ planFit,
3125
3910
  registerControl,
3126
3911
  resetControlRegistry,
3912
+ resolveFitItems,
3127
3913
  styles,
3128
3914
  uiPlugin,
3129
3915
  unregisterControl