注意! この記事はQiitaにて公開されていた内容をimportしたものです。
これらの内容は場合によっては陳腐化していて役に立たなくなっていたり、有害であったり、現在の著者の主張と異なることがあります。
皆様の判断の上でご利用いただけますと幸いです(度を超してヤバいものは著者に連絡して頂ければ対応します m(_ _)m)


はじめに

本ドキュメントを、運命に抗えず動画再生案件に投げ込まれた全ての人に捧ぐ

とは言いつつも備忘録なので誰かに役立つかどうかはわかりません。あと急に加筆などするかもしれませんので悪しからず。

この記事を書いた後にExoPlayerを扱う案件から遠ざかっていたのですが、数年ぶりにExoPlayerを扱うサービスにジョインした為、大規模改訂を行いました。

本記事で扱うバージョン

  • ExoPlayer
    • 2.9.6
  • Kotlin
    • 1.3.30

com.google.android.exoplayer2.Player.EventListener

Playerインターフェースとして定義されている addListener(EventListener) から EventListener をセットすることで、コールバックを受け取れるようになります。 PlayerインターフェースはSimpleExoPlayerクラスにて実装されているので、普通にExoPlayerFactory.newSimpleInstance()したインスタンスであればリスナーを利用することができます。

それぞれのコールバックメソッドを実際に使ってみたJavaDocと感想文は以下の通りです。

void onLoadingChanged(boolean isLoading)

/**
 * Called when the player starts or stops loading the source.
 *
 * @param isLoading Whether the source is currently being loaded.
 */

ローカルファイルやHLSの取得状態が変化したときコールされる。 HLSなどはデータを事前読み込みしながら再生することになるので、1つのMediaSourceで何度も状態が変化することになる。 HLSとかの通信の発生するデータ読み込みでProgressを出したりするとき便利かも知れない。

  • isLoading
    • ソースから取得している状態かどうか

void onPlayerStateChanged(boolean playWhenReady, int playbackState)

/**
 * Called when the value returned from either {@link #getPlayWhenReady()} or {@link
 * #getPlaybackState()} changes.
 *
 * @param playWhenReady Whether playback will proceed when ready.
 * @param playbackState One of the {@code STATE} constants.
 */

playWhenReadyplaybackState が変化するとコールされる(とJavaDocに書かれている)。

  • playWhenReady
    • trueのとき準備が完了次第MediaSourceが再生される。falseなら再生されない。
    • ExoPlayer#setPlayWhenReady(boolean) で制御できます
  • playbackState
    • ExoPlayer.STATE_BUFFERING
      • バッファリング中
    • ExoPlayer.STATE_ENDED
      • メディア(MediaSource)を再生しきった状態
      • この状態からREADYに戻す場合、Player#seekTo(C.TIME_UNSET) をするとか、新しいMediaSourceをセットするとよい
    • ExoPlayer.STATE_IDLE
      • MediaSourceを持っていない状態のため再生も一時停止もできない状態
    • ExoPlayer.STATE_READY
      • 再生可能な状態

void onTimelineChanged(Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason)

/**
 * Called when the timeline and/or manifest has been refreshed.
 *
 * <p>Note that if the timeline has changed then a position discontinuity may also have
 * occurred. For example, the current period index may have changed as a result of periods being
 * added or removed from the timeline. This will <em>not</em> be reported via a separate call to
 * {@link #onPositionDiscontinuity(int)}.
 *
 * @param timeline The latest timeline. Never null, but may be empty.
 * @param manifest The latest manifest. May be null.
 * @param reason The {@link TimelineChangeReason} responsible for this timeline change.
 */

Timeline か Manifest が更新されたときにコールされる。 Timelineが変更されて再生位置の不連続が起きたときも実行されることに注意。TimelineからPeriodが追加・削除された結果として変更された可能性があるとき等。

とまぁなんか分かったような分からなかったようなことが書いてあるんですが、(シンプルにローカルファイルやオンデマンドファイルを再生しているだけならば)ほぼこのコールバックは使わないのではないか?と思います。

ところでManifestって何?という疑問はあるのですが、(僕が調査した当時は)ExoPlayerライブラリ内でも該当するものはDashManifestだけのようでした。 私は残念ながらDASHを触っていないのでこれ以上の追求は出来ませんでした・・・。

余談: Timelineに含まれるWindowとPeriodについて

