@scarlett-player/ui 1.6.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.cjs CHANGED
@@ -20,18 +20,101 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_PRIORITY: () => DEFAULT_PRIORITY,
24
+ assertFitLayout: () => assertFitLayout,
23
25
  default: () => index_default,
24
26
  formatLiveTime: () => formatLiveTime,
25
27
  formatTime: () => formatTime,
26
28
  getControlFactory: () => getControlFactory,
27
29
  icons: () => icons,
30
+ planFit: () => planFit,
28
31
  registerControl: () => registerControl,
29
32
  resetControlRegistry: () => resetControlRegistry,
33
+ resolveFitItems: () => resolveFitItems,
30
34
  styles: () => styles,
31
35
  uiPlugin: () => uiPlugin,
32
36
  unregisterControl: () => unregisterControl
33
37
  });
34
38
  module.exports = __toCommonJS(index_exports);
39
+ var import_core2 = require("@scarlett-player/core");
40
+
41
+ // src/fit.ts
42
+ var UNKNOWN_RANK = 3;
43
+ var DEFAULT_PRIORITY = {
44
+ "bandwidth-indicator": { rank: 0, exit: "hide" },
45
+ "skip-backward": { rank: 1, exit: "overflow" },
46
+ "skip-forward": { rank: 1, exit: "overflow" },
47
+ pip: { rank: 2, exit: "overflow" },
48
+ chromecast: { rank: 4, exit: "overflow" },
49
+ airplay: { rank: 4, exit: "overflow" },
50
+ volume: { rank: 5, exit: "overflow" },
51
+ captions: { rank: 6, exit: "overflow" },
52
+ quality: { rank: 6, exit: "hide" },
53
+ time: { rank: 7, exit: "hide" },
54
+ play: { rank: "never", exit: "overflow" },
55
+ "live-indicator": { rank: "never", exit: "overflow" },
56
+ settings: { rank: "never", exit: "overflow" },
57
+ fullscreen: { rank: "never", exit: "overflow" },
58
+ spacer: { rank: "never", exit: "overflow" }
59
+ };
60
+ function resolveFitItems(layout, priority) {
61
+ return layout.map((id) => {
62
+ const rule = DEFAULT_PRIORITY[id];
63
+ const rank = priority?.[id] ?? rule?.rank ?? UNKNOWN_RANK;
64
+ return { id, rank, exit: rule?.exit ?? "overflow" };
65
+ });
66
+ }
67
+ function assertFitLayout(layout, priority) {
68
+ if (!layout.includes("quality") || layout.includes("settings")) {
69
+ return;
70
+ }
71
+ const [quality] = resolveFitItems(["quality"], priority);
72
+ if (quality.rank === "never") {
73
+ return;
74
+ }
75
+ throw new Error(
76
+ `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.`
77
+ );
78
+ }
79
+ function needed(widths, gap, overflowButtonWidth, trayUsed) {
80
+ const content = widths.reduce((sum, width) => sum + width, 0);
81
+ const gaps = gap * Math.max(0, widths.length - 1);
82
+ const tray = trayUsed ? overflowButtonWidth + gap : 0;
83
+ return content + gaps + tray;
84
+ }
85
+ function planFit(items, available, gap, overflowButtonWidth) {
86
+ const ids = items.map((item) => item.id);
87
+ if (available <= 0) {
88
+ return { inBar: ids, overflow: [], hidden: [] };
89
+ }
90
+ const counted = items.filter((item) => item.visible && item.width > 0);
91
+ const remaining = [...counted];
92
+ const overflow = /* @__PURE__ */ new Set();
93
+ const hidden = /* @__PURE__ */ new Set();
94
+ while (needed(
95
+ remaining.map((item) => item.width),
96
+ gap,
97
+ overflowButtonWidth,
98
+ overflow.size > 0
99
+ ) > available) {
100
+ let victim = -1;
101
+ for (let i = 0; i < remaining.length; i++) {
102
+ const candidate = remaining[i];
103
+ if (candidate.rank === "never") continue;
104
+ if (victim === -1 || candidate.rank <= remaining[victim].rank) {
105
+ victim = i;
106
+ }
107
+ }
108
+ if (victim === -1) break;
109
+ const [removed] = remaining.splice(victim, 1);
110
+ (removed.exit === "hide" ? hidden : overflow).add(removed.id);
111
+ }
112
+ return {
113
+ inBar: ids.filter((id) => !overflow.has(id) && !hidden.has(id)),
114
+ overflow: ids.filter((id) => overflow.has(id)),
115
+ hidden: ids.filter((id) => hidden.has(id))
116
+ };
117
+ }
35
118
 
36
119
  // src/styles.ts
37
120
  var styles = `
@@ -94,7 +177,17 @@ var styles = `
94
177
  display: flex;
95
178
  align-items: center;
96
179
  padding: 0 12px 12px;
180
+ /* Composed through a variable so the fullscreen rule below can add the
181
+ device's own inset without restating the 12px. */
182
+ padding-bottom: calc(12px + var(--sp-inset-bottom, 0px));
97
183
  gap: 4px;
184
+ /* Declared on the bar because the fit needs the same number the volume rules
185
+ below use: the slider expands mid-interaction and the plugin reserves the
186
+ room in advance (see interactionReserve() in index.ts, which reads this
187
+ property off this element at init). One declaration, so the stylesheet and
188
+ the arithmetic cannot drift. Both the bar and the overflow tray are inside
189
+ .sp-controls, so a volume control inherits it wherever the fit put it. */
190
+ --sp-volume-slider-width: 64px;
98
191
  opacity: 0;
99
192
  transform: translateY(4px);
100
193
  transition: opacity 0.25s ease, transform 0.25s ease;
