@angular/youtube-player コードリーディング

## `@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のみです。それはそうかという気もしますが、依存をなるべく少なくするというのは大切ですね。

```json
  "dependencies": {
    "@types/youtube": "^0.0.38"
  },
  "peerDependencies": {
    "@angular/core": "0.0.0-NG",
    "@angular/common": "0.0.0-NG"
  },
```

## 入力(`@Input`)

83行目~

基本はsetterで受け取って`Subject`からデータを流してごにょごにょしています。自分だけで使う場合など、何使ってもいいときは軽量のstate管理ライブラリ使うのはありかなと思いました。

```ts
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しているだけ。

```ts
  /** 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`)

プレイヤーの初期化や入力を受け取るための準備をしています。

```ts
  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`のありなしでプレイヤーを削除したりもする。

```ts
/** 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`はキレイに初期化待ちするときのお手本にするとよさそうです。大体のやつはこの仕組みでいけそう。

```ts
/**
 * 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`

プレイヤーがあったらサイズ変えるだけ。これはかんたん。

```ts
/** 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`

プレイヤーあったら再生品質を変えるだけ。これもかんたん。

```ts
/** 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`で合流させたあとに、実際のデータは別口で流すってのがなるほどーという感じです。これは使ってみたい。

```ts
/**
 * 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`してないかも・・・。

```ts
  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が動いている!)。

```ts
  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/releases/tag/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 (対応済)