20
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Angular #2Advent Calendar 2019

Day 5

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

Last updated at Posted at 2019-12-05

@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();
  }

createPlayerObservablebindSizeToPlayerbindSuggestedQualityToPlayerbindCueVideoCallについて詳しく見ます。

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 (対応済)

20
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?