@@ -112,12 +205,37 @@ var styles = `
112
205
  pointer-events: none;
113
206
  }
114
207
 
208
+ /* ============================================
209
+ Safe Area (fullscreen only)
210
+
211
+ Scoped to :fullscreen on purpose. Applied unconditionally, the inset would
212
+ push an inline player's controls up on any page whose viewport meta says
213
+ viewport-fit=cover, where there is no notch or home indicator over the
214
+ player at all. Both the bar and the progress wrapper are direct children of
215
+ the container, which is the element that goes fullscreen.
216
+
217
+ The :-webkit-full-screen twin is a separate rule because an unknown
218
+ pseudo-class anywhere in a selector list invalidates the whole rule.
219
+
220
+ Nothing is needed in packages/embed/iframe.html: viewport-fit has no effect
221
+ inside an iframe.
222
+ ============================================ */
223
+ :fullscreen > .sp-controls,
224
+ :fullscreen > .sp-progress-wrapper {
225
+ --sp-inset-bottom: env(safe-area-inset-bottom, 0px);
226
+ }
227
+
228
+ :-webkit-full-screen > .sp-controls,
229
+ :-webkit-full-screen > .sp-progress-wrapper {
230
+ --sp-inset-bottom: env(safe-area-inset-bottom, 0px);
231
+ }
232
+
115
233
  /* ============================================
116
234
  Progress Bar (Above Controls)
117
235
  ============================================ */
118
236
  .sp-progress-wrapper {
119
237
  position: absolute;
120
- bottom: 48px;
238
+ bottom: calc(48px + var(--sp-inset-bottom, 0px));
121
239
  left: 12px;
122
240
  right: 12px;
123
241
  height: 20px;
@@ -133,6 +251,31 @@ var styles = `
133
251
  opacity: 1;
134
252
  }
135
253
 
254
+ /* Touch: a 20px wrapper is not a 20px target. The control bar is a later
255
+ sibling at the same z-index and spans 0..56px from the bottom, so it wins
256
+ hit-testing in the 48..56 overlap and the exclusive region for scrubbing is
257
+ 12px. The wrapper grows UPWARD to 44px (48..92) because growing downward
258
+ would be swallowed by the bar; the 3px bar itself stays exactly where it was
259
+ (centred 8.5px above the wrapper's bottom edge, which is what
260
+ align-items: center gave it inside 20px). The handle and tooltip
261
+ enlargements are gated behind (hover: hover) and never match a finger, but
262
+ .sp-progress--dragging is not, so the handle still appears mid-drag.
263
+
264
+ any-pointer, not pointer: (pointer: coarse) describes the PRIMARY pointer
265
+ only, so a hybrid laptop with a mouse and a touchscreen reports fine and kept
266
+ the 12px exclusive region under a finger. (any-pointer: coarse) is true
267
+ whenever a coarse pointer is available at all, which is the population that
268
+ needs the target. The cost on such a machine is 24px of extra hit area for
269
+ the mouse, over the player's own bottom edge. */
270
+ @media (any-pointer: coarse) {
271
+ .sp-progress-wrapper {
272
+ height: 44px;
273
+ align-items: flex-end;
274
+ padding-bottom: 8.5px;
275
+ box-sizing: border-box;
276
+ }
277
+ }
278
+
136
279
  .sp-progress {
137
280
  position: relative;
138
281
  width: 100%;
@@ -340,6 +483,61 @@ var styles = `
340
483
  min-width: 0;
341
484
  }
342
485
 
486
+ /* ============================================
487
+ Overflow Tray
488
+
489
+ The wrapper is deliberately unpositioned: the strip is absolutely
490
+ positioned against .sp-controls (the nearest positioned ancestor), so it
491
+ spans the bar's width and sits directly above it instead of hanging off a
492
+ 44px button.
493
+
494
+ The strip wraps horizontally and keeps overflow visible. A vertical menu of
495
+ 44px rows would be taller than a portrait phone player (211px at 375px wide,
496
+ measured 2026-09-05) and a scrolling one would clip the popovers registered
497
+ controls own.
498
+ ============================================ */
499
+ .sp-overflow {
500
+ display: flex;
501
+ align-items: center;
502
+ flex-shrink: 0;
503
+ }
504
+
505
+ .sp-overflow-tray {
506
+ position: absolute;
507
+ bottom: 100%;
508
+ left: 0;
509
+ right: 0;
510
+ display: flex;
511
+ flex-wrap: wrap;
512
+ justify-content: flex-end;
513
+ gap: 4px;
514
+ padding: 8px 12px;
515
+ background: rgba(20, 20, 20, 0.95);
516
+ backdrop-filter: blur(8px);
517
+ -webkit-backdrop-filter: blur(8px);
518
+ border-radius: 8px;
519
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
520
+ overflow: visible;
521
+ opacity: 0;
522
+ visibility: hidden;
523
+ transform: translateY(8px);
524
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
525
+ z-index: 20;
526
+ }
527
+
528
+ .sp-overflow-tray--open {
529
+ opacity: 1;
530
+ visibility: visible;
531
+ transform: translateY(0);
532
+ }
533
+
534
+ /* Beats a control's own inline style.display = '' on its next update(), so a
535
+ control the fit took off screen stays off screen until the fit says
536
+ otherwise. */
537
+ .sp-control--collapsed {
538
+ display: none !important;
539
+ }
540
+
343
541
  /* ============================================
344
542
  Time Display
345
543
  ============================================ */
@@ -367,14 +565,18 @@ var styles = `
367
565
  transition: width 0.2s ease;
368
566
  }
369
567
 
568
+ /* Both widths come from --sp-volume-slider-width on .sp-controls, which is also
569
+ what the fit reserves for this control. focus-within is deliberately not
570
+ gated on hover: a tap on the mute button focuses it, which is how the slider
571
+ opens on a phone. */
370
572
  @media (hover: hover) {
371
573
  .sp-volume:hover .sp-volume__slider-wrap {
372
- width: 64px;
574
+ width: var(--sp-volume-slider-width);
373
575
  }
374
576
  }
375
577
 
376
578
  .sp-volume:focus-within .sp-volume__slider-wrap {
377
- width: 64px;
579
+ width: var(--sp-volume-slider-width);
378
580
  }
379
581
 
