Skip to content

Commit 0d108d3

Browse files
authored
fix(youtube-player): handle API interactions before API has loaded (#18368)
Currently if the user tries to interact with the API before the YouTube API has loaded (e.g. by calling `playVideo`) their method call will be ignored. These changes add some logic that will store the state and replay it once the player has been initialized. Fixes #18279.
1 parent b219cbc commit 0d108d3

File tree

2 files changed

+194
-6
lines changed

2 files changed

+194
-6
lines changed

src/youtube-player/youtube-player.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,80 @@ describe('YoutubePlayer', () => {
274274
testComponent.youtubePlayer.getVideoEmbedCode();
275275
expect(playerSpy.getVideoEmbedCode).toHaveBeenCalled();
276276
});
277+
278+
it('should play on init if playVideo was called before the API has loaded', () => {
279+
testComponent.youtubePlayer.playVideo();
280+
expect(testComponent.youtubePlayer.getPlayerState()).toBe(YT.PlayerState.PLAYING);
281+
282+
events.onReady({target: playerSpy});
283+
284+
expect(playerSpy.playVideo).toHaveBeenCalled();
285+
});
286+
287+
it('should pause on init if pauseVideo was called before the API has loaded', () => {
288+
testComponent.youtubePlayer.pauseVideo();
289+
expect(testComponent.youtubePlayer.getPlayerState()).toBe(YT.PlayerState.PAUSED);
290+
291+
events.onReady({target: playerSpy});
292+
293+
expect(playerSpy.pauseVideo).toHaveBeenCalled();
294+
});
295+
296+
it('should stop on init if stopVideo was called before the API has loaded', () => {
297+
testComponent.youtubePlayer.stopVideo();
298+
expect(testComponent.youtubePlayer.getPlayerState()).toBe(YT.PlayerState.CUED);
299+
300+
events.onReady({target: playerSpy});
301+
302+
expect(playerSpy.stopVideo).toHaveBeenCalled();
303+
});
304+
305+
it('should set the playback rate on init if setPlaybackRate was called before ' +
306+
'the API has loaded', () => {
307+
testComponent.youtubePlayer.setPlaybackRate(1337);
308+
expect(testComponent.youtubePlayer.getPlaybackRate()).toBe(1337);
309+
310+
events.onReady({target: playerSpy});
311+
312+
expect(playerSpy.setPlaybackRate).toHaveBeenCalledWith(1337);
313+
});
314+
315+
it('should set the volume on init if setVolume was called before the API has loaded', () => {
316+
testComponent.youtubePlayer.setVolume(37);
317+
expect(testComponent.youtubePlayer.getVolume()).toBe(37);
318+
319+
events.onReady({target: playerSpy});
320+
321+
expect(playerSpy.setVolume).toHaveBeenCalledWith(37);
322+
});
323+
324+
it('should mute on init if mute was called before the API has loaded', () => {
325+
testComponent.youtubePlayer.mute();
326+
expect(testComponent.youtubePlayer.isMuted()).toBe(true);
327+
328+
events.onReady({target: playerSpy});
329+
330+
expect(playerSpy.mute).toHaveBeenCalled();
331+
});
332+
333+
it('should unmute on init if umMute was called before the API has loaded', () => {
334+
testComponent.youtubePlayer.unMute();
335+
expect(testComponent.youtubePlayer.isMuted()).toBe(false);
336+
337+
events.onReady({target: playerSpy});
338+
339+
expect(playerSpy.unMute).toHaveBeenCalled();
340+
});
341+
342+
it('should seek on init if seekTo was called before the API has loaded', () => {
343+
testComponent.youtubePlayer.seekTo(1337, true);
344+
expect(testComponent.youtubePlayer.getCurrentTime()).toBe(1337);
345+
346+
events.onReady({target: playerSpy});
347+
348+
expect(playerSpy.seekTo).toHaveBeenCalledWith(1337, true);
349+
});
350+
277351
});
278352

