@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.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:
|
|
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:
|
|
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 (
|
|
1981
|
-
await
|
|
1982
|
-
} else
|
|
1983
|
-
await container
|
|
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
|
-
|
|
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 (
|
|
2916
|
-
|
|
3679
|
+
if (isFullscreen2(api.container)) {
|
|
3680
|
+
exitFullscreen2(api.container).catch(() => {
|
|
2917
3681
|
});
|
|
2918
3682
|
} else {
|
|
2919
|
-
api.container
|
|
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:
|
|
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
|