@angular/youtube-player
とは
Angular公式のYouTube player APIのwrapperです。使用方法などは、https://github.com/angular/components/tree/master/src/youtube-player を参照してください。
https://github.com/angular/components/blob/22fecb35665b43061e3a24a89db3fbcb8b9bc5b8/src/youtube-player/youtube-player.ts のタイミングのコードについてコードリーディングしてみようと思います。
コードの依存
package.jsonのdependencies
あたりを見てみると型定義とAngularのcoreとcommonのみです。それはそうかという気もしますが、依存をなるべく少なくするというのは大切ですね。
"dependencies": {
"@types/youtube": "^0.0.38"
},
"peerDependencies": {
"@angular/core": "0.0.0-NG",
"@angular/common": "0.0.0-NG"
},
入力(@Input
)
83行目~
基本はsetterで受け取ってSubject
からデータを流してごにょごにょしています。自分だけで使う場合など、何使ってもいいときは軽量のstate管理ライブラリ使うのはありかなと思いました。
export class YouTubePlayer implements AfterViewInit, OnDestroy, OnInit {
/** YouTube Video ID to view */
@Input()
get videoId(): string | undefined { return this._videoId; }
set videoId(videoId: string | undefined) {
this._videoId = videoId;
this._videoIdObs.next(videoId);
}
private _videoId: string | undefined;
private _videoIdObs = new Subject<string | undefined>();
// ...
出力(@Output
)
146行目~
愚直にYouTube APIのイベントをemitしているだけ。
/** Outputs are direct proxies from the player itself. */
@Output() ready = new EventEmitter<YT.PlayerEvent>();
@Output() stateChange = new EventEmitter<YT.OnStateChangeEvent>();
@Output() error = new EventEmitter<YT.OnErrorEvent>();
@Output() apiChange = new EventEmitter<YT.PlayerEvent>();
@Output() playbackQualityChange = new EventEmitter<YT.OnPlaybackQualityChangeEvent>();
@Output() playbackRateChange = new EventEmitter<YT.OnPlaybackRateChangeEvent>();
初期化まわり(ngOnInit
)
プレイヤーの初期化や入力を受け取るための準備をしています。
ngOnInit() {
// Don't do anything if we're not in a browser environment.
if (!this._isBrowser) {
return;
}
// iframe APIの初期化待ち。読み込めたらiframeApiAvailableSubjectから単発で流す
let iframeApiAvailableObs: Observable<boolean> = observableOf(true);
if (!window.YT) {
if (this.showBeforeIframeApiLoads) {
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');
}
const iframeApiAvailableSubject = new Subject<boolean>();
this._existingApiReadyCallback = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
if (this._existingApiReadyCallback) {
this._existingApiReadyCallback();
}
this._ngZone.run(() => iframeApiAvailableSubject.next(true));
};
iframeApiAvailableObs = iframeApiAvailableSubject.pipe(take(1), startWith(false));
}
// ngOnInitのタイミング前に@Inputに入ってくる可能性があるのでここで入れておく
// combineLatest使う場合はどっちにしろstartWithは必要
// Add initial values to all of the inputs.
const videoIdObs = this._videoIdObs.pipe(startWith(this._videoId));
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));
// 初期化したプレイヤー or undefined を流す
/** An observable of the currently loaded player. */
const playerObs =
createPlayerObservable(
this._youtubeContainer,
videoIdObs,
iframeApiAvailableObs,
widthObs,
heightObs,
this.createEventsBoundInZone(),
).pipe(waitUntilReady(), takeUntil(this._destroyed), publish());
// 294行目~くらいにコンポーネント外部からAPI呼ぶときのproxyメソッドが整備されているので主にそれ用
// Set up side effects to bind inputs to the player.
playerObs.subscribe(player => this._player = player);
// サイズ変更はそれだけ反映
bindSizeToPlayer(playerObs, widthObs, heightObs);
// 再生品質もそれだけ反映
bindSuggestedQualityToPlayer(playerObs, suggestedQualityObs);
// player.cueVideoIdを呼べる条件になったら呼ぶための設定をする。後述
bindCueVideoCall(
playerObs,
videoIdObs,
startSecondsObs,
endSecondsObs,
suggestedQualityObs,
this._destroyed);
// After all of the subscriptions are set up, connect the observable.
(playerObs as ConnectableObservable<Player>).connect();
}
createPlayerObservable
、bindSizeToPlayer
、bindSuggestedQualityToPlayer
、bindCueVideoCall
について詳しく見ます。
createPlayerObservable
videoId
やらオプションやらからプレイヤーのObservable
を作ります。videoId
のありなしでプレイヤーを削除したりもする。
/** Create an observable for the player based on the given options. */
function createPlayerObservable(
youtubeContainer: Observable<HTMLElement>,
videoIdObs: Observable<string | undefined>,
iframeApiAvailableObs: Observable<boolean>,
widthObs: Observable<number>,
heightObs: Observable<number>,
events: YT.Events,
): Observable<UninitializedPlayer | undefined> {
const playerOptions =
videoIdObs
.pipe(
withLatestFrom(combineLatest([widthObs, heightObs])), // 最新のオプションを引き連れていく
map(([videoId, [width, height]]) => videoId ? ({videoId, width, height, events}) : undefined), // ただし、videoIdがない場合はundefined流す
);
return combineLatest([youtubeContainer, playerOptions])
.pipe(
skipUntilRememberLatest(iframeApiAvailableObs), // iframe apiがくるまではskip
scan(syncPlayerState, undefined), // オプションからプレイヤーを作成orそのまま流用。 undefinedなら削除
distinctUntilChanged()); // 流用するときは参照が同じなのでこれ
}
waitUntilReady
あたりで実際のプレイヤーの初期化待ちなどをしている。fromPlayerOnReady
はキレイに初期化待ちするときのお手本にするとよさそうです。大体のやつはこの仕組みでいけそう。
/**
* Returns an observable that emits the loaded player once it's ready. Certain properties/methods
* won't be available until the iframe finishes loading.
*/
function waitUntilReady(): OperatorFunction<UninitializedPlayer | undefined, Player | undefined> {
return flatMap(player => {
if (!player) {
return observableOf<Player|undefined>(undefined);
}
if ('getPlayerStatus' in player) {
return observableOf(player as Player);
}
// The player is not initialized fully until the ready is called.
return fromPlayerOnReady(player)
.pipe(take(1), startWith(undefined));
});
}
/** Since removeEventListener is not on Player when it's initialized, we can't use fromEvent. */
function fromPlayerOnReady(player: UninitializedPlayer): Observable<Player> {
return new Observable<Player>(emitter => {
let aborted = false;
const onReady = (event: YT.PlayerEvent) => {
if (aborted) {
return;
}
event.target.removeEventListener('onReady', onReady);
emitter.next(event.target);
};
player.addEventListener('onReady', onReady);
return () => {
aborted = true;
};
});
}
bindSizeToPlayer
プレイヤーがあったらサイズ変えるだけ。これはかんたん。
/** Listens to changes to the given width and height and sets it on the player. */
function bindSizeToPlayer(
playerObs: Observable<YT.Player | undefined>,
widthObs: Observable<number>,
heightObs: Observable<number>
) {
return combineLatest([playerObs, widthObs, heightObs])
.subscribe(([player, width, height]) => player && player.setSize(width, height));
}
bindSuggestedQualityToPlayer
プレイヤーあったら再生品質を変えるだけ。これもかんたん。
/** 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));
}
bindCueVideoCall
ストリームに対してplayer.cueVideoById
を呼ぶパターンを複数定義しておいて、merge
で合流させたあとに、実際のデータは別口で流すってのがなるほどーという感じです。これは使ってみたい。
/**
* Call cueVideoById if the videoId changes, or when start or end seconds change. cueVideoById will
* change the loaded video id to the given videoId, and set the start and end times to the given
* start/end seconds.
*/
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<void>,
) {
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(
combineLatest([videoIdObs, cueOptionsObs]),
([videoId, cueOptions], player) =>
!!player &&
(videoId != player.videoId || !!cueOptions.startSeconds || !!cueOptions.endSeconds)));
// mergeで合流させて
merge(changedPlayer, changedVideoId, filteredCueOptions)
.pipe(
// 実際のデータはこちらから流す
withLatestFrom(combineLatest([playerObs, videoIdObs, cueOptionsObs, suggestedQualityObs])),
map(([_, values]) => values),
takeUntil(destroyed),
)
.subscribe(([player, videoId, cueOptions, suggestedQuality]) => {
// それを使ってplayer.cueVideoByIdを呼ぶ
if (!videoId || !player) {
return;
}
player.videoId = videoId;
player.cueVideoById({
videoId,
suggestedQuality,
...cueOptions,
});
});
}
掃除(ngOnDestroy
)
269行目~
なるほど、自分のやつcomplete
してないかも・・・。
ngOnDestroy() {
if (this._player) {
this._player.destroy();
window.onYouTubeIframeAPIReady = this._existingApiReadyCallback;
}
this._videoIdObs.complete();
this._heightObs.complete();
this._widthObs.complete();
this._startSeconds.complete();
this._endSeconds.complete();
this._suggestedQuality.complete();
this._youtubeContainer.complete();
this._destroyed.next();
this._destroyed.complete();
}
その他気になったこと
286行目~あたりでNgZone#run
しているところがあるけど、youtube playerがngZone外で動いているわけではないのでいらなくない?と思ったが、もしかしたら途中なのかもしれない(250msごとにChange Detectionが動いている!)。
private _runInZone<T extends (...args: any[]) => void>(callback: T):
(...args: Parameters<T>) => void {
return (...args: Parameters<T>) => this._ngZone.run(() => callback(...args));
}
2019/12/23追記。上の挙動はcomponentsの9.0.0-rc.6で現在修正済みです(https://github.com/angular/components/commit/27fae29 )。Issueを投げたらすぐ対応してくれました。
まとめ
Angular謹製のコンポーネントから外部のライブラリと連携する方法を学びました。特にrxjsまわりのreal worldなコードは特に参考になりました。
ngZoneの件は言った https://github.com/angular/components/issues/17882 (対応済)