Timelineとはメディアの「時刻表」(文字通りのタイムテーブル)であるが、大雑把にMediaSourceのはじまりからおわりだと思うことにする。 MediaSourceには原則としてTimelineと(場合によっては)Manifestが一対ずつある。 ところがConcatenatingMediaSourceのように複数のTimelineを抱えるものもある(これをConcatenatedTimelineとしてひとつのTimelineにラップし直している)。

Timelineには更にPeriod及びWindowの概念が含まれる。 Periodは単一のメディアファイルなどの論理的なメディアを指す。また広告が含まれるか、どこに含まれるかも定義されている。 Windowはシークをサポートしているかや、どこまで再生できるかを表現している。

静的なメディア(端末内のMP3とか、HLS配信される(ライブ配信ではない)オンデマンド配信コンテンツ)の場合、ひとつのTimelineにはひとつずつのWindow及びPeriodが入っていることになる。 ライブ配信のような終わりが不明なメディアについては、C.TIME_UNSET な長さのPeriodとなり、WindowについてはisDynamic=trueな状態で可変しながら再生が続くことになるでしょう(変化したら都度 onTimelineChanged がコールされるかも知れませんが、都合の良いライブソースがなかったので検証できてません)

void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections)

/**
  * Called when the available or selected tracks change.
  *
  * @param trackGroups The available tracks. Never null, but may be of length zero.
  * @param trackSelections The track selections for each renderer. Never null and always of
  *     length {@link #getRendererCount()}, but may contain null elements.
  */

トラックが変更されたときコールされる。 ひとつのMediaSourceのメディアに含まれる音声・映像・字幕・メタデータのそれぞれを指している概念が Formatである。 Formatはそれぞれのタイプに対して複数存在することもある。例えばHLSだったらAdaptive Streamingの為に動画の形式がビットレート別に複数提供されていることもある。 最適なFormatを選択する仕組みがTrackSelectorで、アルゴリズムを調整したければ ExoPlayerFactory.newSimpleInstance の引数でカスタムしたTrackSelectorを与えると良い。

  • trackGroups
    • 現在のメディア(Period)に属するFormatのArrayを格納する TrackGroupの配列(を格納しているだけのTrackGroupArray)を返す
  • trackSelections

余談: トラックの戻る・進むを検知する

Player#seekTo(nextWindowIndex, C.TIME_UNSET)など、つまりトラックの戻る・進むなどをしたときにコールされることを確認。 更新前の本記事で onTimelineChanged を用いたConcatenatingMediaSourceのPrev/Nextの検知を紹介したが、以下のように実装することで実現できるようになった。

override fun onTracksChanged(trackGroups: TrackGroupArray, trackSelections: TrackSelectionArray) {
    val empty = if (trackGroups == TrackGroupArray.EMPTY) "(empty)" else ""
    val currentPeriodIndex = player.currentPeriodIndex
    val currentWindowIndex = player.currentWindowIndex

    Timber.d("onTracksChanged: groups: %h%s, selections: %h, period: %d, window: %d",
            trackGroups, empty, trackSelections, currentPeriodIndex, currentWindowIndex)

    if (currentPeriodIndex > previousPeriodIndex) {
        onTracksChangedFastForward()
    } else if (currentPeriodIndex < previousPeriodIndex) {
        onTracksChangedRewind()
    }
    previousPeriodIndex = currentPeriodIndex
}

void onRepeatModeChanged(@RepeatMode int repeatMode)

    /**
     * Called when the value of {@link #getRepeatMode()} changes.
     *
     * @param repeatMode The {@link RepeatMode} used for playback.
     */

リピートモードが変更されたときコールされる。 リピート機能はそれ以外のMediaSourceでも使えるかも知れませんが ConcatenatingMediaSource を使っているとき特に便利に機能する気がします。

see: Repeat modes in ExoPlayer – google-exoplayer – Medium

  • repeatMode
    • REPEAT_MODE_OFF
      • 繰り返しなしの通常の再生を行う
    • REPEAT_MODE_ONE
      • 今再生しているWindowを無制限に繰り返し再生する
    • REPEAT_MODE_ALL
      • 今再生しているTimelineを無制限に繰り返し再生する

void onShuffleModeEnabledChanged(boolean shuffleModeEnabled)

    /**
     * Called when the value of {@link #getShuffleModeEnabled()} changes.
     *
     * @param shuffleModeEnabled Whether shuffling of windows is enabled.
     */

