public final class

ExoPlayerTestRunner

extends java.lang.Object

implements Player.Listener, ActionSchedule.Callback

 java.lang.Object

↳androidx.media3.test.utils.ExoPlayerTestRunner

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-test-utils', version: '1.5.0-alpha01'

  • groupId: androidx.media3
  • artifactId: media3-test-utils
  • version: 1.5.0-alpha01

Artifact androidx.media3:media3-test-utils:1.5.0-alpha01 it located at Google repository (https://maven.google.com/)

Overview

Helper class to run an ExoPlayer test.

Summary

Fields
public static final FormatAUDIO_FORMAT

A generic audio Format which can be used to set up a FakeMediaSource.

public static final FormatVIDEO_FORMAT

A generic video Format which can be used to set up a FakeMediaSource.

Methods
public voidassertNoPositionDiscontinuities()

Asserts that was not called.

public voidassertPlaybackStatesEqual(java.lang.Integer states[])

Asserts that the playback states reported by are equal to the provided playback states.

public voidassertPlayedPeriodIndices(java.lang.Integer periodIndices[])

Asserts that the indices of played periods is equal to the provided list of periods.

public voidassertPositionDiscontinuityReasonsEqual(java.lang.Integer discontinuityReasons[])

Asserts that the discontinuity reasons reported by are equal to the provided values.

public voidassertTimelineChangeReasonsEqual(java.lang.Integer reasons[])

Asserts that the timeline change reasons reported by are equal to the provided timeline change reasons.

public voidassertTimelinesSame(Timeline timelines[])

Asserts that the timelines reported by are the same to the provided timelines.

public ExoPlayerTestRunnerblockUntilActionScheduleFinished(long timeoutMs)

Blocks the current thread until the action schedule finished.

public ExoPlayerTestRunnerblockUntilEnded(long timeoutMs)

Blocks the current thread until the test runner finishes.

public voidonActionScheduleFinished()

public voidonMediaItemTransition(MediaItem mediaItem, int reason)

public voidonPlaybackStateChanged(int playbackState)

public voidonPlayerError(PlaybackException error)

public voidonPositionDiscontinuity(Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason)

public voidonTimelineChanged(Timeline timeline, int reason)

public ExoPlayerTestRunnerstart()

Starts the test runner on its own thread.

public ExoPlayerTestRunnerstart(boolean doPrepare)

Starts the test runner on its own thread.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Fields

public static final Format VIDEO_FORMAT

A generic video Format which can be used to set up a FakeMediaSource.

public static final Format AUDIO_FORMAT

A generic audio Format which can be used to set up a FakeMediaSource.

Methods

public ExoPlayerTestRunner start()

Starts the test runner on its own thread. This will trigger the creation of the player, the listener registration, the start of the action schedule, the initial set of media items and the preparation of the player.

Returns:

This test runner.

public ExoPlayerTestRunner start(boolean doPrepare)

Starts the test runner on its own thread. This will trigger the creation of the player, the listener registration, the start of the action schedule and the initial set of media items.

Parameters:

doPrepare: Whether the player should be prepared.

Returns:

This test runner.

public ExoPlayerTestRunner blockUntilEnded(long timeoutMs)

Blocks the current thread until the test runner finishes. A test is deemed to be finished when the playback state transitioned to Player.STATE_ENDED or Player.STATE_IDLE for the specified number of times. The test also finishes when an ExoPlaybackException is thrown.

Parameters:

timeoutMs: The maximum time to wait for the test runner to finish. If this time elapsed the method will throw a java.util.concurrent.TimeoutException.

Returns:

This test runner.

public ExoPlayerTestRunner blockUntilActionScheduleFinished(long timeoutMs)

Blocks the current thread until the action schedule finished. This does not release the test runner and the test must still call ExoPlayerTestRunner.blockUntilEnded(long).

Parameters:

timeoutMs: The maximum time to wait for the action schedule to finish.

Returns:

This test runner.

public void assertTimelinesSame(Timeline timelines[])

Asserts that the timelines reported by are the same to the provided timelines. This assert differs from testing equality by not comparing period ids which may be different due to id mapping of child source period ids.

Parameters:

timelines: A list of expected Timelines.

public void assertTimelineChangeReasonsEqual(java.lang.Integer reasons[])

Asserts that the timeline change reasons reported by are equal to the provided timeline change reasons.

public void assertPlaybackStatesEqual(java.lang.Integer states[])

Asserts that the playback states reported by are equal to the provided playback states.

public void assertNoPositionDiscontinuities()

Asserts that was not called.

public void assertPositionDiscontinuityReasonsEqual(java.lang.Integer discontinuityReasons[])

Asserts that the discontinuity reasons reported by are equal to the provided values.

Parameters:

discontinuityReasons: The expected discontinuity reasons.

public void assertPlayedPeriodIndices(java.lang.Integer periodIndices[])

Asserts that the indices of played periods is equal to the provided list of periods. A period is considered to be played if it was the current period after a position discontinuity or a media source preparation. When the same period is repeated automatically due to enabled repeat modes, it is reported twice. Seeks within the current period are not reported.

Parameters:

periodIndices: A list of expected period indices.

public void onTimelineChanged(Timeline timeline, int reason)

public void onMediaItemTransition(MediaItem mediaItem, int reason)

public void onPlaybackStateChanged(int playbackState)

public void onPlayerError(PlaybackException error)

public void onPositionDiscontinuity(Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason)

public void onActionScheduleFinished()

Source

/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.media3.test.utils;

import static androidx.media3.common.util.Assertions.checkNotNull;
import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static junit.framework.TestCase.assertFalse;

import android.content.Context;
import android.os.HandlerThread;
import android.os.Looper;
import android.view.Surface;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Clock;
import androidx.media3.common.util.HandlerWrapper;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.LoadControl;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.analytics.AnalyticsListener;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.upstream.BandwidthMeter;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeoutException;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;

/** Helper class to run an ExoPlayer test. */
@UnstableApi
public final class ExoPlayerTestRunner implements Player.Listener, ActionSchedule.Callback {

  /** A generic video {@link Format} which can be used to set up a {@link FakeMediaSource}. */
  public static final Format VIDEO_FORMAT =
      new Format.Builder()
          .setSampleMimeType(MimeTypes.VIDEO_H264)
          .setAverageBitrate(800_000)
          .setWidth(1280)
          .setHeight(720)
          .build();

  /** A generic audio {@link Format} which can be used to set up a {@link FakeMediaSource}. */
  public static final Format AUDIO_FORMAT =
      new Format.Builder()
          .setSampleMimeType(MimeTypes.AUDIO_AAC)
          .setAverageBitrate(100_000)
          .setChannelCount(2)
          .setSampleRate(44100)
          .build();

  /**
   * Builder to set-up an {@link ExoPlayerTestRunner}. Default fake implementations will be used for
   * unset test properties.
   */
  public static final class Builder {
    private final TestExoPlayerBuilder testPlayerBuilder;
    private @MonotonicNonNull Timeline timeline;
    private List<MediaSource> mediaSources;
    private Format[] supportedFormats;
    private @MonotonicNonNull Object manifest;
    private @MonotonicNonNull ActionSchedule actionSchedule;
    private @MonotonicNonNull Surface surface;
    private Player.@MonotonicNonNull Listener playerListener;
    private @MonotonicNonNull AnalyticsListener analyticsListener;
    private @MonotonicNonNull Integer expectedPlayerEndedCount;
    private boolean pauseAtEndOfMediaItems;
    private int initialMediaItemIndex;
    private long initialPositionMs;
    private boolean skipSettingMediaSources;

    public Builder(Context context) {
      testPlayerBuilder = new TestExoPlayerBuilder(context);
      mediaSources = new ArrayList<>();
      supportedFormats = new Format[] {VIDEO_FORMAT};
      initialMediaItemIndex = C.INDEX_UNSET;
      initialPositionMs = C.TIME_UNSET;
    }

    /**
     * Sets a {@link Timeline} to be used by a {@link FakeMediaSource} in the test runner. The
     * default value is a seekable, non-dynamic {@link FakeTimeline} with a duration of {@link
     * FakeTimeline.TimelineWindowDefinition#DEFAULT_WINDOW_DURATION_US}. Setting the timeline is
     * not allowed after a call to {@link #setMediaSources(MediaSource...)} or {@link
     * #skipSettingMediaSources()}.
     *
     * @param timeline A {@link Timeline} to be used by a {@link FakeMediaSource} in the test
     *     runner.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setTimeline(Timeline timeline) {
      assertThat(mediaSources).isEmpty();
      assertFalse(skipSettingMediaSources);
      this.timeline = timeline;
      return this;
    }

    /**
     * Sets a manifest to be used by a {@link FakeMediaSource} in the test runner. The default value
     * is null. Setting the manifest is not allowed after a call to {@link
     * #setMediaSources(MediaSource...)} or {@link #skipSettingMediaSources()}.
     *
     * @param manifest A manifest to be used by a {@link FakeMediaSource} in the test runner.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setManifest(Object manifest) {
      assertThat(mediaSources).isEmpty();
      assertFalse(skipSettingMediaSources);
      this.manifest = manifest;
      return this;
    }

    /**
     * Seeks before setting the media sources and preparing the player.
     *
     * @param mediaItemIndex The media item index to seek to.
     * @param positionMs The position in milliseconds to seek to.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder initialSeek(int mediaItemIndex, long positionMs) {
      this.initialMediaItemIndex = mediaItemIndex;
      this.initialPositionMs = positionMs;
      return this;
    }

    /**
     * Sets the {@link MediaSource}s to be used by the test runner. The default value is a {@link
     * FakeMediaSource} with the timeline and manifest provided by {@link #setTimeline(Timeline)}
     * and {@link #setManifest(Object)}. Setting media sources is not allowed after calls to {@link
     * #skipSettingMediaSources()}, {@link #setTimeline(Timeline)} and/or {@link
     * #setManifest(Object)}.
     *
     * @param mediaSources The {@link MediaSource}s to be used by the test runner.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setMediaSources(MediaSource... mediaSources) {
      assertThat(timeline).isNull();
      assertThat(manifest).isNull();
      assertFalse(skipSettingMediaSources);
      this.mediaSources = Arrays.asList(mediaSources);
      return this;
    }

    /**
     * Sets a list of {@link Format}s to be used by a {@link FakeMediaSource} to create media
     * periods. The default value is a single {@link #VIDEO_FORMAT}. Note that this parameter
     * doesn't have any influence if a media source with {@link #setMediaSources(MediaSource...)} is
     * set.
     *
     * @param supportedFormats A list of supported {@link Format}s.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setSupportedFormats(Format... supportedFormats) {
      this.supportedFormats = supportedFormats;
      return this;
    }

    /**
     * Skips calling {@link ExoPlayer#setMediaSources(List)} before preparing. Calling this method
     * is not allowed after calls to {@link #setMediaSources(MediaSource...)}, {@link
     * #setTimeline(Timeline)} and/or {@link #setManifest(Object)}.
     *
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder skipSettingMediaSources() {
      assertThat(timeline).isNull();
      assertThat(manifest).isNull();
      assertThat(mediaSources).isEmpty();
      skipSettingMediaSources = true;
      return this;
    }

    /**
     * @see TestExoPlayerBuilder#setUseLazyPreparation(boolean)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setUseLazyPreparation(boolean useLazyPreparation) {
      testPlayerBuilder.setUseLazyPreparation(useLazyPreparation);
      return this;
    }

    /**
     * Sets whether to enable pausing at the end of media items.
     *
     * @param pauseAtEndOfMediaItems Whether to pause at the end of media items.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setPauseAtEndOfMediaItems(boolean pauseAtEndOfMediaItems) {
      this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems;
      return this;
    }

    /**
     * @see TestExoPlayerBuilder#setTrackSelector(DefaultTrackSelector)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setTrackSelector(DefaultTrackSelector trackSelector) {
      testPlayerBuilder.setTrackSelector(trackSelector);
      return this;
    }

    /**
     * @see TestExoPlayerBuilder#setLoadControl(LoadControl)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setLoadControl(LoadControl loadControl) {
      testPlayerBuilder.setLoadControl(loadControl);
      return this;
    }

    /**
     * @see TestExoPlayerBuilder#setBandwidthMeter(BandwidthMeter)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setBandwidthMeter(BandwidthMeter bandwidthMeter) {
      this.testPlayerBuilder.setBandwidthMeter(bandwidthMeter);
      return this;
    }

    /**
     * @see TestExoPlayerBuilder#setRenderers(Renderer...)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setRenderers(Renderer... renderers) {
      testPlayerBuilder.setRenderers(renderers);
      return this;
    }

    /**
     * @see TestExoPlayerBuilder#setRenderersFactory(RenderersFactory)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setRenderersFactory(RenderersFactory renderersFactory) {
      testPlayerBuilder.setRenderersFactory(renderersFactory);
      return this;
    }

    /**
     * @see TestExoPlayerBuilder#setClock(Clock)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setClock(Clock clock) {
      testPlayerBuilder.setClock(clock);
      return this;
    }

    /**
     * @see ExoPlayer#setPreloadConfiguration(ExoPlayer.PreloadConfiguration)
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setPreloadConfiguration(ExoPlayer.PreloadConfiguration preloadConfiguration) {
      testPlayerBuilder.setPreloadConfiguration(preloadConfiguration);
      return this;
    }

    /**
     * Sets an {@link ActionSchedule} to be run by the test runner. The first action will be
     * executed immediately before {@link ExoPlayer#prepare()}.
     *
     * @param actionSchedule An {@link ActionSchedule} to be used by the test runner.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setActionSchedule(ActionSchedule actionSchedule) {
      this.actionSchedule = actionSchedule;
      return this;
    }

    /**
     * Sets the video {@link Surface}. The default value is {@code null}.
     *
     * @param surface The {@link Surface} to be used by the player.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setVideoSurface(Surface surface) {
      this.surface = surface;
      return this;
    }

    /**
     * Sets an {@link Player.Listener} to be registered to listen to player events.
     *
     * @param playerListener A {@link Player.Listener} to be registered by the test runner to listen
     *     to player events.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setPlayerListener(Player.Listener playerListener) {
      this.playerListener = playerListener;
      return this;
    }

    /**
     * Sets an {@link AnalyticsListener} to be registered.
     *
     * @param analyticsListener An {@link AnalyticsListener} to be registered.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setAnalyticsListener(AnalyticsListener analyticsListener) {
      this.analyticsListener = analyticsListener;
      return this;
    }

    /**
     * Sets the number of times the test runner is expected to reach the {@link Player#STATE_ENDED}
     * or {@link Player#STATE_IDLE}. The default is 1. This affects how long {@link
     * ExoPlayerTestRunner#blockUntilEnded(long)} waits.
     *
     * @param expectedPlayerEndedCount The number of times the player is expected to reach the ended
     *     or idle state.
     * @return This builder.
     */
    @CanIgnoreReturnValue
    public Builder setExpectedPlayerEndedCount(int expectedPlayerEndedCount) {
      this.expectedPlayerEndedCount = expectedPlayerEndedCount;
      return this;
    }

    /**
     * Builds an {@link ExoPlayerTestRunner} using the provided values or their defaults.
     *
     * @return The built {@link ExoPlayerTestRunner}.
     */
    public ExoPlayerTestRunner build() {
      if (mediaSources.isEmpty() && !skipSettingMediaSources) {
        if (timeline == null) {
          timeline =
              new FakeTimeline(
                  /* windowCount= */ 1,
                  manifest != null ? new Object[] {manifest} : new Object[] {});
        }
        mediaSources.add(new FakeMediaSource(timeline, supportedFormats));
      }
      if (expectedPlayerEndedCount == null) {
        expectedPlayerEndedCount = 1;
      }
      return new ExoPlayerTestRunner(
          testPlayerBuilder,
          mediaSources,
          skipSettingMediaSources,
          initialMediaItemIndex,
          initialPositionMs,
          surface,
          actionSchedule,
          playerListener,
          analyticsListener,
          expectedPlayerEndedCount,
          pauseAtEndOfMediaItems);
    }
  }

  private final TestExoPlayerBuilder playerBuilder;
  private final List<MediaSource> mediaSources;
  private final boolean skipSettingMediaSources;
  private final int initialMediaItemIndex;
  private final long initialPositionMs;
  @Nullable private final Surface surface;
  @Nullable private final ActionSchedule actionSchedule;
  @Nullable private final Player.Listener playerListener;
  @Nullable private final AnalyticsListener analyticsListener;

  private final Clock clock;
  private final HandlerThread playerThread;
  private final HandlerWrapper handler;
  private final CountDownLatch endedCountDownLatch;
  private final CountDownLatch actionScheduleFinishedCountDownLatch;
  private final ArrayList<Timeline> timelines;
  private final ArrayList<Integer> timelineChangeReasons;
  private final ArrayList<MediaItem> mediaItems;
  private final ArrayList<Integer> mediaItemTransitionReasons;
  private final ArrayList<Integer> periodIndices;
  private final ArrayList<Integer> discontinuityReasons;
  private final ArrayList<Integer> playbackStates;
  private final boolean pauseAtEndOfMediaItems;

  private @MonotonicNonNull ExoPlayer player;
  private @MonotonicNonNull Exception exception;
  private boolean playerWasPrepared;

  private ExoPlayerTestRunner(
      TestExoPlayerBuilder playerBuilder,
      List<MediaSource> mediaSources,
      boolean skipSettingMediaSources,
      int initialMediaItemIndex,
      long initialPositionMs,
      @Nullable Surface surface,
      @Nullable ActionSchedule actionSchedule,
      @Nullable Player.Listener playerListener,
      @Nullable AnalyticsListener analyticsListener,
      int expectedPlayerEndedCount,
      boolean pauseAtEndOfMediaItems) {
    this.playerBuilder = playerBuilder;
    this.mediaSources = mediaSources;
    this.skipSettingMediaSources = skipSettingMediaSources;
    this.initialMediaItemIndex = initialMediaItemIndex;
    this.initialPositionMs = initialPositionMs;
    this.surface = surface;
    this.actionSchedule = actionSchedule;
    this.playerListener = playerListener;
    this.analyticsListener = analyticsListener;
    this.clock = playerBuilder.getClock();
    timelines = new ArrayList<>();
    timelineChangeReasons = new ArrayList<>();
    mediaItems = new ArrayList<>();
    mediaItemTransitionReasons = new ArrayList<>();
    periodIndices = new ArrayList<>();
    discontinuityReasons = new ArrayList<>();
    playbackStates = new ArrayList<>();
    endedCountDownLatch = new CountDownLatch(expectedPlayerEndedCount);
    actionScheduleFinishedCountDownLatch = new CountDownLatch(actionSchedule != null ? 1 : 0);
    playerThread = new HandlerThread("ExoPlayerTest thread");
    playerThread.start();
    handler = clock.createHandler(playerThread.getLooper(), /* callback= */ null);
    this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems;
  }

  // Called on the test thread to run the test.

  /**
   * Starts the test runner on its own thread. This will trigger the creation of the player, the
   * listener registration, the start of the action schedule, the initial set of media items and the
   * preparation of the player.
   *
   * @return This test runner.
   */
  @CanIgnoreReturnValue
  public ExoPlayerTestRunner start() {
    return start(/* doPrepare= */ true);
  }

  /**
   * Starts the test runner on its own thread. This will trigger the creation of the player, the
   * listener registration, the start of the action schedule and the initial set of media items.
   *
   * @param doPrepare Whether the player should be prepared.
   * @return This test runner.
   */
  @CanIgnoreReturnValue
  public ExoPlayerTestRunner start(boolean doPrepare) {
    handler.post(
        () -> {
          try {
            player = playerBuilder.setLooper(checkNotNull(Looper.myLooper())).build();
            if (surface != null) {
              player.setVideoSurface(surface);
            }
            if (pauseAtEndOfMediaItems) {
              player.setPauseAtEndOfMediaItems(true);
            }
            player.addListener(ExoPlayerTestRunner.this);
            if (playerListener != null) {
              player.addListener(playerListener);
            }
            if (analyticsListener != null) {
              player.addAnalyticsListener(analyticsListener);
            }
            player.play();
            if (actionSchedule != null) {
              actionSchedule.start(
                  player,
                  playerBuilder.getTrackSelector(),
                  surface,
                  handler,
                  /* callback= */ ExoPlayerTestRunner.this);
            }
            if (initialMediaItemIndex != C.INDEX_UNSET) {
              player.seekTo(initialMediaItemIndex, initialPositionMs);
            }
            if (!skipSettingMediaSources) {
              player.setMediaSources(mediaSources, /* resetPosition= */ false);
            }
            if (doPrepare) {
              player.prepare();
            }
          } catch (Exception e) {
            handleException(e);
          }
        });
    return this;
  }

  /**
   * Blocks the current thread until the test runner finishes. A test is deemed to be finished when
   * the playback state transitioned to {@link Player#STATE_ENDED} or {@link Player#STATE_IDLE} for
   * the specified number of times. The test also finishes when an {@link ExoPlaybackException} is
   * thrown.
   *
   * @param timeoutMs The maximum time to wait for the test runner to finish. If this time elapsed
   *     the method will throw a {@link TimeoutException}.
   * @return This test runner.
   * @throws Exception If any exception occurred during playback, release, or due to a timeout.
   */
  @CanIgnoreReturnValue
  public ExoPlayerTestRunner blockUntilEnded(long timeoutMs) throws Exception {
    clock.onThreadBlocked();
    if (!endedCountDownLatch.await(timeoutMs, MILLISECONDS)) {
      exception = new TimeoutException("Test playback timed out waiting for playback to end.");
    }
    release();
    // Throw any pending exception (from playback, timing out or releasing).
    if (exception != null) {
      throw exception;
    }
    return this;
  }

  /**
   * Blocks the current thread until the action schedule finished. This does not release the test
   * runner and the test must still call {@link #blockUntilEnded(long)}.
   *
   * @param timeoutMs The maximum time to wait for the action schedule to finish.
   * @return This test runner.
   * @throws TimeoutException If the action schedule did not finish within the specified timeout.
   * @throws InterruptedException If the test thread gets interrupted while waiting.
   */
  @CanIgnoreReturnValue
  public ExoPlayerTestRunner blockUntilActionScheduleFinished(long timeoutMs)
      throws TimeoutException, InterruptedException {
    clock.onThreadBlocked();
    if (!actionScheduleFinishedCountDownLatch.await(timeoutMs, MILLISECONDS)) {
      throw new TimeoutException("Test playback timed out waiting for action schedule to finish.");
    }
    return this;
  }

  // Assertions called on the test thread after test finished.

  /**
   * Asserts that the timelines reported by {@link Player.Listener#onTimelineChanged(Timeline, int)}
   * are the same to the provided timelines. This assert differs from testing equality by not
   * comparing period ids which may be different due to id mapping of child source period ids.
   *
   * @param timelines A list of expected {@link Timeline}s.
   */
  public void assertTimelinesSame(Timeline... timelines) {
    TestUtil.assertTimelinesSame(
        ImmutableList.copyOf(this.timelines), ImmutableList.copyOf(timelines));
  }

  /**
   * Asserts that the timeline change reasons reported by {@link
   * Player.Listener#onTimelineChanged(Timeline, int)} are equal to the provided timeline change
   * reasons.
   */
  public void assertTimelineChangeReasonsEqual(Integer... reasons) {
    assertThat(timelineChangeReasons).containsExactlyElementsIn(Arrays.asList(reasons)).inOrder();
  }

  /**
   * Asserts that the playback states reported by {@link
   * Player.Listener#onPlaybackStateChanged(int)} are equal to the provided playback states.
   */
  public void assertPlaybackStatesEqual(Integer... states) {
    assertThat(playbackStates).containsExactlyElementsIn(states).inOrder();
  }

  /**
   * Asserts that {@link Player.Listener#onPositionDiscontinuity(Player.PositionInfo,
   * Player.PositionInfo, int)} was not called.
   */
  public void assertNoPositionDiscontinuities() {
    assertThat(discontinuityReasons).isEmpty();
  }

  /**
   * Asserts that the discontinuity reasons reported by {@link
   * Player.Listener#onPositionDiscontinuity(Player.PositionInfo, Player.PositionInfo, int)} are
   * equal to the provided values.
   *
   * @param discontinuityReasons The expected discontinuity reasons.
   */
  public void assertPositionDiscontinuityReasonsEqual(Integer... discontinuityReasons) {
    assertThat(this.discontinuityReasons)
        .containsExactlyElementsIn(Arrays.asList(discontinuityReasons))
        .inOrder();
  }

  /**
   * Asserts that the indices of played periods is equal to the provided list of periods. A period
   * is considered to be played if it was the current period after a position discontinuity or a
   * media source preparation. When the same period is repeated automatically due to enabled repeat
   * modes, it is reported twice. Seeks within the current period are not reported.
   *
   * @param periodIndices A list of expected period indices.
   */
  public void assertPlayedPeriodIndices(Integer... periodIndices) {
    assertThat(this.periodIndices)
        .containsExactlyElementsIn(Arrays.asList(periodIndices))
        .inOrder();
  }

  // Private implementation details.

  private void release() throws InterruptedException {
    handler.post(
        () -> {
          try {
            if (player != null) {
              player.release();
            }
          } catch (Exception e) {
            handleException(e);
          } finally {
            playerThread.quit();
          }
        });
    clock.onThreadBlocked();
    playerThread.join();
  }

  private void handleException(Exception exception) {
    if (this.exception == null) {
      this.exception = exception;
    }
    while (endedCountDownLatch.getCount() > 0) {
      endedCountDownLatch.countDown();
    }
  }

  // Player.Listener

  @Override
  public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
    timelineChangeReasons.add(reason);
    timelines.add(timeline);
    int currentIndex = checkNotNull(player).getCurrentPeriodIndex();
    if (periodIndices.isEmpty() || periodIndices.get(periodIndices.size() - 1) != currentIndex) {
      // Ignore timeline changes that do not change the period index.
      periodIndices.add(currentIndex);
    }
  }

  @Override
  public void onMediaItemTransition(
      @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) {
    if (mediaItem != null) {
      mediaItems.add(mediaItem);
    }
    mediaItemTransitionReasons.add(reason);
  }

  @Override
  public void onPlaybackStateChanged(@Player.State int playbackState) {
    playbackStates.add(playbackState);
    playerWasPrepared |= playbackState != Player.STATE_IDLE;
    if (playbackState == Player.STATE_ENDED
        || (playbackState == Player.STATE_IDLE && playerWasPrepared)) {
      endedCountDownLatch.countDown();
    }
  }

  @Override
  public void onPlayerError(PlaybackException error) {
    handleException(error);
  }

  @Override
  public void onPositionDiscontinuity(
      Player.PositionInfo oldPosition,
      Player.PositionInfo newPosition,
      @Player.DiscontinuityReason int reason) {
    discontinuityReasons.add(reason);
    int currentIndex = checkNotNull(player).getCurrentPeriodIndex();
    if ((reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
            && oldPosition.adGroupIndex != C.INDEX_UNSET
            && newPosition.adGroupIndex != C.INDEX_UNSET)
        || periodIndices.isEmpty()
        || periodIndices.get(periodIndices.size() - 1) != currentIndex) {
      // Ignore seek or internal discontinuities within a period.
      periodIndices.add(currentIndex);
    }
  }

  // ActionSchedule.Callback

  @Override
  public void onActionScheduleFinished() {
    actionScheduleFinishedCountDownLatch.countDown();
  }
}