ts-video-player 0.1.5 → 0.1.7
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 +47 -27
- package/dist/{chunk-z21ez1kz.js → chunk-24n6adw2.js} +87 -15
- package/dist/core/cover.d.ts +4 -3
- package/dist/elements/index.js +1 -1
- package/dist/index.js +1 -1
- package/dist/player.d.ts +6 -0
- package/dist/providers/youtube.d.ts +6 -0
- package/package.json +1 -1
- package/src/core/cover.ts +8 -4
- package/src/elements/video-skin.ts +21 -1
- package/src/player.ts +48 -7
- package/src/providers/youtube.ts +35 -7
|
@@ -625,6 +625,7 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
625
625
|
endTime;
|
|
626
626
|
timeUpdateInterval = null;
|
|
627
627
|
lastState = -1;
|
|
628
|
+
pendingPlay = false;
|
|
628
629
|
get mediaElement() {
|
|
629
630
|
return this.iframe;
|
|
630
631
|
}
|
|
@@ -666,7 +667,9 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
666
667
|
});
|
|
667
668
|
this.iframe = this.player.getIframe();
|
|
668
669
|
this.iframe.className = "ts-video-player__media";
|
|
669
|
-
this.iframe.style.cssText = "position:absolute;
|
|
670
|
+
this.iframe.style.cssText = "position:absolute;left:0;top:-100%;width:100%;height:300%;border:0";
|
|
671
|
+
if (this.iframe.parentElement)
|
|
672
|
+
this.iframe.parentElement.style.overflow = "hidden";
|
|
670
673
|
}
|
|
671
674
|
async loadYouTubeAPI() {
|
|
672
675
|
if (window.YT?.Player)
|
|
@@ -700,6 +703,7 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
700
703
|
this.player?.destroy();
|
|
701
704
|
this.player = null;
|
|
702
705
|
this.playerReady = false;
|
|
706
|
+
this.pendingPlay = false;
|
|
703
707
|
this.iframe = null;
|
|
704
708
|
}
|
|
705
709
|
onReady() {
|
|
@@ -712,8 +716,12 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
712
716
|
this.events.emit("loadedmetadata");
|
|
713
717
|
this.events.emit("canplay");
|
|
714
718
|
if (this.pendingVideoId) {
|
|
715
|
-
|
|
719
|
+
const videoId = this.pendingVideoId;
|
|
716
720
|
this.pendingVideoId = null;
|
|
721
|
+
this.loadVideo(videoId, this.startTime, this.endTime);
|
|
722
|
+
} else if (this.pendingPlay) {
|
|
723
|
+
this.pendingPlay = false;
|
|
724
|
+
this.player?.playVideo();
|
|
717
725
|
}
|
|
718
726
|
}
|
|
719
727
|
onStateChange(event) {
|
|
@@ -725,6 +733,8 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
725
733
|
this.events.emit("loadstart");
|
|
726
734
|
break;
|
|
727
735
|
case PlayerState.PLAYING:
|
|
736
|
+
if (this.getDuration() > 0)
|
|
737
|
+
this.events.emit("durationchange", this.getDuration());
|
|
728
738
|
this.events.emit("play");
|
|
729
739
|
this.events.emit("playing");
|
|
730
740
|
this.events.emit("statechange", { paused: false, playing: true, waiting: false });
|
|
@@ -785,7 +795,8 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
785
795
|
loadVideo(videoId, startTime, endTime) {
|
|
786
796
|
if (!this.player)
|
|
787
797
|
return;
|
|
788
|
-
if (this.options.autoplay) {
|
|
798
|
+
if (this.options.autoplay || this.pendingPlay) {
|
|
799
|
+
this.pendingPlay = false;
|
|
789
800
|
this.player.loadVideoById({
|
|
790
801
|
videoId,
|
|
791
802
|
startSeconds: startTime,
|
|
@@ -800,13 +811,21 @@ class YouTubeProvider2 extends BaseProvider2 {
|
|
|
800
811
|
}
|
|
801
812
|
}
|
|
802
813
|
async play() {
|
|
803
|
-
this.player
|
|
814
|
+
if (!this.player || !this.playerReady || this.pendingVideoId) {
|
|
815
|
+
this.pendingPlay = true;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
this.player.playVideo();
|
|
804
819
|
}
|
|
805
820
|
pause() {
|
|
806
|
-
this.
|
|
821
|
+
this.pendingPlay = false;
|
|
822
|
+
if (this.playerReady)
|
|
823
|
+
this.player?.pauseVideo();
|
|
807
824
|
}
|
|
808
825
|
stop() {
|
|
809
|
-
this.
|
|
826
|
+
this.pendingPlay = false;
|
|
827
|
+
if (this.playerReady)
|
|
828
|
+
this.player?.stopVideo();
|
|
810
829
|
}
|
|
811
830
|
seekTo(time) {
|
|
812
831
|
this.player?.seekTo(time, true);
|
|
@@ -2012,7 +2031,8 @@ function getPreconnectHints2(src, loaders = defaultLoaders2) {
|
|
|
2012
2031
|
// src/core/cover.ts
|
|
2013
2032
|
function embedCover(state, dismissed) {
|
|
2014
2033
|
const embed = state.providerType === "youtube" || state.providerType === "vimeo";
|
|
2015
|
-
|
|
2034
|
+
const waiting = !state.started || state.ended;
|
|
2035
|
+
if (!embed || !waiting || state.error || dismissed)
|
|
2016
2036
|
return { show: false, image: "" };
|
|
2017
2037
|
if (state.poster)
|
|
2018
2038
|
return { show: true, image: state.poster };
|
|
@@ -2037,6 +2057,7 @@ class Player2 {
|
|
|
2037
2057
|
_cover = null;
|
|
2038
2058
|
_coverDismissed = false;
|
|
2039
2059
|
_coverTimer = null;
|
|
2060
|
+
_playRequested = false;
|
|
2040
2061
|
constructor(container, options = {}) {
|
|
2041
2062
|
if (typeof container === "string") {
|
|
2042
2063
|
const el = document.querySelector(container);
|
|
@@ -2109,20 +2130,36 @@ class Player2 {
|
|
|
2109
2130
|
button.setAttribute("aria-hidden", "true");
|
|
2110
2131
|
button.style.cssText = "display:flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;background:rgba(255,255,255,0.92);box-shadow:0 4px 18px rgba(0,0,0,0.35)";
|
|
2111
2132
|
const triangle = document.createElement("span");
|
|
2133
|
+
triangle.className = "ts-video-player__cover-play";
|
|
2112
2134
|
triangle.style.cssText = "width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)";
|
|
2113
2135
|
button.appendChild(triangle);
|
|
2114
2136
|
cover.appendChild(button);
|
|
2115
2137
|
cover.addEventListener("click", (event) => {
|
|
2116
2138
|
event.stopPropagation();
|
|
2139
|
+
if (!cover.dataset.loading) {
|
|
2140
|
+
cover.dataset.loading = "";
|
|
2141
|
+
triangle.style.display = "none";
|
|
2142
|
+
const ring = document.createElement("span");
|
|
2143
|
+
ring.className = "ts-video-player__cover-spinner";
|
|
2144
|
+
ring.style.cssText = "width:26px;height:26px;border-radius:50%;border:3px solid rgba(15,23,42,0.2);border-top-color:#0f172a";
|
|
2145
|
+
button.appendChild(ring);
|
|
2146
|
+
ring.animate?.([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], { duration: 800, iterations: Infinity });
|
|
2147
|
+
}
|
|
2117
2148
|
this.play().catch(() => {});
|
|
2118
2149
|
if (this._coverTimer)
|
|
2119
2150
|
clearTimeout(this._coverTimer);
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2151
|
+
let waits = 0;
|
|
2152
|
+
const settle = () => {
|
|
2153
|
+
this._coverTimer = setTimeout(() => {
|
|
2154
|
+
if (this.state.started)
|
|
2155
|
+
return;
|
|
2156
|
+
if (!this.state.canPlay && ++waits < 10)
|
|
2157
|
+
return settle();
|
|
2158
|
+
this._coverDismissed = true;
|
|
2159
|
+
this.updateCover(this.state);
|
|
2160
|
+
}, 2000);
|
|
2161
|
+
};
|
|
2162
|
+
settle();
|
|
2126
2163
|
});
|
|
2127
2164
|
this._el.appendChild(cover);
|
|
2128
2165
|
this._cover = cover;
|
|
@@ -2142,6 +2179,13 @@ class Player2 {
|
|
|
2142
2179
|
if (cover.style.backgroundImage !== background)
|
|
2143
2180
|
cover.style.backgroundImage = background;
|
|
2144
2181
|
}
|
|
2182
|
+
if (!show && cover.dataset.loading !== undefined) {
|
|
2183
|
+
delete cover.dataset.loading;
|
|
2184
|
+
cover.querySelector(".ts-video-player__cover-spinner")?.remove();
|
|
2185
|
+
const triangle = cover.querySelector(".ts-video-player__cover-play");
|
|
2186
|
+
if (triangle)
|
|
2187
|
+
triangle.style.display = "";
|
|
2188
|
+
}
|
|
2145
2189
|
cover.hidden = !show;
|
|
2146
2190
|
cover.style.display = show ? "flex" : "none";
|
|
2147
2191
|
}
|
|
@@ -2304,6 +2348,7 @@ class Player2 {
|
|
|
2304
2348
|
return;
|
|
2305
2349
|
}
|
|
2306
2350
|
this._coverDismissed = false;
|
|
2351
|
+
this._playRequested = false;
|
|
2307
2352
|
this._store.batch({
|
|
2308
2353
|
sources,
|
|
2309
2354
|
src: sources[0],
|
|
@@ -2361,6 +2406,10 @@ class Player2 {
|
|
|
2361
2406
|
mediaType: loader.mediaType(candidate.src),
|
|
2362
2407
|
providerType: loader.type
|
|
2363
2408
|
});
|
|
2409
|
+
if (this._playRequested) {
|
|
2410
|
+
this._playRequested = false;
|
|
2411
|
+
this._provider.play().catch(() => {});
|
|
2412
|
+
}
|
|
2364
2413
|
return;
|
|
2365
2414
|
} catch (error) {
|
|
2366
2415
|
lastError = error;
|
|
@@ -2499,11 +2548,14 @@ class Player2 {
|
|
|
2499
2548
|
});
|
|
2500
2549
|
}
|
|
2501
2550
|
async play() {
|
|
2502
|
-
if (!this._provider)
|
|
2551
|
+
if (!this._provider) {
|
|
2552
|
+
this._playRequested = true;
|
|
2503
2553
|
return;
|
|
2554
|
+
}
|
|
2504
2555
|
await this._provider.play();
|
|
2505
2556
|
}
|
|
2506
2557
|
pause() {
|
|
2558
|
+
this._playRequested = false;
|
|
2507
2559
|
this._provider?.pause();
|
|
2508
2560
|
}
|
|
2509
2561
|
togglePlay() {
|
|
@@ -2893,6 +2945,7 @@ class VideoSkin2 extends HTMLElementBase {
|
|
|
2893
2945
|
color: white;
|
|
2894
2946
|
font-family: system-ui, -apple-system, sans-serif;
|
|
2895
2947
|
font-size: 14px;
|
|
2948
|
+
container-type: inline-size;
|
|
2896
2949
|
}
|
|
2897
2950
|
|
|
2898
2951
|
/* Click-to-play overlay */
|
|
@@ -2920,6 +2973,25 @@ class VideoSkin2 extends HTMLElementBase {
|
|
|
2920
2973
|
visibility: hidden;
|
|
2921
2974
|
}
|
|
2922
2975
|
|
|
2976
|
+
/* The time and the progress bar. One row with the buttons on a
|
|
2977
|
+
wide player; a phone-width one gives the progress bar a row of
|
|
2978
|
+
its own, where it would otherwise be squeezed to a dot between
|
|
2979
|
+
the buttons. */
|
|
2980
|
+
.center { display: contents; }
|
|
2981
|
+
@container (max-width: 520px) {
|
|
2982
|
+
.controls { flex-wrap: wrap; row-gap: 0; padding: 4px 10px 6px; }
|
|
2983
|
+
.center { display: flex; order: -1; flex: 1 0 100%; align-items: center; gap: 10px; min-width: 0; font-size: 12px; }
|
|
2984
|
+
/* Speed is in the settings menu too; one button fewer keeps the
|
|
2985
|
+
rest on one row. */
|
|
2986
|
+
::slotted(media-playback-rate-button) { display: none; }
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/* A touch screen sets the volume with its own buttons, and iOS
|
|
2990
|
+
ignores a page's volume altogether: no slider there. */
|
|
2991
|
+
@media (pointer: coarse) {
|
|
2992
|
+
::slotted(media-volume-slider) { display: none; }
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2923
2995
|
/* Spacer pushes right-aligned controls */
|
|
2924
2996
|
.spacer {
|
|
2925
2997
|
flex: 1;
|
|
@@ -2935,7 +3007,7 @@ class VideoSkin2 extends HTMLElementBase {
|
|
|
2935
3007
|
</div>
|
|
2936
3008
|
<div class="controls" part="controls">
|
|
2937
3009
|
<slot name="left"></slot>
|
|
2938
|
-
<slot name="center"></slot>
|
|
3010
|
+
<div class="center" part="center"><slot name="center"></slot></div>
|
|
2939
3011
|
<div class="spacer"></div>
|
|
2940
3012
|
<slot name="right"></slot>
|
|
2941
3013
|
<slot></slot>
|
package/dist/core/cover.d.ts
CHANGED
|
@@ -14,10 +14,11 @@ export interface EmbedCover {
|
|
|
14
14
|
* A YouTube or Vimeo embed draws its own chrome before playback (a title bar,
|
|
15
15
|
* a play button, "Watch on YouTube", and on iOS the system's controls for the
|
|
16
16
|
* embed's own video), none of which can be styled from outside the iframe.
|
|
17
|
-
* Until the first play
|
|
18
|
-
* the
|
|
17
|
+
* Until the first play, and again once it ends (where YouTube shows other
|
|
18
|
+
* videos), the player covers it with its poster instead: the one the page
|
|
19
|
+
* gave it, or else YouTube's thumbnail for the video.
|
|
19
20
|
*
|
|
20
21
|
* `dismissed` is set when a tap on the cover did not start playback, so the
|
|
21
22
|
* embed's own button is left reachable.
|
|
22
23
|
*/
|
|
23
|
-
export declare function embedCover(state: Pick<PlayerState, 'providerType' | 'started' | 'error' | 'poster' | 'src'>, dismissed: boolean): EmbedCover;
|
|
24
|
+
export declare function embedCover(state: Pick<PlayerState, 'providerType' | 'started' | 'ended' | 'error' | 'poster' | 'src'>, dismissed: boolean): EmbedCover;
|
package/dist/elements/index.js
CHANGED
package/dist/index.js
CHANGED
package/dist/player.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export declare class Player implements IPlayer {
|
|
|
23
23
|
private _cover;
|
|
24
24
|
private _coverDismissed;
|
|
25
25
|
private _coverTimer;
|
|
26
|
+
/**
|
|
27
|
+
* A play asked for before there was a provider to ask: a tap on the cover
|
|
28
|
+
* while the source was still loading. Kept and honoured once the source has
|
|
29
|
+
* loaded, so one tap plays the video rather than one tap per loading step.
|
|
30
|
+
*/
|
|
31
|
+
private _playRequested;
|
|
26
32
|
/**
|
|
27
33
|
* Create a new player instance
|
|
28
34
|
*/
|
|
@@ -96,6 +96,12 @@ export declare class YouTubeProvider extends BaseProvider {
|
|
|
96
96
|
private endTime?;
|
|
97
97
|
private timeUpdateInterval;
|
|
98
98
|
private lastState;
|
|
99
|
+
/**
|
|
100
|
+
* A play asked for before YouTube's player could take it. Its methods only
|
|
101
|
+
* exist after its ready event, and the video may still be pending then, so
|
|
102
|
+
* the play waits for both and the video loads playing.
|
|
103
|
+
*/
|
|
104
|
+
private pendingPlay;
|
|
99
105
|
get mediaElement(): HTMLIFrameElement | null;
|
|
100
106
|
protected initialize(): Promise<void>;
|
|
101
107
|
private loadYouTubeAPI;
|
package/package.json
CHANGED
package/src/core/cover.ts
CHANGED
|
@@ -18,18 +18,22 @@ export interface EmbedCover {
|
|
|
18
18
|
* A YouTube or Vimeo embed draws its own chrome before playback (a title bar,
|
|
19
19
|
* a play button, "Watch on YouTube", and on iOS the system's controls for the
|
|
20
20
|
* embed's own video), none of which can be styled from outside the iframe.
|
|
21
|
-
* Until the first play
|
|
22
|
-
* the
|
|
21
|
+
* Until the first play, and again once it ends (where YouTube shows other
|
|
22
|
+
* videos), the player covers it with its poster instead: the one the page
|
|
23
|
+
* gave it, or else YouTube's thumbnail for the video.
|
|
23
24
|
*
|
|
24
25
|
* `dismissed` is set when a tap on the cover did not start playback, so the
|
|
25
26
|
* embed's own button is left reachable.
|
|
26
27
|
*/
|
|
27
28
|
export function embedCover(
|
|
28
|
-
state: Pick<PlayerState, 'providerType' | 'started' | 'error' | 'poster' | 'src'>,
|
|
29
|
+
state: Pick<PlayerState, 'providerType' | 'started' | 'ended' | 'error' | 'poster' | 'src'>,
|
|
29
30
|
dismissed: boolean,
|
|
30
31
|
): EmbedCover {
|
|
31
32
|
const embed = state.providerType === 'youtube' || state.providerType === 'vimeo'
|
|
32
|
-
|
|
33
|
+
// Back over the embed when the video ends, where YouTube would otherwise
|
|
34
|
+
// fill the frame with suggestions of other videos.
|
|
35
|
+
const waiting = !state.started || state.ended
|
|
36
|
+
if (!embed || !waiting || state.error || dismissed)
|
|
33
37
|
return { show: false, image: '' }
|
|
34
38
|
|
|
35
39
|
if (state.poster)
|
|
@@ -36,6 +36,7 @@ export class VideoSkin extends HTMLElementBase {
|
|
|
36
36
|
color: white;
|
|
37
37
|
font-family: system-ui, -apple-system, sans-serif;
|
|
38
38
|
font-size: 14px;
|
|
39
|
+
container-type: inline-size;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/* Click-to-play overlay */
|
|
@@ -63,6 +64,25 @@ export class VideoSkin extends HTMLElementBase {
|
|
|
63
64
|
visibility: hidden;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/* The time and the progress bar. One row with the buttons on a
|
|
68
|
+
wide player; a phone-width one gives the progress bar a row of
|
|
69
|
+
its own, where it would otherwise be squeezed to a dot between
|
|
70
|
+
the buttons. */
|
|
71
|
+
.center { display: contents; }
|
|
72
|
+
@container (max-width: 520px) {
|
|
73
|
+
.controls { flex-wrap: wrap; row-gap: 0; padding: 4px 10px 6px; }
|
|
74
|
+
.center { display: flex; order: -1; flex: 1 0 100%; align-items: center; gap: 10px; min-width: 0; font-size: 12px; }
|
|
75
|
+
/* Speed is in the settings menu too; one button fewer keeps the
|
|
76
|
+
rest on one row. */
|
|
77
|
+
::slotted(media-playback-rate-button) { display: none; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* A touch screen sets the volume with its own buttons, and iOS
|
|
81
|
+
ignores a page's volume altogether: no slider there. */
|
|
82
|
+
@media (pointer: coarse) {
|
|
83
|
+
::slotted(media-volume-slider) { display: none; }
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
/* Spacer pushes right-aligned controls */
|
|
67
87
|
.spacer {
|
|
68
88
|
flex: 1;
|
|
@@ -78,7 +98,7 @@ export class VideoSkin extends HTMLElementBase {
|
|
|
78
98
|
</div>
|
|
79
99
|
<div class="controls" part="controls">
|
|
80
100
|
<slot name="left"></slot>
|
|
81
|
-
<slot name="center"></slot>
|
|
101
|
+
<div class="center" part="center"><slot name="center"></slot></div>
|
|
82
102
|
<div class="spacer"></div>
|
|
83
103
|
<slot name="right"></slot>
|
|
84
104
|
<slot></slot>
|
package/src/player.ts
CHANGED
|
@@ -44,6 +44,12 @@ export class Player implements IPlayer {
|
|
|
44
44
|
private _cover: HTMLButtonElement | null = null
|
|
45
45
|
private _coverDismissed = false
|
|
46
46
|
private _coverTimer: ReturnType<typeof setTimeout> | null = null
|
|
47
|
+
/**
|
|
48
|
+
* A play asked for before there was a provider to ask: a tap on the cover
|
|
49
|
+
* while the source was still loading. Kept and honoured once the source has
|
|
50
|
+
* loaded, so one tap plays the video rather than one tap per loading step.
|
|
51
|
+
*/
|
|
52
|
+
private _playRequested = false
|
|
47
53
|
|
|
48
54
|
/**
|
|
49
55
|
* Create a new player instance
|
|
@@ -159,20 +165,39 @@ export class Player implements IPlayer {
|
|
|
159
165
|
button.setAttribute('aria-hidden', 'true')
|
|
160
166
|
button.style.cssText = 'display:flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;background:rgba(255,255,255,0.92);box-shadow:0 4px 18px rgba(0,0,0,0.35)'
|
|
161
167
|
const triangle = document.createElement('span')
|
|
168
|
+
triangle.className = 'ts-video-player__cover-play'
|
|
162
169
|
triangle.style.cssText = 'width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)'
|
|
163
170
|
button.appendChild(triangle)
|
|
164
171
|
cover.appendChild(button)
|
|
165
172
|
cover.addEventListener('click', (event) => {
|
|
166
173
|
event.stopPropagation()
|
|
174
|
+
// The embed can take a few seconds to load on a phone: the tap is
|
|
175
|
+
// answered at once, with the play button turning into a spinner.
|
|
176
|
+
if (!cover.dataset.loading) {
|
|
177
|
+
cover.dataset.loading = ''
|
|
178
|
+
triangle.style.display = 'none'
|
|
179
|
+
const ring = document.createElement('span')
|
|
180
|
+
ring.className = 'ts-video-player__cover-spinner'
|
|
181
|
+
ring.style.cssText = 'width:26px;height:26px;border-radius:50%;border:3px solid rgba(15,23,42,0.2);border-top-color:#0f172a'
|
|
182
|
+
button.appendChild(ring)
|
|
183
|
+
ring.animate?.([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity })
|
|
184
|
+
}
|
|
167
185
|
void this.play().catch(() => {})
|
|
168
186
|
// Some browsers only let a tap inside the embed start it. If this one
|
|
169
|
-
// did not, get out of the way so the embed's own button can.
|
|
187
|
+
// did not, get out of the way so the embed's own button can. Only once
|
|
188
|
+
// the embed is ready, though: before then nothing could have started,
|
|
189
|
+
// and lifting the cover early showed the embed half-loaded.
|
|
170
190
|
if (this._coverTimer) clearTimeout(this._coverTimer)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
this.
|
|
174
|
-
|
|
175
|
-
|
|
191
|
+
let waits = 0
|
|
192
|
+
const settle = () => {
|
|
193
|
+
this._coverTimer = setTimeout(() => {
|
|
194
|
+
if (this.state.started) return
|
|
195
|
+
if (!this.state.canPlay && ++waits < 10) return settle()
|
|
196
|
+
this._coverDismissed = true
|
|
197
|
+
this.updateCover(this.state)
|
|
198
|
+
}, 2000)
|
|
199
|
+
}
|
|
200
|
+
settle()
|
|
176
201
|
})
|
|
177
202
|
this._el.appendChild(cover)
|
|
178
203
|
this._cover = cover
|
|
@@ -190,6 +215,13 @@ export class Player implements IPlayer {
|
|
|
190
215
|
const background = image ? `url("${image.replace(/["\\]/g, '')}")` : 'none'
|
|
191
216
|
if (cover.style.backgroundImage !== background) cover.style.backgroundImage = background
|
|
192
217
|
}
|
|
218
|
+
// Back to a play button once it is no longer waiting on a tap's play.
|
|
219
|
+
if (!show && cover.dataset.loading !== undefined) {
|
|
220
|
+
delete cover.dataset.loading
|
|
221
|
+
cover.querySelector('.ts-video-player__cover-spinner')?.remove()
|
|
222
|
+
const triangle = cover.querySelector<HTMLElement>('.ts-video-player__cover-play')
|
|
223
|
+
if (triangle) triangle.style.display = ''
|
|
224
|
+
}
|
|
193
225
|
// `hidden` alone would not do it: the inline display wins over it.
|
|
194
226
|
cover.hidden = !show
|
|
195
227
|
cover.style.display = show ? 'flex' : 'none'
|
|
@@ -432,6 +464,7 @@ export class Player implements IPlayer {
|
|
|
432
464
|
|
|
433
465
|
// Update state. A new source has not played yet, so its cover returns.
|
|
434
466
|
this._coverDismissed = false
|
|
467
|
+
this._playRequested = false
|
|
435
468
|
this._store.batch({
|
|
436
469
|
sources,
|
|
437
470
|
src: sources[0],
|
|
@@ -498,6 +531,10 @@ export class Player implements IPlayer {
|
|
|
498
531
|
mediaType: loader.mediaType(candidate.src),
|
|
499
532
|
providerType: loader.type,
|
|
500
533
|
})
|
|
534
|
+
if (this._playRequested) {
|
|
535
|
+
this._playRequested = false
|
|
536
|
+
void this._provider.play().catch(() => {})
|
|
537
|
+
}
|
|
501
538
|
return
|
|
502
539
|
}
|
|
503
540
|
catch (error) {
|
|
@@ -667,11 +704,15 @@ export class Player implements IPlayer {
|
|
|
667
704
|
// === Playback ===
|
|
668
705
|
|
|
669
706
|
async play(): Promise<void> {
|
|
670
|
-
if (!this._provider)
|
|
707
|
+
if (!this._provider) {
|
|
708
|
+
this._playRequested = true
|
|
709
|
+
return
|
|
710
|
+
}
|
|
671
711
|
await this._provider.play()
|
|
672
712
|
}
|
|
673
713
|
|
|
674
714
|
pause(): void {
|
|
715
|
+
this._playRequested = false
|
|
675
716
|
this._provider?.pause()
|
|
676
717
|
}
|
|
677
718
|
|
package/src/providers/youtube.ts
CHANGED
|
@@ -192,6 +192,12 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
192
192
|
private endTime?: number
|
|
193
193
|
private timeUpdateInterval: ReturnType<typeof setInterval> | null = null
|
|
194
194
|
private lastState = -1
|
|
195
|
+
/**
|
|
196
|
+
* A play asked for before YouTube's player could take it. Its methods only
|
|
197
|
+
* exist after its ready event, and the video may still be pending then, so
|
|
198
|
+
* the play waits for both and the video loads playing.
|
|
199
|
+
*/
|
|
200
|
+
private pendingPlay = false
|
|
195
201
|
|
|
196
202
|
get mediaElement(): HTMLIFrameElement | null {
|
|
197
203
|
return this.iframe
|
|
@@ -254,8 +260,14 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
254
260
|
// Get iframe reference
|
|
255
261
|
this.iframe = this.player.getIframe()
|
|
256
262
|
this.iframe.className = 'ts-video-player__media'
|
|
257
|
-
//
|
|
258
|
-
|
|
263
|
+
// Three times the player's height, centred, and clipped by the player.
|
|
264
|
+
// YouTube letterboxes the video in the middle of its frame, so the video
|
|
265
|
+
// still fills the player, while the chrome it draws at the frame's edges
|
|
266
|
+
// during playback (the title bar and channel avatar at the top, the
|
|
267
|
+
// YouTube logo and "More videos" at the bottom) falls outside the clip.
|
|
268
|
+
// It cannot be styled from outside the iframe any other way.
|
|
269
|
+
this.iframe.style.cssText = 'position:absolute;left:0;top:-100%;width:100%;height:300%;border:0'
|
|
270
|
+
if (this.iframe.parentElement) this.iframe.parentElement.style.overflow = 'hidden'
|
|
259
271
|
}
|
|
260
272
|
|
|
261
273
|
private async loadYouTubeAPI(): Promise<void> {
|
|
@@ -297,6 +309,7 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
297
309
|
this.player?.destroy()
|
|
298
310
|
this.player = null
|
|
299
311
|
this.playerReady = false
|
|
312
|
+
this.pendingPlay = false
|
|
300
313
|
this.iframe = null
|
|
301
314
|
}
|
|
302
315
|
|
|
@@ -318,8 +331,13 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
318
331
|
|
|
319
332
|
// Load pending video if any
|
|
320
333
|
if (this.pendingVideoId) {
|
|
321
|
-
|
|
334
|
+
const videoId = this.pendingVideoId
|
|
322
335
|
this.pendingVideoId = null
|
|
336
|
+
this.loadVideo(videoId, this.startTime, this.endTime)
|
|
337
|
+
}
|
|
338
|
+
else if (this.pendingPlay) {
|
|
339
|
+
this.pendingPlay = false
|
|
340
|
+
this.player?.playVideo()
|
|
323
341
|
}
|
|
324
342
|
}
|
|
325
343
|
|
|
@@ -334,6 +352,9 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
334
352
|
this.events.emit('loadstart')
|
|
335
353
|
break
|
|
336
354
|
case PlayerState.PLAYING:
|
|
355
|
+
// A video loaded playing never passes through CUED, where the
|
|
356
|
+
// duration is otherwise announced.
|
|
357
|
+
if (this.getDuration() > 0) this.events.emit('durationchange', this.getDuration())
|
|
337
358
|
this.events.emit('play')
|
|
338
359
|
this.events.emit('playing')
|
|
339
360
|
this.events.emit('statechange', { paused: false, playing: true, waiting: false })
|
|
@@ -409,7 +430,8 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
409
430
|
private loadVideo(videoId: string, startTime?: number, endTime?: number): void {
|
|
410
431
|
if (!this.player) return
|
|
411
432
|
|
|
412
|
-
if (this.options.autoplay) {
|
|
433
|
+
if (this.options.autoplay || this.pendingPlay) {
|
|
434
|
+
this.pendingPlay = false
|
|
413
435
|
this.player.loadVideoById({
|
|
414
436
|
videoId,
|
|
415
437
|
startSeconds: startTime,
|
|
@@ -427,15 +449,21 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
427
449
|
// === Playback ===
|
|
428
450
|
|
|
429
451
|
async play(): Promise<void> {
|
|
430
|
-
this.player
|
|
452
|
+
if (!this.player || !this.playerReady || this.pendingVideoId) {
|
|
453
|
+
this.pendingPlay = true
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
this.player.playVideo()
|
|
431
457
|
}
|
|
432
458
|
|
|
433
459
|
pause(): void {
|
|
434
|
-
this.
|
|
460
|
+
this.pendingPlay = false
|
|
461
|
+
if (this.playerReady) this.player?.pauseVideo()
|
|
435
462
|
}
|
|
436
463
|
|
|
437
464
|
stop(): void {
|
|
438
|
-
this.
|
|
465
|
+
this.pendingPlay = false
|
|
466
|
+
if (this.playerReady) this.player?.stopVideo()
|
|
439
467
|
}
|
|
440
468
|
|
|
441
469
|
// === Seeking ===
|