380
582
  .sp-volume__slider {
@@ -475,6 +677,14 @@ var styles = `
475
677
  position: absolute;
476
678
  bottom: calc(100% + 8px);
477
679
  right: 0;
680
+ /* Bounded to the player, see .sp-settings-panel. border-box because the
681
+ bound is a content-box height by default and this menu adds 8px of padding
682
+ top and bottom: at the 139px bound a 211px player gives, it rendered 155px
683
+ and the host clipped the last 16px of it. */
684
+ box-sizing: border-box;
685
+ max-height: var(--sp-menu-max-height, none);
686
+ overflow-y: auto;
687
+ -webkit-overflow-scrolling: touch;
478
688
  background: rgba(20, 20, 20, 0.95);
479
689
  backdrop-filter: blur(8px);
480
690
  -webkit-backdrop-filter: blur(8px);
@@ -543,6 +753,29 @@ var styles = `
543
753
  position: absolute;
544
754
  bottom: calc(100% + 8px);
545
755
  right: 0;
756
+ /* Bounded to the room above the control bar, written by the UI plugin's
757
+ ResizeObserver as max(120px, container height - the bar's measured height
758
+ - 16px). The bar is measured rather than assumed because its
759
+ padding-bottom carries the safe-area inset in fullscreen, which moves the
760
+ anchor these menus hang from. The Speed sub-panel is 253px (a
761
+ 37px header plus six 36px rows) against a 211px portrait phone player, so
762
+ without this the host's overflow: hidden cuts off the Back header and the
763
+ first three speeds and playback speed is unreachable (measured at 375x211
764
+ on 2026-09-05). With the variable unset the panel behaves exactly as it
765
+ did before.
766
+
767
+ Not applied to .sp-overflow-tray: that one has to keep overflow visible so
768
+ the popovers its adopted controls own are not clipped.
769
+
770
+ border-box because max-height bounds the content box: .sp-settings-panel--main
771
+ is this same element with 4px of padding top and bottom, so wherever the
772
+ bound binds the main menu, it rendered 8px past it and the host clipped the
773
+ difference. The --sub views set padding: 0 and were already exact, which is
774
+ why the browser harness's speed-panel check could not see this. */
775
+ box-sizing: border-box;
776
+ max-height: var(--sp-menu-max-height, none);
777
+ overflow-y: auto;
778
+ -webkit-overflow-scrolling: touch;
546
779
  background: rgba(20, 20, 20, 0.95);
547
780
  backdrop-filter: blur(8px);
548
781
  -webkit-backdrop-filter: blur(8px);
@@ -554,7 +787,6 @@ var styles = `
554
787
  transform: translateY(8px);
555
788
  transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
556
789
  z-index: 20;
557
- overflow: hidden;
558
790
  }
559
791
 
560
792
  .sp-settings-panel--open {
@@ -701,6 +933,70 @@ var styles = `
701
933
  opacity: 0.4;
702
934
  }
703
935
 
936
+ /* ============================================
937
+ Big Play Button
938
+
939
+ z-index 12 puts it above the gradient (5) and above the gestures plugin's
940
+ tap surface (6), so a tap lands on the button and starts playback instead
941
+ of being read as a tap-to-toggle-controls gesture - exactly how the control
942
+ bar's play button (10) already behaves. It stays below the spinner (15) and
943
+ the error overlay (25), both of which own the middle of the picture when
944
+ they are up.
945
+
946
+ Hidden with visibility, not opacity alone, so it takes no pointer events
947
+ while it is away.
948
+ ============================================ */
949
+ .sp-big-play {
950
+ position: absolute;
951
+ top: 50%;
952
+ left: 50%;
953
+ transform: translate(-50%, -50%);
954
+ z-index: 12;
955
+ display: flex;
956
+ align-items: center;
957
+ justify-content: center;
958
+ /* Comfortably past the 44px minimum touch target the control bar uses. */
959
+ width: 72px;
960
+ height: 72px;
961
+ padding: 0;
962
+ border: none;
963
+ border-radius: 50%;
964
+ background: var(--sp-accent, #e50914);
965
+ color: #fff;
966
+ cursor: pointer;
967
+ opacity: 0;
968
+ visibility: hidden;
969
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
970
+ transition: opacity 0.2s ease, visibility 0.2s, transform 0.15s ease,
971
+ background 0.15s ease;
972
+ }
973
+
974
+ .sp-big-play--visible {
975
+ opacity: 1;
976
+ visibility: visible;
977
+ }
978
+
979
+ .sp-big-play svg {
980
+ width: 36px;
981
+ height: 36px;
982
+ fill: currentColor;
983
+ /* Optical centring: the play triangle's mass sits left of the glyph box. */
984
+ margin-left: 3px;
985
+ }
986
+
987
+ .sp-big-play:hover {
988
+ transform: translate(-50%, -50%) scale(1.06);
989
+ }
990
+
991
+ .sp-big-play:active {
992
+ transform: translate(-50%, -50%) scale(0.96);
993
+ }
994
+
995
+ .sp-big-play:focus-visible {
996
+ outline: 2px solid #fff;
997
+ outline-offset: 3px;
998
+ }
999
+
704
1000
  /* ============================================
705
1001
  Error Overlay
706
1002
  ============================================ */
@@ -875,17 +1171,24 @@ var styles = `
875
1171
  .sp-control,
876
1172
  .sp-volume__slider-wrap,
877
1173
  .sp-quality-menu,
1174
+ .sp-overflow-tray,
878
1175
  .sp-settings-panel,
879
1176
  .sp-settings-panel__row,
880
1177
  .sp-settings-panel__item,
881
1178
  .sp-settings-panel__header,
882
1179
  .sp-buffering,
1180
+ .sp-big-play,
883
1181
  .sp-error-overlay,
884
1182
  .sp-error-overlay__retry,
885
1183
  .sp-error-overlay__dismiss {
886
1184
  transition: none;
887
1185
  }
888
1186
 
1187
+ .sp-big-play:hover,
1188
+ .sp-big-play:active {
1189
+ transform: translate(-50%, -50%);
1190
+ }
1191
+
889
1192
  .sp-live__dot,
890
1193
  .sp-spin {
891
1194
  animation: none;
@@ -924,6 +1227,8 @@ var icons = {
924
1227
  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>`,
925
1228
  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>`,
926
1229
  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>`,
1230
+ /** Vertical ellipsis for the overflow tray button. */
1231
+ 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>`,
927
1232
  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>`,
928
1233
  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>`,
929
1234
  skipForward: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>`,
@@ -1058,6 +1363,112 @@ var PlayButton = class {
1058
1363
  }
1059
1364
  };
