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 8dfc1b7e7b6f..2e08adef8b26 100644 --- a/src/youtube-player/youtube-player.ts +++ b/src/youtube-player/youtube-player.ts @@ -24,6 +24,11 @@ import { OnChanges, SimpleChanges, 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'; @@ -36,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; @@ -51,6 +69,11 @@ 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); +} + /** * Angular component that renders a YouTube player via the YouTube player * iframe API. @@ -73,37 +96,38 @@ 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() videoId: string | undefined; /** Height of video player */ - @Input() + @Input({transform: numberAttribute}) get height(): number { return this._height; } set height(height: number | undefined) { - this._height = height || DEFAULT_PLAYER_HEIGHT; + this._height = height == null || isNaN(height) ? DEFAULT_PLAYER_HEIGHT : height; } private _height = DEFAULT_PLAYER_HEIGHT; /** Width of video player */ - @Input() + @Input({transform: numberAttribute}) get width(): number { return this._width; } set width(width: number | undefined) { - this._width = width || DEFAULT_PLAYER_WIDTH; + this._width = width == null || isNaN(width) ? DEFAULT_PLAYER_WIDTH : width; } private _width = DEFAULT_PLAYER_WIDTH; /** The moment when the player is supposed to start playing */ - @Input() + @Input({transform: coerceTime}) startSeconds: number | undefined; /** The moment when the player is supposed to stop playing */ - @Input() + @Input({transform: coerceTime}) endSeconds: number | undefined; /** The suggested quality of the player */ @@ -118,15 +142,19 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { playerVars: YT.PlayerVars | undefined; /** Whether cookies inside the player have been disabled. */ - @Input() + @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 * set on the global window. */ - @Input() showBeforeIframeApiLoads: boolean | undefined; + @Input({transform: booleanAttribute}) showBeforeIframeApiLoads: boolean = false; /** Outputs are direct proxies from the player itself. */ @Output() readonly ready: Observable = @@ -155,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); } @@ -165,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: ' + @@ -556,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 de4a35704bae..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,8 +42,23 @@ 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; + // (undocumented) + static ngAcceptInputType_endSeconds: number | undefined; + // (undocumented) + static ngAcceptInputType_height: unknown; + // (undocumented) + static ngAcceptInputType_loadApi: unknown; + // (undocumented) + static ngAcceptInputType_showBeforeIframeApiLoads: unknown; + // (undocumented) + static ngAcceptInputType_startSeconds: number | undefined; + // (undocumented) + static ngAcceptInputType_width: unknown; + // (undocumented) ngAfterViewInit(): void; // (undocumented) ngOnChanges(changes: SimpleChanges): void; @@ -56,7 +75,7 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { seekTo(seconds: number, allowSeekAhead: boolean): void; setPlaybackRate(playbackRate: number): void; setVolume(volume: number): void; - showBeforeIframeApiLoads: boolean | undefined; + showBeforeIframeApiLoads: boolean; startSeconds: number | undefined; // (undocumented) readonly stateChange: Observable; @@ -68,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)