Skip to content

Commit

Permalink
feat(youtube-player): automatically load youtube api
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto committed Nov 22, 2023
1 parent 5de269d commit 883158b
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 63 deletions.
15 changes: 1 addition & 14 deletions src/dev-app/youtube-player/youtube-player-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<HTMLDivElement>;
Expand All @@ -70,8 +69,6 @@ export class YouTubePlayerDemo implements AfterViewInit, OnDestroy {
disableCookies = false;

constructor(private _changeDetectorRef: ChangeDetectorRef) {
this._loadApi();

this.selectedVideo = VIDEOS[0];
}

Expand Down Expand Up @@ -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);
}
}
}
67 changes: 38 additions & 29 deletions src/youtube-player/README.md
Original file line number Diff line number Diff line change
@@ -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 `<yotube-player videoId="<your ID>"`
to your template.

## Example

If your video is found at https://www.youtube.com/watch?v=PRQCAL_RMVo, then your video id is `PRQCAL_RMVo`.
If your video is found at https://www.youtube.com/watch?v=mVjYG9TSN88, then your video id is `mVjYG9TSN88`.

```typescript
// example-component.ts
import {Component, OnInit} from '@angular/core';
import {Component} from '@angular/core';
import {YouTubePlayer} from '@angular/youtube-player';

let apiLoaded = false;

@Component({
standalone: true,
imports: [YouTubePlayer],
template: '<youtube-player videoId="PRQCAL_RMVo"></youtube-player>',
template: '<youtube-player videoId="mVjYG9TSN88"/>',
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 `<youtube-player/>` 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
<youtube-player videoId="mVjYG9TSN88" loadApi="false"/>
```

## 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 {}
```
2 changes: 1 addition & 1 deletion src/youtube-player/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
*/

export * from './youtube-module';
export {YouTubePlayer} from './youtube-player';
export {YouTubePlayer, YOUTUBE_PLAYER_CONFIG, YouTubePlayerConfig} from './youtube-player';
7 changes: 2 additions & 5 deletions src/youtube-player/youtube-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
34 changes: 22 additions & 12 deletions src/youtube-player/youtube-player.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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', () => {
Expand Down Expand Up @@ -553,6 +561,7 @@ describe('YoutubePlayer', () => {
selector: 'test-app',
standalone: true,
imports: [YouTubePlayer],
providers: TEST_PROVIDERS,
template: `
@if (visible) {
<youtube-player #player [videoId]="videoId" [width]="width" [height]="height"
Expand All @@ -564,8 +573,7 @@ describe('YoutubePlayer', () => {
(playbackQualityChange)="onPlaybackQualityChange($event)"
(playbackRateChange)="onPlaybackRateChange($event)"
(error)="onError($event)"
(apiChange)="onApiChange($event)">
</youtube-player>
(apiChange)="onApiChange($event)"/>
}
`,
})
Expand All @@ -591,8 +599,9 @@ class TestApp {
@Component({
standalone: true,
imports: [YouTubePlayer],
providers: TEST_PROVIDERS,
template: `
<youtube-player [videoId]="videoId" [startSeconds]="42" [endSeconds]="1337"></youtube-player>
<youtube-player [videoId]="videoId" [startSeconds]="42" [endSeconds]="1337"/>
`,
})
class StaticStartEndSecondsApp {
Expand All @@ -602,7 +611,8 @@ class StaticStartEndSecondsApp {
@Component({
standalone: true,
imports: [YouTubePlayer],
template: `<youtube-player [videoId]="videoId"></youtube-player>`,
providers: TEST_PROVIDERS,
template: `<youtube-player [videoId]="videoId"/>`,
})
class NoEventsApp {
@ViewChild(YouTubePlayer) player: YouTubePlayer;
Expand Down
60 changes: 59 additions & 1 deletion src/youtube-player/youtube-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
AfterViewInit,
booleanAttribute,
numberAttribute,
InjectionToken,
inject,
} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {Observable, of as observableOf, Subject, BehaviorSubject, fromEventPattern} from 'rxjs';
Expand All @@ -38,6 +40,19 @@ declare global {
}
}

/** Injection token used to configure the `YouTubePlayer`. */
export const YOUTUBE_PLAYER_CONFIG = new InjectionToken<YouTubePlayerConfig>(
'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;

Expand All @@ -53,6 +68,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);
}
Expand Down Expand Up @@ -127,6 +143,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
Expand Down Expand Up @@ -161,6 +181,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);
}

Expand All @@ -171,7 +193,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();
} 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: ' +
Expand Down Expand Up @@ -562,3 +586,37 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy {
);
}
}

let apiLoaded = false;

/** Loads the YouTube API from a specified URL only once. */
function loadApi(): 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.src = url;

Check failure on line 615 in src/youtube-player/youtube-player.ts

View workflow job for this annotation

GitHub Actions / test

[ban-script-src-assignments] Do not assign variables to HTMLScriptElement#src, as this can lead to XSS.
script.async = true;

// 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);
}
14 changes: 13 additions & 1 deletion tools/public_api_guard/youtube-player/youtube-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<YouTubePlayerConfig>;

// @public
export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy {
constructor(_ngZone: NgZone, platformId: Object);
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -80,11 +87,16 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy {
set width(width: number | undefined);
youtubeContainer: ElementRef<HTMLElement>;
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<YouTubePlayer, "youtube-player", never, { "videoId": { "alias": "videoId"; "required": false; }; "height": { "alias": "height"; "required": false; }; "width": { "alias": "width"; "required": false; }; "startSeconds": { "alias": "startSeconds"; "required": false; }; "endSeconds": { "alias": "endSeconds"; "required": false; }; "suggestedQuality": { "alias": "suggestedQuality"; "required": false; }; "playerVars": { "alias": "playerVars"; "required": false; }; "disableCookies": { "alias": "disableCookies"; "required": false; }; "showBeforeIframeApiLoads": { "alias": "showBeforeIframeApiLoads"; "required": false; }; }, { "ready": "ready"; "stateChange": "stateChange"; "error": "error"; "apiChange": "apiChange"; "playbackQualityChange": "playbackQualityChange"; "playbackRateChange": "playbackRateChange"; }, never, never, true, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<YouTubePlayer, "youtube-player", never, { "videoId": { "alias": "videoId"; "required": false; }; "height": { "alias": "height"; "required": false; }; "width": { "alias": "width"; "required": false; }; "startSeconds": { "alias": "startSeconds"; "required": false; }; "endSeconds": { "alias": "endSeconds"; "required": false; }; "suggestedQuality": { "alias": "suggestedQuality"; "required": false; }; "playerVars": { "alias": "playerVars"; "required": false; }; "disableCookies": { "alias": "disableCookies"; "required": false; }; "loadApi": { "alias": "loadApi"; "required": false; }; "showBeforeIframeApiLoads": { "alias": "showBeforeIframeApiLoads"; "required": false; }; }, { "ready": "ready"; "stateChange": "stateChange"; "error": "error"; "apiChange": "apiChange"; "playbackQualityChange": "playbackQualityChange"; "playbackRateChange": "playbackRateChange"; }, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<YouTubePlayer, never>;
}

// @public
export interface YouTubePlayerConfig {
loadApi?: boolean;
}

// @public (undocumented)
export class YouTubePlayerModule {
// (undocumented)
Expand Down

0 comments on commit 883158b

Please sign in to comment.