1060
1365
 
1366
+ // src/controls/BigPlayButton.ts
1367
+ var BigPlayButton = class {
1368
+ /**
1369
+ * @param api - Plugin API for state and container access
1370
+ * @param isOverlayVisible - Whether the error overlay is showing; the button
1371
+ * must not sit on top of it, and `error` state alone does not say (a
1372
+ * dismissed overlay leaves the error behind)
1373
+ */
1374
+ constructor(api, isOverlayVisible = () => false) {
1375
+ /**
1376
+ * Latched on the first `playing`.
1377
+ *
1378
+ * "Hidden from the first playing onward" cannot be read off `currentTime`
1379
+ * alone: a viewer who pauses in the first fraction of a second is still
1380
+ * mid-playback, and the button reappearing over live video would cover the
1381
+ * picture.
1382
+ */
1383
+ this.hasStarted = false;
1384
+ this.clickHandler = () => {
1385
+ this.start();
1386
+ };
1387
+ this.api = api;
1388
+ this.isOverlayVisible = isOverlayVisible;
1389
+ const btn = document.createElement("button");
1390
+ btn.className = "sp-big-play";
1391
+ btn.setAttribute("type", "button");
1392
+ btn.setAttribute("aria-label", "Play");
1393
+ setHTML(btn, icons.play);
1394
+ btn.addEventListener("click", this.clickHandler);
1395
+ this.el = btn;
1396
+ }
1397
+ render() {
1398
+ return this.el;
1399
+ }
1400
+ /**
1401
+ * Show or hide the button, and swap in the replay glyph after `ended`.
1402
+ *
1403
+ * Driven by the same `scheduleUpdate()` pass as every other control, so the
1404
+ * button cannot disagree with the control bar about what state playback is
1405
+ * in.
1406
+ */
1407
+ update() {
1408
+ const playing = this.api.getState("playing");
1409
+ const ended = this.hasEnded();
1410
+ const currentTime = this.api.getState("currentTime");
1411
+ const playbackState = this.api.getState("playbackState");
1412
+ const error = this.api.getState("error");
1413
+ if (playing) {
1414
+ this.hasStarted = true;
1415
+ }
1416
+ let visible;
1417
+ if (error || this.isOverlayVisible()) {
1418
+ visible = false;
1419
+ } else if (playbackState === "loading") {
1420
+ visible = false;
1421
+ } else if (playing) {
1422
+ visible = false;
1423
+ } else if (ended) {
1424
+ visible = true;
1425
+ } else if (this.hasStarted || currentTime !== 0) {
1426
+ visible = false;
1427
+ } else {
1428
+ visible = playbackState === "idle" || playbackState === "ready";
1429
+ }
1430
+ setHTML(this.el, ended ? icons.replay : icons.play);
1431
+ setAttr(this.el, "aria-label", ended ? "Replay" : "Play");
1432
+ this.el.classList.toggle("sp-big-play--visible", visible);
1433
+ }
1434
+ /**
1435
+ * Whether playback has actually ended, asked of the media element.
1436
+ *
1437
+ * NOT the `ended` state key. Measured in Chrome on 2026-09-02: neither
1438
+ * provider clears that key on a replay (only `load()` does), so after a
1439
+ * viewer replays a video it stays true for the rest of the session, while
1440
+ * `video.ended` correctly goes false the moment the position leaves the
1441
+ * end. Trusting the key would leave this button sitting over playing video,
1442
+ * and would make a later pause bring it back as Replay. The key is the
1443
+ * fallback for the window before a provider has created an element.
1444
+ */
1445
+ hasEnded() {
1446
+ const video = getVideo(this.api.container);
1447
+ return video ? video.ended : Boolean(this.api.getState("ended"));
1448
+ }
1449
+ /**
1450
+ * Start (or restart) playback.
1451
+ *
1452
+ * The same two branches as the control bar's play button: restart from zero
1453
+ * after the video ended, otherwise just play. There is no pause branch,
1454
+ * because this button is never on screen while playback is running. It reads
1455
+ * `video.ended` for the same reason `hasEnded()` does.
1456
+ */
1457
+ start() {
1458
+ const video = getVideo(this.api.container);
1459
+ if (!video) return;
1460
+ if (video.ended) {
1461
+ video.currentTime = 0;
1462
+ }
1463
+ video.play().catch(() => {
1464
+ });
1465
+ }
1466
+ destroy() {
1467
+ this.el.removeEventListener("click", this.clickHandler);
1468
+ this.el.remove();
1469
+ }
1470
+ };
1471
+
1061
1472
  // src/controls/ThumbnailPreview.ts
