ts-video-player 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn.min.js +49 -25
- package/dist/{chunk-hvjcmbfn.js → chunk-cay3ahqr.js} +58 -7
- package/dist/{chunk-ds1nj487.js → chunk-ndbfytd6.js} +1 -1
- package/dist/{chunk-rhykj0qy.js → chunk-pvq6e7gz.js} +17 -3
- package/dist/core/events.d.ts +3 -0
- package/dist/elements/index.js +1 -1
- package/dist/features/index.js +1 -1
- package/dist/index.js +3 -3
- package/dist/providers/base.d.ts +12 -0
- package/package.json +1 -1
- package/src/core/events.ts +19 -3
- package/src/elements/media-airplay-button.ts +2 -0
- package/src/elements/media-fullscreen-button.ts +2 -0
- package/src/elements/media-live-button.ts +2 -0
- package/src/elements/media-mute-button.ts +2 -0
- package/src/elements/media-pip-button.ts +2 -0
- package/src/elements/media-play-button.ts +2 -0
- package/src/elements/media-playback-rate-button.ts +2 -0
- package/src/elements/media-progress-bar.ts +2 -0
- package/src/elements/media-remote-playback-button.ts +2 -0
- package/src/elements/media-seek-button.ts +2 -0
- package/src/elements/media-settings-menu.ts +2 -0
- package/src/elements/media-volume-slider.ts +2 -0
- package/src/elements/video-player.ts +3 -1
- package/src/player.ts +13 -2
- package/src/providers/base.ts +34 -1
- package/src/providers/hls.ts +5 -0
- package/src/providers/html5.ts +5 -0
- package/src/providers/vimeo.ts +1 -2
- package/src/providers/youtube.ts +1 -0
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
detectPipAvailability2,
|
|
17
17
|
enterPiP2,
|
|
18
18
|
exitPiP2
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-pvq6e7gz.js";
|
|
20
20
|
|
|
21
21
|
// src/core/media-session.ts
|
|
22
22
|
function isMediaSessionSupported2() {
|
|
@@ -148,6 +148,8 @@ class BaseProvider2 {
|
|
|
148
148
|
}
|
|
149
149
|
destroy() {
|
|
150
150
|
this._ready = false;
|
|
151
|
+
this.stopEmbedFullscreen?.();
|
|
152
|
+
this.stopEmbedFullscreen = null;
|
|
151
153
|
this.cleanup();
|
|
152
154
|
this.events.removeAllListeners();
|
|
153
155
|
}
|
|
@@ -167,6 +169,25 @@ class BaseProvider2 {
|
|
|
167
169
|
return [];
|
|
168
170
|
}
|
|
169
171
|
setAudioTrack(_trackId) {}
|
|
172
|
+
stopEmbedFullscreen = null;
|
|
173
|
+
announceEmbedFeatures() {
|
|
174
|
+
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined")
|
|
175
|
+
return;
|
|
176
|
+
const fullscreenAvailability = this.getFeatureAvailability("fullscreen");
|
|
177
|
+
const pipAvailability = this.getFeatureAvailability("pip");
|
|
178
|
+
const volumeAvailability = this.getFeatureAvailability("volume");
|
|
179
|
+
this.events.emit("statechange", {
|
|
180
|
+
canFullscreen: fullscreenAvailability === "available",
|
|
181
|
+
canPictureInPicture: pipAvailability === "available",
|
|
182
|
+
fullscreenAvailability,
|
|
183
|
+
pipAvailability,
|
|
184
|
+
volumeAvailability
|
|
185
|
+
});
|
|
186
|
+
this.events.emit("availabilitychange", "fullscreen", fullscreenAvailability);
|
|
187
|
+
this.events.emit("availabilitychange", "pip", pipAvailability);
|
|
188
|
+
this.events.emit("availabilitychange", "volume", volumeAvailability);
|
|
189
|
+
this.stopEmbedFullscreen ??= onFullscreenChange2((fullscreen) => this.events.emit("fullscreenchange", fullscreen));
|
|
190
|
+
}
|
|
170
191
|
getFeatureAvailability(feature) {
|
|
171
192
|
const el = this.mediaElement;
|
|
172
193
|
const media = el instanceof HTMLMediaElement ? el : null;
|
|
@@ -267,6 +288,8 @@ class HTML5Provider2 extends BaseProvider2 {
|
|
|
267
288
|
this.isAudio = this.options.mediaType === "audio";
|
|
268
289
|
this.media = document.createElement(this.isAudio ? "audio" : "video");
|
|
269
290
|
this.media.className = "ts-video-player__media";
|
|
291
|
+
if (!this.isAudio)
|
|
292
|
+
this.media.style.cssText = "display:block;width:100%;height:100%;object-fit:contain;background:#000";
|
|
270
293
|
if (this.options.autoplay)
|
|
271
294
|
this.media.autoplay = true;
|
|
272
295
|
if (this.options.loop)
|
|
@@ -715,6 +738,7 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
715
738
|
}, 250);
|
|
716
739
|
this.events.emit("loadedmetadata");
|
|
717
740
|
this.events.emit("canplay");
|
|
741
|
+
this.announceEmbedFeatures();
|
|
718
742
|
if (this.pendingVideoId) {
|
|
719
743
|
const videoId = this.pendingVideoId;
|
|
720
744
|
this.pendingVideoId = null;
|
|
@@ -1066,10 +1090,9 @@ class VimeoProvider2 extends BaseProvider2 {
|
|
|
1066
1090
|
this.events.emit("statechange", {
|
|
1067
1091
|
videoWidth: width,
|
|
1068
1092
|
videoHeight: height,
|
|
1069
|
-
aspectRatio: width / height || 16 / 9
|
|
1070
|
-
canFullscreen: true,
|
|
1071
|
-
canPictureInPicture: false
|
|
1093
|
+
aspectRatio: width / height || 16 / 9
|
|
1072
1094
|
});
|
|
1095
|
+
this.announceEmbedFeatures();
|
|
1073
1096
|
}
|
|
1074
1097
|
attachVimeoEvents() {
|
|
1075
1098
|
if (!this.player)
|
|
@@ -1279,6 +1302,7 @@ class HLSProvider2 extends BaseProvider2 {
|
|
|
1279
1302
|
}
|
|
1280
1303
|
this.video = document.createElement("video");
|
|
1281
1304
|
this.video.className = "ts-video-player__media";
|
|
1305
|
+
this.video.style.cssText = "display:block;width:100%;height:100%;object-fit:contain;background:#000";
|
|
1282
1306
|
if (this.options.autoplay)
|
|
1283
1307
|
this.video.autoplay = true;
|
|
1284
1308
|
if (this.options.loop)
|
|
@@ -2848,6 +2872,8 @@ class Player2 {
|
|
|
2848
2872
|
toggleCaptions: () => this.toggleCaptions(),
|
|
2849
2873
|
seekBackward: () => this.seekBy(-5),
|
|
2850
2874
|
seekForward: () => this.seekBy(5),
|
|
2875
|
+
seekBackwardLong: () => this.seekBy(-10),
|
|
2876
|
+
seekForwardLong: () => this.seekBy(10),
|
|
2851
2877
|
volumeUp: () => this.setVolume(Math.min(1, this.state.volume + 0.1)),
|
|
2852
2878
|
volumeDown: () => this.setVolume(Math.max(0, this.state.volume - 0.1)),
|
|
2853
2879
|
speedUp: () => this.setPlaybackRate(Math.min(4, this.state.playbackRate + 0.25)),
|
|
@@ -3088,7 +3114,7 @@ class Player2 {
|
|
|
3088
3114
|
this.announce(label(this._el, "playing"));
|
|
3089
3115
|
});
|
|
3090
3116
|
provider.on("pause", () => {
|
|
3091
|
-
this._store.
|
|
3117
|
+
this._store.batch({ paused: true, playing: false, playbackState: "paused" });
|
|
3092
3118
|
this._events.emit("pause");
|
|
3093
3119
|
this.announce(label(this._el, "paused"));
|
|
3094
3120
|
});
|
|
@@ -3239,7 +3265,8 @@ class Player2 {
|
|
|
3239
3265
|
if (this.state.pictureInPicture) {
|
|
3240
3266
|
await this.exitPiP();
|
|
3241
3267
|
}
|
|
3242
|
-
|
|
3268
|
+
const media = this._provider?.mediaElement;
|
|
3269
|
+
await enterFullscreen2(this._el, media instanceof HTMLMediaElement ? media : null);
|
|
3243
3270
|
if (this._options.fullscreenOrientationLock !== false) {
|
|
3244
3271
|
lockOrientation2("landscape");
|
|
3245
3272
|
}
|
|
@@ -3357,7 +3384,7 @@ class VideoPlayerElement2 extends HTMLElementBase {
|
|
|
3357
3384
|
this._disconnectId++;
|
|
3358
3385
|
if (this._player)
|
|
3359
3386
|
return;
|
|
3360
|
-
const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" });
|
|
3387
|
+
const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open", delegatesFocus: true });
|
|
3361
3388
|
this._container = document.createElement("div");
|
|
3362
3389
|
this._container.setAttribute("part", "container");
|
|
3363
3390
|
this._container.style.cssText = "position:relative;width:100%;height:100%;";
|
|
@@ -3643,6 +3670,8 @@ class MediaPlayButton2 extends HTMLElementBase {
|
|
|
3643
3670
|
background: transparent; border: none; color: inherit; cursor: pointer;
|
|
3644
3671
|
}
|
|
3645
3672
|
button:hover { opacity: 0.8; }
|
|
3673
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
3674
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
3646
3675
|
</style>
|
|
3647
3676
|
<button part="button" type="button" aria-label="${label(this, "play")}">
|
|
3648
3677
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -3704,6 +3733,8 @@ class MediaMuteButton2 extends HTMLElementBase {
|
|
|
3704
3733
|
background: transparent; border: none; color: inherit; cursor: pointer;
|
|
3705
3734
|
}
|
|
3706
3735
|
button:hover { opacity: 0.8; }
|
|
3736
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
3737
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
3707
3738
|
</style>
|
|
3708
3739
|
<button part="button" type="button" aria-label="${label(this, "mute")}">
|
|
3709
3740
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -3753,6 +3784,8 @@ class MediaFullscreenButton2 extends HTMLElementBase {
|
|
|
3753
3784
|
}
|
|
3754
3785
|
button:hover { opacity: 0.8; }
|
|
3755
3786
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
3787
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
3788
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
3756
3789
|
</style>
|
|
3757
3790
|
<button part="button" type="button" aria-label="${label(this, "enterFullscreen")}">
|
|
3758
3791
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -3808,6 +3841,8 @@ class MediaPipButton2 extends HTMLElementBase {
|
|
|
3808
3841
|
}
|
|
3809
3842
|
button:hover { opacity: 0.8; }
|
|
3810
3843
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
3844
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
3845
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
3811
3846
|
</style>
|
|
3812
3847
|
<button part="button" type="button" aria-label="${label(this, "enterPiP")}">
|
|
3813
3848
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -4068,6 +4103,8 @@ class MediaProgressBar2 extends HTMLElementBase {
|
|
|
4068
4103
|
border-top-color: rgba(0,0,0,0.85);
|
|
4069
4104
|
}
|
|
4070
4105
|
.container:hover .tooltip { opacity: 1; }
|
|
4106
|
+
/* A finger needs more than the 20px a mouse does. */
|
|
4107
|
+
@media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
|
|
4071
4108
|
</style>
|
|
4072
4109
|
<div class="container" part="container" role="slider"
|
|
4073
4110
|
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
|
|
@@ -4216,6 +4253,8 @@ class MediaVolumeSlider2 extends HTMLElementBase {
|
|
|
4216
4253
|
transition: transform 0.1s;
|
|
4217
4254
|
}
|
|
4218
4255
|
.container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
|
|
4256
|
+
/* A finger needs more than the 20px a mouse does. */
|
|
4257
|
+
@media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
|
|
4219
4258
|
</style>
|
|
4220
4259
|
<div class="container" part="container" role="slider"
|
|
4221
4260
|
aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
|
|
@@ -4412,6 +4451,8 @@ class MediaSettingsMenu2 extends HTMLElementBase {
|
|
|
4412
4451
|
.item[aria-checked="true"]::before { content: '\\2713'; }
|
|
4413
4452
|
.item[aria-checked="false"]::before { content: ''; min-width: 12px; display: inline-block; }
|
|
4414
4453
|
.back { font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.2); }
|
|
4454
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
4455
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
4415
4456
|
</style>
|
|
4416
4457
|
<button class="trigger" part="trigger" type="button"
|
|
4417
4458
|
aria-label="${label(this, "settings")}" aria-haspopup="true" aria-expanded="false">
|
|
@@ -4641,6 +4682,8 @@ class MediaSeekButton2 extends HTMLElementBase {
|
|
|
4641
4682
|
button:hover { opacity: .8; }
|
|
4642
4683
|
button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
4643
4684
|
button:disabled { cursor: default; opacity: .45; }
|
|
4685
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
4686
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
4644
4687
|
</style>
|
|
4645
4688
|
<button part="button" type="button"></button>
|
|
4646
4689
|
`;
|
|
@@ -4686,6 +4729,8 @@ class MediaPlaybackRateButton2 extends HTMLElementBase {
|
|
|
4686
4729
|
button { min-width: 42px; height: 40px; padding: 0 8px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-variant-numeric: tabular-nums; }
|
|
4687
4730
|
button:hover { opacity: .8; }
|
|
4688
4731
|
button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
4732
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
4733
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
4689
4734
|
</style>
|
|
4690
4735
|
<button part="button" type="button" aria-label="${label(this, "speed")}"></button>
|
|
4691
4736
|
`;
|
|
@@ -4736,6 +4781,8 @@ class MediaAirplayButton2 extends HTMLElementBase {
|
|
|
4736
4781
|
button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
|
|
4737
4782
|
button:hover { opacity: 0.8; }
|
|
4738
4783
|
:host([connected]) button { color: var(--media-airplay-active-color, #38bdf8); }
|
|
4784
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
4785
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
4739
4786
|
</style>
|
|
4740
4787
|
<button part="button" type="button" aria-label="${label(this, "airPlayPlay")}">
|
|
4741
4788
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${AIRPLAY_ICON}"/></svg>
|
|
@@ -4795,6 +4842,8 @@ class MediaLiveButton2 extends HTMLElementBase {
|
|
|
4795
4842
|
button { display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; }
|
|
4796
4843
|
i { width: 8px; height: 8px; border-radius: 9999px; background: #ef4444; }
|
|
4797
4844
|
:host(:not([at-live-edge])) button { opacity: 0.72; }
|
|
4845
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
4846
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
4798
4847
|
</style>
|
|
4799
4848
|
<button part="button" type="button" aria-label="${label(this, "seekToLive")}"><i aria-hidden="true"></i><span>${label(this, "live")}</span></button>
|
|
4800
4849
|
`;
|
|
@@ -4891,6 +4940,8 @@ class MediaRemotePlaybackButton2 extends HTMLElementBase {
|
|
|
4891
4940
|
button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
|
|
4892
4941
|
button:hover { opacity: 0.8; }
|
|
4893
4942
|
:host([connected]) button { color: var(--media-remote-active-color, #38bdf8); }
|
|
4943
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
4944
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
4894
4945
|
</style>
|
|
4895
4946
|
<button part="button" type="button" aria-label="${label(this, "remotePlayback")}">
|
|
4896
4947
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${REMOTE_ICON}"/></svg>
|
|
@@ -383,7 +383,9 @@ class MediaEventsNormalizer2 {
|
|
|
383
383
|
}
|
|
384
384
|
function onFullscreenChange2(handler) {
|
|
385
385
|
const events = ["fullscreenchange", "webkitfullscreenchange", "mozfullscreenchange", "MSFullscreenChange"];
|
|
386
|
-
const listener = () => {
|
|
386
|
+
const listener = (event) => {
|
|
387
|
+
if (event && typeof CustomEvent !== "undefined" && event instanceof CustomEvent)
|
|
388
|
+
return;
|
|
387
389
|
const fullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
|
|
388
390
|
handler(fullscreen);
|
|
389
391
|
};
|
|
@@ -411,8 +413,8 @@ var DEFAULT_KEYBOARD_SHORTCUTS2 = {
|
|
|
411
413
|
c: "toggleCaptions",
|
|
412
414
|
ArrowLeft: "seekBackward",
|
|
413
415
|
ArrowRight: "seekForward",
|
|
414
|
-
j: "
|
|
415
|
-
l: "
|
|
416
|
+
j: "seekBackwardLong",
|
|
417
|
+
l: "seekForwardLong",
|
|
416
418
|
ArrowUp: "volumeUp",
|
|
417
419
|
ArrowDown: "volumeDown",
|
|
418
420
|
">": "speedUp",
|
|
@@ -463,6 +465,18 @@ function createKeyboardHandler2(actions, options = {}) {
|
|
|
463
465
|
case "seekForward":
|
|
464
466
|
actions.seekForward();
|
|
465
467
|
break;
|
|
468
|
+
case "seekBackwardLong":
|
|
469
|
+
if (actions.seekBackwardLong)
|
|
470
|
+
actions.seekBackwardLong();
|
|
471
|
+
else
|
|
472
|
+
actions.seekBackward();
|
|
473
|
+
break;
|
|
474
|
+
case "seekForwardLong":
|
|
475
|
+
if (actions.seekForwardLong)
|
|
476
|
+
actions.seekForwardLong();
|
|
477
|
+
else
|
|
478
|
+
actions.seekForward();
|
|
479
|
+
break;
|
|
466
480
|
case "volumeUp":
|
|
467
481
|
actions.volumeUp();
|
|
468
482
|
break;
|
package/dist/core/events.d.ts
CHANGED
|
@@ -78,6 +78,9 @@ export interface KeyboardActions {
|
|
|
78
78
|
toggleCaptions(): void;
|
|
79
79
|
seekBackward(): void;
|
|
80
80
|
seekForward(): void;
|
|
81
|
+
/** J and L: ten seconds, as YouTube's keys go (the arrows are five). Falls back to seekBackward/seekForward. */
|
|
82
|
+
seekBackwardLong?(): void;
|
|
83
|
+
seekForwardLong?(): void;
|
|
81
84
|
volumeUp(): void;
|
|
82
85
|
volumeDown(): void;
|
|
83
86
|
speedUp(): void;
|
package/dist/elements/index.js
CHANGED
package/dist/features/index.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
applyPosition2,
|
|
64
64
|
MediaSettingsMenu2,
|
|
65
65
|
registerElements2
|
|
66
|
-
} from "./chunk-
|
|
66
|
+
} from "./chunk-cay3ahqr.js";
|
|
67
67
|
import {
|
|
68
68
|
playback2,
|
|
69
69
|
volume2,
|
|
@@ -77,7 +77,7 @@ import {
|
|
|
77
77
|
videoFeatures2,
|
|
78
78
|
audioFeatures2,
|
|
79
79
|
minimalFeatures2
|
|
80
|
-
} from "./chunk-
|
|
80
|
+
} from "./chunk-ndbfytd6.js";
|
|
81
81
|
import {
|
|
82
82
|
shallowEqual2,
|
|
83
83
|
createSignal2,
|
|
@@ -118,7 +118,7 @@ import {
|
|
|
118
118
|
enterPiP2,
|
|
119
119
|
exitPiP2,
|
|
120
120
|
detectAllFeatures2
|
|
121
|
-
} from "./chunk-
|
|
121
|
+
} from "./chunk-pvq6e7gz.js";
|
|
122
122
|
// src/core/audio-gain.ts
|
|
123
123
|
class AudioGain {
|
|
124
124
|
media;
|
package/dist/providers/base.d.ts
CHANGED
|
@@ -43,6 +43,18 @@ export declare abstract class BaseProvider implements Provider {
|
|
|
43
43
|
setTextTrackMode(_trackId: string, _mode: 'disabled' | 'hidden' | 'showing'): void;
|
|
44
44
|
getAudioTracks(): AudioTrack[];
|
|
45
45
|
setAudioTrack(_trackId: string): void;
|
|
46
|
+
private stopEmbedFullscreen;
|
|
47
|
+
/**
|
|
48
|
+
* What an embedded player (YouTube, Vimeo) can do, said once it is ready.
|
|
49
|
+
*
|
|
50
|
+
* The native-media providers say it on loadedmetadata; the embeds said
|
|
51
|
+
* nothing, so fullscreen stayed "unavailable" and its button disabled,
|
|
52
|
+
* though the player can fill the screen like any other. Fullscreen is the
|
|
53
|
+
* container's own (the player's controls come along); picture in picture
|
|
54
|
+
* is the embed's business. Also follows the document into and out of
|
|
55
|
+
* fullscreen, which only the native providers did.
|
|
56
|
+
*/
|
|
57
|
+
protected announceEmbedFeatures(): void;
|
|
46
58
|
getFeatureAvailability(feature: 'volume' | 'fullscreen' | 'pip'): FeatureAvailability;
|
|
47
59
|
enterFullscreen(): Promise<void>;
|
|
48
60
|
exitFullscreen(): Promise<void>;
|
package/package.json
CHANGED
package/src/core/events.ts
CHANGED
|
@@ -208,7 +208,12 @@ export class MediaEventsNormalizer {
|
|
|
208
208
|
export function onFullscreenChange(handler: (fullscreen: boolean) => void): () => void {
|
|
209
209
|
const events = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
|
|
210
210
|
|
|
211
|
-
const listener = () => {
|
|
211
|
+
const listener = (event?: Event) => {
|
|
212
|
+
// The browser's own change only. <video-player> re-dispatches the player's
|
|
213
|
+
// events on itself, bubbling and composed, under the same names; its
|
|
214
|
+
// "fullscreenchange" reached this document listener, which announced it
|
|
215
|
+
// again, and the two fed each other until the stack overflowed.
|
|
216
|
+
if (event && typeof CustomEvent !== 'undefined' && event instanceof CustomEvent) return
|
|
212
217
|
const fullscreen = !!(
|
|
213
218
|
document.fullscreenElement ||
|
|
214
219
|
(document as any).webkitFullscreenElement ||
|
|
@@ -261,8 +266,8 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: Record<string, string> = {
|
|
|
261
266
|
c: 'toggleCaptions',
|
|
262
267
|
ArrowLeft: 'seekBackward',
|
|
263
268
|
ArrowRight: 'seekForward',
|
|
264
|
-
j: '
|
|
265
|
-
l: '
|
|
269
|
+
j: 'seekBackwardLong',
|
|
270
|
+
l: 'seekForwardLong',
|
|
266
271
|
ArrowUp: 'volumeUp',
|
|
267
272
|
ArrowDown: 'volumeDown',
|
|
268
273
|
'>': 'speedUp',
|
|
@@ -292,6 +297,9 @@ export interface KeyboardActions {
|
|
|
292
297
|
toggleCaptions(): void
|
|
293
298
|
seekBackward(): void
|
|
294
299
|
seekForward(): void
|
|
300
|
+
/** J and L: ten seconds, as YouTube's keys go (the arrows are five). Falls back to seekBackward/seekForward. */
|
|
301
|
+
seekBackwardLong?(): void
|
|
302
|
+
seekForwardLong?(): void
|
|
295
303
|
volumeUp(): void
|
|
296
304
|
volumeDown(): void
|
|
297
305
|
speedUp(): void
|
|
@@ -351,6 +359,14 @@ export function createKeyboardHandler(
|
|
|
351
359
|
case 'seekForward':
|
|
352
360
|
actions.seekForward()
|
|
353
361
|
break
|
|
362
|
+
case 'seekBackwardLong':
|
|
363
|
+
if (actions.seekBackwardLong) actions.seekBackwardLong()
|
|
364
|
+
else actions.seekBackward()
|
|
365
|
+
break
|
|
366
|
+
case 'seekForwardLong':
|
|
367
|
+
if (actions.seekForwardLong) actions.seekForwardLong()
|
|
368
|
+
else actions.seekForward()
|
|
369
|
+
break
|
|
354
370
|
case 'volumeUp':
|
|
355
371
|
actions.volumeUp()
|
|
356
372
|
break
|
|
@@ -27,6 +27,8 @@ export class MediaAirplayButton extends HTMLElementBase {
|
|
|
27
27
|
button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
|
|
28
28
|
button:hover { opacity: 0.8; }
|
|
29
29
|
:host([connected]) button { color: var(--media-airplay-active-color, #38bdf8); }
|
|
30
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
31
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
30
32
|
</style>
|
|
31
33
|
<button part="button" type="button" aria-label="${label(this, 'airPlayPlay')}">
|
|
32
34
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${AIRPLAY_ICON}"/></svg>
|
|
@@ -28,6 +28,8 @@ export class MediaFullscreenButton extends HTMLElementBase {
|
|
|
28
28
|
}
|
|
29
29
|
button:hover { opacity: 0.8; }
|
|
30
30
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
31
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
32
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
31
33
|
</style>
|
|
32
34
|
<button part="button" type="button" aria-label="${label(this, 'enterFullscreen')}">
|
|
33
35
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -20,6 +20,8 @@ export class MediaLiveButton extends HTMLElementBase {
|
|
|
20
20
|
button { display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; }
|
|
21
21
|
i { width: 8px; height: 8px; border-radius: 9999px; background: #ef4444; }
|
|
22
22
|
:host(:not([at-live-edge])) button { opacity: 0.72; }
|
|
23
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
24
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
23
25
|
</style>
|
|
24
26
|
<button part="button" type="button" aria-label="${label(this, 'seekToLive')}"><i aria-hidden="true"></i><span>${label(this, 'live')}</span></button>
|
|
25
27
|
`
|
|
@@ -33,6 +33,8 @@ export class MediaMuteButton extends HTMLElementBase {
|
|
|
33
33
|
background: transparent; border: none; color: inherit; cursor: pointer;
|
|
34
34
|
}
|
|
35
35
|
button:hover { opacity: 0.8; }
|
|
36
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
37
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
36
38
|
</style>
|
|
37
39
|
<button part="button" type="button" aria-label="${label(this, 'mute')}">
|
|
38
40
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -27,6 +27,8 @@ export class MediaPipButton extends HTMLElementBase {
|
|
|
27
27
|
}
|
|
28
28
|
button:hover { opacity: 0.8; }
|
|
29
29
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
30
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
31
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
30
32
|
</style>
|
|
31
33
|
<button part="button" type="button" aria-label="${label(this, 'enterPiP')}">
|
|
32
34
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -27,6 +27,8 @@ export class MediaPlayButton extends HTMLElementBase {
|
|
|
27
27
|
background: transparent; border: none; color: inherit; cursor: pointer;
|
|
28
28
|
}
|
|
29
29
|
button:hover { opacity: 0.8; }
|
|
30
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
31
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
30
32
|
</style>
|
|
31
33
|
<button part="button" type="button" aria-label="${label(this, 'play')}">
|
|
32
34
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
|
|
@@ -18,6 +18,8 @@ export class MediaPlaybackRateButton extends HTMLElementBase {
|
|
|
18
18
|
button { min-width: 42px; height: 40px; padding: 0 8px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-variant-numeric: tabular-nums; }
|
|
19
19
|
button:hover { opacity: .8; }
|
|
20
20
|
button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
21
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
22
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
21
23
|
</style>
|
|
22
24
|
<button part="button" type="button" aria-label="${label(this, 'speed')}"></button>
|
|
23
25
|
`
|
|
@@ -65,6 +65,8 @@ export class MediaProgressBar extends HTMLElementBase {
|
|
|
65
65
|
border-top-color: rgba(0,0,0,0.85);
|
|
66
66
|
}
|
|
67
67
|
.container:hover .tooltip { opacity: 1; }
|
|
68
|
+
/* A finger needs more than the 20px a mouse does. */
|
|
69
|
+
@media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
|
|
68
70
|
</style>
|
|
69
71
|
<div class="container" part="container" role="slider"
|
|
70
72
|
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
|
|
@@ -32,6 +32,8 @@ export class MediaRemotePlaybackButton extends HTMLElementBase {
|
|
|
32
32
|
button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
|
|
33
33
|
button:hover { opacity: 0.8; }
|
|
34
34
|
:host([connected]) button { color: var(--media-remote-active-color, #38bdf8); }
|
|
35
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
36
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
35
37
|
</style>
|
|
36
38
|
<button part="button" type="button" aria-label="${label(this, 'remotePlayback')}">
|
|
37
39
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${REMOTE_ICON}"/></svg>
|
|
@@ -17,6 +17,8 @@ export class MediaSeekButton extends HTMLElementBase {
|
|
|
17
17
|
button:hover { opacity: .8; }
|
|
18
18
|
button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
19
19
|
button:disabled { cursor: default; opacity: .45; }
|
|
20
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
21
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
20
22
|
</style>
|
|
21
23
|
<button part="button" type="button"></button>
|
|
22
24
|
`
|
|
@@ -54,6 +54,8 @@ export class MediaSettingsMenu extends HTMLElementBase {
|
|
|
54
54
|
.item[aria-checked="true"]::before { content: '\\2713'; }
|
|
55
55
|
.item[aria-checked="false"]::before { content: ''; min-width: 12px; display: inline-block; }
|
|
56
56
|
.back { font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.2); }
|
|
57
|
+
/* 44px, the smallest a finger hits reliably, on a touch screen. */
|
|
58
|
+
@media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
|
|
57
59
|
</style>
|
|
58
60
|
<button class="trigger" part="trigger" type="button"
|
|
59
61
|
aria-label="${say(this, 'settings')}" aria-haspopup="true" aria-expanded="false">
|
|
@@ -42,6 +42,8 @@ export class MediaVolumeSlider extends HTMLElementBase {
|
|
|
42
42
|
transition: transform 0.1s;
|
|
43
43
|
}
|
|
44
44
|
.container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
|
|
45
|
+
/* A finger needs more than the 20px a mouse does. */
|
|
46
|
+
@media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
|
|
45
47
|
</style>
|
|
46
48
|
<div class="container" part="container" role="slider"
|
|
47
49
|
aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
|
|
@@ -31,7 +31,9 @@ export class VideoPlayerElement extends HTMLElementBase {
|
|
|
31
31
|
if (this._player) return
|
|
32
32
|
|
|
33
33
|
// Create shadow DOM
|
|
34
|
-
|
|
34
|
+
// delegatesFocus: focusing <video-player> (a dialog's first focus, an
|
|
35
|
+
// `autofocus`) puts focus on the player itself, where its keys work.
|
|
36
|
+
const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open', delegatesFocus: true })
|
|
35
37
|
|
|
36
38
|
// Container
|
|
37
39
|
this._container = document.createElement('div')
|
package/src/player.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createMediaSession, type MediaSessionOptions } from './core/media-sessi
|
|
|
22
22
|
import { lockOrientation, unlockOrientation } from './core/orientation'
|
|
23
23
|
import { defaultLoaders, findSourceCandidates } from './providers'
|
|
24
24
|
import { embedCover } from './core/cover'
|
|
25
|
+
import { enterFullscreen as enterFullscreenElement } from './core/features'
|
|
25
26
|
import { label } from './elements/utils'
|
|
26
27
|
|
|
27
28
|
// =============================================================================
|
|
@@ -263,6 +264,8 @@ export class Player implements IPlayer {
|
|
|
263
264
|
toggleCaptions: () => this.toggleCaptions(),
|
|
264
265
|
seekBackward: () => this.seekBy(-5),
|
|
265
266
|
seekForward: () => this.seekBy(5),
|
|
267
|
+
seekBackwardLong: () => this.seekBy(-10),
|
|
268
|
+
seekForwardLong: () => this.seekBy(10),
|
|
266
269
|
volumeUp: () => this.setVolume(Math.min(1, this.state.volume + 0.1)),
|
|
267
270
|
volumeDown: () => this.setVolume(Math.max(0, this.state.volume - 0.1)),
|
|
268
271
|
speedUp: () => this.setPlaybackRate(Math.min(4, this.state.playbackRate + 0.25)),
|
|
@@ -599,7 +602,10 @@ export class Player implements IPlayer {
|
|
|
599
602
|
})
|
|
600
603
|
|
|
601
604
|
provider.on('pause', () => {
|
|
602
|
-
|
|
605
|
+
// Not playing any more either: the controls hide only while playing,
|
|
606
|
+
// and a paused player that still said `playing` hid them over a still
|
|
607
|
+
// frame, and told everyone reading state.playing it was still going.
|
|
608
|
+
this._store.batch({ paused: true, playing: false, playbackState: 'paused' })
|
|
603
609
|
this._events.emit('pause')
|
|
604
610
|
this.announce(label(this._el, 'paused'))
|
|
605
611
|
})
|
|
@@ -794,7 +800,12 @@ export class Player implements IPlayer {
|
|
|
794
800
|
if (this.state.pictureInPicture) {
|
|
795
801
|
await this.exitPiP()
|
|
796
802
|
}
|
|
797
|
-
|
|
803
|
+
// The whole player, controls included: the provider's own container holds
|
|
804
|
+
// only the media, so a fullscreen video had no controls to pause or seek
|
|
805
|
+
// with. A browser that can only take the <video> itself fullscreen (iOS
|
|
806
|
+
// Safari) still gets it, with the system's controls.
|
|
807
|
+
const media = this._provider?.mediaElement
|
|
808
|
+
await enterFullscreenElement(this._el, media instanceof HTMLMediaElement ? media : null)
|
|
798
809
|
if (this._options.fullscreenOrientationLock !== false) {
|
|
799
810
|
lockOrientation('landscape')
|
|
800
811
|
}
|
package/src/providers/base.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
MediaError,
|
|
19
19
|
FeatureAvailability,
|
|
20
20
|
} from '../types'
|
|
21
|
-
import { EventEmitter } from '../core/events'
|
|
21
|
+
import { EventEmitter, onFullscreenChange } from '../core/events'
|
|
22
22
|
import {
|
|
23
23
|
detectVolumeAvailability,
|
|
24
24
|
detectFullscreenAvailability,
|
|
@@ -63,6 +63,8 @@ export abstract class BaseProvider implements Provider {
|
|
|
63
63
|
|
|
64
64
|
destroy(): void {
|
|
65
65
|
this._ready = false
|
|
66
|
+
this.stopEmbedFullscreen?.()
|
|
67
|
+
this.stopEmbedFullscreen = null
|
|
66
68
|
this.cleanup()
|
|
67
69
|
this.events.removeAllListeners()
|
|
68
70
|
}
|
|
@@ -132,6 +134,37 @@ export abstract class BaseProvider implements Provider {
|
|
|
132
134
|
|
|
133
135
|
// === Feature Availability ===
|
|
134
136
|
|
|
137
|
+
private stopEmbedFullscreen: (() => void) | null = null
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What an embedded player (YouTube, Vimeo) can do, said once it is ready.
|
|
141
|
+
*
|
|
142
|
+
* The native-media providers say it on loadedmetadata; the embeds said
|
|
143
|
+
* nothing, so fullscreen stayed "unavailable" and its button disabled,
|
|
144
|
+
* though the player can fill the screen like any other. Fullscreen is the
|
|
145
|
+
* container's own (the player's controls come along); picture in picture
|
|
146
|
+
* is the embed's business. Also follows the document into and out of
|
|
147
|
+
* fullscreen, which only the native providers did.
|
|
148
|
+
*/
|
|
149
|
+
protected announceEmbedFeatures(): void {
|
|
150
|
+
// Nothing to measure without a page (server rendering, tests).
|
|
151
|
+
if (typeof document === 'undefined' || typeof HTMLMediaElement === 'undefined') return
|
|
152
|
+
const fullscreenAvailability = this.getFeatureAvailability('fullscreen')
|
|
153
|
+
const pipAvailability = this.getFeatureAvailability('pip')
|
|
154
|
+
const volumeAvailability = this.getFeatureAvailability('volume')
|
|
155
|
+
this.events.emit('statechange', {
|
|
156
|
+
canFullscreen: fullscreenAvailability === 'available',
|
|
157
|
+
canPictureInPicture: pipAvailability === 'available',
|
|
158
|
+
fullscreenAvailability,
|
|
159
|
+
pipAvailability,
|
|
160
|
+
volumeAvailability,
|
|
161
|
+
})
|
|
162
|
+
this.events.emit('availabilitychange', 'fullscreen', fullscreenAvailability)
|
|
163
|
+
this.events.emit('availabilitychange', 'pip', pipAvailability)
|
|
164
|
+
this.events.emit('availabilitychange', 'volume', volumeAvailability)
|
|
165
|
+
this.stopEmbedFullscreen ??= onFullscreenChange(fullscreen => this.events.emit('fullscreenchange', fullscreen))
|
|
166
|
+
}
|
|
167
|
+
|
|
135
168
|
getFeatureAvailability(feature: 'volume' | 'fullscreen' | 'pip'): FeatureAvailability {
|
|
136
169
|
// Only pass media element if it's actually an HTMLMediaElement (not an iframe)
|
|
137
170
|
const el = this.mediaElement
|