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.
@@ -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;inset:0;width:100%;height:100%;border:0";
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
- this.loadVideo(this.pendingVideoId, this.startTime, this.endTime);
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?.playVideo();
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.player?.pauseVideo();
821
+ this.pendingPlay = false;
822
+ if (this.playerReady)
823
+ this.player?.pauseVideo();
807
824
  }
808
825
  stop() {
809
- this.player?.stopVideo();
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
- if (!embed || state.started || state.error || dismissed)
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
- this._coverTimer = setTimeout(() => {
2121
- if (this.state.started)
2122
- return;
2123
- this._coverDismissed = true;
2124
- this.updateCover(this.state);
2125
- }, 2000);
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>
@@ -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 the player covers it with its poster instead: the one
18
- * the page gave it, or else YouTube's thumbnail for the video.
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;
@@ -23,7 +23,7 @@ import {
23
23
  MediaErrorDisplay2,
24
24
  MediaRemotePlaybackButton2,
25
25
  registerElements2
26
- } from "../chunk-z21ez1kz.js";
26
+ } from "../chunk-24n6adw2.js";
27
27
  export {
28
28
  MediaAirplayButton2 as MediaAirplayButton,
29
29
  MediaErrorDisplay2 as MediaErrorDisplay,
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  applyPosition2,
52
52
  MediaSettingsMenu2,
53
53
  registerElements2
54
- } from "./chunk-z21ez1kz.js";
54
+ } from "./chunk-24n6adw2.js";
55
55
  import {
56
56
  playback2,
57
57
  volume2,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-video-player",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A modern, framework-agnostic video player with deep stx integration",
5
5
  "author": "Chris Breuer <chris@stacksjs.org>",
6
6
  "license": "MIT",
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 the player covers it with its poster instead: the one
22
- * the page gave it, or else YouTube's thumbnail for the video.
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
- if (!embed || state.started || state.error || dismissed)
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
- this._coverTimer = setTimeout(() => {
172
- if (this.state.started) return
173
- this._coverDismissed = true
174
- this.updateCover(this.state)
175
- }, 2000)
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) return
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
 
@@ -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
- // Filling the player, whatever size the provider's API gave the frame.
258
- this.iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0'
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
- this.loadVideo(this.pendingVideoId, this.startTime, this.endTime)
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?.playVideo()
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.player?.pauseVideo()
460
+ this.pendingPlay = false
461
+ if (this.playerReady) this.player?.pauseVideo()
435
462
  }
436
463
 
437
464
  stop(): void {
438
- this.player?.stopVideo()
465
+ this.pendingPlay = false
466
+ if (this.playerReady) this.player?.stopVideo()
439
467
  }
440
468
 
441
469
  // === Seeking ===