1062
1473
  var ThumbnailPreview = class {
1063
1474
  constructor() {
@@ -1986,6 +2397,7 @@ var PipButton = class {
1986
2397
  };
1987
2398
 
1988
2399
  // src/controls/FullscreenButton.ts
2400
+ var import_core = require("@scarlett-player/core");
1989
2401
  var FullscreenButton = class {
1990
2402
  constructor(api) {
1991
2403
  this.clickHandler = () => {
@@ -2008,16 +2420,22 @@ var FullscreenButton = class {
2008
2420
  setAttr(this.el, "aria-label", "Fullscreen");
2009
2421
  }
2010
2422
  }
2423
+ /**
2424
+ * Enter or leave fullscreen.
2425
+ *
2426
+ * The direction comes from the browser rather than from the `fullscreen`
2427
+ * state key: state is a report of what happened, and a stale one would invert
2428
+ * the button. Rejections are swallowed because the browser refuses these
2429
+ * routinely (no user gesture, denied by permission policy) and an unhandled
2430
+ * rejection helps nobody.
2431
+ */
2011
2432
  async toggle() {
2012
2433
  const container = this.api.container;
2013
- const video = getVideo(container);
2014
2434
  try {
2015
- if (document.fullscreenElement) {
2016
- await document.exitFullscreen();
2017
- } else if (container.requestFullscreen) {
2018
- await container.requestFullscreen();
2019
- } else if (video?.webkitEnterFullscreen) {
2020
- video.webkitEnterFullscreen();
2435
+ if ((0, import_core.isFullscreen)(container)) {
2436
+ await (0, import_core.exitFullscreen)(container);
2437
+ } else {
2438
+ await (0, import_core.enterFullscreen)(container);
2021
2439
  }
2022
2440
  } catch {
2023
2441
  }
@@ -2725,6 +3143,155 @@ var BandwidthIndicator = class {
2725
3143
  }
2726
3144
  };
2727
3145
 
3146
+ // src/controls/OverflowTray.ts
3147
+ var OverflowTray = class {
3148
+ /**
3149
+ * @param api - Plugin API, kept for parity with the other controls and for
3150
+ * logging; the tray itself reads no state
3151
+ */
3152
+ constructor(api) {
3153
+ this.api = api;
3154
+ this.isOpen = false;
3155
+ this.toggleHandler = () => {
3156
+ this.isOpen ? this.close() : this.open();
3157
+ };
3158
+ this.el = createElement("div", { className: "sp-overflow" });
3159
+ this.btn = createButton("sp-overflow__btn", "More controls", icons.more);
3160
+ this.btn.setAttribute("aria-haspopup", "true");
3161
+ this.btn.setAttribute("aria-expanded", "false");
3162
+ this.btn.addEventListener("click", this.toggleHandler);
3163
+ this.panel = createElement("div", {
3164
+ className: "sp-overflow-tray",
3165
+ role: "group",
3166
+ "aria-label": "More controls"
3167
+ });
3168
+ this.el.appendChild(this.btn);
3169
+ this.el.appendChild(this.panel);
3170
+ this.el.style.display = "none";
3171
+ this.closeHandler = (e) => {
3172
+ if (!this.el.contains(e.target)) {
3173
+ this.close();
3174
+ }
3175
+ };
3176
+ document.addEventListener("click", this.closeHandler);
3177
+ this.keyHandler = (e) => {
3178
+ if (!this.isOpen || e.key !== "Escape") return;
3179
+ e.preventDefault();
3180
+ e.stopPropagation();
3181
+ this.close();
3182
+ this.btn.focus();
3183
+ };
3184
+ document.addEventListener("keydown", this.keyHandler);
3185
+ }
3186
+ /**
3187
+ * The bar item to place in the control bar.
3188
+ *
3189
+ * @returns The wrapper holding the button and the strip
3190
+ */
3191
+ render() {
3192
+ return this.el;
3193
+ }
3194
+ /**
3195
+ * No state of its own: the fit loop owns what is inside it.
3196
+ */
3197
+ update() {
3198
+ }
3199
+ /**
3200
+ * Move a control's element into the tray.
3201
+ *
3202
+ * The element is moved as-is, so its class, icon, aria-label, event handlers
3203
+ * and `update()` all keep working.
3204
+ *
3205
+ * @param el - The control element leaving the bar
3206
+ */
3207
+ adopt(el) {
3208
+ this.panel.appendChild(el);
3209
+ this.syncVisibility();
3210
+ }
3211
+ /**
3212
+ * Take a control's element back out of the tray.
3213
+ *
3214
+ * The caller decides where in the bar it goes; this only detaches it and
3215
+ * updates the button's visibility.
3216
+ *
3217
+ * @param el - The control element returning to the bar
3218
+ * @returns The same element, detached
3219
+ */
3220
+ release(el) {
3221
+ if (el.parentNode === this.panel) {
3222
+ this.panel.removeChild(el);
3223
+ }
3224
+ this.syncVisibility();
3225
+ return el;
3226
+ }
3227
+ /**
3228
+ * Whether an element is currently held by the tray.
3229
+ *
3230
+ * @param el - Element to test
3231
+ * @returns True when the tray is its parent
3232
+ */
3233
+ holds(el) {
3234
+ return el.parentNode === this.panel;
3235
+ }
3236
+ /**
3237
+ * Open the strip.
3238
+ */
3239
+ open() {
3240
+ if (this.isOpen) return;
3241
+ this.isOpen = true;
3242
+ this.panel.classList.add("sp-overflow-tray--open");
3243
+ this.btn.setAttribute("aria-expanded", "true");
3244
+ }
3245
+ /**
3246
+ * Close the strip.
3247
+ *
3248
+ * Deliberately not called after a control inside it is used: skip, PiP and
3249
+ * cast are things a viewer taps more than once in a row.
3250
+ */
3251
+ close() {
3252
+ if (!this.isOpen) return;
3253
+ this.isOpen = false;
3254
+ this.panel.classList.remove("sp-overflow-tray--open");
3255
+ this.btn.setAttribute("aria-expanded", "false");
3256
+ }
3257
+ /**
3258
+ * Show the button only while the tray holds something the viewer can see.
3259
+ *
3260
+ * A control that hid itself (no cast device on the network, no text tracks)
3261
+ * can be sitting in the tray with `display: none`, and a button that opens an
3262
+ * empty strip is worse than no button at all.
3263
+ */
3264
+ syncVisibility() {
3265
+ const usable = Array.from(this.panel.children).some(
3266
+ (child) => child.style.display !== "none"
3267
+ );
3268
+ this.el.style.display = usable ? "" : "none";
3269
+ if (!usable && this.isOpen) {
3270
+ this.close();
3271
+ }
3272
+ }
3273
+ /**
3274
+ * Re-check the button's visibility after the controls have updated
3275
+ * themselves.
3276
+ */
3277
+ refresh() {
3278
+ this.syncVisibility();
3279
+ }
3280
+ /**
3281
+ * Remove the document listeners and the tray itself.
3282
+ *
3283
+ * Adopted elements are left where they are: they belong to their own
3284
+ * controls, which are destroyed by the plugin alongside this one.
3285
+ */
3286
+ destroy() {
3287
+ document.removeEventListener("click", this.closeHandler);
3288
+ document.removeEventListener("keydown", this.keyHandler);
3289
+ this.btn.removeEventListener("click", this.toggleHandler);
3290
+ this.el.remove();
3291
+ this.api.logger.debug("Overflow tray destroyed");
3292
+ }
3293
+ };
3294
+
2728
3295
  // src/control-registry.ts
2729
3296
  var registry = /* @__PURE__ */ new Map();
2730
3297
  var listeners = /* @__PURE__ */ new Set();
@@ -2751,6 +3318,9 @@ function resetControlRegistry() {
2751
3318
  listeners.clear();
2752
3319
  }
2753
3320
 
3321
+ // src/version.ts
3322
+ var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
3323
+
2754
3324
  // src/index.ts
2755
3325
  var DEFAULT_LAYOUT = [
2756
3326
  "play",
@@ -2769,6 +3339,14 @@ var DEFAULT_LAYOUT = [
2769
3339
  "fullscreen"
2770
3340
  ];
2771
3341
  var DEFAULT_HIDE_DELAY = 3e3;
3342
+ var UNMEASURED_CONTROL_WIDTH = 48;
3343
+ var FALLBACK_VOLUME_SLIDER_WIDTH = 64;
3344
+ var OVERFLOW_BUTTON_WIDTH = 44;
3345
+ var FALLBACK_BAR_PADDING_X = 24;
3346
+ var FALLBACK_BAR_GAP = 4;
3347
+ var MENU_HEIGHT_RESERVE = 16;
3348
+ var FALLBACK_BAR_HEIGHT = 56;
3349
+ var MIN_MENU_HEIGHT = 120;
2772
3350
  function uiPlugin(config = {}) {
2773
3351
  let api;
2774
3352
  let controlBar = null;
@@ -2776,6 +3354,7 @@ function uiPlugin(config = {}) {
2776
3354
  let progressBar = null;
2777
3355
  let bufferingIndicator = null;
2778
3356
  let errorOverlay = null;
3357
+ let bigPlayButton = null;
2779
3358
  let styleEl = null;
2780
3359
  let controls = [];
2781
3360
  let hideTimeout = null;
@@ -2786,8 +3365,22 @@ function uiPlugin(config = {}) {
2786
3365
  let recoveredUnsubscribe = null;
2787
3366
  let controlsVisible = true;
2788
3367
  let rafHandle = null;
3368
+ let tray = null;
3369
+ let entries = [];
3370
+ let timeEntry = null;
3371
+ let resizeObserver = null;
3372
+ let barPaddingX = FALLBACK_BAR_PADDING_X;
3373
+ let barGap = FALLBACK_BAR_GAP;
3374
+ let volumeSliderWidth = FALLBACK_VOLUME_SLIDER_WIDTH;
3375
+ let lastFitSignature = null;
3376
+ let fitPending = true;
2789
3377
  const layout = config.controls || DEFAULT_LAYOUT;
2790
3378
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
3379
+ const showBigPlayButton = config.bigPlayButton !== false;
3380
+ const responsive = config.responsive !== false;
3381
+ if (responsive) {
3382
+ assertFitLayout(layout, config.priority);
3383
+ }
2791
3384
  const createControl = (slot) => {
2792
3385
  switch (slot) {
2793
3386
  case "play":
@@ -2841,13 +3434,179 @@ function uiPlugin(config = {}) {
2841
3434
  if (!controlBar) {
2842
3435
  return;
2843
3436
  }
3437
+ const rules = new Map(
3438
+ resolveFitItems(layout, config.priority).map((template) => [template.id, template])
3439
+ );
2844
3440
  for (const slot of layout) {
2845
3441
  const control = createControl(slot);
2846
- if (control) {
2847
- controls.push(control);
2848
- controlBar.appendChild(control.render());
3442
+ if (!control) {
3443
+ continue;
3444
+ }
3445
+ controls.push(control);
3446
+ const el = control.render();
3447
+ controlBar.appendChild(el);
3448
+ const rule = rules.get(slot);
3449
+ const entry = {
3450
+ slot,
3451
+ control,
3452
+ el,
3453
+ rank: rule?.rank ?? "never",
3454
+ exit: rule?.exit ?? "overflow",
3455
+ width: -1
3456
+ };
3457
+ entries.push(entry);
3458
+ if (slot === "time") {
3459
+ timeEntry = entry;
3460
+ }
3461
+ }
3462
+ if (responsive) {
3463
+ tray = new OverflowTray(api);
3464
+ controls.push(tray);
3465
+ controlBar.appendChild(tray.render());
3466
+ placeTrayButton();
3467
+ }
3468
+ };
3469
+ const placeTrayButton = () => {
3470
+ if (!controlBar || !tray) {
3471
+ return;
3472
+ }
3473
+ const trayEl = tray.render();
3474
+ const fullscreen = entries.find((entry) => entry.slot === "fullscreen");
3475
+ const before = fullscreen && fullscreen.el.parentNode === controlBar ? fullscreen.el : null;
3476
+ if (before) {
3477
+ if (trayEl.nextSibling !== before) {
3478
+ controlBar.insertBefore(trayEl, before);
3479
+ }
3480
+ return;
3481
+ }
3482
+ if (controlBar.lastChild !== trayEl) {
3483
+ controlBar.appendChild(trayEl);
3484
+ }
3485
+ };
3486
+ const visibilitySignature = () => {
3487
+ let flags = "";
3488
+ for (const entry of entries) {
3489
+ flags += entry.el.style.display === "none" ? "0" : "1";
3490
+ }
3491
+ return `${flags}:${timeEntry?.el.textContent?.length ?? 0}`;
3492
+ };
3493
+ const applyFit = (plan) => {
3494
+ if (!controlBar || !tray) {
3495
+ return;
3496
+ }
3497
+ const overflow = new Set(plan.overflow);
3498
+ const hidden = new Set(plan.hidden);
3499
+ for (const entry of entries) {
3500
+ if (entry.slot === "spacer") {
3501
+ continue;
3502
+ }
3503
+ if (hidden.has(entry.slot)) {
3504
+ if (tray.holds(entry.el)) {
3505
+ returnToBar(entry);
3506
+ }
3507
+ entry.el.classList.add("sp-control--collapsed");
3508
+ continue;
3509
+ }
3510
+ entry.el.classList.remove("sp-control--collapsed");
3511
+ if (overflow.has(entry.slot)) {
3512
+ if (!tray.holds(entry.el)) {
3513
+ tray.adopt(entry.el);
3514
+ }
3515
+ } else if (tray.holds(entry.el)) {
3516
+ returnToBar(entry);
3517
+ }
3518
+ }
3519
+ tray.refresh();
3520
+ placeTrayButton();
3521
+ };
3522
+ const returnToBar = (entry) => {
3523
+ if (!controlBar || !tray) {
3524
+ return;
3525
+ }
3526
+ const el = tray.release(entry.el);
3527
+ const trayEl = tray.render();
3528
+ let before = trayEl.parentNode === controlBar ? trayEl : null;
3529
+ for (let i = entries.indexOf(entry) + 1; i < entries.length; i++) {
3530
+ if (entries[i].el.parentNode === controlBar) {
3531
+ before = entries[i].el;
3532
+ break;
3533
+ }
3534
+ }
3535
+ controlBar.insertBefore(el, before);
3536
+ };
3537
+ const expandedWidth = (entry) => {
3538
+ if (entry.slot !== "volume") {
3539
+ return 0;
3540
+ }
3541
+ const wrap = entry.el.querySelector(".sp-volume__slider-wrap");
3542
+ return wrap ? wrap.getBoundingClientRect().width : 0;
3543
+ };
3544
+ const interactionReserve = (entry) => entry.slot === "volume" ? volumeSliderWidth : 0;
3545
+ const readBarMetrics = (bar) => {
3546
+ const barStyle = getComputedStyle(bar);
3547
+ const paddingLeft = parseFloat(barStyle.paddingLeft);
3548
+ const paddingRight = parseFloat(barStyle.paddingRight);
3549
+ const gap = parseFloat(barStyle.columnGap || barStyle.gap);
3550
+ const sliderWidth = parseFloat(
3551
+ barStyle.getPropertyValue("--sp-volume-slider-width")
3552
+ );
3553
+ barPaddingX = Number.isFinite(paddingLeft) && Number.isFinite(paddingRight) ? paddingLeft + paddingRight : FALLBACK_BAR_PADDING_X;
3554
+ barGap = Number.isFinite(gap) ? gap : FALLBACK_BAR_GAP;
3555
+ volumeSliderWidth = Number.isFinite(sliderWidth) ? sliderWidth : FALLBACK_VOLUME_SLIDER_WIDTH;
3556
+ };
3557
+ const fitControls = () => {
3558
+ if (!responsive || !controlBar || !tray) {
3559
+ return;
3560
+ }
3561
+ if (controlBar.clientWidth === 0) {
3562
+ return;
3563
+ }
3564
+ readBarMetrics(controlBar);
3565
+ const spacerGaps = entries.filter(
3566
+ (entry) => entry.slot === "spacer" && entry.el.style.display !== "none"
3567
+ ).length;
3568
+ const available = controlBar.clientWidth - barPaddingX - spacerGaps * barGap;
3569
+ const items = [];
3570
+ for (const entry of entries) {
3571
+ if (entry.slot === "spacer") {
3572
+ continue;
3573
+ }
3574
+ const visible = entry.el.style.display !== "none";
3575
+ const measurable = visible && entry.el.parentNode === controlBar && !entry.el.classList.contains("sp-control--collapsed");
3576
+ if (measurable) {
3577
+ entry.width = entry.el.getBoundingClientRect().width - expandedWidth(entry);
3578
+ } else if (entry.width < 0) {
3579
+ entry.width = UNMEASURED_CONTROL_WIDTH;
2849
3580
  }
3581
+ items.push({
3582
+ id: entry.slot,
3583
+ rank: entry.rank,
3584
+ exit: entry.exit,
3585
+ width: entry.width + interactionReserve(entry),
3586
+ visible
3587
+ });
2850
3588
  }
3589
+ const trayEl = tray.render();
3590
+ const trayWidth = trayEl.style.display === "none" ? 0 : trayEl.getBoundingClientRect().width;
3591
+ applyFit(planFit(items, available, barGap, trayWidth || OVERFLOW_BUTTON_WIDTH));
3592
+ lastFitSignature = visibilitySignature();
3593
+ fitPending = false;
3594
+ };
3595
+ const maybeFit = () => {
3596
+ if (!responsive) {
3597
+ return;
3598
+ }
3599
+ if (!fitPending && visibilitySignature() === lastFitSignature) {
3600
+ return;
3601
+ }
3602
+ fitControls();
3603
+ };
3604
+ const applyMenuBounds = (height) => {
3605
+ const barHeight = controlBar?.offsetHeight || FALLBACK_BAR_HEIGHT;
3606
+ api?.container?.style.setProperty(
3607
+ "--sp-menu-max-height",
3608
+ `${Math.max(MIN_MENU_HEIGHT, Math.round(height) - barHeight - MENU_HEIGHT_RESERVE)}px`
3609
+ );
2851
3610
  };
2852
3611
  const rebuildControlBar = () => {
2853
3612
  if (!controlBar) {
@@ -2855,7 +3614,12 @@ function uiPlugin(config = {}) {
2855
3614
  }
2856
3615
  controls.forEach((c) => c.destroy());
2857
3616
  controls = [];
3617
+ entries = [];
3618
+ timeEntry = null;
3619
+ tray = null;
2858
3620
  controlBar.replaceChildren();
3621
+ lastFitSignature = null;
3622
+ fitPending = true;
2859
3623
  populateControlBar();
2860
3624
  updateControls();
2861
3625
  };
@@ -2869,6 +3633,8 @@ function uiPlugin(config = {}) {
2869
3633
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
2870
3634
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
2871
3635
  errorOverlay?.update();
3636
+ bigPlayButton?.update();
3637
+ maybeFit();
2872
3638
  };
2873
3639
  const scheduleUpdate = () => {
2874
3640
  if (rafHandle !== null) return;
@@ -2947,11 +3713,11 @@ function uiPlugin(config = {}) {
2947
3713
  break;
2948
3714
  case "f":
2949
3715
  e.preventDefault();
2950
- if (document.fullscreenElement) {
2951
- document.exitFullscreen().catch(() => {
3716
+ if ((0, import_core2.isFullscreen)(api.container)) {
3717
+ (0, import_core2.exitFullscreen)(api.container).catch(() => {
2952
3718
  });
2953
3719
  } else {
2954
- api.container.requestFullscreen?.().catch(() => {
3720
+ (0, import_core2.enterFullscreen)(api.container).catch(() => {
2955
3721
  });
2956
3722
  }
2957
3723
  break;
@@ -2989,7 +3755,7 @@ function uiPlugin(config = {}) {
2989
3755
  id: "ui-controls",
2990
3756
  name: "UI Controls",
2991
3757
  type: "ui",
2992
- version: "1.0.0",
3758
+ version: PKG_VERSION,
2993
3759
  async init(pluginApi) {
2994
3760
  api = pluginApi;
2995
3761
  styleEl = document.createElement("style");
@@ -3030,6 +3796,10 @@ function uiPlugin(config = {}) {
3030
3796
  recoveredUnsubscribe = api.on("error:recovered", () => {
3031
3797
  errorOverlay?.hide();
3032
3798
  });
3799
+ if (showBigPlayButton) {
3800
+ bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
3801
+ container.appendChild(bigPlayButton.render());
3802
+ }
3033
3803
  progressBar = new ProgressBar(api);
3034
3804
  container.appendChild(progressBar.render());
3035
3805
  if (!isPlaying) {
@@ -3041,6 +3811,14 @@ function uiPlugin(config = {}) {
3041
3811
  controlBar.setAttribute("aria-label", "Video controls");
3042
3812
  populateControlBar();
3043
3813
  container.appendChild(controlBar);
3814
+ if (responsive && typeof ResizeObserver === "function") {
3815
+ resizeObserver = new ResizeObserver((observed) => {
3816
+ applyMenuBounds(observed[0]?.contentRect.height ?? container.clientHeight);
3817
+ fitPending = true;
3818
+ scheduleUpdate();
3819
+ });
3820
+ resizeObserver.observe(container);
3821
+ }
3044
3822
  controlRegistryUnsubscribe = onControlRegistered((id) => {
3045
3823
  if (!layout.includes(id)) {
3046
3824
  return;
@@ -3057,7 +3835,6 @@ function uiPlugin(config = {}) {
3057
3835
  container.addEventListener("click", handleInteraction);
3058
3836
  document.addEventListener("keydown", handleKeyDown);
3059
3837
  stateUnsubscribe = api.subscribeToState(scheduleUpdate);
3060
- document.addEventListener("fullscreenchange", scheduleUpdate);
3061
3838
  updateControls();
3062
3839
  if (!container.hasAttribute("tabindex")) {
3063
3840
  container.setAttribute("tabindex", "0");
@@ -3078,6 +3855,9 @@ function uiPlugin(config = {}) {
3078
3855
  cancelAnimationFrame(rafHandle);
3079
3856
  rafHandle = null;
3080
3857
  }
3858
+ resizeObserver?.disconnect();
3859
+ resizeObserver = null;
3860
+ api?.container?.style.removeProperty("--sp-menu-max-height");
3081
3861
  stateUnsubscribe?.();
3082
3862
  stateUnsubscribe = null;
3083
3863
  errorUnsubscribe?.();
@@ -3096,15 +3876,19 @@ function uiPlugin(config = {}) {
3096
3876
  api.container.removeEventListener("click", handleInteraction);
3097
3877
  }
3098
3878
  document.removeEventListener("keydown", handleKeyDown);
3099
- document.removeEventListener("fullscreenchange", scheduleUpdate);
3100
3879
  controlRegistryUnsubscribe?.();
3101
3880
  controlRegistryUnsubscribe = null;
3102
3881
  controls.forEach((c) => c.destroy());
3103
3882
  controls = [];
3883
+ entries = [];
3884
+ timeEntry = null;
3885
+ tray = null;
3104
3886
  progressBar?.destroy();
3105
3887
  progressBar = null;
3106
3888
  errorOverlay?.destroy();
3107
3889
  errorOverlay = null;
3890
+ bigPlayButton?.destroy();
3891
+ bigPlayButton = null;
3108
3892
  controlBar?.remove();
3109
3893
  controlBar = null;
3110
3894
  gradient?.remove();
@@ -3153,12 +3937,16 @@ function uiPlugin(config = {}) {
3153
3937
  var index_default = uiPlugin;
3154
3938
  // Annotate the CommonJS export names for ESM import in node:
3155
3939
  0 && (module.exports = {
3940
+ DEFAULT_PRIORITY,
3941
+ assertFitLayout,
3156
3942
  formatLiveTime,
3157
3943
  formatTime,
3158
3944
  getControlFactory,
3159
3945
  icons,
3946
+ planFit,
3160
3947
  registerControl,
3161
3948
  resetControlRegistry,
3949
+ resolveFitItems,
3162
3950
  styles,
3163
3951
  uiPlugin,
3164
3952
  unregisterControl