public abstract class

Action

extends java.lang.Object

 java.lang.Object

↳androidx.media3.test.utils.Action

Subclasses:

Action.Seek, Action.SetMediaItems, Action.AddMediaItems, Action.SetMediaItemsResetPosition, Action.MoveMediaItem, Action.RemoveMediaItem, Action.RemoveMediaItems, Action.ClearMediaItems, Action.Stop, Action.SetPlayWhenReady, Action.SetRendererDisabled, Action.ClearVideoSurface, Action.SetVideoSurface, Action.SetAudioAttributes, Action.Prepare, Action.SetRepeatMode, Action.SetShuffleOrder, Action.SetShuffleModeEnabled, Action.SendMessages, Action.SetPlaybackParameters, Action.ThrowPlaybackException, Action.PlayUntilPosition, Action.WaitForTimelineChanged, Action.WaitForPositionDiscontinuity, Action.WaitForPlayWhenReady, Action.WaitForPlaybackState, Action.WaitForMessage, Action.WaitForIsLoading, Action.WaitForPendingPlayerCommands, Action.ExecuteRunnable

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

Base class for actions to perform during playback tests.

Summary

Constructors
publicAction(java.lang.String tag, java.lang.String description)

Methods
protected abstract voiddoActionImpl(ExoPlayer player, DefaultTrackSelector trackSelector, Surface surface)

Called by Action to perform the action.

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

Constructors

public Action(java.lang.String tag, java.lang.String description)

Parameters:

tag: A tag to use for logging.
description: A description to be logged when the action is executed, or null if no logging is required.

Methods

protected abstract void doActionImpl(ExoPlayer player, DefaultTrackSelector trackSelector, Surface surface)

Called by Action to perform the action.

Parameters:

player: The player to which the action should be applied.
trackSelector: The track selector to which the action should be applied.
surface: The surface to use when applying actions, or null if no surface is needed.

Source

/*
 * Copyright (C) 2016 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.test.utils.TestUtil.timelinesAreSame;

import android.os.Looper;
import android.view.Surface;
import androidx.annotation.Nullable;
import androidx.annotation.Size;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.IllegalSeekPositionException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.HandlerWrapper;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.PlayerMessage;
import androidx.media3.exoplayer.PlayerMessage.Target;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.ShuffleOrder;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.Parameters;
import androidx.media3.test.utils.ActionSchedule.ActionNode;
import androidx.media3.test.utils.ActionSchedule.PlayerRunnable;
import androidx.media3.test.utils.ActionSchedule.PlayerTarget;
import java.util.Arrays;
import java.util.List;

/** Base class for actions to perform during playback tests. */
@UnstableApi
public abstract class Action {

  @Size(max = 23)
  private final String tag;

  @Nullable private final String description;

  /**
   * @param tag A tag to use for logging.
   * @param description A description to be logged when the action is executed, or null if no
   *     logging is required.
   */
  public Action(@Size(max = 23) String tag, @Nullable String description) {
    this.tag = tag;
    this.description = description;
  }

  /**
   * Called by {@link #doActionAndScheduleNextImpl(ExoPlayer, DefaultTrackSelector, Surface,
   * HandlerWrapper, ActionNode)} to perform the action.
   *
   * @param player The player to which the action should be applied.
   * @param trackSelector The track selector to which the action should be applied.
   * @param surface The surface to use when applying actions, or {@code null} if no surface is
   *     needed.
   */
  protected abstract void doActionImpl(
      ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface);

  /**
   * Executes the action and schedules the next.
   *
   * @param player The player to which the action should be applied.
   * @param trackSelector The track selector to which the action should be applied.
   * @param surface The surface to use when applying actions, or {@code null} if no surface is
   *     needed.
   * @param handler The handler to use to pass to the next action.
   * @param nextAction The next action to schedule immediately after this action finished, or {@code
   *     null} if there's no next action.
   */
  /* package */ final void doActionAndScheduleNext(
      ExoPlayer player,
      DefaultTrackSelector trackSelector,
      @Nullable Surface surface,
      HandlerWrapper handler,
      @Nullable ActionNode nextAction) {
    if (description != null) {
      Log.i(tag, description);
    }
    doActionAndScheduleNextImpl(player, trackSelector, surface, handler, nextAction);
  }

