From 2b4c7cc4bb04df0ffabf7959a62b16a1de0792a4 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 22 Nov 2023 12:12:59 +0100 Subject: [PATCH] feat(youtube-player): automatically load youtube api Currently users have to load the YouTube API themselves which can be tricky to get right and be prone to race conditions. Since there's only one way to load it, these changes switch to doing so automatically. Loading the API automatically also enables us to optimize the component further in a follow-up PR by showing a placeholder image. If users don't want the API to be loaded automatically, they can disable it through an input or using the newly-introduced `YOUTUBE_PLAYER_CONFIG` injection token. Fixes #17037. --- .../youtube-player/youtube-player-demo.ts | 15 +---- src/youtube-player/README.md | 67 +++++++++++-------- src/youtube-player/public-api.ts | 2 +- src/youtube-player/youtube-module.ts | 7 +- src/youtube-player/youtube-player.spec.ts | 34 ++++++---- src/youtube-player/youtube-player.ts | 66 +++++++++++++++++- .../youtube-player/youtube-player.md | 14 +++- 7 files changed, 142 insertions(+), 63 deletions(-) diff --git a/src/dev-app/youtube-player/youtube-player-demo.ts b/src/dev-app/youtube-player/youtube-player-demo.ts index 9c9e85228410..86378c333681 100644 --- a/src/dev-app/youtube-player/youtube-player-demo.ts +++ b/src/dev-app/youtube-player/youtube-player-demo.ts @@ -14,7 +14,6 @@ import { OnDestroy, ViewChild, } from '@angular/core'; -import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {MatRadioModule} from '@angular/material/radio'; import {YouTubePlayer} from '@angular/youtube-player'; @@ -56,7 +55,7 @@ const VIDEOS: Video[] = [ templateUrl: 'youtube-player-demo.html', styleUrls: ['youtube-player-demo.css'], standalone: true, - imports: [CommonModule, FormsModule, MatRadioModule, MatCheckboxModule, YouTubePlayer], + imports: [FormsModule, MatRadioModule, MatCheckboxModule, YouTubePlayer], }) export class YouTubePlayerDemo implements AfterViewInit, OnDestroy { @ViewChild('demoYouTubePlayer') demoYouTubePlayer: ElementRef; @@ -70,8 +69,6 @@ export class YouTubePlayerDemo implements AfterViewInit, OnDestroy { disableCookies = false; constructor(private _changeDetectorRef: ChangeDetectorRef) { - this._loadApi(); - this.selectedVideo = VIDEOS[0]; } @@ -121,14 +118,4 @@ export class YouTubePlayerDemo implements AfterViewInit, OnDestroy { this._selectedVideoId = undefined; } - - private _loadApi() { - if (!window.YT) { - // We don't need to wait for the API to load since the - // component is set up to wait for it automatically. - const script = document.createElement('script'); - script.src = 'https://www.youtube.com/iframe_api'; - document.body.appendChild(script); - } - } } diff --git a/src/youtube-player/README.md b/src/youtube-player/README.md index 430e281546ed..2d76a4b5a5db 100644 --- a/src/youtube-player/README.md +++ b/src/youtube-player/README.md @@ -1,51 +1,60 @@ # Angular YouTube Player component -This component provides a simple angular wrapper around the embed [YouTube player API](https://developers.google.com/youtube/iframe_api_reference). File any bugs against the [angular/components repo](https://github.com/angular/components/issues). +This component provides a simple Angular wrapper around the +[YouTube player API](https://developers.google.com/youtube/iframe_api_reference). +File any bugs against the [angular/components repo](https://github.com/angular/components/issues). ## Installation - To install, run `npm install @angular/youtube-player`. ## Usage - -Follow the following instructions for setting up the YouTube player component: - -- First, follow the [instructions for installing the API script](https://developers.google.com/youtube/iframe_api_reference#Getting_Started). -- Then make sure the API is available before bootstrapping the YouTube Player component. -- Provide the video id by extracting it from the video URL. +Import the component either by adding the `YouTubePlayerModule` to your app or by importing +`YouTubePlayer` into a standalone component. Then add the `', + template: '', selector: 'youtube-player-example', }) -class YoutubePlayerExample implements OnInit { - ngOnInit() { - if (!apiLoaded) { - // This code loads the IFrame Player API code asynchronously, according to the instructions at - // https://developers.google.com/youtube/iframe_api_reference#Getting_Started - const tag = document.createElement('script'); - tag.src = 'https://www.youtube.com/iframe_api'; - document.body.appendChild(tag); - apiLoaded = true; - } - } -} +export class YoutubePlayerExample {} +``` + +## API reference +Check out the [source](./youtube-player.ts) to read the API. +## YouTube iframe API usage +The `` component requires the YouTube `iframe` to work. If the API isn't loaded +by the time the player is initialized, it'll load the API automatically from `https://www.youtube.com/iframe_api`. +If you don't want it to be loaded, you can either control it on a per-component level using the +`loadApi` input: + +```html + ``` -## API +Or at a global level using the `YOUTUBE_PLAYER_CONFIG` injection token: -Check out the [source](./youtube-player.ts) to read the API. +```typescript +import {NgModule} from '@angular/core'; +import {YouTubePlayer, YOUTUBE_PLAYER_CONFIG} from '@angular/youtube-player'; + +@NgModule({ + imports: [YouTubePlayer], + providers: [{ + provide: YOUTUBE_PLAYER_CONFIG, + useValue: { + loadApi: false + } + }] +}) +export class YourApp {} +``` diff --git a/src/youtube-player/public-api.ts b/src/youtube-player/public-api.ts index 08134842450f..74ee8e349aba 100644 --- a/src/youtube-player/public-api.ts +++ b/src/youtube-player/public-api.ts @@ -7,4 +7,4 @@ */ export * from './youtube-module'; -export {YouTubePlayer} from './youtube-player'; +export {YouTubePlayer, YOUTUBE_PLAYER_CONFIG, YouTubePlayerConfig} from './youtube-player'; diff --git a/src/youtube-player/youtube-module.ts b/src/youtube-player/youtube-module.ts index 115456a98480..512c9e29765f 100644 --- a/src/youtube-player/youtube-module.ts +++ b/src/youtube-player/youtube-module.ts @@ -7,13 +7,10 @@ */ import {NgModule} from '@angular/core'; - import {YouTubePlayer} from './youtube-player'; -const COMPONENTS = [YouTubePlayer]; - @NgModule({ - imports: COMPONENTS, - exports: COMPONENTS, + imports: [YouTubePlayer], + exports: [YouTubePlayer], }) export class YouTubePlayerModule {} diff --git a/src/youtube-player/youtube-player.spec.ts b/src/youtube-player/youtube-player.spec.ts index dd87511c53a9..1ae71d9396ee 100644 --- a/src/youtube-player/youtube-player.spec.ts +++ b/src/youtube-player/youtube-player.spec.ts @@ -1,11 +1,25 @@ import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Component, ViewChild} from '@angular/core'; -import {YouTubePlayer, DEFAULT_PLAYER_WIDTH, DEFAULT_PLAYER_HEIGHT} from './youtube-player'; +import {Component, Provider, ViewChild} from '@angular/core'; +import { + YouTubePlayer, + DEFAULT_PLAYER_WIDTH, + DEFAULT_PLAYER_HEIGHT, + YOUTUBE_PLAYER_CONFIG, +} from './youtube-player'; import {createFakeYtNamespace} from './fake-youtube-player'; import {Subscription} from 'rxjs'; const VIDEO_ID = 'a12345'; const YT_LOADING_STATE_MOCK = {loading: 1, loaded: 0}; +const TEST_PROVIDERS: Provider[] = [ + { + provide: YOUTUBE_PLAYER_CONFIG, + useValue: { + // Disable API loading in tests since we don't want to pull in any additional scripts. + loadApi: false, + }, + }, +]; describe('YoutubePlayer', () => { let playerCtorSpy: jasmine.Spy; @@ -20,12 +34,6 @@ describe('YoutubePlayer', () => { playerSpy = fake.playerSpy; window.YT = fake.namespace; events = fake.events; - - TestBed.configureTestingModule({ - imports: [TestApp, StaticStartEndSecondsApp, NoEventsApp], - }); - - TestBed.compileComponents(); })); describe('API ready', () => { @@ -553,6 +561,7 @@ describe('YoutubePlayer', () => { selector: 'test-app', standalone: true, imports: [YouTubePlayer], + providers: TEST_PROVIDERS, template: ` @if (visible) { { (playbackQualityChange)="onPlaybackQualityChange($event)" (playbackRateChange)="onPlaybackRateChange($event)" (error)="onError($event)" - (apiChange)="onApiChange($event)"> - + (apiChange)="onApiChange($event)"/> } `, }) @@ -591,8 +599,9 @@ class TestApp { @Component({ standalone: true, imports: [YouTubePlayer], + providers: TEST_PROVIDERS, template: ` - + `, }) class StaticStartEndSecondsApp { @@ -602,7 +611,8 @@ class StaticStartEndSecondsApp { @Component({ standalone: true, imports: [YouTubePlayer], - template: ``, + providers: TEST_PROVIDERS, + template: ``, }) class NoEventsApp { @ViewChild(YouTubePlayer) player: YouTubePlayer; diff --git a/src/youtube-player/youtube-player.ts b/src/youtube-player/youtube-player.ts index ee70db1e7bc6..2e08adef8b26 100644 --- a/src/youtube-player/youtube-player.ts +++ b/src/youtube-player/youtube-player.ts @@ -26,6 +26,9 @@ import { AfterViewInit, booleanAttribute, numberAttribute, + InjectionToken, + inject, + CSP_NONCE, } from '@angular/core'; import {isPlatformBrowser} from '@angular/common'; import {Observable, of as observableOf, Subject, BehaviorSubject, fromEventPattern} from 'rxjs'; @@ -38,6 +41,19 @@ declare global { } } +/** Injection token used to configure the `YouTubePlayer`. */ +export const YOUTUBE_PLAYER_CONFIG = new InjectionToken( + 'YOUTUBE_PLAYER_CONFIG', +); + +/** Object that can be used to configure the `YouTubePlayer`. */ +export interface YouTubePlayerConfig { + /** + * Whether to load the YouTube iframe API automatically. Defaults to `true`. + */ + loadApi?: boolean; +} + export const DEFAULT_PLAYER_WIDTH = 640; export const DEFAULT_PLAYER_HEIGHT = 390; @@ -53,6 +69,7 @@ interface PendingPlayerState { seek?: {seconds: number; allowSeekAhead: boolean}; } +/** Coercion function for time values. */ function coerceTime(value: number | undefined): number | undefined { return value == null ? value : numberAttribute(value, 0); } @@ -79,6 +96,7 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { private _pendingPlayerState: PendingPlayerState | undefined; private readonly _destroyed = new Subject(); private readonly _playerChanges = new BehaviorSubject(undefined); + private readonly _nonce = inject(CSP_NONCE, {optional: true}); /** YouTube Video ID to view */ @Input() @@ -127,6 +145,10 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { @Input({transform: booleanAttribute}) disableCookies: boolean = false; + /** Whether to automatically load the YouTube iframe API. Defaults to `true`. */ + @Input({transform: booleanAttribute}) + loadApi: boolean; + /** * 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 @@ -161,6 +183,8 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { private _ngZone: NgZone, @Inject(PLATFORM_ID) platformId: Object, ) { + const config = inject(YOUTUBE_PLAYER_CONFIG, {optional: true}); + this.loadApi = config?.loadApi ?? true; this._isBrowser = isPlatformBrowser(platformId); } @@ -171,7 +195,9 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { } if (!window.YT || !window.YT.Player) { - if (this.showBeforeIframeApiLoads && (typeof ngDevMode === 'undefined' || ngDevMode)) { + if (this.loadApi) { + loadApi(this._nonce); + } else 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: ' + @@ -562,3 +588,41 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { ); } } + +let apiLoaded = false; + +/** Loads the YouTube API from a specified URL only once. */ +function loadApi(nonce: string | null): void { + if (apiLoaded) { + return; + } + + // We can use `document` directly here, because this logic doesn't run outside the browser. + const url = 'https://www.youtube.com/iframe_api'; + const script = document.createElement('script'); + const callback = (event: Event) => { + script.removeEventListener('load', callback); + script.removeEventListener('error', callback); + + if (event.type === 'error') { + apiLoaded = false; + + if (typeof ngDevMode === 'undefined' || ngDevMode) { + console.error(`Failed to load YouTube API from ${url}`); + } + } + }; + script.addEventListener('load', callback); + script.addEventListener('error', callback); + (script as any).src = url; + script.async = true; + + if (nonce) { + script.nonce = nonce; + } + + // Set this immediately to true so we don't start loading another script + // while this one is pending. If loading fails, we'll flip it back to false. + apiLoaded = true; + document.body.appendChild(script); +} diff --git a/tools/public_api_guard/youtube-player/youtube-player.md b/tools/public_api_guard/youtube-player/youtube-player.md index 4a025ce8a6ab..6d611c872c0b 100644 --- a/tools/public_api_guard/youtube-player/youtube-player.md +++ b/tools/public_api_guard/youtube-player/youtube-player.md @@ -9,12 +9,16 @@ import { AfterViewInit } from '@angular/core'; import { ElementRef } from '@angular/core'; import * as i0 from '@angular/core'; +import { InjectionToken } from '@angular/core'; import { NgZone } from '@angular/core'; import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { SimpleChanges } from '@angular/core'; +// @public +export const YOUTUBE_PLAYER_CONFIG: InjectionToken; + // @public export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { constructor(_ngZone: NgZone, platformId: Object); @@ -38,6 +42,7 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { get height(): number; set height(height: number | undefined); isMuted(): boolean; + loadApi: boolean; mute(): void; // (undocumented) static ngAcceptInputType_disableCookies: unknown; @@ -46,6 +51,8 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { // (undocumented) static ngAcceptInputType_height: unknown; // (undocumented) + static ngAcceptInputType_loadApi: unknown; + // (undocumented) static ngAcceptInputType_showBeforeIframeApiLoads: unknown; // (undocumented) static ngAcceptInputType_startSeconds: number | undefined; @@ -80,11 +87,16 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { set width(width: number | undefined); youtubeContainer: ElementRef; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface YouTubePlayerConfig { + loadApi?: boolean; +} + // @public (undocumented) export class YouTubePlayerModule { // (undocumented)