シャッフルモードの有効・無効が切り替わったときコールされる・・・ というか、RepeatModeもそうだけど、MediaSource側からではなく、ExoPlayer側から制御できるようになったのか・・・。知らなかった・・・。 僕みたいにならないように、みんなはExoPlayerの公式Mediumを購読しような!

void onPlayerError(ExoPlayerException error)

    /**
     * Called when an error occurs. The playback state will transition to {@link #STATE_IDLE}
     * immediately after this method is called. The player instance can still be used, and {@link
     * #release()} must still be called on the player should it no longer be required.
     *
     * @param error The error.
     */

エラーが起きたときコールされる。 JavaDocに書いてあるとおり、これがコールされたときIDLE状態になるのが注意点。 どういうときにエラーがコールされるかはExoPlayerExceptionを見ると手がかりになりそう

void onPositionDiscontinuity(@DiscontinuityReason int reason)

    /**
     * Called when a position discontinuity occurs without a change to the timeline. A position
     * discontinuity occurs when the current window or period index changes (as a result of playback
     * transitioning from one period in the timeline to the next), or when the playback position
     * jumps within the period currently being played (as a result of a seek being performed, or
     * when the source introduces a discontinuity internally).
     *
     * <p>When a position discontinuity occurs as a result of a change to the timeline this method
     * is <em>not</em> called. {@link #onTimelineChanged(Timeline, Object, int)} is called in this
     * case.
     *
     * @param reason The {@link DiscontinuityReason} responsible for the discontinuity.
     */

Timelineの変更のない状態で、再生位置の不連続が起きたときコールされる。 JavaDocに書いてあるようなケースの時は onTimelineChanged がコールされ、こちらのコールバックはコールされない。

雑に言うと再生位置が飛ぶとコールされる。 (例えば、シークバーが動かされる、10秒早く送るボタンを押すなど)

  • reason
    • DISCONTINUITY_REASON_PERIOD_TRANSITION
      • 再生中にTimeline内のPeriodをまたいだ時
      • ひとつの音源をループさせる LoopingMediaSource を使っているとき、現在のPeriodのindex(Player#getCurrentPeriodIndex())は同じままこのreasonがコールされる
    • DISCONTINUITY_REASON_SEEK
      • シークさせたとき(Period内でも、別のPeriodになってもシークされたならこの理由でコールされる)
    • DISCONTINUITY_REASON_SEEK_ADJUSTMENT
      • リクエストされた位置にシークできないとき、または不正確であることが許可されているとき(?)
      • Seek adjustment due to being unable to seek to the requested position or because the seek was permitted to be inexact.
      • 一部の動画フォーマットでIフレームにしかシークできないようなものだったとき、要求された位置にはシークできないので、これがコールされるのかな・・・?とか思いました
    • DISCONTINUITY_REASON_AD_INSERTION
      • 広告のポジションに入ったとき、この理由でコールされる
    • DISCONTINUITY_REASON_INTERNAL
      • 内部的な理由で呼ばれるやつ、こういうのツラい

void onPlaybackParametersChanged(PlaybackParameters playbackParameters)

    /**
     * Called when the current playback parameters change. The playback parameters may change due to
     * a call to {@link #setPlaybackParameters(PlaybackParameters)}, or the player itself may change
     * them (for example, if audio playback switches to passthrough mode, where speed adjustment is
     * no longer possible).
     *
     * @param playbackParameters The playback parameters.
     */

[PlaybackParameters](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/PlaybackParameters.html) が変更されたときにコールされる。 PlaybackParametersは、 speed, pitch, skipSilenceのフィールドを持っている(いつの間にそんなことが出来るようになっていたんだ・・・)

void onSeekProcessed()

    /**
     * Called when all pending seek requests have been processed by the player. This is guaranteed
     * to happen after any necessary changes to the player state were reported to {@link
     * #onPlayerStateChanged(boolean, int)}.
     */

シークが実行されたときにコールされる。 実行順が面白いケースがある。 HLSコンテンツの時、シークバーを触ってシークさせたときにシークポジションの差分取得が終わっていないことがあるが、 そのときは差分取得が終わってからシークされるため、このコールバックもそれを待つ形になる。 我々の感覚としてシークバーを動かした直後にコールバックされるのかな?と思ってしまうが、そういうことではないので気をつけたい。

またJavaDocにあるように、onPlayerStateChanged の後に呼ばれることが保証されているとのこと。

しかしどういうユースケースがあるのかはちょっと思いつかなかったです。