Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/youtube-player/youtube-player.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,61 @@ describe('YoutubePlayer', () => {
expect(testComponent.youtubePlayer.height).toBe(DEFAULT_PLAYER_HEIGHT);
});

it('initializes the player with start and end seconds', () => {
testComponent.startSeconds = 5;
testComponent.endSeconds = 6;
fixture.detectChanges();

expect(playerSpy.cueVideoById).not.toHaveBeenCalled();

playerSpy.getPlayerState.and.returnValue(window.YT!.PlayerState.CUED);
events.onReady({target: playerSpy});

expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
jasmine.objectContaining({startSeconds: 5, endSeconds: 6}));

testComponent.endSeconds = 8;
fixture.detectChanges();

expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
jasmine.objectContaining({startSeconds: 5, endSeconds: 8}));

testComponent.startSeconds = 7;
fixture.detectChanges();

expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
jasmine.objectContaining({startSeconds: 7, endSeconds: 8}));

testComponent.startSeconds = 10;
testComponent.endSeconds = 11;
fixture.detectChanges();

expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
jasmine.objectContaining({startSeconds: 10, endSeconds: 11}));
});

it('sets the suggested quality', () => {
testComponent.suggestedQuality = 'small';
fixture.detectChanges();

expect(playerSpy.setPlaybackQuality).not.toHaveBeenCalled();

events.onReady({target: playerSpy});

expect(playerSpy.setPlaybackQuality).toHaveBeenCalledWith('small');

testComponent.suggestedQuality = 'large';
fixture.detectChanges();

expect(playerSpy.setPlaybackQuality).toHaveBeenCalledWith('large');

testComponent.videoId = 'other';
fixture.detectChanges();

expect(playerSpy.cueVideoById).toHaveBeenCalledWith(
jasmine.objectContaining({suggestedQuality: 'large'}));
});