  /**
   * Called by {@link #doActionAndScheduleNext(ExoPlayer, DefaultTrackSelector, Surface,
   * HandlerWrapper, ActionNode)} to perform the action and to schedule the next action node.
   *
   * @param player The player to which the action should be applied.
   * @param trackSelector The track selector to which the action should be applied.
   * @param surface The surface to use when applying actions, or {@code null} if no surface is
   *     needed.
   * @param handler The handler to use to pass to the next action.
   * @param nextAction The next action to schedule immediately after this action finished, or {@code
   *     null} if there's no next action.
   */
  /* package */ void doActionAndScheduleNextImpl(
      ExoPlayer player,
      DefaultTrackSelector trackSelector,
      @Nullable Surface surface,
      HandlerWrapper handler,
      @Nullable ActionNode nextAction) {
    doActionImpl(player, trackSelector, surface);
    if (nextAction != null) {
      nextAction.schedule(player, trackSelector, surface, handler);
    }
  }

  /** Calls {@link Player#seekTo(long)} or {@link Player#seekTo(int, long)}. */
  public static final class Seek extends Action {

    @Nullable private final Integer mediaItemIndex;
    private final long positionMs;
    private final boolean catchIllegalSeekException;

    /**
     * Action calls {@link Player#seekTo(long)}.
     *
     * @param tag A tag to use for logging.
     * @param positionMs The seek position.
     */
    public Seek(@Size(max = 23) String tag, long positionMs) {
      super(tag, "Seek:" + positionMs);
      this.mediaItemIndex = null;
      this.positionMs = positionMs;
      catchIllegalSeekException = false;
    }

    /**
     * Action calls {@link Player#seekTo(int, long)}.
     *
     * @param tag A tag to use for logging.
     * @param mediaItemIndex The media item to seek to.
     * @param positionMs The seek position.
     * @param catchIllegalSeekException Whether {@link IllegalSeekPositionException} should be
     *     silently caught or not.
     */
    public Seek(
        String tag, int mediaItemIndex, long positionMs, boolean catchIllegalSeekException) {
      super(tag, "Seek:" + positionMs);
      this.mediaItemIndex = mediaItemIndex;
      this.positionMs = positionMs;
      this.catchIllegalSeekException = catchIllegalSeekException;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      try {
        if (mediaItemIndex == null) {
          player.seekTo(positionMs);
        } else {
          player.seekTo(mediaItemIndex, positionMs);
        }
      } catch (IllegalSeekPositionException e) {
        if (!catchIllegalSeekException) {
          throw e;
        }
      }
    }
  }

  /** Calls {@link ExoPlayer#setMediaSources(List, int, long)}. */
  public static final class SetMediaItems extends Action {

    private final int mediaItemIndex;
    private final long positionMs;
    private final MediaSource[] mediaSources;

    /**
     * @param tag A tag to use for logging.
     * @param mediaItemIndex The media item index to start playback from.
     * @param positionMs The position in milliseconds to start playback from.
     * @param mediaSources The media sources to populate the playlist with.
     */
    public SetMediaItems(
        String tag, int mediaItemIndex, long positionMs, MediaSource... mediaSources) {
      super(tag, "SetMediaItems");
      this.mediaItemIndex = mediaItemIndex;
      this.positionMs = positionMs;
      this.mediaSources = mediaSources;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setMediaSources(Arrays.asList(mediaSources), mediaItemIndex, positionMs);
    }
  }

  /** Calls {@link ExoPlayer#addMediaSources(List)}. */
  public static final class AddMediaItems extends Action {

    private final MediaSource[] mediaSources;

    /**
     * @param tag A tag to use for logging.
     * @param mediaSources The media sources to be added to the playlist.
     */
    public AddMediaItems(@Size(max = 23) String tag, MediaSource... mediaSources) {
      super(tag, /* description= */ "AddMediaItems");
      this.mediaSources = mediaSources;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.addMediaSources(Arrays.asList(mediaSources));
    }
  }

  /** Calls {@link ExoPlayer#setMediaSources(List, boolean)}. */
  public static final class SetMediaItemsResetPosition extends Action {

    private final boolean resetPosition;
    private final MediaSource[] mediaSources;