279353
describe('API loaded asynchronously', () => {

src/youtube-player/youtube-player.ts

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ interface Player extends YT.Player {
7777
// The only field available is destroy and addEventListener.
7878
type UninitializedPlayer = Pick<Player, 'videoId' | 'destroy' | 'addEventListener'>;
7979

80+
/**
81+
* Object used to store the state of the player if the
82+
* user tries to interact with the API before it has been loaded.
83+
*/
84+
interface PendingPlayerState {
85+
playbackState?: YT.PlayerState.PLAYING | YT.PlayerState.PAUSED | YT.PlayerState.CUED;
86+
playbackRate?: number;
87+
volume?: number;
88+
muted?: boolean;
89+
seek?: {seconds: number, allowSeekAhead: boolean};
90+
}
91+
8092
/**
8193
* Angular component that renders a YouTube player via the YouTube player
8294
* iframe API.
@@ -160,6 +172,7 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit {
160172
private _destroyed = new Subject<void>();
161173
private _player: Player | undefined;
162174
private _existingApiReadyCallback: (() => void) | undefined;
175+
private _pendingPlayerState: PendingPlayerState | undefined;
163176

164177
constructor(
165178
private _ngZone: NgZone,
@@ -218,7 +231,15 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit {
218231
}), takeUntil(this._destroyed), publish());
219232

220233
// Set up side effects to bind inputs to the player.
221-
playerObs.subscribe(player => this._player = player);
234+
playerObs.subscribe(player => {
235+
this._player = player;
236+
237+
if (player && this._pendingPlayerState) {
238+
this._initializePlayer(player, this._pendingPlayerState);
239+
}
240+
241+
this._pendingPlayerState = undefined;
242+
});
222243

223244
bindSizeToPlayer(playerObs, this._width, this._height);
224245

@@ -289,71 +310,112 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit {
289310
playVideo() {
290311
if (this._player) {
291312
this._player.playVideo();
313+
} else {
314+
this._getPendingState().playbackState = YT.PlayerState.PLAYING;
292315
}
293316
}
294317

295318
/** See https://developers.google.com/youtube/iframe_api_reference#pauseVideo */
296319
pauseVideo() {
297320
if (this._player) {
298321
this._player.pauseVideo();
322+
} else {
323+
this._getPendingState().playbackState = YT.PlayerState.PAUSED;
299324
}
300325
}
301326

302327
/** See https://developers.google.com/youtube/iframe_api_reference#stopVideo */
303328
stopVideo() {
304329
if (this._player) {
305330
this._player.stopVideo();
331+
} else {
332+
// It seems like YouTube sets the player to CUED when it's stopped.
333+
this._getPendingState().playbackState = YT.PlayerState.CUED;
306334
}
307335
}
308336

309337
/** See https://developers.google.com/youtube/iframe_api_reference#seekTo */
310338
seekTo(seconds: number, allowSeekAhead: boolean) {
311339
if (this._player) {
312340
this._player.seekTo(seconds, allowSeekAhead);
341+
} else {
342+
this._getPendingState().seek = {seconds, allowSeekAhead};
313343
}
314344
}
315345

316346
/** See https://developers.google.com/youtube/iframe_api_reference#mute */
317347
mute() {
318348
if (this._player) {
319349
this._player.mute();
350+
} else {
351+
this._getPendingState().muted = true;
320352
}
321353
}
322354

323355
/** See https://developers.google.com/youtube/iframe_api_reference#unMute */
324356
unMute() {
325357
if (this._player) {
326358
this._player.unMute();
359+
} else {
360+
this._getPendingState().muted = false;
327361
}
328362
}
329363

330364
/** See https://developers.google.com/youtube/iframe_api_reference#isMuted */
331365
isMuted(): boolean {
332-
return !this._player || this._player.isMuted();
366+
if (this._player) {
367+
return this._player.isMuted();
368+
}
369+
370+
if (this._pendingPlayerState) {
371+
return !!this._pendingPlayerState.muted;
372+
}
373+
374+
return false;
333375
}
334376

335377
/** See https://developers.google.com/youtube/iframe_api_reference#setVolume */
336378
setVolume(volume: number) {
337379
if (this._player) {
338380
this._player.setVolume(volume);
381+
} else {
382+
this._getPendingState().volume = volume;
339383
}
340384
}
341385

342386
/** See https://developers.google.com/youtube/iframe_api_reference#getVolume */
343387
getVolume(): number {
344-
return this._player ? this._player.getVolume() : 0;
388+
if (this._player) {
389+
return this._player.getVolume();
390+
}
391+
392+
if (this._pendingPlayerState && this._pendingPlayerState.volume != null) {
393+
return this._pendingPlayerState.volume;
394+
}
395+
396+
return 0;
345397
}
346398

347399
/** See https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate */
348400
setPlaybackRate(playbackRate: number) {
349401
if (this._player) {
350402
return this._player.setPlaybackRate(playbackRate);
403+
} else {
404+
this._getPendingState().playbackRate = playbackRate;
351405
}
352406
}
353407

354408
/** See https://developers.google.com/youtube/iframe_api_reference#getPlaybackRate */
355409
getPlaybackRate(): number {
356-
return this._player ? this._player.getPlaybackRate() : 0;
410+
if (this._player) {
411+
return this._player.getPlaybackRate();
412+
}
413+
414+
if (this._pendingPlayerState && this._pendingPlayerState.playbackRate != null) {
415+
return this._pendingPlayerState.playbackRate;
416+
}
417+
418+
return 0;
357419
}
358420

359421
/** See https://developers.google.com/youtube/iframe_api_reference#getAvailablePlaybackRates */
@@ -372,12 +434,28 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit {
372434
return undefined;
373435
}
374436

375-
return this._player ? this._player.getPlayerState() : YT.PlayerState.UNSTARTED;
437+
if (this._player) {
438+
return this._player.getPlayerState();
439+
}
440+
441+
if (this._pendingPlayerState && this._pendingPlayerState.playbackState != null) {
442+
return this._pendingPlayerState.playbackState;
443+
}
444+
445+
return YT.PlayerState.UNSTARTED;
376446
}
377447

378448
/** See https://developers.google.com/youtube/iframe_api_reference#getCurrentTime */
379449
getCurrentTime(): number {
380-
return this._player ? this._player.getCurrentTime() : 0;
450+
if (this._player) {
451+
return this._player.getCurrentTime();
452+
}
453+
454+
if (this._pendingPlayerState && this._pendingPlayerState.seek) {
455+
return this._pendingPlayerState.seek.seconds;
456+
}
457+
458+
return 0;
381459
}
382460

383461
/** See https://developers.google.com/youtube/iframe_api_reference#getPlaybackQuality */
@@ -404,6 +482,42 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit {
404482
getVideoEmbedCode(): string {
405483
return this._player ? this._player.getVideoEmbedCode() : '';
406484
}
485+
486+
/** Gets an object that should be used to store the temporary API state. */
487+
private _getPendingState(): PendingPlayerState {
488+
if (!this._pendingPlayerState) {
489+
this._pendingPlayerState = {};
490+
}
491+
492+
return this._pendingPlayerState;
493+
}
494+
495+
/** Initializes a player from a temporary state. */
496+
private _initializePlayer(player: YT.Player, state: PendingPlayerState): void {
497+
const {playbackState, playbackRate, volume, muted, seek} = state;
498+
499+
switch (playbackState) {
500+
case YT.PlayerState.PLAYING: player.playVideo(); break;
501+
case YT.PlayerState.PAUSED: player.pauseVideo(); break;
502+
case YT.PlayerState.CUED: player.stopVideo(); break;
503+
}
504+
505+
if (playbackRate != null) {
506+
player.setPlaybackRate(playbackRate);
507+
}
508+
509+
if (volume != null) {
510+
player.setVolume(volume);
511+
}
512+
513+
if (muted != null) {
514+
muted ? player.mute() : player.unMute();
515+
}
516+
517+
if (seek != null) {
518+
player.seekTo(seek.seconds, seek.allowSeekAhead);
519+
}
520+
}
407521
}
408522

409523
/** Listens to changes to the given width and height and sets it on the player. */

0 commit comments

Comments
 (0)