diff --git a/src/youtube-player/youtube-player.ts b/src/youtube-player/youtube-player.ts index 88fc3e8e252b..534586867a9d 100644 --- a/src/youtube-player/youtube-player.ts +++ b/src/youtube-player/youtube-player.ts @@ -10,53 +10,24 @@ /// import { - AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnDestroy, - OnInit, Output, ViewChild, ViewEncapsulation, Inject, PLATFORM_ID, + OnChanges, + SimpleChanges, + AfterViewInit, } from '@angular/core'; import {isPlatformBrowser} from '@angular/common'; - -import { - combineLatest, - ConnectableObservable, - merge, - MonoTypeOperatorFunction, - Observable, - of as observableOf, - OperatorFunction, - pipe, - Subject, - of, - BehaviorSubject, - fromEventPattern, -} from 'rxjs'; - -import { - combineLatest as combineLatestOp, - distinctUntilChanged, - filter, - map, - publish, - scan, - skipWhile, - startWith, - take, - takeUntil, - withLatestFrom, - switchMap, - tap, - mergeMap, -} from 'rxjs/operators'; +import {Observable, of as observableOf, Subject, BehaviorSubject, fromEventPattern} from 'rxjs'; +import {takeUntil, switchMap} from 'rxjs/operators'; declare global { interface Window { @@ -68,21 +39,6 @@ declare global { export const DEFAULT_PLAYER_WIDTH = 640; export const DEFAULT_PLAYER_HEIGHT = 390; -// The native YT.Player doesn't expose the set videoId, but we need it for -// convenience. -interface Player extends YT.Player { - videoId?: string; - playerVars?: YT.PlayerVars; - host?: string; -} - -// The player isn't fully initialized when it's constructed. -// The only field available is destroy and addEventListener. -type UninitializedPlayer = Pick< - Player, - 'videoId' | 'playerVars' | 'destroy' | 'addEventListener' | 'host' ->; - /** * Object used to store the state of the player if the * user tries to interact with the API before it has been loaded. @@ -107,91 +63,62 @@ interface PendingPlayerState { // This div is *replaced* by the YouTube player embed. template: '
', }) -export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { +export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { /** Whether we're currently rendering inside a browser. */ - private _isBrowser: boolean; - private readonly _youtubeContainer = new Subject(); - private readonly _destroyed = new Subject(); - private _player: Player | undefined; + private readonly _isBrowser: boolean; + private _player: YT.Player | undefined; + private _pendingPlayer: YT.Player | undefined; private _existingApiReadyCallback: (() => void) | undefined; private _pendingPlayerState: PendingPlayerState | undefined; - private readonly _playerChanges = new BehaviorSubject(undefined); + private readonly _destroyed = new Subject(); + private readonly _playerChanges = new BehaviorSubject(undefined); /** YouTube Video ID to view */ @Input() - get videoId(): string | undefined { - return this._videoId.value; - } - set videoId(videoId: string | undefined) { - this._videoId.next(videoId); - } - private readonly _videoId = new BehaviorSubject(undefined); + videoId: string | undefined; /** Height of video player */ @Input() - get height(): number | undefined { - return this._height.value; + get height(): number { + return this._height; } set height(height: number | undefined) { - this._height.next(height || DEFAULT_PLAYER_HEIGHT); + this._height = height || DEFAULT_PLAYER_HEIGHT; } - private readonly _height = new BehaviorSubject(DEFAULT_PLAYER_HEIGHT); + private _height = DEFAULT_PLAYER_HEIGHT; /** Width of video player */ @Input() - get width(): number | undefined { - return this._width.value; + get width(): number { + return this._width; } set width(width: number | undefined) { - this._width.next(width || DEFAULT_PLAYER_WIDTH); + this._width = width || DEFAULT_PLAYER_WIDTH; } - private readonly _width = new BehaviorSubject(DEFAULT_PLAYER_WIDTH); + private _width = DEFAULT_PLAYER_WIDTH; /** The moment when the player is supposed to start playing */ @Input() - set startSeconds(startSeconds: number | undefined) { - this._startSeconds.next(startSeconds); - } - private readonly _startSeconds = new BehaviorSubject(undefined); + startSeconds: number | undefined; /** The moment when the player is supposed to stop playing */ @Input() - set endSeconds(endSeconds: number | undefined) { - this._endSeconds.next(endSeconds); - } - private readonly _endSeconds = new BehaviorSubject(undefined); + endSeconds: number | undefined; /** The suggested quality of the player */ @Input() - set suggestedQuality(suggestedQuality: YT.SuggestedVideoQuality | undefined) { - this._suggestedQuality.next(suggestedQuality); - } - private readonly _suggestedQuality = new BehaviorSubject( - undefined, - ); + suggestedQuality: YT.SuggestedVideoQuality | undefined; /** * Extra parameters used to configure the player. See: * https://developers.google.com/youtube/player_parameters.html?playerVersion=HTML5#Parameters */ @Input() - get playerVars(): YT.PlayerVars | undefined { - return this._playerVars.value; - } - set playerVars(playerVars: YT.PlayerVars | undefined) { - this._playerVars.next(playerVars); - } - private _playerVars = new BehaviorSubject(undefined); + playerVars: YT.PlayerVars | undefined; /** Whether cookies inside the player have been disabled. */ @Input() - get disableCookies(): boolean { - return this._disableCookies.value; - } - set disableCookies(value: unknown) { - this._disableCookies.next(!!value); - } - private readonly _disableCookies = new BehaviorSubject(false); + disableCookies: boolean = false; /** * Whether the iframe will attempt to load regardless of the status of the api on the @@ -220,20 +147,22 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { this._getLazyEmitter('onPlaybackRateChange'); /** The element that will be replaced by the iframe. */ - @ViewChild('youtubeContainer') + @ViewChild('youtubeContainer', {static: true}) youtubeContainer: ElementRef; - constructor(private _ngZone: NgZone, @Inject(PLATFORM_ID) platformId: Object) { + constructor( + private _ngZone: NgZone, + @Inject(PLATFORM_ID) platformId: Object, + ) { this._isBrowser = isPlatformBrowser(platformId); } - ngOnInit() { + ngAfterViewInit() { // Don't do anything if we're not in a browser environment. if (!this._isBrowser) { return; } - let iframeApiAvailableObs: Observable = observableOf(true); if (!window.YT || !window.YT.Player) { if (this.showBeforeIframeApiLoads && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw new Error( @@ -243,95 +172,44 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { ); } - const iframeApiAvailableSubject = new Subject(); this._existingApiReadyCallback = window.onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = () => { - if (this._existingApiReadyCallback) { - this._existingApiReadyCallback(); - } - this._ngZone.run(() => iframeApiAvailableSubject.next(true)); + this._existingApiReadyCallback?.(); + this._ngZone.run(() => this._createPlayer()); }; - iframeApiAvailableObs = iframeApiAvailableSubject.pipe(take(1), startWith(false)); + } else { + this._createPlayer(); } + } - const hostObservable = this._disableCookies.pipe( - map(cookiesDisabled => (cookiesDisabled ? 'https://www.youtube-nocookie.com' : undefined)), - ); - - // An observable of the currently loaded player. - const playerObs = createPlayerObservable( - this._youtubeContainer, - this._videoId, - hostObservable, - iframeApiAvailableObs, - this._width, - this._height, - this._playerVars, - this._ngZone, - ).pipe( - tap(player => { - // Emit this before the `waitUntilReady` call so that we can bind to - // events that happen as the player is being initialized (e.g. `onReady`). - this._playerChanges.next(player); - }), - waitUntilReady(player => { - // Destroy the player if loading was aborted so that we don't end up leaking memory. - if (!playerIsReady(player)) { - player.destroy(); - } - }), - takeUntil(this._destroyed), - publish(), - ); - - // Set up side effects to bind inputs to the player. - playerObs.subscribe(player => { - this._player = player; - - if (player && this._pendingPlayerState) { - this._initializePlayer(player, this._pendingPlayerState); + ngOnChanges(changes: SimpleChanges): void { + if (this._shouldRecreatePlayer(changes)) { + this._createPlayer(); + } else if (this._player) { + if (changes['width'] || changes['height']) { + this._setSize(); } - this._pendingPlayerState = undefined; - }); - - bindSizeToPlayer(playerObs, this._width, this._height); - - bindSuggestedQualityToPlayer(playerObs, this._suggestedQuality); - - bindCueVideoCall( - playerObs, - this._videoId, - this._startSeconds, - this._endSeconds, - this._suggestedQuality, - this._destroyed, - ); - - // After all of the subscriptions are set up, connect the observable. - (playerObs as ConnectableObservable).connect(); - } + if (changes['suggestedQuality']) { + this._setQuality(); + } - ngAfterViewInit() { - this._youtubeContainer.next(this.youtubeContainer.nativeElement); + if (changes['startSeconds'] || changes['endSeconds'] || changes['suggestedQuality']) { + this._cuePlayer(); + } + } } ngOnDestroy() { + this._pendingPlayer?.destroy(); + if (this._player) { this._player.destroy(); window.onYouTubeIframeAPIReady = this._existingApiReadyCallback; } this._playerChanges.complete(); - this._videoId.complete(); - this._height.complete(); - this._width.complete(); - this._startSeconds.complete(); - this._endSeconds.complete(); - this._suggestedQuality.complete(); - this._youtubeContainer.complete(); - this._playerVars.complete(); this._destroyed.next(); this._destroyed.complete(); } @@ -522,9 +400,68 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { return this._pendingPlayerState; } - /** Initializes a player from a temporary state. */ - private _initializePlayer(player: YT.Player, state: PendingPlayerState): void { - const {playbackState, playbackRate, volume, muted, seek} = state; + /** + * Determines whether a change in the component state + * requires the YouTube player to be recreated. + */ + private _shouldRecreatePlayer(changes: SimpleChanges): boolean { + const change = changes['videoId'] || changes['playerVars'] || changes['disableCookies']; + return !!change && !change.isFirstChange(); + } + + /** Creates a new YouTube player and destroys the existing one. */ + private _createPlayer() { + this._player?.destroy(); + this._pendingPlayer?.destroy(); + + // A player can't be created if the API isn't loaded, + // or there isn't a video or playlist to be played. + if (typeof YT === 'undefined' || (!this.videoId && !this.playerVars?.list)) { + return; + } + + // Important! We need to create the Player object outside of the `NgZone`, because it kicks + // off a 250ms setInterval which will continually trigger change detection if we don't. + const player = this._ngZone.runOutsideAngular( + () => + new YT.Player(this.youtubeContainer.nativeElement, { + videoId: this.videoId, + host: this.disableCookies ? 'https://www.youtube-nocookie.com' : undefined, + width: this.width, + height: this.height, + playerVars: this.playerVars, + }), + ); + + const whenReady = () => { + // Only assign the player once it's ready, otherwise YouTube doesn't expose some APIs. + this._player = player; + this._pendingPlayer = undefined; + player.removeEventListener('onReady', whenReady); + this._playerChanges.next(player); + this._setSize(); + this._setQuality(); + + if (this._pendingPlayerState) { + this._applyPendingPlayerState(player, this._pendingPlayerState); + this._pendingPlayerState = undefined; + } + + // Only cue the player when it either hasn't started yet or it's cued, + // otherwise cuing it can interrupt a player with autoplay enabled. + const state = player.getPlayerState(); + if (state === YT.PlayerState.UNSTARTED || state === YT.PlayerState.CUED || state == null) { + this._cuePlayer(); + } + }; + + this._pendingPlayer = player; + player.addEventListener('onReady', whenReady); + } + + /** Applies any state that changed before the player was initialized. */ + private _applyPendingPlayerState(player: YT.Player, pendingState: PendingPlayerState): void { + const {playbackState, playbackRate, volume, muted, seek} = pendingState; switch (playbackState) { case YT.PlayerState.PLAYING: @@ -555,6 +492,30 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { } } + /** Cues the player based on the current component state. */ + private _cuePlayer() { + if (this._player && this.videoId) { + this._player.cueVideoById({ + videoId: this.videoId, + startSeconds: this.startSeconds, + endSeconds: this.endSeconds, + suggestedQuality: this.suggestedQuality, + }); + } + } + + /** Sets the player's size based on the current input values. */ + private _setSize() { + this._player?.setSize(this.width, this.height); + } + + /** Sets the player's quality based on the current input values. */ + private _setQuality() { + if (this._player && this.suggestedQuality) { + this._player.setPlaybackQuality(this.suggestedQuality); + } + } + /** Gets an observable that adds an event listener to the player when a user subscribes to it. */ private _getLazyEmitter(name: keyof YT.Events): Observable { // Start with the stream of players. This way the events will be transferred @@ -573,9 +534,7 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { // expose whether the player has been destroyed so we have to wrap it in a try/catch to // prevent the entire stream from erroring out. try { - if ((player as Player).removeEventListener!) { - (player as Player).removeEventListener(name, listener); - } + player?.removeEventListener?.(name, listener); } catch {} }, ) @@ -583,7 +542,7 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { }), // By default we run all the API interactions outside the zone // so we have to bring the events back in manually when they emit. - (source: Observable) => + source => new Observable(observer => source.subscribe({ next: value => this._ngZone.run(() => observer.next(value)), @@ -596,222 +555,3 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { ); } } - -/** Listens to changes to the given width and height and sets it on the player. */ -function bindSizeToPlayer( - playerObs: Observable, - widthObs: Observable, - heightObs: Observable, -) { - return combineLatest([playerObs, widthObs, heightObs]).subscribe( - ([player, width, height]) => player && player.setSize(width, height), - ); -} - -/** Listens to changes from the suggested quality and sets it on the given player. */ -function bindSuggestedQualityToPlayer( - playerObs: Observable, - suggestedQualityObs: Observable, -) { - return combineLatest([playerObs, suggestedQualityObs]).subscribe( - ([player, suggestedQuality]) => - player && suggestedQuality && player.setPlaybackQuality(suggestedQuality), - ); -} - -/** - * Returns an observable that emits the loaded player once it's ready. Certain properties/methods - * won't be available until the iframe finishes loading. - * @param onAbort Callback function that will be invoked if the player loading was aborted before - * it was able to complete. Can be used to clean up any loose references. - */ -function waitUntilReady( - onAbort: (player: UninitializedPlayer) => void, -): OperatorFunction { - return mergeMap(player => { - if (!player) { - return observableOf(undefined); - } - if (playerIsReady(player)) { - return observableOf(player as Player); - } - - // Since removeEventListener is not on Player when it's initialized, we can't use fromEvent. - // The player is not initialized fully until the ready is called. - return new Observable(emitter => { - let aborted = false; - let resolved = false; - const onReady = (event: YT.PlayerEvent) => { - resolved = true; - - if (!aborted) { - event.target.removeEventListener('onReady', onReady); - emitter.next(event.target); - } - }; - - player.addEventListener('onReady', onReady); - - return () => { - aborted = true; - - if (!resolved) { - onAbort(player); - } - }; - }).pipe(take(1), startWith(undefined)); - }); -} - -/** Create an observable for the player based on the given options. */ -function createPlayerObservable( - youtubeContainer: Observable, - videoIdObs: Observable, - hostObs: Observable, - iframeApiAvailableObs: Observable, - widthObs: Observable, - heightObs: Observable, - playerVarsObs: Observable, - ngZone: NgZone, -): Observable { - const playerOptions = combineLatest([videoIdObs, hostObs, playerVarsObs]).pipe( - withLatestFrom(combineLatest([widthObs, heightObs])), - map(([constructorOptions, sizeOptions]) => { - const [videoId, host, playerVars] = constructorOptions; - const [width, height] = sizeOptions; - - // If there's no video id or a list isn't supplied, bail out - if (!videoId && !(playerVars?.list && playerVars?.listType)) { - return undefined; - } - - return {videoId, playerVars, width, height, host}; - }), - ); - - return combineLatest([youtubeContainer, playerOptions, of(ngZone)]).pipe( - skipUntilRememberLatest(iframeApiAvailableObs), - scan(syncPlayerState, undefined), - distinctUntilChanged(), - ); -} - -/** Skips the given observable until the other observable emits true, then emit the latest. */ -function skipUntilRememberLatest(notifier: Observable): MonoTypeOperatorFunction { - return pipe( - combineLatestOp(notifier), - skipWhile(([_, doneSkipping]) => !doneSkipping), - map(([value]) => value), - ); -} - -/** Destroy the player if there are no options, or create the player if there are options. */ -function syncPlayerState( - player: UninitializedPlayer | undefined, - [container, videoOptions, ngZone]: [HTMLElement, YT.PlayerOptions | undefined, NgZone], -): UninitializedPlayer | undefined { - if ( - player && - videoOptions && - (player.playerVars !== videoOptions.playerVars || player.host !== videoOptions.host) - ) { - // The player needs to be recreated if the playerVars are different. - player.destroy(); - } else if (!videoOptions) { - if (player) { - // Destroy the player if the videoId was removed. - player.destroy(); - } - return; - } else if (player) { - return player; - } - - // Important! We need to create the Player object outside of the `NgZone`, because it kicks - // off a 250ms setInterval which will continually trigger change detection if we don't. - const newPlayer: UninitializedPlayer = ngZone.runOutsideAngular( - () => new YT.Player(container, videoOptions), - ); - newPlayer.videoId = videoOptions.videoId; - newPlayer.playerVars = videoOptions.playerVars; - newPlayer.host = videoOptions.host; - return newPlayer; -} - -/** - * Call cueVideoById if the videoId changes, or when start or end seconds change. cueVideoById will - * change the loaded video id to the given videoId, and set the start and end times to the given - * start/end seconds. - */ -function bindCueVideoCall( - playerObs: Observable, - videoIdObs: Observable, - startSecondsObs: Observable, - endSecondsObs: Observable, - suggestedQualityObs: Observable, - destroyed: Observable, -) { - const cueOptionsObs = combineLatest([startSecondsObs, endSecondsObs]).pipe( - map(([startSeconds, endSeconds]) => ({startSeconds, endSeconds})), - ); - - // Only respond to changes in cue options if the player is not running. - const filteredCueOptions = cueOptionsObs.pipe( - filterOnOther(playerObs, player => !!player && !hasPlayerStarted(player)), - ); - - // If the video id changed, there's no reason to run 'cue' unless the player - // was initialized with a different video id. - const changedVideoId = videoIdObs.pipe( - filterOnOther(playerObs, (player, videoId) => !!player && player.videoId !== videoId), - ); - - // If the player changed, there's no reason to run 'cue' unless there are cue options. - const changedPlayer = playerObs.pipe( - filterOnOther( - combineLatest([videoIdObs, cueOptionsObs]), - ([videoId, cueOptions], player) => - !!player && - (videoId != player.videoId || !!cueOptions.startSeconds || !!cueOptions.endSeconds), - ), - ); - - merge(changedPlayer, changedVideoId, filteredCueOptions) - .pipe( - withLatestFrom(combineLatest([playerObs, videoIdObs, cueOptionsObs, suggestedQualityObs])), - map(([_, values]) => values), - takeUntil(destroyed), - ) - .subscribe(([player, videoId, cueOptions, suggestedQuality]) => { - if (!videoId || !player) { - return; - } - player.videoId = videoId; - player.cueVideoById({ - videoId, - suggestedQuality, - ...cueOptions, - }); - }); -} - -function hasPlayerStarted(player: YT.Player): boolean { - const state = player.getPlayerState(); - return state !== YT.PlayerState.UNSTARTED && state !== YT.PlayerState.CUED; -} - -function playerIsReady(player: UninitializedPlayer): player is Player { - return 'getPlayerStatus' in player; -} - -/** Combines the two observables temporarily for the filter function. */ -function filterOnOther( - otherObs: Observable, - filterFn: (t: T, r?: R) => boolean, -): MonoTypeOperatorFunction { - return pipe( - withLatestFrom(otherObs), - filter(([value, other]) => filterFn(other, value)), - map(([value]) => value), - ); -} diff --git a/tools/public_api_guard/youtube-player/youtube-player.md b/tools/public_api_guard/youtube-player/youtube-player.md index 6b0ec32047a0..1d29317416f1 100644 --- a/tools/public_api_guard/youtube-player/youtube-player.md +++ b/tools/public_api_guard/youtube-player/youtube-player.md @@ -11,17 +11,17 @@ import { ElementRef } from '@angular/core'; import * as i0 from '@angular/core'; import { NgZone } from '@angular/core'; import { Observable } from 'rxjs'; +import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; -import { OnInit } from '@angular/core'; +import { SimpleChanges } from '@angular/core'; // @public -export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { +export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { constructor(_ngZone: NgZone, platformId: Object); // (undocumented) readonly apiChange: Observable; - get disableCookies(): boolean; - set disableCookies(value: unknown); - set endSeconds(endSeconds: number | undefined); + disableCookies: boolean; + endSeconds: number | undefined; // (undocumented) readonly error: Observable; getAvailablePlaybackRates(): number[]; @@ -35,38 +35,36 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit { getVideoLoadedFraction(): number; getVideoUrl(): string; getVolume(): number; - get height(): number | undefined; + get height(): number; set height(height: number | undefined); isMuted(): boolean; mute(): void; // (undocumented) ngAfterViewInit(): void; // (undocumented) - ngOnDestroy(): void; + ngOnChanges(changes: SimpleChanges): void; // (undocumented) - ngOnInit(): void; + ngOnDestroy(): void; pauseVideo(): void; // (undocumented) readonly playbackQualityChange: Observable; // (undocumented) readonly playbackRateChange: Observable; - get playerVars(): YT.PlayerVars | undefined; - set playerVars(playerVars: YT.PlayerVars | undefined); + playerVars: YT.PlayerVars | undefined; playVideo(): void; readonly ready: Observable; seekTo(seconds: number, allowSeekAhead: boolean): void; setPlaybackRate(playbackRate: number): void; setVolume(volume: number): void; showBeforeIframeApiLoads: boolean | undefined; - set startSeconds(startSeconds: number | undefined); + startSeconds: number | undefined; // (undocumented) readonly stateChange: Observable; stopVideo(): void; - set suggestedQuality(suggestedQuality: YT.SuggestedVideoQuality | undefined); + suggestedQuality: YT.SuggestedVideoQuality | undefined; unMute(): void; - get videoId(): string | undefined; - set videoId(videoId: string | undefined); - get width(): number | undefined; + videoId: string | undefined; + get width(): number; set width(width: number | undefined); youtubeContainer: ElementRef; // (undocumented)