    /**
     * @param tag A tag to use for logging.
     * @param resetPosition Whether the position should be reset.
     * @param mediaSources The media sources to populate the playlist with.
     */
    public SetMediaItemsResetPosition(
        String tag, boolean resetPosition, MediaSource... mediaSources) {
      super(tag, "SetMediaItems");
      this.resetPosition = resetPosition;
      this.mediaSources = mediaSources;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setMediaSources(Arrays.asList(mediaSources), resetPosition);
    }
  }

  /** Calls {@link ExoPlayer#moveMediaItem(int, int)}. */
  public static class MoveMediaItem extends Action {

    private final int currentIndex;
    private final int newIndex;

    /**
     * @param tag A tag to use for logging.
     * @param currentIndex The current index of the media item.
     * @param newIndex The new index of the media item.
     */
    public MoveMediaItem(@Size(max = 23) String tag, int currentIndex, int newIndex) {
      super(tag, "MoveMediaItem");
      this.currentIndex = currentIndex;
      this.newIndex = newIndex;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.moveMediaItem(currentIndex, newIndex);
    }
  }

  /** Calls {@link ExoPlayer#removeMediaItem(int)}. */
  public static class RemoveMediaItem extends Action {

    private final int index;

    /**
     * @param tag A tag to use for logging.
     * @param index The index of the item to remove.
     */
    public RemoveMediaItem(@Size(max = 23) String tag, int index) {
      super(tag, "RemoveMediaItem");
      this.index = index;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.removeMediaItem(index);
    }
  }

  /** Calls {@link ExoPlayer#removeMediaItems(int, int)}. */
  public static class RemoveMediaItems extends Action {

    private final int fromIndex;
    private final int toIndex;

    /**
     * @param tag A tag to use for logging.
     * @param fromIndex The start if the range of media items to remove.
     * @param toIndex The end of the range of media items to remove (exclusive).
     */
    public RemoveMediaItems(@Size(max = 23) String tag, int fromIndex, int toIndex) {
      super(tag, "RemoveMediaItem");
      this.fromIndex = fromIndex;
      this.toIndex = toIndex;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.removeMediaItems(fromIndex, toIndex);
    }
  }

  /** Calls {@link ExoPlayer#clearMediaItems()}}. */
  public static class ClearMediaItems extends Action {

    /**
     * @param tag A tag to use for logging.
     */
    public ClearMediaItems(String tag) {
      super(tag, "ClearMediaItems");
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.clearMediaItems();
    }
  }

  /** Calls {@link Player#stop()}. */
  public static final class Stop extends Action {

    private static final String STOP_ACTION_TAG = "Stop";

    /**
     * Action will call {@link Player#stop()}.
     *
     * @param tag A tag to use for logging.
     */
    public Stop(String tag) {
      super(tag, STOP_ACTION_TAG);
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.stop();
    }
  }

  /** Calls {@link Player#setPlayWhenReady(boolean)}. */
  public static final class SetPlayWhenReady extends Action {

    private final boolean playWhenReady;

    /**
     * @param tag A tag to use for logging.
     * @param playWhenReady The value to pass.
     */
    public SetPlayWhenReady(@Size(max = 23) String tag, boolean playWhenReady) {
      super(tag, playWhenReady ? "Play" : "Pause");
      this.playWhenReady = playWhenReady;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setPlayWhenReady(playWhenReady);
    }
  }

  /**
   * Updates the {@link Parameters} of a {@link DefaultTrackSelector} to specify whether the
   * renderer at a given index should be disabled.
   */
  public static final class SetRendererDisabled extends Action {

    private final int rendererIndex;
    private final boolean disabled;

    /**
     * @param tag A tag to use for logging.
     * @param rendererIndex The index of the renderer.
     * @param disabled Whether the renderer should be disabled.
     */
    public SetRendererDisabled(@Size(max = 23) String tag, int rendererIndex, boolean disabled) {
      super(tag, "SetRendererDisabled:" + rendererIndex + ":" + disabled);
      this.rendererIndex = rendererIndex;
      this.disabled = disabled;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      trackSelector.setParameters(
          trackSelector.buildUponParameters().setRendererDisabled(rendererIndex, disabled));
    }
  }