it('proxies events as output', () => {
events.onReady({target: playerSpy});
expect(testComponent.onReady).toHaveBeenCalledWith({target: playerSpy});
Expand Down Expand Up @@ -228,6 +283,7 @@ describe('YoutubePlayer', () => {
selector: 'test-app',
template: `
<youtube-player #player [videoId]="videoId" *ngIf="visible" [width]="width" [height]="height"
[startSeconds]="startSeconds" [endSeconds]="endSeconds" [suggestedQuality]="suggestedQuality"
(ready)="onReady($event)"
(stateChange)="onStateChange($event)"
(playbackQualityChange)="onPlaybackQualityChange($event)"
Expand All @@ -242,6 +298,9 @@ class TestApp {
visible = true;
width: number | undefined;
height: number | undefined;
startSeconds: number | undefined;
endSeconds: number | undefined;
suggestedQuality: YT.SuggestedVideoQuality | undefined;
onReady = jasmine.createSpy('onReady');
onStateChange = jasmine.createSpy('onStateChange');
onPlaybackQualityChange = jasmine.createSpy('onPlaybackQualityChange');
Expand Down
95 changes: 80 additions & 15 deletions src/youtube-player/youtube-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy {
private _width = DEFAULT_PLAYER_WIDTH;
private _widthObs = new EventEmitter<number>();

/** The moment when the player is supposed to start playing */
@Input() set startSeconds(startSeconds: number | undefined) {
this._startSeconds.emit(startSeconds);
}
private _startSeconds = new EventEmitter<number | undefined>();

/** The moment when the player is supposed to stop playing */
@Input() set endSeconds(endSeconds: number | undefined) {
this._endSeconds.emit(endSeconds);
}
private _endSeconds = new EventEmitter<number | undefined>();

/** The suggested quality of the player */
@Input() set suggestedQuality(suggestedQuality: YT.SuggestedVideoQuality | undefined) {
this._suggestedQuality.emit(suggestedQuality);
}
private _suggestedQuality = new EventEmitter<YT.SuggestedVideoQuality | undefined>();

/** Outputs are direct proxies from the player itself. */
@Output() ready = new EventEmitter<YT.PlayerEvent>();
@Output() stateChange = new EventEmitter<YT.OnStateChangeEvent>();
Expand All @@ -117,6 +135,10 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy {
const widthObs = this._widthObs.pipe(startWith(this._width));
const heightObs = this._heightObs.pipe(startWith(this._height));

const startSecondsObs = this._startSeconds.pipe(startWith(undefined));
const endSecondsObs = this._endSeconds.pipe(startWith(undefined));
const suggestedQualityObs = this._suggestedQuality.pipe(startWith(undefined));

/** An observable of the currently loaded player. */
const playerObs =
createPlayerObservable(
Expand All @@ -132,7 +154,15 @@ export class YouTubePlayer implements AfterViewInit, OnDestroy {

bindSizeToPlayer(playerObs, widthObs, heightObs);

bindCueVideoCall(playerObs, this._videoId, this._destroyed);
bindSuggestedQualityToPlayer(playerObs, suggestedQualityObs);

bindCueVideoCall(
playerObs,
this._videoId,
startSecondsObs,
endSecondsObs,
suggestedQualityObs,
this._destroyed);

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

/** Listens to changes from the suggested quality and sets it on the given player. */
function bindSuggestedQualityToPlayer(
playerObs: Observable<YT.Player | undefined>,
suggestedQualityObs: Observable<YT.SuggestedVideoQuality | undefined>
) {
return combineLatest(
playerObs,
suggestedQualityObs
).subscribe(
([player, suggestedQuality]) =>
player && suggestedQuality && player.setPlaybackQuality(suggestedQuality));
}

/**
* Returns an observable that emits the loaded player once it's ready. Certain properties/methods
* won't be available until the iframe finishes loading.
Expand Down Expand Up @@ -433,30 +476,52 @@ function syncPlayerState(
function bindCueVideoCall(
playerObs: Observable<Player | undefined>,
videoIdObs: Observable<string | undefined>,
startSecondsObs: Observable<number | undefined>,
endSecondsObs: Observable<number | undefined>,
suggestedQualityObs: Observable<YT.SuggestedVideoQuality | undefined>,
destroyed: Observable<undefined>,
) {
const cueOptionsObs = combineLatest(startSecondsObs, endSecondsObs)
.pipe(map(([startSeconds, endSeconds]) => ({startSeconds, endSeconds})));

// Only respond to changes in cue options if the player is not running.
const filteredCueOptions = cueOptionsObs
.pipe(filterOnOther(playerObs, player => !!player && !hasPlayerStarted(player)));

// If the video id changed, there's no reason to run 'cue' unless the player
// was initialized with a different video id.
const changedVideoId = videoIdObs
.pipe(filterOnOther(playerObs, (player, videoId) => !!player && player.videoId !== videoId));

// If the player changed, there's no reason to run 'cue' unless there are cue options.
const changedPlayer = playerObs.pipe(
filterOnOther(videoIdObs, (videoId, player) => !!player && videoId != player.videoId));

merge(changedPlayer, changedVideoId)
.pipe(
withLatestFrom(combineLatest(playerObs, videoIdObs)),
map(([_, values]) => values),
takeUntil(destroyed),
)
.subscribe(([player, videoId]) => {
if (!videoId || !player) {
return;
}
player.videoId = videoId;
player.cueVideoById({videoId});
filterOnOther(
combineLatest(videoIdObs, cueOptionsObs),
([videoId, cueOptions], player) =>
!!player &&
(videoId != player.videoId || !!cueOptions.startSeconds || !!cueOptions.endSeconds)));

merge(changedPlayer, changedVideoId, filteredCueOptions)
.pipe(
withLatestFrom(combineLatest(playerObs, videoIdObs, cueOptionsObs, suggestedQualityObs)),
map(([_, values]) => values),
takeUntil(destroyed),
)
.subscribe(([player, videoId, cueOptions, suggestedQuality]) => {
if (!videoId || !player) {
return;
}
player.videoId = videoId;
player.cueVideoById({
videoId,
suggestedQuality,
...cueOptions,
});
});
}

function hasPlayerStarted(player: YT.Player): boolean {
return [YT.PlayerState.UNSTARTED, YT.PlayerState.CUED].indexOf(player.getPlayerState()) === -1;
}

/** Combines the two observables temporarily for the filter function. */
Expand Down