@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/README.md +86 -2
- package/dist/index.cjs +808 -20
- package/dist/index.d.cts +225 -4
- package/dist/index.d.ts +225 -4
- package/dist/index.js +806 -20
- package/package.json +5 -5
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:
|
|
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:
|
|
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 (
|
|
2016
|
-
await
|
|
2017
|
-
} else
|
|
2018
|
-
await
|
|
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
|
-
|
|
2848
|
-
|
|
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 (
|
|
2951
|
-
|
|
3716
|
+
if ((0, import_core2.isFullscreen)(api.container)) {
|
|
3717
|
+
(0, import_core2.exitFullscreen)(api.container).catch(() => {
|
|
2952
3718
|
});
|
|
2953
3719
|
} else {
|
|
2954
|
-
api.container
|
|
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:
|
|
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
|