  /** Calls {@link ExoPlayer#clearVideoSurface()}. */
  public static final class ClearVideoSurface extends Action {

    /**
     * @param tag A tag to use for logging.
     */
    public ClearVideoSurface(String tag) {
      super(tag, "ClearVideoSurface");
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.clearVideoSurface();
    }
  }

  /** Calls {@link ExoPlayer#setVideoSurface(Surface)}. */
  public static final class SetVideoSurface extends Action {

    /**
     * @param tag A tag to use for logging.
     */
    public SetVideoSurface(String tag) {
      super(tag, "SetVideoSurface");
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setVideoSurface(Assertions.checkNotNull(surface));
    }
  }

  /** Calls {@link ExoPlayer#setAudioAttributes(AudioAttributes, boolean)}. */
  public static final class SetAudioAttributes extends Action {

    private final AudioAttributes audioAttributes;
    private final boolean handleAudioFocus;

    /**
     * @param tag A tag to use for logging.
     * @param audioAttributes The attributes to use for audio playback.
     * @param handleAudioFocus True if the player should handle audio focus, false otherwise.
     */
    public SetAudioAttributes(
        String tag, AudioAttributes audioAttributes, boolean handleAudioFocus) {
      super(tag, "SetAudioAttributes");
      this.audioAttributes = audioAttributes;
      this.handleAudioFocus = handleAudioFocus;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setAudioAttributes(audioAttributes, handleAudioFocus);
    }
  }

  /** Calls {@link ExoPlayer#prepare()}. */
  public static final class Prepare extends Action {
    /**
     * @param tag A tag to use for logging.
     */
    public Prepare(String tag) {
      super(tag, "Prepare");
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.prepare();
    }
  }

  /** Calls {@link Player#setRepeatMode(int)}. */
  public static final class SetRepeatMode extends Action {

    private final @Player.RepeatMode int repeatMode;

    /**
     * @param tag A tag to use for logging.
     * @param repeatMode The repeat mode.
     */
    public SetRepeatMode(@Size(max = 23) String tag, @Player.RepeatMode int repeatMode) {
      super(tag, "SetRepeatMode:" + repeatMode);
      this.repeatMode = repeatMode;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setRepeatMode(repeatMode);
    }
  }

  /** Calls {@link ExoPlayer#setShuffleOrder(ShuffleOrder)} . */
  public static final class SetShuffleOrder extends Action {

    private final ShuffleOrder shuffleOrder;

    /**
     * @param tag A tag to use for logging.
     * @param shuffleOrder The shuffle order.
     */
    public SetShuffleOrder(@Size(max = 23) String tag, ShuffleOrder shuffleOrder) {
      super(tag, "SetShufflerOrder");
      this.shuffleOrder = shuffleOrder;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setShuffleOrder(shuffleOrder);
    }
  }

  /** Calls {@link Player#setShuffleModeEnabled(boolean)}. */
  public static final class SetShuffleModeEnabled extends Action {

    private final boolean shuffleModeEnabled;

    /**
     * @param tag A tag to use for logging.
     * @param shuffleModeEnabled Whether shuffling is enabled.
     */
    public SetShuffleModeEnabled(@Size(max = 23) String tag, boolean shuffleModeEnabled) {
      super(tag, "SetShuffleModeEnabled:" + shuffleModeEnabled);
      this.shuffleModeEnabled = shuffleModeEnabled;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setShuffleModeEnabled(shuffleModeEnabled);
    }
  }

  /** Calls {@link ExoPlayer#createMessage(Target)} and {@link PlayerMessage#send()}. */
  public static final class SendMessages extends Action {

    private final Target target;
    private final int mediaItemIndex;
    private final long positionMs;
    private final boolean deleteAfterDelivery;

    /**
     * @param tag A tag to use for logging.
     * @param target A message target.
     * @param positionMs The position at which the message should be sent, in milliseconds.
     */
    public SendMessages(@Size(max = 23) String tag, Target target, long positionMs) {
      this(
          tag,
          target,
          /* mediaItemIndex= */ C.INDEX_UNSET,
          positionMs,
          /* deleteAfterDelivery= */ true);
    }

