Skip to content

Commit d5d7db4

Browse files
Nathan Tatejelbourn
Nathan Tate
authored andcommitted
feat(youtube-player): add start/end seconds & suggested quality inputs (#16736)
1 parent 5e0b8ec commit d5d7db4

File tree

2 files changed

+139
-15
lines changed

2 files changed

+139
-15
lines changed

src/youtube-player/youtube-player.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,61 @@ describe('YoutubePlayer', () => {
132132
expect(testComponent.youtubePlayer.height).toBe(DEFAULT_PLAYER_HEIGHT);
133133
});
134134

135+
it('initializes the player with start and end seconds', () => {
136+
testComponent.startSeconds = 5;
137+
testComponent.endSeconds = 6;
138+
fixture.detectChanges();
139+
140+
expect(playerSpy.cueVideoById).not.toHaveBeenCalled();
141+
142+
playerSpy.getPlayerState.and.returnValue(window.YT!.PlayerState.CUED);
143+
events.onReady({target: playerSpy});
144+
145+
expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
146+
jasmine.objectContaining({startSeconds: 5, endSeconds: 6}));
147+
148+
testComponent.endSeconds = 8;
149+
fixture.detectChanges();
150+
151+
expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
152+
jasmine.objectContaining({startSeconds: 5, endSeconds: 8}));
153+
154+
testComponent.startSeconds = 7;
155+
fixture.detectChanges();
156+
157+
expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
158+
jasmine.objectContaining({startSeconds: 7, endSeconds: 8}));
159+
160+
testComponent.startSeconds = 10;
161+
testComponent.endSeconds = 11;
162+
fixture.detectChanges();
163+
164+
expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
165+
jasmine.objectContaining({startSeconds: 10, endSeconds: 11}));
166+
});
167+
168+
it('sets the suggested quality', () => {
169+
testComponent.suggestedQuality = 'small';
170+
fixture.detectChanges();
171+
172+
expect(playerSpy.setPlaybackQuality).not.toHaveBeenCalled();
173+
174+
events.onReady({target: playerSpy});
175+
176+
expect(playerSpy.setPlaybackQuality).toHaveBeenCalledWith('small');
177+
178+
testComponent.suggestedQuality = 'large';
179+
fixture.detectChanges();
180+
181+
expect(playerSpy.setPlaybackQuality).toHaveBeenCalledWith('large');
182+
183+
testComponent.videoId = 'other';
184+
fixture.detectChanges();
185+
186+
expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
187+
jasmine.objectContaining({suggestedQuality: 'large'}));
188+
});
189+
135190
it('proxies events as output', () => {
136191
events.onReady({target: playerSpy});
137192
expect(testComponent.onReady).toHaveBeenCalledWith({target: playerSpy});
@@ -228,6 +283,7 @@ describe('YoutubePlayer', () => {
228283
selector: 'test-app',
229284
template: `
230285
<youtube-player #player [videoId]="videoId" *ngIf="visible" [width]="width" [height]="height"
286+
[startSeconds]="startSeconds" [endSeconds]="endSeconds" [suggestedQuality]="suggestedQuality"
231287
(ready)="onReady($event)"
232288
(stateChange)="onStateChange($event)"
233289
(playbackQualityChange)="onPlaybackQualityChange($event)"
@@ -242,6 +298,9 @@ class TestApp {
242298
visible = true;
243299
width: number | undefined;
244300
height: number | undefined;
301+
startSeconds: number | undefined;
302+
endSeconds: number | undefined;
303+
suggestedQuality: YT.SuggestedVideoQuality | undefined;
245304
onReady = jasmine.createSpy('onReady');
246305
onStateChange = jasmine.createSpy('onStateChange');
247306
onPlaybackQualityChange = jasmine.createSpy('onPlaybackQualityChange');

src/youtube-player/youtube-player.ts

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy {
9292
private _width = DEFAULT_PLAYER_WIDTH;
9393
private _widthObs = new EventEmitter<number>();
9494

95+
/** The moment when the player is supposed to start playing */
96+
@Input() set startSeconds(startSeconds: number | undefined) {
97+
this._startSeconds.emit(startSeconds);
98+
}
99+
private _startSeconds = new EventEmitter<number | undefined>();
100+
101+
/** The moment when the player is supposed to stop playing */
102+
@Input() set endSeconds(endSeconds: number | undefined) {
103+
this._endSeconds.emit(endSeconds);
104+
}
105+
private _endSeconds = new EventEmitter<number | undefined>();
106+
107+
/** The suggested quality of the player */
108+
@Input() set suggestedQuality(suggestedQuality: YT.SuggestedVideoQuality | undefined) {
109+
this._suggestedQuality.emit(suggestedQuality);
110+
}
111+
private _suggestedQuality = new EventEmitter<YT.SuggestedVideoQuality | undefined>();
112+
95113
/** Outputs are direct proxies from the player itself. */
96114
@Output() ready = new EventEmitter<YT.PlayerEvent>();
97115
@Output() stateChange = new EventEmitter<YT.OnStateChangeEvent>();
@@ -117,6 +135,10 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy {
117135
const widthObs = this._widthObs.pipe(startWith(this._width));
118136
const heightObs = this._heightObs.pipe(startWith(this._height));
119137

138+
const startSecondsObs = this._startSeconds.pipe(startWith(undefined));
139+
const endSecondsObs = this._endSeconds.pipe(startWith(undefined));
140+
const suggestedQualityObs = this._suggestedQuality.pipe(startWith(undefined));
141+
120142
/** An observable of the currently loaded player. */
121143
const playerObs =
122144
createPlayerObservable(
@@ -132,7 +154,15 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy {
132154

133155
bindSizeToPlayer(playerObs, widthObs, heightObs);
134156

135-
bindCueVideoCall(playerObs, this._videoId, this._destroyed);
157+
bindSuggestedQualityToPlayer(playerObs, suggestedQualityObs);
158+
159+
bindCueVideoCall(
160+
playerObs,
161+
this._videoId,
162+
startSecondsObs,
163+
endSecondsObs,
164+
suggestedQualityObs,
165+
this._destroyed);
136166

137167
// After all of the subscriptions are set up, connect the observable.
138168
(playerObs as ConnectableObservable<Player>).connect();
@@ -345,6 +375,19 @@ function bindSizeToPlayer(
345375
.subscribe(([player, width, height]) => player && player.setSize(width, height));
346376
}
347377

378+
/** Listens to changes from the suggested quality and sets it on the given player. */
379+
function bindSuggestedQualityToPlayer(
380+
playerObs: Observable<YT.Player | undefined>,
381+
suggestedQualityObs: Observable<YT.SuggestedVideoQuality | undefined>
382+
) {
383+
return combineLatest(
384+
playerObs,
385+
suggestedQualityObs
386+
).subscribe(
387+
([player, suggestedQuality]) =>
388+
player && suggestedQuality && player.setPlaybackQuality(suggestedQuality));
389+
}
390+
348391
/**
349392
* Returns an observable that emits the loaded player once it's ready. Certain properties/methods
350393
* won't be available until the iframe finishes loading.
@@ -433,30 +476,52 @@ function syncPlayerState(
433476
function bindCueVideoCall(
434477
playerObs: Observable<Player | undefined>,
435478
videoIdObs: Observable<string | undefined>,
479+
startSecondsObs: Observable<number | undefined>,
480+
endSecondsObs: Observable<number | undefined>,
481+
suggestedQualityObs: Observable<YT.SuggestedVideoQuality | undefined>,
436482
destroyed: Observable<undefined>,
437483
) {
484+
const cueOptionsObs = combineLatest(startSecondsObs, endSecondsObs)
485+
.pipe(map(([startSeconds, endSeconds]) => ({startSeconds, endSeconds})));
486+
487+
// Only respond to changes in cue options if the player is not running.
488+
const filteredCueOptions = cueOptionsObs
489+
.pipe(filterOnOther(playerObs, player => !!player && !hasPlayerStarted(player)));
490+
438491
// If the video id changed, there's no reason to run 'cue' unless the player
439492
// was initialized with a different video id.
440493
const changedVideoId = videoIdObs
441494
.pipe(filterOnOther(playerObs, (player, videoId) => !!player && player.videoId !== videoId));
442495

443496
// If the player changed, there's no reason to run 'cue' unless there are cue options.
444497
const changedPlayer = playerObs.pipe(
445-
filterOnOther(videoIdObs, (videoId, player) => !!player && videoId != player.videoId));
446-
447-
merge(changedPlayer, changedVideoId)
448-
.pipe(
449-
withLatestFrom(combineLatest(playerObs, videoIdObs)),
450-
map(([_, values]) => values),
451-
takeUntil(destroyed),
452-
)
453-
.subscribe(([player, videoId]) => {
454-
if (!videoId || !player) {
455-
return;
456-
}
457-
player.videoId = videoId;
458-
player.cueVideoById({videoId});
498+
filterOnOther(
499+
combineLatest(videoIdObs, cueOptionsObs),
500+
([videoId, cueOptions], player) =>
501+
!!player &&
502+
(videoId != player.videoId || !!cueOptions.startSeconds || !!cueOptions.endSeconds)));
503+
504+
merge(changedPlayer, changedVideoId, filteredCueOptions)
505+
.pipe(
506+
withLatestFrom(combineLatest(playerObs, videoIdObs, cueOptionsObs, suggestedQualityObs)),
507+
map(([_, values]) => values),
508+
takeUntil(destroyed),
509+
)
510+
.subscribe(([player, videoId, cueOptions, suggestedQuality]) => {
511+
if (!videoId || !player) {
512+
return;
513+
}
514+
player.videoId = videoId;
515+
player.cueVideoById({
516+
videoId,
517+
suggestedQuality,
518+
...cueOptions,
459519
});
520+
});
521+
}
522+
523+
function hasPlayerStarted(player: YT.Player): boolean {
524+
return [YT.PlayerState.UNSTARTED, YT.PlayerState.CUED].indexOf(player.getPlayerState()) === -1;
460525
}
461526

462527
/** Combines the two observables temporarily for the filter function. */

0 commit comments

Comments
 (0)