license import { AfterViewInit , ChangeDetectionStrategy , Component , ElementRef , Input , NgZone , OnDestroy , OnInit , Output , ViewChild , ViewEncapsulation , Inject , PLATFORM_ID , } 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' ; declare global { interface Window { YT : typeof YT | undefined ; onYouTubeIframeAPIReady : ( ( ) => void ) | undefined ; } } export const DEFAULT_PLAYER_WIDTH = 640 ; export const DEFAULT_PLAYER_HEIGHT = 390 ; interface Player extends YT . Player { videoId ? : string ; playerVars ? : YT . PlayerVars ; } type UninitializedPlayer = Pick < Player , 'videoId' | 'playerVars' | 'destroy' | 'addEventListener' > ; /**
 * Object used to store the state of the player if the
 * user tries to interact with the API before it has been loaded.
 */ interface PendingPlayerState { playbackState ? : YT . PlayerState . PLAYING | YT . PlayerState . PAUSED | YT . PlayerState . CUED ; playbackRate ? : number ; volume ? : number ; muted ? : boolean ; seek ? : { seconds : number ; allowSeekAhead : boolean ; } ; } see https @ Component ( { selector : 'youtube-player' , changeDetection : ChangeDetectionStrategy . OnPush , encapsulation : ViewEncapsulation . None , template : '<div #youtubeContainer></div>' , } ) export class YouTubePlayer implements AfterViewInit , OnDestroy , OnInit { /** Whether we're currently rendering inside a browser. */ private _isBrowser : boolean ; private readonly _youtubeContainer = new Subject < HTMLElement > ( ) ; private readonly _destroyed = new Subject < void > ( ) ; private _player : Player | undefined ; private _existingApiReadyCallback : ( ( ) => void ) | undefined ; private _pendingPlayerState : PendingPlayerState | undefined ; private readonly _playerChanges = new BehaviorSubject < UninitializedPlayer | undefined > ( undefined ) ; /** YouTube Video ID to view */ @ Input ( ) get videoId ( ) : string | undefined { return this . _videoId . value ; } set videoId ( videoId ) { this . _videoId . next ( videoId ) ; } private readonly _videoId = new BehaviorSubject < string | undefined > ( undefined ) ; /** Height of video player */ @ Input ( ) get height ( ) : number | undefined { return this . _height . value ; } set height ( height ) { this . _height . next ( height || DEFAULT_PLAYER_HEIGHT ) ; } private readonly _height = new BehaviorSubject < number > ( DEFAULT_PLAYER_HEIGHT ) ; /** Width of video player */ @ Input ( ) get width ( ) : number | undefined { return this . _width . value ; } set width ( width ) { this . _width . next ( width || DEFAULT_PLAYER_WIDTH ) ; } private readonly _width = new BehaviorSubject < number > ( DEFAULT_PLAYER_WIDTH ) ; /** The moment when the player is supposed to start playing */ @ Input ( ) set startSeconds ( startSeconds ) { this . _startSeconds . next ( startSeconds ) ; } private readonly _startSeconds = new BehaviorSubject < number | undefined > ( undefined ) ; /** The moment when the player is supposed to stop playing */ @ Input ( ) set endSeconds ( endSeconds ) { this . _endSeconds . next ( endSeconds ) ; } private readonly _endSeconds = new BehaviorSubject < number | undefined > ( undefined ) ; /** The suggested quality of the player */ @ Input ( ) set suggestedQuality ( suggestedQuality ) { this . _suggestedQuality . next ( suggestedQuality ) ; } private readonly _suggestedQuality = new BehaviorSubject < YT . SuggestedVideoQuality | undefined > ( 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 ) { this . _playerVars . next ( playerVars ) ; } private _playerVars = new BehaviorSubject < YT . PlayerVars | undefined > ( undefined ) ; /**
     * Whether the iframe will attempt to load regardless of the status of the api on the
     * page. Set this to true if you don't want the `onYouTubeIframeAPIReady` field to be
     * set on the global window.
     */ @ Input ( ) showBeforeIframeApiLoads : boolean | undefined ; /** Outputs are direct proxies from the player itself. */ @ Output ( ) readonly ready : Observable < YT . PlayerEvent > = this . _getLazyEmitter < YT . PlayerEvent > ( 'onReady' ) ; @ Output ( ) readonly stateChange : Observable < YT . OnStateChangeEvent > = this . _getLazyEmitter < YT . OnStateChangeEvent > ( 'onStateChange' ) ; @ Output ( ) readonly error : Observable < YT . OnErrorEvent > = this . _getLazyEmitter < YT . OnErrorEvent > ( 'onError' ) ; @ Output ( ) readonly apiChange : Observable < YT . PlayerEvent > = this . _getLazyEmitter < YT . PlayerEvent > ( 'onApiChange' ) ; @ Output ( ) readonly playbackQualityChange : Observable < YT . OnPlaybackQualityChangeEvent > = this . _getLazyEmitter < YT . OnPlaybackQualityChangeEvent > ( 'onPlaybackQualityChange' ) ; @ Output ( ) readonly playbackRateChange : Observable < YT . OnPlaybackRateChangeEvent > = this . _getLazyEmitter < YT . OnPlaybackRateChangeEvent > ( 'onPlaybackRateChange' ) ; /** The element that will be replaced by the iframe. */ @ ViewChild ( 'youtubeContainer' ) youtubeContainer : ElementRef < HTMLElement > ; constructor ( private _ngZone , @ Inject ( PLATFORM_ID ) platformId ) { this . _isBrowser = isPlatformBrowser ( platformId ) ; } ngOnInit ( ) { if ( ! this . _isBrowser ) { return ; } let iframeApiAvailableObs = observableOf ( true ) ; if ( ! window . YT || ! window . YT . Player ) { if ( this . showBeforeIframeApiLoads && ( typeof ngDevMode === 'undefined' || ngDevMode ) ) { throw new Error ( 'Namespace YT not found, cannot construct embedded youtube player. ' + 'Please install the YouTube Player API Reference for iframe Embeds: ' + 'https://developers.google.com/youtube/iframe_api_reference' ) ; } const iframeApiAvailableSubject = new Subject < boolean > ( ) ; this . _existingApiReadyCallback = window . onYouTubeIframeAPIReady ; window . onYouTubeIframeAPIReady = ( ) => { if ( this . _existingApiReadyCallback ) { this . _existingApiReadyCallback ( ) ; } this . _ngZone . run ( ( ) => iframeApiAvailableSubject . next ( true ) ) ; } ; iframeApiAvailableObs = iframeApiAvailableSubject . pipe ( take ( 1 ) , startWith ( false ) ) ; } const playerObs = createPlayerObservable ( this . _youtubeContainer , this . _videoId , iframeApiAvailableObs , this . _width , this . _height , this . _playerVars , this . _ngZone ) . pipe ( tap ( player => { this . _playerChanges . next ( player ) ; } ) , waitUntilReady ( player => { if ( ! playerIsReady ( player ) ) { player . destroy ( ) ; } } ) , takeUntil ( this . _destroyed ) , publish ( ) ) ; playerObs . subscribe ( player => { this . _player = player ; if ( player && this . _pendingPlayerState ) { this . _initializePlayer ( player , this . _pendingPlayerState ) ; } 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 ) ; ( playerObs as ConnectableObservable < Player > ) . connect ( ) ; } ngAfterViewInit ( ) { this . _youtubeContainer . next ( this . youtubeContainer . nativeElement ) ; } ngOnDestroy ( ) { 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 ( ) ; } /** See https://developers.google.com/youtube/iframe_api_reference#playVideo */ playVideo ( ) { if ( this . _player ) { this . _player . playVideo ( ) ; } else { this . _getPendingState ( ) . playbackState = YT . PlayerState . PLAYING ; } } /** See https://developers.google.com/youtube/iframe_api_reference#pauseVideo */ pauseVideo ( ) { if ( this . _player ) { this . _player . pauseVideo ( ) ; } else { this . _getPendingState ( ) . playbackState = YT . PlayerState . PAUSED ; } } /** See https://developers.google.com/youtube/iframe_api_reference#stopVideo */ stopVideo ( ) { if ( this . _player ) { this . _player . stopVideo ( ) ; } else { this . _getPendingState ( ) . playbackState = YT . PlayerState . CUED ; } } /** See https://developers.google.com/youtube/iframe_api_reference#seekTo */ seekTo ( seconds , allowSeekAhead ) { if ( this . _player ) { this . _player . seekTo ( seconds , allowSeekAhead ) ; } else { this . _getPendingState ( ) . seek = { seconds , allowSeekAhead } ; } } /** See https://developers.google.com/youtube/iframe_api_reference#mute */ mute ( ) { if ( this . _player ) { this . _player . mute ( ) ; } else { this . _getPendingState ( ) . muted = true ; } } /** See https://developers.google.com/youtube/iframe_api_reference#unMute */ unMute ( ) { if ( this . _player ) { this . _player . unMute ( ) ; } else { this . _getPendingState ( ) . muted = false ; } } /** See https://developers.google.com/youtube/iframe_api_reference#isMuted */ isMuted ( ) { if ( this . _player ) { return this . _player . isMuted ( ) ; } if ( this . _pendingPlayerState ) { return ! ! this . _pendingPlayerState . muted ; } return false ; } /** See https://developers.google.com/youtube/iframe_api_reference#setVolume */ setVolume ( volume ) { if ( this . _player ) { this . _player . setVolume ( volume ) ; } else { this . _getPendingState ( ) . volume = volume ; } } /** See https://developers.google.com/youtube/iframe_api_reference#getVolume */ getVolume ( ) { if ( this . _player ) { return this . _player . getVolume ( ) ; } if ( this . _pendingPlayerState && this . _pendingPlayerState . volume != null ) { return this . _pendingPlayerState . volume ; } return 0 ; } /** See https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate */ setPlaybackRate ( playbackRate ) { if ( this . _player ) { return this . _player . setPlaybackRate ( playbackRate ) ; } else { this . _getPendingState ( ) . playbackRate = playbackRate ; } } /** See https://developers.google.com/youtube/iframe_api_reference#getPlaybackRate */ getPlaybackRate ( ) { if ( this . _player ) { return this . _player . getPlaybackRate ( ) ; } if ( this . _pendingPlayerState && this . _pendingPlayerState . playbackRate != null ) { return this . _pendingPlayerState . playbackRate ; } return 0 ; } /** See https://developers.google.com/youtube/iframe_api_reference#getAvailablePlaybackRates */ getAvailablePlaybackRates ( ) { return this . _player ? this . _player . getAvailablePlaybackRates ( ) : [ ] ; } /** See https://developers.google.com/youtube/iframe_api_reference#getVideoLoadedFraction */ getVideoLoadedFraction ( ) { return this . _player ? this . _player . getVideoLoadedFraction ( ) : 0 ; } /** See https://developers.google.com/youtube/iframe_api_reference#getPlayerState */ getPlayerState ( ) { if ( ! this . _isBrowser || ! window . YT ) { return undefined ; } if ( this . _player ) { return this . _player . getPlayerState ( ) ; } if ( this . _pendingPlayerState && this . _pendingPlayerState . playbackState != null ) { return this . _pendingPlayerState . playbackState ; } return YT . PlayerState . UNSTARTED ; } /** See https://developers.google.com/youtube/iframe_api_reference#getCurrentTime */ getCurrentTime ( ) { if ( this . _player ) { return this . _player . getCurrentTime ( ) ; } if ( this . _pendingPlayerState && this . _pendingPlayerState . seek ) { return this . _pendingPlayerState . seek . seconds ; } return 0 ; } /** See https://developers.google.com/youtube/iframe_api_reference#getPlaybackQuality */ getPlaybackQuality ( ) { return this . _player ? this . _player . getPlaybackQuality ( ) : 'default' ; } /** See https://developers.google.com/youtube/iframe_api_reference#getAvailableQualityLevels */ getAvailableQualityLevels ( ) { return this . _player ? this . _player . getAvailableQualityLevels ( ) : [ ] ; } /** See https://developers.google.com/youtube/iframe_api_reference#getDuration */ getDuration ( ) { return this . _player ? this . _player . getDuration ( ) : 0 ; } /** See https://developers.google.com/youtube/iframe_api_reference#getVideoUrl */ getVideoUrl ( ) { return this . _player ? this . _player . getVideoUrl ( ) : '' ; } /** See https://developers.google.com/youtube/iframe_api_reference#getVideoEmbedCode */ getVideoEmbedCode ( ) { return this . _player ? this . _player . getVideoEmbedCode ( ) : '' ; } /** Gets an object that should be used to store the temporary API state. */ private _getPendingState ( ) { if ( ! this . _pendingPlayerState ) { this . _pendingPlayerState = { } ; } return this . _pendingPlayerState ; } /** Initializes a player from a temporary state. */ private _initializePlayer ( player , state ) { const { playbackState , playbackRate , volume , muted , seek } = state ; switch ( playbackState ) { case YT . PlayerState . PLAYING : player . playVideo ( ) ; break ; case YT . PlayerState . PAUSED : player . pauseVideo ( ) ; break ; case YT . PlayerState . CUED : player . stopVideo ( ) ; break ; } if ( playbackRate != null ) { player . setPlaybackRate ( playbackRate ) ; } if ( volume != null ) { player . setVolume ( volume ) ; } if ( muted != null ) { muted ? player . mute ( ) : player . unMute ( ) ; } if ( seek != null ) { player . seekTo ( seek . seconds , seek . allowSeekAhead ) ; } } /** Gets an observable that adds an event listener to the player when a user subscribes to it. */ private _getLazyEmitter < T extends YT . PlayerEvent > ( name ) { return this . _playerChanges . pipe ( switchMap ( player => { return player ? fromEventPattern < T > ( ( listener ) => { player . addEventListener ( name , listener ) ; } , ( listener ) => { try { if ( ( player as Player ) . removeEventListener ! ) { ( player as Player ) . removeEventListener ( name , listener ) ; } } catch { } } ) : observableOf < T > ( ) ; } ) , ( source ) => new Observable < T > ( observer => source . subscribe ( { next : value => this . _ngZone . run ( ( ) => observer . next ( value ) ) , error : error => observer . error ( error ) , complete : ( ) => observer . complete ( ) , } ) ) , takeUntil ( this . _destroyed ) ) ; } } /** Listens to changes to the given width and height and sets it on the player. */ function bindSizeToPlayer ( playerObs , widthObs , heightObs ) { 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 , suggestedQualityObs ) { return combineLatest ( [ playerObs , suggestedQualityObs ] ) . subscribe ( ( [ player , suggestedQuality ] ) => player && suggestedQuality && player . setPlaybackQuality ( suggestedQuality ) ) ; } param onAbort function waitUntilReady ( onAbort ) { return mergeMap ( player => { if ( ! player ) { return observableOf < Player | undefined > ( undefined ) ; } if ( playerIsReady ( player ) ) { return observableOf ( player as Player ) ; } return new Observable < Player > ( emitter => { let aborted = false ; let resolved = false ; const onReady = ( event ) => { 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 , videoIdObs , iframeApiAvailableObs , widthObs , heightObs , playerVarsObs , ngZone ) { const playerOptions = combineLatest ( [ videoIdObs , playerVarsObs ] ) . pipe ( withLatestFrom ( combineLatest ( [ widthObs , heightObs ] ) ) , map ( ( [ constructorOptions , sizeOptions ] ) => { const [ videoId , playerVars ] = constructorOptions ; const [ width , height ] = sizeOptions ; return videoId ? { videoId , playerVars , width , height } : undefined ; } ) ) ; 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 < T > ( notifier ) { 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 , [ container , videoOptions , ngZone ] ) { if ( player && videoOptions && player . playerVars !== videoOptions . playerVars ) { player . destroy ( ) ; } else if ( ! videoOptions ) { if ( player ) { player . destroy ( ) ; } return ; } else if ( player ) { return player ; } const newPlayer = ngZone . runOutsideAngular ( ( ) => new YT . Player ( container , videoOptions ) ) ; newPlayer . videoId = videoOptions . videoId ; newPlayer . playerVars = videoOptions . playerVars ; 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 , videoIdObs , startSecondsObs , endSecondsObs , suggestedQualityObs , destroyed ) { const cueOptionsObs = combineLatest ( [ startSecondsObs , endSecondsObs ] ) . pipe ( map ( ( [ startSeconds , endSeconds ] ) => ( { startSeconds , endSeconds } ) ) ) ; const filteredCueOptions = cueOptionsObs . pipe ( filterOnOther ( playerObs , player => ! ! player && ! hasPlayerStarted ( player ) ) ) ; const changedVideoId = videoIdObs . pipe ( filterOnOther ( playerObs , ( player , videoId ) => ! ! player && player . videoId !== videoId ) ) ; 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 ) { const state = player . getPlayerState ( ) ; return state !== YT . PlayerState . UNSTARTED && state !== YT . PlayerState . CUED ; } function playerIsReady ( player ) { return 'getPlayerStatus' in player ; } /** Combines the two observables temporarily for the filter function. */ function filterOnOther < R , T > ( otherObs , filterFn ) { return pipe ( withLatestFrom ( otherObs ) , filter ( ( [ value , other ] ) => filterFn ( other , value ) ) , map ( ( [ value ] ) => value ) ) ; }