    /**
     * @param tag A tag to use for logging.
     * @param target A message target.
     * @param mediaItemIndex The media item index at which the message should be sent, or {@link
     *     C#INDEX_UNSET} for the current media item.
     * @param positionMs The position at which the message should be sent, in milliseconds.
     * @param deleteAfterDelivery Whether the message will be deleted after delivery.
     */
    public SendMessages(
        String tag,
        Target target,
        int mediaItemIndex,
        long positionMs,
        boolean deleteAfterDelivery) {
      super(tag, "SendMessages");
      this.target = target;
      this.mediaItemIndex = mediaItemIndex;
      this.positionMs = positionMs;
      this.deleteAfterDelivery = deleteAfterDelivery;
    }

    @Override
    protected void doActionImpl(
        final ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      if (target instanceof PlayerTarget) {
        ((PlayerTarget) target).setPlayer(player);
      }
      PlayerMessage message = player.createMessage(target);
      if (mediaItemIndex != C.INDEX_UNSET) {
        message.setPosition(mediaItemIndex, positionMs);
      } else {
        message.setPosition(positionMs);
      }
      message.setLooper(Util.getCurrentOrMainLooper());
      message.setDeleteAfterDelivery(deleteAfterDelivery);
      message.send();
    }
  }

  /** Calls {@link Player#setPlaybackParameters(PlaybackParameters)}. */
  public static final class SetPlaybackParameters extends Action {

    private final PlaybackParameters playbackParameters;

    /**
     * Creates a set playback parameters action instance.
     *
     * @param tag A tag to use for logging.
     * @param playbackParameters The playback parameters.
     */
    public SetPlaybackParameters(
        @Size(max = 23) String tag, PlaybackParameters playbackParameters) {
      super(tag, "SetPlaybackParameters:" + playbackParameters);
      this.playbackParameters = playbackParameters;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player.setPlaybackParameters(playbackParameters);
    }
  }

  /** Throws a playback exception on the playback thread. */
  public static final class ThrowPlaybackException extends Action {

    private final ExoPlaybackException exception;

    /**
     * @param tag A tag to use for logging.
     * @param exception The exception to throw.
     */
    public ThrowPlaybackException(@Size(max = 23) String tag, ExoPlaybackException exception) {
      super(tag, "ThrowPlaybackException:" + exception);
      this.exception = exception;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      player
          .createMessage(
              (messageType, payload) -> {
                throw exception;
              })
          .send();
    }
  }

  /**
   * Schedules a play action to be executed, waits until the player reaches the specified position,
   * and pauses the player again.
   */
  public static final class PlayUntilPosition extends Action {

    private final int mediaItemIndex;
    private final long positionMs;

    /**
     * @param tag A tag to use for logging.
     * @param mediaItemIndex The media item index at which the player should be paused again.
     * @param positionMs The position in that media item at which the player should be paused again.
     */
    public PlayUntilPosition(@Size(max = 23) String tag, int mediaItemIndex, long positionMs) {
      super(tag, "PlayUntilPosition:" + mediaItemIndex + ":" + positionMs);
      this.mediaItemIndex = mediaItemIndex;
      this.positionMs = positionMs;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      // Schedule a message on the playback thread to ensure the player is paused immediately.
      Looper applicationLooper = Util.getCurrentOrMainLooper();
      player
          .createMessage(
              (messageType, payload) -> {
                // Block playback thread until pause command has been sent from test thread.
                ConditionVariable blockPlaybackThreadCondition = new ConditionVariable();
                player
                    .getClock()
                    .createHandler(applicationLooper, /* callback= */ null)
                    .post(
                        () -> {
                          player.pause();
                          blockPlaybackThreadCondition.open();
                        });
                try {
                  player.getClock().onThreadBlocked();
                  blockPlaybackThreadCondition.block();
                } catch (InterruptedException e) {
                  // Ignore.
                }
              })
          .setPosition(mediaItemIndex, positionMs)
          .send();
      if (nextAction != null) {
        // Schedule another message on this test thread to continue action schedule.
        player
            .createMessage(
                (messageType, payload) ->
                    nextAction.schedule(player, trackSelector, surface, handler))
            .setPosition(mediaItemIndex, positionMs)
            .setLooper(applicationLooper)
            .send();
      }
      player.play();
    }
  }

