From b7c47c3025d430c738175f0e7e84d37c6311d8fd Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 30 Nov 2023 13:37:27 +0100 Subject: [PATCH] feat(youtube-player): improve initial load performance using a placeholder image (#28207) Currently the `youtube-player` component loads the YouTube API and sets up the video on initialization. This can slow the page down a lot, because it loads and executes ~150kb of JavaScript, even though the video isn't playing. These changes rework the `youtube-player` component to show the thumbnail of the video and a fake button instead. When the button is clicked, the API will be loaded and the video will be autoplayed, thus moving the YouTube API out of the critical path. There are a few cases where the placeholder won't be shown: * A video that plays automatically. * When the `youtube-player` is showing a playlist, rather than a single video. --- .stylelintrc.json | 2 +- .../youtube-player/youtube-player-demo.html | 27 +- .../youtube-player/youtube-player-demo.ts | 34 ++- src/youtube-player/BUILD.bazel | 9 + src/youtube-player/README.md | 71 ++++++ src/youtube-player/public-api.ts | 1 + .../youtube-player-placeholder.scss | 40 +++ .../youtube-player-placeholder.ts | 74 ++++++ src/youtube-player/youtube-player.spec.ts | 238 ++++++++++++++++-- src/youtube-player/youtube-player.ts | 205 +++++++++++---- .../youtube-player/youtube-player.md | 19 +- 11 files changed, 632 insertions(+), 88 deletions(-) create mode 100644 src/youtube-player/youtube-player-placeholder.scss create mode 100644 src/youtube-player/youtube-player-placeholder.ts diff --git a/.stylelintrc.json b/.stylelintrc.json index 40a5afcb9783..2c4fc36f5ac0 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -136,7 +136,7 @@ ], "linebreaks": "unix", "selector-class-pattern": [ - "^_?(mat-|cdk-|example-|demo-|ng-|mdc-|map-|test-)", + "^_?(mat-|cdk-|example-|demo-|ng-|mdc-|map-|test-|youtube-player-)", { "resolveNestedSelectors": true } diff --git a/src/dev-app/youtube-player/youtube-player-demo.html b/src/dev-app/youtube-player/youtube-player-demo.html index 5a8a04f8c6c2..1f08254f95b6 100644 --- a/src/dev-app/youtube-player/youtube-player-demo.html +++ b/src/dev-app/youtube-player/youtube-player-demo.html @@ -1,5 +1,5 @@
-

Basic Example

+

Basic Example

@@ -12,10 +12,31 @@

Basic Example

Disable cookies + Disable placeholder
+ [width]="videoWidth" + [height]="videoHeight" + [disableCookies]="disableCookies" + [disablePlaceholder]="disablePlaceholder" + [placeholderImageQuality]="placeholderQuality">
+ +

Placeholder quality comparison (high to low)

+ + +
diff --git a/src/dev-app/youtube-player/youtube-player-demo.ts b/src/dev-app/youtube-player/youtube-player-demo.ts index 86378c333681..cc65729dfabb 100644 --- a/src/dev-app/youtube-player/youtube-player-demo.ts +++ b/src/dev-app/youtube-player/youtube-player-demo.ts @@ -16,37 +16,60 @@ import { } from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MatRadioModule} from '@angular/material/radio'; -import {YouTubePlayer} from '@angular/youtube-player'; +import {PlaceholderImageQuality, YouTubePlayer} from '@angular/youtube-player'; import {MatCheckboxModule} from '@angular/material/checkbox'; interface Video { id: string; name: string; isPlaylist?: boolean; + autoplay?: boolean; + placeholderQuality: PlaceholderImageQuality; } const VIDEOS: Video[] = [ { - id: 'PRQCAL_RMVo', - name: 'Instructional', + id: 'hsUxJjY-PRg', + name: 'Control Flow', + placeholderQuality: 'high', }, { id: 'O0xx5SvjmnU', name: 'Angular Conf', + placeholderQuality: 'high', }, { id: 'invalidname', name: 'Invalid', + placeholderQuality: 'high', }, { id: 'PLOa5YIicjJ-XCGXwnEmMmpHHCn11gUgvL', name: 'Angular Forms Playlist', isPlaylist: true, + placeholderQuality: 'high', }, { id: 'PLOa5YIicjJ-VpOOoLczAGTLEEznZ2JEa6', name: 'Angular Router Playlist', isPlaylist: true, + placeholderQuality: 'high', + }, + { + id: 'PXNp4LENMPA', + name: 'Angular.dev (autoplay)', + autoplay: true, + placeholderQuality: 'high', + }, + { + id: 'txqiwrbYGrs', + name: 'David after dentist (only standard quality placeholder)', + placeholderQuality: 'low', + }, + { + id: 'EwTZ2xpQwpA', + name: 'Chocolate rain (only low quality placeholder)', + placeholderQuality: 'low', }, ]; @@ -67,6 +90,8 @@ export class YouTubePlayerDemo implements AfterViewInit, OnDestroy { videoWidth: number | undefined; videoHeight: number | undefined; disableCookies = false; + disablePlaceholder = false; + placeholderQuality: PlaceholderImageQuality; constructor(private _changeDetectorRef: ChangeDetectorRef) { this.selectedVideo = VIDEOS[0]; @@ -102,11 +127,12 @@ export class YouTubePlayerDemo implements AfterViewInit, OnDestroy { set selectedVideo(value: Video | undefined) { this._selectedVideo = value; + this.placeholderQuality = value?.placeholderQuality || 'standard'; // If the video is a playlist, don't send a video id, and prepare playerVars instead if (!value?.isPlaylist) { - this._playerVars = undefined; + this._playerVars = value?.autoplay ? {autoplay: 1} : undefined; this._selectedVideoId = value?.id; return; } diff --git a/src/youtube-player/BUILD.bazel b/src/youtube-player/BUILD.bazel index 0eb596ab52c6..00c74350579b 100644 --- a/src/youtube-player/BUILD.bazel +++ b/src/youtube-player/BUILD.bazel @@ -4,6 +4,7 @@ load( "ng_package", "ng_test_library", "ng_web_test_suite", + "sass_binary", ) package(default_visibility = ["//visibility:public"]) @@ -17,6 +18,9 @@ ng_module( "fake-youtube-player.ts", ], ), + assets = [ + ":youtube_player_placeholder_scss", + ], deps = [ "//src:dev_mode_types", "@npm//@angular/common", @@ -26,6 +30,11 @@ ng_module( ], ) +sass_binary( + name = "youtube_player_placeholder_scss", + src = "youtube-player-placeholder.scss", +) + ng_package( name = "npm_package", srcs = ["package.json"], diff --git a/src/youtube-player/README.md b/src/youtube-player/README.md index 2d76a4b5a5db..528690e7f86a 100644 --- a/src/youtube-player/README.md +++ b/src/youtube-player/README.md @@ -58,3 +58,74 @@ import {YouTubePlayer, YOUTUBE_PLAYER_CONFIG} from '@angular/youtube-player'; }) export class YourApp {} ``` + +## Loading behavior +By default the `` will show a placeholder element instead of loading the API +up-front until the user interacts with it. This speeds up the initial render of the page by not +loading unnecessary JavaScript for a video that might not be played. Once the user clicks on the +video, the API will be loaded and the placeholder will be swapped out with the actual video. + +Note that the placeholder won't be shown in the following scenarios: +* Video that plays automatically (e.g. `playerVars` contains `autoplay: 1`). +* The player is showing a playlist (e.g. `playerVars` contains a `list` property). + +If you want to disable the placeholder and have the `` load the API on +initialization, you can either pass in the `disablePlaceholder` input: + +```html + +``` + +Or set it at a global level using the `YOUTUBE_PLAYER_CONFIG` injection token: + +```typescript +import {NgModule} from '@angular/core'; +import {YouTubePlayer, YOUTUBE_PLAYER_CONFIG} from '@angular/youtube-player'; + +@NgModule({ + imports: [YouTubePlayer], + providers: [{ + provide: YOUTUBE_PLAYER_CONFIG, + useValue: { + disablePlaceholder: true + } + }] +}) +export class YourApp {} +``` + +### Placeholder image quality +YouTube provides different sizes of placeholder images depending on when the video was uploaded +and the thumbnail that was provided by the uploader. The `` defaults to a quality +that should be available for the majority of videos, but if you're seeing a grey placeholder, +consider switching to the `low` quality using the `placeholderImageQuality` input or through the +`YOUTUBE_PLAYER_CONFIG`. + +```html + + + + + + + + +``` + +### Placeholder internationalization +Since the placeholder has an interactive `button` element, it needs an `aria-label` for proper +accessibility. The default label is "Play video", but you can customize it based on your app through +the `placeholderButtonLabel` input or the `YOUTUBE_PLAYER_CONFIG` injection token: + +```html + +``` + +### Placeholder caveats +There are a couple of considerations when using placeholders: +1. Different videos support different sizes of placeholder images and there's no way to know +ahead of time which one is supported. The `` defaults to a value that should +work for most videos, but if you want something higher or lower, you can refer to the +["Placeholder image quality" section](#placeholder-image-quality). +2. Unlike the native YouTube placeholder, the Angular component doesn't show the video's title, +because it isn't known ahead of time. diff --git a/src/youtube-player/public-api.ts b/src/youtube-player/public-api.ts index 74ee8e349aba..41a6a048c3c1 100644 --- a/src/youtube-player/public-api.ts +++ b/src/youtube-player/public-api.ts @@ -8,3 +8,4 @@ export * from './youtube-module'; export {YouTubePlayer, YOUTUBE_PLAYER_CONFIG, YouTubePlayerConfig} from './youtube-player'; +export {PlaceholderImageQuality} from './youtube-player-placeholder'; diff --git a/src/youtube-player/youtube-player-placeholder.scss b/src/youtube-player/youtube-player-placeholder.scss new file mode 100644 index 000000000000..69c2530afeba --- /dev/null +++ b/src/youtube-player/youtube-player-placeholder.scss @@ -0,0 +1,40 @@ +.youtube-player-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + overflow: hidden; + cursor: pointer; + background-color: #000; + background-position: center center; + background-size: cover; + transition: box-shadow 300ms ease; + + // YouTube has a slight drop shadow on the preview that we try to imitate here. + // Note that they use a base64 image, likely for performance reasons. We can't use the + // image, because it can break users with a CSP that doesn't allow `data:` URLs. + box-shadow: inset 0 120px 90px -90px rgba(0, 0, 0, 0.8); +} + +.youtube-player-placeholder-button { + transition: opacity 300ms ease; + -moz-appearance: none; + -webkit-appearance: none; + background: none; + border: none; + padding: 0; + display: flex; + + svg { + width: 68px; + height: 48px; + } +} + +.youtube-player-placeholder-loading { + box-shadow: none; + + .youtube-player-placeholder-button { + opacity: 0; + } +} diff --git a/src/youtube-player/youtube-player-placeholder.ts b/src/youtube-player/youtube-player-placeholder.ts new file mode 100644 index 000000000000..1d537e193e7c --- /dev/null +++ b/src/youtube-player/youtube-player-placeholder.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core'; + +/** Quality of the placeholder image. */ +export type PlaceholderImageQuality = 'high' | 'standard' | 'low'; + +@Component({ + selector: 'youtube-player-placeholder', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + standalone: true, + styleUrl: 'youtube-player-placeholder.css', + host: { + 'class': 'youtube-player-placeholder', + '[class.youtube-player-placeholder-loading]': 'isLoading', + '[style.background-image]': '_getBackgroundImage()', + '[style.width.px]': 'width', + '[style.height.px]': 'height', + }, +}) +export class YouTubePlayerPlaceholder { + /** ID of the video for which to show the placeholder. */ + @Input() videoId: string; + + /** Width of the video for which to show the placeholder. */ + @Input() width: number; + + /** Height of the video for which to show the placeholder. */ + @Input() height: number; + + /** Whether the video is currently being loaded. */ + @Input() isLoading: boolean; + + /** Accessible label for the play button. */ + @Input() buttonLabel: string; + + /** Quality of the placeholder image. */ + @Input() quality: PlaceholderImageQuality; + + /** Gets the background image showing the placeholder. */ + protected _getBackgroundImage(): string | undefined { + let url: string; + + if (this.quality === 'low') { + url = `https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg`; + } else if (this.quality === 'high') { + url = `https://i.ytimg.com/vi/${this.videoId}/maxresdefault.jpg`; + } else { + url = `https://i.ytimg.com/vi_webp/${this.videoId}/sddefault.webp`; + } + + return `url(${url})`; + } +} diff --git a/src/youtube-player/youtube-player.spec.ts b/src/youtube-player/youtube-player.spec.ts index 1ae71d9396ee..658a9d5821b5 100644 --- a/src/youtube-player/youtube-player.spec.ts +++ b/src/youtube-player/youtube-player.spec.ts @@ -7,6 +7,7 @@ import { YOUTUBE_PLAYER_CONFIG, } from './youtube-player'; import {createFakeYtNamespace} from './fake-youtube-player'; +import {PlaceholderImageQuality} from './youtube-player-placeholder'; import {Subscription} from 'rxjs'; const VIDEO_ID = 'a12345'; @@ -36,8 +37,19 @@ describe('YoutubePlayer', () => { events = fake.events; })); + function getVideoHost(componentFixture: ComponentFixture): HTMLElement { + // Not the most resilient selector, but we don't want to introduce any + // classes/IDs on the `div` so users don't start depending on it. + return componentFixture.nativeElement.querySelector('div > div'); + } + + function getPlaceholder(componentFixture: ComponentFixture): HTMLElement { + return componentFixture.nativeElement.querySelector('youtube-player-placeholder'); + } + describe('API ready', () => { beforeEach(() => { + TestBed.configureTestingModule({providers: TEST_PROVIDERS}); fixture = TestBed.createComponent(TestApp); testComponent = fixture.debugElement.componentInstance; fixture.detectChanges(); @@ -48,21 +60,29 @@ describe('YoutubePlayer', () => { window.onYouTubeIframeAPIReady = undefined; }); - it('initializes a youtube player', () => { - let containerElement = fixture.nativeElement.querySelector('div'); + it('initializes a youtube player when the placeholder is clicked', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + events.onReady({target: playerSpy}); + fixture.detectChanges(); expect(playerCtorSpy).toHaveBeenCalledWith( - containerElement, + getVideoHost(fixture), jasmine.objectContaining({ videoId: VIDEO_ID, width: DEFAULT_PLAYER_WIDTH, height: DEFAULT_PLAYER_HEIGHT, - playerVars: undefined, + playerVars: {autoplay: 1}, }), ); + + expect(getPlaceholder(fixture)).toBeFalsy(); }); it('destroys the iframe when the component is destroyed', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + events.onReady({target: playerSpy}); testComponent.visible = false; @@ -72,7 +92,10 @@ describe('YoutubePlayer', () => { }); it('responds to changes in video id', () => { - let containerElement = fixture.nativeElement.querySelector('div'); + getPlaceholder(fixture).click(); + fixture.detectChanges(); + + const containerElement = getVideoHost(fixture); testComponent.videoId = 'otherId'; fixture.detectChanges(); @@ -100,6 +123,9 @@ describe('YoutubePlayer', () => { }); it('responds to changes in size', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.width = 5; fixture.detectChanges(); @@ -147,6 +173,10 @@ describe('YoutubePlayer', () => { }); it('passes the configured playerVars to the player', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + events.onReady({target: playerSpy}); + const playerVars: YT.PlayerVars = {modestbranding: YT.ModestBranding.Modest}; fixture.componentInstance.playerVars = playerVars; fixture.detectChanges(); @@ -157,11 +187,14 @@ describe('YoutubePlayer', () => { // We expect 2 calls since the first one is run on init and the // second one happens after the `playerVars` have changed. expect(calls.length).toBe(2); - expect(calls[0].args[1]).toEqual(jasmine.objectContaining({playerVars: undefined})); + expect(calls[0].args[1]).toEqual(jasmine.objectContaining({playerVars: {autoplay: 1}})); expect(calls[1].args[1]).toEqual(jasmine.objectContaining({playerVars})); }); it('initializes the player with start and end seconds', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.startSeconds = 5; testComponent.endSeconds = 6; fixture.detectChanges(); @@ -199,6 +232,9 @@ describe('YoutubePlayer', () => { }); it('sets the suggested quality', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.suggestedQuality = 'small'; fixture.detectChanges(); @@ -222,6 +258,9 @@ describe('YoutubePlayer', () => { }); it('proxies events as output', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + events.onReady({target: playerSpy}); expect(testComponent.onReady).toHaveBeenCalledWith({target: playerSpy}); @@ -245,6 +284,9 @@ describe('YoutubePlayer', () => { }); it('proxies methods to the player', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + events.onReady({target: playerSpy}); testComponent.youtubePlayer.playVideo(); @@ -312,6 +354,9 @@ describe('YoutubePlayer', () => { }); it('should play on init if playVideo was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.youtubePlayer.playVideo(); expect(testComponent.youtubePlayer.getPlayerState()).toBe(YT.PlayerState.PLAYING); @@ -321,6 +366,9 @@ describe('YoutubePlayer', () => { }); it('should pause on init if pauseVideo was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.youtubePlayer.pauseVideo(); expect(testComponent.youtubePlayer.getPlayerState()).toBe(YT.PlayerState.PAUSED); @@ -330,6 +378,9 @@ describe('YoutubePlayer', () => { }); it('should stop on init if stopVideo was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.youtubePlayer.stopVideo(); expect(testComponent.youtubePlayer.getPlayerState()).toBe(YT.PlayerState.CUED); @@ -338,20 +389,22 @@ describe('YoutubePlayer', () => { expect(playerSpy.stopVideo).toHaveBeenCalled(); }); - it( - 'should set the playback rate on init if setPlaybackRate was called before ' + - 'the API has loaded', - () => { - testComponent.youtubePlayer.setPlaybackRate(1337); - expect(testComponent.youtubePlayer.getPlaybackRate()).toBe(1337); + it('should set the playback rate on init if setPlaybackRate was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); - events.onReady({target: playerSpy}); + testComponent.youtubePlayer.setPlaybackRate(1337); + expect(testComponent.youtubePlayer.getPlaybackRate()).toBe(1337); - expect(playerSpy.setPlaybackRate).toHaveBeenCalledWith(1337); - }, - ); + events.onReady({target: playerSpy}); + + expect(playerSpy.setPlaybackRate).toHaveBeenCalledWith(1337); + }); it('should set the volume on init if setVolume was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.youtubePlayer.setVolume(37); expect(testComponent.youtubePlayer.getVolume()).toBe(37); @@ -361,6 +414,9 @@ describe('YoutubePlayer', () => { }); it('should mute on init if mute was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.youtubePlayer.mute(); expect(testComponent.youtubePlayer.isMuted()).toBe(true); @@ -370,6 +426,9 @@ describe('YoutubePlayer', () => { }); it('should unmute on init if umMute was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.youtubePlayer.unMute(); expect(testComponent.youtubePlayer.isMuted()).toBe(false); @@ -379,6 +438,9 @@ describe('YoutubePlayer', () => { }); it('should seek on init if seekTo was called before the API has loaded', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); + testComponent.youtubePlayer.seekTo(1337, true); expect(testComponent.youtubePlayer.getCurrentTime()).toBe(1337); @@ -388,7 +450,11 @@ describe('YoutubePlayer', () => { }); it('should be able to disable cookies', () => { - const containerElement = fixture.nativeElement.querySelector('div'); + getPlaceholder(fixture).click(); + fixture.detectChanges(); + events.onReady({target: playerSpy}); + + const containerElement = getVideoHost(fixture); expect(playerCtorSpy).toHaveBeenCalledWith( containerElement, @@ -410,6 +476,8 @@ describe('YoutubePlayer', () => { }); it('should play with a playlist id instead of a video id', () => { + getPlaceholder(fixture).click(); + fixture.detectChanges(); playerCtorSpy.calls.reset(); const playerVars: YT.PlayerVars = { @@ -462,20 +530,20 @@ describe('YoutubePlayer', () => { it('waits until the api is ready before initializing', () => { (window.YT as any) = YT_LOADING_STATE_MOCK; - + TestBed.configureTestingModule({providers: TEST_PROVIDERS}); fixture = TestBed.createComponent(TestApp); testComponent = fixture.debugElement.componentInstance; fixture.detectChanges(); + getPlaceholder(fixture).click(); + fixture.detectChanges(); expect(playerCtorSpy).not.toHaveBeenCalled(); window.YT = api!; window.onYouTubeIframeAPIReady!(); - let containerElement = fixture.nativeElement.querySelector('div'); - expect(playerCtorSpy).toHaveBeenCalledWith( - containerElement, + getVideoHost(fixture), jasmine.objectContaining({ videoId: VIDEO_ID, width: DEFAULT_PLAYER_WIDTH, @@ -487,10 +555,12 @@ describe('YoutubePlayer', () => { it('should not override any pre-existing API loaded callbacks', () => { const spy = jasmine.createSpy('other API loaded spy'); window.onYouTubeIframeAPIReady = spy; - + TestBed.configureTestingModule({providers: TEST_PROVIDERS}); fixture = TestBed.createComponent(TestApp); testComponent = fixture.debugElement.componentInstance; fixture.detectChanges(); + getPlaceholder(fixture).click(); + fixture.detectChanges(); expect(playerCtorSpy).not.toHaveBeenCalled(); @@ -501,9 +571,112 @@ describe('YoutubePlayer', () => { }); }); + describe('placeholder behavior', () => { + beforeEach(() => { + TestBed.configureTestingModule({providers: TEST_PROVIDERS}); + fixture = TestBed.createComponent(TestApp); + testComponent = fixture.debugElement.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = testComponent = (window as any).YT = window.onYouTubeIframeAPIReady = undefined!; + }); + + it('should show a placeholder', () => { + const placeholder = getPlaceholder(fixture); + expect(placeholder).toBeTruthy(); + expect(placeholder.style.backgroundImage).toContain( + `https://i.ytimg.com/vi_webp/${VIDEO_ID}/sddefault.webp`, + ); + expect(placeholder.style.width).toBe(`${DEFAULT_PLAYER_WIDTH}px`); + expect(placeholder.style.height).toBe(`${DEFAULT_PLAYER_HEIGHT}px`); + expect(placeholder.querySelector('button')).toBeTruthy(); + + testComponent.videoId = 'foo123'; + testComponent.width = 100; + testComponent.height = 50; + fixture.detectChanges(); + + expect(placeholder.style.backgroundImage).toContain( + 'https://i.ytimg.com/vi_webp/foo123/sddefault.webp', + ); + expect(placeholder.style.width).toBe('100px'); + expect(placeholder.style.height).toBe('50px'); + }); + + it('should allow for the placeholder to be disabled', () => { + expect(getPlaceholder(fixture)).toBeTruthy(); + + testComponent.disablePlaceholder = true; + fixture.detectChanges(); + + expect(getPlaceholder(fixture)).toBeFalsy(); + }); + + it('should allow for the placeholder button label to be changed', () => { + const button = getPlaceholder(fixture).querySelector('button')!; + + expect(button.getAttribute('aria-label')).toBe('Play video'); + + testComponent.placeholderButtonLabel = 'Play Star Wars'; + fixture.detectChanges(); + + expect(button.getAttribute('aria-label')).toBe('Play Star Wars'); + }); + + it('should not show the placeholder if a playlist is assigned', () => { + expect(getPlaceholder(fixture)).toBeTruthy(); + + testComponent.videoId = undefined; + testComponent.playerVars = { + list: 'some-playlist-id', + listType: 'playlist', + }; + fixture.detectChanges(); + + expect(getPlaceholder(fixture)).toBeFalsy(); + }); + + it('should hide the placeholder and start playing if an autoplaying video is assigned', () => { + expect(getPlaceholder(fixture)).toBeTruthy(); + expect(playerCtorSpy).not.toHaveBeenCalled(); + + testComponent.playerVars = {autoplay: 1}; + fixture.detectChanges(); + events.onReady({target: playerSpy}); + fixture.detectChanges(); + + expect(getPlaceholder(fixture)).toBeFalsy(); + expect(playerCtorSpy).toHaveBeenCalled(); + }); + + it('should allow for the placeholder image quality to be changed', () => { + const placeholder = getPlaceholder(fixture); + expect(placeholder.style.backgroundImage).toContain( + `https://i.ytimg.com/vi_webp/${VIDEO_ID}/sddefault.webp`, + ); + + testComponent.placeholderImageQuality = 'low'; + fixture.detectChanges(); + expect(placeholder.style.backgroundImage).toContain( + `https://i.ytimg.com/vi/${VIDEO_ID}/hqdefault.jpg`, + ); + + testComponent.placeholderImageQuality = 'high'; + fixture.detectChanges(); + expect(placeholder.style.backgroundImage).toContain( + `https://i.ytimg.com/vi/${VIDEO_ID}/maxresdefault.jpg`, + ); + }); + }); + it('should pick up static startSeconds and endSeconds values', () => { + TestBed.configureTestingModule({providers: TEST_PROVIDERS}); const staticSecondsApp = TestBed.createComponent(StaticStartEndSecondsApp); staticSecondsApp.detectChanges(); + getPlaceholder(staticSecondsApp).click(); + staticSecondsApp.detectChanges(); playerSpy.getPlayerState.and.returnValue(window.YT!.PlayerState.CUED); events.onReady({target: playerSpy}); @@ -514,8 +687,11 @@ describe('YoutubePlayer', () => { }); it('should be able to subscribe to events after initialization', () => { + TestBed.configureTestingModule({providers: TEST_PROVIDERS}); const noEventsApp = TestBed.createComponent(NoEventsApp); noEventsApp.detectChanges(); + getPlaceholder(noEventsApp).click(); + noEventsApp.detectChanges(); events.onReady({target: playerSpy}); noEventsApp.detectChanges(); @@ -561,13 +737,20 @@ describe('YoutubePlayer', () => { selector: 'test-app', standalone: true, imports: [YouTubePlayer], - providers: TEST_PROVIDERS, template: ` @if (visible) { - `, @@ -611,7 +796,6 @@ class StaticStartEndSecondsApp { @Component({ standalone: true, imports: [YouTubePlayer], - providers: TEST_PROVIDERS, template: ``, }) class NoEventsApp { diff --git a/src/youtube-player/youtube-player.ts b/src/youtube-player/youtube-player.ts index 2e08adef8b26..ee451e626f9c 100644 --- a/src/youtube-player/youtube-player.ts +++ b/src/youtube-player/youtube-player.ts @@ -23,16 +23,18 @@ import { PLATFORM_ID, OnChanges, SimpleChanges, - AfterViewInit, booleanAttribute, numberAttribute, InjectionToken, inject, CSP_NONCE, + ChangeDetectorRef, + AfterViewInit, } from '@angular/core'; import {isPlatformBrowser} from '@angular/common'; import {Observable, of as observableOf, Subject, BehaviorSubject, fromEventPattern} from 'rxjs'; import {takeUntil, switchMap} from 'rxjs/operators'; +import {PlaceholderImageQuality, YouTubePlayerPlaceholder} from './youtube-player-placeholder'; declare global { interface Window { @@ -48,10 +50,24 @@ export const YOUTUBE_PLAYER_CONFIG = new InjectionToken( /** 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; + /** - * Whether to load the YouTube iframe API automatically. Defaults to `true`. + * By default the player shows a placeholder image instead of loading the YouTube API which + * improves the initial page load performance. Use this option to disable the placeholder loading + * behavior globally. Defaults to `false`. */ - loadApi?: boolean; + disablePlaceholder?: boolean; + + /** Accessible label for the play button inside of the placeholder. */ + placeholderButtonLabel?: string; + + /** + * Quality of the displayed placeholder image. Defaults to `standard`, + * because not all video have a high-quality placeholder. + */ + placeholderImageQuality?: PlaceholderImageQuality; } export const DEFAULT_PLAYER_WIDTH = 640; @@ -84,8 +100,22 @@ function coerceTime(value: number | undefined): number | undefined { changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, standalone: true, - // This div is *replaced* by the YouTube player embed. - template: '
', + imports: [YouTubePlayerPlaceholder], + template: ` + @if (_shouldShowPlaceholder()) { + + } +
+
+
+ `, }) export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { /** Whether we're currently rendering inside a browser. */ @@ -97,6 +127,9 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { private readonly _destroyed = new Subject(); private readonly _playerChanges = new BehaviorSubject(undefined); private readonly _nonce = inject(CSP_NONCE, {optional: true}); + private readonly _changeDetectorRef = inject(ChangeDetectorRef); + protected _isLoading = false; + protected _hasPlaceholder = true; /** YouTube Video ID to view */ @Input() @@ -149,6 +182,13 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { @Input({transform: booleanAttribute}) loadApi: boolean; + /** + * By default the player shows a placeholder image instead of loading the YouTube API which + * improves the initial page load performance. This input allows for the behavior to be disabled. + */ + @Input({transform: booleanAttribute}) + disablePlaceholder: boolean = false; + /** * 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 @@ -156,6 +196,15 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { */ @Input({transform: booleanAttribute}) showBeforeIframeApiLoads: boolean = false; + /** Accessible label for the play button inside of the placeholder. */ + @Input() placeholderButtonLabel: string; + + /** + * Quality of the displayed placeholder image. Defaults to `standard`, + * because not all video have a high-quality placeholder. + */ + @Input() placeholderImageQuality: PlaceholderImageQuality; + /** Outputs are direct proxies from the player itself. */ @Output() readonly ready: Observable = this._getLazyEmitter('onReady'); @@ -185,40 +234,19 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { ) { const config = inject(YOUTUBE_PLAYER_CONFIG, {optional: true}); this.loadApi = config?.loadApi ?? true; + this.disablePlaceholder = !!config?.disablePlaceholder; + this.placeholderButtonLabel = config?.placeholderButtonLabel || 'Play video'; + this.placeholderImageQuality = config?.placeholderImageQuality || 'standard'; this._isBrowser = isPlatformBrowser(platformId); } ngAfterViewInit() { - // Don't do anything if we're not in a browser environment. - if (!this._isBrowser) { - return; - } - - if (!window.YT || !window.YT.Player) { - 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: ' + - 'https://developers.google.com/youtube/iframe_api_reference', - ); - } - - this._existingApiReadyCallback = window.onYouTubeIframeAPIReady; - - window.onYouTubeIframeAPIReady = () => { - this._existingApiReadyCallback?.(); - this._ngZone.run(() => this._createPlayer()); - }; - } else { - this._createPlayer(); - } + this._conditionallyLoad(); } ngOnChanges(changes: SimpleChanges): void { if (this._shouldRecreatePlayer(changes)) { - this._createPlayer(); + this._conditionallyLoad(); } else if (this._player) { if (changes['width'] || changes['height']) { this._setSize(); @@ -424,6 +452,64 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { return this._player ? this._player.getVideoEmbedCode() : ''; } + /** + * Loads the YouTube API and sets up the player. + * @param playVideo Whether to automatically play the video once the player is loaded. + */ + protected _load(playVideo: boolean) { + // Don't do anything if we're not in a browser environment. + if (!this._isBrowser) { + return; + } + + if (!window.YT || !window.YT.Player) { + if (this.loadApi) { + this._isLoading = true; + 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: ' + + 'https://developers.google.com/youtube/iframe_api_reference', + ); + } + + this._existingApiReadyCallback = window.onYouTubeIframeAPIReady; + + window.onYouTubeIframeAPIReady = () => { + this._existingApiReadyCallback?.(); + this._ngZone.run(() => this._createPlayer(playVideo)); + }; + } else { + this._createPlayer(playVideo); + } + } + + /** Loads the player depending on the internal state of the component. */ + private _conditionallyLoad() { + // If the placeholder isn't shown anymore, we have to trigger a load. + if (!this._shouldShowPlaceholder()) { + this._load(false); + } else if (this.playerVars?.autoplay === 1) { + // If it's an autoplaying video, we have to hide the placeholder and start playing. + this._load(true); + } + } + + /** Whether to show the placeholder element. */ + protected _shouldShowPlaceholder(): boolean { + if (this.disablePlaceholder) { + return false; + } + + // Since we don't load the API on the server, we show the placeholder permanently. + if (!this._isBrowser) { + return true; + } + + return this._hasPlaceholder && !!this.videoId && !this._player; + } + /** Gets an object that should be used to store the temporary API state. */ private _getPendingState(): PendingPlayerState { if (!this._pendingPlayerState) { @@ -438,12 +524,19 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { * requires the YouTube player to be recreated. */ private _shouldRecreatePlayer(changes: SimpleChanges): boolean { - const change = changes['videoId'] || changes['playerVars'] || changes['disableCookies']; + const change = + changes['videoId'] || + changes['playerVars'] || + changes['disableCookies'] || + changes['disablePlaceholder']; return !!change && !change.isFirstChange(); } - /** Creates a new YouTube player and destroys the existing one. */ - private _createPlayer() { + /** + * Creates a new YouTube player and destroys the existing one. + * @param playVideo Whether to play the video once it loads. + */ + private _createPlayer(playVideo: boolean) { this._player?.destroy(); this._pendingPlayer?.destroy(); @@ -462,30 +555,38 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { host: this.disableCookies ? 'https://www.youtube-nocookie.com' : undefined, width: this.width, height: this.height, - playerVars: this.playerVars, + // Calling `playVideo` on load doesn't appear to actually play + // the video so we need to trigger it through `playerVars` instead. + playerVars: playVideo ? {...(this.playerVars || {}), autoplay: 1} : 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; - } + this._ngZone.run(() => { + this._isLoading = false; + this._hasPlaceholder = false; + this._player = player; + this._pendingPlayer = undefined; + player.removeEventListener('onReady', whenReady); + this._playerChanges.next(player); + this._setSize(); + this._setQuality(); - // 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(); - } + 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._changeDetectorRef.markForCheck(); + }); }; this._pendingPlayer = player; diff --git a/tools/public_api_guard/youtube-player/youtube-player.md b/tools/public_api_guard/youtube-player/youtube-player.md index 6d611c872c0b..c4a2248c94c0 100644 --- a/tools/public_api_guard/youtube-player/youtube-player.md +++ b/tools/public_api_guard/youtube-player/youtube-player.md @@ -16,6 +16,9 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { SimpleChanges } from '@angular/core'; +// @public +export type PlaceholderImageQuality = 'high' | 'standard' | 'low'; + // @public export const YOUTUBE_PLAYER_CONFIG: InjectionToken; @@ -25,6 +28,7 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { // (undocumented) readonly apiChange: Observable; disableCookies: boolean; + disablePlaceholder: boolean; endSeconds: number | undefined; // (undocumented) readonly error: Observable; @@ -39,14 +43,21 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { getVideoLoadedFraction(): number; getVideoUrl(): string; getVolume(): number; + // (undocumented) + protected _hasPlaceholder: boolean; get height(): number; set height(height: number | undefined); + // (undocumented) + protected _isLoading: boolean; isMuted(): boolean; + protected _load(playVideo: boolean): void; loadApi: boolean; mute(): void; // (undocumented) static ngAcceptInputType_disableCookies: unknown; // (undocumented) + static ngAcceptInputType_disablePlaceholder: unknown; + // (undocumented) static ngAcceptInputType_endSeconds: number | undefined; // (undocumented) static ngAcceptInputType_height: unknown; @@ -65,6 +76,8 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { // (undocumented) ngOnDestroy(): void; pauseVideo(): void; + placeholderButtonLabel: string; + placeholderImageQuality: PlaceholderImageQuality; // (undocumented) readonly playbackQualityChange: Observable; // (undocumented) @@ -75,6 +88,7 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { seekTo(seconds: number, allowSeekAhead: boolean): void; setPlaybackRate(playbackRate: number): void; setVolume(volume: number): void; + protected _shouldShowPlaceholder(): boolean; showBeforeIframeApiLoads: boolean; startSeconds: number | undefined; // (undocumented) @@ -87,14 +101,17 @@ 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 { + disablePlaceholder?: boolean; loadApi?: boolean; + placeholderButtonLabel?: string; + placeholderImageQuality?: PlaceholderImageQuality; } // @public (undocumented)