  /** Waits for {@link Player.Listener#onTimelineChanged(Timeline, int)}. */
  public static final class WaitForTimelineChanged extends Action {

    @Nullable private final Timeline expectedTimeline;
    private final boolean ignoreExpectedReason;
    private final @Player.TimelineChangeReason int expectedReason;

    /**
     * Creates action waiting for a timeline change for a given reason.
     *
     * @param tag A tag to use for logging.
     * @param expectedTimeline The expected timeline or {@code null} if any timeline change is
     *     relevant.
     * @param expectedReason The expected timeline change reason.
     */
    public WaitForTimelineChanged(
        String tag,
        @Nullable Timeline expectedTimeline,
        @Player.TimelineChangeReason int expectedReason) {
      super(tag, "WaitForTimelineChanged");
      this.expectedTimeline = expectedTimeline;
      this.ignoreExpectedReason = false;
      this.expectedReason = expectedReason;
    }

    /**
     * Creates action waiting for any timeline change for any reason.
     *
     * @param tag A tag to use for logging.
     */
    public WaitForTimelineChanged(String tag) {
      super(tag, "WaitForTimelineChanged");
      this.expectedTimeline = null;
      this.ignoreExpectedReason = true;
      this.expectedReason = Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      if (nextAction == null) {
        return;
      }
      Player.Listener listener =
          new Player.Listener() {
            @Override
            public void onTimelineChanged(
                Timeline timeline, @Player.TimelineChangeReason int reason) {
              if ((expectedTimeline == null || timelinesAreSame(timeline, expectedTimeline))
                  && (ignoreExpectedReason || expectedReason == reason)) {
                player.removeListener(this);
                nextAction.schedule(player, trackSelector, surface, handler);
              }
            }
          };
      player.addListener(listener);
      if (expectedTimeline != null
          && timelinesAreSame(player.getCurrentTimeline(), expectedTimeline)) {
        player.removeListener(listener);
        nextAction.schedule(player, trackSelector, surface, handler);
      }
    }
  }

  /**
   * Waits for {@link Player.Listener#onPositionDiscontinuity(Player.PositionInfo,
   * Player.PositionInfo, int)}.
   */
  public static final class WaitForPositionDiscontinuity extends Action {

    /**
     * @param tag A tag to use for logging.
     */
    public WaitForPositionDiscontinuity(String tag) {
      super(tag, "WaitForPositionDiscontinuity");
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      if (nextAction == null) {
        return;
      }
      player.addListener(
          new Player.Listener() {
            @Override
            public void onPositionDiscontinuity(
                Player.PositionInfo oldPosition,
                Player.PositionInfo newPosition,
                @Player.DiscontinuityReason int reason) {
              player.removeListener(this);
              nextAction.schedule(player, trackSelector, surface, handler);
            }
          });
    }
  }

  /**
   * Waits for a specified playWhenReady value, returning either immediately or after a call to
   * {@link Player.Listener#onPlayWhenReadyChanged(boolean, int)}.
   */
  public static final class WaitForPlayWhenReady extends Action {

    private final boolean targetPlayWhenReady;

    /**
     * @param tag A tag to use for logging.
     * @param playWhenReady The playWhenReady value to wait for.
     */
    public WaitForPlayWhenReady(@Size(max = 23) String tag, boolean playWhenReady) {
      super(tag, "WaitForPlayWhenReady");
      targetPlayWhenReady = playWhenReady;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      if (nextAction == null) {
        return;
      }
      if (targetPlayWhenReady == player.getPlayWhenReady()) {
        nextAction.schedule(player, trackSelector, surface, handler);
      } else {
        player.addListener(
            new Player.Listener() {
              @Override
              public void onPlayWhenReadyChanged(
                  boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
                if (targetPlayWhenReady == playWhenReady) {
                  player.removeListener(this);
                  nextAction.schedule(player, trackSelector, surface, handler);
                }
              }
            });
      }
    }
  }

  /**
   * Waits for a specified playback state, returning either immediately or after a call to {@link
   * Player.Listener#onPlaybackStateChanged(int)}.
   */
  public static final class WaitForPlaybackState extends Action {

    private final @Player.State int targetPlaybackState;

    /**
     * @param tag A tag to use for logging.
     * @param targetPlaybackState The playback state to wait for.
     */
    public WaitForPlaybackState(@Size(max = 23) String tag, @Player.State int targetPlaybackState) {
      super(tag, "WaitForPlaybackState");
      this.targetPlaybackState = targetPlaybackState;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      if (nextAction == null) {
        return;
      }
      if (targetPlaybackState == player.getPlaybackState()) {
        nextAction.schedule(player, trackSelector, surface, handler);
      } else {
        player.addListener(
            new Player.Listener() {
              @Override
              public void onPlaybackStateChanged(@Player.State int playbackState) {
                if (targetPlaybackState == playbackState) {
                  player.removeListener(this);
                  nextAction.schedule(player, trackSelector, surface, handler);
                }
              }
            });
      }
    }
  }

  /**
   * Waits for a player message to arrive. If the target already received a message, the action
   * returns immediately.
   */
  public static final class WaitForMessage extends Action {

    private final PlayerTarget playerTarget;

    /**
     * @param tag A tag to use for logging.
     * @param playerTarget The target to observe.
     */
    public WaitForMessage(@Size(max = 23) String tag, PlayerTarget playerTarget) {
      super(tag, "WaitForMessage");
      this.playerTarget = playerTarget;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      if (nextAction == null) {
        return;
      }
      PlayerTarget.Callback callback =
          () -> nextAction.schedule(player, trackSelector, surface, handler);

      playerTarget.setCallback(callback);
    }
  }

  /**
   * Waits for a specified loading state, returning either immediately or after a call to {@link
   * Player.Listener#onIsLoadingChanged(boolean)}.
   */
  public static final class WaitForIsLoading extends Action {

    private final boolean targetIsLoading;

    /**
     * @param tag A tag to use for logging.
     * @param targetIsLoading The loading state to wait for.
     */
    public WaitForIsLoading(@Size(max = 23) String tag, boolean targetIsLoading) {
      super(tag, "WaitForIsLoading");
      this.targetIsLoading = targetIsLoading;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      if (nextAction == null) {
        return;
      }
      if (targetIsLoading == player.isLoading()) {
        nextAction.schedule(player, trackSelector, surface, handler);
      } else {
        player.addListener(
            new Player.Listener() {
              @Override
              public void onIsLoadingChanged(boolean isLoading) {
                if (targetIsLoading == isLoading) {
                  player.removeListener(this);
                  nextAction.schedule(player, trackSelector, surface, handler);
                }
              }
            });
      }
    }
  }

  /** Waits until the player acknowledged all pending player commands. */
  public static final class WaitForPendingPlayerCommands extends Action {

    /**
     * @param tag A tag to use for logging.
     */
    public WaitForPendingPlayerCommands(String tag) {
      super(tag, "WaitForPendingPlayerCommands");
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      // Not triggered.
    }

    @Override
    /* package */ void doActionAndScheduleNextImpl(
        ExoPlayer player,
        DefaultTrackSelector trackSelector,
        @Nullable Surface surface,
        HandlerWrapper handler,
        @Nullable ActionNode nextAction) {
      if (nextAction == null) {
        return;
      }
      // Send message to player that will arrive after all other pending commands. Thus, the message
      // execution on the app thread will also happen after all other pending command
      // acknowledgements have arrived back on the app thread.
      player
          .createMessage(
              (type, data) -> nextAction.schedule(player, trackSelector, surface, handler))
          .setLooper(Util.getCurrentOrMainLooper())
          .send();
    }
  }

  /** Calls {@code Runnable.run()}. */
  public static final class ExecuteRunnable extends Action {

    private final Runnable runnable;

    /**
     * Constructs an instance.
     *
     * @param tag A tag to use for logging.
     * @param runnable The runnable to run.
     */
    public ExecuteRunnable(@Size(max = 23) String tag, Runnable runnable) {
      super(tag, "ExecuteRunnable");
      this.runnable = runnable;
    }

    @Override
    protected void doActionImpl(
        ExoPlayer player, DefaultTrackSelector trackSelector, @Nullable Surface surface) {
      if (runnable instanceof PlayerRunnable) {
        ((PlayerRunnable) runnable).setPlayer(player);
      }
      runnable.run();
    }
  }
}